Cost-Based Ranker (P3/3): CBR và plan cache; biết ranker nào chạy

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

Ở phần trước: CBR ước lượng từ dữ liệu mẫu, và một lần chọn sai có thể làm query chậm gấp đôi. Với chunk sampling, CBR chọn sai 15 trên 200 lần.

CBR và plan cache

Cùng một cache, cùng luật chơi

[tài liệu] Cả hai cơ chế ghi plan thắng vào cùng plan cache và dùng lại cho các query cùng plan cache query shape. Luật Missing → Inactive → Active và replanning của bài Query Planner & Plan Cache áp dụng nguyên vẹn.

Chi tiết quan trọng là con số works được ghi cho một plan do CBR chọn.

[quan sát] Trong allPlansExecution, plan được CBR chọn tiếp tục chạy sau trial ngắn cho tới khi ra kết quả, chạy hết, hoặc chạm trần 30% số document của collection (300.000 works trên orders, 180.000 trên imported). Con số đó vào plan cache:

lần 1: CBR chọn sai tenantId_1
       $planCacheStats: tenantId_1, isActive false, works 180.000   (= 0,3 × 600.000)
lần 2: entry Inactive → lập plan lại → CBR lấy mẫu mới, chọn region_1
       region_1 cần 120.377 works ≤ 180.000 → thay entry, isActive true
lần 3+: dùng cache, cbr.count không tăng

[chi tiết cài đặt] Trần 30% khớp với tham số nội bộ internalQueryPlanEvaluationCollFraction: 0.3.

Lần này cơ chế Inactive đã cứu: một lần chọn sai chỉ để lại entry Inactive, và lần chạy sau có cơ hội sửa. Nhưng nếu hai lần liên tiếp cùng chọn sai thì sao?

Thí nghiệm: plan sai thành Active

Lặp: xoá plan cache, chạy query thật (find(q).toArray(), không phải explain). Nếu lần đầu chọn sai, chạy thêm lần nữa và xem entry.

281 lần thử: 21 lần lần đầu chọn sai (7,5%, khớp thí nghiệm 4)
lần thử 281: lần thứ hai cũng sai → entry tenantId_1, isActive true, works 180.000

9 lần chạy tiếp theo:
  cbr.count không tăng (dùng cache)
  thời gian median 95 ms (91–98), so với 47–55 ms khi region_1 được cache

[quan sát] Plan sai giờ được cache Active với budget replan 10 × 180.000 = 1,8 triệu works. Query chỉ cần 270.000. Theo luật replanning, entry này không bao giờ tự bị loại, cho tới khi cache bị xoá (restart, tạo/xoá/ẩn index, LRU) hoặc ai đó can thiệp.

Cùng loại bẫy với vụ plan cache lật trong bài Query Planner & Plan Cache, chỉ khác nguồn gốc: ở đó là tham số lệch, ở đây là mẫu xui. Hai lần xui liên tiếp, trong lab là 1 trên 281 lần cache trống (khoảng 0,4%), nghe nhỏ. Nhưng cache bị xoá mỗi lần tạo index hay restart, và mỗi lần như vậy là một lần thử mới.

Cách chữa

Giống hệt chương trước, vì vấn đề nằm ở chỗ có hai plan gần nhau mà planner phải đoán:

  • Compound index phục vụ cả hai điều kiện ({ tenantId: 1, region: 1 }) biến hai plan "đoán được" thành một plan rõ ràng tốt nhất.
  • Query settings (8.0+) ghim plan cho một query shape khi bạn biết chắc plan nào đúng. Xem bài Query Planner & Plan Cache.
  • Không dùng các tham số nội bộ (internalQueryCBRCEMode, internalQuerySamplingCEMethod) trên production. Chúng không có trong tài liệu, không được hỗ trợ, và có thể biến mất ở bản vá sau.

Làm sao biết ranker nào đã chạy

[quan sát] Trên 8.3.11, plan cache entry và slow query log không ghi plan do multi-planner hay CBR chọn. $planCacheStats của một entry do CBR tạo có cùng các trường như mọi entry khác, candidatePlanScores vẫn có một score kiểu multi-planner (2.0002). Slow query log ghi fromMultiPlanner: true và planningTimeMicros (58.855 µs cho lần chọn đúng, 74.672 µs cho lần chọn sai, vì thời gian lập plan gồm cả phần chạy tiếp tới trần 30%), nhưng không có trường nào về CBR.

Những gì dùng được trên 8.3:

Công cụCho biết gìGhi chú
db.serverStatus().metrics.query.cbrcount (số lần CBR được dùng), choseWinningPlan, numPlans, numPlansFailedCostEstimation, numPlansTiedCostEstimation, micros, samplingMicros, và histogram thời gian[tài liệu] mới từ 8.3.3. Đếm toàn server, không theo query. So sánh hai lần đọc để thấy CBR có chạy không
explain()costEstimate, cardinalityEstimate, numKeysEstimate, estimatesMetadata.ceSource trên các stage được CBR định giá[tài liệu] mới từ 8.3.3. [quan sát] ở chế độ mặc định chỉ thấy trên plan bị loại. Explain bỏ qua plan cache, nên nó cho biết planner sẽ chọn gì bây giờ, không phải production đang dùng gì
$queryStats → metrics.costBasedRankertheo từng query shape: nDocsSampled (sum/min/max), cardinalityEstimationMethods (Sampling, Heuristics, Histogram, Mixed, Metadata, Code)[tài liệu] mới từ 8.3.3; $queryStats được bật trên Atlas từ tier M10. [quan sát] trong lab tự host phải bật bằng tham số nội bộ, chỉ để kiểm chứng

Một đoạn kiểm tra nhanh:

const before = db.serverStatus().metrics.query.cbr;
db.orders.find({ tenantId: "t0000", status: "completed", channel: "web" }).toArray();
const after = db.serverStatus().metrics.query.cbr;
print("CBR calls:", after.count - before.count, "chose:", after.choseWinningPlan - before.choseWinningPlan);

Trên server có nhiều client, hiệu này lẫn cả query của người khác; theo từng query shape thì dùng $queryStats.

Từ 9.0 (không áp dụng cho 8.3): [tài liệu] $queryStats được bật mặc định (lấy mẫu 1% thao tác) và có nhóm metrics.queryPlanner mới, gồm bộ đếm true/false fromMultiPlanner, fromPlanCache và planShapeCounters. Tài liệu 9.0 không có trường slow query log nào cho biết ranker nào đã chạy.

So với PostgreSQL

PostgreSQL luôn ước lượng, không chạy thử. So sánh rõ nhất là ở chỗ ước lượng lấy dữ liệu từ đâu.

[tài liệu PostgreSQL] Planner dùng thống kê lưu trong catalog pg_statistic (bản dễ đọc là view pg_stats): số giá trị khác nhau (n_distinct), danh sách giá trị phổ biến nhất (most_common_vals), và histogram_bounds. Thống kê được tạo bởi ANALYZE, chạy tay hoặc do autovacuum tự chạy khi bảng thay đổi nhiều. Với bảng lớn, ANALYZE lấy một mẫu ngẫu nhiên chứ không đọc hết. default_statistics_target (mặc định 100) quyết định độ dài tối đa của danh sách giá trị phổ biến và số bin của histogram. Thống kê "luôn là xấp xỉ" và đổi chút ít sau mỗi lần ANALYZE, nên hiếm khi plan đổi theo. Với các cột tương quan, có thể tạo thống kê mở rộng bằng CREATE STATISTICS (dependencies, ndistinct, mcv).

Trong EXPLAIN, rows= là số dòng ước lượng mà một node trả ra. EXPLAIN ANALYZE in thêm actual rows. Cost tính theo đơn vị trừu tượng, quy ước theo chi phí đọc một trang đĩa.

MongoDB 8.3 (CBR)PostgreSQL
Khi nào ước lượngchỉ khi trial ngắn không ra kết quả (một lượng nhỏ query)mọi query
Nguồn ước lượnglấy mẫu lúc lập plan, mỗi lần một mẫu mới (~380 document)thống kê lưu sẵn từ ANALYZE (mẫu ngẫu nhiên, MCV, histogram)
Dữ liệu cũkhông có khái niệm thống kê cũ: mẫu luôn mớithống kê cũ sau thay đổi lớn là nguyên nhân kinh điển của plan sai
Tương quan giữa fieldsampling đánh giá cả filter trên mẫu, nên tự nắm đượcmặc định coi các cột độc lập; cần CREATE STATISTICS
Giá trị hiếmthường đoán 0MCV và histogram nắm được giá trị phổ biến; giá trị hiếm ước lượng từ phần còn lại
Chi phí ước lượngtrả mỗi lần lập plan (~0,2 ms trong lab)trả một lần lúc ANALYZE; lập plan chỉ đọc thống kê
Xem ước lượng vs thậtcardinalityEstimate (explain) vs nReturned/keys (executionStats)rows= vs actual rows trong EXPLAIN ANALYZE
Điều khiểnkhông có tham số được tài liệu hoádefault_statistics_target, ALTER TABLE ... SET STATISTICS, CREATE STATISTICS

Hai điểm đáng suy nghĩ:

  • Mẫu mới mỗi lần của MongoDB tránh được vấn đề "thống kê cũ", nhưng đổi lại tính ngẫu nhiên: cùng query, cùng dữ liệu, hai lần lập plan có thể ra hai plan khác nhau (thí nghiệm 4). Thống kê của PostgreSQL chỉ đổi khi ANALYZE chạy lại; tài liệu của nó thừa nhận thống kê đổi chút ít sau mỗi lần ANALYZE, và hiếm khi điều đó làm plan đổi theo.
  • Cách đọc rows= cạnh actual rows áp dụng y nguyên cho MongoDB: đặt cardinalityEstimate cạnh số thật. Lệch một bậc độ lớn ở plan đáng lẽ thắng là dấu hiệu của điểm mù.

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

  • Nghĩ rằng 8.3 "chuyển sang cost-based optimizer như PostgreSQL". Trên 8.3, multi-planner vẫn quyết định đại đa số query. CBR chỉ là dự phòng khi trial ngắn không ra kết quả. [tài liệu] "chỉ gọi CBR cho một lượng nhỏ query".
  • Nhầm query cardinality với selectivity hay relationship cardinality. Cardinality là số đếm qua một stage của một plan, selectivity là tỉ lệ, relationship cardinality là chuyện schema. cardinalityEstimate: 0 không có nghĩa "field này có cardinality thấp".
  • Tin ước lượng cho điều kiện hiếm. Mẫu ~380 document đoán 0 cho mọi điều kiện dưới khoảng 0,3%. cardinalityEstimate: 0 là "không thấy trong mẫu", không phải "không có".
  • Bật tham số nội bộ trên production để "tắt CBR" hay "lấy mẫu chuẩn hơn". Muốn ghim plan, dùng query settings. Muốn planner khỏi phải đoán, dùng compound index.
  • Kiểm tra plan bằng một lần explain duy nhất cho query có thể gặp CBR. Mỗi lần lập plan là một mẫu mới. Với query trả 0 kết quả có hai index gần nhau, hãy explain nhiều lần và đếm.

Cột mốc: Bạn đã biết plan do CBR chọn cũng vào plan cache và có thể thành Active như mọi plan khác, và biết hai cách chữa khi plan sai: compound index hoặc query settings. Bài CBR khép lại ở đây.

Hỏi & đáp

Plan cache có entry do CBR chọn: tenantId_1, isActive: true, works: 180.000 trên collection 600.000 document. Chạy hết plan này cần 270.000 works. Điều gì xảy ra ở các lần chạy sau?

  1. Mỗi lần chạy đều replan vì 270.000 lớn hơn 180.000 works đã ghi

    Replanning chỉ xảy ra khi plan vượt budget 10 × works đã ghi, tức 1,8 triệu works. 270.000 nằm gọn trong đó. Xem mục "Thí nghiệm: plan sai thành Active".

  2. Entry ở lại và vẫn được dùng, vì 270.000 dưới budget replan

    Đúng. Trong lab, 9 lần chạy tiếp theo dùng cache (cbr.count không tăng), median 95 ms so với 47–55 ms khi region_1 được cache. Entry chỉ mất khi cache bị xoá (restart, tạo/xoá/ẩn index, LRU) hoặc có người can thiệp. Xem mục "Thí nghiệm: plan sai thành Active".

  3. CBR lấy mẫu mới ở mỗi lần chạy nên sớm muộn sẽ chọn lại region_1

    Entry Active thì query dùng plan đã cache, CBR không tham gia, nên không có mẫu mới nào. Mẫu mới chỉ có khi entry Inactive hoặc bị xoá. Xem phần Cardinality, khi nào CBR vào việc, CBR tính gì.

  4. Entry bị loại sau vài lần vì plan do CBR chọn được đánh dấu riêng và hết hạn sớm

    Không có đánh dấu nào như vậy: $planCacheStats của entry do CBR tạo có cùng các trường như mọi entry khác, và cùng luật Inactive/Active. Xem mục "Làm sao biết ranker nào đã chạy".

Một query trả 0 kết quả, có hai index gần nhau, thỉnh thoảng bị CBR chọn plan chậm gấp đôi và plan đó có lúc kẹt Active trong cache. Cách xử lý nào hợp lý trên production?

  1. Đổi internalQuerySamplingCEMethod sang "random", vì lab cho thấy 0 lần chọn sai

    Trong lab random đúng 200/200 lần, nhưng đó là tham số nội bộ, không có trong tài liệu, không được hỗ trợ và có thể biến mất ở bản vá sau. Xem mục "Cách chữa".

  2. Ép internalQueryCBRCEMode: "heuristicCE" để ước lượng ổn định giữa các lần

    Ổn định nhưng sai: heuristics đoán mọi phép bằng như nhau và chọn sai 3/4 query có kết quả, chậm hơn 3–40 lần. Và nó cũng là tham số nội bộ. Xem phần Nguồn ước lượng và ba thí nghiệm.

  3. Chạy explain() một lần, thấy plan đúng thì yên tâm

    Mỗi lần lập plan là một mẫu mới, nên một lần explain chỉ là một lần tung xúc xắc; explain cũng bỏ qua plan cache nên không cho biết production đang dùng plan nào. Xem mục "Những lỗi thường gặp".

  4. Tạo compound index { tenantId: 1, region: 1 }, hoặc ghim plan bằng query settings

    Đúng. Compound index biến hai plan phải đoán thành một plan rõ ràng tốt nhất; query settings (8.0+) là cách được hỗ trợ để ghim plan cho một query shape. Xem mục "Cách chữa".

Nếu bỏ hết thuật ngữ: khi chạy thử mà không tuyến nào giao được kiện nào, người quản lý rút vài trăm phiếu trong kho để đoán tuyến nào ngắn hơn. Đoán khá đúng với khách lớn, không thấy khách nhỏ. Nếu phiếu được rút theo xấp liền nhau và một khách lớn nằm gọn trong vài xấp, có hôm anh đoán sai, ghi tuyến dài vào sổ, và cuốn sổ giữ tuyến đó rất lâu.

Bài tiếp theo

Đến đây, Phần Index & Query đã trả lời xong câu hỏi "chọn plan nào": multi-planner chạy thử, CBR ước lượng, plan cache ghi nhớ. Nhưng suốt hai bài về planner, ta coi việc chạy một plan là hộp đen. Ta chỉ đọc kết quả của nó qua works, advanced, isEOF, và thấy pipeline có $group thì chạy bằng một engine khác, đến mức CBR không chạm tới.

Bài Query Execution Engine mở hộp đen đó. Một plan là một cây stage hoạt động theo kiểu "kéo" (pull-based). works thật ra là gì, NEED_TIME và NEED_YIELD là gì. SORT dùng bao nhiêu bộ nhớ khi có và không có limit. Classic engine khác SBE ở đâu, và query nào chạy bằng engine nào trên 8.x. Cuối cùng là chuyện yielding: vì sao một lần đọc không nằm trong transaction có thể thấy một document hai lần hoặc bỏ sót nó. Câu hỏi đó dẫn thẳng sang Phần Transactions.

Tài liệu tham khảo