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ằng memcmp) · 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 trong shard_catalog) và manual (ESR, TTL, index build, partial, covered, bounds multikey); release notes + compatibility 9.0; source laravel-mongodb 5.11.0 (Schema/Blueprint.php); docs Mongoose. Môi trường lab ở phụ lục; output labs/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 file index-*.wt): danh sách key đã sắp xếp, key mã hoá KeyString để so sánh bằng memcmp, 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:

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 viSuy ra từ "danh sách đã sắp xếp"Lab
Equality trên field đầuNhảy (seek) tới đầu đoạn key bằng nhau1 key cho email
Sort không tốn RAMĐọc đoạn theo thứ tự lưu, xuôi hoặc ngượcmục 3.3
ESRE thu về 1 đoạn; S giữ thứ tự trong đoạn; R lọc trong lúc đi193 vs 12 927 key
MultikeyMột document → nhiều key ở nhiều chỗ trong danh sách4 378 390 key / 2 triệu đơn
Covered queryKey đã chứa đủ field cần trả, khỏi FETCHdocsExamined 0
TTLXoá = quét đoạn key < now − expireAfterSeconds500 000 doc / 7 s
Giá ghiMỗi index là một B-tree nữa phải chèn10 index: insert chậm 4,28×
E-S-R dừng sớm, E-R-S phải quét cả khoảng rồi SORT Truy vấn đơn paid trong 30 ngày, sắp total giảm dần, lấy 20. Bên trái index status, total giảm dần, createdAt: con trỏ nhảy tới đầu đoạn paid rồi đi xuống theo total; key nào ngoài 30 ngày thì bỏ qua; đủ 20 kết quả thì dừng sau 193 key. Bên phải index status, createdAt, total giảm dần: con trỏ nhảy tới đầu khoảng 30 ngày, phải đọc hết 12 927 key của khoảng rồi đưa vào SORT trong RAM mới lấy được 20 đơn total cao nhất. find({status: "paid", createdAt: 30 ngày}).sort({total: -1}).limit(20) A. {status, total: -1, createdAt} — E·S·R status total ↓ createdAt delivered 9 990 000 2026-09-02 paid 9 980 000 2025-01-15 paid 9 975 000 2026-09-20 paid 9 940 000 2026-09-11 paid 9 900 000 2024-12-03 paid 9 870 000 2026-09-28 paid … đi tiếp tới đủ 20 pending 9 999 000 2026-09-30 ✗ ✓ ✓ ✗ ✓ 20 ✓ 193 key → 20 kết quả, không SORT, 0 ms khoảng 1 ngày (435 đơn khớp): phải đi 5 312 key B. {status, createdAt, total: -1} — E·R·S status createdAt total ↓ delivered 2026-09-30 120 000 paid 2025-01-15 9 980 000 paid 2026-09-01 350 000 paid 2026-09-11 9 940 000 paid 2026-09-20 9 975 000 paid … 12 927 key … paid 2026-09-30 120 000 pending 2024-10-02 9 999 000 SORT (RAM): 12 927 key → 20 total cao nhất 12 927 key + SORT → 20 kết quả, 12 ms khoảng 1 ngày: chỉ 435 key + sort 435 → nhanh hơn A
Hình 1 — Cùng 3 field, khác thứ tự. Trái (E·S·R): seek tới đoạn 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 trên 2 000 000 đơnGiá trị khác nhauFile (prefix compression bật)Tắt prefix compressionByte/entry
_id_ — dựng dần qua 2 triệu insert2 000 00066,9 MB (4 275 trang lá)—35,1
{_id, _none} — cùng key, dựng 1 lần bằng createIndex2 000 00036,7 MB (2 344 trang lá)—19,2
createdAt_12 000 00022,6 MB31,7 MB11,9 → 16,6
customerId_1199 48012,9 MB20,0 MB6,7 → 10,5
status_159,4 MB34,1 MB4,9 → 17,9

Ba điều đọc ra từ bảng:

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ả:

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):

Index365 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 ms193 key, 0 ms5 312 key, 11 ms
E-R-S {status, createdAt, total:-1}119 742 key + SORT, 105 ms12 927 + SORT, 12 ms435 + SORT, 0 ms
S-E-R {total:-1, status, createdAt}441 key, 1 ms2 528 key, 2 ms137 642 key, 76 ms
R-E-S {createdAt, status, total:-1}1 500 000 + SORT, 2 546 ms161 006 + SORT, 252 ms5 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-RE-S-RE-R-S
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.

3.4 Multikey: một document, nhiều key

Một document có mảng 3 phần tử sinh ra 3 entry trong index multikey Document _id 7 có items gồm 3 sku khác nhau. Mỗi sku được tách thành một key riêng, cùng trỏ về RecordId 7, rồi chèn vào đúng vị trí đã sắp xếp trong index items.sku_1 — ba vị trí cách xa nhau. Ghi 1 document là 3 lần chèn vào B-tree; quét index sẽ gặp document 7 ba lần nên phải khử trùng. insertOne 1 đơn có 3 sản phẩm → 3 lần chèn vào index items.sku_1 { _id: 7, items: [ { sku: "SKU-000123", qty: 2 }, { sku: "SKU-004567", qty: 1 }, { sku: "SKU-008800", qty: 5 } ] } index items.sku_1 — key đã sắp xếp → RecordId SKU-000045 → RecordId 3 SKU-000123 → RecordId 912 SKU-002210 → RecordId 15 SKU-004567 → RecordId 88 SKU-009001 → RecordId 41 SKU-000123 → RecordId 7 SKU-004567 → RecordId 7 SKU-008800 → RecordId 7 1 document → 1 entry 1 document → 2 entry 1 document → 3 entry, 3 lần chèn B-tree sku trùng trong cùng 1 document chỉ tạo 1 key lab: 2 000 000 đơn, 4 381 001 phần tử items → index 4 378 390 key, 43,0 MB, build 15,6 s
Hình 2 — Index trên field mảng tạo một key cho mỗi giá trị khác nhau trong mảng, rải ở nhiều chỗ của danh sách. Ghi một đơn 3 sản phẩm = 3 lần chèn B-tree; quét index sẽ gặp đơn đó nhiều lần nên plan phải khử trùng theo RecordId (lab: 4 378 390 key đọc, trả 2 000 000 document).

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ả:

  1. 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 tags dạng mảng). Bẫy nổ trễ:
    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)
    Hai field trong cùng một mảng thì được: {"items.sku": 1, "items.qty": 1}.
  2. Ghép bound giữa hai field của cùng mảng cần $elemMatch. Đơn có SKU-000001 với số lượng ≥ 5:
    $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
    Không có $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ẹp qty: đọc gấp 48 lần và ra kết quả khác.
  3. 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 sang true để tránh WriteConflict giữa các writer — "Once true, an index is always multikey".
  4. 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:
    9.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ần
    Compatibility notes 9.0 nói query "might fail with QueryKilledError"; 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ấnkeys / docsPlan
Chỉ lấy field trong index, _id: 04 / 0PROJECTION_COVERED←IXSCAN
Quên _id: 04 / 4PROJECTION_SIMPLE←FETCH←IXSCAN
Lấy thêm total / lọc thêm status (ngoài index)4 / 4FETCH
Lọc {paymentMethod: null}— / FETCHIndex không phân biệt null với thiếu field → phải đọc document
Sau khi 1 đơn có paymentMethod là mảng6 / 5FETCH — field mảng không cover được
Cùng lúc đó, chỉ lấy createdAt6 / 0Vẫn covered: multiKeyPaths biết createdAt không phải mảng
Xoá đơn mảng đó / drop + tạo lại index4 / 4 → 4 / 0Chỉ 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 email2 khách thiếu email2 khách email: nullfind({email: "x"}) dùng index?
uniquecái thứ 2: E11000 { email: null }E11000có
unique + sparseOKcái thứ 2: E11000có
partial {email: {$exists: true}}OKcái thứ 2: E11000có
partial {email: {$type: "string"}}OKOKkhông — COLLSCAN
partial {email: {$gt: ""}}OKOKcó (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 ra email = "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/$in dù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.

TTL monitor ngủ 60 giây: document hết hạn nằm lại tới lần thức sau Trục thời gian 180 giây. Document hết hạn đều đặn nên đường "đã hết hạn nhưng chưa xoá" tăng dần, tới lúc TTL monitor thức (giây 20, 80, 140) thì rơi về gần 0 trong vài giây. Document X hết hạn ở giây 26, ngay sau một lần thức, nên nằm thêm khoảng 55 giây tới lần thức giây 80 mới bị xoá. TTL monitor thức 60 s/lần: document hết hạn nằm lại tới lần thức sau document X hết hạn nằm thêm ~55 s sau khi hết hạn✗ xoá đã hết hạn, chưa xoá thức → xoá thức → xoá thức → xoá 0 s306090120150180 s Lab: 500 000 phiên hết hạn → xoá hết trong 7 s (~71 000 doc/s), 1 lượt quét oplog: 50 037 entry applyOps, mỗi entry ≤ 10 lệnh xoá · expireAt kiểu chuỗi/số: không bao giờ bị xoá
Hình 3 — Đường vàng là số document đã hết hạn mà còn nằm trong collection: tăng đều, chỉ rơi khi monitor thức. Một document hết hạn ngay sau lần thức (X) sống thêm gần 60 s, cộng thời gian xoá. Thời điểm thức là minh hoạ; số ở dưới là lab 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 TTL1,2 s11,8 s
Lần xoá đầu tiên sau khi tạo index3 s20 s
Xoá hết phần quá hạn500 000 doc trong 7 s780 659 doc trong 10 s
metrics.ttlpasses +1, subPasses +1, deletedKeys = 2 × doc (_id + expireAt)passes 1, subPasses 1, deletedKeys 1 561 320
Oplog50 037 entry applyOps (≤ 10 lệnh xoá/entry) + 26 entry d78 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á

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 đó:

Các pha của hybrid index build trên trục thời gian 16 giây Khoá X rất ngắn ở giây 0,3 và giây 15,35; giữa hai lần là khoá IX nên app vẫn đọc ghi. Pha 1 quét 2 triệu document và sort key mất 11,4 giây; pha 2 nạp key vào B-tree mất 2,9 giây. Insert của app chạy suốt, không bị chặn, nhưng p99 từ mức 34 đến 166 ms lên 289 và 472 ms trong 4 giây đầu build. Thay đổi đến trong lúc build bị chặn vào side-writes table, cuối build drain 847 key trong 32 ms rồi commit; xong sau 15,6 giây. createIndex {"items.sku": 1, createdAt: -1} — 2 triệu đơn, app vẫn insert Khoá collectionIndex buildApp insert(w: majority)side-writes table IX: app vẫn đọc/ghi X X quét 2 triệu document + sort key: 11,4 s nạp key: 2,9 s p99 lúc thường 34–166 ms 4 s đầu build: p99 289 / 472 ms drain 847 key: 32 ms → commit 0 s481216 s Xong sau 15,6 s · khoá X chỉ ở 2 đầu · writer không bị chặn, chỉ chậm hơn
Hình 4 — Trục thời gian lấy từ log của chính lần build (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.

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ạiLab (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_1Chỉ 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 keyMỗ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 keyChỉ 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 namestrength 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 → COLLSCANTiế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 indexHidden: find → COLLSCAN, nhưng insert trùng vẫn E11000; unhide → EXPRESS_IXSCAN ngayVẫ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 sIndex phụ {userId} 16,7 MB vs 6,8 MB — RecordId giờ là _id 12 byte, nối vào mọi key
2dsphere, textChư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/sSo với 1 indexTổng index
13 052 / 3 815 / 3 51632 7651,00×3,6 MB
33 728 / 4 581 / 5 26126 8241,22×7,9 MB
6 (có items.sku multikey)6 297 / 8 855 / 6 87715 8812,06×19,2 MB
10 (thêm items.productId multikey)13 050 / 19 093 / 21 3077 6634,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ụcGiá trị / hành viNguồn
Nhất quán index ↔ documentGhi document và mọi index trong cùng một transaction của storage engine — không có "index trễ" như search enginelab: insert bị E11000 thì document cũng không được ghi; bài 6
Số index / collection64 — thứ 65: CannotCreateIndex (67): add index fails, too many indexeslab
Số field / compound32 — 33 field: too many compound keys (13103)lab
Kích thước keyKhông giới hạn từ 4.2 (key 5 000 byte OK)lab
UniqueThiếu field = null; số khác kiểu cùng giá trị là trùng; sharded thì phải chứa shard keylab, bài 9
MultikeyTối đa 1 field mảng mỗi document trong compound; cờ multikey không tự tắtlab
TTLTrễ 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 fieldlab + docs
Sort trong RAM100 MB/stage, 9.0 mặc định tràn đĩalab
Build200 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ạpTì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ốngHệ quảPhát hiện / phục hồi
1Build index lớn giờ cao điểmChậ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
2mongod chết giữa lúc buildSIGKILL: quét lại từ đầu; SIGTERM: chạy tiếp (1 lần)Log Index build: restarting / resuming
31 node có quyền bầu mất mạng khi buildcreateIndex treo (lab 64,6 s tới khi can thiệp)config.system.indexBuilds; setIndexCommitQuorum
4Unique trên dữ liệu đã trùngTố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
5App ghi mảng vào field đang có compound index với mảng khácInsert lỗi 171 "cannot index parallel arrays" — nổ khi ra tính năng mới$jsonSchema khoá kiểu field
6Field đổi sang mảng lúc cursor đang chạyDocument bị trả 2 lần (lab) / QueryKilledError (docs 9.0)Khử trùng theo _id; khoá kiểu field
7Thêm TTL cho collection nhiều dữ liệu cũCơn xoá: 780 659 doc / 10 s, 78 088 entry oplog → secondary lagXoá trước theo lô; theo dõi replication lag
8TTL field sai kiểuKhông xoá, không lỗi, không metric → đĩa phình$type check định kỳ
9Index (hoặc phần nóng) không vừa cacheIXSCAN/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
10Planner đổi plan / chọn index saiTruy vấn chậm đột ngộtBà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:

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 migrationKế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 600An 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 ISOLư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.

7. So sánh và ma trận quyết định

TrụcMongoDB 9.0 (WiredTiger)PostgreSQL 18
Index trỏ tớiRecordId (key của B-tree collection; clustered: chính _id)TID — vị trí trên heap
Đọc chỉ từ indexCovered: chỉ cần đủ fieldIndex-only scan cần visibility map (VACUUM) — scan node
Unique + NULLThiếu = null, chỉ 1; partial $gt: "" để cho nhiềuNhiều NULL mặc định; NULLS NOT DISTINCT để chặn
Partial indexKhông suy luận $type; field key phải có trong query/sortSuy luận email = 'x' ⇒ IS NOT NULL
Field mảngMultikey tự động, sort được, 1 mảng/compoundGIN (jsonb_path_ops): chứa/không chứa, không sort
Build không chặnHybrid mặc định; lỗi thì tự dọnCONCURRENTLY: 2 lần quét, lỗi để lại index INVALID
Hết hạn dữ liệuTTL index có sẵn (trễ ≤ 60 s + thời gian xoá)Không có — job DELETE theo lô hoặc drop partition
Nhu cầuChọnTránh
Lọc bằng + sort + khoảngCompound theo ESR, đo lại với khoảng hẹpNhiều index 1 field rồi mong planner ghép
Thuộc tính động của sản phẩmAttribute pattern {attrs.k, attrs.v} hoặc wildcardMỗ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ớnPartial index theo trạng tháiIndex đủ trên field ít giá trị
Dọn session/logTTL + kiểm hạn khi đọcTTL cho nghiệp vụ cần đúng giây
Tìm kiếm chữ, gõ sai, xếp hạngAtlas Search / ElasticsearchRegex /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

Đoạn chép vào ADR: "Index MongoDB được khai báo trong migration, không bật autoIndex ở 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ước hideIndex tố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 $type hằng tuần."

9. Anti-patterns và bài học thực tế

10. Câu hỏi phụ interviewer hay hỏi tiếp

  1. 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.
  2. 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.
  3. 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.
  4. 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 $type vì query thường thành COLLSCAN.
  5. 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á.
  6. 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.
  7. 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.
  8. 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.
  9. Xoá index an toàn thế nào? — $indexStats trên mọi node qua một chu kỳ nghiệp vụ → hideIndex → theo dõi → dropIndex; cẩn thận dropIndex('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.