Query Planner & explain(): MongoDB chọn cách chạy query ra sao
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 PipelineMô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êngmongo-lab-08, databaselab08. 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 periodBa ý cần giữ suốt bài:
- 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.)
- 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. - 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 : 29882Cố ý 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ên | Filter | Index tốt hơn | Vì sao |
|---|---|---|---|
| A | { tenantId: "t0042", status: "pending" } | tenantId_1 | tenant nhỏ (1.409 đơn), pending thì nhiều (99.656) |
| B | { tenantId: "t0000", status: "disputed" } | status_1 | tenant khổng lồ (299.814), disputed thì hiếm (496) |
| C | { tenantId: "t0000", status: "refunded" } | status_1 | 99.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ệuVí 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_1cầ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_1chỉ cần 352 works để có 101 đơn disputed của t0000.tenantId_1lật 352 đơn của t0000 mà không gặp đơn disputed nào. allPlansExecutionlà 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 ởexecutionStatsphí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 trialKiể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 0Trial 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ướiA 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ường | Phụ thuộc vào | Dùng để |
|---|---|---|
planCacheShapeHash (trước 8.0 tên là queryHash) | chỉ hình dạng query | gom các query chậm cùng kiểu trong log, profiler |
planCacheKey | hì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 worksNgâ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_1Ba điều đáng chú ý:
- 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.
- 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ộinternalQueryCacheWorksGrowthCoefficienttrong 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. - 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ạy | median lượt 1 | median lượt 2 |
|---|---|---|
Active, status_1 (đúng plan) | 3,35 ms | 4,83 ms |
| Trống, phải chạy trial | 3,30 ms | 4,70 ms |
Active, tenantId_1 (sai plan) → replan | 12,90 ms | 11,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.0960status_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 1056Khô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 C | keysExamined | median lượt 1 | median lượt 2 |
|---|---|---|---|
tenantId_1 lấy từ cache (A đến trước) | 299.814 | 203 ms | 183 ms |
status_1 qua hint() | 99.483 | 103 ms | 126 ms |
compound tenantId_1_status_1 (phần sau) | 29.882 | 30 ms | 32 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
serverStatuscho 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
completedtheo ướ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_1xuất hiện hai lần trongrejectedPlans, 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
| Verbosity | Chạy gì | Trả về |
|---|---|---|
"queryPlanner" (mặc định) | chạy trial để chọn plan, không chạy hết query | queryPlanner: plan thắng, plan bị loại |
"executionStats" | chạy trial, rồi chạy plan thắng tới hết | thêm executionStats của plan thắng |
"allPlansExecution" | như trên | thê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ĩa | Nhìn vào để làm gì |
|---|---|---|
explainVersion | '1': plan của classic engine. '2': có phần chạy bằng SBE | biết engine nào chạy (phần sau) |
namespace | db.collection | |
parsedQuery | filter sau khi chuẩn hoá: $and ngầm định thành tường minh, $eq thành tường minh | kiểm tra MongoDB hiểu filter đúng ý bạn |
indexFilterSet | có index filter (deprecated) áp lên hình dạng này không | nếu true, hint() bị bỏ qua |
querySettings | query settings áp dụng (8.0+) | |
planCacheShapeHash / queryHash | hash của hình dạng | gom slow query cùng kiểu |
planCacheKey | khoá entry trong cache | tra $planCacheStats |
optimizationTimeMillis | thời gian tối ưu hoá, gồm cả trial | trial đắt khi có nhiều candidate |
winningPlan | cây stage được chọn | đọc từ trong ra ngoài |
rejectedPlans | các candidate bị loại | nếu rỗng thì chỉ có một cách chạy |
isCached | plan này có phải plan đang ở trong cache không | trong explain luôn là false ở lab, vì explain không đọc cache |
max...Reached | planner có chạm giới hạn số plan khi bung $or/$in không | nế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ó íchBốn hình mẫu hay gặp:
| Hình mẫu | Nghĩa |
|---|---|
| keys ≈ docs ≈ nReturned | index trả lời gần như toàn bộ filter. Lý tưởng. |
| keys ≈ docs ≫ nReturned | index 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 ≫ docs | index 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 = nReturned | covered 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ảipending.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ộtLIMITcó thểisEOF: 1trong khi IXSCAN con của nóisEOF: 0.seeksở IXSCAN: số lần nhảy tới vị trí mới trong B-tree.1nghĩa là quét một đoạn liền. Con số lớn (với$indà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 COLLSCANhint status_1 explain : keys 99483 docs 99483 n 29882 rejectedPlans 0
hint tenantId_1 explain: keys 299814 docs 299814 n 29882rejectedPlans: 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 productionHint 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 clustersQuery 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: sbeQuery 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ản | Thay đổi [tài liệu] |
|---|---|
| 5.1 | SBE ra đời, dùng cho một số query |
| 7.0 | SBE 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.0 | chọ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.0 | slow 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:
explainVersion: '2', vàwinningPlancó hai phần.queryPlanlà cây stage quen thuộc, đọc như classic.slotBasedPlanlà 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.- Không có
works/advanced/needTimeở các stage. [tài liệu]workschỉ có ở classic engine. Thay vào đó cóopens/closes. Phân tích theototalKeysExamined/totalDocsExamined/nReturnedthì vẫn dùng được như cũ. - 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 2TRƯỚ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ằng | chạ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ần | phải có và phải mới. Thống kê cũ dẫn tới plan sai |
| Plan có được nhớ không | có: 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 plan | hint(), 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 plan | explain("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 đặttotalKeysExamined,totalDocsExamined,nReturnedcạ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ó entryversion: 1. Hãy dùngexplainVersionvà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ị).
planCacheKeycò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 chokeys = docs = nReturnedvới mọi tham số (C: 30 ms).- Classic vs SBE:
explainVersion1/2,queryPlan/slotBasedPlan, SBE không có works,queryFrameworktrong log. Trên 8.3.11,findthuần chạy classic, pipeline có$groupchạy SBE.
Tự kiểm tra
- 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.$eqvà$inlà hai hình dạng khác nhau, lab cho 2DA7E177 và C1C71E4C.) - Explain cho bạn plan
status_1, nhưng slow log của cùng query trên production ghiIXSCAN { 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.) - 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.) - Vì sao score của plan thua ở A là
1.0002mà không phải1? (Đ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.) - Làm sao biết một aggregation trên production chạy bằng SBE? (Xem
queryFrameworktrong slow query log/profiler, hoặcexplainVersion: '2'vàslotBasedPlantrong explain. Đừng dựa vàoversioncủ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
- Query Plans (multi-planner, cost-based ranker, plan cache states, flushes, planCacheShapeHash/planCacheKey)
- Explain Results
- $planCacheStats
- cursor.hint()
- Slot-Based Query Execution Engine
- Query Shapes
- Glossary: plan cache query shape
- Release Notes for MongoDB 7.0 (Slot-Based Query Execution Engine)
- Release Notes for MongoDB 8.0 (query shape, query settings, planCache metrics)
- Release Notes for MongoDB 8.3 (Cost-Based Ranker)
- MongoDB Server Parameters (planCacheSize)