Query Planner & explain(): MongoDB chọn cách chạy query ra sao

38 phút đọcSeries: MongoDB: từ gốc đến internals

Bài 07–09 cho ta index: đơn, compound, multikey, partial... Nhưng có index chưa có nghĩa là query dùng đúng index. Khi một query có hai, ba cách chạy, ai quyết định chọn cách nào? Quyết định đó được nhớ ở đâu, nhớ trong bao lâu? Và vì sao cùng một câu query, cùng index, có hôm chạy 3 ms, có hôm chạy 200 ms?

Câu trả lời nằm ở query planner và plan cache. Bài này mở cả hai ra, rồi đọc explain() từng trường một. Phần quan trọng nhất là một thí nghiệm thật: một query mà plan tốt nhất phụ thuộc vào giá trị tham số. Ta sẽ xem plan cache lật qua lật lại, và có lúc giữ một plan tệ mà không báo gì.

Bài này nằm ở đâu

Cần biết trước : bài 06 (COLLSCAN, keysExamined/docsExamined/nReturned)
                 bài 07 (IXSCAN + FETCH, selectivity), bài 08 (compound index, ESR)
Giới thiệu     : candidate plan, trial period, works/advanced/productivity/score,
                 plan cache query shape, planCacheShapeHash/planCacheKey,
                 trạng thái Missing/Inactive/Active, replanning, $planCacheStats,
                 cost-based ranker (8.3), hint(), query settings, classic vs SBE,
                 ba mức verbosity của explain
Dẫn tới        : bài 11, Aggregation Pipeline

Môi trường lab: MongoDB 8.3.11 chạy trong Docker (image mongo:8, standalone), giới hạn 2 CPU, 3 GB RAM, WiredTiger cache 1 GB, máy host Apple M4. Container riêng mongo-lab-08, database lab08. Trong lúc đo, các container lab của bài 07, 08, 11, 13 cũng chạy trên cùng máy host, nên thời gian có nhiễu. Bài báo cáo median và luôn kèm số keysExamined/docsExamined, vốn không bị nhiễu. Số nào không đo thì ghi là minh hoạ.

Như các bài trước, mỗi khẳng định quan trọng có nhãn:

  • [tài liệu]: tài liệu chính thức của MongoDB mô tả. Có thể dựa vào.
  • [quan sát]: đo được trong lab này. Đúng với 8.3.11, nên kiểm lại trên phiên bản của bạn.
  • [chi tiết cài đặt]: cách server đang làm hiện nay (tham số nội bộ, mã nguồn). Không phải cam kết API, có thể đổi giữa các phiên bản.
  • [hình dung]: mô hình đơn giản để dễ nhớ.

Giải thích trong 30 giây

Khi một query có nhiều cách chạy (mỗi index dùng được là một cách), MongoDB chạy thử tất cả cùng lúc trong một khoảng ngắn. Cách nào trả được nhiều kết quả nhất với ít công nhất thì thắng. Plan thắng được ghi vào plan cache, theo "hình dạng" của query chứ không theo giá trị cụ thể. Lần sau gặp query cùng hình dạng, MongoDB dùng lại plan đó luôn. Nhưng nó vẫn canh chừng: nếu plan cũ tốn công gấp nhiều lần so với lúc được ghi nhận, nó bỏ plan đó và chạy thử lại từ đầu.

Hình dung trước: đội shipper và cuốn sổ tuyến đường

Một công ty giao hàng nhận đơn "giao cho khách X ở quận Y". Từ kho tới quận Y có hai tuyến: đi đường vành đai, hoặc đi xuyên trung tâm.

Lần đầu gặp kiểu đơn này, quản lý không đoán. Anh cử hai shipper chạy hai tuyến cùng lúc trong một khoảng ngắn. Ai giao được nhiều kiện hơn trên mỗi km thì thắng. Tuyến thắng được ghi vào sổ tuyến, ở trang "giao cho một khách ở một quận". Trang sổ ghi theo kiểu chuyến, không ghi tên khách cụ thể.

Lần sau có đơn cùng kiểu, quản lý mở sổ và cho đi tuyến đã ghi, khỏi thử. Nhưng anh kèm một điều kiện: "Lần trước tuyến này mất 10 km cho một chuyến. Nếu lần này đi quá 100 km mà vẫn chưa xong, quay về, cử hai shipper chạy thử lại."

Vấn đề nằm ở chỗ trang sổ không phân biệt khách. Khách X là một shop nhỏ, khách Z là một siêu thị lớn. Tuyến tốt cho khách nhỏ có thể tệ cho khách lớn, nhưng cả hai dùng chung một trang sổ.

Đời thực                                MongoDB
──────────────────────────────────      ─────────────────────────────────────
các tuyến có thể đi                     candidate plans (mỗi index một plan)
cử shipper chạy thử song song           multi-planner, trial period
"kiện giao được trên mỗi km"            productivity = advanced / works
sổ tuyến, ghi theo kiểu chuyến          plan cache, theo plan cache query shape
"quá 10 lần quãng đường cũ thì thử lại" replanning (hệ số 10)
shop nhỏ và siêu thị chung một trang    tham số khác nhau, cùng query shape

[hình dung] Đây chỉ là cách hình dung. MongoDB không chạy các plan trên nhiều luồng song song. Nó cho từng plan làm một bước theo vòng tròn trên cùng một luồng. "Km" thật là works, một đơn vị công việc của stage, không phải mili giây.

Mental model: đường đi của một query qua planner

find({ tenantId: ?, status: ? })
        │
        ▼
 tính plan cache query shape  (predicate + sort + projection + collation, BỎ giá trị)
        │
        ▼
 tra plan cache
        │
        ├── Missing / Inactive ──► sinh candidate plans ──► trial period (chạy thử vòng tròn)
        │                                                       │
        │                                                       ▼
        │                                     chọn plan thắng, cập nhật entry trong cache
        │
        └── Active ──► chạy plan đã cache, có "ngân sách" works
                          │
                          ├── xong trong ngân sách ✓ → dùng tiếp, chạy hết query
                          │
                          └── vượt ngân sách ✗ → REPLAN: quay về trial period

Ba ý cần giữ suốt bài:

  1. Planner không ước lượng chi phí như PostgreSQL. Mặc định nó chạy thử thật rồi đo. (Từ 8.3 có thêm một nhánh ước lượng dự phòng, phần cuối sẽ nói.)
  2. Cache được tra theo hình dạng, không theo giá trị. tenantId: "t0042" và tenantId: "t0000" là cùng một hình dạng.
  3. Cache chỉ kiểm tra plan ở đoạn đầu của lần chạy. Qua được đoạn đó rồi thì plan chạy hết query, kể cả khi phần còn lại tốn kém.

Setup và dataset

Cần một dataset mà câu trả lời "index nào tốt hơn" đổi theo tham số. Dữ liệu SaaS thật thường như vậy: có một tenant khổng lồ và hàng trăm tenant nhỏ, có trạng thái phổ biến và trạng thái rất hiếm.

MongoDB version : 8.3.11 (mongo:8, standalone)
Hardware        : Apple M4 host; container 2 CPU, 3 GB RAM
Configuration   : --wiredTigerCacheSizeGB 1
Dataset         : lab08.orders, 1.000.000 document, avgObjSize 111 byte
                  111,7 MB chưa nén, 32,3 MB trên đĩa
Indexes         : _id_ (9,5 MB), tenantId_1 (7,5 MB), status_1 (5,0 MB)
// trích gen.js: PRNG mulberry32 seed 8, 100 lần insertMany x 10.000 document
docs.push({
  tenantId: rnd() < 0.30 ? "t0000"                                   // 30%: tenant khổng lồ
                          : "t" + String(1 + Math.floor(rnd() * 499)).padStart(4, "0"),
  userId:   "u" + String(Math.floor(rnd() * 200)).padStart(4, "0"),
  status:   pickStatus(rnd()),     // 70% completed, 10% pending, 10% cancelled,
                                   // ~9,95% refunded, 0,05% disputed
  createdAt: new Date(END - Math.floor(rnd() * 365 * DAY)),
  total:    (1 + Math.floor(rnd() * 200)) * 50000                   // VND
});
db.orders.createIndex({ tenantId: 1 });
db.orders.createIndex({ status: 1 });
inserted 1000000 docs in 19819 ms
tenantId_1 build ms 1329
status_1 build ms 1716

status  : completed 700317, cancelled 100048, pending 99656, refunded 99483, disputed 496
tenant  : t0000 299814, t0042 1409
t0042 + pending  : 128
t0000 + disputed : 154
t0000 + refunded : 29882

Cố ý chỉ có hai index đơn, chưa có compound { tenantId: 1, status: 1 }. Đây là tình huống rất hay gặp ngoài đời: mỗi index được tạo cho một màn hình khác nhau, rồi một query mới lọc theo cả hai field.

Ba query ta sẽ dùng, cùng một hình dạng:

TênFilterIndex tốt hơnVì sao
A{ tenantId: "t0042", status: "pending" }tenantId_1tenant nhỏ (1.409 đơn), pending thì nhiều (99.656)
B{ tenantId: "t0000", status: "disputed" }status_1tenant khổng lồ (299.814), disputed thì hiếm (496)
C{ tenantId: "t0000", status: "refunded" }status_199.483 refunded so với 299.814 đơn của t0000

Bước 1: candidate plans

Planner liệt kê những cách có thể chạy

[tài liệu] Với mỗi query, planner xem các index đang có và sinh ra các candidate plan, mỗi plan là một cây stage. Với filter { tenantId, status } và hai index đơn, có hai cách:

Plan 1                                   Plan 2
FETCH  filter: status = ?                FETCH  filter: tenantId = ?
  └── IXSCAN tenantId_1                    └── IXSCAN status_1
        bounds: [tenantId, tenantId]             bounds: [status, status]

Mỗi plan dùng index cho một điều kiện, rồi FETCH document và kiểm tra điều kiện còn lại bằng filter. Bài 09 đã giải thích vì sao MongoDB hiếm khi ghép hai index đơn (index intersection). Ở đây explain cũng không đưa ra plan intersection nào.

Nếu chỉ có một candidate plan thì không có gì để chọn, plan đó được dùng luôn. Bài 06 có rejectedPlans: 0 vì lý do này.

Thấy candidate plans bằng explain("queryPlanner")

db.orders.find({ tenantId: "t0042", status: "pending" }).explain("queryPlanner")
explainVersion: '1',
queryPlanner: {
  namespace: 'lab08.orders',
  parsedQuery: { '$and': [ { status: { '$eq': 'pending' } }, { tenantId: { '$eq': 't0042' } } ] },
  indexFilterSet: false,
  queryHash: '2DA7E177',
  planCacheShapeHash: '2DA7E177',
  planCacheKey: 'B1BCA75B',
  optimizationTimeMillis: 13,
  maxIndexedOrSolutionsReached: false,
  maxIndexedAndSolutionsReached: false,
  maxScansToExplodeReached: false,
  winningPlan: {
    isCached: false,
    stage: 'FETCH', filter: { status: { '$eq': 'pending' } },
    inputStage: { stage: 'IXSCAN', indexName: 'tenantId_1',
                  indexBounds: { tenantId: [ '["t0042", "t0042"]' ] }, ... }
  },
  rejectedPlans: [
    { isCached: false, stage: 'FETCH', filter: { tenantId: { '$eq': 't0042' } },
      inputStage: { stage: 'IXSCAN', indexName: 'status_1',
                    indexBounds: { status: [ '["pending", "pending"]' ] }, ... } }
  ]
}

Ở mức queryPlanner, MongoDB vẫn chạy trial để chọn plan thắng, nhưng không chạy hết query và không trả số liệu thực thi. Muốn biết vì sao tenantId_1 thắng, ta cần mức verbosity cao hơn.

Bước 2: trial period, works và score

Chạy thử vòng tròn

[tài liệu] Trong classic multi-planner, plan thắng là plan trả về nhiều kết quả nhất trong trial period mà tốn ít công nhất.

Cụ thể hơn, các candidate plan được cho chạy xen kẽ, mỗi lượt mỗi plan làm một work. Một work là một lần stage được gọi để "làm một bước": đọc một index key, fetch một document, hoặc kiểm tra một điều kiện. Kết quả của một work là một trong ba:

work() ──┬── ADVANCED   : đưa được 1 kết quả lên stage cha
         ├── NEED_TIME  : đã làm việc nhưng chưa có gì để đưa lên (vd. document không khớp filter)
         └── IS_EOF     : hết dữ liệu

Ví dụ với chính COLLSCAN cuối bài 06 (1 triệu document, trả 21): explain ghi works: 1000001, advanced: 21, needTime: 999979. Mỗi document đọc là một lượt work; chỉ 21 lượt cho ra kết quả, còn lại là "cần thêm thời gian". Cơ chế pull từng lượt này được mổ xẻ ở bài 15.

Trial period dừng khi xảy ra điều đầu tiên trong ba điều sau:

1. một plan đã trả đủ 101 kết quả         (đủ một batch đầu, bài 06)
2. một plan chạy hết (EOF)
3. tổng số works chạm trần

[chi tiết cài đặt] Ba ngưỡng này là tham số nội bộ. Trong lab đọc được internalQueryPlanEvaluationMaxResults: 101, internalQueryPlanEvaluationWorks: 10000 và internalQueryPlanEvaluationCollFraction: 0.3. Trần works là giá trị lớn hơn trong hai số: 10.000, hoặc 30% số document của collection (ở đây là 300.000). Tài liệu chính thức không công bố các con số này, nên đừng xây logic ứng dụng dựa trên chúng.

Xem trial bằng allPlansExecution

db.orders.find({ tenantId: "t0042", status: "pending" }).explain("allPlansExecution")

Output thật, gom gọn mỗi candidate một dòng:

=== A: t0042 + pending
winning: FETCH <- IXSCAN(tenantId_1)
exec    : nReturned 128  keys 1409  docs 1409  executionTimeMillis 25
  candidate tenantId_1  nReturned 101  works 1056  advanced 101  needTime 955   isEOF 0  score 1.0958439393939394
  candidate status_1    nReturned 0    works 1056  advanced 0    needTime 1056  isEOF 0  score 1.0002

=== B: t0000 + disputed
winning: FETCH <- IXSCAN(status_1)
exec    : nReturned 154  keys 496   docs 496   executionTimeMillis 11
  candidate status_1    nReturned 101  works 352   advanced 101  needTime 251   isEOF 0  score 1.287131818181818
  candidate tenantId_1  nReturned 0    works 352   advanced 0    needTime 352   isEOF 0  score 1.0002

Đọc từng phần:

  • Hai plan có cùng số works (1.056 ở A, 352 ở B). Đó là dấu vết của việc chạy vòng tròn: mỗi plan được cho số lượt như nhau.
  • Trial dừng khi một plan đạt 101 kết quả. Ở A, tenantId_1 cần 1.056 works để gom 101 đơn pending của t0042. Cùng lúc đó, status_1 đọc 1.056 đơn pending đầu tiên trong index mà không gặp đơn nào của t0042 (advanced: 0).
  • B thì ngược lại. status_1 chỉ cần 352 works để có 101 đơn disputed của t0000. tenantId_1 lật 352 đơn của t0000 mà không gặp đơn disputed nào.
  • allPlansExecution là số liệu một phần. [tài liệu] Phần này chỉ ghi lại những gì xảy ra trong trial. nReturned: 101 ở đây không phải kết quả cuối. Kết quả cuối nằm ở executionStats phía trên: 128 với A, 154 với B.

Score: công thức đằng sau "nhiều kết quả, ít công"

Score trong output không phải con số bí ẩn:

score ≈ 1                         (điểm nền)
      + advanced / works          (productivity: tỉ lệ work có ích)
      + các điểm thưởng nhỏ        (không cần SORT, không dùng index intersection, ...)
      + 1 nếu plan đã chạy hết EOF trong trial

Kiểm tra bằng số thật:

A, tenantId_1 : 1 + 101/1056 = 1.0956439...   + 0.0002 = 1.0958439  ✓ khớp output
A, status_1   : 1 + 0/1056   = 1               + 0.0002 = 1.0002     ✓
B, status_1   : 1 + 101/352  = 1.2869318...   + 0.0002 = 1.2871318  ✓

Phần + 0.0002 là hai điểm thưởng, mỗi điểm 0.0001: plan không cần SORT trong bộ nhớ, và plan không dùng index intersection. Không plan nào được thưởng "không cần FETCH" vì cả hai đều phải đọc document.

[chi tiết cài đặt] Công thức này nằm trong mã nguồn của plan ranker, không có trong tài liệu. Ở đây nó khớp với output tới chữ số thập phân cuối, nên dùng được để đọc explain. Nhưng đừng coi nó là hợp đồng. Thứ được tài liệu cam kết chỉ là nguyên tắc "nhiều kết quả nhất, ít công nhất".

Thứ đáng nhớ là productivity = advanced / works. Đây là cách planner hỏi: trong mỗi bước đã làm, bao nhiêu bước tạo ra kết quả? Với A, tenantId_1 có ích ở khoảng 1 bước trên 10. status_1 thì không bước nào có ích.

                 works     advanced   productivity
A tenantId_1     1056      101        0,096   ← thắng
A status_1       1056      0          0
B status_1       352       101        0,287   ← thắng
B tenantId_1     352       0          0

Trial chỉ nhìn đoạn đầu

Trial chỉ thấy 101 kết quả đầu tiên. Nếu phần đầu của index không đại diện cho phần còn lại (ví dụ các đơn khớp dồn về cuối), kết luận có thể sai. Đo thật thì chính xác, nhưng chỉ cho đoạn đã đo.

Bước 3: plan cache

30 giây

Chạy trial cho mỗi query thì tốn kém. Vì vậy plan thắng được lưu vào plan cache, một bộ nhớ đệm trong RAM, riêng cho từng collection. Khoá của cache là hình dạng của query.

Plan cache query shape: hình dạng, không phải giá trị

[tài liệu] Plan cache query shape là tổ hợp của predicate, sort, projection và collation. Với predicate, chỉ cấu trúc và tên field được dùng. Giá trị thì bị bỏ qua: { type: 'food' } và { type: 'drink' } là cùng một hình dạng. Mỗi hình dạng có một mã hash là planCacheShapeHash.

Thử với vài biến thể:

const h = c => { const e = c.explain(); return e.queryPlanner.planCacheShapeHash + " / " + e.queryPlanner.planCacheKey; };
h(db.orders.find({ tenantId: "t0042", status: "pending" }));
// ... các biến thể bên dưới
A                2DA7E177 / B1BCA75B
B                2DA7E177 / B1BCA75B
field order swap 2DA7E177 / B1BCA75B     { status, tenantId } thay vì { tenantId, status }
+ limit 20       2DA7E177 / B1BCA75B
+ sort createdAt B5EE3E4E / 3B7D2868
status $in       C1C71E4C / 066A2E7C

[quan sát] A, B, đảo thứ tự field, thêm limit đều cho cùng một hash, nên chúng dùng chung một entry trong cache. Thêm sort hay đổi $eq thành $in thì ra hình dạng khác.

Có hai mã hash, và chúng khác nhau ở một điểm:

TrườngPhụ thuộc vàoDùng để
planCacheShapeHash (trước 8.0 tên là queryHash)chỉ hình dạng querygom các query chậm cùng kiểu trong log, profiler
planCacheKeyhình dạng và các index đang hỗ trợ hình dạng đókhoá thật của entry trong cache; đổi khi thêm hoặc xoá index liên quan

[tài liệu] Từ 8.0, queryHash được nhân bản thành planCacheShapeHash. queryHash đã deprecated và sẽ bị bỏ ở phiên bản sau, nên code giám sát nên đọc planCacheShapeHash. Cũng từ 8.0 có thêm queryShapeHash (chuỗi hex dài ở cuối output explain). Đó là hash của query shape mới, dùng cho query settings và $queryStats. Nó không phải khoá của plan cache. Ba cái tên này dễ lẫn, hãy giữ bảng trên bên cạnh.

Ba trạng thái của một entry

[tài liệu] Mỗi plan cache query shape ở một trong ba trạng thái:

Missing ──(query đầu tiên)──► Inactive ──(plan mới tốn ≤ works ghi nhận)──► Active
                                  ▲   │                                       │
                                  │   └─(plan mới tốn > works) giữ Inactive,  │
                                  │      tăng works ghi nhận                  │
                                  └────────(plan cache không còn đạt)─────────┘
  • Missing: chưa có entry. Query chạy trial, rồi cache tạo một entry Inactive và ghi lại số works của plan thắng.
  • Inactive: chỉ là chỗ giữ. Planner đã thấy hình dạng này nhưng chưa dùng entry để chạy. Query vẫn chạy trial. Nếu plan thắng tốn ít hơn hoặc bằng số works đã ghi, entry thành Active. Nếu tốn nhiều hơn, entry vẫn Inactive và số works ghi nhận được tăng lên.
  • Active: plan trong entry được dùng thẳng, bỏ qua trial. Planner vẫn đánh giá hiệu năng của nó. Nếu plan không còn đạt tiêu chí, entry quay về Inactive.

Vì sao cần bước Inactive? Nó là bộ lọc chống "nhớ nhầm". Một plan chỉ được tin dùng khi nó đã thắng ít nhất hai lần với chi phí không tệ hơn. Một lần thắng ngẫu nhiên nhờ tham số đặc biệt chưa đủ.

Ngân sách của plan đã cache và replanning

Khi entry Active, plan được chạy với một ngân sách. [quan sát] Lý do replan trong profiler cho thấy ngân sách đó:

cached plan was less efficient than expected: expected trial execution to take 1056 works
but it took at least 10560 works

Ngân sách là 10 lần số works đã ghi. [chi tiết cài đặt] Hệ số này là tham số nội bộ internalQueryCacheEvictionRatio, giá trị 10 trong lab. Tài liệu chỉ nói "không còn đạt tiêu chí" mà không công bố con số.

Active entry: works = 1056
        │
        ▼
chạy plan đã cache, đếm works
        │
        ├── đạt 101 kết quả hoặc EOF trước 10.560 works ✓  → dùng tiếp, chạy hết query
        │
        └── tới 10.560 works mà chưa xong ✗               → bỏ, chạy trial lại (replan)

Thí nghiệm: plan cache lật qua lật lại

Cách đo

explain() không dùng được cho thí nghiệm này. [tài liệu] explain bỏ qua plan cache: nó luôn sinh candidate plan và chọn lại từ đầu, và nó cũng không ghi plan thắng vào cache. [quan sát] Số entry trong $planCacheStats trước và sau một lần explain: 2 và 2.

Vì vậy ta chạy query thật bằng toArray(), bật profiler (db.setProfilingLevel(2, { slowms: 0 }), chỉ trong lab08) để đọc các trường planSummary, keysExamined, fromPlanCache, replanned, replanReason. Sau mỗi query, ta đọc cache bằng $planCacheStats, và đọc bộ đếm replan trong serverStatus.

db.orders.getPlanCache().clear();
const seq = [["A",A],["A",A],["B",B],["B",B],["A",A],["A",A],["B",B]];
for (const [name, q] of seq) {
  db.orders.find(q).toArray();
  // đọc entry mới nhất trong system.profile
  // đọc db.orders.aggregate([{ $planCacheStats: {} }])
  // đọc db.serverStatus().metrics.query.planCache.classic.replanned
}

Kết quả thật

Mỗi dòng profiler là lệnh find, tức batch đầu 101 document. Phần getMore sau đó được bỏ ra cho gọn.

#1 A | IXSCAN { tenantId: 1 } | keys 1056 | fromPlanCache -    replanned false | 12 ms
     cache: {isActive:false, works:1056, plan:tenantId_1, key:B1BCA75B, scores:[1.0958,1.0002]}
#2 A | IXSCAN { tenantId: 1 } | keys 1056 | fromPlanCache -    replanned false |  4 ms
     cache: {isActive:true,  works:1056, plan:tenantId_1}
#3 B | IXSCAN { status: 1 }   | keys 352  | fromPlanCache -    replanned TRUE  | 22 ms | replanned+1
     (cached plan was less efficient than expected: expected trial execution to take 1056 works
      but it took at least 10560 works)
     cache: {isActive:true,  works:352,  plan:status_1,   scores:[1.2871,1.0002]}
#4 B | IXSCAN { status: 1 }   | keys 352  | fromPlanCache TRUE replanned false |  1 ms
     cache: {isActive:true,  works:352,  plan:status_1}
#5 A | IXSCAN { tenantId: 1 } | keys 1056 | fromPlanCache -    replanned TRUE  |  6 ms | replanned+1
     (cached plan was less efficient than expected: expected trial execution to take 352 works
      but it took at least 3520 works)
     cache: {isActive:false, works:704,  plan:tenantId_1}
#6 A | IXSCAN { tenantId: 1 } | keys 1056 | fromPlanCache -    replanned false |  2 ms
     cache: {isActive:false, works:1408, plan:tenantId_1}
#7 B | IXSCAN { status: 1 }   | keys 352  | fromPlanCache -    replanned false |  1 ms
     cache: {isActive:true,  works:352,  plan:status_1}

Lần nào profiler cũng ghi queryFramework: "classic".

Đọc từng bước

#1 A  Missing  → trial → tenantId_1 thắng (1056 works) → entry INACTIVE, works 1056
#2 A  Inactive → trial lại → thắng với 1056 ≤ 1056      → entry ACTIVE
#3 B  Active (tenantId_1, ngân sách 10.560)
      tenantId_1 lật đơn của t0000 tìm disputed, 10.560 works chưa đủ 101 kết quả
      → REPLAN → status_1 thắng (352 works) → entry ACTIVE, status_1, works 352
#4 B  Active (status_1) → dùng luôn, fromPlanCache: true, 1 ms
#5 A  Active (status_1, ngân sách 3.520)
      status_1 lật đơn pending tìm t0042, 3.520 works chưa đủ
      → REPLAN → tenantId_1 thắng với 1056 works > 352
      → entry về INACTIVE, works ghi nhận tăng 352 → 704
#6 A  Inactive → trial → 1056 > 704 → vẫn INACTIVE, works 704 → 1408
#7 B  Inactive → trial → status_1 thắng với 352 ≤ 1408 → ACTIVE, status_1

Ba điều đáng chú ý:

  1. Replanning đã cứu cả hai lần lật. Ở #3 và #5, plan trong cache sai cho tham số mới, và ngân sách 10 lần đã bắt được. Kết quả cuối vẫn dùng đúng plan. Nhưng mỗi lần như vậy, server làm thêm việc: chạy plan cũ cho tới khi hết ngân sách, rồi chạy trial lại.
  2. Bước Inactive làm đúng việc của nó. Sau #5, cache không vội tin tenantId_1, vì plan này tốn 1.056 works, nhiều hơn mức 352 đã ghi. [quan sát] Số works ghi nhận tăng gấp đôi mỗi lần (352 → 704 → 1.408). Tham số nội bộ internalQueryCacheWorksGrowthCoefficient trong lab có giá trị 2. Tài liệu chỉ nói "được tăng lên", không nói tăng bao nhiêu.
  3. Ai chạy trước sẽ "chiếm" entry. Ở #7, B đổi entry thành status_1. Nếu sau đó là một loạt query kiểu A, mỗi lần sẽ lại có một vòng replan. Trên production, entry có thể lật liên tục theo luồng request, và mỗi lần lật là một request chậm hơn bình thường.

Replanning tốn bao nhiêu?

Đo B trong ba tình huống, mỗi tình huống 11 lần, chạy 2 lượt. Thời gian đo phía client, gồm cả round-trip:

Tình huống của cache khi B chạymedian lượt 1median lượt 2
Active, status_1 (đúng plan)3,35 ms4,83 ms
Trống, phải chạy trial3,30 ms4,70 ms
Active, tenantId_1 (sai plan) → replan12,90 ms11,06 ms

[quan sát] Bộ đếm metrics.query.planCache.classic.replanned tăng đúng 11 ở mỗi lượt, một lần cho mỗi lần chạy kiểu thứ ba. Trial với hai candidate plan rẻ tới mức gần như không thấy được (3,30 so với 3,35 ms). Replan thì tốn khoảng 2,3 đến 3,9 lần (11,06/4,83 và 12,90/3,35), vì server phải trả cả 10.560 works cho plan sai rồi mới bắt đầu lại.

Khi cache giữ plan tệ mà không ai báo

Replanning chỉ bảo vệ ta khi plan sai vượt ngân sách trong đoạn đầu. Còn trường hợp plan sai vẫn qua được đoạn đầu thì sao?

Query C: { tenantId: "t0000", status: "refunded" }, kết quả 29.882 đơn. Nếu không có cache, planner chọn thế nào?

explain C (không dùng cache): winner status_1, keys 99483
  candidate status_1    works 334  advanced 101  score 1.3026   ← thắng
  candidate tenantId_1  works 334  advanced 32   score 1.0960

status_1 đúng là tốt hơn: đọc 99.483 key thay vì 299.814. Bây giờ cho A chạy trước hai lần để entry thành Active với tenantId_1, rồi chạy C:

C sau A (round 1): keys 299814 docs 299814 n 29882 first-op {"op":"query","fromPlanCache":true,"plan":"IXSCAN { tenantId: 1 }"}
   cache now: tenantId_1 active true works 1056

Không có replan. Theo tỉ lệ trong explain ở trên (32 kết quả sau 334 works), tenantId_1 cần khoảng 1.050 works để có 101 đơn refunded của t0000, vẫn nằm trong ngân sách 10.560. Plan qua được đoạn đầu, rồi chạy tiếp hết query: 299.814 key và 299.814 document để trả về 29.882 đơn.

So sánh thời gian phía server (tổng millis của find và các getMore trong profiler), 9 lần mỗi lượt, 2 lượt:

Plan cho query CkeysExaminedmedian lượt 1median lượt 2
tenantId_1 lấy từ cache (A đến trước)299.814203 ms183 ms
status_1 qua hint()99.483103 ms126 ms
compound tenantId_1_status_1 (phần sau)29.88230 ms32 ms
CACHE "CHIẾM" BỞI A                      PLAN TỐT NHẤT TRONG HAI INDEX ĐƠN
IXSCAN tenantId_1                        IXSCAN status_1
  ↓ 299.814 key                            ↓ 99.483 key
FETCH 299.814 document                   FETCH 99.483 document
  ↓ filter status = refunded               ↓ filter tenantId = t0000
29.882 kết quả                           29.882 kết quả

Query C chậm hơn 1,5 đến 2 lần (183–203 ms so với 103–126 ms) chỉ vì một query khác chạy trước nó. Không có lỗi nào được báo, replanned không bật. Dấu hiệu duy nhất là fromPlanCache: true cùng tỉ lệ keysExamined / nreturned cao bất thường trong slow query log.

Đây là cái bẫy cốt lõi của plan cache: nó cache theo hình dạng, kiểm tra theo đoạn đầu, và tin plan cho phần còn lại. Với dữ liệu lệch (một tenant chiếm 30%), ba điều đó cộng lại thành những query lúc nhanh lúc chậm. Nhìn từ ứng dụng, hiện tượng này trông như ngẫu nhiên.

Cost-based ranker: điều mới trong 8.3

[tài liệu] Từ MongoDB 8.3, cơ chế chọn plan mặc định cho các query đủ điều kiện là multi-planning có cost-based ranker (CBR) làm dự phòng. Multi-planner chạy một trial ngắn để tìm plan trả được kết quả. Nếu không tìm được, MongoDB áp một bộ quy tắc để quyết định: tiếp tục multi-planning, hay để CBR đánh giá. CBR tính chi phí từng node trong plan bằng một hàm chi phí và ước lượng cardinality, rồi chọn plan có tổng chi phí thấp nhất. Tài liệu ghi rõ hiện tại CBR chỉ được gọi cho một lượng nhỏ query. Cả hai cơ chế đều ghi plan thắng vào cùng một plan cache.

Ở các query A, B, C, multi-planner đều tìm được plan có kết quả, nên CBR không tham gia. Để thấy CBR, cần một query mà không plan nào trả được kết quả trong trial:

// total tối đa là 10.000.000, nên query này trả về 0 document
const q = { tenantId: "t0000", status: "completed", total: { $gt: 10000000 } };
db.orders.find(q).explain("allPlansExecution");
metrics.query.cbr.count            0 -> 1
metrics.query.cbr.choseWinningPlan 0 -> 1
winning : FETCH <- IXSCAN(tenantId_1)
rejected: FETCH <- IXSCAN(status_1)
rejected: FETCH{cost:1513.75, card:0, ce:{"ceSource":"Sampling"}}
            <- IXSCAN(status_1){cost:311.76, card:715789.47, ce:{"ceSource":"Sampling"}}
exec    : nReturned 0  keys 299814  docs 299814  executionTimeMillis 483
  cand tenantId_1  works 299815  advanced 0  isEOF 1  score 2.0002
  cand status_1    works 5000    advanced 0  isEOF 0

[quan sát] Những điều chắc chắn đọc được từ output:

  • Bộ đếm trong serverStatus cho thấy CBR đã được gọi và đã chọn plan thắng cho query này.
  • Plan bị loại có thêm các trường [tài liệu] mới từ 8.3.3: costEstimate, cardinalityEstimate, estimatesMetadata.ceSource. Nguồn ước lượng ở đây là Sampling: server lấy mẫu dữ liệu để đoán.
  • Ước lượng là xấp xỉ. Có 715.789 key completed theo ước lượng, trong khi thật là 700.317. Chạy lại lần nữa thì ra 694.737, vì mẫu đổi giữa các lần.
  • status_1 xuất hiện hai lần trong rejectedPlans, chỉ một lần có số ước lượng. Tài liệu chưa giải thích cách trình bày này, nên tôi không suy diễn thêm.

Điều cần mang theo: trên 8.3, đa số query vẫn được chọn plan bằng chạy thử, như toàn bộ phần trên. CBR là phương án dự phòng cho những ca mà chạy thử không phân định được. Muốn biết query của bạn rơi vào nhánh nào, hãy xem metrics.query.cbr trong serverStatus và các trường ước lượng trong explain. Từ 9.0, slow query log có thêm trường planRankerMethod ("cbr" hoặc "mp").

Đọc explain: ba mức verbosity và từng trường quan trọng

Ba mức verbosity

VerbosityChạy gìTrả về
"queryPlanner" (mặc định)chạy trial để chọn plan, không chạy hết queryqueryPlanner: plan thắng, plan bị loại
"executionStats"chạy trial, rồi chạy plan thắng tới hếtthêm executionStats của plan thắng
"allPlansExecution"như trênthêm allPlansExecution: số liệu một phần của mọi candidate trong trial

⚠ executionStats và allPlansExecution thực sự chạy query. Trên collection lớn, một explain có thể nặng ngang chính query đó. Với lệnh ghi, explain không ghi dữ liệu thật, nhưng vẫn tốn công đi tìm document.

Phần queryPlanner

TrườngÝ nghĩaNhìn vào để làm gì
explainVersion'1': plan của classic engine. '2': có phần chạy bằng SBEbiết engine nào chạy (phần sau)
namespacedb.collection
parsedQueryfilter sau khi chuẩn hoá: $and ngầm định thành tường minh, $eq thành tường minhkiểm tra MongoDB hiểu filter đúng ý bạn
indexFilterSetcó index filter (deprecated) áp lên hình dạng này khôngnếu true, hint() bị bỏ qua
querySettingsquery settings áp dụng (8.0+)
planCacheShapeHash / queryHashhash của hình dạnggom slow query cùng kiểu
planCacheKeykhoá entry trong cachetra $planCacheStats
optimizationTimeMillisthời gian tối ưu hoá, gồm cả trialtrial đắt khi có nhiều candidate
winningPlancây stage được chọnđọc từ trong ra ngoài
rejectedPlanscác candidate bị loạinếu rỗng thì chỉ có một cách chạy
isCachedplan này có phải plan đang ở trong cache khôngtrong explain luôn là false ở lab, vì explain không đọc cache
max...Reachedplanner có chạm giới hạn số plan khi bung $or/$in khôngnếu true, có thể đã có plan tốt bị bỏ qua

Trong winningPlan, ba thứ cần đọc ở IXSCAN là indexName, indexBounds (khoảng key sẽ quét, ví dụ ["t0042", "t0042"]) và isMultiKey. Ở FETCH, hãy xem filter: đó là những điều kiện không được index trả lời, phải kiểm tra trên từng document. Filter ở FETCH càng loại nhiều document thì index càng ít hợp với query.

Phần executionStats

Explain thật của A, đã cắt bớt:

executionStats: {
  executionSuccess: true,
  nReturned: 128,
  executionTimeMillis: 17,
  totalKeysExamined: 1409,
  totalDocsExamined: 1409,
  executionStages: {
    stage: 'FETCH', filter: { status: { '$eq': 'pending' } },
    nReturned: 128, executionTimeMillisEstimate: 15,
    works: 1410, advanced: 128, needTime: 1281, needYield: 0,
    saveState: 2, restoreState: 2, isEOF: 1,
    docsExamined: 1409, alreadyHasObj: 0,
    inputStage: {
      stage: 'IXSCAN', nReturned: 1409,
      works: 1410, advanced: 1409, needTime: 0, isEOF: 1,
      keysExamined: 1409, seeks: 1, dupsTested: 0, dupsDropped: 0
    }
  }
}

Bộ ba quan trọng nhất là nReturned, totalKeysExamined, totalDocsExamined:

totalKeysExamined 1409 ──► totalDocsExamined 1409 ──► nReturned 128
      (IXSCAN)                    (FETCH)               (sau filter status)

tỉ lệ docs/nReturned = 11 : cứ 11 document fetch thì 1 cái có ích

Bốn hình mẫu hay gặp:

Hình mẫuNghĩa
keys ≈ docs ≈ nReturnedindex trả lời gần như toàn bộ filter. Lý tưởng.
keys ≈ docs ≫ nReturnedindex chỉ trả lời một phần, FETCH vứt đi nhiều. Thiếu field trong index (A ở trên, C ở phần trước).
keys ≫ docsindex loại được nhiều trước khi fetch (compound index có điều kiện trên field sau, hoặc multikey phải khử trùng).
docs = 0, keys = nReturnedcovered query (bài 08): không đụng tới document.

Các trường theo từng stage:

  • works, advanced, needTime: nhịp làm việc đã gặp ở trên. Ở FETCH: 1.410 works, 128 advanced, 1.281 needTime. Tức là 1.281 lần fetch một document lên rồi thấy không phải pending.
  • needYield: số lần stage phải nhường để storage engine xử lý xung đột hoặc chờ dữ liệu. Thường bằng 0 trong lab yên tĩnh.
  • saveState / restoreState: [tài liệu] số lần stage tạm dừng, lưu trạng thái (ví dụ để nhả lock trong lúc yield) rồi khôi phục. Con số lớn trên query dài là bình thường.
  • isEOF: stage đã đi hết dữ liệu chưa. Một LIMIT có thể isEOF: 1 trong khi IXSCAN con của nó isEOF: 0.
  • seeks ở IXSCAN: số lần nhảy tới vị trí mới trong B-tree. 1 nghĩa là quét một đoạn liền. Con số lớn (với $in dài, hoặc compound index bị quét kiểu "nhảy cóc") cho thấy index đang được dùng kém liên tục.
  • dupsTested / dupsDropped: khử trùng khi index là multikey.
  • executionTimeMillis: [tài liệu] gồm cả chọn plan và thực thi, không gồm mạng. Vì plan đã cache bỏ qua bước chọn, con số này có thể không phản ánh thời gian ổn định khi chạy thật. executionTimeMillisEstimate ở từng stage là ước lượng thô. Đừng cộng chúng lại.

[quan sát] Lần explain đầu của A ghi executionTimeMillis: 25, lần sau ghi 17. Với các query ngắn, hãy chạy vài lần và nhìn số works và keys, đừng tin một con số mili giây.

[tài liệu] Output explain đổi khá nhiều qua các phiên bản: 8.0 chỉ giữ phần find trong rejectedPlans, 8.2 thêm các chỉ số spill, 8.3 thêm peakTrackedMemBytes, 8.3.3 thêm score và các trường của CBR. Đọc bài blog cũ thì để ý phiên bản.

hint(): ép planner theo ý mình

30 giây

hint() bỏ qua việc chọn plan và ép MongoDB dùng index bạn chỉ định:

db.orders.find({ tenantId: "t0000", status: "refunded" }).hint({ status: 1 })   // theo key pattern
db.orders.find({ tenantId: "t0000", status: "refunded" }).hint("status_1")      // theo tên
db.orders.find({ ... }).hint({ $natural: 1 })                                   // ép COLLSCAN
hint status_1 explain  : keys 99483  docs 99483  n 29882  rejectedPlans 0
hint tenantId_1 explain: keys 299814 docs 299814 n 29882

rejectedPlans: 0 vì không còn gì để chọn. [tài liệu] Hint tới một index không tồn tại hoặc đang hidden sẽ báo lỗi. Nếu hình dạng đã có index filter thì hint() bị bỏ qua. Query có $text thì không dùng hint() được.

Cái giá của hint

hint() trong code
   │
   ├── ✓ plan ổn định, hết lật
   ├── ✓ bỏ qua trial và replan
   ├── ✗ đóng cứng quyết định: dữ liệu đổi phân bố thì hint không đổi theo
   ├── ✗ hint đúng cho C lại sai cho A (status_1 cho A: lật 99.656 key pending để lấy 128 đơn)
   └── ✗ đổi tên hoặc xoá index → query báo lỗi ngay trên production

Hint giải quyết được bài toán lật plan khi bạn biết chắc một plan tốt cho mọi giá trị tham số. Với dataset này thì không có plan nào như vậy trong hai index đơn: A cần tenantId_1, B và C cần status_1. Cách đúng hơn là cho planner một lựa chọn tốt cho mọi tham số. Đó là phần "sửa tận gốc" bên dưới.

Index filter và query settings

[tài liệu] Trước 8.0, muốn ép plan mà không sửa code thì dùng index filter (planCacheSetFilter). Index filter đã deprecated từ 8.0. Thay thế là query settings: setQuerySettings gắn index được phép (indexHints), hoặc engine thực thi, vào một query shape. Tài liệu của setQuerySettings nói index filter không bền (mất khi restart) và khó áp cho mọi node trong cluster, còn query settings thì không có hai hạn chế đó.

[quan sát] Trên standalone của lab, lệnh này bị từ chối:

MongoServerError: setQuerySettings can only run on replica sets or sharded clusters

Query settings là "hint từ phía server", áp theo hình dạng, nên có đúng điểm yếu của hint khi plan tốt nhất phụ thuộc vào tham số.

Classic engine và slot-based engine (SBE)

30 giây

Planner quyết định chạy theo plan nào. Còn ai thực thi plan đó thì có hai engine: classic engine, và slot-based execution engine (SBE) có từ 5.1. [tài liệu] SBE dùng mô hình "slot" để tránh phải dựng ra các kết quả trung gian trong lúc chạy. Trong đa số trường hợp, nó tốn ít CPU và bộ nhớ hơn.

              planner (multi-planner / CBR)
                      │  chọn cây plan: GROUP ← FETCH ← IXSCAN
                      ▼
        ┌─────────────┴─────────────┐
   classic engine                 SBE
   stage có works/advanced         cây slot: ixseek, seek, filter, group ...
   explainVersion '1'              explainVersion '2'
   queryFramework: classic         queryFramework: sbe

Query nào chạy bằng SBE?

[tài liệu] MongoDB tự quyết định theo từng query, dựa trên việc các operator và expression trong query có được SBE hỗ trợ hay không. Tài liệu nói rõ phạm vi hỗ trợ "thay đổi theo phiên bản". Hai trường hợp phổ biến được nêu là pipeline có $group hoặc $lookup. Theo dòng thời gian:

Phiên bảnThay đổi [tài liệu]
5.1SBE ra đời, dùng cho một số query
7.0SBE cải thiện hiệu năng cho "phạm vi rộng hơn" các query find và aggregation. Slow query log có thêm queryFramework
8.0chọn engine cho từng query shape được qua query settings. Tự tắt SBE trên collection có index kiểu "hashed path là tiền tố của một path không hashed". Thêm block processing cho time series
8.3$planCacheStats đổi output (version 1 = classic, 2 = SBE)
9.0slow query log có thêm planRankerMethod

[quan sát] Trong lab 8.3.11, tham số nội bộ internalQueryFrameworkControl có giá trị "trySbeRestricted". Mọi query find thuần ở trên đều chạy classic (explainVersion: '1', queryFramework: "classic"). Thêm $group thì khác:

db.orders.explain("executionStats").aggregate([
  { $match: { tenantId: "t0042", status: "pending" } },
  { $group: { _id: "$userId", revenue: { $sum: "$total" } } }
])
explainVersion 2
winningPlan keys: isCached,queryPlan,slotBasedPlan
queryPlan: GROUP <- FETCH <- IXSCAN(tenantId_1)
slotBasedPlan.stages:
  [4] project [s15 = newBsonObj("_id", s13, "revenue", s14)]
  [4] project [s14 = doubleDoubleSumFinalize(s12)]
  [4] group [s13] [s12 = aggDoubleDoubleSum(s8)] spillSlots[s11] ...
  [2] filter {traverseF(s10, ...
exec: nReturned 100 keys 1409 docs 1409
top stage keys: stage,planNodeId,nReturned,executionTimeMillisEstimate,opens,closes,saveState,restoreState,isEOF,...

Ba khác biệt khi đọc explain của SBE:

  1. explainVersion: '2', và winningPlan có hai phần. queryPlan là cây stage quen thuộc, đọc như classic. slotBasedPlan là cây thực thi thật bằng slot. [tài liệu] slotBasedPlan "dành cho MongoDB dùng nội bộ". Đọc để tò mò thì được, đừng parse nó trong tool giám sát.
  2. Không có works/advanced/needTime ở các stage. [tài liệu] works chỉ có ở classic engine. Thay vào đó có opens/closes. Phân tích theo totalKeysExamined/totalDocsExamined/nReturned thì vẫn dùng được như cũ.
  3. Profiler ghi queryFramework: "sbe". Đây là cách chắc chắn nhất để biết engine thật trên production, vì nó lấy từ lần chạy thật chứ không phải từ explain.

Một chỗ tài liệu và quan sát không khớp

Tài liệu $planCacheStats (đổi ở 8.3) nói trường version là 1 khi dùng classic engine và 2 khi dùng SBE. [quan sát] Trong lab, pipeline $group ở trên chạy bằng SBE (profiler: queryFramework: "sbe", fromPlanCache: true), nhưng entry của nó trong $planCacheStats lại có version: 1, cachedPlan dạng classic (PROJECTION_SIMPLE ← FETCH ← IXSCAN). Bộ đếm metrics.query.planCache.sbe.* vẫn đứng ở 0, còn classic.hits thì tăng.

Cách đọc an toàn là: trên 8.3.11 với cấu hình mặc định, plan của query chạy bằng SBE vẫn có thể được cache trong classic plan cache. Đừng suy ra engine từ trường version của plan cache. Hãy dùng explainVersion hoặc queryFramework trong profiler/slow log. Nếu bạn cần tự động hoá việc này, hãy kiểm lại trên đúng phiên bản và cấu hình của mình.

Sửa tận gốc: cho planner một lựa chọn đúng với mọi tham số

Plan lật vì không index nào tốt cho mọi giá trị. Bài 08 đã có câu trả lời: compound index theo đúng các điều kiện bằng của query.

db.orders.createIndex({ tenantId: 1, status: 1 });   // build 2.135 ms, 6,1 MB
{"tenantId":"t0042","status":"pending"}  -> tenantId_1_status_1 keys 128   docs 128   n 128   rejected 2
{"tenantId":"t0000","status":"disputed"} -> tenantId_1_status_1 keys 154   docs 154   n 154   rejected 2
{"tenantId":"t0000","status":"refunded"} -> tenantId_1_status_1 keys 29882 docs 29882 n 29882 rejected 2
TRƯỚC (2 index đơn, plan lật theo tham số)      SAU (compound)
A: 1.409 key → 128      (nếu cache đúng)        A: 128 key → 128
B:   496 key → 154      (nếu cache đúng)        B: 154 key → 154
C: 299.814 key → 29.882 (cache "chiếm" bởi A)   C: 29.882 key → 29.882
   99.483 key           (nếu cache đúng)

Với mọi tham số, keys = docs = nReturned. Plan compound thắng trial ở mọi giá trị, nên dù cache giữ nó cho hình dạng này, nó không bao giờ sai. Query C đi từ 183–203 ms (cache sai) và 103–126 ms (hint đúng) xuống 30–32 ms phía server (median, đo bằng profiler như bảng ở trên).

Không có gì miễn phí:

Thêm { tenantId: 1, status: 1 }
   │
   ├── ✓ plan ổn định với mọi tham số, hết lật cache
   ├── ✓ keys = docs = nReturned cho mọi filter tenantId + status
   ├── ✗ thêm 6,1 MB index, cần nằm trong cache để nhanh (bài 21)
   ├── ✗ mỗi insert, và mỗi update đổi tenantId/status, ghi thêm một index
   └── ⚠ tenantId_1 giờ là tiền tố thừa của compound index → cân nhắc xoá (bài 08)

Lưu ý cuối: rejectedPlans: 2. Planner vẫn sinh ba candidate và vẫn chạy trial. Index đơn tenantId_1 giờ thừa, vì compound index đã phục vụ được mọi query trên tiền tố tenantId. Xoá nó đi vừa bớt chi phí ghi, vừa bớt một candidate cho mọi lần trial.

So với PostgreSQL

Hai hệ thống giải cùng một bài toán theo hai triết lý khác nhau:

MongoDB (classic multi-planner)PostgreSQL
Chọn plan bằngchạy thử thật các candidate trong một trial ngắnước lượng chi phí từ thống kê (ANALYZE: histogram, most common values)
Thống kêkhông cần thu thập trước. Từ 8.3, CBR lấy mẫu khi cầnphải có và phải mới. Thống kê cũ dẫn tới plan sai
Plan có được nhớ khôngcó: plan cache theo hình dạng, dùng chung cho mọi giá trịcâu lệnh thường được lập plan lại mỗi lần. Prepared statement có thể chuyển sang generic plan sau vài lần chạy (plan_cache_mode)
Bẫy "tham số lệch"entry cache lật hoặc bị chiếm (thí nghiệm ở trên)generic plan tốt cho giá trị phổ biến nhưng tệ cho giá trị hiếm, cùng một loại bẫy
Ép planhint(), query settings (8.0+)không có hint chính thức. Thường dùng SET enable_*, extension pg_hint_plan, hoặc sửa index/thống kê
Xem planexplain("executionStats")EXPLAIN (ANALYZE, BUFFERS)

Chạy thử không cần thống kê nhưng chỉ nhìn đoạn đầu. Ước lượng nhìn toàn cục nhưng sai khi thống kê sai. Cả hai cùng gặp khó khi một plan được dùng lại cho những tham số phân bố rất khác nhau, và cách chữa giống nhau: một index tốt cho mọi tham số, hoặc tách query thành nhiều hình dạng.

Những lỗi thường gặp

  • Chỉ nhìn stage: 'IXSCAN' rồi kết luận "có dùng index, ổn". C dùng IXSCAN mà vẫn đọc 299.814 key để trả 29.882 đơn. Hãy luôn đặt totalKeysExamined, totalDocsExamined, nReturned cạnh nhau.
  • Dùng explain để giải thích một query chậm trên production. Explain bỏ qua plan cache. Production có thể đang chạy một plan khác. Hãy xem slow query log/profiler: planSummary, fromPlanCache, replanned, planCacheShapeHash.
  • Thử với tham số "đẹp" trong môi trường dev. Tenant test thường nhỏ. Trên production, tenant lớn nhất mới là thứ làm lật plan. Hãy explain với cả giá trị phổ biến nhất lẫn hiếm nhất.
  • Gắn hint() để chữa cháy rồi quên. Dữ liệu đổi, hint thì không. Xoá hay đổi tên index thì query lỗi ngay.
  • Tạo một index đơn cho mỗi field rồi trông vào planner. Hai index đơn cho filter hai field là công thức sinh ra plan lật theo tham số. Một compound index đúng (bài 08) thường giải quyết tận gốc.
  • Xoá plan cache (getPlanCache().clear()) như một cách sửa. Query tiếp theo chọn lại, và nếu nó mang tham số "xấu" thì cache lật như cũ. [tài liệu] Cache cũng tự xoá khi restart, khi tạo/xoá/ẩn index, và theo LRU.
  • Suy ra engine từ $planCacheStats.version. Trong lab, query chạy bằng SBE vẫn có entry version: 1. Hãy dùng explainVersion và queryFramework.

Tóm tắt

  • Planner sinh candidate plan từ các index dùng được và chạy thử chúng xen kẽ trong trial period, dừng khi một plan có 101 kết quả, chạy hết, hoặc chạm trần works. Plan thắng có productivity (advanced/works) cao nhất. Score trong lab khớp công thức tới chữ số cuối.
  • Plan thắng vào plan cache, khoá theo hình dạng (predicate, sort, projection, collation, bỏ giá trị). planCacheKey còn phụ thuộc vào các index.
  • Entry đi qua Missing → Inactive → Active. Entry Active có ngân sách 10 lần số works đã ghi, vượt thì replan: query B từ 3–5 ms lên 11–13 ms.
  • Nguy hiểm hơn là plan sai mà vẫn qua được trial: C dùng plan A để lại, đọc 299.814 key thay vì 99.483, không có cảnh báo nào.
  • Từ 8.3, CBR làm dự phòng khi trial không phân định được (trong lab: query trả 0 kết quả, ước lượng bằng Sampling).
  • hint() và query settings (8.0+, cần replica set) ép được plan nhưng đóng cứng quyết định. Compound index đúng cho keys = docs = nReturned với mọi tham số (C: 30 ms).
  • Classic vs SBE: explainVersion 1/2, queryPlan/slotBasedPlan, SBE không có works, queryFramework trong log. Trên 8.3.11, find thuần chạy classic, pipeline có $group chạy SBE.

Tự kiểm tra

  1. Hai query { tenantId: "t1", status: "a" } và { tenantId: "t2", status: { $in: ["a"] } } có dùng chung một entry plan cache không? (Không. $eq và $in là hai hình dạng khác nhau, lab cho 2DA7E177 và C1C71E4C.)
  2. Explain cho bạn plan status_1, nhưng slow log của cùng query trên production ghi IXSCAN { tenantId: 1 } và fromPlanCache: true. Vì sao hai bên khác nhau? (Explain không đọc cache, nó chọn lại từ đầu. Production đang dùng entry do một tham số khác để lại.)
  3. Một entry Active có works: 500. Query mới với plan đó cần 3.000 works để có 101 kết quả. Có replan không? Nếu cần 6.000 works thì sao? (3.000 < 5.000 nên không replan, plan chạy hết query. 6.000 vượt ngân sách 10 × 500 nên replan, theo hệ số quan sát trong lab.)
  4. Vì sao score của plan thua ở A là 1.0002 mà không phải 1? (Điểm nền 1, productivity 0, cộng hai điểm thưởng 0,0001: không cần SORT và không dùng index intersection.)
  5. Làm sao biết một aggregation trên production chạy bằng SBE? (Xem queryFramework trong slow query log/profiler, hoặc explainVersion: '2' và slotBasedPlan trong explain. Đừng dựa vào version của $planCacheStats.)

Nếu bỏ hết thuật ngữ: khi có nhiều đường, người quản lý cho chạy thử một đoạn ngắn rồi ghi đường thắng vào sổ, theo kiểu chuyến chứ không theo tên khách. Lần sau anh dùng lại đường đó, chỉ quay lại thử khi đường cũ tốn gấp mười lần. Khách lớn và khách nhỏ dùng chung một trang sổ, nên có lúc khách này phải đi đường của khách kia. Cách chữa tận gốc là làm một con đường tốt cho cả hai.

Bài tiếp theo

Toàn bộ bài này xoay quanh find: một filter, một cây stage, một plan. Nhưng nhiều query thật là một chuỗi bước: lọc, nhóm, nối với collection khác, sắp xếp, rồi định dạng. Ta đã thấy một chút ở đó: pipeline $group chạy bằng SBE, và planner chỉ chọn plan cho phần $match ở đầu.

Bài 11, Aggregation Pipeline, đi tiếp từ đây. Các stage xếp thành dây chuyền ra sao, MongoDB tự sắp xếp lại pipeline thế nào, vì sao index chỉ dùng được ở đầu pipeline, $group và $sort ăn bao nhiêu RAM trước khi phải spill ra đĩa, và $lookup/$facet tốn gì. Cách đọc explain của bài này, gồm explainVersion, queryPlan và bộ ba keys/docs/nReturned, sẽ là công cụ chính ở đó.

Tài liệu tham khảo