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): READMEsrc/mongo/db/pipelinenhá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/$addToSetkhông spill; 9.0 thêm trầnmax(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$matchlên trước (9.0: qua cả$lookup), gộp$sort+$limitthành top-k, gộp$lookup+$unwind; phần đầu thànhfinddù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 đó:
- 3.2:
$lookup— left outer join giữa hai collection (blog MongoDB). - 4.2:
$merge— ghi tăng dần vào collection có sẵn, tức on-demand materialized view (docs 4.2). - 5.0:
$setWindowFields(window function); map-reduce bị deprecated: "Aggregation pipelines provide better performance and usability than map-reduce" (manual). - 5.2 / 6.0:
$grouprồi$lookupchạy trên SBE (slot-based execution engine) khi mọi stage trước nó cũng chạy được trên SBE (aggregation pipeline optimization). - 8.2: số liệu spill chuẩn trong explain; "if disk space is running low, MongoDB fails queries that are spilling to disk" (release notes 8.2).
- 9.0: giới hạn bộ nhớ mỗi operation; optimizer dời được cả phép tính lẫn
$matchlên trước$lookup("New in version 9.0");$function/$accumulator/$wherehết deprecated, chạy trên engine WebAssembly (release notes 9.0).
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:
- Streaming (chảy thẳng): nhận một, nhả ngay —
$match,$project/$addFields/$set,$unwind,$lookup(mỗi document trái một lần tra),$limit,$skip. Bộ nhớ cỡ một document. - Blocking (chặn): phải đọc hết đầu vào mới nhả được document đầu tiên —
$sort(khi index không cho sẵn thứ tự),$group,$bucketAuto,$setWindowFields(theo partition),$facet(đệm đầu vào). Bộ nhớ theo cỡ đầu vào hoặc theo số nhóm.
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ết | Optimizer làm | Explain / số đo |
|---|---|---|
(a) $addFields → $project → $match createdAt | Dời $match lên đầu → thành find | FETCH ← 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 $group | GROUP ← 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 10 | Gộp $limit vào $sort = top-k | SORT limit=10: chỉ giữ 10 document |
(g) $lookup → $unwind mảng vừa join → $match c.tier | Gộp cả ba vào $lookup | $lookup {…, unwinding, pipeline: [{$match: {tier}}]} |
(h) $sort {customerId, createdAt: -1} → $group $first | Khô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 $lookup | EQ_LOOKUP ← MATCH yr ← PROJECTION ← IXSCAN status_1; 1,2–2,2 s, chặn optimizer 36 s |
$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:
- Đ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ảngcreatedAt: {$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. - 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). - Nhảy bản: case (i) trên 8.0.32 không dời phép tính qua
$lookup— lab05-v80.txt,$lookup → $addFields hour → $match {hour: 3}: 8.0.32 chạy$lookupcho cả 2 triệu đơn (docsExamined 4 000 000, 33,2 s), 9.0.2 dời$matchlê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. - 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:
| Pipeline | Engine (explainVersion) | Vì sao |
|---|---|---|
$match → $group | SBE (2) | Luật 5.2+ |
$match → $lookup (localField/foreignField) | SBE, stage EQ_LOOKUP | Luật 6.0+ |
$match → $lookup → $unwind (bảng phải có index) | classic (1) | featureFlagSbeEqLookupUnwindIndexedLoopJoin=false |
$lookup bảng nhỏ không index → $unwind | SBE, HashJoin | featureFlagSbeEqLookupUnwindHashJoin=true (9.0) |
$lookup dạng let/pipeline | classic | featureFlagLookupWithPipelinesInSbe=false |
$match → $sort → $project | classic | Khô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ống | Strategy | Document đọc | Thời gian |
|---|---|---|---|
5 khách → orders.customerId, chưa có index | NestedLoopJoin | 10 000 005 | 8,2 s |
20 khách, có customerId_1 | IndexedLoopJoin | 1 645 | 0,7 s |
| 2 000 khách, có index | IndexedLoopJoin | 119 072 | 8,1 s |
200 000 khách → cities (60 doc), không index | HashJoin | 200 060 | 0,59 s |
như trên, có index cities.name | IndexedLoopJoin | 400 000 (+200 000 key) | 2,0 s |
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ấn | Thời gian | Spill | Byte theo dõi (explain) |
|---|---|---|---|
$group theo status (5 nhóm) | 1,9 s | không | ~0 |
$group theo khách (199 480 nhóm) | 4,3 s | không | 25,7 MB |
$group theo _id (2 triệu nhóm) | 32,9 s | 3 lần, 127,8 MB → 46,3 MB trên đĩa | 100 MB |
$sort total, giữ cả document | 8,2 s | 9 lần, 714,1 MB → 186,3 MB | 99 MB |
$sort total + $limit 10 (top-k) | 1,3 s | khô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):
| Node | memLimitMB | Trần mỗi operation |
|---|---|---|
9.0.2, --memory 1g | 1 024 | 1 024 MB — bằng cả container |
9.0.2, --memory 6g | 6 144 | 1 228,8 MB (20%) |
| 9.0.2, không giới hạn (máy 7,9 GB) | 7 941 | 1 588,2 MB (20%) |
| 8.0.32 | 1 024 | khô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.
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ết | Khô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 field | 7,3 s; spill 3 lần | DISTINCT_SCAN, 10,2 s |
$top với $$ROOT | 78,9–79,4 s; SBE spill 7 lần (370 MB) | 81,4 s — $top không dùng index |
$top 2 field | 8,5 s; không spill (51,9 MB) | 9,1 s |
- Spill của SBE với giá trị lớn rất đắt: cùng
$top $$ROOT, nâng ngưỡng spill SBE lên 400 MB thì 9,1 s (không spill, theo dõi 147,8 MB, heap +147 MB); để mặc định 100 MB thì 79,4 s (heap +224 MB); classic 27,3 s (spill 13 lần, 1,2 GB, heap +295 MB). Spill giữ mongod sống, đổi bằng thời gian ×3–9. - Giữ ít field trong nhóm.
$$ROOTlà cả document 357 B mỗi khách; hai field thì không cần spill. - Index đúng biến
$sort + $firstthànhDISTINCT_SCAN(nhảy qua từng khách, đọc một key). Nhưng 199 480 lần FETCH ngẫu nhiên với cache 0,25 GB chậm hơn quét tuần tự rồi sort — trong lab này index làm chậm đi. Đo trên dữ liệu và RAM thật trước khi tin "có index là nhanh".
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):
- Nhánh không dùng được index. Đầu vào của
$facetlà phần pipeline trước nó; nếu$facetđứng đầu thì đó là COLLSCAN cả collection. Hai số cho dashboard (đơn pending, đơn tuần qua):$facet2 nhánh 2,9–3,8 s; hai lệnh đếm riêng 23–88 ms (COUNT_SCAN); thêm$match {$or: […]}trước$facetcòn 0,67–0,84 s. - "Trang 1 + tổng số" bằng
$facet: {data: [$limit 20], total: [$count]}đẩy mọi đơn khớp qua$facet: lọc 1 tuần 59–68 ms (lệnhfind … limit 20riêng 5–8 ms); lọcstatus: "delivered"(65%) 3,4–6,6 s so với 6–12 ms. - Một document cho cả kết quả. 30 ngày (161 006 đơn, 3 field) vừa: document 13,7 MB. 40 ngày (213 173 đơn):
BSONObjectTooLarge (10334): … Serializing Document failed :: caused by :: Size 19108813 exceeds maximum 16793600(05-facet2.txt). 90 ngày (462 751 đơn): mongod bị OOM-kill trước khi kịp báo lỗi kích thước (05-oom-dmesg.txt, lần 5) —internalQueryFacetMaxOutputDocSizeBytescho document trung gian tới 100 MB.
$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 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 được | Bẫy |
|---|---|---|
$out | Dựng lại 5,8–6,2 s; index tạo tay được chép sang; UUID đổi mỗi lần | Cursor đọ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 |
$merge | Dựng đủ 7,8 s; thêm 2 000 đơn ngày 30/09, chạy lại chỉ ngày đó 27 ms | on 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 ms | Lọ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
- laravel-mongodb 5.11:
sum()/avg()là một$group({$group: {aggregate: {$sum: "$total"}, _id: null}}). Trên Decimal128 nó trả về objectMongoDB\BSON\Decimal128:$s + 1→TypeError: Unsupported operand types: MongoDB\BSON\Decimal128 + int.sum('items.qty')trả0, không lỗi —$sumcủa accumulator bỏ qua giá trị là mảng. Cần$unwindhoặc$sumtrong$projecttrước (05-laravel.txt). groupBy('status')->get(['status'])sinh{$group: {_id: {status: "$status"}, status: {$last: "$status"}}}, dòng ra có thêm"id": {"status": …};Model::raw(fn ($c) => $c->aggregate([...]))trả Eloquent Collection các model — khoá nhóm thànhidcủa model ("id": "delivered"), dễ lỡ taysave().- Lab PHP với
memory_limit128M (mặc định image): dựng 5 476 model đơn + khách thì tiến trình thoát mã 139, không in lỗi; 512M chạy bình thường. - Mongoose 9:
Model.aggregate()không ép kiểu theo schema (docs: "Mongoose does not cast aggregation pipelines").find({customerId: "42"})→ 484 đơn;aggregate([{$match: {customerId: "42"}}])→ 0;{_id: "<chuỗi hex>"}→ 0, phảinew mongoose.Types.ObjectId(…). Kết quả là object thường (không có.save, không getter/virtual) (05-mongoose.txt).
4. Đảm bảo và giới hạn
| Hạng mục | Giá trị (9.0.2, lab) | Ghi chú |
|---|---|---|
| Thứ tự stage | Optimizer đổ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ặn | 100 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/$addToSet | 100 MiB, không spill | ExceededMemoryLimit (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 byte | Document trung gian: $lookup, $facet tới 100 MB |
| Spill ra đĩa | Cầ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 pipeline | 1 000 stage (internalPipelineLengthLimit) | Pipeline sinh tự động (builder) mới chạm tới |
$lookup | Left outer join; HashJoin khi bảng phải ≤ 10 000 doc và ≤ 100 MB | Không chọn thứ tự join; có index thì luôn IndexedLoopJoin |
$out | Thay collection nguyên khối khi xong; chép định nghĩa index | Cursor đang đọc bản cũ bị giết (mục 3.7) |
$merge | Upsert theo on; cần unique index trên on | Khô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 s | internalQueryJavaScriptHeapSizeLimitMB, …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.
| Failure | Hệ 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 + recovery | dmesg, 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 MB | Chậm ×3–9, đọc lại spill gấp ~20 lần lượng ghi | spills trong explain/slow log / top-k, lọc sớm, allowDiskUse: false ở API |
| Đĩa trống dưới 500 MB | OutOfDiskSpace (14031) dù đĩa chưa đầy | Cả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ớn | Chậm theo N × M, nặng dần khi dữ liệu lớn | strategy: NestedLoopJoin / index foreignField |
$out / $merge giữa chừng | Cursor đọ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ỗi | Test 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
- Metric:
serverStatus().metrics.query.<stage>.spills/spilledBytes/spilledDataStorageSize(group, sort, setWindowFields, hashJoin…),operationsFailedDueToMemoryLimit,peakMemoryUsageOperation(9.0),spillWiredTiger, vàmem.residentso vớihostInfo().system.memLimitMB. Spill/phút tăng = báo cáo vừa vượt 100 MB, chậm đi vài lần mà không lỗi. Slow log có sẵn số spill (05-slowlog.txt,$group2 triệu nhóm):"groupSpills": 2, "peakTrackedMemBytes": 104857686, "queryFramework": "sbe", "spillStorage": {"data": {"bytesRead": 7844640732, "bytesWritten": 387446728}}, "durationMillis": 29748— ghi 387 MB nhưng đọc lại 7,8 GB từ spill engine: lý do spill trên SBE đắt. - Tính RAM cho node báo cáo: cache WiredTiger + cache spill engine (≥ 100 MB) + số báo cáo song song × heap thật mỗi báo cáo (lab: 73–598 MB cho một truy vấn "100 MB") + phần RSS tcmalloc chưa trả. Container 1 GB, cache 0,25 GB không đủ cho một sort classic 2 triệu document.
- Tách tải: pipeline nặng chạy trên secondary dành cho phân tích (hidden node, tag — bài 7) hoặc trên bảng materialize; API không được có pipeline mà phần đầu không dùng index.
- Chặn:
maxTimeMScho mọi aggregate; shape nguy hiểm thì query settings (reject, 9.0 cómaxTimeMStheo shape — bài 4) hoặcqueryKnobshạ trần bộ nhớ;allowDiskUse: falseở đường đi của API (lỗi sớm 292 thay vì chậm 9 lần). - Tham số
internalQuery…là nội bộ, không cam kết giữ qua các bản; chỉ đổi khi đã đo, ghi lại vì sao. Nâng ngưỡng trong container nhỏ = OOM. - Nâng cấp: chạy lại
explaincủa pipeline quan trọng trước/sau — luật viết lại và feature flag SBE đổi theo bản. Trực lúc 3 giờ sáng:db.currentOp({"command.aggregate": {$exists: true}})→killOp→ slow log của shape đó.
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.2 | PostgreSQL 18.4 |
|---|---|---|
| A. Doanh thu theo ngành hàng × tháng | A1 ($lookup từng dòng hàng): 52–60 s, classicA2 (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ên | 4,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 A | 517 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ục | Aggregation | SQL (PostgreSQL) | Join ở ứng dụng | Materialize |
|---|---|---|---|---|
| Lọc + gom nhóm 1 collection | Tốt (index, SBE) | Tốt | — | Tốt nhất khi đọc nhiều |
| Join nhiều bảng lớn | Yếu: không chọn thứ tự join, HashJoin ≤ 10 000 doc | Mạnh: cost-based, hash join, song song | Ổn khi bên phải ít | Né join lúc đọc |
| Bộ nhớ | 100 MB/stage, heap thật khó đoán | work_mem/node, EXPLAIN báo spill | RAM của PHP/Node | Đĩa |
| Độ tươi · độ phức tạp | Tức thời · pipeline dài khó review | Tức thời · SQL quen thuộc | Tức thời · N+1 nếu quên eager load | Trễ theo lịch · thêm job |
| Tuyển người · hệ sinh thái | Hẹp hơn, ít tool BI | Phổ biến, BI kết nối thẳng | Phổ 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
- Báo cáo là workload riêng, cần chủ. Mỗi pipeline định kỳ (job
$merge, dashboard) có người chịu trách nhiệm,maxTimeMS, số đo trước/sau, chạy trên node phân tích. Gom về một chỗ trong code (query object/repository), không rải pipeline viết tay trong controller. - Review bằng explain, không bằng mắt. Checklist PR: thứ tự sau khi viết lại; phần đầu có dùng index;
explainVersion;strategycủa từng$lookup;spills; số document qua mỗi stage chặn — trên dữ liệu lệch như thật. - Dữ liệu trước, pipeline sau.
$lookupmỗi dòng hàng để lấy category là thiếu extended reference;$unwind+$groupvề lại chính document là nên tính sẵn (computed pattern) — bài 2. - Materialized view là hợp đồng dữ liệu: độ trễ chấp nhận được, ai chạy lại, chạy lại thế nào khi job chết giữa chừng (
$mergekhông nguyên khối → idempotent theo ngày). - Onboarding người từ SQL: thứ tự join là thứ tự viết; optimizer chỉ áp luật. Dạy 3 thứ: đọc explain pipeline, stage streaming/chặn, bảng chiến lược
$lookup. - Rủi ro dài hạn, exit strategy: pipeline là ngôn ngữ riêng, tham số
internalQuery…và hành vi SBE đổi theo bản. Giữ logic báo cáo ở một lớp (dễ viết lại thành SQL); khi báo cáo cần join nhiều bảng hoặc chạy hàng phút, đẩy dữ liệu sang kho phân tích qua change stream/CDC (bài 10) — tín hiệu đã dùng sai công cụ.
9. Anti-patterns và bài học thực tế
- Viết pipeline như SQL:
$lookuptừ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. $lookupvào collection lớn không indexforeignField— nổ trễ: nhanh khi dữ liệu nhỏ, chậm theo N × M khi cả hai cùng lớn.$unwindrồi$groupvề lại chính document — 20,8 s và spill 1,1 GB cho việc$reducelàm trong 10,5 s.$facetcho "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.- Lọc ngày bằng
$expr+$year/$month— COLLSCAN 1,8–2,1 s thay vì 32–46 ms. $projectsớm "để đỡ RAM" — optimizer tự làm; lab còn thấy$project → $sortclassic tốn heap hơn sort cả document (+484 MB so với +119 MB).$push: "$$ROOT"để "lấy kèm chi tiết" — không spill được: lỗi 146 hoặc 10334.- Nâng
internalQuery…để "hết spill" trong container nhỏ — OOM-kill. $outvào collection API đang đọc — cursor đang mở chết.- JS phía server cho việc biểu thức gốc làm được —
$functionchậ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
- 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. $matchsau$groupcó đượ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.- 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/$addToSetkhông spill; 9.0 thêm trần mỗi truy vấnmax(1 GB, 20% RAM cgroup). RAM thật có thể gấp ~6. - 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.
$lookupchậm khi nào? — Không index ởforeignFieldvà bảng phải > 10 000 doc → NestedLoopJoin N × M. Có index → IndexedLoopJoin; bảng nhỏ → HashJoin.- Mảng
asquá 16 MB? — Trung gian được tới 100 MB;$unwindngay sau thì không tạo mảng; document trả về > 16 MB thì lỗi 10334. $facetcó dùng index? — Không cho từng nhánh;$matchtrước$facethoặc chạy lệnh riêng.- Materialized view? —
$outthay nguyên collection (giữ index, giết cursor đang đọc);$mergeupsert tăng dần (cần unique index trênon, không nguyên khối); view thường chạy lại pipeline mỗi lần đọc. $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.- 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.