Query Planner (P3/3): `hint()`, query settings và sửa tận gốc

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

Ở 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ó.

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 COLLSCAN

Explain 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 production

Hint 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):

QueryTrước: plankeysrejectedSau: plankeysrejected
A t0042 + pendingtenantId_11.4091status_199.6560
B t0000 + disputedstatus_14961status_14960
C t0000 + refundedstatus_199.4831status_199.4830

[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ốngplankeysmedian lượt 1median lượt 2
C, cache bị A chiếm, không settingtenantId_1 (cache)299.814100 ms98 ms
C, cache trống, planner tự chọnstatus_199.48342 ms41 ms
C, có setting (A chạy trước)status_199.48349 ms40 ms
A, không settingtenantId_11.4091 ms1 ms
A, có settingstatus_199.65636 ms37 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ông

Plan 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 null
Query 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ốc

Dù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 2

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 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ằ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 (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ôngcó: 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 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ê

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êm limit/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?

  1. Mọi tham số đều nhanh hơn vì planner không phải chạy trial nữa

    Setting áp theo query shape: C về ~40 ms, nhưng A (shop nhỏ) từ ~1 ms lên 36–37 ms vì phải lật 99.656 đơn pending để lấy 128 đơn. Xem mục "Plan trước và sau".

  2. C hết phụ thuộc thứ tự chạy, nhưng A chậm lên 36–37 ms

    Query settings ghim plan cho cả query shape, nên mang đúng điểm yếu của hint(): tốt cho tham số này, tệ cho tham số kia. Đó là băng gạc; cách chữa tận gốc là compound index. Xem mục "Plan trước và sau".

  3. Setting chỉ sống trong RAM của một mongod và mất khi restart

    Đó là index filter (planCacheSetFilter), deprecated từ 8.0. Query settings áp cho cả cluster và bền qua restart; lab đã tắt, bật lại mongod và setting vẫn còn. Xem mục "Bền qua restart, và cách gỡ".

  4. Client vẫn ép được tenantId_1 bằng hint() cho riêng A

    Query settings được ưu tiên hơn hint gửi kèm câu lệnh: trong lab, C gửi hint("tenantId_1") vẫn chạy status_1. Xem mục "Plan trước và sau".

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?

  1. Giá trị limit khác với câu query đại diện, nên hash khác

    Giá trị không quan trọng: limit(50) cho cùng queryShapeHash với limit(20). Thứ phân biệt là có hay không có limit. Xem mục "Bẫy: query shape không phải plan cache query shape".

  2. Plan cache coi câu có limit là query shape khác, nên dùng entry khác

    Ngược lại: plan cache bỏ qua limit (cùng planCacheShapeHash 2DA7E177). Chính query shape mới mới phân biệt. Xem mục "Bẫy: query shape không phải plan cache query shape".

  3. Query shape tính cả việc có limit, nên không khớp setting

    Query shape của query settings rộng hơn plan cache query shape. Trong lab, find(A).limit(20) có queryShapeHash khác và chạy tenantId_1, không có querySettings. Hãy đặt setting từ đúng câu lệnh trong slow log hoặc dùng thẳng queryShapeHash của nó. Xem mục "Bẫy: query shape không phải plan cache query shape".

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?

  1. Dán cứng tuyến tốt cho khách lớn lên trang sổ

    Đó là hint() hay query settings: khách còn lại khổ. Trong lab, ghim status_1 làm A từ ~1 ms lên 36–37 ms. Xem mục "Query settings: ghim plan từ phía server (8.0+)".

  2. Xoá trang sổ mỗi khi có khách phàn nàn

    Đó là clearPlansByQuery: query kế tiếp chọn lại theo tham số của nó và sổ lật như cũ. Xem Plan cache và sự cố query bỗng chậm.

  3. Làm con đường mới tốt cho cả khách lớn lẫn nhỏ

    Đó là compound index { tenantId: 1, status: 1 }: với mọi tham số keys = docs = nReturned, plan thắng ở mọi giá trị nên cache giữ nó cũng không bao giờ sai; C xuống 30–32 ms. Giá là 6,1 MB index và thêm chi phí ghi. Xem mục "Sửa tận gốc: cho planner một lựa chọn đúng với mọi tham số".

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