Query Execution Engine (P3/3): EXPRESS và bảng tra explain

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

Ở 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.

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:

QueryPlancpuNanos (lượt 1 / 2)planningTimeMicros (lượt 1 / 2)
{ _id }EXPRESS_IXSCAN14.042 / 15.6254 / 4
{ phone } (không limit)FETCH ← IXSCAN25.625 / 29.91713 / 16
{ _id, tenantId }FETCH ← IXSCAN28.250 / 32.04217 / 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

MongoDBPostgreSQL
Mô hình thực thicây stage kiểu pull (work()), hoặc SBEcây node kiểu pull, gọi là Volcano/iterator: mỗi node trả từng tuple khi được hỏi
Đếm côngworks, advanced, needTime mỗi stageEXPLAIN ANALYZE: rows, loops, thời gian thật mỗi node
Bộ nhớ sort100 MB mỗi stage, vượt thì spill nếu được phépwork_mem mỗi node sort/hash (mặc định 4 MB), vượt thì "external merge"
Top-kSORT có limitAmount"top-N heapsort" trong EXPLAIN ANALYZE
Đọc chỉ từ indexPROJECTION_COVEREDIndex Only Scan (còn phụ thuộc visibility map)
Một câu đọc có nhất quán khôngkhông, ngoài transaction: yield buông snapshotcó: 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.

StageLàm gìLoạiNhìn vào
COLLSCANđọc từng document của collectionstreamingdocsExamined so với nReturned; có filter
IXSCANquét một đoạn indexstreamingindexBounds, 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
FETCHlấy document theo RecordIdstreamingfilter ở FETCH = điều kiện index không trả lời; docsExamined
SORTsort trong bộ nhớblockinglimitAmount (top-k), totalDataSizeSorted, usedDisk, spills, peakTrackedMemBytes
SORT_MERGEtrộn nhiều luồng đã sort ($in, $or)streamingtốt: thứ tự đến từ index
SORT_KEY_GENERATORtính sort key để mang lên trênstreamingvô hại, có thể gặp trên shard
PROJECTION_SIMPLElấy/bỏ field cấp cao nhấtstreamingrẻ
PROJECTION_DEFAULTprojection tổng quát (lồng, biểu thức, $slice)streamingtốn CPU hơn trên mỗi document
PROJECTION_COVEREDdựng kết quả từ index keystreamingtotalDocsExamined: 0 = covered
LIMITdừng sau n kết quảstreamingcon có isEOF: 0 là dừng sớm thật
SKIPbỏ n kết quả đầustreamingvẫ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)
ORhợp kết quả nhiều nhánh index, khử trùngstreamingmỗi nhánh nên là IXSCAN tốt; dupsDropped = số trùng bị bỏ
SUBPLANplanner chọn plan riêng cho từng nhánh $or—thường đi với OR
SHARDING_FILTERbỏ orphan document trên shardstreamingcó thể buộc FETCH nếu shard key không có trong index (Sharding)
GROUP (SBE)$group đẩy xuống query layerblockingusedDisk, spills, peakTrackedMemBytes
EQ_LOOKUP (SBE)$lookup so khớp bằngtuỳ strategystrategy: IndexedLoopJoin tốt, nested loop không index thì đắt ($lookup & Joins)
EOFbiế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ưởngNếu lệch
totalKeysExamined / nReturned≈ 1index 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 totalDocsExaminedkeys ≥ docsdocs ≫ keys = COLLSCAN; keys ≫ docs = index lọc tốt trước khi fetch
works so với advanced ở một stagegần nhauneedTime lớn = stage làm nhiều việc vô ích
có SORT không, có limitAmount khôngkhông có SORTSORT không limit trên tập lớn = rủi ro 100 MB
usedDisk / spillsfalse / 0đang spill: cắt projection, thêm limit, hoặc cho index cung cấp thứ tự
saveState, numYieldtỉ lệ với thời gian chạyquery 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 allowDiskUse là 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 internalQueryFrameworkControl hay internalQueryMaxBlockingSortMemoryUsageBytes trê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ì?

  1. Latency mỗi request giảm khoảng một nửa, vì cpuNanos giảm khoảng một nửa

    cpuNanos giảm từ khoảng 26–32 µs xuống 14–16 µs, nhưng đo từ client, mỗi findOne mất 340–450 µs, gần như toàn bộ là round-trip và overhead của shell. Xem mục "EXPRESS: đường tắt cho tra một document".

  2. Muốn hưởng đường tắt thì nên thêm tenantId vào filter cho chặt

    Ngược lại: find({ _id: 4242, tenantId: "t0241" }) mất đường tắt và quay về FETCH ← IXSCAN. Xem mục "EXPRESS: đường tắt cho tra một document".

  3. Không nhanh hơn thấy được; lợi ích là thêm throughput

    Đúng. Đường tắt bớt khoảng 12–16 µs CPU, phần lớn ở planning (4 so với 13–19 µs), trong khi round-trip là hàng trăm µs. Với workload tra theo khoá lưu lượng lớn, CPU tiết kiệm được là throughput trên cùng phần cứng. Xem mục "EXPRESS: đường tắt cho tra một document".

  4. Chậm hơn một chút, vì bỏ planning thì không chọn được index tốt nhất

    Tra bằng trên một field có index và tối đa một kết quả thì chỉ có một cách đi hợp lý; lab đo cpuNanos thấp hơn chứ không cao hơn. Xem mục "EXPRESS: đường tắt cho tra một document".

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?

  1. totalKeysExamined 15, totalDocsExamined 5: 10 key bị bỏ không fetch

    Đúng. SKIP vẫn phải đi qua mọi kết quả bị bỏ nên đọc 10 + 5 = 15 key, nhưng trong lab SKIP nằm dưới FETCH nên chỉ 5 document được fetch. Xem mục "Các stage hay gặp".

  2. totalKeysExamined 5, totalDocsExamined 5: skip nhảy thẳng qua 10 kết quả đầu

    SKIP không nhảy: nó vẫn phải đọc mọi kết quả bị bỏ, lab ghi 15 key chứ không phải 5. Xem mục "Các stage hay gặp".

  3. totalKeysExamined 15, totalDocsExamined 15: kết quả bị bỏ cũng đã fetch rồi vứt

    Lab đo ngược lại: SKIP nằm dưới FETCH nên chỉ 5 document cuối được fetch, còn 10 kết quả bị bỏ dừng lại ở khâu index. Xem mục "Các stage hay gặp".

  4. totalKeysExamined 10, totalDocsExamined 5

    Thiếu 5 key: ngoài 10 key bị bỏ, 5 kết quả trả về cũng đọc từ index, tổng 15 key. Xem mục "Các stage hay gặp".

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