Query Execution Engine (P2/3): PROJECTION, yielding và SBE

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

Ở phần trước: một plan là cây stage, stage gốc kéo kết quả từ dưới lên qua work(). SORT là stage blocking, phải nuốt hết input trước khi nhả kết quả đầu tiên.

Streaming với chi phí theo document: PROJECTION

Explain có ba loại projection. Đo trên cùng 700.217 đơn completed, explain median 7 lần (projection chạy trong server, chưa tính gửi qua mạng):

ProjectionStagemedian (ms)
không projectionCOLLSCAN160
{ tenantId: 1, total: 1, _id: 0 }PROJECTION_SIMPLE203
{ tenantId: 1, "items.sku": 1, _id: 0 } (đường dẫn có chấm)PROJECTION_DEFAULT269
{ tenantId: 1, vat: { $multiply: ["$total", 0.1] }, _id: 0 }PROJECTION_DEFAULT284
{ tenantId: 1, items: { $slice: 1 }, _id: 0 }PROJECTION_DEFAULT465
{ tenantId: 1, status: 1, total: 1 } qua index 3 fieldPROJECTION_SIMPLE ← FETCH ← IXSCAN677
như trên, bỏ _idPROJECTION_COVERED ← IXSCAN161

[quan sát] Ba điều đọc được:

  • PROJECTION_SIMPLE chỉ lấy/bỏ các field cấp cao nhất. Rẻ nhất trong ba loại.
  • PROJECTION_DEFAULT là loại tổng quát: đường dẫn lồng, biểu thức, $slice, $elemMatch, $meta. Nó phải duyệt sâu vào document, nên tốn CPU hơn, $slice trên mảng tốn nhất trong lab.
  • PROJECTION_COVERED dựng kết quả thẳng từ index key, không FETCH. Bản FETCH cùng index mất 677 ms vì phải nhảy tới 700.217 document theo thứ tự của index (ngẫu nhiên trên đĩa và cache), chậm hơn cả COLLSCAN đọc tuần tự. Bỏ _id để covered là đưa nó về 161 ms.

Đừng đọc bảng trên thành "projection làm chậm query": explain không gửi document qua mạng. Đo đầu-cuối bằng itcount() trên 140.043 đơn (100 tenant đầu, completed), hai lượt:

document đầy đủ        1399 ms | 1349 ms
PROJECTION_SIMPLE      1154 ms | 1117 ms
PROJECTION_COVERED      964 ms |  979 ms

Projection tốn thêm chút CPU ở server nhưng tiết kiệm nhiều hơn ở serialize, mạng và client.

Yielding: query nhường giữa chừng

Vì sao phải nhường

[tài liệu] Với WiredTiger, đọc storage không cần yield vì intent lock không chặn người đọc và người ghi khác. Nhưng tài liệu FAQ concurrency nói query dài vẫn định kỳ yield để:

  • tránh giữ một storage transaction quá lâu, vì nó có thể buộc giữ nhiều dữ liệu (các phiên bản cũ) trong bộ nhớ;
  • làm điểm ngắt, để thao tác chạy lâu có thể bị kill (ví dụ bằng killOp);
  • cho phép các thao tác cần độc quyền collection (tạo/xoá index, xoá collection) chen vào.

[tài liệu] Explain đếm saveState (số lần stage tạm dừng và lưu trạng thái, "ví dụ để chuẩn bị nhường lock") và restoreState (số lần khôi phục sau khi lấy lại lock). Profiler có numYield.

[chi tiết cài đặt] Mỗi lần yield, các stage ghi nhớ vị trí, query buông lock và cả snapshot của storage engine (khớp lý do đầu tiên của FAQ), rồi lấy lại và khôi phục.

[chi tiết cài đặt + quan sát] Lab đọc được internalQueryExecYieldPeriodMS: 10 và internalQueryExecYieldIterations: -1, tức là trên 8.3.11 việc yield được kích hoạt theo thời gian. COLLSCAN 1 triệu document ở phần trước (Cây stage, work() và SORT) ghi saveState: 7 đến 9. Một COLLSCAN 244 ms khác trong profiler ghi numYield: 13. Query 1 ms ghi numYield: 0. Ngoài ra, giữa hai lần getMore cursor cũng buông snapshot: trong lúc app xử lý batch, server chỉ giữ vị trí của cursor, không giữ snapshot hay lock nào.

[chi tiết cài đặt] Còn needYield là chuyện khác: storage engine bắt query nhường (ví dụ gặp xung đột ghi). Trong lab yên tĩnh, nó bằng 0 ở mọi query.

Cái giá: đọc không phải snapshot

[tài liệu] Khi không có cô lập (transaction hoặc read concern "snapshot"), một thao tác đọc không thấy dữ liệu ở một thời điểm duy nhất. Trang Read Isolation, Consistency, and Recency liệt kê: một read bắt đầu ở t1 có thể thấy bản cập nhật commit ở t2 sau đó, read có thể bỏ sót document khớp bị cập nhật trong lúc đọc, và (mục Cursor Snapshot) cursor có thể trả cùng một document nhiều lần nếu một thao tác khác đổi field thuộc index mà query đang dùng.

Thử làm cho nó xảy ra. Đơn completed của t0042, đọc theo total tăng dần qua index { tenantId, status, total }, batch 50:

// trích: cheapest / priciest là đơn có total nhỏ nhất / lớn nhất, đọc trước khi mở cursor
const seen = [];
const other = new Mongo("mongodb://127.0.0.1:27017").getDB("lab15");   // client thứ hai
const cur = db.orders.find({ tenantId: "t0042", status: "completed" })
                     .sort({ total: 1 }).batchSize(50);
for (let i = 0; i < 50; i++) seen.push(cur.next());     // đọc batch đầu

// client thứ hai, trong lúc cursor đang mở:
other.orders.updateOne({ _id: cheapest._id },  { $set: { total: 999999999 } }); // đơn ĐÃ đọc → cuối index
other.orders.updateOne({ _id: priciest._id },  { $set: { total: -1 } });        // đơn CHƯA đọc → đầu index

while (cur.hasNext()) seen.push(cur.next());             // đọc nốt
count trước và sau         : 1390 | 1390
cursor trả về              : 1390 document, 1389 _id khác nhau
đơn rẻ nhất xuất hiện      : 2 lần (total 30000, rồi total 999999999)
đơn đắt nhất xuất hiện     : 0 lần

[quan sát] Tái hiện được ngay lần đầu. Số document trả về đúng bằng countDocuments, nhưng một đơn bị đếm hai lần và một đơn biến mất. Nếu app cộng doanh thu từ cursor này, con số sai mà không có lỗi nào được báo.

[chi tiết cài đặt] Cơ chế rất thẳng. IXSCAN nhớ vị trí "đã đọc tới key (t0042, completed, 460000)". Lúc restoreState, nó đi tiếp từ đó trên index hiện tại. Đơn rẻ nhất giờ có key mới ở phía trước nên gặp lại. Đơn đắt nhất bị dời về phía sau vị trí đã qua nên không bao giờ gặp.

index (total ↑)   lúc đọc batch đầu                lúc getMore
                  [30000 ... 460000] | ... 21870000      ... 460000] | ... 999999999
                   └── đã đọc ──────┘                                      ↑ gặp lại đơn cũ
                  đơn 21870000 → -1: dời về vùng đã qua, không bao giờ gặp

Ở đây khe hở nằm giữa hai batch cho dễ thấy; một yield giữa batch mở đúng khe hở đó, chỉ ngắn hơn. Bài học:

  • Đọc trên index của field hay đổi (status, total, updatedAt) là kiểu dễ dính nhất. Sort theo field bất biến (_id, createdAt) thì document không "chạy" trong index.
  • Cần một con số nhất quán (báo cáo, đối soát) thì phải đọc trên một snapshot: read concern "snapshot" hoặc transaction. Đó là chủ đề của bài Isolation & Snapshot (cần replica set, lab standalone này không thử).
  • Job xử lý theo lô nên idempotent: xử lý một document hai lần không được làm sai dữ liệu.

Classic engine và slot-based engine (SBE)

Planner quyết định plan nào. Ai chạy plan đó thì có hai engine: classic engine (cây stage gọi work() như ta vừa xem) và slot-based execution engine (SBE), có từ 5.1. [tài liệu] SBE dùng mô hình "slot" để tránh dựng ra 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 classic.

Slot là gì

Ở classic engine, thứ đi giữa hai stage là một document (hoặc index key) hoàn chỉnh.

[chi tiết cài đặt, đơn giản hoá] Ở SBE, thứ đi giữa các stage là các slot: những ô giá trị có tên s1, s2... Một stage ghi giá trị vào slot, stage sau đọc slot đó. Cần total và userId thì stage fetch chỉ rút đúng hai giá trị đó ra hai slot, không ai phải dựng lại document trung gian.

[hình dung] Classic như chuyền nguyên khay đồ ăn qua từng trạm; SBE như mỗi trạm có dãy ô đánh số, trạm trước đặt đúng nguyên liệu trạm sau cần vào ô đã hẹn.

Explain thật của một pipeline $group:

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_status_1_total_1)
slotBasedPlan.stages:
  [4] project [s14 = newBsonObj("_id", s12, "revenue", s13)]
  [4] project [s13 = doubleDoubleSumFinalize(s11)]
  [4] group [s12] [s11 = aggDoubleDoubleSum(s8)] spillSlots[s10] ...
  [4] project [s12 = (s9 ?: null)]
  [2] fetch s1 = seek, s6 = result, ... [s8 = total, s9 = userId]
  [1] ixseek seekKeyLow = KS(...) seekKeyHigh = KS(...) [s3 = indexKey, s1 = recordId, ...]
exec: nReturned 114  keys 188  docs 188
top stage fields: stage, planNodeId, nReturned, executionTimeMillisEstimate, opens, closes, saveState, ...

Đọc từ dưới lên: ixseek quét đoạn index và đặt RecordId vào s1. fetch dùng s1 để lấy document, rồi rút đúng total vào s8 và userId vào s9. group gom theo s12, cộng s8. Chỉ ở bước cuối mới dựng một document kết quả (newBsonObj). Số trong ngoặc vuông là planNodeId, trỏ về node tương ứng trong queryPlan.

Đọc explain của SBE

  1. explainVersion: '2', và winningPlan có hai phần. queryPlan là cây stage quen thuộc, đọc như classic. slotBasedPlan là cây thực thi thật. [tài liệu] slotBasedPlan "dành cho MongoDB dùng nội bộ": đọc cho hiểu thì được, đừng parse trong tool giám sát.
  2. Không có works/advanced/needTime. [tài liệu] works chỉ có khi query chạy bằng classic engine. [quan sát] Stage của SBE trong lab có opens/closes (hai trường tài liệu ghi là có từ 5.1). Bộ ba totalKeysExamined / totalDocsExamined / nReturned thì vẫn đọc như cũ.
  3. queryFramework trong profiler và slow query log cho biết engine thật đã chạy.

Query nào chạy bằng SBE trên 8.x

[tài liệu] MongoDB tự chọn engine cho từng query, tuỳ mọi operator và expression trong query có được SBE hỗ trợ không. Tài liệu chỉ nêu hai ví dụ (pipeline có $group hoặc $lookup), nói phạm vi "thay đổi theo phiên bản", và không công bố danh sách đầy đủ.

Phiên bảnThay đổi
5.1SBE ra đời, dùng cho một số query [tài liệu]
7.0SBE 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ó queryFramework [tài liệu]
7.0.5 / 7.2.1tham số nội bộ internalQueryFrameworkControl có giá trị mặc định mới trySbeRestricted: chỉ query hưởng lợi từ việc đẩy $group hoặc $lookup xuống mới dùng SBE, mọi query khác về classic, giống cách chọn của 6.0 [Jira SERVER-83470, SERVER-83685]
8.0chọn engine cho từng query shape được bằng query settings (queryFramework: "classic" hoặc "sbe"); tự tắt SBE trên collection có index mà một path hashed là tiền tố của một path không hashed; block processing cho một số query time series [tài liệu]

Tài liệu 8.0 không công bố việc mở rộng phạm vi tự động của SBE: cái mới là điều khiển engine qua query settings và vài chỗ SBE bị tắt. Phạm vi thật vẫn do giá trị mặc định trySbeRestricted quyết định (theo Jira và lab, không theo trang tài liệu chính).

[quan sát] Lab 8.3.11 đọc được internalQueryFrameworkControl: "trySbeRestricted"; ba giá trị forceClassicEngine, trySbeRestricted, trySbeEngine đều được nhận, giá trị lạ bị từ chối ("not a valid value"). Mọi find thuần trong bài chạy classic (explainVersion: '1'). Pipeline có $group chạy SBE. Profiler xác nhận:

aggregate [$match, $group]               queryFramework sbe      planSummary IXSCAN { tenantId: 1, status: 1, total: 1 }
find { tenantId, status }                queryFramework classic  planSummary IXSCAN { tenantId: 1, status: 1, total: 1 }

Lab: ép classic để so

internalQueryFrameworkControl là tham số nội bộ, không có trong trang tham số chính thức, tên và giá trị có thể đổi giữa các bản. Chỉ dùng trong lab. Trên production, cách được hỗ trợ là query settings (cần replica set, xem bài Query Planner).

Cùng hai query, đổi tham số, median 7 lần, hai lượt:

Chế độ$match {status: completed} + $group theo tenant (500 nhóm)find COLLSCAN + SORT (17 kết quả)
trySbeRestricted (mặc định)SBE, v2: 204 / 228 msclassic, v1: 151 / 158 ms
forceClassicEngineclassic, v1: 314 / 326 msclassic, v1: 152 / 151 ms
trySbeEngineSBE, v2: 215 / 244 msSBE, v2: 152 / 162 ms

[quan sát] Với $group trên 700.217 document, SBE nhanh hơn classic khoảng 1,4–1,5 lần. Cách giải thích hợp lý ([chi tiết cài đặt], không đo trực tiếp): classic phải dựng document cho từng đơn và chuyền nó qua ranh giới query layer / aggregation, SBE chỉ rút các field cần vào slot. Với find COLLSCAN, ép SBE không đổi gì đáng kể: chi phí chính là đọc 1 triệu document từ storage, engine nào cũng phải làm. Một phép đo trên một kiểu query thì không đủ để kết luận chung.

Một chỗ tài liệu và quan sát không khớp

Tài liệu $planCacheStats nói version là 1 với classic và 2 với SBE. [quan sát] Xoá plan cache, chạy 5 lần pipeline [$match { tenantId: "t0043", status: "pending" }, $group theo userId] với profiler bật:

profiler            : queryFramework sbe (cả 5 lần), fromPlanCache true (3 lần sau)
$planCacheStats     : 1 entry, version 1, isActive true
                      cachedPlan PROJECTION_SIMPLE <- FETCH <- IXSCAN
                      createdFromQuery projection { total: 1, userId: 1, _id: 0 }
planCache.classic   : hits 0 -> 3
planCache.sbe.*     : tất cả 0, trước và sau

Pipeline chạy bằng SBE, nhưng thứ được cache chỉ là phần find (match + projection hai field $group cần), dạng classic, trong classic plan cache. Đừng suy ra engine từ version của plan cache. Dùng explainVersion hoặc queryFramework.

Cột mốc: Bạn đã biết ba loại projection khác nhau về chi phí trong explain, vì sao một cursor có thể đọc trùng hoặc bỏ sót, và dùng queryFramework để xem engine thật đã chạy. Tiếp theo: EXPRESS và bảng tra explain.

Hỏi & đáp

Một cursor đọc 1.390 đơn completed của t0042 theo total tăng dần qua index { tenantId, status, total }, batch 50. Sau batch đầu, client khác đổi total của đơn rẻ nhất (đã đọc) thành 999999999 và của đơn đắt nhất (chưa đọc) thành -1. Cursor trả về gì?

  1. Đơn rẻ nhất xuất hiện 2 lần, đơn đắt nhất không xuất hiện

    Đúng, lab tái hiện được ngay lần đầu. IXSCAN đi tiếp từ vị trí đã nhớ trên index hiện tại: đơn rẻ nhất có key mới ở phía trước nên gặp lại, đơn đắt nhất bị dời về vùng đã qua. Số document vẫn bằng countDocuments, nên lỗi không lộ ra. Xem mục "Cái giá: đọc không phải snapshot".

  2. 1.390 _id khác nhau với giá trị cũ: cursor đọc trên snapshot lúc mở

    Ngoài transaction hoặc read concern "snapshot", đọc không nhìn dữ liệu ở một thời điểm duy nhất: giữa các getMore và mỗi lần yield, query buông snapshot. Xem mục "Yielding: query nhường giữa chừng".

  3. Cursor báo lỗi vì dữ liệu dưới nó đã đổi

    Không có lỗi nào. Sau khi lấy lại trạng thái, IXSCAN chỉ đi tiếp từ vị trí đã nhớ; đó là lý do kết quả sai mà không ai được báo. Xem mục "Cái giá: đọc không phải snapshot".

  4. 1.391 document, vì đơn bị sửa được tính như một đơn mới

    Không đơn nào được thêm: countDocuments trước và sau đều là 1.390, và cursor cũng trả đúng 1.390 document. Sai ở chỗ một đơn bị trả hai lần còn một đơn bị bỏ sót. Xem mục "Cái giá: đọc không phải snapshot".

Explain của một pipeline $match + $group có explainVersion: '2' và không có works/advanced. Bạn nên đọc gì để biết query tốn bao nhiêu và engine nào đã chạy?

  1. version trong $planCacheStats: 2 là SBE, 1 là classic

    Lab thấy pipeline chạy SBE nhưng entry cache có version 1 trong classic plan cache, vì thứ được cache chỉ là phần find. Đừng suy ra engine từ đó. Xem mục "Một chỗ tài liệu và quan sát không khớp".

  2. Parse slotBasedPlan trong tool giám sát để lấy số liệu từng slot

    Tài liệu nói slotBasedPlan dành cho MongoDB dùng nội bộ: đọc cho hiểu thì được, đừng parse trong tool giám sát. Xem mục "Đọc explain của SBE".

  3. Đặt internalQueryFrameworkControl: "forceClassicEngine" trên production để có lại works

    Đó là tham số nội bộ, chỉ dùng trong lab; ép engine trên production thì dùng query settings. Ép classic còn làm chính pipeline này chậm đi (314–326 ms so với 204–228 ms). Xem mục "Lab: ép classic để so".

  4. Bộ ba keys/docs/nReturned, và queryFramework trong profiler/slow log

    Đúng. works chỉ có với classic engine; bộ ba keys/docs/nReturned vẫn đọc như cũ, và queryFramework cho biết engine thật đã chạy. Xem mục "Đọc explain của SBE".

Ở quầy bánh mì làm theo đơn, người chọn bánh thỉnh thoảng rời chỗ rồi quay lại đi tiếp từ "phiếu kế tiếp". Lúc anh vắng, ai đó dời một ổ anh đã giao về cuối kệ. Chuyện gì xảy ra, và muốn khách nhận đúng thì cần gì?

  1. Không sao: anh nhớ những ổ đã giao rồi nên sẽ bỏ qua ổ bị dời đó

    Anh chỉ nhớ vị trí đang đứng trên kệ, không nhớ từng ổ đã giao. Cursor cũng vậy: IXSCAN nhớ vị trí key, không nhớ document nào đã trả. Xem phần Cây stage, work() và SORT.

  2. Anh giao ổ đó lần nữa; muốn đúng phải làm trên một snapshot cố định của kệ

    Đúng. Đi tiếp từ vị trí cũ trên kệ đã bị xếp lại có thể giao trùng hoặc bỏ sót. Muốn vậy phải đọc trên snapshot (read concern "snapshot" hoặc transaction). Xem mục "Cái giá: đọc không phải snapshot".

  3. Anh quay lại đầu kệ làm lại từ đầu, nên chỉ chậm hơn chứ không giao sai

    Khi quay lại, anh đi tiếp từ chỗ đã dừng chứ không làm lại từ đầu; đó là lý do kết quả có thể sai mà không chậm đi. Xem phần Cây stage, work() và SORT.