MapReduce — map, shuffle, reduce trên hàng nghìn máy
Ba pha map → shuffle → reduce chia một job khổng lồ ra nghìn máy. Điểm khoá: shuffle chính là external sort phân tán chạy trên cả cluster.
TL;DR: MapReduce là mô hình tính toán phân tán chia job lớn thành ba pha: Map (mỗi worker biến đổi một mảnh input thành cặp key-value), Shuffle (hệ thống gom và sắp xếp mọi giá trị cùng key về đúng một reducer — đây là pha đắt nhất và thực chất là external sort chạy phân tán), Reduce (reducer tổng hợp danh sách giá trị của mỗi key thành kết quả cuối). Năm 2004, Google chạy mô hình này trên cluster khoảng 1.800 máy để sort 1 TB dữ liệu trong 891 giây và dựng chỉ mục từ khoảng 20 TB HTML đã crawl. Shuffle chiếm khoảng 1/3 tổng thời gian job điển hình và là nút thắt chính khi dữ liệu lớn.
Năm 2004, nhóm kỹ sư Google đứng trước bài toán không giải được bằng một máy: dựng chỉ mục tìm kiếm từ khoảng 20 TB tài liệu HTML đã crawl được. Không có máy đơn nào đủ RAM, đủ đĩa, hay đủ CPU để xử lý khối đó trong thời gian hợp lý. Cần nghìn máy — nhưng nghìn máy thì phối hợp thế nào?
Câu trả lời của Dean và Ghemawat trong bài báo OSDI 2004 là lập trình viên không cần viết code phối hợp. Họ chỉ cần khai báo hai hàm: map (biến đổi từng dòng thành cặp key-value) và reduce (tổng hợp mọi giá trị của cùng một key). Framework lo phần còn lại: chia input, chạy song song, gom kết quả. Kết quả: cluster khoảng 1.800 máy sort 1 TB dữ liệu trong 891 giây (~15 phút).
Bài này giải thích ba pha map → shuffle → reduce từ góc độ cơ chế — đặc biệt là phase shuffle, thứ ít được giải thích nhưng chiếm khoảng 1/3 thời gian job và thực chất là external sort chạy phân tán mà bạn đã biết nguyên lý từ bài thuật toán.
1. Analogy — bưu cục phân loại thư toàn quốc
Hình dung một chiến dịch gửi 100 triệu lá phiếu về trung ương để đếm. Không có bưu cục nào xử lý nổi 100 triệu lá một mình, nên cả hệ thống chia thành ba tầng:
Tầng 1 — Nhân viên địa phương (Map): mỗi nhân viên nhận một túi thư nhỏ, mở từng phong bì, xem tỉnh đích, dán nhãn (tỉnh, lá phiếu). Nhân viên không đếm — chỉ phân loại và dán nhãn.
Tầng 2 — Xe tải phân phối (Shuffle): xe tải gom tất cả lá được dán cùng tỉnh, chở về một trung tâm tỉnh duy nhất, và sắp xếp thứ tự trước khi đặt lên bàn đếm. Đây là công đoạn nặng nhất.
Tầng 3 — Nhân viên tỉnh (Reduce): mỗi nhân viên tỉnh nhận đúng chồng lá phiếu của tỉnh mình (đã sắp sẵn thứ tự), đếm, ghi kết quả.
| Bưu cục bầu cử | MapReduce |
|---|---|
| Túi thư nhỏ giao từng nhân viên | Input split — mảnh dữ liệu mỗi mapper nhận |
| Nhân viên địa phương dán nhãn tỉnh | Mapper phát ra (key, value) |
| Tỉnh đích trên nhãn | Key quyết định reducer nào nhận |
| Xe tải chở về đúng trung tâm tỉnh | Shuffle: transfer qua mạng |
| Sắp xếp thứ tự trước khi đếm | Sort phase trong shuffle |
| Nhân viên tỉnh đếm chồng lá | Reducer tổng hợp value cùng key |
Mapper = nhân viên địa phương (phân loại, dán nhãn). Shuffle = xe tải (chở đúng nơi + sắp thứ tự). Reducer = nhân viên trung tâm (đếm chồng đã sắp). Lập trình viên chỉ viết "cách dán nhãn" và "cách đếm" — không cần viết "cách lái xe tải".
2. Tổng quan ba pha — map, shuffle, reduce
Một MapReduce job xử lý input theo thứ tự cố định: Map song song → Shuffle tự động → Reduce song song.
flowchart LR INPUT["Input (HDFS)"] --> M1["Mapper 1"] INPUT --> M2["Mapper 2"] INPUT --> M3["Mapper 3"] M1 -->|"key-value"| SH["SHUFFLE<br/>sort + route"] M2 -->|"key-value"| SH M3 -->|"key-value"| SH SH -->|"key A, vals"| R1["Reducer A"] SH -->|"key B, vals"| R2["Reducer B"] R1 --> OUT["Output (HDFS)"] R2 --> OUT
Điểm then chốt: Shuffle là tầng trung gian duy nhất nối mọi mapper với mọi reducer. Mọi output của mapper đều chạy qua Shuffle trước khi tới reducer — không có đường tắt.
3. Phase Map — biến đổi từng mảnh input thành cặp key-value
Framework chia input thành nhiều split (mảnh) — thường tương ứng một block HDFS khoảng 128 MB. Mỗi split chạy trên một Mapper song song với các Mapper khác.
Hàm map do người dùng viết, nhận từng dòng input, phát ra một hoặc nhiều cặp (key, value):
-- Job: dem so don hang theo san pham
function map(line):
order <- parse(line)
emit(order.product_id, 1) -- moi dong emit mot cap (san pham, 1)
Với job "đếm số đơn theo sản phẩm" trên 40 triệu dòng log: mỗi mapper nhận một mảnh dữ liệu và phát ra các cặp ("product_123", 1), ("product_456", 1), v.v. Tất cả chạy song song — N mapper cùng lúc.
Sau khi mapper phát ra cặp key-value, framework không gửi thẳng cho reducer mà buffer trong RAM (thường 100 MB), rồi khi buffer đầy thì spill (tràn) ra đĩa thành một sorted run. Đây là bước đầu của external sort — giải thích kỹ ở section tiếp theo.
4. Phase Shuffle — external sort phân tán (điểm khoá)
Đây là pha ít được giải thích nhất nhưng là trái tim của MapReduce. Mục tiêu: đảm bảo mọi giá trị có cùng key tập hợp tại đúng một reducer, đến nơi theo thứ tự đã sắp xếp.
Shuffle thực hiện đúng ý tưởng external merge sort đã học: khi data lớn hơn RAM, chia thành nhiều sorted run ghi ra đĩa, rồi merge nhiều run thành một dòng sorted toàn cục. Ở MapReduce, các "chunk" nằm phân tán trên nhiều máy khác nhau — phần "external" giờ là cả cluster.
4.1 Phía Mapper — sort và partition cục bộ
Khi buffer đầy, framework thực hiện:
- Sort các entry trong buffer theo
(partition, key)— partition xác định reducer nào nhận. - Spill (ghi tràn) ra đĩa thành một sorted run.
- Cuối cùng, merge tất cả sorted run của mapper đó thành một file duy nhất.
HashPartitioner mặc định: partition(key) = hash(key) % numReducers. Mọi record cùng key → cùng partition → cùng reducer.
flowchart TD KV["Key-value from mapper"] --> BUF["RAM buffer (100 MB)"] BUF -->|full| SORT["Sort: partition + key"] SORT --> DISK["Sorted run on disk"] DISK --> MERGE["Merge all runs into 1 file"] MERGE --> NET["HTTP transfer to reducer"]
4.2 Phía Reducer — fetch và merge nhiều sorted run
Reducer kéo (fetch) phần partition của mình từ tất cả mapper qua HTTP. Nó nhận nhiều sorted run (một từ mỗi mapper), rồi merge chúng thành một dòng key-value sorted toàn cục — đúng là k-way merge của external sort:
-- Tu Mapper 1: ("product_123", 1), ("product_456", 1)
-- Tu Mapper 2: ("product_123", 1), ("product_789", 1)
-- Tu Mapper 3: ("product_123", 1)
-- Sau k-way merge (sorted):
key = "product_123" -> values = [1, 1, 1]
key = "product_456" -> values = [1]
key = "product_789" -> values = [1]
Reducer cần nhận tất cả value của cùng một key liên tiếp nhau để xử lý một lần rồi chuyển sang key tiếp theo — không thể giữ toàn bộ dataset trong RAM để gom cùng key về sau. Sort đảm bảo điều này: khi reducer đọc đến key A, mọi value của A đến liền kề, xử lý xong rồi mới gặp key B. Đây là cách "streaming" qua TB data mà không cần buffer toàn bộ.
5. Phase Reduce — tổng hợp từng key
Sau khi Shuffle hoàn thành, reducer nhận một dòng sorted key-value. Hàm reduce được gọi cho mỗi key với toàn bộ danh sách value:
-- Job: dem so don hang theo san pham
function reduce(product_id, counts):
total <- 0
for each c in counts:
total <- total + c
emit(product_id, total)
Output của reducer được ghi ra HDFS — là derived data (từ bài trước): phái sinh, dựng lại được, không sửa input.
6. Vì sao Shuffle chiếm 1/3 thời gian job?
Theo các đo đạc trên workload thực tế, shuffle chiếm khoảng 1/3 tổng thời gian job điển hình. Nguyên nhân rõ ràng từ con số cụ thể:
Cuộc thi Daytona GraySort 2014, sort 100 TB dữ liệu tạo ra 500 TB disk I/O và 200 TB network I/O — tức mỗi byte dữ liệu cần khoảng 5 byte I/O. Phần lớn trong đó là Shuffle.
Tại sao nhiều I/O như vậy? Mỗi sorted spill của mapper ghi ra đĩa, được đọc lại khi merge, gửi qua mạng, ghi ra đĩa phía reducer, rồi đọc lại khi merge phía reducer. Một byte data đi qua nhiều lần đọc-ghi trước khi tới reducer.
Đòn bẩy giảm shuffle: compress map output trước khi ghi ra đĩa và gửi qua mạng giảm lượng data shuffle xuống khoảng 70% trong benchmark thực tế. CPU đánh đổi lấy I/O — thường có lợi vì mạng/đĩa chậm hơn CPU.
7. Pitfall — shuffle không miễn phí và hot key
❌ SAI — emit toàn bộ record lớn:
-- order_line co 50 truong, ~10 KB moi dong
function map(order_line):
emit(order_line.product_id, order_line) -- 10 KB cho moi cap
-- 40 trieu dong * 10 KB = 400 GB phai shuffle qua mang
✅ ĐÚNG — chỉ emit trường cần thiết:
function map(order_line):
emit(order_line.product_id, order_line.amount) -- ~100 byte thay vi 10 KB
-- 40 trieu dong * 100 B = 4 GB -- giam 100 lan
Shuffle không tính phí như cloud function call, nhưng network I/O và disk I/O có giới hạn vật lý. Bài học: chỉ emit field cần cho reduce, không emit toàn record.
❌ Hiểu sai: hash(key) % N chia đều công việc cho N reducer.
✅ Thực tế: hash chia đều số key khác nhau, KHÔNG chia đều số record của mỗi key.
Ví dụ: 100 triệu đơn hàng có product_id = "iphone-15" xuất hiện 50 triệu lần. hash("iphone-15") % 100 = 42 → Reducer 42 nhận 50 triệu record, trong khi 99 reducer còn lại chia nhau 50 triệu kia (~500.000 mỗi cái). Reducer 42 chạy khoảng 100 lần chậm hơn — toàn job phải chờ nó.
Fix — salting:
-- MAP PHASE: them random suffix de trai data
function map(order_line):
salt <- random(0, 9) -- 10 bucket
new_key <- order_line.product_id + "_" + salt
emit(new_key, order_line.amount)
-- "iphone-15_0", "iphone-15_1", ..., "iphone-15_9"
-- -> trai deu cho 10 reducer thay vi 1
-- REDUCE PHASE 1: tong hop tung bucket
-- Moi reducer chi nhan 1/10 data cua hot key
-- JOB 2 - gop lai:
function map2(new_key, subtotal):
base_key <- strip_suffix(new_key)
emit(base_key, subtotal)
function reduce2(product_id, subtotals):
emit(product_id, sum(subtotals))
Đổi lại cần một job thứ hai để gộp kết quả — đây là trade-off của salting.
8. 📚 Deep Dive
Spec / reference chính thức:
- MapReduce: Simplified Data Processing on Large Clusters — Dean & Ghemawat (OSDI 2004) — paper gốc; xem §4.1 "Partitioning Function" (HashPartitioner), §4.2 "Ordering Guarantees" (vì sao shuffle phải sort), §4 "Refinements" (Combiner), §5 "Performance" (benchmark 1TB sort = 891s, grep đọc peak trên 30 GB/s).
- Apache Hadoop MapReduce Tutorial — implementation thực tế: reducer fetch mapper output qua HTTP, secondary sort, Combiner API.
Ghi chú về Combiner: Paper §4 giới thiệu Combiner — hàm tùy chọn chạy ngay trên mapper output trước khi shuffle, gộp sơ bộ cùng key cục bộ để giảm data gửi đi. Với job đếm: Combiner cộng ba cặp ("product_123", 1) thành một cặp ("product_123", 3) ngay tại mapper — giảm 3 lần data shuffle. Combiner chỉ dùng được khi hàm reduce associative và commutative (tổng, max, min — được; trung bình, median — không).
9. Liên hệ các bài khác
- Bài 01 — Tư duy batch: MapReduce là dòng lệnh
cut | sort | uniq -cphóng to ra cả cluster — map giốngcut(biến đổi từng dòng), shuffle giốngsort(gom theo key), reduce giốnguniq -c(tổng hợp). Input bất biến + output phái sinh từ bài trước là nền để hiểu vì sao task re-execution an toàn. - Bài 03 — Join trong batch: sort-merge join dùng lại đúng cơ chế shuffle đã học ở bài này — gom record cùng join key về một reducer, rồi merge hai luồng đã sort.
- External sort: Shuffle là external sort chạy phân tán — nếu muốn hiểu vì sao reducer merge k sorted run bằng min-heap, bài đó đào sâu cơ chế chunk-sort-merge.
10. Tóm tắt
- MapReduce chia job thành 3 pha: Map (phát key-value song song), Shuffle (gom + sort + phân phối đúng reducer), Reduce (tổng hợp). Lập trình viên chỉ viết Map và Reduce; framework tự lo Shuffle và fault tolerance.
- Shuffle = external sort phân tán: mapper sort cục bộ → spill sorted run ra đĩa → reducer fetch từ tất cả mapper → merge nhiều sorted run thành dòng sorted toàn cục. Kết quả: mọi value cùng key đến reducer liên tiếp nhau, đã sắp xếp.
- Shuffle chiếm khoảng 1/3 thời gian job điển hình (đo trên workload thực tế). Giảm data emit trong map (chỉ emit field cần), compress map output là hai đòn bẩy lớn nhất.
- Fault tolerance: task chết → chạy lại task đó từ input HDFS (bất biến). Không cần replica dữ liệu trung gian — tính lại từ đầu là đủ.
- Hot key (skew): hash chia đều key khác nhau, KHÔNG chia đều record. Hot key → một reducer nhận hàng triệu record → straggler. Fix: salting (thêm random suffix, cần job thứ hai để gộp).
- Combiner (tùy chọn): pre-aggregate cùng key ngay tại mapper trước shuffle — chỉ dùng được với hàm commutative và associative (tổng, max, min; không phải average hay median).
11. Tự kiểm tra
Q1Vì sao phase Shuffle phải sắp xếp (sort) key-value trước khi chuyển cho Reducer? Điều gì xảy ra nếu bỏ bước sort này?▸
Reducer cần xử lý tất cả value của cùng một key liên tiếp nhau rồi mới chuyển sang key tiếp theo — không thể giữ toàn bộ dataset trong RAM để gom cùng key về sau. Sort đảm bảo điều này: khi reducer gặp key A, nó biết mọi value của A sẽ đến liền kề, xử lý xong rồi mới gặp key B.
Nếu bỏ sort, reducer phải buffer toàn bộ dataset vào RAM hoặc dùng một bảng hash khổng lồ để gom cùng key — bất khả thi với TB data. Sort chính là cách "streaming" qua data mà không cần nhìn lui: đọc đến đâu xử lý đến đó.
Q2Shuffle trong MapReduce thực hiện external sort phân tán như thế nào? Liên hệ hai bước 'spill + merge' với cơ chế external merge sort.▸
External sort cổ điển: khi data lớn hơn RAM, chia thành nhiều chunk vừa RAM, sort từng chunk ghi ra đĩa (sorted run), rồi k-way merge nhiều sorted run thành một dòng sorted toàn cục.
Shuffle làm y hệt nhưng phân tán: Mapper buffer key-value trong RAM, khi đầy thì sort và spill ra đĩa thành sorted run — đây là bước "sort chunk". Reducer sau đó fetch sorted run từ mọi mapper qua HTTP, rồi merge k sorted run thành một dòng sorted toàn cục bằng k-way merge (min-heap). Kết quả: mọi value cùng key liền nhau, đúng như external sort cần.
Q3Một đồng nghiệp nói: 'hash(key) % N đảm bảo N reducer nhận đúng bằng nhau việc'. Điều này đúng hay sai, và trong trường hợp nào thì sai nghiêm trọng nhất?▸
Sai. hash(key) % N phân phối đều số key khác nhau vào N reducer, không phân phối đều số record. Nếu một key xuất hiện 50 triệu lần trong khi trung bình key xuất hiện 500 lần, reducer nhận key đó xử lý khoảng 100.000 lần nhiều hơn các reducer còn lại — toàn job phải chờ nó (straggler).
Trường hợp nghiêm trọng nhất: bất kỳ dataset nào có phân phối lệch theo power-law — top sản phẩm bán chạy, user nổi tiếng, hashtag viral. Fix: salting — thêm random suffix vào key để trải sang nhiều reducer, sau đó có job thứ hai gộp lại.
Q4Combiner trong MapReduce là gì, và vì sao nó chỉ dùng được với một số hàm reduce nhất định? Cho ví dụ hàm được và không được.▸
Combiner là hàm tùy chọn chạy ngay sau mapper, trước khi shuffle — nó gộp sơ bộ các cặp cùng key cục bộ tại mapper để giảm lượng data gửi qua mạng. Ví dụ: thay vì gửi 1.000 cặp ('product_123', 1), mapper gộp thành một cặp ('product_123', 1000).
Combiner chỉ dùng được khi hàm reduce có tính associative và commutative. Được: tổng, đếm, max, min. Không được: trung bình cộng — Combiner tính trung bình cục bộ rồi Reducer lấy trung bình của các trung bình đó sẽ sai; hay median, phân vị. Với average: Combiner nên emit (sum, count) thay vì average để Reducer tính đúng.
Q5Vì sao nói phase shuffle 'chính là external merge sort chạy phân tán'? Chỉ ra ba việc shuffle làm tương ứng với ba bước chunk → sort → merge của external sort.▸
External merge sort có ba bước: chia dữ liệu thành chunk vừa RAM, sort từng chunk thành sorted run ghi ra đĩa, rồi k-way merge các run thành một dòng sorted toàn cục. Shuffle làm đúng ba việc đó, chỉ khác là các "chunk" nằm rải trên nhiều máy.
(1) Chunk + sort: mỗi mapper buffer key-value trong RAM, khi đầy thì sort theo (partition, key) rồi spill một sorted run ra đĩa cục bộ. (2) Gom run phân tán: mỗi reducer fetch phần partition của mình từ tất cả mapper qua HTTP — nhận nhiều sorted run, mỗi run từ một máy. (3) Merge: reducer k-way merge các run thành một dòng key-value sorted, nhờ đó mọi value cùng key tới liên tiếp. "External" ở đây chính là cả cluster thay vì một đĩa.
Q6Vì sao Google chọn mô hình MapReduce thay vì dùng một database phân tán lớn để xử lý 20 TB log HTML? Tư duy 'input bất biến + derived data' đóng vai trò gì?▸
Database truyền thống tối ưu cho giao dịch nhỏ, nhiều lần đọc/ghi ngẫu nhiên với index phức tạp — không tối ưu cho đọc tuần tự toàn bộ 20 TB một lần để tính một kết quả lớn. MapReduce tối ưu đúng cho bài toán đó: đọc tuần tự (throughput đĩa cao nhất), xử lý song song, không cần index.
Tư duy "input bất biến + derived data" là nền móng cho fault tolerance: log HTML gốc là source of truth (không bao giờ sửa), chỉ mục tìm kiếm là derived data (luôn dựng lại được). Khi muốn đổi cấu trúc chỉ mục, Google chạy một MapReduce job mới từ log gốc — không cần migrate tại chỗ. Task crash → chạy lại từ input gốc, không cần rollback hay undo bất kỳ trạng thái nào.
Bài tiếp theo: Join trong batch — sort-merge, broadcast hash, partitioned hash
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên