Query Execution Engine (P2/3): PROJECTION, yielding và SBE
Ở 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.
- Cần đọc trước: Cây stage,
work()và SORT - Dẫn tới: EXPRESS và bảng tra explain
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):
| Projection | Stage | median (ms) |
|---|---|---|
| không projection | COLLSCAN | 160 |
{ tenantId: 1, total: 1, _id: 0 } | PROJECTION_SIMPLE | 203 |
{ tenantId: 1, "items.sku": 1, _id: 0 } (đường dẫn có chấm) | PROJECTION_DEFAULT | 269 |
{ tenantId: 1, vat: { $multiply: ["$total", 0.1] }, _id: 0 } | PROJECTION_DEFAULT | 284 |
{ tenantId: 1, items: { $slice: 1 }, _id: 0 } | PROJECTION_DEFAULT | 465 |
{ tenantId: 1, status: 1, total: 1 } qua index 3 field | PROJECTION_SIMPLE ← FETCH ← IXSCAN | 677 |
như trên, bỏ _id | PROJECTION_COVERED ← IXSCAN | 161 |
[quan sát] Ba điều đọc được:
PROJECTION_SIMPLEchỉ lấy/bỏ các field cấp cao nhất. Rẻ nhất trong ba loại.PROJECTION_DEFAULTlà 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,$slicetrên mảng tốn nhất trong lab.PROJECTION_COVEREDdự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 msProjection 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ốtcount 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
explainVersion: '2', vàwinningPlancó hai phần.queryPlanlà cây stage quen thuộc, đọc như classic.slotBasedPlanlà 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.- Không có
works/advanced/needTime. [tài liệu]workschỉ 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ộ batotalKeysExamined/totalDocsExamined/nReturnedthì vẫn đọc như cũ. queryFrameworktrong 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ản | Thay đổi |
|---|---|
| 5.1 | SBE ra đời, dùng cho một số query [tài liệu] |
| 7.0 | SBE 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.1 | tham 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.0 | chọ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 ms | classic, v1: 151 / 158 ms |
forceClassicEngine | classic, v1: 314 / 326 ms | classic, v1: 152 / 151 ms |
trySbeEngine | SBE, v2: 215 / 244 ms | SBE, 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à sauPipeline 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ì?
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?
Ở 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ì?