Compound & Advanced Indexing: thứ tự field, ESR và các loại index đặc biệt

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

Bài 07 dừng ở một tỉ lệ còn khó chịu. Có index tenantId_1, query "đơn pending của tenant t0042 từ 01/09" không còn đọc 1 triệu document nữa, nhưng vẫn đọc khoảng 2.000 document để trả về khoảng 20. Index chỉ biết tenantId. Còn status và createdAt thì server phải mở từng document ra kiểm tra.

Bài này trả lời câu hỏi tiếp theo: làm sao để một index biết nhiều field cùng lúc, và xếp các field đó theo thứ tự nào. Thứ tự field không phải chi tiết thẩm mỹ. Trong lab, cùng một query và cùng ba field, chỉ đổi thứ tự là đổi từ 45 key lên 48.751 key. Nửa sau của bài đi qua các index "có tính năng riêng": multikey, partial, sparse, unique, TTL, wildcard, covered query, và index intersection, thứ nghe rất hay nhưng hiếm khi được dùng.

Bài này nằm ở đâu

Cần biết trước : bài 06 (ngữ nghĩa query trên mảng, $elemMatch, sort/limit)
                 bài 07 (B-tree, IXSCAN + FETCH, selectivity, chi phí mỗi index)
Giới thiệu     : compound index và quy tắc prefix, sort bằng index (kể cả chiều ngược),
                 ESR (Equality, Sort, Range) và ngoại lệ ERS, index cho multi-tenant,
                 multikey (và luật "một mảng mỗi compound index"), partial, sparse,
                 unique (với null/field vắng), TTL, wildcard, covered query,
                 index intersection (AND_SORTED / AND_HASH)
Dẫn tới        : bài 10, Query Planner & explain()

Môi trường lab: MongoDB 8.3.11 chạy trong Docker (image mongo:8, standalone), container riêng mongo-lab-07, giới hạn 2 CPU, 3 GB RAM, WiredTiger cache 1 GB, máy host Apple M4. Database lab07. Mọi con số đều đo thật, trừ khi được ghi là minh hoạ.

Trong lúc đo, lab của các bài 07, 10, 11, 13 chạy song song trên cùng máy host, nên thời gian có nhiễu. Mỗi phép đo thời gian lặp 21–51 lần (lấy median) và chạy ít nhất hai lượt. Con số tuyệt đối dao động giữa các lượt, nhưng tỉ lệ giữa hai phương án thì ổn định. Số key, số document và số byte không bị nhiễu.

Như bài 07, bài dùng ba nhãn: [tài liệu] là hành vi được tài liệu chính thức mô tả, [quan sát] là điều đo được trong lab nhưng không phải cam kết của MongoDB, [hình dung] là mô hình đơn giản hoá để dễ nhớ.

Hình dung trước: cuốn danh bạ điện thoại

Bài 07 dùng tủ phiếu mục lục của thư viện: mỗi phiếu ghi một giá trị và chỉ tới chỗ để cuốn sách. Giờ hãy nghĩ tới một cuốn danh bạ điện thoại in giấy. Nó được sắp theo tỉnh, trong mỗi tỉnh sắp theo họ, cùng họ thì sắp theo tên.

Danh bạ sắp theo (tỉnh, họ, tên)

Đà Nẵng  │ Lê      │ An     → 0905...
Đà Nẵng  │ Lê      │ Bình   → 0905...
Đà Nẵng  │ Nguyễn  │ Hoa    → 0906...
Hà Nội   │ Lê      │ Minh   → 0912...
Hà Nội   │ Nguyễn  │ An     → 0913...
Hà Nội   │ Nguyễn  │ Lan    → 0913...
Hà Nội   │ Trần    │ Bảo    → 0914...

Với cuốn sổ này:

  • "Mọi người ở Hà Nội" → mở đúng đoạn Hà Nội. ✓
  • "Họ Nguyễn ở Hà Nội" → mở Hà Nội, rồi đoạn Nguyễn bên trong. ✓
  • "Họ Nguyễn ở Hà Nội, xếp theo tên" → đoạn đó vốn đã xếp theo tên, chỉ việc đọc. ✓
  • "Mọi người họ Nguyễn, tỉnh nào cũng được" → Nguyễn nằm rải ở mọi tỉnh. Phải lật từng tỉnh. ✗
  • "Mọi người ở Hà Nội, xếp theo tên" (không quan tâm họ) → đoạn Hà Nội xếp theo họ trước, nên phải chép ra rồi xếp lại. ✗

Đó gần như là toàn bộ bài này. Compound index chính là cuốn danh bạ: một danh sách sắp theo nhiều cột, cột trước quyết định trước. Câu hỏi nào đi theo đúng thứ tự sắp xếp thì nhanh. Câu hỏi nào đi ngược thì phải lật hoặc phải xếp lại.

[hình dung] MongoDB không in danh bạ. Index là một B-tree trong WiredTiger (bài 07), và key của compound index là một bộ giá trị ghép. Nhưng thứ tự của các key trong B-tree đúng là thứ tự "cột trước, rồi cột sau", nên hình ảnh danh bạ khớp với hành vi.

Setup và dataset

Dataset có cùng hình dạng với bài 06 và 07, nhưng được sinh lại bằng PRNG mulberry32 với seed cố định, và có thêm hai field để dùng cho phần multikey và sparse/partial. Vì vậy số liệu không khớp từng con số với hai bài trước (tenant t0042 có 1.948 đơn thay vì 2.003).

// trích gen.js: 100 lần insertMany, mỗi lần 10.000 document
const doc = {
  tenantId: "t" + String(Math.floor(rnd() * 500)).padStart(4, "0"),  // 500 tenant
  userId:   "u" + String(Math.floor(rnd() * 200)).padStart(4, "0"),
  status:   pickStatus(rnd()),          // 70% completed, 10% pending/cancelled/refunded
  createdAt: new Date(END - Math.floor(rnd() * 365 * DAY)),  // 365 ngày trước 2026-10-01
  total:    items.reduce((s, it) => s + it.qty * it.price, 0),  // VND
  tags,                                 // mảng 0–3 phần tử, lấy từ 12 nhãn
  items                                 // 1–3 món { sku, qty, price }
};
if (rnd() < 0.03) doc.couponCode = "C" + Math.floor(rnd() * 50);  // chỉ ~3% đơn có mã
// cộng thêm tenant "t9999": 30 đơn rất cũ (khoảng 360 ngày trước)
MongoDB version : 8.3.11 (mongo:8, standalone)
Hardware        : Apple M4 host; container 2 CPU, 3 GB RAM
Configuration   : --wiredTigerCacheSizeGB 1
Dataset         : lab07.orders, 1.000.030 document (sinh mất 50,7 s)
Kích thước      : 233,6 MB chưa nén (avgObjSize 244 byte), 78,6 MB trên đĩa
Phân bố         : t0042 = 1.948 đơn (182 pending), t9999 = 30 đơn, 30.093 đơn có couponCode

Toàn bộ dữ liệu và index nằm gọn trong cache 1 GB. Mọi con số thời gian trong bài là khi dữ liệu nóng.

Mỗi dòng kết quả kiểu plan | keys | docs | nReturned trong bài là explain("executionStats") được rút gọn bằng một helper nhỏ. Thời gian là median của toArray() đo bằng process.hrtime.

Compound index: một danh sách sắp theo nhiều cột

30 giây

Compound index là index trên nhiều field, ví dụ { tenantId: 1, status: 1, createdAt: -1 }. Mỗi document tạo ra một key ghép ba giá trị. Các key được sắp theo tenantId trước, cùng tenantId thì theo status, cùng status thì theo createdAt giảm dần. Một index ghép tối đa 32 field [tài liệu].

Index { tenantId: 1, status: 1, createdAt: -1 }   (một đoạn, minh hoạ)

("t0042", "cancelled", 2026-09-28) → RecordId
("t0042", "cancelled", 2026-09-02) → RecordId
("t0042", "completed", 2026-09-30) → RecordId
  ...
("t0042", "pending",   2026-09-29) → RecordId  ┐
("t0042", "pending",   2026-09-14) → RecordId  │ đơn pending của t0042,
("t0042", "pending",   2026-08-30) → RecordId  │ đã xếp mới nhất trước
  ...                                          ┘
("t0042", "refunded",  2026-09-27) → RecordId
("t0043", "cancelled", ...)

Đơn pending của t0042 nằm liền nhau và đã xếp sẵn theo ngày. Đó là hai thứ mà index một field không cho được.

Quy tắc prefix: index dùng được cho những câu hỏi nào

Prefix là các field đầu tiên của index tính từ trái sang. Index { tenantId, status, createdAt } có hai prefix: { tenantId } và { tenantId, status }. Tài liệu nói compound index hỗ trợ query trên các prefix của nó, và không dùng được cho query thiếu field đầu tiên [tài liệu].

Thử bảy câu hỏi trên cùng một index:

db.orders.createIndex({ tenantId: 1, status: 1, createdAt: -1 });
const since = ISODate("2026-09-01T00:00:00Z");
show("A", db.orders.find({ tenantId: "t0042" }));
show("B", db.orders.find({ tenantId: "t0042", status: "pending" }));
show("C", db.orders.find({ tenantId: "t0042", status: "pending", createdAt: { $gte: since } }));
show("D", db.orders.find({ tenantId: "t0042", createdAt: { $gte: since } }));
show("E", db.orders.find({ status: "pending" }));
show("F", db.orders.find({ status: "pending", createdAt: { $gte: since } }));
A tenant                 | FETCH <- IXSCAN tenantId_1_status_1_createdAt_-1 | keys 1948 | docs 1948 | nReturned 1948
B tenant+status          | FETCH <- IXSCAN tenantId_1_status_1_createdAt_-1 | keys 182  | docs 182  | nReturned 182
C tenant+status+date     | FETCH <- IXSCAN tenantId_1_status_1_createdAt_-1 | keys 13   | docs 13   | nReturned 13
D tenant+date (bỏ status)| FETCH <- IXSCAN tenantId_1_status_1_createdAt_-1 | keys 174  | docs 169  | nReturned 169
E status                 | COLLSCAN | keys 0 | docs 1000030 | nReturned 99639
F status+date            | COLLSCAN | keys 0 | docs 1000030 | nReturned 8151

Đọc từng dòng:

  • C là câu query của bài 06 và 07. Bài 07 với tenantId_1 đọc khoảng 2.000 document để trả về khoảng 20. Ở đây là 13 : 13 : 13, tức mọi key đọc ra đều có ích. Đây là tỉ lệ lý tưởng mà bài 07 nhắc tới.
  • E, F thiếu tenantId, field đầu tiên. Đơn pending nằm rải ở 500 đoạn tenant khác nhau, giống họ Nguyễn rải ở mọi tỉnh. Planner không có index phù hợp nên chạy COLLSCAN.
  • D thú vị nhất. Query bỏ qua status ở giữa nhưng vẫn có điều kiện trên createdAt. Index bounds trong explain:
tenantId : ["t0042", "t0042"]
status   : [MinKey, MaxKey]          ← mọi status
createdAt: [new Date(9223372036854775807), new Date(1788220800000)]   ← từ 01/09 trở về sau

Server không đọc hết 1.948 key của t0042. Trong lab, IXSCAN báo seeks: 5: nó nhảy vào từng đoạn status (t0042 có 4 status), chỉ đọc phần từ 01/09 trở đi, đọc 174 key cho 169 kết quả [quan sát]. Hiệu quả là nhờ status chỉ có 4 giá trị. Field ở giữa có hàng nghìn giá trị thì cũng phải nhảy hàng nghìn lần. Tài liệu nói index kiểu này vẫn dùng được nhưng "không hiệu quả bằng" index không có field chen giữa [tài liệu].

Hệ quả thực tế: nếu đã có { tenantId: 1, status: 1, createdAt: -1 } thì index đơn { tenantId: 1 } là thừa. Mọi query nó phục vụ được thì prefix của index ghép cũng phục vụ được, còn bạn thì trả chi phí ghi, RAM, đĩa (bài 07) cho hai index. Bài 10 gặp lại đúng trường hợp này.

Sort bằng index, và chiều của sort

Đọc index theo thứ tự là có kết quả đã xếp, không cần stage SORT, nhưng chỉ khi thứ tự yêu cầu trùng với thứ tự của index sau khi các field đứng trước đã bị cố định bằng điều kiện bằng.

db.orders.find({ tenantId: "t0042" }).sort({ status: 1, createdAt: -1 })                  // G
db.orders.find({ tenantId: "t0042" }).sort({ createdAt: -1 }).limit(20)                   // H
db.orders.find({ tenantId: "t0042", status: "pending" }).sort({ createdAt: 1 })           // I
db.orders.find({ tenantId: "t0042" }).sort({ status: 1, createdAt: 1 })                   // J
db.orders.find({ tenantId: "t0042" }).sort({ status: -1, createdAt: 1 })                  // K
G sort status↑ createdAt↓          | FETCH <- IXSCAN          | keys 1948 | docs 1948
H sort createdAt↓, limit 20        | FETCH <- SORT <- IXSCAN  | keys 1948 | docs 20
I status=pending, sort createdAt↑  | FETCH <- IXSCAN          | keys 182  | docs 182
J sort status↑ createdAt↑          | FETCH <- SORT <- IXSCAN  | keys 1948 | docs 1948
K sort status↓ createdAt↑          | FETCH <- IXSCAN          | keys 1948 | docs 1948
  • G trùng thứ tự index. Không SORT.
  • H muốn xếp theo createdAt nhưng status đứng chen giữa và không bị cố định. Trong index, đơn của t0042 xếp theo status trước, nên server phải đọc cả 1.948 key rồi xếp lại. [quan sát] Trên 8.3.11, SORT nằm dưới FETCH: server xếp các key, rồi chỉ FETCH 20 document thắng. Vẫn là blocking sort, nhưng rẻ hơn kiểu "fetch hết rồi xếp" của bài 06.
  • I sort tăng dần trên index giảm dần vẫn không cần SORT: B-tree đọc được theo cả hai chiều (explain ghi direction: 'backward').
  • J và K là quy tắc chiều sort. Index { status: 1, createdAt: -1 } đọc xuôi cho (↑, ↓), đọc ngược cho (↓, ↑). (↑, ↑) không phải chiều nào của index cả, nên phải SORT. Chiều chỉ quan trọng khi sort nhiều field với chiều trộn lẫn.

Tài liệu tóm lại thành một câu: index hỗ trợ sort trên một phần các key của nó chỉ khi query có điều kiện bằng trên mọi field prefix đứng trước các sort key [tài liệu]. Câu đó mở đường cho ESR.

ESR: Equality, Sort, Range

30 giây

Khi query có điều kiện bằng, có sort và có điều kiện khoảng, hãy xếp field trong index theo thứ tự:

E  Equality   field so sánh bằng          tenantId: "t0042", status: "pending"
S  Sort       field trong .sort()          createdAt
R  Range      field so sánh khoảng          total: { $gte: ... }, $ne, $nin, $regex

Vì sao thứ tự này

Quay lại danh bạ. Câu hỏi: "người ở Hà Nội, có số điện thoại bắt đầu bằng 09, xếp theo tên".

Sổ A sắp theo (tỉnh, tên, số)  = E S R          Sổ B sắp theo (tỉnh, số, tên) = E R S

Hà Nội │ An   │ 0248 ✗ bỏ qua                   Hà Nội │ 0901 │ Minh  ┐
Hà Nội │ An   │ 0913 ✓                         Hà Nội │ 0912 │ An    │ đoạn "09..." liền nhau
Hà Nội │ Bảo  │ 0914 ✓                         Hà Nội │ 0914 │ Bảo   │ nhưng tên lộn xộn
Hà Nội │ Lan  │ 0913 ✓                         Hà Nội │ 0987 │ Lan   ┘
  ...                                             → chép ra, xếp lại theo tên
→ đọc từ trên xuống là có thứ tự tên,
  cần 20 người thì dừng sau ~20 dòng có ích
  • E trước: điều kiện bằng thu hẹp về một đoạn liền nhau. Trong đoạn đó, các field sau vẫn có thứ tự.
  • S trước R: nếu field sort đứng ngay sau equality, đọc index theo thứ tự là có kết quả đã xếp. Range được kiểm tra ngay trên key (không cần mở document), và có limit thì dừng sớm.
  • R trước S (sổ B): range cắt ra một đoạn liền nhau, nhưng trong đoạn đó thứ tự là theo field range, không phải field sort. Phải gom mọi key khớp rồi xếp lại trong bộ nhớ.

Thí nghiệm: hai thứ tự, cùng ba field

Câu hỏi kiểu dashboard: "20 đơn pending mới nhất có tổng tiền từ 5 triệu trở lên".

db.orders.createIndex({ status: 1, createdAt: -1, total: 1 }, { name: "ESR_s" });
db.orders.createIndex({ status: 1, total: 1, createdAt: -1 }, { name: "ERS_s" });
const q = th => db.orders.find({ status: "pending", total: { $gte: th } })
                         .sort({ createdAt: -1 }).limit(20);
show("ESR_s", q(5e6).hint("ESR_s"));
show("ERS_s", q(5e6).hint("ERS_s"));

hint() ép server dùng đúng index được chỉ định, để so sánh hai cách một cách công bằng (tài liệu ESR cũng khuyên dùng hint() khi thử index [tài liệu]). Có 48.751 đơn pending từ 5 triệu trở lên.

ESR_s | LIMIT <- FETCH <- IXSCAN ESR_s        | keys 45    | docs 20 | nReturned 20
ERS_s | FETCH <- SORT <- IXSCAN ERS_s         | keys 48751 | docs 20 | nReturned 20
IndexThứ tựkeysExaminedSORT trong bộ nhớmedian lượt 1lượt 2lượt 3
ESR_sstatus, createdAt, total45không1,24 ms0,60 ms0,36 ms
ERS_sstatus, total, createdAt48.751có24,58 ms12,59 ms13,10 ms
ESR                                        ERS
IXSCAN đoạn "pending", đi từ mới nhất       IXSCAN đoạn "pending, total ≥ 5tr"
  kiểm tra total trên key                     48.751 key, thứ tự theo total
  45 key → 20 key khớp → dừng (LIMIT)           ↓
  ↓                                          SORT 48.751 key theo createdAt (top 20)
FETCH 20 document                              ↓
                                           FETCH 20 document

ESR đọc 45 key: khoảng một nửa đơn pending có tổng từ 5 triệu, nên đi qua khoảng 45 đơn mới nhất là gom đủ 20. ERS phải đọc cả 48.751 key, vì chỉ khi thấy hết mới biết 20 đơn nào mới nhất. Chênh lệch khoảng 20–35 lần về thời gian và hơn 1.000 lần về số key. Chênh lệch này tăng theo số đơn khớp range: dữ liệu càng lớn, ERS càng đuối.

Cả hai chỉ FETCH 20 document vì, như câu H, bản 8.3 xếp trên key rồi mới fetch [quan sát]. Nếu SORT nằm trên FETCH, ERS còn phải fetch cả 48.751 document.

Ngoại lệ: khi range rất kén chọn

Giờ đổi ngưỡng thành 25 triệu. Chỉ có 5 đơn pending đạt mức này.

ESR_s | LIMIT <- FETCH <- IXSCAN ESR_s        | keys 99637 | docs 5 | nReturned 5
ERS_s | FETCH <- SORT <- IXSCAN ERS_s         | keys 5     | docs 5 | nReturned 5
IndexkeysExaminedmedian lượt 1lượt 2lượt 3
ESR_s99.63786,37 ms47,87 ms48,24 ms
ERS_s50,55 ms0,33 ms0,27 ms

Thứ tự thắng thua đảo ngược. ESR đi từ đơn mới nhất xuống, kiểm tra từng key, mong gom đủ 20 đơn. Nhưng chỉ có 5 đơn trong cả 99.639 đơn pending, nên nó đi hết cả đoạn. ERS nhảy thẳng vào đoạn "total ≥ 25 triệu", lấy 5 key, xếp 5 phần tử trong bộ nhớ, xong.

Tài liệu ESR ghi đúng ngoại lệ này: nếu range predicate rất kén chọn, đặt nó trước field sort (ERS) để giảm số document phải xếp và cho phép sort trong bộ nhớ [tài liệu]. Quy tắc vì vậy không phải "luôn ESR", mà là:

Range khớp nhiều, có sort + limit         → E S R   (tránh sort, dừng sớm)
Range khớp rất ít                        → E R S   (đọc ít key, sort vài phần tử)
Không biết trước / cả hai đều xảy ra      → đo bằng hint() trên dữ liệu thật,
                                            có khi cần cả hai index

[quan sát] Không có hint, planner chọn ESR_s cho ngưỡng 5 triệu và ERS_s cho ngưỡng 25 triệu. Lần này nó chọn đúng cả hai. Nhưng đó là cùng một query shape với hai giá trị tham số khác nhau, và plan được chọn có thể được cache rồi dùng lại cho giá trị kia. Đó là câu chuyện "plan cache lật" của bài 10.

Cùng thí nghiệm trên tenant t0042 (1.948 đơn, index { tenantId, createdAt, total } so với { tenantId, total, createdAt }, query { tenantId, total ≥ ngưỡng } sort createdAt limit 20) cho cùng chiều kết quả, chỉ nhỏ hơn: ngưỡng 5 triệu thì ESR đọc 37 key, ERS 927 key (median một lượt: 0,72 ms so với 0,92 ms). Ngưỡng 20 triệu (5 đơn) thì ESR đọc 1.949 key, ERS 5 key (1,76 ms so với 0,32 ms). Với một tenant nhỏ, chọn sai thứ tự không gây hoạ. Với cả collection hay một tenant cỡ lớn thì có.

Operator nào là E, operator nào là R

OperatorXếp vàoGhi chú
giá trị trực tiếp, $eqEquality
$in dùng một mìnhEqualitymột loạt so khớp bằng
$in + sort(), mảng < 201 phần tửgần như Equalityserver nở ra từng giá trị rồi trộn theo thứ tự sort (SORT_MERGE)
$in + sort(), mảng ≥ 201 phần tửgần như Range
$gt $gte $lt $lteRange
$ne, $ninRange"khác" không phải "bằng"
$regexRange

Toàn bộ bảng là [tài liệu] theo trang ESR. Tài liệu cũng ghi rõ ngưỡng 201 phần tử của $in "không được đảm bảo giữ nguyên ở mọi phiên bản". Trong nhóm Equality, các field có thể xếp theo bất kỳ thứ tự nào với nhau, miễn là đứng trước mọi field sort và range.

Multi-tenant: { tenantId, createdAt } hay { createdAt }

Màn hình phổ biến nhất của một hệ thống SaaS: "20 đơn mới nhất của shop tôi". Một lỗi hay gặp là tạo index { createdAt: -1 } vì "query nào cũng sort theo ngày". Hai index được so sánh:

db.orders.createIndex({ createdAt: -1 });
db.orders.createIndex({ tenantId: 1, createdAt: -1 });
const page = t => db.orders.find({ tenantId: t }).sort({ createdAt: -1 }).limit(20);

Ba tenant: t0042 (1.948 đơn, cỡ trung bình), t9999 (30 đơn, đều cũ gần một năm) và t7777 (không tồn tại, chẳng hạn một shop vừa đăng ký).

TenantIndexkeysExamineddocsExaminedmedian lượt 1lượt 2lượt 3
t0042{ createdAt: -1 }8.2698.26919,73 ms7,34 ms7,71 ms
t0042{ tenantId: 1, createdAt: -1 }20200,72 ms0,29 ms0,25 ms
t9999{ createdAt: -1 }985.146985.1461.644 ms996 ms918 ms
t9999{ tenantId: 1, createdAt: -1 }20201,62 ms0,41 ms0,24 ms
t7777{ createdAt: -1 }1.000.0301.000.0301.717 ms——
t7777{ tenantId: 1, createdAt: -1 }000,40 ms——
{ createdAt: -1 }: đi từ đơn mới nhất của CẢ HỆ THỐNG xuống

  2026-09-30 t0311 ✗ → FETCH, kiểm tra tenantId, vứt
  2026-09-30 t0042 ✓
  2026-09-30 t0187 ✗ → FETCH, vứt
  ...                                   t0042: trung bình 1/500 đơn là của nó
                                        t9999: 30 đơn nằm gần CUỐI index
{ tenantId: 1, createdAt: -1 }: nhảy thẳng vào đoạn "t0042", đọc 20 key, dừng

Ba điều rút ra:

  1. Chi phí của { createdAt: -1 } phụ thuộc vào tenant. Tenant lớn và hoạt động nhiều thì thấy nhanh. Tenant nhỏ, ít hoạt động, hoặc vừa đăng ký thì quét gần cả collection. Đây là lỗi kiểu "dev thì nhanh, khách nhỏ thì kêu chậm", vì môi trường dev thường test bằng tenant có nhiều dữ liệu.
  2. IXSCAN + FETCH trên cả collection còn chậm hơn COLLSCAN. Với t9999, đi theo { createdAt: -1 } mất 918–1.644 ms. COLLSCAN có hint({ $natural: 1 }) cho cùng query đo được median 152 ms. [quan sát] Đi theo index nghĩa là FETCH từng document theo thứ tự của index, tức là nhảy lung tung trong collection. COLLSCAN đọc lần lượt. Bài 07 đã đo hiện tượng này ở dạng "index làm chậm khi selectivity thấp".
  3. { tenantId, createdAt } cho chi phí gần như không đổi (20 key, 20 document) bất kể tenant to hay nhỏ. Đó là tính chất bạn muốn ở một hệ thống nhiều tenant: chi phí của một request tỉ lệ với kết quả, không tỉ lệ với kích thước của người khác.

Cái giá: tenantId_1_createdAt_-1 chiếm 14,6 MB so với 11,8 MB của createdAt_-1, vì key dài hơn. Phân trang tiếp theo trang này (range/cursor theo createdAt + _id) là việc của bài 13.

Multikey index: index trên mảng

30 giây

Đánh index trên một field là mảng, MongoDB tạo một key cho mỗi phần tử. Index đó tự động trở thành multikey index. Bạn không cần khai báo gì [tài liệu].

Document                              Key trong index { tags: 1 }
{ _id: A, tags: ["gift", "vip"] }  →  "gift" → A
                                      "vip"  → A
{ _id: B, tags: ["gift"] }         →  "gift" → B
{ _id: C, tags: [] }               →  undefined → C

Quan sát thật

db.orders.createIndex({ tags: 1 });
show("tags gift", db.orders.find({ tags: "gift" }));
tag elements: 1418053   docs with empty tags: 250094
tags gift | FETCH <- IXSCAN tags_1 | keys 118296 | docs 118296 | nReturned 118296
isMultiKey true  multiKeyPaths {"tags":["tags"]}
full index scan keys: 1668147

1.000.030 document sinh ra 1.668.147 key: 1.418.053 key cho các phần tử, cộng 250.094 key cho các document có mảng rỗng [quan sát]. Một document có 3 tag tốn 3 key, và mỗi lần thêm hay bớt tag là thêm hay bớt key. Đây là lý do bài 04 cảnh báo mảng không giới hạn: một mảng 5.000 phần tử có index là 5.000 key phải ghi cho một document.

Explain có hai trường cần để ý: isMultiKey: true và multiKeyPaths, cho biết field nào gây ra multikey. Planner dùng thông tin này để biết khi nào được phép ghép bounds và khi nào được phép dùng index để cover query.

Bounds: vì sao $elemMatch quan trọng với index

Bài 06 cho thấy { vals: { $gt: 1, $lt: 5 } } trên mảng có ngữ nghĩa "một phần tử thoả điều kiện này, có thể một phần tử khác thoả điều kiện kia". Index phải tôn trọng ngữ nghĩa đó:

db.orders.createIndex({ "items.price": 1 });
show("plain    ", db.orders.find({ "items.price": { $gte: 500000, $lte: 510000 } }));
show("elemMatch", db.orders.find({ items: { $elemMatch: { price: { $gte: 500000, $lte: 510000 } } } }));
plain     | FETCH <- IXSCAN items.price_1 | keys 1013360 | docs 717184 | nReturned 434798
          indexBounds: { 'items.price': [ '[500000, inf]' ] }
elemMatch | FETCH <- IXSCAN items.price_1 | keys 39707   | docs 39442  | nReturned 39442
          indexBounds: { 'items.price': [ '[500000, 510000]' ] }

Không có $elemMatch, planner không được giao hai bounds thành [500000, 510000], vì một document có món giá 900.000 và món khác giá 100.000 vẫn khớp. Nó chỉ dùng được một nửa điều kiện ([500000, inf]) rồi kiểm tra nửa kia sau khi FETCH. Kết quả là đọc 1.013.360 key so với 39.707 [tài liệu] cho quy tắc bounds, [quan sát] cho con số. Hai query cũng trả về kết quả khác nhau (434.798 so với 39.442), đúng như bài 06.

keys > docs ở dòng đầu là vì mỗi đơn có tới 3 món: cùng một document có thể được nhiều key trỏ tới. IXSCAN phải khử trùng (explain ghi dupsDropped: 296176).

Luật "một mảng mỗi compound index"

db.orders.createIndex({ tags: 1, "items.sku": 1 });
CannotIndexParallelArrays - Index build failed ... cannot index parallel arrays [items] [tags]

Một compound index chỉ cho phép tối đa một field là mảng trong mỗi document [tài liệu]. Lý do dễ hình dung: 3 tag nhân 3 món là 9 key cho một document, và mảng càng dài thì tích càng nổ. Luật này cũng được kiểm tra khi ghi:

db.orders.createIndex({ tags: 1, couponCode: 1 });   // OK: couponCode chưa bao giờ là mảng
db.orders.insertOne({ tenantId: "t0001", tags: ["gift"], couponCode: ["C1", "C2"] });
insert error: cannot index parallel arrays [couponCode] [tags]

⚠ Nghĩa là một index tạo ra hôm nay có thể làm lệnh insert của ngày mai thất bại, khi ai đó đổi một field thành mảng. Nếu dùng flexible schema (bài 02), hãy khoá kiểu của các field nằm trong compound index bằng $jsonSchema.

Ngoài ra multikey index không làm shard key được, và $expr không dùng được multikey index [tài liệu].

Partial và sparse: chỉ index một phần collection

30 giây

Không phải document nào cũng cần có mặt trong index. Partial index chỉ chứa những document khớp một điều kiện (partialFilterExpression). Sparse index chỉ chứa những document có field được index. Partial là phiên bản tổng quát và linh hoạt hơn, và tài liệu khuyên dùng partial thay cho sparse [tài liệu].

Partial: chỉ index đơn pending

Đơn pending chỉ chiếm 10%, nhưng đó lại là 10% mà màn hình "cần xử lý" hỏi liên tục. Bài 07 đã gợi ý cách này cho field có selectivity thấp như status.

db.orders.createIndex({ tenantId: 1, createdAt: -1 }, { name: "full" });
db.orders.createIndex({ tenantId: 1, createdAt: -1 },
  { name: "pending_only", partialFilterExpression: { status: "pending" } });
indexSizes: { full: 14589952, pending_only: 1921024 }

Cùng key pattern, nhưng index partial chỉ 1,9 MB so với 14,6 MB, nhỏ hơn khoảng 7,6 lần. Nhỏ hơn nghĩa là ít RAM hơn, và 90% lệnh ghi (đơn không phải pending) không đụng tới nó.

Điều kiện để planner dùng nó: query phải bao hàm filter của index [tài liệu].

{ tenantId, status: "pending" } sort createdAt  → IXSCAN pending_only | keys 20 | docs 20 ✓
  (cùng query, hint "full")                     → IXSCAN full         | keys 219 | docs 219
{ tenantId } sort createdAt                     → IXSCAN full  (không có status trong query)
{ tenantId, status: { $in: ["pending","refunded"] } } → IXSCAN full  (rộng hơn filter của index)

Query có status: "pending" dùng pending_only và đọc đúng 20 key. Index full phải đọc 219 key rồi FETCH để lọc status. Query không nói gì về status, hoặc nói "pending hoặc refunded", thì không thể dùng pending_only: index đó không chứa đơn refunded, dùng nó sẽ trả thiếu.

⚠ [quan sát] Đừng ép partial index bằng hint(). Trong lab, find({ tenantId: "t0042" }).hint("pending_only") không báo lỗi, mà trả về 182 đơn thay vì 1.948. Server tin vào hint của bạn và trả về đúng những gì có trong index.

Sparse: hành vi cũ, nhiều bẫy hơn

db.orders.createIndex({ couponCode: 1 }, { name: "coupon_sparse", sparse: true });

Index này chỉ có 30.093 key (204 KB), vì chỉ 3% đơn có couponCode. Query { couponCode: "C7" } dùng nó bình thường (587 key). Nhưng:

find().sort({ couponCode: 1 }).limit(5)       → SORT <- COLLSCAN, docs 1000030
find({ couponCode: null })                    → COLLSCAN
countDocuments({}, { hint: "coupon_sparse" }) → 30093     (thật ra có 1000030 document)
find().sort({couponCode:1}).hint(sparse)      → trả 30093 document, thiếu 969.937

Tài liệu nói rõ: MongoDB không dùng sparse index nếu nó cho kết quả thiếu cho query hoặc sort, trừ khi bạn hint() nó, và một count() có hint sparse index sẽ trả về số sai [tài liệu]. Lab xác nhận cả hai.

Vì sao partial thường tốt hơn:

SparsePartial
Lọc theochỉ "field có tồn tại"mọi điều kiện: bằng, $exists: true, $gt/$gte/$lt/$lte, $type, $and, $or, $in [tài liệu]
Lọc theo field khác field được indexkhôngđược (vd index tenantId, createdAt, lọc theo status)
Compounddocument vào index nếu có ít nhất một field của index [tài liệu], dễ gây bất ngờđiều kiện rõ ràng
Ý đồ khi đọc lạingầmviết ngay trong định nghĩa index

Muốn giả lập sparse thì dùng partial { couponCode: { $exists: true } }. Không đặt được cả sparse lẫn partialFilterExpression trên cùng index. Lưu ý thêm: wildcard, text, 2dsphere (v2) luôn là sparse [tài liệu].

Unique index: ràng buộc, không chỉ là tốc độ

30 giây

Unique index từ chối mọi lệnh ghi tạo ra key trùng với một document khác. Đó là cách duy nhất để database đảm bảo không trùng, kể cả khi hai request chạy đồng thời, như bài 06 đã thấy với upsert.

Bẫy null: field vắng mặt cũng là một giá trị

db.users.createIndex({ email: 1 }, { unique: true });
db.users.insertOne({ _id: 1, tenantId: "t0042", email: "lan@example.com" });
db.users.insertOne({ _id: 2, tenantId: "t0042", email: "lan@example.com" });
db.users.insertOne({ _id: 3, tenantId: "t0042", phone: "0901" });   // đăng ký bằng SĐT
db.users.insertOne({ _id: 4, tenantId: "t0042", phone: "0902" });   // người thứ hai cũng vậy
_id 1 → ok
_id 2 → E11000 duplicate key error collection: lab07.users index: email_1 dup key: { email: "lan@example.com" }
_id 3 → ok
_id 4 → E11000 duplicate key error collection: lab07.users index: email_1 dup key: { email: null }

Document không có email được index với key null. Người đầu tiên đăng ký bằng số điện thoại chiếm mất key null, và người thứ hai bị từ chối vì "trùng email" dù không ai có email. Tài liệu mô tả đúng như vậy: unique index một field chỉ chứa được một document có key null [tài liệu].

Cách sửa: unique + partial, và unique theo tenant

db.users.createIndex({ tenantId: 1, email: 1 },
  { unique: true, partialFilterExpression: { email: { $type: "string" } } });
{ _id: 1, tenantId: "t0042", email: "lan@example.com" }  → ok
{ _id: 2, tenantId: "t0099", email: "lan@example.com" }  → ok      (tenant khác)
{ _id: 3, tenantId: "t0042", email: "lan@example.com" }  → E11000 dup key: { tenantId: "t0042", email: "lan@example.com" }
{ _id: 4, tenantId: "t0042", phone: "0901" }             → ok
{ _id: 5, tenantId: "t0042", phone: "0902" }             → ok      (không có email, không vào index)
{ _id: 6, tenantId: "t0042", email: null }               → ok      (null không phải string)
{ _id: 7, tenantId: "t0042", email: "Lan@example.com" }  → ok      ⚠ khác chữ hoa
  • Ràng buộc unique chỉ áp dụng cho document khớp filter [tài liệu]. Dùng $type: "string" thay vì $exists: true để cả email: null cũng không bị tính.
  • { tenantId, email } nghĩa là "duy nhất trong một tenant", thường đúng với nghiệp vụ SaaS hơn là duy nhất toàn hệ thống.
  • Dòng cuối là bẫy riêng: so sánh string phân biệt chữ hoa. Hãy chuẩn hoá (lowercase) trước khi ghi, hoặc tạo index với collation không phân biệt hoa thường (collation strength 2) và query với cùng collation.

Còn một chi tiết vận hành: không tạo được unique index khi dữ liệu đã có trùng. Lab báo DuplicateKey ... Index build failed [tài liệu]. Thêm unique index vào một collection đã chạy lâu thì phải tìm và xử lý chỗ trùng trước.

TTL index: để database tự dọn dữ liệu hết hạn

30 giây

TTL index là index một field trên field kiểu Date, có thêm expireAfterSeconds. Một tiến trình nền (TTL monitor) định kỳ xoá các document có ngày + expireAfterSeconds < bây giờ. Hợp với session, OTP, log tạm, cache.

[hình dung] Như nhân viên dọn kho đi một vòng mỗi phút. Hàng hết hạn lúc 10:00:05 vẫn nằm trên kệ tới khi nhân viên đi qua, có thể là 10:01:00.

Thí nghiệm: nhìn TTL monitor làm việc

db.sessions.createIndex({ lastSeenAt: 1 }, { expireAfterSeconds: 30 });
// 5.000 session "old"   : lastSeenAt = 1 giờ trước   → đã hết hạn từ lâu
// 5.000 session "fresh" : lastSeenAt = bây giờ       → hết hạn sau 30 s
// 10 session "string-date": lastSeenAt là STRING ISO, không phải Date
// 10 session "no-field" : không có lastSeenAt
// rồi cứ 0,5 s đếm số document theo kind, và đọc serverStatus().metrics.ttl
inserted at 07:31:35, fresh docs expire at 07:32:05
07:31:35 (t=0.1s)   fresh=5000 no-field=10 old=5000 string-date=10 | TTL passes +0, deleted +0
07:32:20 (t=45.2s)  no-field=10 string-date=10                     | TTL passes +1, deleted +10000
07:33:20 (t=105.3s) no-field=10 string-date=10                     | TTL passes +2, deleted +10000

Một lần đo riêng, chỉ theo dõi bộ đếm metrics.ttl.passes, thấy các lượt chạy ở 07:27:20, 07:28:20, 07:29:20, 07:30:20, 07:31:20, tức đúng khoảng 60 s một lượt. getParameter đọc được ttlMonitorSleepSecs: 60.

Đọc kết quả:

  • Hết hạn không có nghĩa là bị xoá ngay. 5.000 session "old" đã hết hạn từ một tiếng trước, nhưng vẫn nằm đó 45 giây sau khi insert, cho tới lượt chạy kế tiếp. Session "fresh" hết hạn lúc 07:32:05 và bị xoá lúc 07:32:20. Tài liệu nói TTL monitor chạy mỗi 60 giây, và việc xoá có thể trễ hơn nữa khi hệ thống tải nặng [tài liệu]. Đừng dựa vào TTL để chặn đăng nhập bằng session hết hạn. Query vẫn phải tự lọc lastSeenAt > now - 30s.
  • Field không phải Date thì không bao giờ hết hạn. 10 session lưu ngày dạng string và 10 session thiếu field vẫn còn sau mọi lượt [tài liệu]. Đây là bẫy kiểu dữ liệu của bài 03, và nó không báo lỗi. Dùng $jsonSchema để bắt lastSeenAt phải là date.
  • metrics.ttl cuối lab: deletedDocuments: 10000, deletedKeys: 20000. Mỗi document xoá kéo theo xoá key trong mọi index (ở đây là _id_ và lastSeenAt_1). TTL xoá cũng là ghi, và tốn như ghi.

Vài quy tắc khác [tài liệu]:

  • TTL chỉ cho index một field. Compound index bỏ qua expireAfterSeconds.
  • Muốn mỗi document hết hạn ở một thời điểm riêng thì đặt expireAfterSeconds: 0 và lưu thời điểm hết hạn vào field (expireAt).
  • Field là mảng ngày thì dùng ngày sớm nhất.
  • Trên replica set chỉ primary chạy TTL. Secondary nhận các lệnh xoá qua replication (bài 25).
  • TTL monitor là một thread. Trong mỗi vòng con (sub-pass), với mỗi TTL index, nó xoá tới khi đủ 50.000 document, hoặc hết 1 giây, hoặc hết document hết hạn, rồi chuyển sang index kế tiếp; các vòng con lặp lại tới khi xoá hết. Từ 6.1 việc xoá có thể được gom batch (BATCHED_DELETE). Một collection có hàng triệu document hết hạn cùng lúc sẽ mất nhiều vòng để dọn xong.
  • Đổi expireAfterSeconds bằng collMod, không bằng createIndex. Hạ giá trị này trên collection lớn có thể làm hàng triệu document hết hạn cùng lúc và tạo một đợt xoá lớn.

Wildcard index: khi không biết trước tên field

30 giây

Một cửa hàng bán điện thoại, áo, sách và laptop. Mỗi loại có attributes khác nhau: điện thoại có ramGB, áo có size, sách có author. Không thể tạo index cho từng thuộc tính khi người bán có thể tự thêm thuộc tính mới. Wildcard index { "attributes.$**": 1 } tạo key cho mọi đường dẫn bên trong attributes.

Quan sát thật

Collection products với 200.000 sản phẩm, 4 loại, mỗi loại 3 thuộc tính riêng:

db.products.createIndex({ "attributes.$**": 1 });
show("color=red", db.products.find({ "attributes.color": "red" }));
color=red | FETCH <- IXSCAN attributes.$**_1 | keys 12570 | docs 12570 | nReturned 12570
keyPattern : { '$_path': 1, 'attributes.color': 1 }
indexBounds: { '$_path': [ '["attributes.color", "attributes.color"]' ],
               'attributes.color': [ '["red", "red"]' ] }

Explain hé lộ cách wildcard index được tổ chức [quan sát]: key thật là cặp (đường dẫn, giá trị). Index giống như một compound index { $_path, value }. Query trên attributes.color nhảy vào đoạn $_path = "attributes.color" rồi tìm giá trị trong đó.

Những gì nó làm được và không làm được:

"attributes.color" = "red"                      ✓ 12.570 key
"attributes.ramGB" ≥ 32                         ✓ 25.063 key (range được)
color = red VÀ size = M                         ⚠ chỉ dùng index cho MỘT field:
                                                   IXSCAN trên size (12.393 key),
                                                   color lọc sau FETCH, trả 1.540
"attributes.author" $exists: false              ✗ COLLSCAN 200.000 document
find({author:"A42"}, {_id:0, "attributes.author":1})  ✓ covered: keys 16, docs 0
sort theo "attributes.pages" khi query trên pages     ✓ không có stage SORT

Tất cả khớp với trang giới hạn của wildcard [tài liệu]: một wildcard index chỉ hỗ trợ một field trong predicate, không hỗ trợ $exists: false, chỉ sort được trên chính field đang query (và field đó không bao giờ là mảng). Wildcard cũng không thể là unique, TTL, hashed hay shard key.

Chi phí so với index nhắm đúng chỗ:

attributes.$**_1    3,8 MB    (mọi thuộc tính của mọi sản phẩm)
attributes.color_1  0,9 MB    (chỉ color)

Khi có cả hai, planner chọn attributes.color_1 cho query trên color (wildcard nằm trong rejectedPlans). Tài liệu nói thẳng: wildcard index không hiệu quả bằng index nhắm vào field cụ thể. Chỉ dùng khi tên field thật sự không biết trước hoặc hay thay đổi, và nếu tên field tuỳ ý cản trở việc tạo index thì hãy cân nhắc đổi schema [tài liệu]. Một cách đổi phổ biến là attribute pattern attrs: [{ k: "color", v: "red" }] cộng index { "attrs.k": 1, "attrs.v": 1 }.

Từ MongoDB 7.0 có compound wildcard index: một wildcard term cộng các field thường [tài liệu]. Với SaaS, { tenantId: 1, "attributes.$**": 1 } cho bounds trên cả ba phần (tenantId, $_path, giá trị), và query "sản phẩm màu đỏ của t0042" chỉ đọc 31 key. Index này 6,2 MB.

Covered query: khi index trả lời luôn

30 giây

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 1359

Dấ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, dữ liệu nóng trong cache:

Phép đoLượt 1Lượt 2
aggregate cộng total của t0042/completed, index có total (covered, docs 0)0,60 ms0,49 ms
Cùng pipeline, hint index { tenantId, status } (FETCH 1.359 doc)0,89 ms0,77 ms
find trả 1.359 kết quả, covered1,51 ms1,02 ms
find trả 1.359 kết quả, có _id (FETCH)2,55 ms1,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 21). 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 _id có 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 từ multikey index. Lab: index { tenantId, tags, total }, query trên tags, trả total thì covered (240 key, 0 doc), trả tags thì phải FETCH 240 document. Index chỉ chứa từng phần tử, không chứa nguyên mảng.
  • 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 rất nóng, trả ít field.

Index intersection: ghép hai index đơn, và vì sao hiếm thấy

30 giây

Có { tenantId: 1 } và { status: 1 }, query hỏi cả hai. Về lý thuyết, server có thể quét cả hai index, lấy giao hai tập RecordId, rồi chỉ FETCH phần giao. Đó là index intersection. Explain thể hiện nó bằng stage AND_SORTED (giao hai luồng đã xếp theo RecordId) hoặc AND_HASH (dựng bảng băm từ một luồng rồi dò luồng kia).

IXSCAN tenantId = t0042  → 1.948 RecordId ┐
                                          ├─ giao → 182 → FETCH 182
IXSCAN status = pending  → 99.639 RecordId┘

Nghe hợp lý. Nhưng để có giao, server vẫn phải đọc cả hai luồng, ở đây là hơn 100.000 key, trong khi một index đơn rồi FETCH chỉ cần 1.948 key và 1.948 document. Còn compound index thì chỉ cần 182 key.

Quan sát trên 8.3.11

db.orders.createIndex({ tenantId: 1 });
db.orders.createIndex({ status: 1 });
db.orders.createIndex({ userId: 1 });
db.orders.createIndex({ total: 1 });
Q1 tenant=t0042 & status=pending
  winning : FETCH <- IXSCAN tenantId_1   | keys 1948 docs 1948 n 182
  rejected: FETCH <- IXSCAN status_1
Q2 tenant=t0042 & userId=u0007
  winning : FETCH <- IXSCAN tenantId_1   | keys 1948 docs 1948 n 9
  rejected: FETCH <- IXSCAN userId_1
Q3 tenant=t0042 & total>=2e7
  winning : FETCH <- IXSCAN total_1      | keys 1857 docs 1857 n 5
  rejected: FETCH <- IXSCAN tenantId_1

Không có plan intersection nào, kể cả trong rejectedPlans. Tham số internalQueryPlannerEnableHashIntersection của server đọc ra false. Chỉ khi bật nó lên (một tham số nội bộ, chỉ làm trong lab), AND_HASH mới xuất hiện, và vẫn bị loại:

--- sau khi setParameter internalQueryPlannerEnableHashIntersection: true
Q1  winning : FETCH <- IXSCAN tenantId_1
    rejected: FETCH <- IXSCAN status_1
    rejected: FETCH <- AND_HASH <- IXSCAN status_1 ...

AND_SORTED không xuất hiện trong mọi query thử [quan sát]. Trang tài liệu riêng về index intersection không còn trong bộ tài liệu hiện tại. Bản 6.0 của trang đó viết rằng optimizer "hiếm khi" chọn plan intersection, hash-based intersection bị tắt mặc định, sort-based intersection bị đánh giá thấp khi chọn plan, và thiết kế schema không nên dựa vào index intersection, hãy dùng compound index [tài liệu, bản 6.0].

Kết luận thực tế: hai index đơn không thay được một compound index. Q1 với compound { tenantId, status, ... } đọc 182 key (câu B ở phần prefix). Với hai index đơn, nó đọc 1.948 key, 1.948 document. Bài 10 sẽ cho thấy hai index đơn còn gây ra một vấn đề tệ hơn: planner phải chọn giữa chúng, và lựa chọn tốt nhất thay đổi theo giá trị tham số.

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 nóng?     → thêm vào cuối để cover, projection bỏ _id
6. Chỉ hỏi một phần dữ liệu?   → partialFilterExpression
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 07): 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

Phần lớn ý tưởng của bài có bản tương ứng bên PostgreSQL. Khác biệt nằm ở mặc định và ở chỗ planner dám làm gì.

Chủ đềMongoDBPostgreSQL
Index nhiều cộtcompound index; quy tắc prefix; ESRB-tree nhiều cột; hiệu quả nhất khi có điều kiện trên cột đầu. Tài liệu bản 18 mô tả skip scan khi cột đầu có ít giá trị khác nhau
Ghép nhiều indexindex intersection hầu như không được chọn; hash intersection tắt mặc địnhbitmap index scan (BitmapAnd/BitmapOr) là cơ chế bình thường và hay dùng
Index một phầnpartialFilterExpression (tập operator giới hạn)CREATE INDEX ... WHERE <biểu thức>
Unique và giá trị trốngfield vắng/null chiếm một key, chỉ một documentNULL mặc định không trùng nhau, nhiều hàng NULL được phép (từ bản 15 có NULLS NOT DISTINCT để đổi lại)
Coveredmọi field phải nằm trong key của indexindex-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
Mảng, thuộc tính tuỳ ýmultikey, wildcardGIN trên mảng hoặc jsonb
Hết hạn tự độngTTL indexkhông có sẵn; dùng job định kỳ hoặc partition theo thời gian rồi DROP partition

Hai dòng đáng nhớ nhất. Unique với null ngược nhau hoàn toàn: người chuyển từ PostgreSQL sang thường mặc định "nhiều user không có email là bình thường", và gặp lỗi dup key: { email: null } ở người dùng thứ hai. Ghép index cũng ngược nhau: trên PostgreSQL, vài index đơn cộng với bitmap scan là chiến lược hợp lệ; trên MongoDB thì không, và compound index là đường chính.

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 (index intersection). Một compound index đúng thứ tự thường thay được cả nhóm.
  • 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.
  • 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).
  • Compound index có hai field có thể là mảng. Lỗi cannot index parallel arrays xuất hiện ở lệnh insert, không phải lúc tạo index.
  • Unique trên field có thể vắng mặt. Người thứ hai không có email bị từ chối. Dùng unique + partial.
  • hint() một partial hoặc sparse index. Không lỗi, chỉ trả về thiếu.
  • Lưu ngày hết hạn dạng string, hoặc tin TTL xoá đúng giây. String không bao giờ hết hạn. TTL chạy khoảng mỗi 60 s.
  • Quên _id: 0 khi nhắm tới covered query. Một field _id là đủ để FETCH mọi document.

Tóm tắt

  • Compound index là danh sách sắp theo nhiều field, field trước quyết định trước. Nó phục vụ query trên prefix của nó. Query thiếu field đầu thì không dùng được (COLLSCAN trong lab).
  • ESR: Equality, Sort, Range. Trong lab: 45 key so với 48.751 key, khoảng 0,4–1,2 ms so với 13–25 ms. Ngoại lệ: range rất kén chọn (5 kết quả) thì ERS thắng, 5 key so với 99.637 key.
  • Multi-tenant: { tenantId, createdAt } cho chi phí 20 key bất kể tenant. { createdAt } tốn từ 8.269 tới hơn 1 triệu key, chậm hơn cả COLLSCAN với tenant nhỏ.
  • Multikey: một key mỗi phần tử (1,67 triệu key cho 1 triệu đơn), tối đa một field mảng mỗi compound index, $elemMatch để giao được bounds.
  • Partial nhỏ hơn 7,6 lần trong lab, dùng được khi query bao hàm filter. Sparse là bản cũ hẹp hơn, nên ưu tiên partial.
  • Unique: field vắng mặt là null và chỉ được một lần. Dùng unique + partial, và thường unique theo { tenantId, ... }.
  • TTL: chạy khoảng mỗi 60 s (lab: 07:27:20, 07:28:20, ...), document hết hạn có thể còn sống thêm tới một phút, field không phải Date không bao giờ hết hạn.
  • Covered query: totalDocsExamined: 0, PROJECTION_COVERED. Nhanh hơn khoảng 1,5 lần khi dữ liệu nóng, lợi hơn nhiều khi dữ liệu không vừa cache.
  • Index intersection hầu như không được chọn trên 8.3.11 (AND_HASH tắt mặc định). Dùng compound index.

Tự kiểm tra

  1. Có index { tenantId: 1, status: 1, createdAt: -1 }. Query find({ status: "pending" }).sort({ createdAt: -1 }) có dùng được index không? Vì sao? (Không. status không phải prefix, field đầu là tenantId. Trong lab, query chỉ trên status chạy COLLSCAN.)
  2. Query find({ tenantId, total: { $gte: X } }).sort({ createdAt: -1 }).limit(20). Bạn chọn { tenantId, createdAt, total } hay { tenantId, total, createdAt }, và khi nào thì đổi ý? (ESR là mặc định vì tránh sort và dừng sớm. Nếu X lớn tới mức chỉ vài đơn khớp, ERS đọc ít key hơn nhiều. Đo bằng hint() trên dữ liệu thật.)
  3. Collection users có unique index trên email. Người dùng thứ hai đăng ký bằng số điện thoại bị lỗi dup key: { email: null }. Sửa thế nào? (Thay bằng unique index có partialFilterExpression: { email: { $type: "string" } }, thường là trên { tenantId, email }.)
  4. Explain cho thấy IXSCAN → FETCH, totalDocsExamined bằng nReturned, và projection là { total: 1 }. Index có đủ field total. Vì sao query không được cover? (Projection mặc định trả _id, mà _id không có trong index. Thêm _id: 0.)

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à có những cuốn sổ đặc biệt: sổ chỉ ghi khách nợ tiền, sổ không cho ghi trùng tên, sổ tự gạch tên người hết hạn mỗi phút một lần.

Bài tiếp theo

Bài này dùng hint() khắp nơi để so sánh công bằng. Nhưng trong ứng dụng thật, không ai hint(). Server tự chọn. Ta đã thấy vài lần nó chọn khác nhau: ESR_s cho ngưỡng 5 triệu, ERS_s cho ngưỡng 25 triệu, total_1 thay vì tenantId_1 cho Q3, và luôn có một danh sách rejectedPlans.

Bài 10, Query Planner & explain(), mở chiếc hộp đó ra: planner sinh ra các candidate plan thế nào, chạy thử chúng trong trial period ra sao, chấm điểm bằng gì, và nhớ lựa chọn trong plan cache bao lâu. Câu hỏi treo lơ lửng từ phần ESR sẽ được trả lời bằng số đo: khi cùng một query shape lúc thì khớp 48.751 đơn, lúc thì khớp 5 đơn, plan nào được cache, và chuyện gì xảy ra với giá trị còn lại.

Tài liệu tham khảo