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ánh v9.0 trê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; source laravel-mongodb 5.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ết max(10 000, 0,3 × số document) works), chấm điểm theo advanced/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ằng setQuerySettings (index, reject, 9.0 thêm maxTimeMS) thay vì hint trong 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ả.

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 viSuy 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ướcTrí 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ẳnNgưỡ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 worksplanning 3,8–8,1 s
CBR chỉ chạy cho một nhóm nhỏ truy vấnChỉ khi lượt thi ngắn không ra kết quả nàocbr.count +0 với truy vấn thường
Ghim plan không cần sửa codeQuery settings gắn với shape3 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):

FieldLà gìLab
queryShapeHash (8.0+)SHA-256 của query shape đầy đủ; khoá của query settings và $queryStats1D50D7ED… — giống nhau trên 8.0.32 và 9.0.2
planCacheShapeHash (= queryHash cũ)Hash 8 ký tự của shape theo plan cache2B1A8DA0 (8.0) vs C28DB142 (9.0) — đổi khi nâng cấp
planCacheKeyShape + 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: explain bỏ 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 $planCacheStats và 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)
Multi-planner thi 101 kết quả đầu rồi chạy hết truy vấn Hai plan chạy xen kẽ. Plan 1 quét index status_1: 101 works ra 101 kết quả vì 65% đơn là delivered. Plan 2 quét createdAt_1: 101 works chỉ ra 62 kết quả. Plan 1 thắng với điểm 2,0002 so với 1,6141 và được ghi vào plan cache. Khi chạy hết truy vấn, plan 1 phải quét 1 300 209 key mất 2,2 giây, còn plan 2 chỉ cần 3 607 key, 6 ms. find({status: "delivered", createdAt: tháng 10/2024}) — mỗi plan 101 works Plan 1 · IXSCAN status_1 FETCH rồi lọc createdAt 101 / 101 Plan 2 · IXSCAN createdAt_1 FETCH rồi lọc status 62 / 101 thanh = số kết quả ra được (advanced) sau 101 works → Plan 1 về đích trước: score 2,0002 vs 1,6141 → thắng, ghi vào plan cache Rồi chạy HẾT truy vấn (2 354 đơn khớp): Plan 1 (đã chọn) 1 300 209 key · 2,2 s Plan 2 (bị loại) 3 607 key · 6 ms Thắng ở 101 kết quả đầu ≠ rẻ nhất cho cả truy vấn (chậm ×370)
Hình 1 — Multi-planner chấm điểm bằng tỉ lệ 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ớpPlanner tự chọnhint createdAt_1hint status_1
Tháng đầu (10/2024)2 354status_1 — 1 300 209 key, 2 237–2 540 ms3 607 key, 6–7 ms2 096–2 413 ms
Quý đầu20 589status_1 — 2 199–2 350 ms31 766 key, 38–45 ms2 264–2 692 ms
Tháng cuối (9/2026)104 434createdAt_1 — 161 006 key, 365–558 ms178–287 ms2 256–2 647 ms
8.0.32, tháng đầu2 354status_1 — 2 910–3 109 ms5–6 ms2 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.

Plan cache lật theo thứ tự truy vấn đến Sáu truy vấn cùng query shape đến lần lượt: A, A, A, B, A, A. A là tháng 10/2024, B là ngày 30/9/2026. Entry trong plan cache đi từ trống sang inactive status_1 works 101, rồi active. Khi B đến, plan trong cache cần ít nhất 1 010 works, vượt 10 lần 101, nên bị bỏ và lập plan lại; entry thành inactive createdAt_1 works 202. A đến lần nữa, multi-planner lại chọn status_1 với 101 works nhỏ hơn 202 nên thay entry thành active status_1. A luôn mất khoảng 2 giây. Cùng shape C28DB142 — A = tháng 10/2024 · B = ngày 30/9/2026 A lần 1 2,2 smulti-planner A lần 2 2,2 smulti-planner A lần 3 2,0 stừ plan cache B 0,13 sREPLANNED A 2,3 smulti-planner A 2,0 stừ plan cache entry trong plan cache trống (sau restart / tạo index / planCacheClear) inactive · plan status_1 · works 101 ACTIVE · plan status_1 · works 101 inactive · plan createdAt_1 · works 202 B thử plan cache: ≥ 1 010 works > 10 × 101 → bỏ entry, lập plan lại ACTIVE · plan status_1 · works 101 A lập plan lại, 101 ≤ 202 → thay entry. Plan tốt cho A (createdAt_1) không bao giờ thắng Đảo thứ tự (B chạy trước): entry ACTIVE createdAt_1 · works 149 → A lấy từ cache: 37 ms. Plan đi theo lịch sử truy vấn đến, không theo giá trị của truy vấn đang chạy.
Hình 2 — Chuỗi s1…s6 của lab 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 gianNguồn plan (profiler)Entry sau bước
s1–s3: A ×32 231 / 2 182 / 2 042 msmulti-planner, multi-planner, fromPlanCacheinactive → ACTIVE status_1, works 101
s4: B127 msREPLANNED: "expected trial execution to take 101 works but it took at least 1010 works"inactive createdAt_1, works 202
s5–s6: A ×22 290 / 2 023 msmulti-planner, fromPlanCacheACTIVE status_1, works 101
r1–r4: B, B, A, A (cache rỗng)58 / 53 / 37 / 49 msA lấy createdAt_1 từ cacheACTIVE 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.txtKế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 thicount +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ần8/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ần3/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ế độplanningTimeMicrosTổngPlan, key
8.0.32 (chỉ multi-planner)3,8–8,1 s (6 lần)5,4–10,3 sstatus_1, 1 300 209 key — cùng plan
9.0.2 ép multiPlanning2,8–3,3 s3,9–4,5 s
9.0.2 mặc định (planRanker: cbr)0,95–1,05 s2,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ấntrySbeRestricted (mặc định)trySbeEngineforceClassicEngine
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 msclassic, 1 705–1 838 ms
$group 2 triệu đơn theo statusSBE, 1 699–1 894 msSBE, 1 706–1 748 msclassic, 2 526–2 736 ms
$match + $lookup customers + $groupclassic, 496–1 160 msSBE, 250–263 msclassic, 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 viBẫy đã đo
hint() trong code1 câu lệnh, cần deployIndex đổi tên/bị xoá → lỗi ngay: BadValue (2) … hint provided does not correspond to an existing index
setQuerySettings indexHintsShape, cả cluster, bềnallowedIndexes 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: trueShapeChặ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)Shape8.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ạySettings đã 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ạymaxEstimatedScanBytes 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 restartDeprecated từ 8.0; vẫn chạy ở 9.0.2 (indexFilterSet: true, đè cả hint)
planCacheClear1 collection, 1 nodeChỉ 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ụcGiá trị (9.0.2)Ghi chú
Plan được chọnKhông đảm bảo ổn địnhPhụ 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 thi101 kết quả · max(10 000, 0,3 × N) works/plan · tổng ≤ 2 × NinternalQueryPlanTotalEvaluationCollFraction 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 cache5 000 entry/collection (LRU); debug info bỏ khi tổng > 0,5 GBKhông lưu đĩa, không replicate
ReplanLượt thử đầu > 10 × works đã ghiChỉ 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 documentChỉ engine classic lập plan; điều kiện khớp dưới ~0,26% dễ ước lượng 0
Sort không index100 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)
CursorBatch đầ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)
$queryStatsLấ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
ĐếmcountDocuments = quét thật; estimatedDocumentCount = metadataMetadata 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 đi qua từng key, keyset nhảy thẳng tới mốc Trang 100 000 với 20 dòng mỗi trang trên index createdAt giảm dần, _id giảm dần có 2 triệu key. skip phải đi qua 1 999 980 key rồi mới lấy 20 dòng, mất 795 đến 1 068 ms. Keyset dùng mốc createdAt và _id của dòng cuối trang trước, seek thẳng tới đó và đọc 20 key, mất 0 đến 7 ms. Trang 100 000 · 20 dòng/trang · index {createdAt: -1, _id: -1} có 2 000 000 key skip(1 999 980) đi qua 2 000 000 key rồi mới lấy 20 — 795–1 068 ms keyset (mốc trang trước) seek thẳng tới (createdAt, _id) của mốc, đọc 20 key — 0–7 ms dữ liệu đổi giữa 2 lần bấm: 5 đơn mới → trang 2 lặp 5 dòng; xoá 5 đơn trang 1 → trang 2 sót 5
Hình 3 — 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 giankeyset
120 / 0–2 ms—
1 00020 000 / 7–21 ms20 key / 1–7 ms
10 000200 000 / 73–95 ms20 key / 1–2 ms
100 0002 000 000 / 795–1 068 ms20 key / 0–1 ms
10 000, có lọc status không nằm trong index308 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 so Date vớ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

Cursor của job batch chết khi session hết hạn Job đọc đơn: find trả 101 document, getMore trả khoảng 46 nghìn document 16 MiB, rồi xử lý chậm và không gọi getMore. Mốc 10 phút cursorTimeoutMillis bị bỏ qua vì cursor gắn session. Sau khoảng 30 phút không dùng session, server xoá session và kill cursor. Lần getMore tiếp theo nhận lỗi CursorNotFound mã 43. noCursorTimeout không cứu được. Job đọc đơn theo batch, xử lý chậm — cursor chết lúc nào? client find: 101 doc getMore 46 119 doc (16 MiB) rồi xử lý chậm, không gọi getMore… server cursor mở, gắn session (lsid) 10 phút: cursorTimeoutMillis bỏ qua (cursor có session) ~30 phút không dùng session → server kill cursor getMore → CursorNotFound (43) noCursorTimeout không cứu: nó chỉ tắt đồng hồ 10 phút, không tắt đồng hồ session. 010 phút30 phút35 phút
Hình 4 — Hai đồng hồ của cursor. Lab 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)mongoshNode driver 7.7PHP ext-mongodb 2.5.3
(a) idle 25 s > cursorTimeoutMillis 10 sgetMore OK cả ba — cursor gắn lsid nên không bị dọn
(b) như (a) + enableTimeoutOfInactiveSessionCursors: trueCursorNotFound (43): cursor id … not foundMongoServerError code 43MongoDB\Driver\Exception\CommandException code 43
(c) idle 100 s > session 1 phút, có noCursorTimeoutCursorNotFound cả ba (ở giây ~99–100)
(d) 20 cursor đọc dở, không đóng—metrics.cursor.open.total 0 → 200 → 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:

FieldGiá trị labĐọc thế nào
planSummaryIXSCAN { status: 1 }Plan đã chạy; nhóm theo shape để thấy plan đổi
keysExamined / docsExamined / nreturned1 300 209 / 1 300 209 / 2 354Tỉ lệ xa 1:1:1 là plan sai hoặc thiếu index
queryShapeHash · planCacheShapeHash · planCacheKey1D50D7ED… · C28DB142 · 44D26D66Gom nhóm bằng queryShapeHash (ổn định qua nâng cấp)
fromMultiPlanner / fromPlanCache / replanned + replanReasonfromMultiPlanner: truePlan đến từ đâu; replan nhiều = shape đang "lật"
planningTimeMicros145 013Lớn so với tổng = lượt thi đắt (mục 5.1)
planRankermp (khác: cbr, none khi lấy từ cache)9.0: ai chọn plan
queryFrameworkclassicsbe nếu chạy SBE
storage.data.bytesRead, cpuNanos, durationMillis1,38 GB · 2,89 s CPU · 5 909 msĐọc đĩa nhiều = cache lạnh hoặc working set vượt cache

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ụcMongoDB 9.0PostgreSQL 18
Chọn planThi 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ào101 kết quả đầu không đại diện; giá trị hiếmThống kê cũ, cột tương quan, ước lượng join (khi plan sai)
Nhớ planPlan cache theo shape, mỗi nodePrepared statement: custom → generic plan sau 5 lần (theo session)
Ghim / chặnhint, query settings (bền, cả cluster), rejectKhô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ổngskip tuyến tính; countDocuments quétGiố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

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

  1. 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.
  2. 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.
  3. hint cứng trong code — đổi tên index là lỗi BadValue ở 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.
  4. "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.
  5. Tin explain hơn production — explain không dùng plan cache; so với fromPlanCache trong slow log.
  6. noCursorTimeout cho 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.
  7. $or có một nhánh không index — cả truy vấn thành COLLSCAN (5,3 s).
  8. Dùng estimatedDocumentCount cho số liệu nghiệp vụ — sai hàng trăm nghìn sau một lần crash, tới khi validate().
  9. Đ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

  1. 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ả.
  2. 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.
  3. explain nhanh mà app chậm? — Explain bỏ qua cache; app chạy plan trong cache. Xem $planCacheStats, fromPlanCache.
  4. 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.
  5. Ghim plan production không deploy? — setQuerySettings + indexHints; chặn hẳn bằng reject: true; 9.0 thêm maxTimeMS. Bẫy: shape phải khớp (thứ tự field, limit, sort), index sai tên bị bỏ qua im lặng.
  6. 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ằng explainVersion "2", queryFramework: "sbe".
  7. 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.
  8. CursorNotFound trong job batch? — Cursor của driver gắn session: chết khi session idle 30 phút, noCursorTimeout không cứu; dùng lô nhỏ hoặc keyset theo _id.
  9. countDocuments vs estimatedDocumentCount? — Quét thật (chậm, đúng) vs metadata (nhanh, sai sau crash tới khi validate()).
  10. Theo dõi truy vấn tốn nhất ở 9.0? — $queryStats theo shape (mẫu 1%), slow log gom theo queryShapeHash, 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.