Aggregation pipeline — optimizer, giới hạn bộ nhớ, $lookup và cái giá của một báo cáo

Báo cáo "doanh thu theo ngành hàng × tháng" trên 2 triệu đơn: PostgreSQL 18.4 chạy 3,5–5,1 s; MongoDB 9.0.2 viết kiểu SQL (join từng dòng hàng rồi gom nhóm) 52–60 s, đổi thứ tự stage còn 31–33 s, cùng kết quả. Trong lúc đo, mongod bị kernel OOM-kill 7 lần trong container 1 GB — dù mỗi stage "chỉ được dùng 100 MB". Bài này mổ aggregation pipeline: optimizer viết lại gì (và không viết lại được gì), bộ nhớ thật của stage chặn, giới hạn mỗi operation của 9.0, ba chiến lược join của $lookup, $unwind/$facet/$setWindowFields, materialized view. Planner, plan cache, SBE ở bài 4; index ở bài 3; embed hay reference ở bài 2.

Thuật ngữ dùng trong bài
stage (một bước của pipeline, vd $match) · streaming / blocking stage (stage nhả document ngay / phải đọc hết đầu vào mới nhả) · spill (ghi phần dữ liệu đang giữ trong RAM ra đĩa tạm rồi trộn lại) · top-k sort (sort chỉ giữ k phần tử tốt nhất) · pushdown (đẩy phần đầu pipeline xuống tầng truy vấn / engine bên dưới) · SBE (slot-based execution engine, bộ máy chạy mới) · IndexedLoopJoin / HashJoin / NestedLoopJoin (tra index bảng phải cho từng dòng / dựng bảng băm từ bảng phải / quét cả bảng phải cho từng dòng) · materialized view (kết quả báo cáo lưu thành collection) · OOM-kill (kernel giết process khi cgroup hết RAM).
Kiểm chứng: Context7 (08–09/10/2026): README src/mongo/db/pipeline nhánh v9.0, manual (pipeline optimization, $lookup, $out, $setWindowFields); source v9.0 (document_source_group.h, query_execution_knobs.idl, query_knob_expressions.h, group_processor.cpp, sbe/util/spilling.cpp); release notes 8.2, 9.0; docs Mongoose. Output: labs/mongodb/out/05-*.txt. MongoDB 9.0.2 trong container --memory 1g, cache 0,25 GB, máy 4 vCPU dùng chung — số ms chỉ để so tỉ lệ.

Trả lời 30 giây

Aggregation là chuỗi stage C++, document chảy qua từng stage. Stage streaming ($match, $project, $unwind, $lookup) nhả ngay; stage chặn ($sort, $group, $setWindowFields, $facet) đọc hết đầu vào → mỗi stage giữ tối đa 100 MB byte ước lượng rồi spill ra đĩa (chậm ×3–9); $push/$addToSet không spill; 9.0 thêm trần max(1 GB, 20% RAM theo cgroup) cho cả truy vấn. Heap thật có thể gấp ~6 con số explain. Optimizer viết lại theo luật, không theo chi phí: dời $match lên trước (9.0: qua cả $lookup), gộp $sort+$limit thành top-k, gộp $lookup+$unwind; phần đầu thành find dùng index; $group/$lookup đủ điều kiện chạy SBE. $lookup = left outer join: IndexedLoopJoin (index ở foreignField), HashJoin (bảng phải ≤ 10 000 doc), còn lại NestedLoopJoin = N × M. Không chọn thứ tự join → gom nhóm trước, join sau; báo cáo định kỳ thì materialize bằng $merge.

1. Bài toán gốc và bối cảnh ra đời

Trước 2012, muốn có "doanh thu theo tháng" trên MongoDB phải dùng map-reduce: viết hàm map/reduce bằng JavaScript, server đưa từng document qua engine JS. Chậm (mỗi document đi qua JS), khó viết, và server không tối ưu được — nó không hiểu hàm JS làm gì, nên không tự lọc sớm, không dùng index cho phần bên trong. MongoDB 2.2 (29/08/2012) thêm aggregation framework: các bước xử lý viết sẵn bằng C++ (gọi là stage) nối thành pipeline; press release ghi báo cáo nhanh hơn map-reduce "up to 80 percent". Vì server hiểu từng stage nên nó tự viết lại pipeline và đẩy phần đầu xuống index. Các mốc sau đó:

Tối ưu cho: biến đổi và gom nhóm document của một collection, ngay cạnh dữ liệu, với phần lọc đầu dùng index; báo cáo vừa phải chạy trong vài giây; dựng bảng tổng hợp ($merge). Không tối ưu cho: join nhiều bảng với kết quả trung gian lớn (không có cost-based join order như SQL), phân tích ad-hoc trên dữ liệu lớn (OLAP), và mọi thứ phải gom vào một document (16 MB).

2. Mô hình tư duy cốt lõi

Pipeline = một dòng document chảy qua chuỗi stage. Mỗi stage là một iterator: stage sau gọi getNext() của stage trước, nhận một document, xử lý, rồi nhả ra 0, 1 hay nhiều document. Chỉ có hai loại stage, và gần như mọi hành vi của aggregation suy ra từ hai loại này:

Lab 05-memory.txt mục 1, thời gian tới batch đầu tiên (batchSize: 1) trên 2 triệu đơn: streaming ($match field không index, $unwind) 2–8 ms; $group theo khách 2,1–3,0 s; $sort field không index 6,7–6,8 s. Màn hình "xem trước 20 dòng" mà pipeline có stage chặn thì người dùng đợi cả báo cáo.

Hệ quả: (1) optimizer chỉ cần biết stage nào phụ thuộc field nào (dependency tracking) là dời, gộp được stage; (2) phần đầu pipeline thành một lệnh find — dùng index, đi qua planner như bài 4; (3) bộ nhớ chỉ là chuyện của stage chặn → giới hạn 100 MB mỗi stage và spill ra đĩa; (4) đầu ra của stage là document, nên cái gì gom vào một document ($push, mảng as của $lookup, $facet) vướng giới hạn 16 MB; (5) stage cuối có thể ghi xuống collection ($out/$merge) → materialized view.

flowchart LR
  A["aggregate(pipeline)"] --> B["parse thành chuỗi stage
(DocumentSource)"] B --> C["optimizer theo luật:
dời, gộp, tách, chèn stage"] C --> D{"tách phần đầu"} D -->|"$match $sort $limit $project"| E["query layer: planner chọn index
(như find, bài 4)"] D -->|"$group $lookup đủ điều kiện"| F["SBE
(slot-based engine)"] E --> G["phần còn lại: stage classic
gọi getNext() từng document"] F --> G G --> H["cursor: batch 101 doc,
getMore tới 16 MiB"]

3. Internals và quyết định thiết kế

3.1 Optimizer: viết lại theo luật, không theo chi phí

Theo README src/mongo/db/pipeline (nhánh v9.0), optimizePipeline() chạy hai vòng: inter-stage (dời, gộp, bỏ, chèn stage — mỗi stage có optimizeAt(); riêng việc dời $match, $sample, $project, $redact là luật ưu tiên cao) rồi stage-specific (bỏ stage rỗng như {$match: {}}, gộp hằng số). Điều kiện để dời là dependency tracking: stage chỉ được đẩy qua stage trước nếu không dùng field mà stage đó tạo ra hoặc đổi tên. Không có mô hình chi phí — luật áp được là áp. Lab 05-optimizer.txt (explain thật, 9.0.2):

Bạn viếtOptimizer làmExplain / số đo
(a) $addFields → $project → $match createdAtDời $match lên đầu → thành findFETCH ← IXSCAN createdAt_1; 1,7–2,3 s, chặn optimizer thì COLLSCAN 7,6–12,2 s
(c) $group theo status → $match {_id: "pending"}$match trên khoá nhóm dời qua $groupGROUP ← PROJECTION_COVERED ← IXSCAN status_1 (covered)
(d) $group → $match {n: {$gt: …}}Không dời (kiểu HAVING)MATCH ← GROUP ← COLLSCAN
(e) $sort → $project → $limit 10Gộp $limit vào $sort = top-kSORT limit=10: chỉ giữ 10 document
(g) $lookup → $unwind mảng vừa join → $match c.tierGộp cả ba vào $lookup$lookup {…, unwinding, pipeline: [{$match: {tier}}]}
(h) $sort {customerId, createdAt: -1} → $group $firstKhông đổi thành $top (mục 3.5)GROUP ← SORT ← COLLSCAN
(i) $lookup → $addFields yr → $match {yr, status}9.0: dời cả phép tính lẫn $match lên trước $lookupEQ_LOOKUP ← MATCH yr ← PROJECTION ← IXSCAN status_1; 1,2–2,2 s, chặn optimizer 36 s
Optimizer sắp lại pipeline trước khi chạy Pipeline viết addFields, project, match, sort, limit. Optimizer dời match về đầu thành IXSCAN createdAt, gộp limit vào sort thành top-k 10 document, nhưng addFields vẫn chạy cho 161 006 đơn tháng 9. Hàng trên: bạn viết · Hàng dưới: explain 9.0.2 cho thấy thứ tự thật bạn viết $addFields$project$match$sort$limit →→→→ optimizer chạy ↓ →→→ $addFieldsmonth = … $projectbỏ items $sort total: -1 top-k, limit 10 $limit10 $matchcreatedAt tháng 9 ① $match về đầu → query layer: FETCH ← IXSCAN createdAt_1 (161 006 đơn tháng 9) ② $limit gộp vào $sort: top-k chỉ giữ 10 document trong RAM ③ nhưng top-k không được dời lên trước $addFields: month vẫn tính cho 161 006 đơn
Hình 1 — Optimizer dời $match qua hai stage không đụng tới createdAt, đẩy nó xuống index, gộp $sort+$limit thành top-k. Nó không có luật "dời top-k lên trước stage tính toán": tự viết $match → $sort → $limit → $addFields → $project thì $addFields chỉ chạy cho 10 document — đo: 529–895 ms theo thứ tự đã viết, 216–307 ms khi tự sắp (lúc đó SORT limit=10 cũng xuống luôn query layer). Nguồn: 05-optimizer.txt, 05-optimizer2.txt.

Bẫy của optimizer, theo mức hay dính:

  1. Điều kiện viết bằng biểu thức thì không dùng được index. Lọc tháng 9 bằng $expr + $year/$month: COLLSCAN, 1,8–2,1 s; viết thành khoảng createdAt: {$gte, $lt}: COUNT_SCAN createdAt_1, 32–46 ms — cùng 161 006 đơn. Optimizer dời được $match, không đổi biểu thức thành khoảng.
  2. Lọc trên kết quả cộng dồn (case d) luôn chạy sau $group: lọc đầu vào trước, hoặc materialize (mục 3.7).
  3. Nhảy bản: case (i) trên 8.0.32 không dời phép tính qua $lookup — lab 05-v80.txt, $lookup → $addFields hour → $match {hour: 3}: 8.0.32 chạy $lookup cho cả 2 triệu đơn (docsExamined 4 000 000, 33,2 s), 9.0.2 dời $match lên trước (2 083 253, 5,6 s), cùng 83 253 kết quả. Pipeline nhanh trên 9.0 có thể chậm hẳn trên cụm 8.0.
  4. Xem thứ tự thật bằng explain(), không đọc pipeline trong code: explain in pipeline sau khi viết lại. So sánh "có/không optimizer" trong lab bằng stage nội bộ $_internalInhibitOptimization.

3.2 Phần đầu đẩy xuống đâu: query layer, SBE, rồi classic

Sau khi viết lại, MongoDB cắt phần đầu pipeline đưa xuống query layer: chuỗi $match/$sort/$limit/$skip/$project ở đầu trở thành một find có planner, index, plan cache (bài 4). Với internalQueryFrameworkControl: trySbeRestricted (mặc định 9.0.2), việc đẩy xuống còn đi tiếp vào SBE nếu gặp $group hoặc $lookup đủ điều kiện và mọi stage trước chúng cũng chạy được trên SBE. Phần còn lại chạy bằng stage classic, từng document một. Lab 05-lookup2.txt + 05-lookup.txt:

PipelineEngine (explainVersion)Vì sao
$match → $groupSBE (2)Luật 5.2+
$match → $lookup (localField/foreignField)SBE, stage EQ_LOOKUPLuật 6.0+
$match → $lookup → $unwind (bảng phải có index)classic (1)featureFlagSbeEqLookupUnwindIndexedLoopJoin=false
$lookup bảng nhỏ không index → $unwindSBE, HashJoinfeatureFlagSbeEqLookupUnwindHashJoin=true (9.0)
$lookup dạng let/pipelineclassicfeatureFlagLookupWithPipelinesInSbe=false
$match → $sort → $projectclassicKhông có $group/$lookup → không vào SBE

Hệ quả đo được: doanh số theo thành phố của 38 172 đơn một tuần, $lookup → $unwind → $group "$c.city" (classic) 819–850 ms; bỏ $unwind, nhóm theo {$first: "$c.city"} (cả pipeline ở SBE) 414–459 ms, cùng kết quả. Các flag trên đang được bật dần qua từng bản — đọc explainVersion của chính pipeline của bạn.

3.3 $lookup: ba chiến lược join, chọn theo index trước, kích thước sau

$lookup là left outer join: mỗi document bên trái nhận một mảng as chứa các document bên phải khớp. Không có cost-based optimizer chọn thứ tự join như SQL — bảng trái là collection bạn gọi aggregate, luôn thế. Khi chạy ở SBE, explain ghi rõ strategy. Lab 05-lookup.txt:

Tình huốngStrategyDocument đọcThời gian
5 khách → orders.customerId, chưa có indexNestedLoopJoin10 000 0058,2 s
20 khách, có customerId_1IndexedLoopJoin1 6450,7 s
2 000 khách, có indexIndexedLoopJoin119 0728,1 s
200 000 khách → cities (60 doc), không indexHashJoin200 0600,59 s
như trên, có index cities.nameIndexedLoopJoin400 000 (+200 000 key)2,0 s
Ba chiến lược join của $lookup NestedLoopJoin quét cả 2 triệu đơn cho từng khách (5 khách đọc 10 triệu document, 8,2 s). IndexedLoopJoin nhảy thẳng qua index (20 khách, 1 645 document, 0,7 s). HashJoin dựng bảng băm 60 thành phố một lần rồi tra (200 000 khách, 0,59 s). Mỗi document bên trái tìm bên phải thế nào (explain strategy, 9.0.2) NestedLoopJoinkhông index, bảng phải lớn orders: 2 000 000 document mỗi khách quét lại cả bảng: 5 khách → 10 000 005 document, 8,2 s IndexedLoopJoinindex ở foreignField customerId_1 nhảy thẳng tới đơn của từng khách: 20 khách → 1 645 document, 0,7 s HashJoinbảng phải ≤ 10 000 doc hash table cities: 60 doc dựng 1 lần 200 000 khách chỉ tra bảng băm: 0,59 s (có index → IndexedLoopJoin 2,0 s) Cách chọn: có index ở foreignField → IndexedLoopJoin, kể cả khi HashJoin nhanh hơn; không index và bảng phải nhỏ → HashJoin; còn lại → NestedLoopJoin = N × M.
Hình 2 — NestedLoopJoin làm số document đọc bằng số khách × cỡ bảng phải; một index ở foreignField đổi nó thành vài key mỗi khách. HashJoin dựng bảng băm từ bảng phải nhỏ (≤ internalQueryCollectionMaxNoOfDocumentsToChooseHashJoin 10 000 doc và ≤ 100 MB) một lần rồi tra. Nguồn: 05-lookup.txt.

Dạng let/pipeline chạy ở classic nhưng vẫn dùng index: 2 000 khách, $expr: {$eq: ["$customerId", "$$cid"]} → indexesUsed: ["customerId_1"], 117 072 key, 6,9 s; thêm điều kiện $gte ngày 6,5 s; dạng gọn localField + pipeline (5.0+) 6,1 s — cùng cỡ IndexedLoopJoin (8,1 s, máy dùng chung). Docs ghi $eq/$lt/$lte/$gt/$gte trong $expr dùng được index (không áp cho index multikey, partial, sparse); lab 9.0.2 thấy cả $in với mảng từ let cũng dùng customerId_1.

Kích thước (05-lookup2.txt): mảng as chỉ là document trung gian, được tới internalLookupStageIntermediateDocumentMaxSizeBytes 100 MB. Sản phẩm bán chạy nhất nằm trong 92 374 đơn: mảng ~38 MB vẫn chạy được nếu sau đó chỉ lấy $size; trả nguyên document thì lỗi. Kéo 400 269 đơn vào một mảng thì lỗi ngay trong pipeline:

BSONObjectTooLarge (10334): … Serializing Document failed :: caused by :: Size 38518342 exceeds maximum 16793600
Location4568 (4568): … Total size of documents in orders matching pipeline's $lookup stage exceeds 104857600 bytes

Chữa: $unwind ngay sau $lookup (optimizer gộp, không tạo mảng — 92 374 document trả bình thường), hoặc pipeline: [{$project: {_id: 0, total: 1}}] để mảng chỉ mang field cần dùng (400 269 phần tử, chạy được).

3.4 Bộ nhớ: 100 MB mỗi stage, spill, trần mỗi operation — và RAM thật

Mỗi stage chặn có ngưỡng riêng, mặc định 104 857 600 byte (100 MiB): internalQueryMaxBlockingSortMemoryUsageBytes, internalDocumentSourceGroupMaxMemoryBytes (bản SBE: …HashAggApproxMemoryUseInBytesBeforeSpill), tương tự cho $setWindowFields, $facet, $lookup, $push/$addToSet. Vượt ngưỡng thì stage spill (ghi phần đang giữ ra đĩa, sau đó trộn lại) — allowDiskUseByDefault: true. Từ 8.2 spill của SBE vào một WiredTiger thứ hai ở <dbpath>/_tmp/spilldb (nén zstd, cache riêng 2,5% RAM, kẹp 100–1 000 MB), sorter classic ghi file tạm cũng dưới _tmp. Lab 05-memory.txt, 2 triệu đơn:

Truy vấnThời gianSpillByte theo dõi (explain)
$group theo status (5 nhóm)1,9 skhông~0
$group theo khách (199 480 nhóm)4,3 skhông25,7 MB
$group theo _id (2 triệu nhóm)32,9 s3 lần, 127,8 MB → 46,3 MB trên đĩa100 MB
$sort total, giữ cả document8,2 s9 lần, 714,1 MB → 186,3 MB99 MB
$sort total + $limit 10 (top-k)1,3 skhông~0

Tắt spill (allowDiskUse: false) thì lỗi nguyên văn:

QueryExceededMemoryLimitNoDiskUseAllowed (292): … Exceeded memory limit for group, but didn't allow external spilling; pass allowDiskUse:true to opt in
QueryExceededMemoryLimitNoDiskUseAllowed (292): … Sort exceeded memory limit of 104857600 bytes, but did not opt in to external sorting.

Accumulator gom mảng không spill được. Spill chia theo nhóm; một nhóm có mảng 100 MB thì không chia nhỏ được. $push: "$$ROOT" theo status (nhóm delivered ~1,3 triệu đơn) chết ở 100 MB; nhóm paid (~160 000 đơn) vừa RAM nhưng document kết quả 57 MB không gửi được:

ExceededMemoryLimit (146): … Used too much memory for a single array. Memory limit: 104857600. Concatenating array of 229009 elements
  and 84011991 bytes with array of 57631 elements and 21131103 bytes.
BSONObjectTooLarge (10334): Building Message failed. Size 57550227 exceeds maximum 33619968

9.0: trần mỗi operation internalQueryMaxMemoryUsageBytesPerOperation cộng mọi stage của một truy vấn. Source (query_knob_expressions.h): mặc định max(1 GB, 20% × ProcessInfo::getMemSizeBytes()), "respects cgroup/container limits", tính một lần lúc khởi động. Lab 05-oplimit.txt (hostInfo.system.memLimitMB là RAM mongod thấy):

NodememLimitMBTrần mỗi operation
9.0.2, --memory 1g1 0241 024 MB — bằng cả container
9.0.2, --memory 6g6 1441 228,8 MB (20%)
9.0.2, không giới hạn (máy 7,9 GB)7 9411 588,2 MB (20%)
8.0.321 024không có tham số (InvalidOptions)

Hạ trần xuống 64 MB (05-oplimit.txt): $group 2 triệu nhóm không lỗi mà spill sớm hơn (2 lần, đỉnh 64,1 MB thay vì 100 MB); chỉ khi allowDiskUse: false mới ra lỗi 292 và operationsFailedDueToMemoryLimit tăng. 9.0 cho đặt trần riêng một lệnh: querySettings: {queryKnobs: {queryMaxMemoryUsageBytesPerOperation: …}} trên aggregate (cần FCV 9.0) — lab chạy được với 32 MB và 16 MB. Với container 1 GB, trần mặc định bằng cả container: nó không bảo vệ gì.

Byte "theo dõi" không phải RAM thật. Ngưỡng 100 MB đo theo cách stage ước lượng dữ liệu nó giữ. Lab 05-heap.txt đo song song tcmalloc allocated của cả mongod (mẫu mỗi 300 ms, node vừa restart): $group theo khách (SBE) theo dõi 9,5 MB, heap +73 MB; $sort total giữ cả document (classic) 99 MB, +119 MB; $sort → $project total ép SBE 99 MB, +114 MB; cùng truy vấn ở classic (mặc định) 99 MB, +598 MB (324 → 922 MB); $project → $sort classic 360,5 MB, +484 MB.

Byte theo dõi và heap thật của một sort chặn Cùng sort 2 triệu đơn, cùng 100 MB theo dõi và 3 lần spill. SBE: heap tăng 114 MB. Classic: heap tăng 598 MB lên 922 MB, sát giới hạn 1 GB của container; chạy sau truy vấn nặng khác thì bị OOM-kill. --memory 1g · cache 0,25 GB · $sort 2 triệu đơn theo total (không index) · lab 05-heap.txt SBE: $sort → $project classic (mặc định): $sort → $project byte theo dõibyte theo dõi 100 MB100 MB đĩađĩa heap mongod (tcmalloc)heap mongod (tcmalloc) spill ×3spill ×3 1 GB = --memory nền 324 MBnền 324 MB +114 MB → đỉnh 437 MB+598 MB → đỉnh 922 MB heap tăng đúng cỡ byte theo dõi chạy sau truy vấn nặng khác → OOM-kill (4 lần)
Hình 3 — Cùng ngưỡng 100 MB, cùng 3 lần spill, nhưng sorter classic khi chỉ cần field total giữ thêm ~500 MB mà explain không đếm. Giả thuyết (chưa đọc tới source): mỗi phần tử sort chỉ khai báo kích thước field cần dùng nhưng vẫn trỏ vào cả bản ghi gốc. Sort giữ cả document thì kế toán đúng (+119 MB). Bằng chứng OOM: 05-oom-dmesg.txt.

Thêm hai điều làm container nhỏ dễ chết: RSS không tụt sau truy vấn — tcmalloc giữ trang để dùng lại (heap về 324 MB nhưng mem.resident đứng ở 980–1 001 MB nhiều phút); và trần mỗi operation không cộng các truy vấn song song. Các lần OOM-kill sort trong lab đều là truy vấn classic $sort → $project chạy khi RSS đã cao, kể cả lần nâng ngưỡng sort lên 400 MB để "khỏi spill". 8.0.32 cùng phép đo: +596 MB (05-v80.txt) — không phải lỗi riêng của 9.0.

3.5 "Bản ghi mới nhất của mỗi nhóm": $sort + $first không tự thành $top

Có hai cách viết "đơn mới nhất của mỗi khách": $sort {customerId: 1, createdAt: -1} rồi $group {last: {$first: …}}, hoặc một mình $group {last: {$top: {sortBy: {createdAt: -1}, output: …}}}. Hay nghe nói optimizer tự đổi cách 1 thành cách 2. Source 9.0 nói khác — comment trong document_source_group.h: "The $sort/$group with $first/$last is rewritten to use $top/$bottom … Currently this rewrite is only invoked from time-series. TODO SERVER-28980"; ticket SERVER-28980 (mở từ 2017) vẫn ở trạng thái Backlog. Explain xác nhận: GROUP ← SORT ← COLLSCAN. Lab 05-optimizer.txt mục 3 + 05-memory.txt mục 5:

Cách viếtKhông có index phụCó index {customerId: 1, createdAt: -1}
$sort + $first: "$$ROOT"12,4 s; SORT spill 11 lần (820 MB)DISTINCT_SCAN 199 480 key, 17,1 s
$sort + $first 2 field7,3 s; spill 3 lầnDISTINCT_SCAN, 10,2 s
$top với $$ROOT78,9–79,4 s; SBE spill 7 lần (370 MB)81,4 s — $top không dùng index
$top 2 field8,5 s; không spill (51,9 MB)9,1 s

3.6 $unwind, $facet, $setWindowFields: ba stage hay bị dùng sai

$unwind biến 1 document có mảng k phần tử thành k document (mỗi bản chép lại mọi field khác). 2 triệu đơn → 4 381 001 dòng hàng; chỉ để đếm thì $sum: {$size: "$items"} (3,0 s) rẻ hơn $unwind → $count (3,4–4,1 s). Dùng $unwind đúng chỗ là khi cần nhóm theo phần tử mảng ($group theo items.productId: 9,0 s, 20,6 MB). Sai chỗ là "đi rồi gom lại về chính document đó" — kiểm tra total của mỗi đơn bằng $unwind → $group theo _id: 20,8 s, spill 12 lần (1,1 GB); cùng phép kiểm bằng $reduce trên mảng trong một document: 10,5 s, không giữ bộ nhớ (05-unwind.txt). Bẫy ngữ nghĩa (cùng file):

$unwind mặc định:                 hai-item:"a"  hai-item:"b"  chuoi:"x"
preserveNullAndEmptyArrays: true: hai-item:"a"#0  hai-item:"b"#1  rong:undefined#null  null:null#null  thieu:undefined#null  chuoi:"x"#null

Mặc định $unwind làm mất document có mảng rỗng, null hoặc thiếu field — báo cáo "doanh thu theo sản phẩm" lặng lẽ bỏ đơn không có dòng hàng. Giá trị không phải mảng ("x") được coi như mảng một phần tử. Có preserveNullAndEmptyArrays thì đơn mảng rỗng ra với field bị xoá (không phải null).

$facet chạy nhiều sub-pipeline trên cùng một luồng đầu vào và trả một document. Hai hệ quả (05-facet.txt):

$setWindowFields cần đầu vào xếp theo partitionBy + sortBy; không có index thì optimizer chèn $sort chặn phía trước (05-window.txt). Luỹ kế doanh thu theo khách × tháng (1 336 719 cặp): riêng $group 58,5 s; thêm window thành $cursor → $sort → $_internalSetWindowFields, 62,3 s, spill 15 lần. $rank trên một partition 2 triệu đơn: spill 9 lần, 15,9 s. Bẫy bậc hai: $push với cửa sổ ["unbounded", "current"] cho khách có 7 770 đơn sinh 30 190 335 phần tử, 628,4 MB output — không lỗi kể cả allowDiskUse: false, vì cửa sổ chỉ giữ 7 770 giá trị, cái phình là luồng ra. Cửa sổ [-29, 0]: 1,5 s, 3,4 MB.

3.7 Materialized view: $out, $merge và view thường

Báo cáo chạy 6 s thì không thể chạy mỗi lần mở trang. Ba cách giữ kết quả (lab 05-mview.txt, doanh thu theo ngày, 730 ngày):

$out thay collection còn $merge cập nhật tại chỗ $out ghi vào collection tạm rồi đổi tên đè lên đích, cursor đang đọc bản cũ nhận lỗi QueryPlanKilled. $merge upsert tại chỗ, chạy lại một ngày mất 27 ms, người đọc không bị ngắt nhưng lỗi giữa chừng để lại phần đã ghi. Một job dựng lại báo cáo trong lúc có người đang đọc (cursor batch 5) $out tmp.agg_out.… rpt_daily_out rename, dropTarget người đọc: find batch 5 → getMore … getMore: QueryPlanKilled (175) collection dropped index chép sang · UUID mới · lỗi giữa chừng: bản cũ nguyên vẹn $merge rpt_daily upsert theo _id (ngày) — chạy lại riêng 30/09: 27 ms người đọc không bị ngắt không nguyên khối: lỗi ở giữa → giữ phần đã ghi
Hình 4 — $out ghi vào collection tạm rồi renameCollection đè lên đích: người đọc thấy bản cũ hoặc bản mới, nhưng cursor đang mở trên bản cũ chết. $merge sửa tại chỗ từng document: không ai bị ngắt, đổi lại không có "tất cả hoặc không có gì". Nguồn: 05-mview.txt.
CáchĐo đượcBẫy
$outDựng lại 5,8–6,2 s; index tạo tay được chép sang; UUID đổi mỗi lầnCursor đọc dở: QueryPlanKilled (175): collection dropped. Lỗi giữa chừng (E11000 … shop.tmp.agg_out.…): bản cũ còn đủ 730 dòng
$mergeDựng đủ 7,8 s; thêm 2 000 đơn ngày 30/09, chạy lại chỉ ngày đó 27 mson thiếu unique index: Location51183: Cannot find index to verify that join fields will be unique. whenMatched: "fail" gặp ngày đã có ở cuối: lỗi E11000 nhưng 729 ngày đã ghi
View thườngĐọc 30 ngày 6,0–6,1 s mỗi lần; bảng materialize 4–8 msLọc trên khoá nhóm tính bằng $dateTrunc không dời qua $group: MATCH ← GROUP ← FETCH ← IXSCAN status_1. Không tạo index được: CommandNotSupportedOnView (166)

Cách làm thường dùng: $merge theo lịch cho khoảng thời gian vừa đổi (vd 2 ngày gần nhất, vì đơn đổi trạng thái muộn), pipeline idempotent để chạy lại được; dựng lại toàn bộ bằng $out vào tên mới rồi đổi tên ở tầng ứng dụng khi cần.

3.8 Từ Laravel và Node: query builder sinh pipeline gì, kiểu dữ liệu đi đâu

4. Đảm bảo và giới hạn

Hạng mụcGiá trị (9.0.2, lab)Ghi chú
Thứ tự stageOptimizer đổi theo luật, không theo chi phíKết quả không đổi; thứ tự thật xem trong explain
Bộ nhớ mỗi stage chặn100 MiB rồi spill (allowDiskUseByDefault: true)Byte theo dõi; heap thật có thể gấp ~6 (mục 3.4)
Mảng của $push/$addToSet100 MiB, không spillExceededMemoryLimit (146)
Bộ nhớ mỗi operation (9.0)max(1 GB, 20% RAM theo cgroup)Không cộng các truy vấn song song; 8.0 không có
Document kết quả16 MiB; message trả về 33 619 968 byteDocument trung gian: $lookup, $facet tới 100 MB
Spill ra đĩaCần ≥ 500 MB trống (internalQuerySpillingMinAvailableDiskSpaceBytes)Ghi vào <dbpath>/_tmp, cùng đĩa với dữ liệu (lab: OutOfDiskSpace khi đĩa còn 516 MB)
Độ dài pipeline1 000 stage (internalPipelineLengthLimit)Pipeline sinh tự động (builder) mới chạm tới
$lookupLeft outer join; HashJoin khi bảng phải ≤ 10 000 doc và ≤ 100 MBKhông chọn thứ tự join; có index thì luôn IndexedLoopJoin
$outThay collection nguyên khối khi xong; chép định nghĩa indexCursor đang đọc bản cũ bị giết (mục 3.7)
$mergeUpsert theo on; cần unique index trên onKhông nguyên khối: lỗi giữa chừng để lại phần đã ghi
JS phía server (9.0, WASM)Heap 100 MB/lần gọi, timeout 60 sinternalQueryJavaScriptHeapSizeLimitMB, …FnTimeoutMillis

Khung lý thuyết: aggregation là Volcano/iterator model (mỗi stage kéo dữ liệu từ stage trước) như executor SQL, nhưng optimizer là rule-based — giống planner SQL đời đầu trước khi có cost model. Vì vậy câu hỏi "join nào trước" không có ai trả lời thay bạn: thứ tự join là thứ tự bạn viết.

5. Failure modes

Ba kiểu hỏng: chết cả mongod (OOM), chậm nhiều lần mà không lỗi (spill, NestedLoopJoin), lỗi hoặc sai kết quả (kích thước, kiểu dữ liệu). Mỗi dòng có lab tái hiện ở mục 3.

FailureHệ quảPhát hiện / chữa
Stage chặn trong container nhỏ: sort classic, $facet gom 462 751 đơn, $function cấp phát lớn (9.0)Kernel giết mongod (exit 137), mọi kết nối rớt, restart + recoverydmesg, OOMKilled=true / bớt dữ liệu trước stage chặn, index cho sort, node báo cáo nhiều RAM
Stage chặn vượt 100 MBChậm ×3–9, đọc lại spill gấp ~20 lần lượng ghispills trong explain/slow log / top-k, lọc sớm, allowDiskUse: false ở API
Đĩa trống dưới 500 MBOutOfDiskSpace (14031) dù đĩa chưa đầyCảnh báo dung lượng đĩa của _tmp, spillPath riêng (đặt lúc khởi động)
$lookup không index, bảng phải lớnChậm theo N × M, nặng dần khi dữ liệu lớnstrategy: NestedLoopJoin / index foreignField
$out / $merge giữa chừngCursor đọc dở bị giết / bảng nửa mới nửa cũPipeline idempotent theo khoảng ngày, chạy lại cả khoảng
Kiểu dữ liệu: Mongoose aggregate, sum('items.qty')0 kết quả / tổng = 0, không lỗiTest so với find / ép kiểu, $unwind trước

Lab đĩa gần đầy (05-diskfull.txt, node có _tmp là tmpfs 700 MB, 1,2 triệu document 254 B): sort classic spill được ~200 MB thì dừng:

OutOfDiskSpace (14031): … Available space of 516558848 bytes is less than the required minimum of 524288000 bytes

Bằng chứng OOM (05-oom-dmesg.txt, 7 lần trong một buổi lab, mỗi lần mongod phải khởi động và recovery lại):

# lần 4: mongod vừa restart; $group _id (spill, 41,9 s), tới [$sort total, $project total] classic thì bị kill
[Fri Oct  9 08:15:24 2026] Memory cgroup out of memory: Killed process 3478100 (mongod) total-vm:5329456kB, anon-rss:1041520kB, …
# lần 6: $function cấp phát mảng 50 triệu phần tử (9.0, engine WebAssembly). Log mongod trước đó:
#   WASM bridge linear memory growth {… "heapLimitMB": 1100, "storeLimitMB": 1210}
[Fri Oct  9 08:27:01 2026] Memory cgroup out of memory: Killed process 3497484 (mongod) total-vm:29896020kB, anon-rss:1040032kB, …

Cùng lệnh $function trên 8.0.32 (mozjs) trả lỗi gọn JSInterpreterFailure (139): … Out of memory — 9.0 vẫn báo internalQueryJavaScriptHeapSizeLimitMB=100 nhưng engine WASM cho heap tới 1 100 MB. Ngưỡng 100 MB/stage, trần mỗi operation và load shedding theo RSS (9.0 có sẵn, mặc định tắt: queryMemoryLoadSheddingLowMarkPercent=-1, chưa verify) đều là lưới mềm; kernel thì giết cả process.

6. Vận hành trong production

7. So sánh và ma trận quyết định

Cùng hai báo cáo trên MongoDB và PostgreSQL 18.4 (05-report.txt, 05-pg.txt): nạp đủ 2 000 000 đơn, 4 381 001 dòng hàng, 100 000 sản phẩm, 200 000 khách sang bảng tmp_mdb_* (khoá bigint thay ObjectId; index created_at, status, order_items(order_id)). Hai bên ra cùng con số (tháng 9/2026: Điện thoại 48 531 670 000, Thời trang nữ 20 008 990 000; khách #1: 5 653 038 000).

Báo cáo (974 991 đơn delivered, 12 tháng)MongoDB 9.0.2PostgreSQL 18.4
A. Doanh thu theo ngành hàng × thángA1 ($lookup từng dòng hàng): 52–60 s, classic
A2 (gom theo sản phẩm × tháng rồi mới $lookup): 31–33 s, spill 3 lần
3,5–5,1 s: Parallel Seq Scan → Parallel Hash Join → Hash Join → Sort (external merge ~32 MB/worker) → GroupAggregate
B. Top 10 khách + tên4,0–6,5 s (SBE)1,0–1,1 s: Partial HashAggregate (21 batch, tràn đĩa vì work_mem 4 MB) → top-N heapsort → Index Scan khách
Độ dài lệnh A517 ký tự JSON (A2)334 ký tự SQL

Không cùng điều kiện, nên chỉ đọc tỉ lệ: MongoDB trong container 1 GB, cache 0,25 GB, một luồng mỗi truy vấn; PostgreSQL không giới hạn RAM container (shared_buffers 128 MB + page cache của máy), 2 worker song song. Phần chênh nằm ở join: PostgreSQL dựng bảng băm 974 991 đơn và 100 000 sản phẩm một lần; MongoDB tra index products cho từng dòng hàng (bảng phải 100 000 doc > ngưỡng HashJoin 10 000) và chạy ở classic vì $lookup + $unwind. Cùng thao tác, cùng dữ liệu: join nhiều bảng là việc SQL làm tốt hơn hẳn.

Join ở tầng ứng dụng (Laravel with() = 2 lệnh: lấy đơn, rồi customers.find({_id: {$in: […]}})) so với $lookup — 5 476 đơn một ngày kèm tên khách: with('customer') 533–612 ms (lệnh 2 là $in ~3 000 _id), $lookup 466–560 ms (05-laravel.txt). Gần bằng nhau: phần dựng model Eloquent chiếm phần lớn thời gian. Join ở ứng dụng vẫn hợp lý khi bảng phải nằm ở service/DB khác, hoặc cần cache theo khách.

TrụcAggregationSQL (PostgreSQL)Join ở ứng dụngMaterialize
Lọc + gom nhóm 1 collectionTốt (index, SBE)Tốt—Tốt nhất khi đọc nhiều
Join nhiều bảng lớnYếu: không chọn thứ tự join, HashJoin ≤ 10 000 docMạnh: cost-based, hash join, song songỔn khi bên phải ítNé join lúc đọc
Bộ nhớ100 MB/stage, heap thật khó đoánwork_mem/node, EXPLAIN báo spillRAM của PHP/NodeĐĩa
Độ tươi · độ phức tạpTức thời · pipeline dài khó reviewTức thời · SQL quen thuộcTức thời · N+1 nếu quên eager loadTrễ theo lịch · thêm job
Tuyển người · hệ sinh tháiHẹp hơn, ít tool BIPhổ biến, BI kết nối thẳngPhổ biến—

Dùng aggregation khi: báo cáo trên một collection (có thể kèm $lookup vào bảng nhỏ hoặc sau khi đã gom nhóm), phần lọc đầu có index, kết quả cần ngay; dựng bảng tổng hợp theo lịch bằng $merge. Tránh khi: báo cáo join nhiều collection lớn, phân tích ad-hoc trên toàn bộ dữ liệu, hay endpoint API mà pipeline có stage chặn trên hàng triệu document — chuyển sang bảng materialize, hoặc đẩy dữ liệu sang SQL/warehouse.

8. Góc nhìn tech lead: tác động tổ chức

9. Anti-patterns và bài học thực tế

  1. Viết pipeline như SQL: $lookup từng dòng rồi mới $group — báo cáo A 52–60 s; gom nhóm trước, join sau 31–33 s. Tốt hơn nữa: chép field cần dùng vào document.
  2. $lookup vào collection lớn không index foreignField — nổ trễ: nhanh khi dữ liệu nhỏ, chậm theo N × M khi cả hai cùng lớn.
  3. $unwind rồi $group về lại chính document — 20,8 s và spill 1,1 GB cho việc $reduce làm trong 10,5 s.
  4. $facet cho "trang + tổng số" hay dashboard nhiều số — nhánh không dùng index, mọi document đi qua, kết quả dồn vào một document.
  5. Lọc ngày bằng $expr + $year/$month — COLLSCAN 1,8–2,1 s thay vì 32–46 ms.
  6. $project sớm "để đỡ RAM" — optimizer tự làm; lab còn thấy $project → $sort classic tốn heap hơn sort cả document (+484 MB so với +119 MB).
  7. $push: "$$ROOT" để "lấy kèm chi tiết" — không spill được: lỗi 146 hoặc 10334.
  8. Nâng internalQuery… để "hết spill" trong container nhỏ — OOM-kill.
  9. $out vào collection API đang đọc — cursor đang mở chết.
  10. JS phía server cho việc biểu thức gốc làm được — $function chậm ~18 lần trên 9.0 (WASM chậm hơn mozjs của 8.0 ~4 lần), có thể làm chết mongod.

Bài học công khai: SERVER-28980 ("aggregation can subsume $sort into $group when $first/$last are present", mở 2017, Backlog) — nhiều bài viết coi việc đổi $sort + $first thành $top là có sẵn, source 9.0 chỉ làm cho time series. Manual xếp Reduce $lookup Operations vào anti-pattern thiết kế. Điều docs không nói mà lab thấy: ngưỡng 100 MB là byte ước lượng, heap thật có thể gấp ~6; RSS không tụt sau truy vấn, nên container nhỏ thường chết ở truy vấn thứ hai.

10. Câu hỏi phụ interviewer hay hỏi tiếp

  1. Optimizer của aggregation khác planner của find? — Viết lại chuỗi stage theo luật (dời $match, gộp $sort+$limit, $lookup+$unwind), không có chi phí; phần đầu sau khi viết lại mới qua planner thật như find.
  2. $match sau $group có được dời lên? — Lọc trên khoá nhóm (field đơn giản) thì có; trên kết quả cộng dồn hay khoá tính bằng biểu thức thì không.
  3. Giới hạn 100 MB là gì? — Mỗi stage chặn giữ ~100 MiB byte ước lượng rồi spill (mặc định bật); $push/$addToSet không spill; 9.0 thêm trần mỗi truy vấn max(1 GB, 20% RAM cgroup). RAM thật có thể gấp ~6.
  4. Spill có miễn phí? — Không: 9,1 s → 79,4 s khi spill 7 lần trên SBE; cần ≥ 500 MB đĩa trống, ghi cùng đĩa dữ liệu.
  5. $lookup chậm khi nào? — Không index ở foreignField và bảng phải > 10 000 doc → NestedLoopJoin N × M. Có index → IndexedLoopJoin; bảng nhỏ → HashJoin.
  6. Mảng as quá 16 MB? — Trung gian được tới 100 MB; $unwind ngay sau thì không tạo mảng; document trả về > 16 MB thì lỗi 10334.
  7. $facet có dùng index? — Không cho từng nhánh; $match trước $facet hoặc chạy lệnh riêng.
  8. Materialized view? — $out thay nguyên collection (giữ index, giết cursor đang đọc); $merge upsert tăng dần (cần unique index trên on, không nguyên khối); view thường chạy lại pipeline mỗi lần đọc.
  9. $function/map-reduce? — Map-reduce deprecated từ 5.0; $function (9.0 WASM) chậm ~18 lần biểu thức gốc — chỉ khi không có toán tử tương đương.
  10. Báo cáo nặng chạy ở đâu? — Gom nhóm trước rồi join, materialize bằng $merge, chạy trên node phân tích riêng; join nhiều bảng lớn thì đưa sang SQL/warehouse (lab: 31–60 s so với 3,5–5,1 s trên PostgreSQL).

Phụ lục: lab đã chạy

Môi trường: MongoDB 9.0.2 (mongo:9.0.2, replica set 1 node rs1, WiredTiger cache 0,25 GB, --memory 1g); 8.0.32 (tmp-mdb-80, chỉ seed orders/customers/products); node rỗng không giới hạn RAM / --memory 6g để đọc giá trị mặc định; node tmp-mdb-disk có <dbpath>/_tmp là tmpfs 700 MB; PostgreSQL 18.4 (my-test-pgdb, bảng tmp_mdb_* đã drop); PHP 8.3 + ext-mongodb 2.5.3 + mongodb/laravel-mongodb 5.11.0; Node 22 + mongoose 9.10.4. Bộ dữ liệu chung seed bằng js/seed.js (124,6 s, out/05-seed.txt); index thêm createdAt_1, status_1 (phần nào cần index khác tự tạo rồi xoá). Lấy mẫu heap: js/05-rss.js đọc serverStatus({tcmalloc: 1}) mỗi 300 ms trong container client riêng, mongod restart trước mỗi phép đo.

cd labs/mongodb
./05-aggregation.sh              # chạy hết (~100 phút): seed setup optimizer … report pg pg_drop v80 slowlog diskfull
./05-aggregation.sh heap         # 1 phần
(source lib.sh && mdb_clean)    # dọn container sau khi xong

Bẫy khi chạy lại: container 1 GB bị OOM-kill ở memory, oplimit, facet, jsfn nếu chạy liền — script restart mongod trước mỗi phần nặng và để lệnh gây OOM ở cuối; PHP cần -d memory_limit=512M.

Chưa verify: nguyên nhân chính xác của heap +598 MB ở sort classic (mới có giả thuyết, chưa đọc source sorter); load shedding theo RSS (queryMemoryLoadShedding*, tắt mặc định); nhiều báo cáo chạy song song; $lookup/$merge trên sharded cluster (bài 9); số đo ở quy mô lớn hơn 2 triệu document và RAM lớn hơn 1 GB; spill engine của SBE khi đĩa gần đầy (lab chỉ bắt được lỗi ở sorter classic); hành vi trên Atlas.