Index trong MongoDB — B-tree của WiredTiger, compound và ESR, multikey, TTL và cái giá của mỗi index
"Thêm index cho field hay lọc" là câu trả lời trượt phỏng vấn senior. Nó không giải thích được vì sao cùng 3 field mà đổi thứ tự thì một truy vấn đọc 193 key, truy vấn kia đọc
12 927 key rồi phải sort trong RAM; vì sao index unique chặn khách hàng thứ hai không có email; vì sao TTL index để document hết hạn nằm thêm gần một phút; hay vì sao
thêm index thứ 10 làm insert chậm 4 lần. Bài này chốt một mô hình — index là một danh sách key đã sắp xếp, nằm trong một B-tree riêng — rồi suy ra mọi quy tắc từ đó.
Mọi con số là output thật trên MongoDB 9.0.2 với bộ dữ liệu chung của series (shop.orders 2 000 000 đơn), so với 8.0.32 và PostgreSQL 18.4 ở chỗ hành vi khác.
Thuật ngữ dùng trong bài
B-tree (cây cân bằng; WiredTiger dùng dạng B+tree: key nằm ở trang lá, trang tối đa 16 KB) · KeyString (cách MongoDB mã hoá key thành chuỗi byte để so sánh bằngmemcmp) · RecordId (địa chỉ nội bộ của document trong bảng collection) · COLLSCAN / IXSCAN / FETCH (quét cả collection / quét một đoạn index / đọc document theo RecordId) · compound index (index nhiều field) · ESR (Equality → Sort → Range: thứ tự field nên dùng) · multikey (index trên field mảng — mỗi phần tử một key) · covered query (trả lời chỉ từ index, không đọc document) · partial / sparse index (chỉ index một phần document) · TTL index (index giúp tự xoá document hết hạn) · hybrid build (build index mà vẫn cho đọc/ghi) · prefix compression (key trùng phần đầu với key trước chỉ lưu phần khác) · collation (luật so sánh chuỗi theo ngôn ngữ: hoa/thường, dấu).
Kiểm chứng: Context7 ngày 08/10/2026 — README trong source server (storage/key_string,index_builds, multikey trongshard_catalog) và manual (ESR, TTL, index build, partial, covered, bounds multikey); release notes + compatibility 9.0; sourcelaravel-mongodb5.11.0 (Schema/Blueprint.php); docs Mongoose. Môi trường lab ở phụ lục; outputlabs/mongodb/out/03-*.txt. Máy 4 vCPU dùng chung — số ms chỉ để so tỉ lệ.
Trả lời 30 giây
Mỗi index của MongoDB là một bảng B-tree riêng của WiredTiger (1 fileindex-*.wt): danh sách key đã sắp xếp, key mã hoá KeyString để so sánh bằngmemcmp, kèm RecordId trỏ về document. Mọi quy tắc suy ra từ chữ "đã sắp xếp": equality nhảy tới một đoạn liền, sort đọc theo thứ tự có sẵn, range là một đoạn liền nhưng phá thứ tự của field sau nó — nên ESR (lab: 193 key so với 12 927 key + SORT). ESR là mặc định chứ không phải luật: range rất hẹp thì ERS thắng (435 vs 5 312 key). Field mảng → multikey: 1 key cho mỗi phần tử khác nhau (2 triệu đơn → 4,38 triệu key), không index được 2 mảng song song, field mảng không covered được, cờ multikey không tự tắt. Unique coi thiếu field lànull→ chỉ một document được thiếu; dùng partial{email: {$gt: ""}}. TTL không đúng giờ: monitor ngủ 60 s rồi xoá theo lô. Mỗi index là thêm một lần ghi: 10 index làm insert chậm 4,28×. Build trên collection đang chạy là hybrid build: chỉ khoá X rất ngắn ở hai đầu; chết đột ngột giữa chừng thì build lại từ đầu, tắt sạch thì chạy tiếp.
1. Bài toán gốc và bối cảnh ra đời
Không có index, mọi truy vấn là COLLSCAN: đọc từng document rồi lọc. Lab: find({status: "pending"}) để lấy 2% số đơn vẫn đọc đủ 2 000 000 document
(1,3–1,9 s). Index giải bài toán cũ của mọi database: tìm nhanh một phần nhỏ mà không đọc hết, và trả kết quả theo thứ tự mà không phải sort.
Lịch sử ngắn, vì nó giải thích nhiều hành vi hôm nay:
- MongoDB có B-tree index từ bản đầu (2009). Thời engine MMAPv1, index nằm chung file dữ liệu của database.
- WiredTiger (3.0, mặc định từ 3.2): mỗi index là một bảng WiredTiger riêng = một file. Thêm index = thêm file, thêm cache, thêm một lần ghi.
- 3.2 partial index · 3.4 collation · 4.2 hybrid build (bỏ cặp foreground/background), bỏ giới hạn key 1 024 byte, wildcard index · 4.4 build đồng thời trên mọi
node của replica set +
commitQuorum, hidden index · 5.3 clustered collection · 7.0 compound wildcard · 8.3dropIndexeskhông báo lỗi khi index không tồn tại · 9.0 query có thể bịQueryKilledErrorkhi field thành multikey giữa chừng (nguồn: release notes, dropIndexes, 9.0 compatibility).
Tối ưu cho: tìm theo giá trị và theo khoảng trên phần đầu của key; trả kết quả đã sắp xếp; đếm/đọc chỉ từ key (covered). Không tối ưu cho: lọc trên field không đứng đầu key; điều kiện ít chọn lọc (status 5 giá trị); tìm "chứa chuỗi" hay full-text (dùng Atlas Search/Elasticsearch); collection ghi rất nhiều mà gánh nhiều index.
2. Mô hình tư duy cốt lõi
Một index là một danh sách (key → RecordId) đã sắp xếp, lưu trong một B-tree riêng. Đọc qua index = tìm vị trí trong danh sách (log n) rồi đi tiếp
theo thứ tự. Ghi một document = chèn/xoá key ở mọi danh sách liên quan. Truy vấn chỉ rẻ khi điều kiện của nó biến được thành một (hoặc vài) đoạn liền của danh sách.
flowchart LR Q["find status paid
sort total -1"] --> IX["IXSCAN index-….wt
key = KeyString(status, total) + RecordId"] IX -->|"RecordId"| F["FETCH collection-….wt
RecordId → BSON document"] IX -.->|"covered: đủ field trong key"| R["kết quả"] F --> R
| Hành vi | Suy ra từ "danh sách đã sắp xếp" | Lab |
|---|---|---|
| Equality trên field đầu | Nhảy (seek) tới đầu đoạn key bằng nhau | 1 key cho email |
| Sort không tốn RAM | Đọc đoạn theo thứ tự lưu, xuôi hoặc ngược | mục 3.3 |
| ESR | E thu về 1 đoạn; S giữ thứ tự trong đoạn; R lọc trong lúc đi | 193 vs 12 927 key |
| Multikey | Một document → nhiều key ở nhiều chỗ trong danh sách | 4 378 390 key / 2 triệu đơn |
| Covered query | Key đã chứa đủ field cần trả, khỏi FETCH | docsExamined 0 |
| TTL | Xoá = quét đoạn key < now − expireAfterSeconds | 500 000 doc / 7 s |
| Giá ghi | Mỗi index là một B-tree nữa phải chèn | 10 index: insert chậm 4,28× |
paid, key trong đoạn đã xếp theo total giảm dần nên chỉ việc đi xuống, bỏ key ngoài
khoảng ngày, đủ 20 là dừng — không SORT. Phải (E·R·S): khoảng ngày là một đoạn liền nhưng trong đoạn đó total không có thứ tự, nên phải đọc hết rồi sort trong RAM.
Số liệu: lab 03-esr.txt, khoảng 30 ngày. Dữ liệu từng dòng là minh hoạ.3. Internals và quyết định thiết kế
3.1 Một index = một bảng WiredTiger = một file
$collStats của index status_1 cho thấy nó là một bảng WiredTiger bình thường: key_format=u, value_format=u (byte thô), trang lá tối đa 16 KB,
prefix_compression=true, block_compressor= rỗng — index không nén snappy như collection, chỉ có prefix compression. Sau khi seed, thư mục dữ liệu có
24 file collection-*.wt và 28 file index-*.wt. Theo README KeyString, mỗi entry là:
- Index
_id: key = KeyString(_id), value = RecordId. - Index thường (và unique tạo từ 6.0): key = KeyString(các field) + RecordId, value = TypeBits (nếu cần). Nối RecordId làm mọi key duy nhất, kể cả khi nhiều document cùng giá trị; unique được kiểm ở tầng MongoDB.
| Index trên 2 000 000 đơn | Giá trị khác nhau | File (prefix compression bật) | Tắt prefix compression | Byte/entry |
|---|---|---|---|---|
_id_ — dựng dần qua 2 triệu insert | 2 000 000 | 66,9 MB (4 275 trang lá) | — | 35,1 |
{_id, _none} — cùng key, dựng 1 lần bằng createIndex | 2 000 000 | 36,7 MB (2 344 trang lá) | — | 19,2 |
createdAt_1 | 2 000 000 | 22,6 MB | 31,7 MB | 11,9 → 16,6 |
customerId_1 | 199 480 | 12,9 MB | 20,0 MB | 6,7 → 10,5 |
status_1 | 5 | 9,4 MB | 34,1 MB | 4,9 → 17,9 |
Ba điều đọc ra từ bảng:
- Prefix compression ăn theo độ trùng:
status_1chỉ 5 giá trị nên key liền nhau trùng gần hết phần đầu, mỗi entry chỉ còn ~5 byte (thực chất là RecordId). - Index dựng dần to gấp 1,8× bản dựng một lần (dù mỗi entry của bản sau còn dài hơn): ghi dần thì trang bị tách nhiều lần và để lại chỗ trống; createIndex nạp key đã sort nên trang đầy. Hệ quả: rebuild hoặc initial sync làm index nhỏ lại.
- RAM ≠ file. Restart cho cache rỗng rồi quét trọn từng index:
status_19,4 MB trên đĩa chiếm 25,7 MB trong cache;customerId_112,9 → 29,1 MB;createdAt_122,6 → 38,7 MB;_id_66,9 → 71,2 MB. Trang trong cache là bản làm việc, không phải bản nén trên đĩa (bài 6). Tính RAM cho index theo con số trong cache, không theo kích thước file.
3.2 KeyString: vì sao so sánh key chỉ là memcmp
Index phải sắp theo thứ tự so sánh BSON — số so theo giá trị toán học dù là int, long, double hay decimal. So sánh kiểu đó chậm (đổi kiểu, nhiều trường hợp biên), mà tìm một key
trong cây hàng triệu entry cần hàng chục lần so sánh. MongoDB đổi mỗi key sang KeyString sao cho x < y ⇔ memcmp(t(x), t(y)) < 0. Ba hệ quả:
- Field
-1trong compound index: mọi byte của phần đó bị đảo bit, nên vẫn chỉ cầnmemcmp. Với index 1 field thì hướng không quan trọng (quét ngược được). 42,NumberLong(42),42.0,Decimal128("42.00")ra cùng một KeyString; kiểu gốc giữ riêng trong TypeBits. Vì vậy unique index coi chúng là trùng (bài 1).- Giới hạn key 1 024 byte đã bỏ từ 4.2 — lab insert key 5 000 byte vẫn được. Nhưng key to = index to = cache to.
3.3 Compound index và ESR — đo trên 2 triệu đơn
Màn hình admin "đơn paid giá trị cao nhất trong khoảng thời gian": find({status: "paid", createdAt: {$gte, $lt}}).sort({total: -1}).limit(20). Năm index, ép từng
cái bằng hint, lấy lần tốt nhất trong 3 lần (03-esr.txt):
| Index | 365 ngày (119 742 đơn khớp) | 30 ngày (12 927) | 1 ngày (435) |
|---|---|---|---|
E-S-R {status, total:-1, createdAt} | 31 key, 0 ms | 193 key, 0 ms | 5 312 key, 11 ms |
E-R-S {status, createdAt, total:-1} | 119 742 key + SORT, 105 ms | 12 927 + SORT, 12 ms | 435 + SORT, 0 ms |
S-E-R {total:-1, status, createdAt} | 441 key, 1 ms | 2 528 key, 2 ms | 137 642 key, 76 ms |
R-E-S {createdAt, status, total:-1} | 1 500 000 + SORT, 2 546 ms | 161 006 + SORT, 252 ms | 5 476 + SORT, 9 ms |
Chỉ {status} | 159 591 key + 159 591 doc + SORT, 908 ms | … 791 ms | … 802 ms |
| Planner 9.0 tự chọn (không hint) | E-S-R | E-S-R | E-R-S |
- ESR là hệ quả, không phải luật học thuộc. E đứng đầu thu truy vấn về một đoạn. S đứng ngay sau thì trong đoạn đó key đã xếp đúng thứ tự cần trả. R đứng cuối chỉ để lọc trong lúc đi (đọc từ key, chưa cần FETCH). Đặt R trước S thì R vẫn là một đoạn liền, nhưng thứ tự của S trong đoạn bị phá → SORT.
- Ngoại lệ — range rất hẹp: 1 ngày chỉ có 435 đơn. E-S-R đi theo
totalvà gặp toàn đơn ngoài ngày đó, phải qua 5 312 key mới đủ 20. E-R-S đọc đúng 435 key rồi sort 435 dòng (2 KB) — rẻ hơn. Planner 9.0 tự đổi sang E-R-S. Docs ESR ghi đúng điều này: range chọn lọc mạnh thì đặt trước sort. - Bỏ
limit: E-R-S sort 1 565 KB trong RAM (131 ms); E-S-R vẫn không sort (77 ms). Sort 2 triệu đơn theo field không có index: SORT 963 658 KB, vượt 100 MB/stage nên 9.0 mặc định tràn ra đĩa — 21,3 s. Tắt tràn đĩa thì lỗi nguyên văn:
allowDiskUse(false) → QueryExceededMemoryLimitNoDiskUseAllowed (292): Executor error during find command: shop.orders :: caused by ::
Sort exceeded memory limit of 104857600 bytes, but did not opt in to external sorting.
$incó ngưỡng 201. Index{customerId: 1, createdAt: -1}, sortcreatedAt:$in200 khách → trộn 200 đoạn đã xếp (SORT_MERGE), 219 key, 3 ms; 201 khách → coi như range, 12 828 key + SORT, 12 ms. Docs ghi ngưỡng này "không đảm bảo giữ nguyên giữa các bản".- Hướng sort: index
{status: 1, createdAt: -1}phục vụ sort{status: 1, createdAt: -1}(quét xuôi) và{status: -1, createdAt: 1}(quét ngược), nhưng{status: 1, createdAt: 1}phải SORT. Chỉ sortcreatedAtvớistatus $in2 giá trị →SORT_MERGE2 đoạn. - Prefix: compound
{status, total, createdAt}đã phục vụ truy vấn chỉ lọcstatus→ index{status}riêng là thừa (tốn ghi + RAM).
3.4 Multikey: một document, nhiều key
Index thành multikey ngay khi có một document mang mảng ở field được index; explain ghi isMultiKey: true, multiKeyPaths: {"items.sku": ["items"]}. Bốn hệ quả:
- Không index được hai mảng song song trong cùng compound index (số key sẽ là tích hai mảng). Index tạo được khi chưa document nào vi phạm — và nổ khi app ghi document
đầu tiên có cả hai mảng (vd tính năng mới lưu
tagsdạng mảng). Bẫy nổ trễ:
Hai field trong cùng một mảng thì được:insert {"_id":3,"items":[{"sku":"D"}],"tags":["sale","tet"]} → lỗi 171: cannot index parallel arrays [items] [tags] createIndex khi đã có document 2 mảng → CannotIndexParallelArrays (171){"items.sku": 1, "items.qty": 1}. - Ghép bound giữa hai field của cùng mảng cần
$elemMatch. Đơn cóSKU-000001với số lượng ≥ 5:
Không có$elemMatch {sku, qty ≥ 5} bounds sku ["SKU-000001","SKU-000001"] | qty [5, inf] → 1 956 key, 1 954 doc {"items.sku": …, "items.qty": ≥ 5} bounds sku ["SKU-000001","SKU-000001"] | qty [MinKey, MaxKey] → 93 190 key, 92 374 doc, trả 5 313$elemMatch, hai điều kiện có thể thoả bởi hai phần tử khác nhau (khớp chéo — bài 1), nên MongoDB không được thu hẹpqty: đọc gấp 48 lần và ra kết quả khác. - Cờ multikey không tự tắt. Xoá document mảng duy nhất, explain vẫn
isMultiKey: true; drop + tạo lại index mới hết. Source giải thích: cờ lưu trong catalog, chỉ ghi khi đổi sangtrueđể tránh WriteConflict giữa các writer — "Once true, an index is always multikey". - Field đổi từ giá trị đơn sang mảng giữa lúc cursor đang đọc → kết quả trùng. Cursor mở khi index chưa multikey (plan không có bước khử trùng); insert một document
paymentMethod: ["momo", "voucher"]phía trước con trỏ; đọc tiếp:
Compatibility notes 9.0 nói query "might fail with9.0.2 covered [classic]: khớp 50001 document, cursor trả 50002 ← document mảng bị trả 2 lần customerId 40000 → [{…"paymentMethod":"card"},{…"paymentMethod":"momo"},{…"paymentMethod":"voucher"}] 9.0.2 có FETCH [SBE] : khớp 50001 document, cursor trả 50002 ← document mảng bị trả 2 lần 8.0.32 covered [classic]: khớp 50001 document, cursor trả 50002 ← document mảng bị trả 2 lầnQueryKilledError"; lab (3 kiểu truy vấn × classic/SBE) không ra lỗi đó mà ra bản trùng im lặng ở cả 9.0.2 và 8.0.32. Bản covered còn trả document mới thành 2 dòng"momo","voucher"dạng chuỗi — document thật chứa mảng. Chữa gốc: đừng để field đổi kiểu đơn ↔ mảng (khoá bằng$jsonSchema— bài 2); cursor dài/export thì khử trùng theo_id.
3.5 Covered query: khi nào được, cái gì làm vỡ
lab.cov (200 000 đơn), index {customerId, paymentMethod, createdAt: -1}, khách 4242 có 4 đơn (03-multikey.txt phần D):
| Truy vấn | keys / docs | Plan |
|---|---|---|
Chỉ lấy field trong index, _id: 0 | 4 / 0 | PROJECTION_COVERED←IXSCAN |
Quên _id: 0 | 4 / 4 | PROJECTION_SIMPLE←FETCH←IXSCAN |
Lấy thêm total / lọc thêm status (ngoài index) | 4 / 4 | FETCH |
Lọc {paymentMethod: null} | — / FETCH | Index không phân biệt null với thiếu field → phải đọc document |
Sau khi 1 đơn có paymentMethod là mảng | 6 / 5 | FETCH — field mảng không cover được |
Cùng lúc đó, chỉ lấy createdAt | 6 / 0 | Vẫn covered: multiKeyPaths biết createdAt không phải mảng |
| Xoá đơn mảng đó / drop + tạo lại index | 4 / 4 → 4 / 0 | Chỉ tạo lại index mới covered lại |
Trên sharded collection, index phải chứa shard key mới cover được qua mongos (manual; bài 9).
3.6 Unique, sparse, partial — và field bị thiếu
Index không lưu "thiếu field": document không có email được index với key null. Unique vì thế coi mọi document thiếu email là trùng nhau (03-unique.txt):
Index trên email | 2 khách thiếu email | 2 khách email: null | find({email: "x"}) dùng index? |
|---|---|---|---|
unique | cái thứ 2: E11000 { email: null } | E11000 | có |
unique + sparse | OK | cái thứ 2: E11000 | có |
partial {email: {$exists: true}} | OK | cái thứ 2: E11000 | có |
partial {email: {$type: "string"}} | OK | OK | không — COLLSCAN |
partial {email: {$gt: ""}} | OK | OK | có (EXPRESS_IXSCAN) |
Bẫy nổ trễ: công thức hay được khuyên "unique khi có email" là partial$type: "string". Nó chặn trùng đúng, nhưng planner không suy raemail = "x"⇒$type: "string", nên truy vấn đăng nhập bình thường thành COLLSCAN (200 000 khách = quét hết mỗi lần login). Chỉ query viết thêm$type: "string"mới dùng được index. Dùng{$gt: ""}: chặn trùng chuỗi, cho nhiều null/thiếu, và equality/$indùng được index (regex thì không). Thói quen SQL "cột nullable + UNIQUE" mang sang MongoDB thì nổ khi khách thứ hai không có email — Laravel lab ở mục 6.
Planner chỉ dùng partial index khi chứng minh được truy vấn nằm trong filter. Index {createdAt: 1} chỉ cho đơn pending (2% → 0,5 MB thay vì 22,6 MB):
{status: "pending", createdAt: {$lt}} và {status: {$in: ["pending"]}, …} dùng được; thiếu status, hay $in 2 giá trị → COLLSCAN; và
{status: "pending"} không có điều kiện trên createdAt cũng COLLSCAN — field key phải xuất hiện trong filter hoặc sort (thêm .sort({createdAt: 1})
thì dùng). Ghi chú về chi phí: 36 610 đơn pending rải trên 2 năm, FETCH ngẫu nhiên với cache 0,25 GB mất 1,5–4,6 s ở lần chạy đầu (3 lần chạy lab), 0,56 s ở lần sau — ngang hoặc chậm
hơn COLLSCAN cả 2 triệu đơn (1,3–2,1 s). Index chọn lọc chưa đủ; FETCH ngẫu nhiên mới là tiền thật.
So với PostgreSQL 18 (03-pg.txt): unique cho nhiều NULL mặc định, muốn như MongoDB phải NULLS NOT DISTINCT; partial index
WHERE email IS NOT NULL được dùng cho email = 'x' vì planner tự suy ra; WHERE status = 'pending' được dùng cả khi query chỉ lọc status.
Unique không phân biệt hoa/thường = unique với collation {locale: "en", strength: 2}: chặn được "An@lms.vn" vs "an@lms.vn", nhưng lỗi E11000 không còn in
email mà in dup key: { email: "CollationKey(0x29430a7a3f414d085343010d)" } (khó debug), và query không ghi collation thì COLLSCAN và trả 0 dòng — với
strength 2 thì EXPRESS_IXSCAN, 1 dòng; strength 1 lại COLLSCAN. Collation của query phải trùng hệt collation của index.
3.7 TTL: xoá theo đợt, không theo giờ
TTL index là index bình thường trên một field ngày, cộng một thread nền trên primary: TTL monitor ngủ ttlMonitorSleepSecs = 60 s, thức dậy quét đoạn key
< now − expireAfterSeconds của từng TTL index và xoá theo lô (mỗi index mỗi lượt tối đa ttlIndexDeleteTargetDocs 50 000 document hoặc
ttlIndexDeleteTargetTimeMS 1 000 ms, rồi sang index khác). Secondary không tự xoá, chỉ nhận lệnh xoá qua oplog.
03-ttl.txt.Lab 03-ttl.txt, hai tình huống, mỗi cái có một writer ghi nền vào collection khác để xem tác động:
| (1) 500 000 phiên đã hết hạn | (2) TTL 365 ngày cho lms.events (3 triệu event) | |
|---|---|---|
| createIndex TTL | 1,2 s | 11,8 s |
| Lần xoá đầu tiên sau khi tạo index | 3 s | 20 s |
| Xoá hết phần quá hạn | 500 000 doc trong 7 s | 780 659 doc trong 10 s |
metrics.ttl | passes +1, subPasses +1, deletedKeys = 2 × doc (_id + expireAt) | passes 1, subPasses 1, deletedKeys 1 561 320 |
| Oplog | 50 037 entry applyOps (≤ 10 lệnh xoá/entry) + 26 entry d | 78 088 entry applyOps + 15 entry d |
| Writer ở collection khác (p99, cửa sổ 10 s) | 9–32 ms trước/sau, 19,5 ms lúc xoá | 12,2–12,8 ms lúc xoá |
- Không đúng giờ. Lần xoá đầu sau 3 s ở lab (1) nhưng 20 s ở lab (2) — tuỳ lúc monitor đang ngủ tới đâu; docs ghi 0–60 s, cộng thời gian xoá. Logic nghiệp vụ cần đúng giờ
(giữ chỗ 10 phút, OTP) vẫn phải tự kiểm
expireAt > nowkhi đọc. - Một lượt có thể xoá rất nhiều. Với 1 TTL index, lab thấy cả 500 000 và 780 659 document đi hết trong một pass/sub-pass — ngưỡng 50 000/1 s chỉ để luân phiên giữa các index. Mỗi document xoá vẫn là một thao tác trong oplog (gom 10 cái/entry): đó là tải mà secondary phải áp lại. Thêm TTL cho collection đã có nhiều dữ liệu cũ = một cơn xoá lớn; docs khuyên xoá trước theo lô, ngoài giờ cao điểm.
- Sai kiểu là im lặng. 1 000 document
expireAtkiểu chuỗi ISO và 1 000 kiểu số (epoch ms) không bao giờ bị xoá, vàinvalidTTLIndexSkipsvẫn 0 — không metric nào báo. Kiểm định kỳ bằngcountDocuments({expireAt: {$not: {$type: "date"}}}). - TTL chỉ trên index 1 field, không trên
_id; đổiexpireAfterSecondsphải dùngcollMod(createIndex lại báo lỗi — lab Laravel ở mục 6).
3.8 Build index khi collection đang chạy: hybrid build
Từ 4.2, mọi build là hybrid: khoá X trên collection rất ngắn lúc bắt đầu, khoá IX (đọc/ghi vẫn chạy) trong lúc quét, khoá X ngắn lúc commit. Lệnh ghi đến trong lúc build
không vào thẳng index mới mà được chặn lại, ghi vào side-writes table (insert/xoá key theo thứ tự), rồi được "drain" (áp vào index) ở cuối. Lab 03-build.txt: build
{"items.sku": 1, createdAt: -1} (4 381 226 key) trên shop.orders trong lúc một writer insert vào chính collection đó:
collection scan done 11 357 ms, inserted keys from external sorter 2 879 ms,
drained side writes 847 key / 32 ms). Làn insert: lệnh ghi của app không dừng lúc nào.- Ghi không bị chặn lần nào, nhưng chậm: 4 s đầu build, p99 của writer lên 289 và 472 ms, số insert mỗi 2 s còn 58–76 (lúc thường đa số 125–235, p99 34–166 ms — máy dùng chung nên nhiễu); thêm một đợt 318–325 ms ngay sau commit.
- Bộ nhớ:
maxIndexBuildMemoryUsageMegabytesmặc định 200 (mỗi lệnh createIndexes), tối đa 3 build cùng lúc. Hết 200 MB thì sort tràn ra file tạm trong_tmp: 2 lần tràn, 56,6 MB. Đặt 50 MB: 6 lần tràn nhưng thời gian ngang nhau (11,7–13,9 s, nhiễu). Đặt 10 MB:Must be above minimum of 50 MB. - Chết giữa chừng:
docker kill(SIGKILL) lúc quét 50% → khởi động lại, build quét lại từ đầu (totalRecords: 2000000), xong sau 22,4 s.docker stop(SIGTERM) lúc 66% → logwrote resumable state to disk, khởi động lạiresuming index build from phase: collection scan, chỉ quét 626 050 document còn lại, xong sau 8,1 s. Docs: chỉ resume được một lần; mongod khởi động dạng standalone thì build treo, phải drop tay. - Unique trên dữ liệu đã trùng: lỗi chỉ lộ ở bước commit (kiểm ràng buộc cuối build), sau khi đã tốn cả lần quét. Build lỗi thì MongoDB tự dọn — không để lại index
hỏng. PostgreSQL ngược lại:
CREATE UNIQUE INDEX CONCURRENTLYlỗi xong để lại indexindisvalid = fvẫn phải cập nhật khi ghi, phải drop tay (03-pg.txt).
Replica set: từ 4.4 mọi node build cùng lúc; mỗi node quét xong thì vote, primary commit khi đủ commitQuorum — mặc định
"votingMembers", tức mọi node có quyền bầu và mang dữ liệu.
sequenceDiagram participant A as App participant P as Primary r1 participant S as Secondary r2 participant D as Secondary r3 (mất mạng) A->>P: createIndexes k_1 P->>S: oplog startIndexBuild P--xD: không tới S->>P: voteCommitIndexBuild Note over P: commitReadyMembers r1, r2 - quorum votingMembers chờ r3 A->>P: setIndexCommitQuorum majority (sau 64 s) P->>S: oplog commitIndexBuild P-->>A: createIndex trả về sau 64,6 s
Lab 03-quorum.txt: cắt mạng r3 rồi build trên 300 000 document. Sau 30 s và 62 s, config.system.indexBuilds vẫn commitQuorum "votingMembers",
commitReadyMembers ["tmp-mdb-r1:27017","tmp-mdb-r2:27017"] — lệnh treo, dù ghi w: majority trong lúc đó vẫn bình thường (27 ms, 9 ms). Đổi
setIndexCommitQuorum sang "majority" thì lệnh trả về sau 64,6 s. Node r3 khi về sẽ phải tự build xong; secondary nhận commitIndexBuild mà chưa build
xong thì dừng áp oplog tới khi xong (README index_builds) → replication lag.
3.9 Các loại index còn lại — mỗi loại một phép đo
| Loại | Lab (03-misc.txt) | Bẫy |
|---|---|---|
Hashed {email: "hashed"} | Equality 1 key; range $gt → COLLSCAN 200 000 doc; sort → SORT←COLLSCAN; 3,4 MB vs 5,0 MB của email_1 | Chỉ equality; không unique được: "Currently hashed indexes cannot guarantee uniqueness". Chủ yếu để làm shard key (bài 9) |
Wildcard {"specs.$**": 1} vs attribute pattern {"attrs.k", "attrs.v"} | Cùng 4 617 key cho "màu đỏ + RAM 8", cùng 3,0 MB; $exists: false → COLLSCAN; compound wildcard {category, "specs.$**"}: 137 key | Mỗi lần chỉ dùng 1 field của wildcard; không unique/TTL. Attribute pattern ở bài 2 |
Regex trên {email: 1} | /^minh\./ 4 997 key; /^minh\./i 200 000 key; /minh\./ 200 000 key | Chỉ regex có neo ^ và phân biệt hoa thường mới thu được bound; /i = quét cả index |
Collation vi trên name | strength 1: "nguyên văn an" khớp 201, "nguyen van an" khớp 0; en strength 1: 201; query không collation hoặc strength khác → COLLSCAN | Tiếng Việt: ă â đ ê ô ơ ư là chữ riêng, chỉ dấu thanh là "dấu" → muốn bỏ dấu kiểu "nguyen" phải dùng en hoặc lưu thêm field không dấu |
| Hidden index | Hidden: find → COLLSCAN, nhưng insert trùng vẫn E11000; unhide → EXPRESS_IXSCAN ngay | Vẫn tốn ghi + RAM như index thường |
| Clustered collection (1 triệu event) | Không có file _id: 45,1 MB + 0 so với 43,7 + 34,3 MB; CLUSTERED_IXSCAN; nạp 8,0 s vs 11,6 s | Index phụ {userId} 16,7 MB vs 6,8 MB — RecordId giờ là _id 12 byte, nối vào mọi key |
| 2dsphere, text | Chưa đo (ngoài phạm vi) | Text index là tính năng cũ; tìm kiếm thật dùng Atlas Search / Elasticsearch |
3.10 Giá ghi: mỗi index là một B-tree nữa
Insert 100 000 đơn (dữ liệu giống shop.orders, insertMany lô 1 000) vào collection có 1 / 3 / 6 / 10 index, mỗi cấu hình 3 lần, xen thứ tự (03-writecost.txt):
Số index (gồm _id) | 3 lần (ms) | Tốt nhất: doc/s | So với 1 index | Tổng index |
|---|---|---|---|---|
| 1 | 3 052 / 3 815 / 3 516 | 32 765 | 1,00× | 3,6 MB |
| 3 | 3 728 / 4 581 / 5 261 | 26 824 | 1,22× | 7,9 MB |
6 (có items.sku multikey) | 6 297 / 8 855 / 6 877 | 15 881 | 2,06× | 19,2 MB |
10 (thêm items.productId multikey) | 13 050 / 19 093 / 21 307 | 7 663 | 4,28× | 34,3 MB |
Chi phí không tuyến tính: index multikey chèn ~2,2 key mỗi đơn, và càng nhiều B-tree thì càng nhiều trang bẩn tranh chỗ trong cache, càng nhiều việc cho checkpoint. Số lần đo dao động mạnh (13–21 s) vì máy dùng chung — so tỉ lệ, đừng so số tuyệt đối.
4. Đảm bảo và giới hạn
| Mục | Giá trị / hành vi | Nguồn |
|---|---|---|
| Nhất quán index ↔ document | Ghi document và mọi index trong cùng một transaction của storage engine — không có "index trễ" như search engine | lab: insert bị E11000 thì document cũng không được ghi; bài 6 |
| Số index / collection | 64 — thứ 65: CannotCreateIndex (67): add index fails, too many indexes | lab |
| Số field / compound | 32 — 33 field: too many compound keys (13103) | lab |
| Kích thước key | Không giới hạn từ 4.2 (key 5 000 byte OK) | lab |
| Unique | Thiếu field = null; số khác kiểu cùng giá trị là trùng; sharded thì phải chứa shard key | lab, bài 9 |
| Multikey | Tối đa 1 field mảng mỗi document trong compound; cờ multikey không tự tắt | lab |
| TTL | Trễ 0–60 s + thời gian xoá; chỉ primary xoá; field phải là Date (mảng Date thì lấy ngày sớm nhất); 1 field | lab + docs |
| Sort trong RAM | 100 MB/stage, 9.0 mặc định tràn đĩa | lab |
| Build | 200 MB/lệnh (tối thiểu 50), 3 build cùng lúc, dừng nếu đĩa trống < 500 MB (indexBuildMinAvailableDiskSpaceMB) | getParameter + docs |
| Độ phức tạp | Tìm O(log n) — vài trang 16 KB mỗi lần; ghi: O(log n) × số key (index × phần tử mảng) | — |
Khung lý thuyết: index là đánh đổi kinh điển giữa chi phí đọc và chi phí ghi/bộ nhớ (họ RUM: Read, Update, Memory — tối ưu hai thì mất cái thứ ba). Mỗi index giảm đọc cho một nhóm truy vấn, đổi bằng một lần ghi nữa và một phần cache.
5. Failure modes
| # | Tình huống | Hệ quả | Phát hiện / phục hồi |
|---|---|---|---|
| 1 | Build index lớn giờ cao điểm | Chậm, không chặn: p99 ghi 34–166 ms → 289/472 ms đầu build, 318–325 ms ngay sau commit (lab) | $currentOp msg "Index Build: scanning collection: x/y"; build giờ thấp điểm |
| 2 | mongod chết giữa lúc build | SIGKILL: quét lại từ đầu; SIGTERM: chạy tiếp (1 lần) | Log Index build: restarting / resuming |
| 3 | 1 node có quyền bầu mất mạng khi build | createIndex treo (lab 64,6 s tới khi can thiệp) | config.system.indexBuilds; setIndexCommitQuorum |
| 4 | Unique trên dữ liệu đã trùng | Tốn trọn lần quét rồi lỗi E11000; ORM có thể nuốt lỗi (Mongoose, mục 6) | Đếm trùng bằng $group trước khi build |
| 5 | App ghi mảng vào field đang có compound index với mảng khác | Insert lỗi 171 "cannot index parallel arrays" — nổ khi ra tính năng mới | $jsonSchema khoá kiểu field |
| 6 | Field đổi sang mảng lúc cursor đang chạy | Document bị trả 2 lần (lab) / QueryKilledError (docs 9.0) | Khử trùng theo _id; khoá kiểu field |
| 7 | Thêm TTL cho collection nhiều dữ liệu cũ | Cơn xoá: 780 659 doc / 10 s, 78 088 entry oplog → secondary lag | Xoá trước theo lô; theo dõi replication lag |
| 8 | TTL field sai kiểu | Không xoá, không lỗi, không metric → đĩa phình | $type check định kỳ |
| 9 | Index (hoặc phần nóng) không vừa cache | IXSCAN/FETCH đọc đĩa; FETCH ngẫu nhiên 36 610 doc chậm ngang COLLSCAN 2 triệu (lab) | Cache bytes theo index; covered index; bài 6 |
| 10 | Planner đổi plan / chọn index sai | Truy vấn chậm đột ngột | Bài 4 |
Mọi dòng có chữ "lab" đều tái hiện được bằng 03-index.sh (phần build, quorum, multikey, v80, ttl, unique).
6. Vận hành trong production
Metric cần theo dõi:
$indexStats→accesses.ops,since: index nào không ai dùng. Bộ đếm nằm trong RAM, reset khi restart hoặc tạo lại index (lab: index vừa tạo cósincemới) — xem trên mọi node, qua đủ một chu kỳ nghiệp vụ (báo cáo cuối tháng!).$collStats→indexSizesvàindexDetails.*.cache["bytes currently in the cache"]: RAM thật của từng index.- Slow query log / profiler:
keysExamined,docsExamined,nReturned— tỉ lệ xa 1:1:1 là thiếu hoặc sai index (bài 4). serverStatus().metrics.ttl(deletedDocuments,passes…),indexBulkBuilder(số lần tràn đĩa khi build),$currentOpkhi build.
Quy trình xoá index an toàn: hideIndex → theo dõi latency qua một chu kỳ nghiệp vụ → dropIndex. Lỡ cần lại thì unhideIndex có tác dụng
ngay, không phải build lại (lab). Index hidden vẫn tốn ghi và vẫn chặn trùng nếu là unique.
Quy trình thêm index lớn: kiểm đĩa trống (file tạm + ngưỡng 500 MB), giờ thấp điểm, theo dõi $currentOp; replica set thì chắc mọi node có quyền bầu đang khoẻ,
hoặc đặt commitQuorum rõ ràng. Collection cực lớn có thể build cuốn chiếu từng node (rolling build theo docs) — chưa verify trong lab.
Laravel (mongodb/laravel-mongodb 5.11.0) — migration chạy thật trên 9.0.2 (03-laravel.txt):
| Viết trong migration | Kết quả thật |
|---|---|
$c->string('email')->unique() | Tạo email_1 unique thật (addColumn chỉ nhớ tên field); ->nullable() bị bỏ qua (__call rỗng) → khách thứ 2 không email: E11000 … dup key: { email: null } |
$c->sparse_and_unique('email') khi email_1 đã có | An existing index has the same name as the requested index… |
$c->expire('expires_at', 600) khi đang là 3 600 | An equivalent index already exists with the same name but different options → phải collMod |
$c->dropIndex('phone') | 9.0: OK nhưng phone_1 vẫn còn (tên index là phone_1; từ 8.3 dropIndexes không báo lỗi khi không thấy). 8.0.32: IndexNotFound — index not found with name [phone] |
$c->dropIndexIfExists('phone') | OK, không làm gì |
$c->dropIndex(['customerId' => 1, 'createdAt' => -1]) | Đúng: ghép tên customerId_1_createdAt_-1 |
TTL expire('expires_at', 0) + model không cast, gán toDateTimeString() / timestamp số / chuỗi ISO | Lưu kiểu string / int → không bao giờ bị xoá. Cast 'datetime' hoặc gán Carbon → date, đúng |
Schema::connection('mongodb')->table('customers', function (Blueprint $c) {
$c->index(['customerId' => 1, 'createdAt' => -1]); // compound có hướng
$c->index('email', null, null, ['unique' => true, // unique khi có email,
'partialFilterExpression' => ['email' => ['$gt' => '']]]); // equality vẫn dùng index
$c->dropIndex(['phone' => 1]); // drop theo cột, đừng theo chuỗi
});
Ở FCV 9.0, listIndexes trả thêm collation: { locale: "simple" } cho mọi index (known issue SERVER-89953) — script tự so sánh định nghĩa index có thể báo lệch giả.
Mongoose 9.10.4 (03-mongoose.txt): autoIndex mặc định bật — khai báo model là gọi createIndex ngầm.
- Deploy bản có thêm 1 index trên 2 triệu đơn: app chạy tiếp, query trong lúc build mất 3 492 ms (chưa có index), index xong sau 7,2 s. App nào
await Model.init()lúc khởi động thì bị chặn chừng ấy — nhân với số pod khởi động cùng lúc. unique: truetrên dữ liệu đã trùng: build lỗi E11000 chỉ hiện qua event'index', app vẫn chạy, không có ràng buộc unique,User.createtrùng vẫn OK.syncIndexes()xoá index không khai báo trong schema — lab xoá luôn index hotfix DBA tạo tay. ChạydiffIndexes()trước (nó báotoDrop).- Production:
autoIndex: false, tạo index bằng script/migration riêng có người duyệt.
7. So sánh và ma trận quyết định
| Trục | MongoDB 9.0 (WiredTiger) | PostgreSQL 18 |
|---|---|---|
| Index trỏ tới | RecordId (key của B-tree collection; clustered: chính _id) | TID — vị trí trên heap |
| Đọc chỉ từ index | Covered: chỉ cần đủ field | Index-only scan cần visibility map (VACUUM) — scan node |
| Unique + NULL | Thiếu = null, chỉ 1; partial $gt: "" để cho nhiều | Nhiều NULL mặc định; NULLS NOT DISTINCT để chặn |
| Partial index | Không suy luận $type; field key phải có trong query/sort | Suy luận email = 'x' ⇒ IS NOT NULL |
| Field mảng | Multikey tự động, sort được, 1 mảng/compound | GIN (jsonb_path_ops): chứa/không chứa, không sort |
| Build không chặn | Hybrid mặc định; lỗi thì tự dọn | CONCURRENTLY: 2 lần quét, lỗi để lại index INVALID |
| Hết hạn dữ liệu | TTL index có sẵn (trễ ≤ 60 s + thời gian xoá) | Không có — job DELETE theo lô hoặc drop partition |
| Nhu cầu | Chọn | Tránh |
|---|---|---|
| Lọc bằng + sort + khoảng | Compound theo ESR, đo lại với khoảng hẹp | Nhiều index 1 field rồi mong planner ghép |
| Thuộc tính động của sản phẩm | Attribute pattern {attrs.k, attrs.v} hoặc wildcard | Mỗi thuộc tính một index (giới hạn 64) |
| "Unique nếu có" | Partial {$gt: ""} | Sparse (null vẫn trùng), partial $type (COLLSCAN) |
| Hàng đợi nhỏ trong collection lớn | Partial index theo trạng thái | Index đủ trên field ít giá trị |
| Dọn session/log | TTL + kiểm hạn khi đọc | TTL cho nghiệp vụ cần đúng giây |
| Tìm kiếm chữ, gõ sai, xếp hạng | Atlas Search / Elasticsearch | Regex /i, text index |
Dùng index khi truy vấn chạy thường xuyên, chọn lọc, hoặc cần thứ tự — và bạn đọc được explain của nó. Tránh index "cho chắc" trên collection ghi nhiều, index trên
field ít giá trị đứng một mình, và index không ai đo $indexStats.
8. Góc nhìn tech lead: tác động tổ chức
- Index là code: nằm trong migration/script có review, không sinh ngầm lúc app khởi động (Mongoose
autoIndex,ensureIndextrong code). PR thêm truy vấn mới phải kèm explain (keysExamined/nReturned). - Ngân sách index: collection ghi nhiều đặt trần (vd ≤ 6 index — số minh hoạ, lab cho 2,06× thời gian insert ở 6 index và 4,28× ở 10). Thêm index phải chỉ ra index nào bỏ được.
- Ai được build index trên production, lúc nào: build ăn I/O và kéo p99 lên; replica set có thể treo khi một node hỏng. Ghi vào runbook cùng lệnh
setIndexCommitQuorum, cách theo dõi và cách huỷ (dropIndextrên index đang build). - Onboarding: dev từ SQL mang sang 3 thói quen sai — unique nullable, partial index tin planner suy luận,
dropIndex('tên cột'). Đưa bảng ở mục 6 vào checklist review. - Exit strategy: định nghĩa index khá dễ mang đi — B-tree, unique, partial có ở PostgreSQL. Chỗ khó: multikey → GIN (mất sort), TTL → job hoặc partition, collation → ICU, wildcard → GIN trên jsonb. Lock-in nằm ở ngữ nghĩa truy vấn (mảng, null) hơn là ở index.
Đoạn chép vào ADR: "Index MongoDB được khai báo trong migration, không bậtautoIndexở production. Mỗi index mới kèm explain trước/sau và tên truy vấn nó phục vụ. Collection ghi nhiều tối đa N index. Xoá index qua bướchideIndextối thiểu một chu kỳ báo cáo. Unique cho field không bắt buộc dùng partial{$gt: ""}. Field có TTL phải là BSON date, có job kiểm$typehằng tuần."
9. Anti-patterns và bài học thực tế
- Tạo index lúc app khởi động. Postmortem CircleCI (26/3–10/4/2019): "a deploy of our API would redeclare every index roughly one hundred times"; các lệnh tạo index đó
"were starving the 'tickets' on their WiredTiger storage backend" — query bình thường không lấy được ticket, workflow ùn lại; họ gỡ việc tạo index thừa ngày 5/4/2019 (nguồn:
CircleCI incident report). Đó đúng là cơ chế của
autoIndexkhi nhiều pod khởi động cùng lúc. - TTL dừng im lặng. SERVER-97368 (8.0.4): time series collection có một document với thời gian sau 19/01/2038 làm TTL của cả collection bị bỏ qua, không log, đĩa phình; sửa ở 8.0.12 (nguồn: Mydbops, JIRA) — chưa verify trong lab. Cùng họ với bẫy sai kiểu ở mục 3.7: TTL hỏng thì không ai báo, phải tự đo.
- Một index cho mỗi field rồi mong planner ghép (index intersection hiếm khi được chọn) — dùng compound theo truy vấn thật.
- Index đơn trên field ít giá trị (
status): lab đọc 159 591 key + 159 591 document rồi vẫn SORT, 800–900 ms. - Regex
/icho tìm kiếm: quét cả index (200 000 key). Lưu thêm field chuẩn hoá (lower-case, bỏ dấu) hoặc dùng search engine. - Không bao giờ xoá index: index chết vẫn tốn ghi + cache mãi. Dọn định kỳ bằng
$indexStats+ hidden index. - Tin explain trên dữ liệu dev nhỏ: lựa chọn ESR/ERS đổi theo độ rộng khoảng (lab), plan đổi theo phân bố dữ liệu thật.
10. Câu hỏi phụ interviewer hay hỏi tiếp
- Vì sao thứ tự field trong compound index quan trọng? — Index là danh sách sắp theo field 1 rồi field 2…; chỉ prefix mới tạo đoạn liền. Equality thu về một đoạn, sort cần field kế tiếp đã có thứ tự, range phá thứ tự của field sau nó → ESR.
- Khi nào ESR sai? — Range rất chọn lọc: ERS đọc ít key hơn và sort vài trăm dòng rẻ (lab 435 vs 5 312 key); planner 9.0 tự chọn ERS. Và
$in≥ 201 phần tử thành range. - Multikey index có covered được không? — Không cho field mảng; được cho field không phải mảng trong cùng index (nhờ
multiKeyPaths). Xoá hết mảng cũng không hết multikey. - Unique index với field không bắt buộc? — Thiếu field = null nên chỉ một document được thiếu. Partial
{$gt: ""}; tránh$typevì query thường thành COLLSCAN. - TTL có xoá đúng giờ không? — Không: monitor 60 s/lần, chỉ primary, xoá theo lô; field phải là Date, sai kiểu thì im lặng không xoá.
- Build index trên production có khoá collection không? — Chỉ khoá X rất ngắn ở hai đầu; giữa là IX, ghi đi vào side-writes table. Vẫn tốn I/O, p99 tăng; replica set chờ
commitQuorum(mặc định mọi node có quyền bầu) → một node chết là treo. - mongod chết giữa lúc build? — Chết đột ngột: build lại từ đầu. Tắt sạch (SIGTERM): lưu trạng thái, chạy tiếp một lần.
- Mỗi index tốn bao nhiêu? — Một lần chèn B-tree cho mỗi key mỗi lần ghi (multikey nhân theo số phần tử), cộng cache. Lab: 10 index chậm 4,28×; RAM index trong cache 1,1–2,7× file.
- Xoá index an toàn thế nào? —
$indexStatstrên mọi node qua một chu kỳ nghiệp vụ →hideIndex→ theo dõi →dropIndex; cẩn thậndropIndex('tên sai')từ 8.3 không báo lỗi.
Phụ lục: lab đã chạy
Môi trường: MongoDB 9.0.2 (mongo:9.0.2, replica set 1 node rs1, WiredTiger cache 0,25 GB, --memory 1g) và 8.0.32 cho phần so sánh;
replica set 3 node rs0 cho commitQuorum; PHP 8.3 + ext-mongodb 2.5.3 + mongodb/laravel-mongodb 5.11.0 (Laravel 13); Node 22 + mongoose 9.10.4;
PostgreSQL 18.4 (my-test-pgdb, bảng tmp_mdb_*). Container tạm tmp-mdb-* trên network riêng. Bộ dữ liệu chung seed bằng js/seed.js
(PRNG cố định, 5,8 triệu document, 148,8 s).
cd labs/mongodb
./03-index.sh # chạy hết (~35 phút): seed trước, quorum (3 node) cuối
./03-index.sh esr # 1 phần: seed | size | esr | multikey | unique | writecost | build | misc | v80 | laravel | mongoose | ttl | quorum
P=/var/www/chiennn/postgresql-learning; $P/scripts/psql.sh < sql/03-pg.sql
(source lib.sh && mdb_clean) # dọn container sau khi xong
Chưa verify: điều kiện để 9.0 ném QueryKilledError thay vì trả bản trùng (lab chỉ thấy bản trùng); rolling index build; SERVER-97368 (TTL time series 8.0.4);
2dsphere/text index; số liệu ở quy mô lớn hơn 2 triệu document và cache lớn hơn 0,25 GB (tỉ lệ có thể khác); hành vi trên Atlas.