External merge sort — sắp xếp vượt RAM
Chia dữ liệu thành chunk vừa RAM, sort từng chunk, rồi k-way merge. Nền của ORDER BY trên database và shuffle MapReduce.
TL;DR: Bạn cần sắp một đống dữ liệu khổng lồ (100 GB) nhưng máy chỉ chứa được 8 GB cùng lúc — như sắp một chồng hồ sơ quá lớn, không để hết lên bàn được. Cách làm: chia đống lớn thành từng xấp nhỏ vừa bàn, sắp gọn từng xấp một, rồi trộn các xấp đã sắp lại thành một đống sắp hoàn chỉnh. Mẹo trộn nhanh: cứ nhặt tờ nhỏ nhất đang nằm trên đỉnh các xấp. Đây là external merge sort — chạy mỗi khi cơ sở dữ liệu sắp xếp một bảng lớn (ORDER BY trong Postgres), mỗi khi hệ xử lý dữ liệu lớn dồn kết quả lại (shuffle của MapReduce), hay khi Kafka gộp log.
Bạn vừa nhận việc: sắp xếp một file log 100 GB từ server. Máy chỉ có 8 GB bộ nhớ (RAM). Gọi hàm sắp xếp có sẵn (Arrays.sort())? Chương trình tắt ngay với lỗi hết bộ nhớ (OutOfMemoryError) — vì không thể nhét hết dữ liệu vào bàn làm việc cùng lúc. Đây là bài toán sắp xếp khi dữ liệu vượt bộ nhớ (external sort): phải mượn ổ đĩa làm chỗ để tạm.
Cách làm này không phải chuyện hiếm gặp: mỗi lần Postgres sắp xếp một bảng lớn (ORDER BY mà không đủ bộ nhớ), mỗi lần MapReduce gom dữ liệu từ nhiều máy lại, mỗi lần Kafka gộp log — đều chạy một biến thể của thuật toán này.
1. Trực giác — sắp một chồng hồ sơ quá lớn
Trước khi đụng tới công thức, hãy hình dung một việc đời thường. Bạn có một chồng hồ sơ khổng lồ cần sắp theo thứ tự tên, nhưng bàn làm việc chỉ để vừa một phần nhỏ. Bạn làm gì?
Cách tự nhiên gồm ba bước:
- Chia thành từng xấp vừa bàn. Lấy mỗi lần một xấp đủ để đặt lên bàn.
- Sắp gọn từng xấp. Với mỗi xấp đã trên bàn, sắp theo thứ tự rồi để qua một bên.
- Trộn các xấp đã sắp. Đặt các xấp đã sắp cạnh nhau, rồi cứ nhặt tờ có tên đứng trước nhất đang nằm trên đỉnh mỗi xấp, đặt vào chồng kết quả. Lặp đến khi hết.
Bước 3 chính là mẹo trộn nhiều cọc bài đã sắp: bạn không cần nhìn cả cọc, chỉ cần nhìn lá trên đỉnh mỗi cọc, nhặt lá nhỏ nhất, rồi lật lá kế tiếp lên.
Thuật toán làm đúng ba bước này trên dữ liệu. Bảng dưới ánh xạ việc đời thường sang tên kỹ thuật (đặt trong ngoặc để gặp lại sau):
| Việc đời thường | Khái niệm trong thuật toán |
|---|---|
| Một xấp hồ sơ vừa bàn | Một "chunk" (xấp dữ liệu nhỏ vừa bộ nhớ) |
| Xấp đã sắp, để qua bên | Một "run" (xấp đã sắp ghi ra đĩa) |
| Trộn nhiều cọc bài đã sắp cùng lúc | "k-way merge" (trộn k xấp một lúc) |
| Nhặt lá nhỏ nhất ở đỉnh các cọc | Dùng "min-heap" (cấu trúc luôn cho ra phần tử nhỏ nhất) |
Vài từ ngữ sẽ dùng suốt bài:
- chunk = một xấp dữ liệu nhỏ vừa bộ nhớ (đọc vào để sắp).
- run = một xấp đã được sắp xong và ghi ra đĩa.
- min-heap = một cái rổ thông minh: bạn bỏ vài phần tử vào, hỏi "cái nhỏ nhất đâu?" thì nó đưa ra ngay, không cần dò cả rổ.
- pass (lượt) = một lần đọc qua toàn bộ dữ liệu và ghi kết quả ra. Mỗi lượt đọc/ghi đĩa rất tốn thời gian, nên mục tiêu là ít lượt nhất có thể.
Thứ chậm ở đây không phải CPU mà là đọc/ghi ổ đĩa. Đọc liền mạch (tuần tự) nhanh hơn đọc nhảy chỗ (ngẫu nhiên) khoảng 100 lần. Vì vậy thuật toán tốt là thuật toán đọc qua toàn bộ dữ liệu càng ít lượt càng tốt, chứ không phải tiết kiệm phép so sánh.
Bốn ký hiệu sẽ gặp ở phần công thức (chỉ cần nhớ ý nghĩa, chưa cần dùng ngay):
| Ký hiệu | Nghĩa đời thường |
|---|---|
N | tổng số bản ghi cần sắp (ví dụ 10 tỷ dòng) |
M | bộ nhớ chứa được tối đa bao nhiêu bản ghi (ví dụ 800 triệu) |
k | trộn bao nhiêu xấp cùng một lúc ở bước trộn |
R = ceil(N/M) | số xấp sinh ra sau bước 1 (chia hết đống lớn thành từng xấp) |
2. Bước 1 — chia thành từng xấp đã sắp (tạo sorted run)
Đọc một xấp dữ liệu vừa bộ nhớ vào RAM, sắp gọn ngay trong bộ nhớ (dùng bất kỳ cách sắp xếp quen thuộc nào), rồi ghi xấp đã sắp đó ra đĩa thành một "run". Lặp cho đến khi đọc hết đầu vào.
function createRuns(inputFile, M):
runs <- [] -- danh sách các xấp đã sắp trên đĩa
while inputFile chưa hết:
chunk <- đọc M bản ghi từ inputFile -- một xấp vừa bộ nhớ
sort(chunk) -- sắp gọn ngay trong bộ nhớ
runFile <- ghi chunk ra đĩa
runs.append(runFile)
return runs
// (phần chi phí — xem mục 4): đọc qua toàn bộ 1 lượt + ghi 1 lượt = 2N lần I/O
Với 100 GB dữ liệu và 8 GB bộ nhớ: chia thành ceil(100/8) = 13 xấp (run), mỗi xấp 8 GB đã sắp xong.
3. Bước 2 — trộn các xấp đã sắp (k-way merge)
Giờ có R xấp đã sắp nằm trên đĩa. Cần trộn chúng thành một đống sắp hoàn chỉnh. Cách ngây thơ là trộn từng cặp một (như khi học merge sort) — nhưng cách đó phải đọc đi đọc lại toàn bộ dữ liệu nhiều vòng. Thay vào đó, ta trộn k xấp cùng một lúc (gọi là "k-way merge") để giảm số vòng đọc đĩa.
Mẹo trộn nhanh chính là việc nhặt lá bài: nhìn tờ trên đỉnh mỗi xấp, nhặt tờ nhỏ nhất, ghi ra kết quả, rồi lật tờ kế tiếp của xấp vừa nhặt lên. Để khỏi phải dò qua từng đỉnh mỗi lần, ta dùng min-heap — cái rổ thông minh luôn đưa ra phần tử nhỏ nhất. Mỗi bước: lấy phần tử nhỏ nhất ra khỏi rổ (đó cũng là phần tử nhỏ nhất của toàn bộ dữ liệu còn lại), ghi vào kết quả, rồi bỏ tờ kế tiếp của xấp đó vào rổ.
function kWayMerge(runs[0..k-1], outputFile):
H <- MinHeap()
for i từ 0 đến k-1: -- bỏ tờ trên đỉnh mỗi xấp vào rổ
if runs[i] không rỗng:
H.push((runs[i].readNext(), i)) -- (giá trị, xấp số mấy)
while H không rỗng:
(val, runIdx) <- H.pop() -- lấy phần tử nhỏ nhất toàn bộ
outputFile.write(val)
if runs[runIdx] không rỗng:
H.push((runs[runIdx].readNext(), runIdx)) -- lật tờ kế của xấp đó
// (phần chi phí — xem mục 4): rổ chỉ giữ k phần tử nên rất nhẹ bộ nhớ
Vì sao mẹo này luôn cho ra đúng thứ tự? Tại mọi thời điểm, cái rổ giữ đúng k ứng viên — một tờ từ đỉnh mỗi xấp. Tờ nhỏ nhất trong rổ là tờ nhỏ nhất trong k ứng viên đó; mà vì mỗi xấp đã sắp sẵn, tờ đó cũng là tờ nhỏ nhất trong tất cả dữ liệu còn lại. Cứ thế, mỗi lần lấy ra đều là phần tử đúng vị trí kế tiếp (lập luận chặt chẽ ở phần tự kiểm tra cuối bài).

Câu hỏi hay tự đặt ở đây: trộn 13 xấp 8 GB thì chẳng phải cần 104 GB bộ nhớ à? Không — dữ liệu vẫn nằm trên đĩa và chỉ chảy qua bộ nhớ theo dòng. Cái rổ giữ đúng 13 tờ, mỗi xấp một tờ trên đỉnh; đó là chỗ duy nhất trong sơ đồ có kích thước không phụ thuộc con số 100 GB.
3.1 Chạy thử trộn 3 xấp (k=3)
3 xấp đã sắp:
Run 0: [1, 5, 9]
Run 1: [2, 4, 8]
Run 2: [3, 6, 7]
Rổ ban đầu chứa đỉnh của 3 xấp: (1,0), (2,1), (3,2) (mỗi cặp là "giá trị, xấp số mấy"). Sau khi lấy ra (1,0) rồi lật tờ kế (5,0) bỏ vào rổ, phần tử nào được lấy ra tiếp theo và từ xấp nào? Đoán trước khi xem bảng.
| Bước | Rổ (giá trị, xấp) | Lấy ra | Kết quả | Lật tờ kế |
|---|---|---|---|---|
| Khởi tạo | 2 | — | — | — |
| 1 | 2 | (1,0) | 1 | (5,0) |
| 2 | 0 | (2,1) | 2 | (4,1) |
| 3 | 0 | (3,2) | 3 | (6,2) |
| 4 | 2 | (4,1) | 4 | (8,1) |
| 5 | 1 | (5,0) | 5 | (9,0) |
| ... | ... | ... | ... | ... |
Kết quả cuối: 1 2 3 4 5 6 7 8 9 — đúng thứ tự.
4. Phần chi phí — vì sao trộn nhiều xấp một lúc thì nhanh?
Trực giác trước: mỗi vòng đọc qua toàn bộ dữ liệu trên đĩa rất tốn thời gian. Nếu mỗi lần chỉ trộn được 2 xấp, ta phải đọc đi đọc lại nhiều vòng mới gộp xong; trộn được nhiều xấp cùng lúc thì ít vòng hơn. Giờ tới phần con số để thấy tiết kiệm bao nhiêu.
Đếm số lượt (mỗi lượt = một lần đọc/ghi qua toàn bộ N bản ghi):
Tổng số lượt = 1 (bước 1: tạo xấp) + ceil(log_k(R)) -- R = ceil(N/M)
Với N=100 GB, M=8 GB → R=13 xấp:
| k (trộn mấy xấp một lúc) | Số vòng trộn ceil(log_k(13)) | Tổng số lượt | Tổng đọc/ghi đĩa |
|---|---|---|---|
| 2 | 4 | 5 | 1000 GB |
| 4 | 2 | 3 | 600 GB |
| 8 | 2 | 3 | 600 GB |
| 13 | 1 | 2 | 400 GB |
Khi k=13 (trộn tất cả 13 xấp trong một vòng), chỉ cần 2 lượt tổng cộng — tiết kiệm khoảng 60% lượng đọc/ghi đĩa so với k=2. Nhưng trộn càng nhiều xấp một lúc thì càng cần nhiều vùng nhớ đệm (buffer): mỗi xấp đang trộn cần một vùng đệm riêng trong RAM. Vì thế k không thể lớn vô hạn — nó bị giới hạn bởi lượng RAM có sẵn.
Postgres chọn k dựa trên bộ nhớ cho phép chia cho kích thước mỗi vùng đệm (work_mem / merge_buffer_size). Ví dụ work_mem = 64 MB và mỗi xấp cần đệm 4 MB thì trộn được k=16 xấp. Cho phép nhiều bộ nhớ hơn (work_mem) thì k tăng, số vòng đọc đĩa giảm, truy vấn chạy nhanh hơn.

Mỗi ô là một lần đọc và ghi qua trọn 100 GB — nên bớt được một ô là bớt 200 GB đọc/ghi. Trộn nhiều xấp một lúc không làm mỗi lượt nhanh hơn, nó làm bớt lượt.
5. Gặp cách làm này ở đâu trong thực tế?
Cơ sở dữ liệu Postgres: khi bạn yêu cầu sắp xếp một bảng lớn (ORDER BY) mà bộ nhớ cho phép (work_mem) không đủ, Postgres tự chia thành các xấp ghi ra đĩa rồi trộn lại — đúng thuật toán này. Lệnh chẩn đoán EXPLAIN ANALYZE hiện dòng Sort Method: external merge khi điều này xảy ra (gọi là "tràn ra đĩa", spill to disk).
MapReduce shuffle: sau bước Map, mỗi máy sắp output của mình (đây là bước 1); máy Reduce nhận nhiều xấp đã sắp từ nhiều máy rồi trộn lại (bước 2). Đây chính là lý do bước "shuffle" của MapReduce tốn kém — nó là external sort trải trên cả cụm máy.
Lọc trùng dữ liệu lớn (dedup): thay vì dùng bảng tra để tìm phần tử khác nhau, các hệ lớn (Spark distinct(), Flink) sắp dữ liệu trước rồi quét một lượt loại bản trùng cạnh nhau — quét một lượt sau khi đã sắp thì rẻ hơn dùng bảng tra khi dữ liệu vượt bộ nhớ.
6. Những chỗ dễ sai
Sai 1 — Trộn quá nhiều xấp một lúc, mỗi xấp đệm quá nhỏ
-- SAI: trộn k = 1000 xấp một lúc, mỗi vùng đệm chỉ còn 8 KB
-- -> mỗi lần lấy tờ kế chỉ đọc 8 KB, đọc đĩa nhảy chỗ liên tục (ngẫu nhiên)
-- -> còn chậm hơn trộn ít xấp hơn
-- ĐÚNG: chọn k = bộ nhớ chia cho kích thước đệm mỗi xấp
-- Mỗi xấp có vùng đệm đủ lớn để đọc liền mạch (tuần tự)
-- Thực tế Postgres/DBMS giới hạn k khoảng 500 vì mở quá nhiều
-- file cùng lúc cũng tốn tài nguyên hệ điều hành
Sai 2 — Quên đọc trước (double buffering) khiến CPU ngồi chờ đĩa
-- SAI: đọc một đệm -> xử lý -> đọc đệm kế -> xử lý (CPU phải chờ mỗi lần đọc)
-- ĐÚNG: dùng hai vùng đệm luân phiên — trong khi CPU xử lý đệm A,
-- ổ đĩa đã đọc sẵn đệm B (đọc trước, prefetch); xong thì đổi vai
-- Tăng tốc khoảng 2 lần trên ổ đĩa thông thường
Sai 3 — Quên xấp cuối có thể nhỏ hơn các xấp khác
-- SAI: tưởng mọi xấp đều đúng M bản ghi
-- -> nếu N không chia hết cho M, xấp cuối nhỏ hơn
-- -> lật tờ kế khi xấp đã hết -> lỗi vượt giới hạn
-- ĐÚNG: luôn kiểm tra "xấp còn tờ nào không" trước khi lật tờ kế
-- (đã có trong pseudocode kWayMerge ở trên: "if runs[runIdx] không rỗng")
7. Liên hệ các bài khác
- Heap & heapsort: min-heap (cái rổ luôn cho ra phần tử nhỏ nhất) là cốt lõi của bước trộn. Mỗi lần bỏ một tờ vào rổ hay lấy tờ nhỏ nhất ra đều chậm theo số xấp đang trộn (gần như tức thời với vài chục xấp). Đó là lý do bước trộn nhanh. Đọc bài đó trước nếu chưa quen min-heap.
- Merge sort: external sort là phiên bản mở rộng (generalisation) của merge sort khi dữ liệu phải để trên đĩa. Bước 1 (tạo xấp đã sắp) giống trường hợp nhỏ nhất (base case) của merge sort; bước 2 (trộn) giống vòng trộn của nó. Khác ở chỗ: merge sort gốc gọi đệ quy và làm hết trong bộ nhớ, còn external sort làm theo vòng lặp, trộn nhiều xấp một lúc, và được thiết kế để đọc/ghi đĩa ít nhất có thể.
- Amortized analysis: cách tính "số lượt × 2 lần đọc/ghi mỗi lượt" là một kiểu phân tích chi phí trung bình (amortized), nhưng nhìn theo số lần đọc/ghi đĩa thay vì số phép tính của CPU (bộ xử lý).
- Count-Min Sketch: cùng chủ đề dữ liệu lớn dạng dòng chảy (streaming). Khi sắp xếp quá đắt (dữ liệu chảy về vô tận, không bao giờ "hết"), người ta dùng cấu trúc đếm gần đúng (probabilistic) thay thế. Đọc chéo để hiểu sự đánh đổi giữa "chính xác tuyệt đối" và "gần đúng nhưng rẻ".
- Case study Redis & Kafka: Kafka gộp log (log compaction) dùng external sort để trộn các đoạn file lại — đọc case study để thấy thuật toán này chạy trong một hệ thống thật.
📚 Deep Dive
Nền lý thuyết:
- Knuth, The Art of Computer Programming Vol. 3, §5.4 "External Sorting" — phân tích đầy đủ nhất về sắp xếp khi dữ liệu vượt bộ nhớ, gồm cả mẹo làm mỗi xấp dài hơn (replacement selection) và một biến thể trộn nhiều vòng (polyphase merge).
- Ramakrishnan & Gehrke, Database Management Systems Ch. 13 — external sort đặt trong bối cảnh cơ sở dữ liệu, với cách tính chi phí theo số trang đọc/ghi đĩa.
Cách Postgres sắp xếp (tuplesort):
- Mã nguồn:
src/backend/utils/sort/tuplesort.c. Lệnh chẩn đoánEXPLAIN ANALYZEin dòngSort Method: external merge Disk: X MBcho biết khi nào dữ liệu bị tràn ra đĩa. - Trong một phiên làm việc, chạy
SET work_mem = '256MB'để cho phép nhiều bộ nhớ hơn, ép một truy vấn cụ thể sắp xếp gọn trong bộ nhớ thay vì tràn ra đĩa.
Mẹo làm mỗi xấp dài hơn (replacement selection — ngoài phạm vi bài):
- Thay vì cắt xấp cố định bằng đúng
Mbản ghi, dùng một min-heap kích thướcMđể sinh ra các xấp trung bình dài gấp đôi (2M), nhờ đó sinh ra ít xấp hơn (Knuth §5.4.1). Postgres dùng biến thể này.
MapReduce:
- Dean & Ghemawat (2004), "MapReduce: Simplified Data Processing on Large Clusters" — Section 4 mô tả bước shuffle (gom dữ liệu giữa các máy) chính là external sort trải trên cả cụm máy.
Tóm tắt
- Chia xấp nhỏ vừa bộ nhớ, sắp từng xấp (bước 1: 1 lượt đọc/ghi), rồi trộn bằng min-heap nhặt tờ nhỏ nhất ở đỉnh từng xấp.
- Trộn nhiều xấp cùng lúc thì ít vòng đọc đĩa hơn, nhưng mỗi xấp cần vùng đệm riêng — k bị giới hạn bởi RAM có sẵn.
- Postgres hiện
Sort Method: external merge DiskkhiORDER BYkhông đủ bộ nhớ; bước shuffle của MapReduce là external sort trải trên cả cụm máy. - Double buffering giúp CPU không chờ đĩa (~2 lần nhanh hơn); replacement selection làm mỗi xấp trung bình dài gấp đôi, ít xấp hơn (chi tiết ở Deep Dive).
Tự kiểm tra
Q1Với N = 1 tỷ bản ghi, M = 100 triệu bản ghi (bộ nhớ chứa được), trộn k = 10 xấp một lúc: bước 1 sinh ra bao nhiêu xấp đã sắp, và bước 2 cần bao nhiêu lượt? Tổng đọc/ghi đĩa bằng bao nhiêu lần N?▸
Bước 1 (tạo xấp đã sắp): chia 1 tỷ bản ghi thành các xấp 100 triệu một, ra ceil(1 tỷ / 100 triệu) = 10 xấp — đọc qua toàn bộ dữ liệu 1 lần rồi ghi 10 xấp ra, tốn 2 lần đọc/ghi qua toàn bộ N.
Bước 2 (trộn): trộn cả 10 xấp cùng một lúc (k = 10), nên chỉ cần ceil(log_10(10)) = 1 vòng trộn — đọc và ghi thêm 2 lần đọc/ghi qua toàn bộ N.
Tổng cộng: 2 + 2 = 4 lần đọc/ghi qua toàn bộ N. Nếu N = 1 tỷ bản ghi × 100 byte = 100 GB thì tổng đọc/ghi đĩa khoảng 400 GB. Với ổ SSD đọc liền mạch 500 MB mỗi giây thì mất khoảng 13 phút.
Q2Vì sao mẹo trộn bằng min-heap (cái rổ luôn cho ra phần tử nhỏ nhất) luôn đúng — tại mọi bước, tờ lấy ra khỏi rổ đúng là tờ nhỏ nhất trong tất cả dữ liệu còn lại chưa ghi ra?▸
Điều luôn đúng (gọi là tính chất bất biến): rổ giữ đúng một tờ từ mỗi xấp — chính là tờ nhỏ nhất còn lại của xấp đó (vì mỗi xấp đã sắp sẵn, nên tờ trên đỉnh xấp là tờ nhỏ nhất của xấp).
Mọi tờ chưa ghi ra đều thuộc một trong các xấp đang trộn. Tờ nhỏ nhất của toàn bộ dữ liệu còn lại bắt buộc phải là tờ trên đỉnh của một xấp nào đó. Mà rổ đang giữ đúng tất cả các tờ-trên-đỉnh này, nên tờ nhỏ nhất trong rổ cũng là tờ nhỏ nhất của toàn bộ.
Sau khi lấy tờ đó ra và lật tờ kế của xấp đó bỏ vào rổ, tính chất trên vẫn giữ nguyên. Cứ lặp lại như vậy (lập luận quy nạp), mỗi lần lấy ra đều đúng là tờ nhỏ nhất kế tiếp.
Q3Nếu tăng k (số xấp trộn cùng lúc) từ 10 lên 100, số vòng trộn giảm, nhưng có thể nảy sinh vấn đề gì với tốc độ thực tế?▸
Hai vấn đề chính. Thứ nhất, vùng nhớ đệm (buffer — chỗ tạm trong bộ nhớ cho mỗi xấp) bị nhỏ lại. Nếu tổng bộ nhớ cố định, chia cho 100 xấp thì mỗi xấp chỉ còn một mẩu đệm tí xíu. Đệm nhỏ nghĩa là mỗi lần lật tờ kế chỉ đọc được vài byte, phải quay lại đĩa liên tục, sinh ra đọc nhảy chỗ (đọc ngẫu nhiên) nhiều hơn — chậm hẳn trên ổ cứng quay (HDD).
Thứ hai, tốn tài nguyên mở file. Mở 100 file cùng lúc ngốn tài nguyên của hệ điều hành; nhiều hệ thống còn giới hạn số file được mở đồng thời. Vì vậy Postgres giới hạn k trong thực tế khoảng 500.
Điểm cân bằng tốt thường là lấy tổng bộ nhớ chia cho kích thước đệm tối thiểu mỗi xấp, sao cho mỗi xấp vẫn còn đủ đệm để đọc liền mạch hiệu quả (thường mỗi đệm 1–4 MB).
Q4Postgres báo 'Sort Method: external merge Disk: 256 MB' trong EXPLAIN ANALYZE. Điều gì đang xảy ra bên dưới, và bạn có thể làm gì để khỏi phải tràn dữ liệu ra đĩa?▸
Postgres đã phải sắp xếp trên đĩa (external sort) vì câu ORDER BY không vừa với lượng bộ nhớ cho phép (work_mem). Cụ thể: nó chia dữ liệu thành các xấp đã sắp, ghi ra file tạm (256 MB trên đĩa), rồi trộn các xấp lại khi đọc ngược lên. Truy vấn vì thế chậm hơn hẳn so với sắp gọn trong bộ nhớ.
Cách khắc phục: cho phép nhiều bộ nhớ hơn cho phiên làm việc bằng SET work_mem = '512MB' (hoặc lớn hơn tuỳ kích thước dữ liệu). Nếu truy vấn quan trọng, tăng work_mem trong file cấu hình postgresql.conf cho riêng vai trò (role) đó. Lưu ý: mỗi lần sắp xếp của mỗi kết nối đều dùng một suất work_mem riêng — đặt quá cao mà nhiều kết nối cùng sắp xếp một lúc thì dễ hết bộ nhớ (lỗi OutOfMemory).
Q5Tại sao bước shuffle (gom dữ liệu giữa các máy) trong MapReduce tốn kém và bị xem là điểm nghẽn (bottleneck) của nhiều công việc?▸
Bước shuffle thực chất là external sort trải trên cả cụm máy, có thêm khâu truyền qua mạng. Sau bước Map, mỗi máy Map có một mớ kết quả chưa sắp; hệ thống (Hadoop/Spark) phải làm ba việc: (1) mỗi máy tự sắp kết quả của mình — đây là bước 1 làm tại chỗ; (2) gửi từng phần dữ liệu qua mạng tới đúng máy Reduce phụ trách; (3) máy Reduce trộn các xấp đã sắp nhận về thành một — đây là bước 2.
Việc (2) là điểm nghẽn: tốc độ mạng thường chậm hơn đĩa nhiều lần, mà toàn bộ dữ liệu trung gian đều phải đi qua mạng. Nếu một máy Reduce nhận quá nhiều dữ liệu vì dữ liệu chia không đều (data skew — lệch tải), nó trở thành kẻ-lê-bước (straggler) làm cả công việc phải chờ. Vì vậy các hệ thống mới (Spark, Flink) cố tránh shuffle khi có thể — ví dụ phát một bảng nhỏ tới mọi máy để ghép tại chỗ (broadcast join), hoặc chia dữ liệu sẵn theo khoá để ghép mà khỏi gom lại (partition-aware join).
Q6Replacement selection (mẹo làm mỗi xấp dài hơn) là kỹ thuật gì, và nó cải thiện bước 1 ra sao so với cách cắt xấp cố định?▸
Thay vì cắt xấp cứng nhắc bằng đúng M bản ghi, kỹ thuật này dùng một min-heap (cái rổ luôn cho ra phần tử nhỏ nhất) kích thước M. Cơ chế: đổ M bản ghi vào rổ; mỗi lần ghi tờ nhỏ nhất ra xấp đang dựng, lại đọc một bản ghi mới từ đầu vào — nếu bản ghi mới còn lớn hơn tờ vừa ghi thì bỏ vào rổ để tiếp tục xấp hiện tại; nếu nhỏ hơn thì đánh dấu "để dành cho xấp sau".
Kết quả: với dữ liệu xáo trộn ngẫu nhiên, mỗi xấp trung bình dài gấp đôi — khoảng 2M bản ghi thay vì M (Knuth §5.4.1). Số xấp ở bước 1 giảm một nửa, nên bước 2 cần ít vòng trộn hơn. Postgres dùng biến thể này. Khi dữ liệu đầu vào vốn đã gần như sắp sẵn, một xấp có thể dài bằng cả file — lúc đó chỉ cần sắp một lần là xong, gần như không phải trộn nhiều vòng.
Q7Nếu cần lọc trùng (loại bản lặp) cho 50 GB log, bạn dùng external sort thế nào? Vì sao không dùng bảng tra (hash set)?▸
Quy trình: sắp xếp toàn bộ 50 GB theo khoá cần lọc trùng (dùng external sort), rồi quét kết quả một lượt — mỗi khi gặp bản ghi giống bản ghi liền trước thì bỏ qua. Chi phí gồm một lần sắp xếp (chậm theo n·log n) cộng một lượt quét (tỉ lệ số dòng).
Bảng tra (hash set — cấu trúc nhớ những giá trị đã gặp để hỏi "đã thấy chưa?") thì nhanh, nhưng đòi bộ nhớ tỉ lệ với số phần tử. Với 50 GB dữ liệu, bảng tra cần hàng chục GB bộ nhớ (còn tốn thêm cho con trỏ và ô chứa), vượt sức máy. Có cách lai (external hash): chia dữ liệu thành nhiều nhóm theo giá trị băm rồi xử lý từng nhóm vừa bộ nhớ — nhưng phức tạp hơn.
External sort tận dụng được đọc liền mạch (nhanh) và không đòi bộ nhớ tỉ lệ với toàn bộ dữ liệu. Lệnh distinct() của Spark mặc định dùng external sort khi dữ liệu tràn ra đĩa; chỉ chuyển sang dùng bảng tra khi dữ liệu vừa bộ nhớ.
Bài tiếp theo: Reservoir sampling — mẫu ngẫu nhiên từ stream
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
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