Query Execution Engine (P3/3): EXPRESS và bảng tra explain
Ở phần trước: query có thể nhường giữa chừng, nên cursor đọc không phải snapshot. Engine có thể là classic hoặc SBE, và queryFramework trong explain cho biết engine thật.
- Cần đọc trước: PROJECTION, yielding và SBE
- Dẫn tới: Transactions. Phần này là phần cuối của bài về query execution engine.
EXPRESS: đường tắt cho tra một document
[tài liệu] Từ 8.0, một nhóm nhỏ query, trong đó có so khớp bằng trên _id, bỏ qua query planning và thực thi thông thường, đi thẳng vào một plan quét index tối ưu gồm một trong các stage EXPRESS_IXSCAN, EXPRESS_CLUSTERED_IXSCAN, EXPRESS_UPDATE, EXPRESS_DELETE. [chi tiết cài đặt] Trước 8.0, explain của tra theo _id thường hiện stage IDHACK (tên này không có trong các trang tài liệu đã đọc).
[quan sát] Trên customers:
find({ _id: 4242 }) EXPRESS_IXSCAN
find({ _id: 4242 }, { name: 1 }) EXPRESS_IXSCAN
find({ _id: { $in: [4242] } }) EXPRESS_IXSCAN ($in một phần tử = $eq)
find({ email: "user4241@shop.vn" }) EXPRESS_IXSCAN (unique index)
find({ phone: "0900004241" }) FETCH <- IXSCAN (index thường)
find({ phone: "0900004241" }).limit(1) EXPRESS_IXSCAN (index thường, nhưng chỉ cần 1)
find({ _id: 4242, tenantId: "t0241" }) FETCH <- IXSCAN (thêm điều kiện là mất đường tắt)Điều kiện chính xác để được đi đường tắt không được tài liệu liệt kê. [quan sát] Lab cho thấy: bằng trên một field có index, và chắc chắn có tối đa một kết quả (unique, hoặc limit(1) như findOne). Thêm điều kiện thứ hai là quay về đường thường.
Nhanh hơn bao nhiêu? Profiler (slowms: 0), find().toArray() 300 lần mỗi loại (profiler giữ lại khoảng 262 bản ghi mỗi loại vì system.profile là capped collection), mỗi query trả 1 document, median của hai lượt:
| Query | Plan | cpuNanos (lượt 1 / 2) | planningTimeMicros (lượt 1 / 2) |
|---|---|---|---|
{ _id } | EXPRESS_IXSCAN | 14.042 / 15.625 | 4 / 4 |
{ phone } (không limit) | FETCH ← IXSCAN | 25.625 / 29.917 | 13 / 16 |
{ _id, tenantId } | FETCH ← IXSCAN | 28.250 / 32.042 | 17 / 19 |
[quan sát] Đường tắt bớt khoảng 12–16 µs CPU mỗi query, phần lớn ở khâu planning (4 so với 13–19 µs). Nhưng đo từ client mongosh, 5.000 findOne mỗi loại, hai lượt: theo _id 417 / 340 µs mỗi lần, email 444 / 410 µs, phone 450 / 411 µs. Gần như toàn bộ là round-trip và overhead của shell. Bộ đếm metrics.query.planning.fastPath.express tăng đúng 3.000 sau 3.000 findOne (1.000 mỗi loại), tức cả ba loại đều đi đường tắt, kể cả phone (vì findOne có limit 1).
Kết luận: EXPRESS không làm một request nhanh hơn thấy được. Nó làm server rẻ hơn cho workload tra theo khoá với lưu lượng lớn: ít CPU hơn trên mỗi request là thêm throughput trên cùng phần cứng.
So với PostgreSQL
| MongoDB | PostgreSQL | |
|---|---|---|
| Mô hình thực thi | cây stage kiểu pull (work()), hoặc SBE | cây node kiểu pull, gọi là Volcano/iterator: mỗi node trả từng tuple khi được hỏi |
| Đếm công | works, advanced, needTime mỗi stage | EXPLAIN ANALYZE: rows, loops, thời gian thật mỗi node |
| Bộ nhớ sort | 100 MB mỗi stage, vượt thì spill nếu được phép | work_mem mỗi node sort/hash (mặc định 4 MB), vượt thì "external merge" |
| Top-k | SORT có limitAmount | "top-N heapsort" trong EXPLAIN ANALYZE |
| Đọc chỉ từ index | PROJECTION_COVERED | Index Only Scan (còn phụ thuộc visibility map) |
| Một câu đọc có nhất quán không | không, ngoài transaction: yield buông snapshot | có: mỗi câu lệnh chạy trên một snapshot, kể cả ở Read Committed |
[tài liệu PostgreSQL] Ở mức Read Committed, mỗi câu SELECT thấy snapshot của database tại lúc câu lệnh bắt đầu. Một câu SELECT dài trong PostgreSQL không thể trả một dòng hai lần vì dòng đó bị update giữa chừng. Đây là khác biệt lớn nhất của bảng: hai engine kéo dữ liệu giống nhau, nhưng phạm vi của snapshot khác nhau. Ở MongoDB, muốn nhất quán thì phải yêu cầu tường minh.
Bảng tra explain
Các stage hay gặp
Trang Explain Results chỉ nêu tên một phần các stage (COLLSCAN, IXSCAN, FETCH, GROUP, SHARDING_FILTER, EOF, OR, SORT, các stage EXPRESS_*). Các tên còn lại là tên quan sát trong explain của 8.3.11 (SORT_MERGE, SORT_KEY_GENERATOR, PROJECTION_*, LIMIT, SKIP, SUBPLAN, EQ_LOOKUP đều hiện trong lab này), không phải cam kết API, và có thể đổi giữa các bản.
| Stage | Làm gì | Loại | Nhìn vào |
|---|---|---|---|
COLLSCAN | đọc từng document của collection | streaming | docsExamined so với nReturned; có filter |
IXSCAN | quét một đoạn index | streaming | indexBounds, keysExamined, seeks (nhiều seek = nhảy cóc) |
EXPRESS_IXSCAN | đường tắt 8.0+ cho tra bằng, tối đa một kết quả | — | thấy là tốt; mất nó khi thêm điều kiện |
FETCH | lấy document theo RecordId | streaming | filter ở FETCH = điều kiện index không trả lời; docsExamined |
SORT | sort trong bộ nhớ | blocking | limitAmount (top-k), totalDataSizeSorted, usedDisk, spills, peakTrackedMemBytes |
SORT_MERGE | trộn nhiều luồng đã sort ($in, $or) | streaming | tốt: thứ tự đến từ index |
SORT_KEY_GENERATOR | tính sort key để mang lên trên | streaming | vô hại, có thể gặp trên shard |
PROJECTION_SIMPLE | lấy/bỏ field cấp cao nhất | streaming | rẻ |
PROJECTION_DEFAULT | projection tổng quát (lồng, biểu thức, $slice) | streaming | tốn CPU hơn trên mỗi document |
PROJECTION_COVERED | dựng kết quả từ index key | streaming | totalDocsExamined: 0 = covered |
LIMIT | dừng sau n kết quả | streaming | con có isEOF: 0 là dừng sớm thật |
SKIP | bỏ n kết quả đầu | streaming | vẫn phải đọc mọi kết quả bị bỏ (lab: skip(10).limit(5) đọc 15 key, nhưng SKIP nằm dưới FETCH nên chỉ fetch 5 document) |
OR | hợp kết quả nhiều nhánh index, khử trùng | streaming | mỗi nhánh nên là IXSCAN tốt; dupsDropped = số trùng bị bỏ |
SUBPLAN | planner chọn plan riêng cho từng nhánh $or | — | thường đi với OR |
SHARDING_FILTER | bỏ orphan document trên shard | streaming | có thể buộc FETCH nếu shard key không có trong index (Sharding) |
GROUP (SBE) | $group đẩy xuống query layer | blocking | usedDisk, spills, peakTrackedMemBytes |
EQ_LOOKUP (SBE) | $lookup so khớp bằng | tuỳ strategy | strategy: IndexedLoopJoin tốt, nested loop không index thì đắt ($lookup & Joins) |
EOF | biết trước không có kết quả | — | ví dụ collection không tồn tại |
Các tỉ lệ cần đọc
| Tỉ lệ | Lý tưởng | Nếu lệch |
|---|---|---|
totalKeysExamined / nReturned | ≈ 1 | index quét quá rộng: thiếu field equality, range đứng trước sort |
totalDocsExamined / nReturned | ≈ 1 (hoặc 0 khi covered) | FETCH vứt nhiều: filter có field không nằm trong index |
totalKeysExamined so với totalDocsExamined | keys ≥ docs | docs ≫ keys = COLLSCAN; keys ≫ docs = index lọc tốt trước khi fetch |
works so với advanced ở một stage | gần nhau | needTime lớn = stage làm nhiều việc vô ích |
có SORT không, có limitAmount không | không có SORT | SORT không limit trên tập lớn = rủi ro 100 MB |
usedDisk / spills | false / 0 | đang spill: cắt projection, thêm limit, hoặc cho index cung cấp thứ tự |
saveState, numYield | tỉ lệ với thời gian chạy | query dài nhường nhiều; nhớ hệ quả về snapshot |
explainVersion / queryFramework | — | 2 / sbe = SBE: không có works, đọc opens/closes |
Những lỗi thường gặp
- Thêm
limit()và tin query sẽ rẻ. Có SORT không index bên dưới thì vẫn đọc toàn bộ: lab đọc 1.000.000 document để trả 5. - Kéo cả document qua một SORT lớn. Projection đặt dưới SORT đưa dữ liệu sort từ 173,7 MB xuống 33,6 MB và hết spill.
- Coi
allowDiskUselà cách sửa. Nó chỉ đổi lỗi thành chậm (541 so với 410 ms trong lab). Sửa thật là limit, projection, hoặc index cho thứ tự. - Cộng dồn từ một cursor dài rồi coi là con số chính xác. Cursor đọc trên index của field hay đổi có thể đếm trùng hoặc bỏ sót. Báo cáo cần nhất quán thì dùng snapshot read hoặc transaction.
- Chỉnh
internalQueryFrameworkControlhayinternalQueryMaxBlockingSortMemoryUsageBytestrên production. Đó là tham số nội bộ. Ép engine thì dùng query settings. Nâng giới hạn sort là đổi lỗi lấy nguy cơ OOM (bài Aggregation Pipeline đã gặp một lần container bị kill). - Kỳ vọng EXPRESS làm API nhanh hơn. Nó tiết kiệm khoảng 12–16 µs CPU server, trong khi round-trip là hàng trăm µs. Lợi ích nằm ở throughput.
Cột mốc: Bạn đã biết EXPRESS bỏ qua planning khi tra theo
_id, và đọc được bảng tra explain để tìm đúng stage và tỉ lệ cần xem. Bài query execution engine khép lại ở đây.
Hỏi & đáp
Lên 8.0, tra theo _id đi EXPRESS_IXSCAN, bỏ qua planning. Với một API tra đơn theo khoá, nên kỳ vọng gì?
Một find có index phục vụ cả filter lẫn sort, thêm .skip(10).limit(5). executionStats cho thấy số key và số document đọc là bao nhiêu?
Nếu bỏ hết thuật ngữ: mỗi người trong quầy chỉ làm khi được hỏi, nên đủ hàng là cả quầy nghỉ, trừ khi có ai phải xem hết cả mẻ trước khi giao. Người làm thỉnh thoảng rời chỗ, và nếu kệ bị xếp lại lúc đó, anh có thể giao trùng hoặc bỏ sót một ổ. Khách hỏi đúng một ổ theo số phiếu thì đi lối riêng.
Bài tiếp theo
Bài này khép lại Phần Index & Query: index cho biết dữ liệu nằm đâu, planner và CBR chọn cách đi, execution engine kéo từng kết quả lên, aggregation biến chúng thành báo cáo.
Thí nghiệm yielding để lại một câu hỏi: đọc trùng, đọc sót là hành vi đúng thiết kế khi ta không yêu cầu gì hơn. Còn khi ứng dụng cần nhiều thao tác đọc và ghi xảy ra như một khối, như chuyển tiền giữa hai ví hay trừ kho và tạo đơn cùng lúc?
Bài Transactions & Atomicity mở đầu Phần Transactions từ đúng chỗ đó: replica set tối thiểu, single-document vs multi-document atomicity, vòng đời, giới hạn và cái giá của transaction. Sau đó bài Isolation & Snapshot trả lời câu hỏi bài này để ngỏ: đọc trên một snapshot nghĩa là gì, và nó chặn đọc trùng, đọc sót ra sao.
Tài liệu tham khảo
- Explain Results (stages, EXPRESS, works, saveState/restoreState, isEOF, spill fields, slotBasedPlan)
- Slot-Based Query Execution Engine
- Release Notes for MongoDB 8.0 (Express Query Stages, SBE disabling)
- Release Notes for MongoDB 7.0 (Slot-Based Query Execution Engine, queryFramework)
- setQuerySettings (queryFramework)
- $planCacheStats (version field)
- cursor.sort() (top-k sort, memory limit)
- cursor.allowDiskUse()
- FAQ: Concurrency (why operations yield)
- Read Isolation, Consistency, and Recency (non-point-in-time reads, cursor snapshot)
- SERVER-83470: internalQueryFrameworkControl setting for 6.0-style engine selection
- SERVER-83685: make "trySbeRestricted" the default
- PostgreSQL: Transaction Isolation (Read Committed snapshot per statement)
- PostgreSQL: Using EXPLAIN