Query Planner (P2/3): Plan cache và sự cố query bỗng chậm

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

Ở phần trước: planner chạy trial cho mỗi candidate plan và chấm điểm bằng productivity, tức advanced chia cho works. Plan thắng được ghi vào plan cache, nên các lần sau không phải chạy lại trial.

Bước 3: plan cache

Ý chính

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à query shape của query.

Plan cache query shape: cấu trúc, 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 query shape. Mỗi query shape có một mã hash là planCacheShapeHash.

Thử vài biến thể, đọc planCacheShapeHash / planCacheKey trong explain:

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 query shape 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ỉ query shapegom các query chậm cùng kiểu trong log, profiler
planCacheKeyquery shape và các index đang hỗ trợ query shape đó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 và sẽ bị bỏ ở phiên bản sau; code giám sát nên đọc planCacheShapeHash. Cũng từ 8.0 có queryShapeHash (chuỗi hex dài ở cuối explain): hash của query shape mới, dùng cho query settings và $queryStats, không phải khoá plan cache. Phần query settings sẽ cho thấy hai loại "query shape" này khác nhau ở đâu.

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, cache tạo entry Inactive ghi số works của plan thắng.
  • Inactive: chỗ giữ, chưa dùng để chạy; query vẫn chạy trial. Plan thắng tốn ≤ works đã ghi thì entry thành Active, tốn hơn thì vẫn Inactive và works ghi nhận được tăng lên.
  • Active: plan được dùng thẳng, bỏ qua trial, nhưng vẫn bị đánh giá; không còn đạt tiêu chí thì về Inactive.

Bước Inactive là bộ lọc chống "nhớ nhầm": một plan chỉ được tin khi đã thắng ít nhất hai lần với chi phí không tệ hơn.

Budget của plan đã cache và replanning

Khi entry Active, plan được chạy với một budget. [quan sát] Lý do replan trong profiler ("expected trial execution to take 1056 works but it took at least 10560 works", gặp lại ở phần sự cố) cho thấy budget 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)

Đọc explain từ góc nhìn của planner

Phần này gom lại những gì explain nói về quyết định của planner. Từng stage chạy ra sao (works, needYield, seeks...) thuộc về bài Query Execution Engine, nơi có bảng tra mọi stage.

VerbosityChạy gìTrả về
"queryPlanner" (mặc định)chạy trial để chọn plan, không chạy hết querywinningPlan, rejectedPlans
"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 số liệu một phần (tới cuối trial) của mọi candidate

⚠ Hai mức sau thực sự chạy query. Trên collection lớn, một explain có thể nặng ngang chính query đó.

⚠ [tài liệu] Explain bỏ qua plan cache: nó luôn chọn lại từ đầu và 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. Explain cho biết planner sẽ chọn gì nếu chọn lại lúc này, không cho biết production đang chạy plan nào. Sự cố ở phần sau xoay quanh đúng khoảng cách đó.

Ba câu hỏi:

  • Plan nào thắng, đọc index nào, quét khoảng nào? winningPlan: ở IXSCAN đọc indexName và indexBounds; ở FETCH đọc filter, tức điều kiện index không trả lời được. rejectedPlans rỗng nghĩa là planner không phải chọn.
  • Vì sao plan kia thua? allPlansExecution: so advanced/works của từng candidate, như ở Bước 2.
  • Plan thắng có thật sự tốt cho cả query không? Planner chỉ nhìn đoạn đầu; executionStats cho biết giá của cả query. Đặt ba con số cạnh nhau:
totalKeysExamined 1409 ──► totalDocsExamined 1409 ──► nReturned 128      (A, plan tenantId_1)
      (IXSCAN)                    (FETCH)               (sau filter status)
tỉ lệ docs/nReturned = 11 : cứ 11 document fetch thì 1 cái có ích

keys ≈ docs ≈ nReturned là lý tưởng; keys ≈ docs ≫ nReturned (như A ở trên, C ở phần sau) nghĩa là index chỉ trả lời một phần filter và FETCH vứt đi nhiều. Các hình mẫu còn lại nằm trong bảng tra của bài Query Execution Engine.

queryPlanner còn hai trường dùng ở phần sau: planCacheKey (tra $planCacheStats) và querySettings (8.0+, plan đang bị ghim từ phía server).

Sự cố: query đang nhanh bỗng chậm

Bối cảnh

[hình dung] Câu chuyện là kịch bản dựng lại; mọi con số trong đó là số đo thật.

Màn hình "Đơn hàng theo trạng thái" của một hệ thống SaaS gọi một query: find({ tenantId, status }), trên collection có tenantId_1 và status_1. Nhiều tháng liền, người dùng là các shop nhỏ xem đơn pending (kiểu A). Plan cache ổn định ở tenantId_1, query chạy vài mili giây.

Rồi hai thứ đổi, không thứ nào là code: phân bố dữ liệu (khách lớn t0000 chuyển sang, chiếm 30% số đơn) và tham số (đội chăm sóc khách hàng của t0000 xem disputed và refunded, kiểu B và C). Từ hôm đó dashboard có những gai latency không theo quy luật.

Diễn biến bên trong plan cache

Ta chạy lại chuỗi request đó. Explain bỏ qua cache nên ta chạy query thật, bật profiler (db.setProfilingLevel(2, { slowms: 0 }), chỉ trong database lab) để đọc planSummary, fromPlanCache, replanned, replanReason, rồi đọc $planCacheStats sau mỗi query. Kết quả thật (môi trường lab08; mỗi dòng là lệnh find, tức batch đầu 101 document):

#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
     (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
#5 A | IXSCAN { tenantId: 1 } | keys 1056 | fromPlanCache -    replanned TRUE  |  6 ms
     (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}
#1–#2  A, A : Missing → Inactive → Active (tenantId_1, 1056 works, budget 10.560)
#3     B    : tenantId_1 hết 10.560 works chưa đủ 101 kết quả → REPLAN → status_1
#5     A    : status_1 hết budget 3.520 → REPLAN → tenantId_1 thắng nhưng 1056 > 352 → INACTIVE
#7     B    : status_1 thắng với 352 ≤ 1408 → B lại chiếm entry

Replanning bắt được cả hai lần lật. [quan sát] Số works ghi nhận tăng gấp đôi mỗi lần (352 → 704 → 1.408); [chi tiết cài đặt] tham số nội bộ internalQueryCacheWorksGrowthCoefficient = 2, tài liệu chỉ nói "được tăng lên". Nhưng ai chạy trước sẽ chiếm entry: entry lật theo luồng request, và mỗi lần lật là một request chậm. Đó là những gai trên dashboard. Mỗi gai tốn bao nhiêu? Đo B, mỗi tình huống 11 lần, 2 lượt, thời gian phía client (môi trường lab08):

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. Trial với hai candidate gần như miễn phí; replan tốn khoảng 2,3 đến 3,9 lần, vì server trả cả budget cho plan sai rồi mới chạy trial lại.

Ca nặng nhất: plan sai mà không ai báo

Replanning chỉ bảo vệ ta khi plan sai vượt budget trong đoạn đầu. Query C của đội chăm sóc khách hàng (t0000 + refunded, 29.882 đơn) thì không vượt. Khi cache trống, planner chọn đúng:

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

Bây giờ cho A (shop nhỏ) 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 (32 kết quả sau 334 works), tenantId_1 cần khoảng 1.050 works để có 101 đơn, trong budget 10.560. Plan qua đoạn đầu rồi chạy hết query: 299.814 key và 299.814 document cho 29.882 đơn.

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 (môi trường lab08):

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

C chậm 1,5 đến 2 lần chỉ vì một query khác chạy trước nó, không lỗi, không replanned. Đó là cái bẫy cốt lõi: cache theo query shape, kiểm tra theo đoạn đầu, tin plan cho phần còn lại.

Phát hiện: dấu vết trong slow query log

Explain chạy lúc điều tra sẽ chọn lại từ đầu và cho status_1: trông hoàn toàn ổn. Dấu vết thật nằm ở lần chạy thật. Hai dòng slow query log thật (môi trường lab10, đã lọc trường), của C sau A và của B khi bị replan:

"Slow query" C  planSummary "IXSCAN { tenantId: 1 }"  keysExamined 967  nreturned 101  fromPlanCache true
                planCacheShapeHash "2DA7E177"  planCacheKey "B1BCA75B"
"Slow query" B  planSummary "IXSCAN { status: 1 }"  keysExamined 352  nreturned 101  replanned true
                replanReason "cached plan was less efficient than expected: expected trial execution
                              to take 1056 works but it took at least 10560 works"
                planCacheShapeHash "2DA7E177"  planCacheKey "B1BCA75B"

Cách đọc:

  1. Gom theo planCacheShapeHash. [tài liệu] Trường này chỉ phụ thuộc vào query shape, nên mọi biến thể của màn hình rơi vào một nhóm. Cùng hash mà planSummary khi thế này khi thế khác nghĩa là entry đang lật.
  2. replanned: true và replanReason đánh dấu từng lần lật bị bắt. Bộ đếm toàn server là metrics.query.planCache.classic.replanned.
  3. Ca C không có replanned. Dấu hiệu duy nhất là fromPlanCache: true đi cùng tỉ lệ keysExamined / nreturned cao bất thường (967/101; cả query 299.814/29.882). Một cảnh báo trên tỉ lệ này bắt được cả những plan sai mà replanning bỏ sót.
  4. Xác nhận bằng $planCacheStats: db.orders.aggregate([{ $planCacheStats: {} }, { $match: { planCacheShapeHash: "2DA7E177" } }]) cho biết entry đang Active hay Inactive, giữ plan nào, works bao nhiêu (lab10: isActive: true, works: 1056, plan tenantId_1).

[quan sát] Dòng find chỉ phản ánh batch đầu; mỗi getMore có dòng riêng. Dựng cả quy trình giám sát là việc của bài Observe → Diagnose → Tune.

Chữa cháy tạm, và ba cách chữa thật

Cách nhanh nhất là xoá entry: db.orders.getPlanCache().clearPlansByQuery({ tenantId: "x", status: "y" }) (chỉ query shape có nghĩa; [quan sát] sau lệnh còn 0 entry). Nhưng query kế tiếp chọn lại theo tham số của nó, và 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, nên sự cố kiểu này hay "tự hết" rồi quay lại.

Muốn hết hẳn, có ba tầng: ép plan trong code (hint()), ép plan từ phía server (query settings), và cho planner một lựa chọn tốt cho mọi tham số (index).

Cột mốc: Bạn đã biết plan cache khoá theo query shape, và đọc slow log để tìm một entry chọn sai plan. Tiếp theo: hint(), query settings và sửa tận gốc.

Hỏi & đáp

Explain cho query C 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?

  1. Explain chạy trên dữ liệu thống kê cũ, còn production dùng dữ liệu mới nhất

    Multi-planner không dựa vào thống kê thu thập trước: nó chạy thử thật các candidate. Khác biệt nằm ở plan cache, không ở thống kê. Xem mục "Đọc explain từ góc nhìn của planner".

  2. Explain bỏ qua plan cache; production dùng entry do một tham số khác để lại

    Explain luôn chọn lại từ đầu và không ghi vào cache (số entry trong $planCacheStats trước và sau explain: 2 và 2). Trong lab, A chạy trước hai lần làm entry Active với tenantId_1, rồi C dùng lại entry đó và đọc 299.814 key. Xem mục "Phát hiện: dấu vết trong slow query log".

  3. Slow log ghi plan của lần chạy trial, còn explain ghi plan chạy hết query

    Slow log ghi lần chạy thật; fromPlanCache: true nghĩa là không có trial nào cả, plan lấy thẳng từ cache. Xem mục "Phát hiện: dấu vết trong slow query log".

  4. hint() của driver chỉ có tác dụng trên production, không có tác dụng trong explain

    Không có hint() nào trong kịch bản này. Plan khác nhau vì explain bỏ qua plan cache, còn production dùng entry đang có. Xem mục "Đọc explain từ góc nhìn của planner".

Một entry Active có works: 500. Query mới dùng plan đó cần 3.000 works để có 101 kết quả. Theo hệ số quan sát trong lab, chuyện gì xảy ra?

  1. Replan, vì 3.000 works nhiều gấp 6 lần số works đã ghi

    Budget là 10 lần works đã ghi, tức 5.000. 3.000 vẫn nằm trong budget nên không replan. Xem mục "Budget của plan đã cache và replanning".

  2. Entry chuyển về Inactive nhưng query vẫn chạy plan cũ tới hết

    Entry chỉ về Inactive khi có replan, và replan chỉ xảy ra khi vượt budget 10 × 500 = 5.000 works. 3.000 thì chưa vượt. Xem mục "Ba trạng thái của một entry".

  3. Không replan: 3.000 < 10 × 500, plan qua đoạn đầu và chạy hết query

    Budget là 10 lần works đã ghi (internalQueryCacheEvictionRatio = 10 trong lab, như replanReason "expected 1056 works but it took at least 10560"). Nếu cần 6.000 works thì vượt 5.000 và replan. Xem mục "Budget của plan đã cache và replanning".

Query C (t0000 + refunded) chạy sau A và dùng tenantId_1 từ cache: đọc 299.814 key cho 29.882 đơn, không có replanned. Vì sao replanning không bắt được?

  1. Replanning chỉ chạy khi entry đang Inactive, mà entry này đang Active

    Ngược lại: replanning là cơ chế canh chừng plan của entry Active. Nó không bắt được C vì C không vượt budget trong đoạn đầu. Xem mục "Ca nặng nhất: plan sai mà không ai báo".

  2. Replanning tắt mặc định từ 8.0, phải bật bằng tham số

    Bài không nói vậy, và trong lab replanning bắt được cả hai lần lật của A và B (replanned: true). Xem mục "Diễn biến bên trong plan cache".

  3. Replanning so tổng works của cả query, mà C chỉ chậm gấp 1,5 lần

    Cache chỉ kiểm tra plan ở đoạn đầu (tới 101 kết quả), không so tổng works của cả query. Xem mục "Budget của plan đã cache và replanning".

  4. Plan đủ 101 kết quả trong budget nên được tin dùng cho phần còn lại

    Cache chỉ kiểm tra đoạn đầu. Theo tỉ lệ trong explain (32 kết quả sau 334 works), plan qua được budget rồi chạy hết query: 299.814 key, 183–203 ms so với 103–126 ms của status_1. Dấu hiệu duy nhất là fromPlanCache: true cùng keys/nreturned cao. Xem mục "Ca nặng nhất: plan sai mà không ai báo".