Compound Indexes (P3/3): Covered query và quy trình thiết kế
Ở phần trước: ESR xếp field theo equality, rồi sort, rồi range, trừ khi range rất kén chọn thì đặt range trước sort. Lựa chọn đó quyết định query có cần sort trong bộ nhớ hay không.
- Cần đọc trước: ESR và multi-tenant
- Dẫn tới: Specialized Indexes, bài tiếp theo. Phần này là phần cuối của bài về compound index.
Covered query: khi index trả lời luôn
Ý chính
Nếu mọi field query cần, gồm cả field trong filter và field trả về, đều nằm trong một index, server trả kết quả từ chính key của index, không mở document nào. Đó là covered query.
[hình dung] Bạn hỏi thủ thư "sách của tác giả X xuất bản năm nào". Phiếu mục lục đã ghi sẵn năm, nên thủ thư đọc phiếu là trả lời được, không cần đi lấy sách.
Thí nghiệm
db.orders.createIndex({ tenantId: 1, status: 1, total: 1 });
const f = { tenantId: "t0042", status: "completed" };
show("covered ", db.orders.find(f, { _id: 0, total: 1 }));
show("with _id", db.orders.find(f, { total: 1 }));
show("full doc", db.orders.find(f));covered | PROJECTION_COVERED <- IXSCAN tenantId_1_status_1_total_1 | keys 1359 | docs 0 | nReturned 1359
with _id | PROJECTION_SIMPLE <- FETCH <- IXSCAN ... | keys 1359 | docs 1359 | nReturned 1359
full doc | FETCH <- IXSCAN ... | keys 1359 | docs 1359 | nReturned 1359Dấu hiệu của covered query trong explain: totalDocsExamined: 0, stage PROJECTION_COVERED, và IXSCAN không nằm dưới FETCH. Tài liệu explain định nghĩa đúng điều cuối: covered khi IXSCAN không phải con cháu của một stage FETCH [tài liệu].
Chỉ thêm _id vào kết quả (mặc định projection luôn trả _id) là phá vỡ covered: _id không có trong index, nên server phải FETCH cả 1.359 document để lấy nó. Đây là lỗi phổ biến nhất khi thiết kế covered query.
Thời gian, khi dữ liệu đã nằm sẵn trong cache (warm cache):
| Phép đo | Lượt 1 | Lượt 2 |
|---|---|---|
aggregate cộng total của t0042/completed, index có total (covered, docs 0) | 0,60 ms | 0,49 ms |
Cùng pipeline, hint index { tenantId, status } (FETCH 1.359 doc) | 0,89 ms | 0,77 ms |
find trả 1.359 kết quả, covered | 1,51 ms | 1,02 ms |
find trả 1.359 kết quả, có _id (FETCH) | 2,55 ms | 1,65 ms |
Covered nhanh hơn khoảng 1,5 lần trong lab này. Ở một lượt đo thử trước đó, find covered còn chậm hơn một chút (2,15 ms so với 1,92 ms): với find, phần lớn thời gian là gửi và decode 1.359 kết quả ở client, nên chênh lệch nằm trong nhiễu. Hãy giữ kỳ vọng đúng [quan sát]: khi mọi thứ đã nằm trong cache, FETCH một document chỉ là một lần tra B-tree trong RAM, và covered query tiết kiệm một phần, không phải mười lần. Lợi ích lớn hơn nhiều khi collection không vừa cache: mỗi FETCH bị tránh có thể là một lần đọc đĩa được tránh, và covered query không kéo document vào cache, nhường chỗ cho dữ liệu khác (bài về cache và working set). Lab này không đo trường hợp đó.
Những thứ làm hỏng covered [tài liệu]:
- Projection không loại
_id(trừ khi_idcó trong index). - Filter so sánh bằng
null({ status: null }). Lab xác nhận: plan có FETCH. - Trả về một field mảng. Index trên mảng (multikey, xem bài Specialized Indexes) chứa từng phần tử, không chứa nguyên mảng. Lab: index
{ tenantId, tags, total }, query trêntags, trảtotalthì covered (240 key, 0 doc), trảtagsthì phải FETCH 240 document. - Trên sharded collection qua
mongos, index phải chứa shard key.
Cái giá: muốn cover thì phải nhét thêm field vào index. Index to hơn, và field đó đổi giá trị là index phải cập nhật. Chỉ đáng làm cho query chạy rất thường xuyên, trả ít field.
Thiết kế index cho một query: quy trình
Gom lại thành một quy trình áp dụng được cho query mới:
1. Viết ra query thật: filter, sort, limit, projection, tần suất
2. Equality fields → đầu index (thứ tự giữa chúng tuỳ ý)
3. Sort fields → tiếp theo, đúng thứ tự và chiều của sort
4. Range fields → cuối
⚠ range rất kén chọn và kết quả nhỏ → cân nhắc đặt trước sort (ERS)
5. Chỉ cần vài field hay đọc? → thêm vào cuối để cover, projection bỏ _id
6. Chỉ hỏi một phần dữ liệu? → partial index (bài Specialized Indexes)
7. Đo: explain("executionStats") với hint, so keys : docs : nReturned
8. Dọn: index nào giờ là prefix của index mới → xoáVà đừng quên mỗi index đều có giá (bài Index Fundamentals): thêm key phải ghi ở mỗi insert/update, thêm RAM, thêm đĩa. Index ghép dài hơn thì key to hơn (14,6 MB so với 11,8 MB ở phần multi-tenant). Một compound index đúng thứ tự thường thay được hai, ba index rời rạc, nên tổng chi phí thường giảm chứ không tăng.
So với PostgreSQL
Ý tưởng "index nhiều cột, cột trước quyết định trước" có ở cả hai bên. Khác biệt nằm ở chỗ planner dám làm gì khi query không đi đúng thứ tự.
| Chủ đề | MongoDB | PostgreSQL |
|---|---|---|
| Index nhiều cột | Compound index, tối đa 32 field; quy tắc prefix; ESR | B-tree nhiều cột; hiệu quả nhất khi có điều kiện trên các cột đầu. Tài liệu bản 18 mô tả skip scan: có thể dùng index dù một cột phía trước không có điều kiện bằng |
| Thiếu cột đầu | Không dùng được index (COLLSCAN trong lab) | Vẫn có thể dùng index (skip scan, hoặc quét cả index) |
| Bỏ qua cột giữa | Dùng được, nhảy qua từng giá trị của cột giữa (seeks: 5 trong lab) | Tương tự: skip scan |
| Covered | Mọi field phải nằm trong key của index | index-only scan, có thể thêm cột không phải key bằng INCLUDE; nhưng vẫn phải kiểm tra visibility map, page chưa "all-visible" thì vẫn đọc heap |
Người chuyển từ PostgreSQL sang hay mang theo thói quen "vài index đơn, planner tự ghép". Trên PostgreSQL đó là chiến lược hợp lệ nhờ bitmap index scan. Trên MongoDB thì không: compound index đúng thứ tự là đường chính. Bài Specialized Indexes đo chuyện này ở phần index intersection.
Những lỗi thường gặp
- Tạo một index đơn cho mỗi field trong filter. MongoDB gần như không ghép chúng. Một compound index đúng thứ tự thường thay được cả nhóm.
- Giữ index đơn đã là prefix của một compound index.
{ tenantId: 1 }thừa khi đã có{ tenantId: 1, status: 1, createdAt: -1 }: trả chi phí ghi, RAM, đĩa hai lần cho cùng một khả năng. - Xếp field theo "độ kén chọn giảm dần" thay vì theo ESR. Field kén chọn nhất chưa chắc đứng đầu. Equality đứng trước, sort trước range, trừ khi range cực kén chọn.
- Sort nhiều field với chiều trộn lẫn mà index không khớp chiều.
{ status: 1, createdAt: -1 }phục vụ(↑, ↓)và(↓, ↑), không phục vụ(↑, ↑). - Index
{ createdAt: -1 }cho màn hình theo tenant. Tenant nhỏ hoặc mới quét gần hết collection (985.146 document cho 20 kết quả trong lab). - Quên
_id: 0khi nhắm tới covered query. Một field_idlà đủ để FETCH mọi document.
Cột mốc: Bạn đã có thể nhận ra covered query trong explain, biết những gì làm nó mất covered, và thiết kế index cho một query theo quy trình. Bài compound index khép lại ở đây.
Hỏi & đáp
Index { tenantId: 1, status: 1, total: 1 }. Query find({ tenantId: "t0042", status: "completed" }, { total: 1 }) vẫn có FETCH, totalDocsExamined bằng nReturned. Vì sao không được cover?
Một dev quen PostgreSQL định lập index cho find({ tenantId, status }).sort({ createdAt: -1 }) trên MongoDB. Cách nào hợp với cách MongoDB làm việc?
Nếu phải giải thích bài này mà không dùng thuật ngữ MongoDB nào: một cuốn danh bạ sắp theo tỉnh, rồi họ, rồi tên chỉ giúp được những câu hỏi đi theo đúng thứ tự đó. Hãy cố định những gì bạn biết chắc trước, rồi tới thứ bạn muốn xếp, cuối cùng mới tới khoảng giá trị. Và nếu danh bạ đã ghi sẵn thứ bạn cần hỏi, khỏi phải gọi điện cho ai.
Bài tiếp theo
Mọi index trong bài này đều là index "bình thường": mỗi document một key, field nào cũng là giá trị đơn, document nào cũng có mặt. Dữ liệu thật thì không ngoan như vậy. tags và items là mảng. Màn hình "cần xử lý" chỉ quan tâm 10% đơn pending. Email phải duy nhất nhưng không phải ai cũng có email. Session phải tự biến mất sau 30 phút. Thuộc tính sản phẩm thì người bán tự đặt tên.
Bài Specialized Indexes đi qua các index sinh ra cho đúng những tình huống đó: multikey, partial và sparse, unique, TTL, wildcard, và index intersection, thứ nghe như lời giải cho "nhiều index đơn" nhưng hiếm khi được dùng. Với mỗi loại: nó giải quyết gì, tốn gì, và bẫy nằm ở đâu.