Query engine — explain, multi-planner, plan cache, Cost-Based Ranker và khi plan đổi làm query chậm
Cùng một câu find lọc theo trạng thái và khoảng ngày, cùng dữ liệu, cùng index: ép index đúng thì 6 ms, để planner tự chọn thì 2,2 s.
Không có bug nào. Planner của MongoDB chọn plan bằng cách cho các plan chạy thử tới 101 kết quả đầu, và plan về đích trước không phải plan rẻ nhất cho cả truy vấn.
Kết quả thi được nhớ theo hình dạng truy vấn, nên hôm nay nhanh, mai chậm, tuỳ giá trị nào chạy trước. Bài này mổ bộ máy chọn và chạy plan: explain, multi-planner,
plan cache, Cost-Based Ranker (mặc định từ 8.3), classic/SBE/express, cursor — và các cách ghim, chặn, quan sát khi plan đổi lúc 3 giờ sáng. Cấu trúc index (B-tree, ESR, multikey,
covered) đã ở bài 3, ở đây chỉ link. Mọi con số là output thật trên MongoDB 9.0.2 với bộ dữ liệu chung (shop.orders 2 000 000 đơn), so với 8.0.32.
Thuật ngữ dùng trong bài
query shape (hình dạng truy vấn: filter/sort/projection đã bỏ giá trị cụ thể) · plan cache (bộ nhớ plan đã thắng, theo shape, mỗi mongod một bộ) · multi-planner (cho các plan ứng viên chạy thử xen kẽ rồi chọn) · work (một bước nhỏ của plan: đọc 1 key, 1 document…) · trial period (lượt thi) · CBR — Cost-Based Ranker (xếp hạng plan theo chi phí ước lượng) · cardinality estimation, CE (ước lượng số dòng qua mỗi bước) · SBE (slot-based execution engine, bộ máy chạy mới) · express path (đường tắt cho tìm theo_id) · query settings (cấu hình gắn với một query shape trên cả cluster) · cursor / batch / getMore (con trỏ kết quả ở server, mỗi lần trả một lô) · keyset pagination (phân trang theo mốc, không dùng skip).
Kiểm chứng: Context7 ngày 08/10/2026 — README trong source server (plan_cache,classic_runtime_planner,classic_runtime_planner_for_sbe,README_explain) và manual (query plans, query shapes, cursors, sessions); source nhánhv9.0trên GitHub (plan_ranking/cbr_for_no_mp_results.cpp,cbr_plan_ranking.cpp,query_optimization_knobs.idl,sampling_estimator_impl.cpp,cursor_manager.cpp,logical_session_cache_impl.cpp); release notes 8.3 và 9.0; sourcelaravel-mongodb5.11.0. Output ởlabs/mongodb/out/04-*.txt. Máy 4 vCPU dùng chung, cache WiredTiger 0,25 GB — số ms chỉ để so tỉ lệ.
Trả lời 30 giây
Planner MongoDB là thực nghiệm có trí nhớ: liệt kê plan từ các index dùng được, cho chúng chạy thử xen kẽ tới khi một plan đủ 101 kết quả (hoặc chạy xong, hoặc hếtmax(10 000, 0,3 × số document)works), chấm điểm theoadvanced/works, rồi nhớ plan thắng theo query shape trong plan cache. Lần sau dùng lại; nếu lượt thử đầu tốn quá 10 × works đã ghi thì replan. Hệ quả: plan được chọn theo 101 kết quả đầu chứ không theo cả truy vấn (lab: 1 300 209 key, 2,2 s thay vì 3 607 key, 6 ms), và cùng shape có thể đổi plan theo giá trị chạy trước. Từ 8.3, khi không plan nào ra kết quả trong lượt thi ngắn, CBR lấy mẫu ~384 document để ước lượng chi phí (lab 8.0 → 9.0: thời gian lập plan 3,8–8,1 s → ~1 s). Ghim/chặn bằngsetQuerySettings(index,reject, 9.0 thêmmaxTimeMS) thay vìhinttrong code. Phân trang dùng keyset, không dùng skip (trang 100 000 = 2 triệu key). Cursor của driver luôn gắn session: chết sau 30 phút session idle, kể cả cónoCursorTimeout.
1. Bài toán gốc và bối cảnh ra đời
Có nhiều index dùng được cho một truy vấn thì phải chọn một. Database SQL giải bằng cost-based optimizer (bộ tối ưu theo chi phí): ANALYZE thu thống kê
(histogram, giá trị phổ biến), rồi ước lượng chi phí từng plan (xem series query plan của PostgreSQL). MongoDB ra đời không schema:
cùng field có thể là số ở document này, chuỗi ở document kia, mảng ở document khác — thống kê kiểu SQL khó dựng và dễ cũ. Nên MongoDB chọn cách thực nghiệm:
không đoán, cho các plan chạy thử trên dữ liệu thật, đo, rồi nhớ kết quả.
- Plan cache có trạng thái inactive/active từ 4.2 (sửa SERVER-32452, fix version 4.1.1: "Replanning may not occur when a plan with an extremely high 'works' value is cached").
- SBE có từ 5.1 (manual); mặc định hiện nay chỉ dùng cho pipeline có
$group/$lookup. - 8.0: query settings (
setQuerySettings), index filter bị deprecated,planCacheShapeHashthay tên choqueryHash, express path cho_id(stageEXPRESS_*— "New in version 8.0", explain results). - 8.3: "multi-planning with a cost-based ranker backup is the default plan selection mechanism for eligible queries" (release notes 8.3).
- 9.0:
querySettingstruyền thẳng trên lệnh,queryKnobsvàmaxTimeMStheo shape,$queryStatsbật mặc định lấy mẫu 1% (release notes 9.0).
Tối ưu cho: chọn đúng khi một plan rõ ràng tốt hơn ở ngay những kết quả đầu; không cần thống kê; plan cache làm truy vấn lặp lại gần như không tốn lập plan. Không tối ưu cho: truy vấn mà "nhanh ở đầu" khác "rẻ cho cả truy vấn" (lọc sau FETCH, dữ liệu lệch), truy vấn trả 0 kết quả trên khoảng lớn (thi rất lâu), và workload mà cùng shape nhưng giá trị khác nhau cần plan khác nhau.
2. Mô hình tư duy cốt lõi
Planner = một cuộc thi ngắn có trí nhớ. Thi: các plan ứng viên chạy xen kẽ từng work, ai ra kết quả đều nhất thì thắng. Trí nhớ: plan thắng được gắn với query shape (không gắn với giá trị) và được kiểm tra lại rẻ bằng ngưỡng 10 × works. Mọi hành vi khác suy ra từ hai ý này.
flowchart LR P["find / aggregate
parse + chuẩn hoá"] --> S["query shape
queryShapeHash · planCacheShapeHash"] S --> X{"_id = X hoặc
unique = X?"} X -->|"có"| E["express executor
EXPRESS_IXSCAN"] X -->|"không"| C{"plan cache
có entry ACTIVE?"} C -->|"có"| T{"chạy thử: dưới
10 × works?"} T -->|"đạt"| R["chạy plan
classic hoặc SBE"] T -->|"vượt: replan"| EN C -->|"không"| EN["liệt kê plan
áp query settings"] EN --> RK["thi: multi-planner
9.0: CBR dự phòng"] RK --> R R --> B["cursor: batch 101 doc
getMore tối đa 16 MiB"]
| Hành vi | Suy ra từ | Lab |
|---|---|---|
| Chọn plan "nhanh ở đầu" chứ không "rẻ cả bài" | Thi dừng ở 101 kết quả | status_1: 2,2 s thay vì 6 ms |
| Cùng shape, plan đổi theo giá trị chạy trước | Trí nhớ theo shape, không theo giá trị | A trước: 2,2 s; B trước: A 37 ms |
| Replan khi plan cache tệ hẳn | Ngưỡng 10 × works | "took at least 1010 works" |
| Truy vấn 0 kết quả lập plan rất lâu (8.0) | Không ai về đích, thi tới hết 0,3 × N works | planning 3,8–8,1 s |
| CBR chỉ chạy cho một nhóm nhỏ truy vấn | Chỉ khi lượt thi ngắn không ra kết quả nào | cbr.count +0 với truy vấn thường |
| Ghim plan không cần sửa code | Query settings gắn với shape | 3 607 key, 6 ms cho mọi giá trị |
3. Internals và quyết định thiết kế
3.1 Ba cái hash và ba mức explain
Một truy vấn có ba định danh, đọc log phải phân biệt (lab 04-explain.txt, 04-slowlog.txt, 04-v80.txt):
| Field | Là gì | Lab |
|---|---|---|
queryShapeHash (8.0+) | SHA-256 của query shape đầy đủ; khoá của query settings và $queryStats | 1D50D7ED… — giống nhau trên 8.0.32 và 9.0.2 |
planCacheShapeHash (= queryHash cũ) | Hash 8 ký tự của shape theo plan cache | 2B1A8DA0 (8.0) vs C28DB142 (9.0) — đổi khi nâng cấp |
planCacheKey | Shape + bộ index hiện có | 29E0A5B1 → 44D26D66 sau khi thêm 1 index |
Truy vấn mẫu xuyên bài: find({status: "delivered", createdAt: tháng 10/2024}) trên index status_1, createdAt_1 — khớp 2 354 đơn.
explain("queryPlanner") chỉ lập plan, không chạy
winningPlan : FETCH ← IXSCAN status_1 filter sau FETCH: createdAt trong tháng 10/2024
rejectedPlan: FETCH ← IXSCAN createdAt_1 filter: status = delivered
explain("executionStats") chạy plan thắng tới hết
nReturned 2354, totalKeysExamined 1300209, totalDocsExamined 1300209, executionTimeMillis 2209
FETCH: works 1300210, advanced 2354, needTime 1297855
explain("allPlansExecution") thêm số liệu từng plan TRONG LƯỢT THI
plan status_1 : score 2.0002, works 101, advanced 101, needTime 0
plan createdAt_1: score 1.6141, works 101, advanced 62, needTime 39
Đọc tỉ lệ keysExamined : docsExamined : nReturned = 1 300 209 : 1 300 209 : 2 354 — đọc 552 document mới ra 1. Đó là dấu hiệu chính của plan sai (bài 3 có các tỉ lệ đẹp).
allPlansExecution là mức duy nhất cho thấy vì sao plan thắng: ở đây cả hai plan mới đi 101 works.
Bẫy:explainbỏ qua plan cache và không ghi vào cache (manual: "Using explain ignores all existing plan cache entries"). Explain nhanh mà production chậm thường là do production đang dùng plan trong cache — xem$planCacheStatsvà slow log (fromPlanCache).
3.2 Multi-planner: thi bằng "works", dừng ở 101 kết quả
Mỗi plan là một cây stage; gọi work() một lần thì stage làm một việc nhỏ và trả về ADVANCED (ra 1 kết quả), NEED_TIME (đã làm việc nhưng chưa ra kết quả)
hoặc IS_EOF. Multi-planner gọi work() xen kẽ trên mọi plan. Thi dừng khi một plan: ra đủ 101 kết quả (internalQueryPlanEvaluationMaxResults),
hoặc chạy xong, hoặc mọi plan hết max(10 000, 0,3 × số document) works (internalQueryPlanEvaluationWorks,
internalQueryPlanEvaluationCollFraction; 2 triệu đơn → 600 000 works mỗi plan). Điểm (README classic_runtime_planner):
score = 1 + advanced/works + eofBonus (1 nếu chạy xong trong lượt thi) + tie-breakers (ít FETCH, ít key, nhiều equality hơn)
advanced/works trong lượt thi. Đơn delivered chiếm 65% nên index status_1 ra 101 kết quả
sau đúng 101 works; createdAt_1 phải bỏ 39 đơn không phải delivered. Nhưng tháng 10/2024 chỉ có 2 354 đơn delivered nằm rải trong 1,3 triệu key của status_1.
Số liệu: 04-explain.txt.Plan "cận thị" có hại tới đâu tuỳ khoảng thời gian (3 lần mỗi ô, executionTimeMillis):
| Khoảng | Đơn khớp | Planner tự chọn | hint createdAt_1 | hint status_1 |
|---|---|---|---|---|
| Tháng đầu (10/2024) | 2 354 | status_1 — 1 300 209 key, 2 237–2 540 ms | 3 607 key, 6–7 ms | 2 096–2 413 ms |
| Quý đầu | 20 589 | status_1 — 2 199–2 350 ms | 31 766 key, 38–45 ms | 2 264–2 692 ms |
| Tháng cuối (9/2026) | 104 434 | createdAt_1 — 161 006 key, 365–558 ms | 178–287 ms | 2 256–2 647 ms |
| 8.0.32, tháng đầu | 2 354 | status_1 — 2 910–3 109 ms | 5–6 ms | 2 495–3 317 ms |
Tháng cuối thì planner đúng: dữ liệu dồn về gần hiện tại nên 101 key đầu của createdAt_1 đã đủ delivered. Cùng logic chấm điểm, kết quả đúng hay sai tuỳ phân bố dữ liệu
ở đoạn đầu của mỗi index. Cách chữa gốc là index khớp truy vấn ({status: 1, createdAt: 1} — E rồi R, xem bài 3) để chỉ còn một ứng viên tốt;
ghim plan (mục 3.6) là cách chữa cháy.
3.3 Plan cache: nhớ theo shape, replan ở 10 × works
README plan_cache và manual mô tả ba trạng thái của một entry: missing → lập plan, tạo entry inactive kèm số works của plan thắng →
lần sau cùng shape lại lập plan; nếu works mới ≤ works đã ghi thì entry thành active, nếu lớn hơn thì works của entry tăng lên min(works mới, 2 × works cũ)
(internalQueryCacheWorksGrowthCoefficient 2). Entry active được dùng ngay, nhưng vẫn chạy một lượt thử đầu: nếu plan cần quá 10 × works
(internalQueryCacheEvictionRatio) mới đủ 101 kết quả thì bỏ entry, lập plan lại (replan). Cache mất khi restart, khi tạo/xoá/ẩn index, khi LRU đẩy ra
(internalQueryCacheMaxEntriesPerCollection 5 000), hoặc planCacheClear.
04-cache.txt (9.0.2; 8.0.32 cho đúng cùng chuỗi trạng thái và works). Replan chỉ bảo vệ truy vấn bị plan cache làm chậm
ở lượt thử đầu (B); nó không cứu A, vì với A plan sai lại trông tốt nhất trong 101 kết quả đầu.| Bước (9.0.2) | Thời gian | Nguồn plan (profiler) | Entry sau bước |
|---|---|---|---|
| s1–s3: A ×3 | 2 231 / 2 182 / 2 042 ms | multi-planner, multi-planner, fromPlanCache | inactive → ACTIVE status_1, works 101 |
| s4: B | 127 ms | REPLANNED: "expected trial execution to take 101 works but it took at least 1010 works" | inactive createdAt_1, works 202 |
| s5–s6: A ×2 | 2 290 / 2 023 ms | multi-planner, fromPlanCache | ACTIVE status_1, works 101 |
| r1–r4: B, B, A, A (cache rỗng) | 58 / 53 / 37 / 49 ms | A lấy createdAt_1 từ cache | ACTIVE createdAt_1, works 149 |
Bẫy nổ trễ: sau mỗi lần deploy có tạo index, restart hay failover, plan cache trống — truy vấn nào đến trước quyết định plan cho cả shape tới lần xoá cache sau. Benchmark ở staging (chạy A trước hay B trước) không nói gì về production. Mỗi mongod có plan cache riêng: primary và secondary có thể đang chạy hai plan khác nhau cho cùng shape.
3.4 Cost-Based Ranker trong 9.0: dự phòng khi cuộc thi không ra kết quả
Đọc source nhánh v9.0 (plan_ranking/cbr_for_no_mp_results.cpp) thì "CBR mặc định" không thay multi-planner. Chế độ mặc định là
internalQueryPlanRanker: "mixed" + internalQueryMixedPlanRankingStrategy: "NoMultiplanningResults":
flowchart TD A["2 plan ứng viên trở lên"] --> B["thi ngắn: tổng 10 000 works
chia đều cho các plan"] B --> C{"có plan ra kết quả
hoặc chạy xong?"} C -->|"có (đa số truy vấn)"| D["multi-planner chọn như cũ
planRanker: mp"] C -->|"không plan nào ra kết quả"| E["CBR: lấy mẫu ~384 document
ước lượng cardinality và cost"] E --> F{"chọn được 1 plan?"} F -->|"có"| G["chạy plan CBR chọn
planRanker: cbr"] F -->|"không: ước lượng 0, cost vô cực"| H["quay lại multi-planner
thi tiếp tới hạn mức"]
Ước lượng bằng gì: internalQueryCBRCEMode có samplingCE / histogramCE / heuristicCE / exactCE (test), nhưng chế độ
mixed luôn ép samplingCE (truy vấn luôn đúng/luôn sai thì dùng heuristic). Cỡ mẫu = z² / (2 × sai số)² = 1,96² / 0,1² ≈ 384 document
(samplingConfidenceInterval 95, samplingMarginOfError 5), lấy theo internalQuerySamplingCEMethod: "chunk" — 10 đoạn liên tiếp, mỗi đoạn ~38 document từ
một vị trí ngẫu nhiên. Ước lượng = (số document mẫu khớp / cỡ mẫu) × numRecords. Histogram chỉ dùng khi tự bật histogramCE và chạy lệnh analyze (chưa verify).
Lab 04-cbr.txt | Kết quả |
|---|---|
| Truy vấn A (có plan ra kết quả ngay) | metrics.query.cbr.count +0 — CBR không chạy |
S = {status: "pending", createdAt ≥ 1/10/2025} sort total limit 10: mọi plan phải SORT chặn → 0 kết quả trong lượt thi | count +1,
choseWinningPlan +1 → status_1, 39 907 key, 609–738 ms. Ước lượng IXSCAN 21 052–52 632 key (thật 39 907), mẫu 380 document |
Ép planRanker: costBased cho A, mẫu chunk, 8 lần | 8/8 lần ước lượng createdAt_1 = 0 key (thật 3 607) → cost DBL_MAX,
numPlansFailedCostEstimation +1 → quay về multi-planner → status_1, 1,8–2,2 s |
Như trên, mẫu random, 8 lần | 3/8 lần thấy 1–3 document → ước lượng 5 208 / 15 625 → chọn createdAt_1 (25–37 ms); 5/8 lần ước lượng 0 → status_1 |
Vì sao mẫu chunk luôn ra 0: document được chèn theo thời gian nên một đoạn 38 document liền nhau chỉ phủ vài phút. Tháng 10/2024 chiếm 0,18% dữ liệu → xác suất một trong 10
đoạn rơi vào đó ~1,8%. Điều kiện khớp dưới ~1/384 (0,26%) dữ liệu dễ ra ước lượng 0; mẫu chunk trên dữ liệu chèn theo thời gian thì gần như luôn 0 (lab 8/8). Explain khi CBR chọn có thêm ceSamplingMetadata
(sampleTechnique: 'chunk', sampleRequestedDocCount: 384, sampleDocCount: 380) và mỗi stage có cardinalityEstimate,
costEstimate, estimatesMetadata.ceSource (Sampling/Metadata).
CBR có lợi nhất ở chỗ multi-planner tệ nhất: truy vấn không ra kết quả nào. Z = {status: "delivered", createdAt ≥ 1/1/2025, paymentMethod: "crypto"}
(0 kết quả, paymentMethod không có index), cache xoá trước mỗi lần (04-v80.txt):
| Bản / chế độ | planningTimeMicros | Tổng | Plan, key |
|---|---|---|---|
| 8.0.32 (chỉ multi-planner) | 3,8–8,1 s (6 lần) | 5,4–10,3 s | status_1, 1 300 209 key — cùng plan |
9.0.2 ép multiPlanning | 2,8–3,3 s | 3,9–4,5 s | |
9.0.2 mặc định (planRanker: cbr) | 0,95–1,05 s | 2,1–2,3 s |
Không ai về đích nên multi-planner thi tới 600 000 works mỗi plan; phần thi của plan thua là việc bỏ đi. CBR dừng sau 10 000 works, lấy mẫu, chọn, chỉ plan thắng chạy tiếp. Lab không tìm được trường hợp CBR ở chế độ mặc định chọn sai plan: khi mẫu không thấy gì, nó không đoán mà trả lại cho multi-planner.
3.5 Bộ máy chạy: classic, SBE, express
SBE dùng chung planner của classic: README classic_runtime_planner_for_sbe giải thích getNext() của SBE có thể chạy rất lâu (stage SORT) nên không
thi xen kẽ được; MongoDB thi bằng classic rồi chạy plan thắng bằng SBE. internalQueryFrameworkControl mặc định trySbeRestricted (lab 04-engine.txt):
| Truy vấn | trySbeRestricted (mặc định) | trySbeEngine | forceClassicEngine |
|---|---|---|---|
find 1 tháng (104 434 đơn) | classic, 1 919–2 054 ms (1 lần nhiễu 5 522) | SBE, 1 667–2 115 ms | classic, 1 705–1 838 ms |
$group 2 triệu đơn theo status | SBE, 1 699–1 894 ms | SBE, 1 706–1 748 ms | classic, 2 526–2 736 ms |
$match + $lookup customers + $group | classic, 496–1 160 ms | SBE, 250–263 ms | classic, 525–606 ms |
Explain SBE có explainVersion: "2" và winningPlan.slotBasedPlan (cây stage dạng slot: scan generic [s1 = record…] → group [s9]…);
slow log ghi queryFramework: "sbe". Pipeline $lookup này vẫn chạy classic ở mặc định — đừng giả định "có $lookup là SBE", đọc explainVersion.
Chi tiết pipeline để bài 5.
Express path (8.0+): {_id: X}, {_id: {$in: [X]}} và equality trên index unique một field (lab: email) chạy thẳng, không liệt kê plan,
không thi, không dựng cây stage; explain chỉ có EXPRESS_IXSCAN. Thêm một điều kiện ({_id: X, status: …}), $in 2 phần tử hay hint là về đường thường.
5 000 lần tìm theo _id, trung vị phía server: planningTimeMicros 54–56 vs 168–169 khi ép hint({_id: 1}), cpuNanos 0,39–0,51 ms vs 0,60–0,62 ms.
3.6 Ghim và chặn plan: hint, query settings, index filter
Query settings (8.0+) gắn với queryShapeHash, lưu trong cluster (còn sau restart), áp cho mọi giá trị của shape mà không sửa code. Lab 04-pin.txt:
db.adminCommand({setQuerySettings: {find: "orders", filter: A, $db: "shop"},
settings: {indexHints: {ns: {db: "shop", coll: "orders"}, allowedIndexes: ["createdAt_1"]}, comment: "INC-2041 ghim createdAt"}})
find(A) → createdAt_1, 3 607 key, 4 ms (trước: status_1, 2,2 s)
find(C) giá trị khác → createdAt_1, 8 118 key, 10 ms (cùng shape)
find(A).hint("status_1") → createdAt_1 (settings thắng hint)
filter {createdAt, status} → status_1, 1 953 ms (đổi thứ tự field = SHAPE KHÁC)
find(A).sort({_id: 1}) → _id_, 2 000 000 key, 6 079 ms (thêm sort = shape khác)
settings: {reject: true} → QueryRejectedBySettings (411): Query rejected by admin query settings
settings: {maxTimeMS: 300} → MaxTimeMSExpired (50) — kể cả khi lệnh tự ghi maxTimeMS 5000 (9.0)
| Công cụ | Phạm vi | Bẫy đã đo |
|---|---|---|
hint() trong code | 1 câu lệnh, cần deploy | Index đổi tên/bị xoá → lỗi ngay: BadValue (2) … hint provided does not correspond to an existing index |
setQuerySettings indexHints | Shape, cả cluster, bền | allowedIndexes trỏ tới index không tồn tại → im lặng bỏ qua, quay về status_1 2 591 ms (explain vẫn in querySettings). Đổi thứ tự field, thêm limit/sort/projection là shape khác |
reject: true | Shape | Chặn cả giá trị "vô hại" của shape; biến thể shape khác vẫn lọt (sort _id: 4 071 ms). Không thấy counter riêng trong serverStatus |
maxTimeMS theo shape (9.0) | Shape | 8.0.32 không báo lỗi khi kèm indexHints: lưu ["indexHints"], bỏ maxTimeMS; chỉ có maxTimeMS thì lỗi settings field … cannot be empty |
querySettings trên lệnh (9.0) | 1 lần chạy | Settings đã lưu thắng settings trên lệnh (lab: lưu status_1, lệnh createdAt_1 → status_1). 8.0: BSON field 'querySettings' is an unknown field |
queryKnobs (9.0) | Shape / 1 lần chạy | maxEstimatedScanBytes 100 MB → COLLSCAN bị từ chối: NoQueryExecutionPlans (291) … Query rejected by maxEstimatedScanBytes; hint $natural vượt qua được. Tên tham số phải dùng tên ngắn (planRanker), tên internalQuery… báo not settable via QuerySettings |
planCacheSetFilter (index filter) | 1 mongod, mất khi restart | Deprecated từ 8.0; vẫn chạy ở 9.0.2 (indexFilterSet: true, đè cả hint) |
planCacheClear | 1 collection, 1 node | Chỉ là "đổ xúc xắc lại": truy vấn đến trước quyết định (hình 2) |
3.7 Cursor: batch đầu 101 document, getMore tới 16 MiB, gắn session
find không ghi batchSize trả 101 document (internalQueryFindCommandBatchSize), aggregate với cursor: {} cũng 101.
Mỗi getMore không giới hạn số document thì trả tới ~16 MiB: lab 46 119 đơn = 15,70 MiB một lô. Giữa hai getMore, cursor nằm ở server (ClientCursor). Cursor có hai đồng hồ (source cursor_manager.cpp, logical_session_cache_impl.cpp):
// cursor_manager.cpp (v9.0) — cursor nào được dọn sau cursorTimeoutMillis (mặc định 600 000 ms)
if (cursor->isNoTimeout() || cursor->_operationUsingCursor ||
(cursor->getSessionId() && !enableTimeoutOfInactiveSessionCursors.load())) return false;
Driver và mongosh luôn gắn session ngầm (implicit session) vào find/getMore, và getMore phải đi đúng session đã tạo cursor
(tự gửi lệnh rời nhau mà không giữ session: Location50738 … Cannot run getMore on cursor …, which was created in session …). Nên quy tắc "cursor idle 10 phút bị xoá" không áp dụng cho chúng
(trừ khi bật enableTimeoutOfInactiveSessionCursors, mặc định false ở cả 8.0.32 và 9.0.2). Cái giết cursor là session hết hạn: session không được
dùng 30 phút (localLogicalSessionTimeoutMinutes) thì bản ghi trong config.system.sessions bị TTL xoá, lần refresh sau (logicalSessionRefreshMillis
5 phút) server kill mọi cursor của session đó — kể cả cursor noCursorTimeout. Lab ở mục 5.4.
4. Đảm bảo và giới hạn
| Hạng mục | Giá trị (9.0.2) | Ghi chú |
|---|---|---|
| Plan được chọn | Không đảm bảo ổn định | Phụ thuộc thứ tự truy vấn, dữ liệu ở đầu index, restart, index mới; mỗi node một plan cache |
| Lượt thi | 101 kết quả · max(10 000, 0,3 × N) works/plan · tổng ≤ 2 × N | internalQueryPlanTotalEvaluationCollFraction 2 |
| Số plan ứng viên | ≤ 64 (internalQueryPlannerMaxIndexedSolutions) | $in > 200 phần tử hết "nổ" thành nhiều điểm (bài 3) |
| Plan cache | 5 000 entry/collection (LRU); debug info bỏ khi tổng > 0,5 GB | Không lưu đĩa, không replicate |
| Replan | Lượt thử đầu > 10 × works đã ghi | Chỉ xét tới 101 kết quả đầu |
| CBR (mixed) | Chỉ khi lượt thi 10 000 works không ra kết quả; mẫu ~384 document | Chỉ engine classic lập plan; điều kiện khớp dưới ~0,26% dễ ước lượng 0 |
| Sort không index | 100 MB/stage, tràn đĩa mặc định | Đã đo ở bài 3; 9.0 thêm giới hạn 1 GB mỗi operation (bài 5) |
| Cursor | Batch đầu 101 doc; getMore ≤ 16 MiB; idle 10 phút (cursor không session) / 30 phút (session) | Session timeout không đổi lúc chạy (not allowed to change … at runtime) |
$queryStats | Lấy mẫu 1% đọc + 1% ghi (9.0); 8.0.32 tắt (internalQueryStatsRateLimit 0) | Số đếm là mẫu, không phải tổng |
| Đếm | countDocuments = quét thật; estimatedDocumentCount = metadata | Metadata lệch sau crash (mục 5.5) |
5. Failure modes
Plan "cận thị" (mục 3.2) và plan cache lật (mục 3.3) là hai failure mode chính của planner. Phần này là các failure mode còn lại, mỗi cái có lab.
5.1 Truy vấn 0 kết quả làm lập plan mất nhiều giây (8.0)
Bảng Z ở mục 3.4: trên 8.0.32 riêng lập plan đã 3,8–8,1 s, và lặp lại mỗi khi cache trống (sau restart, sau tạo index). Kiểu truy vấn hay gặp: tìm bản ghi trùng trước khi
insert, job dọn "đơn pending quá hạn", kiểm tra quyền trên một field không có index. Phát hiện: slow log có planningTimeMicros chiếm phần lớn durationMillis.
Chữa: index đủ chọn lọc để plan đúng ra EOF sớm; 9.0 giảm còn ~1 s nhờ CBR.
5.2 Phân trang skip: chậm tuyến tính, trùng và sót dòng
skip không nhảy được: index không biết "dòng thứ 1 999 980" nằm đâu, phải đếm từng key. Keyset biến trang thành một khoảng
{$or: [{createdAt: {$lt: t}}, {createdAt: t, _id: {$lt: id}}]} — planner dùng SUBPLAN hai nhánh, 20 key. Số liệu: 04-page.txt.| Trang (20 dòng) | skip: key / thời gian | keyset |
|---|---|---|
| 1 | 20 / 0–2 ms | — |
| 1 000 | 20 000 / 7–21 ms | 20 key / 1–7 ms |
| 10 000 | 200 000 / 73–95 ms | 20 key / 1–2 ms |
| 100 000 | 2 000 000 / 795–1 068 ms | 20 key / 0–1 ms |
10 000, có lọc status không nằm trong index | 308 023 key + 308 023 document / 459 ms | — |
Laravel (04-laravel.txt, laravel-mongodb 5.11.0) — bật command monitoring để thấy lệnh thật:
paginate(20) trang 5000:
1497.9 ms aggregate [{"$match":[]},{"$group":{"_id":1,"n":{"$sum":1}}}] ← đếm tổng, COLLSCAN 2 triệu đơn
91.0 ms find {"limit":20,"skip":99980,"sort":{"createdAt":-1,"_id":-1}}
simplePaginate(20) trang 5000:
78.2 ms find {"limit":21,"skip":99980,…} ← không đếm, lấy 21 dòng để biết còn trang sau
cursorPaginate(20) trang 2:
cursor = {"createdAt":"2026-09-30 23:54:52","_id":"6abda14c…","_pointsToNextItems":true}
find {"$or":[{"createdAt":{"$lt":"2026-09-30 23:54:52"}}, …]} → trang 2 RỖNG (đúng phải có 20 dòng)
Bẫy nổ trễ (laravel-mongodb 5.11.0):cursorPaginate()sort theo field ngày lưu giá trị ngày thành chuỗi"Y-m-d H:i:s"(mất cả mili giây) rồi soDatevới chuỗi — type bracketing không khớp gì (bài 1), trang 2 rỗng, không lỗi. Thêm$casts ['createdAt' => 'datetime']không đổi gì. Chạy được:orderBy('_id', 'desc')->cursorPaginate()(_idđược đổi về ObjectId, ObjectId tăng theo thời điểm tạo) — trang 2 khớp đúng 20 dòng.
Đếm tổng cho paginate() (3 lần): countDocuments({}) 1 365–1 918 ms (thực chất là aggregate [$match, $group {$sum: 1}] chạy COLLSCAN);
estimatedDocumentCount() 2–4 ms; countDocuments({status: "delivered"}) có index 271–312 ms; field không index 1 418–1 615 ms.
5.3 $or có một nhánh không index, $ne/$nin
$or được lập plan từng nhánh (SUBPLAN, mỗi nhánh tự chọn index rồi gộp + khử trùng). Một nhánh không có index là cả truy vấn thành COLLSCAN
(04-misc.txt): {$or: [{customerId: 77}, {paymentMethod: "cod"}]} → SUBPLAN COLLSCAN, 2 000 000 document, 5 281 ms. $ne thành hai khoảng
[MinKey, "delivered") và ("delivered", MaxKey]: 699 792 key cho 35% đơn — không tệ hơn $in viết dương (cùng 299 524 key với $nin), nhưng
$ne trên một giá trị hiếm nghĩa là quét gần hết index.
5.4 Cursor chết giữa job batch, và cursor bị bỏ quên
04-cursor.txt thu nhỏ thời gian trên node riêng (localLogicalSessionTimeoutMinutes=1,
cursorTimeoutMillis=10000): mongosh, Node và PHP cùng đọc 100 document, ngồi yên, rồi đọc tiếp.| Kịch bản (node lab) | mongosh | Node driver 7.7 | PHP ext-mongodb 2.5.3 |
|---|---|---|---|
| (a) idle 25 s > cursorTimeoutMillis 10 s | getMore OK cả ba — cursor gắn lsid nên không bị dọn | ||
(b) như (a) + enableTimeoutOfInactiveSessionCursors: true | CursorNotFound (43): cursor id … not found | MongoServerError code 43 | MongoDB\Driver\Exception\CommandException code 43 |
| (c) idle 100 s > session 1 phút, có noCursorTimeout | CursorNotFound cả ba (ở giây ~99–100) | ||
| (d) 20 cursor đọc dở, không đóng | — | metrics.cursor.open.total 0 → 20 | 0 → 1 (PHP huỷ object → gửi killCursors); giữ trong mảng → 20 |
Hệ quả cho job batch (Laravel command, Node worker): xử lý một lô lâu hơn ~30 phút là chết, noCursorTimeout không giúp, mà bật nó trên cursor không session còn tạo cursor
không bao giờ được dọn. Cách đúng: lô nhỏ (batchSize vừa đủ để mỗi lô xử lý nhanh), hoặc đọc theo keyset trên _id — mỗi lô một truy vấn mới, chết
giữa chừng thì chạy tiếp từ mốc. Manual gợi ý session tường minh + refreshSessions định kỳ (chưa verify). Node: cursor đọc dở phải close() (hoặc
for await chạy hết/break) — không thì cursor và session nằm ở server tới khi session hết hạn.
5.5 estimatedDocumentCount sai sau khi mongod chết đột ngột
Lab 04-count.txt: collection 200 000 document + fsync; writer insert 1 000 / xoá 400 document mỗi vòng (writeConcern majority); docker kill sau ~12 s;
start lại rồi so:
lần 1: writer xác nhận insert 328000, delete 100000
sau restart: estimatedDocumentCount 200000, countDocuments 429000 lệch -229000
validate(): nrecords 429000 → sau validate: estimatedDocumentCount 429000, lệch 0
lần 2: sau restart: estimatedDocumentCount 200000, countDocuments 867000 lệch -667000
Số đếm nhanh nằm trong bảng sizeStorer của WiredTiger, chỉ ghi xuống đĩa định kỳ (wiredTigerSizeStorerPeriodicSyncPeriodMillis 60 000, wiredTigerSizeStorerPeriodicSyncHits 100 000).
Crash trước mốc đó → về số cũ, và giữ số sai tới khi chạy validate() (lần 2: số đã sửa bằng validate ở lần 1 cũng mất vì chưa kịp ghi). Dữ liệu không mất: countDocuments
đúng. Lần 1 còn có 1 000 document đã ghi nhưng client nhận lỗi (429 000 = 200 000 + 328 000 − 100 000 + 1 000) — ghi xong mà không biết là xong, chuyện của
retry và idempotency (bài 8). Bị ảnh hưởng: estimatedDocumentCount(), lệnh count không filter, $collStats, dashboard "tổng số đơn".
CBR cũng nhân tỉ lệ mẫu với numRecords này (đọc từ source, chưa đo hệ quả).
6. Vận hành trong production
Slow query log (mặc định slowms 100) là nguồn chính. Một dòng thật (04-slowlog.txt, ngay sau restart nên cache lạnh), các field nên đọc:
| Field | Giá trị lab | Đọc thế nào |
|---|---|---|
planSummary | IXSCAN { status: 1 } | Plan đã chạy; nhóm theo shape để thấy plan đổi |
keysExamined / docsExamined / nreturned | 1 300 209 / 1 300 209 / 2 354 | Tỉ lệ xa 1:1:1 là plan sai hoặc thiếu index |
queryShapeHash · planCacheShapeHash · planCacheKey | 1D50D7ED… · C28DB142 · 44D26D66 | Gom nhóm bằng queryShapeHash (ổn định qua nâng cấp) |
fromMultiPlanner / fromPlanCache / replanned + replanReason | fromMultiPlanner: true | Plan đến từ đâu; replan nhiều = shape đang "lật" |
planningTimeMicros | 145 013 | Lớn so với tổng = lượt thi đắt (mục 5.1) |
planRanker | mp (khác: cbr, none khi lấy từ cache) | 9.0: ai chọn plan |
queryFramework | classic | sbe nếu chạy SBE |
storage.data.bytesRead, cpuNanos, durationMillis | 1,38 GB · 2,89 s CPU · 5 909 ms | Đọc đĩa nhiều = cache lạnh hoặc working set vượt cache |
- Profiler:
db.setProfilingLevel(1, {slowms: 50, sampleRate: 0.1})ghi vào capped collectionsystem.profilecủa từng database — có cảreplanReason,fromPlanCache(lab 3.3 đọc từ đây). Mức 2 ghi mọi lệnh: chỉ bật ngắn. $queryStats(9.0 bật sẵn): gom theo query shape, giá trị bị thay bằng kiểu ({"customerId":{"$eq":"?number"}}). Lab: 2 000 lần chạy →execCount21 — đây là mẫu 1%, dùng để xếp hạng shape tốn nhất, không dùng làm số tuyệt đối. Nhómmetrics.queryPlannercófromMultiPlanner,fromPlanCache,planningTimeMicros,costBasedRanker. 8.0.32 mặc định tắt (300 lần chạy → không có entry).$currentOp+killOp: lab tìm truy vấn báo cáo theocomment(secs_running 5,planSummary COLLSCAN,queryShapeHash),killOp→ client nhậnInterrupted (11601): … operation was interruptedsau 5 901 ms. Gắncommentvào truy vấn từ app để tìm được nhanh.- serverStatus:
metrics.query.planCache,metrics.query.cbr(count,choseWinningPlan,numPlansFailedCostEstimation,samplingMicros),metrics.cursor.open.total,metrics.cursor.timedOut.
Runbook "query đột nhiên chậm": (1) slow log/$queryStats → shape nào, planSummary đổi chưa; (2) $planCacheStats của shape → plan, works,
active; (3) explain("allPlansExecution") với giá trị thật; (4) chữa cháy: setQuerySettings indexHints (comment ghi số ticket) hoặc reject: true nếu shape
đang làm sập cluster; (5) chữa gốc: index khớp truy vấn; (6) xoá query settings khi đã chữa gốc. Không dùng planCacheClear làm bước cuối.
7. So sánh và ma trận quyết định
| Trục | MongoDB 9.0 | PostgreSQL 18 |
|---|---|---|
| Chọn plan | Thi thực nghiệm; CBR lấy mẫu khi thi không ra gì | Cost-based theo thống kê ANALYZE (histogram, MCV) |
| Sai khi nào | 101 kết quả đầu không đại diện; giá trị hiếm | Thống kê cũ, cột tương quan, ước lượng join (khi plan sai) |
| Nhớ plan | Plan cache theo shape, mỗi node | Prepared statement: custom → generic plan sau 5 lần (theo session) |
| Ghim / chặn | hint, query settings (bền, cả cluster), reject | Không có hint gốc (pg_hint_plan); statement_timeout theo role |
| Thống kê workload | $queryStats (mẫu 1%) | pg_stat_statements (đếm đủ) |
| Phân trang sâu, đếm tổng | skip tuyến tính; countDocuments quét | Giống hệt: OFFSET tuyến tính, count(*) quét; ước lượng = reltuples |
Ghim plan — dùng khi nào: setQuerySettings indexHints cho sự cố đang cháy và cho shape mà index đúng không thể thắng lượt thi; reject khi một
shape làm sập cluster và tắt được tính năng đó; maxTimeMS theo shape (9.0) cho báo cáo nặng; hint trong code chỉ khi truy vấn cố ý khác thường (vd quét theo
$natural). Tránh: index filter (deprecated, mất khi restart), planCacheClear như cách sửa, ghim vĩnh viễn thay cho index đúng.
Phân trang: keyset cho API và danh sách dài; skip chỉ cho vài trang đầu của màn admin; đếm tổng bằng estimatedDocumentCount hoặc bỏ hẳn tổng số trang.
8. Góc nhìn tech lead: tác động tổ chức
- Thiết kế index để planner khỏi phải đoán. Nhiều index một field = nhiều ứng viên = nhiều lượt thi và nhiều cơ hội chọn sai. Mỗi truy vấn nóng có một compound index khớp
ESR; review PR có
explain("executionStats")với dữ liệu lệch như thật (không phải dữ liệu seed đều). - Query settings là cấu hình production, phải quản lý như code: xuất
$querySettingsvào git, mỗi entry cócommentghi ticket và người chịu trách nhiệm, review định kỳ. Lý do: settings sống sau restart và áp cả cluster, nhưng im lặng khi index trongallowedIndexesbị xoá/đổi tên (lab 3.6). - Nâng cấp 8.0 → 9.0: dashboard/alert gom theo
planCacheShapeHash/queryHashsẽ gãy (hash đổi, lab); dùngqueryShapeHash. Plan của một số shape đổi do CBR → so slow log trước/sau theo shape. Runbook viết cho 9.0 mà chạy trên 8.0:maxTimeMSđi kèmindexHintsbị bỏ qua im lặng (lab); ở 9.0,maxTimeMS/queryKnobscần FCV 9.0 (release notes). - Hợp đồng API phân trang: cursor/keyset ngay từ đầu (đổi về sau là breaking change cho client). Với Laravel, đừng tin
cursorPaginate()trên field ngày (lab 5.2). - Job batch: chuẩn hoá cách đọc lô theo keyset
_id, chạy lại được từ mốc; cấmnoCursorTimeouttrong code review. - Trực sự cố: người trực cần biết đọc 1 dòng slow log,
$planCacheStats,setQuerySettings/reject,killOp. Viết sẵn lệnh vào runbook. - Exit strategy: keyset pagination và tư duy "index khớp truy vấn" mang sang PostgreSQL được;
hint, query settings,$queryStatsthì không — giữ chúng ở tầng vận hành, đừng để logic nghiệp vụ phụ thuộc.
9. Anti-patterns và bài học thực tế
- Phân trang bằng skip trên danh sách dài — trang 100 000 = 2 triệu key; còn trùng/sót dòng khi dữ liệu đổi.
paginate()đếm tổng mỗi lần bấm trang — 1,5 s COLLSCAN (lab Laravel) cho một con số người dùng không cần.hintcứng trong code — đổi tên index là lỗiBadValueở mọi request; query settings cùng tình huống thì im lặng chạy plan cũ. Cả hai đều nổ trễ, theo hai kiểu ngược nhau.- "Chữa" bằng
planCacheClear— truy vấn đến sau quyết định lại; chậm lại vào lần restart hay tạo index sau. - Tin explain hơn production — explain không dùng plan cache; so với
fromPlanCachetrong slow log. noCursorTimeoutcho job chậm — không chống được session timeout 30 phút (lab c), còn để lại cursor không ai dọn.$orcó một nhánh không index — cả truy vấn thành COLLSCAN (5,3 s).- Dùng
estimatedDocumentCountcho số liệu nghiệp vụ — sai hàng trăm nghìn sau một lần crash, tới khivalidate(). - Đo truy vấn bằng dữ liệu seed đều — lỗi "cận thị" chỉ hiện khi dữ liệu lệch (65% delivered) và dồn theo thời gian như thật.
Bài học công khai: SERVER-32452 — plan có works rất cao được cache thì replan không bao giờ xảy ra
(ngưỡng 10 × works quá lớn để vượt); bản sửa (4.2) sinh ra trạng thái inactive/active và cơ chế nhân đôi works. Điều docs không nói rõ mà lab cho thấy: cơ chế này chỉ chống plan cache
làm chậm lượt thử đầu, không chống plan sai từ chính multi-planner (hình 2).
10. Câu hỏi phụ interviewer hay hỏi tiếp
- MongoDB chọn index thế nào? — Liệt kê plan từ index dùng được, chạy thử xen kẽ theo works, dừng ở 101 kết quả / chạy xong / hết
max(10 000, 0,3N)works, điểm = 1 + advanced/works + EOF bonus; nhớ theo query shape. 8.3+: CBR lấy mẫu khi không plan nào ra kết quả. - Vì sao cùng query hôm qua nhanh hôm nay chậm? — Plan cache theo shape: restart/tạo index xoá cache, giá trị đến trước quyết định plan; replan chỉ khi lượt thử đầu > 10 × works.
- explain nhanh mà app chậm? — Explain bỏ qua cache; app chạy plan trong cache. Xem
$planCacheStats,fromPlanCache. - CBR của 8.3/9.0 có thay multi-planner không? — Không, là dự phòng: chỉ chạy khi lượt thi 10 000 works không ra kết quả; mẫu ~384 document; ước lượng 0 thì trả lại multi-planner.
- Ghim plan production không deploy? —
setQuerySettings+indexHints; chặn hẳn bằngreject: true; 9.0 thêmmaxTimeMS. Bẫy: shape phải khớp (thứ tự field, limit, sort), index sai tên bị bỏ qua im lặng. - SBE là gì, khi nào dùng? — Engine chạy mới (5.1+), planner vẫn là classic; mặc định chỉ pipeline có
$group/$lookup; nhận biết bằngexplainVersion "2",queryFramework: "sbe". - Vì sao skip chậm, thay bằng gì? — Index không nhảy theo số thứ tự, phải đi qua N key; keyset
(createdAt, _id) < mốcđọc đúng 20 key và không trùng/sót. - CursorNotFound trong job batch? — Cursor của driver gắn session: chết khi session idle 30 phút,
noCursorTimeoutkhông cứu; dùng lô nhỏ hoặc keyset theo_id. countDocumentsvsestimatedDocumentCount? — Quét thật (chậm, đúng) vs metadata (nhanh, sai sau crash tới khivalidate()).- Theo dõi truy vấn tốn nhất ở 9.0? —
$queryStatstheo shape (mẫu 1%), slow log gom theoqueryShapeHash, profiler khi cần chi tiết.
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 shop.orders); node cursor riêng 9.0.2 với localLogicalSessionTimeoutMinutes=1, logicalSessionRefreshMillis=5000, ttlMonitorSleepSecs=5,
cursorTimeoutMillis=10000; PHP 8.3 + ext-mongodb 2.5.3 + mongodb/laravel-mongodb 5.11.0; Node 22 + driver mongodb 7.7.0. Bộ dữ liệu chung seed bằng
js/seed.js (160,1 s); index thêm: status_1, createdAt_1, customerId_1, {createdAt: -1, _id: -1}.
cd labs/mongodb
./04-query-engine.sh # chạy hết: seed setup explain cache cbr pin engine observe page laravel misc cursor count v80 slowlog
./04-query-engine.sh cache # 1 phần
(source lib.sh && mdb_clean) # dọn container sau khi xong
Chưa verify: chế độ histogramCE + lệnh analyze và chiến lược EstimateRankingEffort; mốc chết cursor đúng 30 phút với tham số mặc định (lab
thu nhỏ còn 1 phút); refreshSessions để giữ cursor; CBR bị lệch khi numRecords sai sau crash; plan cache và query settings trên sharded cluster (mỗi shard một plan,
bài 9); số liệu ở quy mô lớn hơn 2 triệu document và cache lớn hơn 0,25 GB; hành vi trên Atlas.