Query Planner (P3/3): `hint()`, query settings và sửa tận gốc
Ở phần trước: plan cache khoá theo query shape, nên một entry có thể đúng với giá trị này nhưng sai với giá trị khác. Xoá entry chỉ là chữa cháy, vì query kế tiếp chọn lại theo tham số của nó.
- Cần đọc trước: Plan cache và sự cố query bỗng chậm
- Dẫn tới: Aggregation Pipeline, bài này khép lại ở đây.
hint(): ép planner theo ý mình
Ý chính
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 COLLSCANExplain của C với hint("status_1"): keys 99.483, 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 query shape đã có index filter, hoặc query settings có index hints (phần sau), 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, 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 (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 chỉ chữa được khi bạn biết chắc một plan tốt cho mọi giá trị tham số. Ở đây không có: A cần tenantId_1, B và C cần status_1. Code biết tham số nên có thể hint khác nhau theo tenant, nhưng khi đó logic chọn plan bị kéo vào ứng dụng, và vẫn phải chờ deploy.
Query settings: ghim plan từ phía server (8.0+)
Ý chính
Sự cố đang diễn ra, bản deploy có hint() sớm nhất là chiều mai. Query settings gắn quyết định vào query shape ngay trên server: không sửa code, không deploy.
[tài liệu] setQuerySettings (mới từ 8.0) gắn vào một query shape: indexHints.allowedIndexes (các index planner được phép dùng), queryFramework (ép classic hay SBE), reject: true (từ chối mọi query mới có query shape đó, một "cầu dao" khi query đang làm sập cluster), và comment (từ 8.0.4/8.1). Hai câu cần đọc kỹ: index hints trong query settings thu hẹp tập index planner được xét nhưng không bảo đảm nó dùng index đó (vẫn có thể chọn COLLSCAN); và query settings được ưu tiên hơn hint gửi kèm câu lệnh.
[tài liệu] Nó thay index filter (planCacheSetFilter), deprecated từ 8.0 vì chỉ sống trong RAM của một mongod (mất khi restart), khó đặt cho mọi node, và ít chức năng hơn. Query settings áp cho cả cluster và bền qua restart.
Lab: cần replica set
[quan sát] Trên standalone, lệnh bị từ chối: MongoServerError: setQuerySettings can only run on replica sets or sharded clusters (trang tài liệu của lệnh không ghi yêu cầu này). Vì vậy phần này chạy trên mongod thứ hai trong mongo-lab-10, khởi động bằng --replSet rs10 --port 27018 rồi rs.initiate() với một member. Dataset sinh lại bằng đúng script và seed khớp từng con số, và sự cố tái hiện y hệt: sau A, A, query C chạy tenantId_1 từ cache với 299.814 key.
Ghim status_1 cho query shape { tenantId, status }
Đội trực quyết định ghim status_1, vì đội chăm sóc khách hàng đang kêu. Lệnh nhận một câu query đại diện (giá trị nào cũng được, chỉ query shape có nghĩa):
db.adminCommand({
setQuerySettings: { find: "orders", filter: { tenantId: "t0000", status: "refunded" }, $db: "lab10" },
settings: {
indexHints: { ns: { db: "lab10", coll: "orders" }, allowedIndexes: ["status_1"] },
comment: "INC-0427: pin status_1 for tenantId+status" // mã sự cố minh hoạ
}
})
// → { queryShapeHash: 'E551AD443CCD0822...', settings: {...}, representativeQuery: {...}, ok: 1 }
db.getSiblingDB("admin").aggregate([{ $querySettings: {} }]) // liệt kê mọi setting đang cóPlan trước và sau
Explain (executionStats) trước và sau khi đặt setting (môi trường lab10):
| Query | Trước: plan | keys | rejected | Sau: plan | keys | rejected |
|---|---|---|---|---|---|---|
A t0042 + pending | tenantId_1 | 1.409 | 1 | status_1 | 99.656 | 0 |
B t0000 + disputed | status_1 | 496 | 1 | status_1 | 496 | 0 |
C t0000 + refunded | status_1 | 99.483 | 1 | status_1 | 99.483 | 0 |
[quan sát] Explain có thêm queryPlanner.querySettings (đúng indexHints và comment): cách nhanh nhất để biết query đang bị ghim. C gửi kèm hint("tenantId_1") vẫn chạy status_1: hint từ client bị bỏ qua, như tài liệu nói. planCacheKey đổi từ B1BCA75B sang 68A8E90B, và sau A, A thì $planCacheStats trống: chỉ một candidate thì không có gì để cache, nên không còn gì để lật.
Thời gian phía server (tổng find + getMore trong profiler), 9 lần mỗi lượt, 2 lượt, môi trường lab10:
| Tình huống | plan | keys | median lượt 1 | median lượt 2 |
|---|---|---|---|---|
| C, cache bị A chiếm, không setting | tenantId_1 (cache) | 299.814 | 100 ms | 98 ms |
| C, cache trống, planner tự chọn | status_1 | 99.483 | 42 ms | 41 ms |
| C, có setting (A chạy trước) | status_1 | 99.483 | 49 ms | 40 ms |
| A, không setting | tenantId_1 | 1.409 | 1 ms | 1 ms |
| A, có setting | status_1 | 99.656 | 36 ms | 37 ms |
(Một lần đo lại dòng đầu cho 109 và 105 ms. Số lab10 thấp hơn lab08 vì đo lúc máy host chịu tải khác; chỉ so trong cùng một bảng.)
Setting chữa đúng ca của đội chăm sóc khách hàng: C hết phụ thuộc vào ai chạy trước, từ ~100 ms về ~40 ms. Nhưng shop nhỏ giờ chậm hẳn (từ ~1 ms lên 36–37 ms): A bị buộc lật 99.656 đơn pending để lấy 128 đơn. Query settings áp theo query shape, nên mang đúng điểm yếu của hint(). Ta vừa chuyển sự cố từ khách lớn sang khách nhỏ.
Bẫy: query shape không phải plan cache query shape
[quan sát] Với setting trên, thêm limit, skip hay projection vào cùng filter thì setting không áp dụng nữa:
plan queryShapeHash planCacheShapeHash querySettings
find(A) status_1 E551AD443CCD... 2DA7E177 có
find(A).limit(20) tenantId_1 31CC4C3B695C... 2DA7E177 không
find(A).skip(10) tenantId_1 FD0DE465E26B... 2DA7E177 không
find(A, { total: 1 }) tenantId_1 859B349C4078... D0B20AB2 khôngPlan cache bỏ qua limit, nhưng query shape mới phân biệt có hay không có limit/skip (giá trị thì không: limit(50) cho cùng hash với limit(20)). [tài liệu] Query shape mới bao gồm "phần lớn các trường" của lệnh find/aggregate/distinct, rộng hơn plan cache query shape. Vì vậy hãy đặt setting từ đúng câu lệnh trong slow log, hoặc dùng thẳng queryShapeHash của nó (setQuerySettings: "<hash>"), rồi kiểm bằng explain rằng querySettings đã xuất hiện.
Bền qua restart, và cách gỡ
[quan sát] Tắt rồi bật lại mongod port 27018: $querySettings vẫn còn đúng setting đó, khớp với tài liệu. Gỡ bằng removeQuerySettings, theo hash hoặc theo một câu query cùng query shape (giá trị không cần khớp):
db.adminCommand({ removeQuerySettings: "E551AD443CCD082271EABA9BB22C60C5C2FC873DE9572792D5A9E0472FC778AB" })
// hoặc: db.adminCommand({ removeQuerySettings: { find: "orders", filter: { tenantId: "x", status: "y" }, $db: "lab10" } })$querySettings sau khi gỡ : 0 document
A : win tenantId_1 | rejected 1 | keys 1409 | planCacheKey B1BCA75B | querySettings null
C : win status_1 | rejected 1 | keys 99483 | planCacheKey B1BCA75B | querySettings nullQuery settings
├── ✓ hiệu lực ngay, không deploy, mọi client, mọi node, bền qua restart
├── ✗ áp theo query shape: tốt cho tham số này, tệ cho tham số kia (A: 1 → 36 ms)
└── ⚠ trạng thái nằm ngoài code: ghi comment trỏ về sự cố, gỡ khi đã sửa tận gốcDùng nó như băng gạc lúc sự cố đang diễn ra, hoặc khi bạn biết chắc một plan tốt cho mọi tham số. Ở đây thứ cần là một index mới.
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 Compound Indexes & ESR đã 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 }); // lab08: 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 2Với mọi tham số, keys = docs = nReturned. Plan compound thắng trial ở mọi giá trị, nên dù cache giữ nó cho query shape 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, môi trường lab08). A và B cũng được lợi, không ai phải nhường ai.
Đây là cái kết đúng của sự cố: build index (giờ thấp điểm, xem bài Index Fundamentals), kiểm bằng explain với cả tham số nhỏ lẫn lớn, rồi gỡ query settings đã đặt lúc chữa cháy. Không gỡ thì planner vẫn chỉ được dùng status_1, và index mới tốn chi phí ghi mà query shape này không được dùng. [tài liệu] Tạo index cũng xoá plan cache của collection, nên entry cũ không sống sót.
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 (xem bài Cache & Working Set)
├── ✗ 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árejectedPlans: 2: planner vẫn chạy trial với ba candidate. Xoá tenantId_1 vừa bớt chi phí ghi, vừa bớt một candidate.
So với PostgreSQL
| MongoDB (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 (CBR của 8.3 lấy mẫu khi cần) | phải có và phải mới |
| Plan có được nhớ không | có: plan cache theo query shape, 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 (sự cố ở phần trước, Plan cache và sự cố query bỗng chậm) | 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ê |
Cả hai cùng 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 query shape.
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à đọc 299.814 key để trả 29.882 đơn. Luôn đặt keys, docs, nReturned cạnh nhau. - Dùng explain để giải thích một query chậm trên production. Explain bỏ qua plan cache. Hãy xem slow log:
planSummary,fromPlanCache,replanned,planCacheShapeHash. - Thử với tham số "đẹp" ở môi trường dev. Tenant lớn nhất mới làm lật plan. Explain với cả giá trị phổ biến nhất lẫn hiếm nhất.
- Ghim plan (
hint(), query settings) rồi quên. Dữ liệu đổi, quyết định đã ghim thì không. Query settings còn nằm ngoài code và không theo câu lệnh khi nó thêmlimit/skip. - Một index đơn cho mỗi field rồi trông vào planner. Công thức sinh ra plan lật theo tham số.
Cột mốc: Bạn đã biết khi nào nên dùng hint() hoặc query settings, vì sao chúng đóng cứng quyết định, và vì sao compound index đúng với mọi tham số là cách sửa tận gốc. Bài Query Planner khép lại ở đây.
Hỏi & đáp
Sự cố đang diễn ra, bạn đặt query settings ghim status_1 cho query shape { tenantId, status }. Hệ quả nào là đúng theo lab?
Bạn đặt query settings cho find({ tenantId, status }), nhưng màn hình vẫn chạy plan cũ. Màn hình gọi .limit(50). Vì sao?
Một kho có hai tuyến giao hàng. Quản lý ghi tuyến thắng vào sổ theo kiểu chuyến, không theo tên khách, và khách lớn lẫn khách nhỏ dùng chung một trang. Cách chữa nào bền nhất?
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. Dán cứng một tuyến lên trang sổ thì khách còn lại khổ. 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
Bài này xoay quanh find: một filter, một plan. Nhiều query thật là một chuỗi bước: lọc, nhóm, sắp xếp. Planner vẫn có mặt, nhưng chỉ chọn plan cho phần lọc ở đầu chuỗi.
Bài Aggregation Pipeline đi tiếp từ đây: server tự sắ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 spill ra đĩa.
Tài liệu tham khảo
- Query Plans (multi-planner, cost-based ranker, plan cache states, flushes, index filters, planCacheShapeHash/planCacheKey)
- Explain Results
- $planCacheStats
- cursor.hint()
- setQuerySettings
- removeQuerySettings
- Query Shapes
- Database Profiler Output (replanned, replanReason, planCacheShapeHash, queryShapeHash)
- Glossary: plan cache query shape
- Release Notes for MongoDB 8.0 (query shape, query settings, planCache metrics)
- Release Notes for MongoDB 8.3 (Cost-Based Ranker)