Index đặc biệt: mảng, một phần dữ liệu, ràng buộc unique, TTL và wildcard

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

Một thư viện lớn không chỉ có một tủ phiếu mục lục. Có tủ phiếu cho sách nhiều tác giả, mỗi tác giả một phiếu, nên một cuốn sách nằm ở nhiều chỗ. Có cuốn sổ chỉ ghi sách đang mượn quá hạn, mỏng hơn sổ mượn sách đầy đủ rất nhiều. Có sổ cấp thẻ bạn đọc, không cho ghi trùng số CMND. Có giá báo cũ mà nhân viên cứ mỗi sáng đi một vòng dọn những tờ quá 30 ngày. Và có một hộp phiếu "thông tin khác", ghi bất kỳ thuộc tính nào người ta nghĩ ra cho cuốn sách.

Mỗi cuốn sổ đó giải một bài toán mà tủ phiếu bình thường không giải được. Và mỗi cuốn có cái giá và cái bẫy riêng: sổ quá hạn không giúp được câu hỏi về sách đã trả, sổ không cho trùng sẽ từ chối người thứ hai không có CMND, nhân viên dọn báo không dọn đúng giây.

Bài Compound Indexes & ESR dạy cách xếp field trong một index "bình thường": mỗi document một key, mọi document đều có mặt. Bài này đi qua những index có tính năng riêng trong MongoDB, tương ứng với các cuốn sổ trên: multikey, partial và sparse, unique, TTL, wildcard. Cuối bài là index intersection, thứ nghe như cách ghép nhiều index đơn cho một query nhưng hiếm khi được dùng. Với mỗi loại, bài trả lời ba câu: nó giải quyết gì, nó tốn gì, và bẫy nằm ở đâu.

[hình dung] Các cuốn sổ chỉ là cách hình dung. Mọi loại index trong bài vẫn là B-tree trong WiredTiger như bài Index Fundamentals đã mô tả. Cái khác nhau là key nào được đưa vào B-tree, và server dùng hay không dùng index đó trong trường hợp nào.

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

  • Cần biết trước: CRUD & Query Model (ngữ nghĩa query trên mảng, $elemMatch), Index Fundamentals (B-tree, IXSCAN + FETCH, chi phí mỗi index), Compound Indexes & ESR (compound index, prefix, covered query)
  • Giới thiệu: multikey index (và luật "một mảng mỗi compound index", bounds với $elemMatch), partial vs sparse, unique (với null/field vắng, unique theo tenant), TTL (TTL monitor khoảng 60 giây), wildcard và compound wildcard, index intersection (AND_SORTED / AND_HASH) và vì sao hiếm thấy
  • Dẫn tới: Query Planner & Plan Cache

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ạ. Đây cũng là môi trường và dataset của bài Compound Indexes & ESR: hai bài từng là một, các phép đo được làm chung một đợt.

Trong lúc đo, lab của mấy bài khác trong series chạy song song trên cùng máy host, nên thời gian có nhiễu. Bài này chủ yếu dựa vào số key, số document, số byte và hành vi (lỗi gì, xoá lúc nào), vốn không bị nhiễu.

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

Dataset

Collection chính là lab07.orders: 1.000.030 đơn hàng của 500 tenant, sinh bằng PRNG với seed cố định (đoạn sinh dữ liệu nằm ở bài Compound Indexes & ESR). Bài này dùng những field mà bài trước không đụng tới:

{
  tenantId: "t0042",
  status: "pending",                  // 70% completed, 10% pending/cancelled/refunded
  createdAt: ISODate("2026-09-14T..."),
  total: 1250000,                     // VND
  tags: ["gift", "vip"],              // mảng 0–3 phần tử, lấy từ 12 nhãn
  items: [ { sku: "...", qty: 2, price: 450000 } ],   // 1–3 món
  couponCode: "C7"                    // chỉ ~3% đơn có field này (30.093 đơn)
}

Phần unique dùng một collection nhỏ users, phần TTL dùng sessions, phần wildcard dùng products 200.000 sản phẩm. Mỗi phần mô tả collection của nó. Mỗi dòng kết quả kiểu plan | keys | docs | nReturned là explain("executionStats") được rút gọn bằng một helper nhỏ.

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 Data Modeling 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 CRUD & Query Model 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 CRUD đã chỉ ra.

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 Document Model), 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 Index Fundamentals đã 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, $geoWithin, $geoIntersects [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, 2d và 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 CRUD & Query Model đã 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 BSON & ObjectId, 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.
  • 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.

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 (phần quy tắc prefix của bài Compound Indexes & ESR). Với hai index đơn, nó đọc 1.948 key, 1.948 document. Bài Query Planner 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ố.

Bảng tổng kết: giải quyết gì, tốn gì, bẫy ở đâu

LoạiGiải quyếtCái giáBẫy
Multikeyquery trên phần tử của mảngmột key mỗi phần tử (1,67 triệu key cho 1 triệu đơn trong lab), ghi nhiều hơn khi mảng dàitối đa một field mảng mỗi compound index, kiểm tra cả lúc insert; thiếu $elemMatch thì bounds không giao được
Partialchỉ index phần dữ liệu hay hỏigần như không, index nhỏ hơn (7,6 lần trong lab)query phải bao hàm filter; hint() vào partial index trả thiếu mà không báo lỗi
Sparsebỏ qua document thiếu fieldnhư partialkhông dùng cho sort/null; count có hint trả số sai; nên dùng partial
Uniqueràng buộc không trùng ở tầng databasemột lần kiểm tra trùng mỗi lần ghi; không tạo được khi dữ liệu đã trùngfield vắng mặt là null, chỉ được một lần; phân biệt chữ hoa
TTLdatabase tự xoá dữ liệu hết hạnmỗi lần xoá là một lần ghi, kéo theo xoá key ở mọi indexchạy khoảng mỗi 60 s, không đúng giây; field không phải Date không bao giờ hết hạn
Wildcardindex cho field không biết trước tênindex to hơn index nhắm đúng field (3,8 MB so với 0,9 MB)một field mỗi predicate; không hỗ trợ $exists: false; không unique/TTL
Intersectionghép hai index đơn cho một queryđọc cả hai luồng keyhầu như không được chọn; không thay được compound index

So với PostgreSQL

PostgreSQL có bản tương ứng cho gần như mọi loại index trong bài. Khác biệt đáng nhớ nằm ở mặc định.

Chủ đềMongoDBPostgreSQL
Unique và giá trị trốngfield vắng/null chiếm một key, chỉ một documentNULL mặc định không bằng nhau, nhiều hàng NULL được phép; NULLS NOT DISTINCT (từ bản 15) đổi lại
Index một phầnpartialFilterExpression (tập operator giới hạn)CREATE INDEX ... WHERE <biểu thức>
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
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 đầu và dòng ghép index là chỗ người chuyển từ PostgreSQL sang hay vấp. Unique với null ngược nhau hoàn toàn: bên PostgreSQL "nhiều user không có email" là bình thường, bên MongoDB người thứ hai gặp lỗi dup key: { email: null }. 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

  • Compound index có hai field có thể là mảng. Lỗi cannot index parallel arrays xuất hiện ở lệnh insert, có khi nhiều tháng sau lúc tạo index.
  • Query khoảng trên mảng object mà không dùng $elemMatch. Index chỉ dùng được một nửa điều kiện (1.013.360 key so với 39.707 trong lab), và kết quả cũng khác.
  • hint() một partial hoặc sparse index. Không lỗi, chỉ trả về thiếu.
  • Dùng sparse khi partial làm được việc rõ ràng hơn. Sparse chỉ lọc theo "field có tồn tại", và hành vi với compound index dễ gây bất ngờ.
  • 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 với $type: "string", thường trên { tenantId, email }.
  • 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; query vẫn phải tự lọc theo thời gian.
  • Dùng wildcard thay cho thiết kế schema. Wildcard to hơn và yếu hơn index nhắm đúng field; với tập thuộc tính tuỳ ý, attribute pattern thường tốt hơn.
  • Tạo một index đơn cho mỗi field rồi trông vào index intersection. Trên 8.3.11 planner không chọn nó trong mọi query thử.

Hỏi & đáp

Có index { "items.price": 1 }. Vì sao find({ "items.price": { $gte: 500000, $lte: 510000 } }) đọc nhiều key hơn hẳn cùng điều kiện viết bằng $elemMatch?

  1. Index multikey không hỗ trợ range, nên server quét gần hết index

    Multikey vẫn dùng range được: bản $elemMatch đọc đúng 39.707 key với bounds [500000, 510000]. Vấn đề là bounds nào được phép ghép. Xem mục "Bounds: vì sao $elemMatch quan trọng với index".

  2. Không có $elemMatch, hai điều kiện có thể khớp hai món khác nhau

    Một đơn có món 900.000 và món 100.000 vẫn khớp, nên planner chỉ dùng [500000, inf] rồi lọc sau FETCH. Lab: 1.013.360 key so với 39.707, và hai query trả kết quả khác nhau (434.798 so với 39.442). Xem mục "Bounds: vì sao $elemMatch quan trọng với index".

  3. Hai cách viết tương đương; chênh lệch là do cache lạnh ở lần chạy đầu

    Không tương đương: số key (1.013.360 so với 39.707) và cả số kết quả (434.798 so với 39.442) đều khác, không phụ thuộc cache. Xem mục "Bounds: vì sao $elemMatch quan trọng với index".

Collection có index { tags: 1, couponCode: 1 }, tạo thành công vì couponCode chưa bao giờ là mảng. Vài tháng sau, một bản deploy ghi couponCode: ["C1", "C2"] cho một đơn có tags. Chuyện gì xảy ra?

  1. Insert thành công, index tự bỏ qua field mảng thứ hai

    Index không bỏ qua gì: một compound index chỉ cho phép tối đa một field là mảng trong mỗi document, và luật được kiểm tra khi ghi. Xem mục về luật "một mảng mỗi compound index".

  2. Insert thành công, nhưng index bị đánh dấu hỏng tới khi build lại

    Server không nhận document rồi làm hỏng index; nó từ chối chính lệnh insert. Lab: cannot index parallel arrays [couponCode] [tags]. Xem mục về luật "một mảng mỗi compound index".

  3. Insert thành công, index tạo key cho mọi tổ hợp tag × coupon

    Đây chính là sự bùng nổ mà luật này ngăn: 3 tag nhân 3 món đã là 9 key cho một document. Server từ chối thay vì sinh tích. Xem mục về luật "một mảng mỗi compound index".

  4. Insert bị từ chối với lỗi cannot index parallel arrays

    Một index tạo hôm nay có thể làm lệnh insert của ngày mai thất bại khi một field đổi thành mảng. Nếu dùng flexible schema, khoá kiểu các field trong compound index bằng $jsonSchema. Xem mục về luật "một mảng mỗi compound index".

Có partial index pending_only trên { tenantId: 1, createdAt: -1 } với filter { status: "pending" }. Ai đó viết find({ tenantId: "t0042" }).hint("pending_only"). Lab cho thấy gì?

  1. Không báo lỗi, trả 182 đơn thay vì 1.948

    Server tin hint và trả đúng những gì có trong index, tức chỉ đơn pending. Không có hint, planner chỉ dùng partial index khi query bao hàm filter của nó (ví dụ có status: "pending"). Xem mục "Partial: chỉ index đơn pending".

  2. Báo lỗi vì query không bao hàm filter của index

    Đó là điều ta mong đợi, nhưng lab cho thấy không có lỗi nào: query trả thiếu một cách im lặng. Xem mục "Partial: chỉ index đơn pending".

  3. Server bỏ qua hint và dùng index full, trả đủ 1.948 đơn

    Server không bỏ qua hint; nó dùng đúng pending_only và trả 182 đơn. Bỏ qua index là việc planner làm khi không có hint. Xem mục "Partial: chỉ index đơn pending".

  4. Server dùng pending_only rồi COLLSCAN bổ sung phần còn thiếu

    Không có bước bổ sung nào. Partial index không chứa đơn ngoài filter, và kết quả chỉ có 182 đơn. Xem mục "Partial: chỉ index đơn pending".

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 }. Cách sửa mà bài đề xuất?

  1. Bỏ unique index, trước mỗi insert find kiểm tra email đã có chưa

    Kiểm tra ở ứng dụng không chặn được hai request chạy đồng thời. Unique index là cách để chính database đảm bảo không trùng. Xem mục "Unique index: ràng buộc, không chỉ là tốc độ".

  2. Unique + partial với { email: { $exists: true } }

    Gần đúng, nhưng document có email: null vẫn có field nên vẫn vào index và vẫn đụng nhau. Bài dùng $type: "string" để null cũng không bị tính. Xem mục "Cách sửa: unique + partial, và unique theo tenant".

  3. Unique + partial { email: { $type: "string" } }, thường trên { tenantId, email }

    Ràng buộc unique chỉ áp cho document khớp filter. Lab: user không có email và user có email: null đều ok, email trùng trong cùng tenant thì E11000. Nhớ chuẩn hoá chữ hoa: Lan@ và lan@ là hai key khác nhau. Xem mục "Cách sửa: unique + partial, và unique theo tenant".

TTL index trên lastSeenAt với expireAfterSeconds: 30. Một session hết hạn lúc 10:00:05. Lúc 10:00:30, ứng dụng nên dựa vào điều gì?

  1. Session đã bị xoá đúng lúc 10:00:05, chỉ cần find theo _id

    TTL monitor chạy khoảng mỗi 60 giây, không đúng giây. Lab: session "fresh" hết hạn lúc 07:32:05, bị xoá lúc 07:32:20; session "old" hết hạn từ một tiếng trước vẫn nằm đó 45 giây. Xem mục "Thí nghiệm: nhìn TTL monitor làm việc".

  2. Session có thể vẫn còn; query phải tự lọc theo lastSeenAt

    Hết hạn không có nghĩa là bị xoá ngay: TTL monitor chạy khoảng mỗi 60 s (ttlMonitorSleepSecs: 60), và có thể trễ hơn khi tải nặng. Đừng dựa vào TTL để chặn đăng nhập. Xem mục "Thí nghiệm: nhìn TTL monitor làm việc".

  3. Giảm expireAfterSeconds xuống 0 để xoá ngay khi hết hạn

    expireAfterSeconds: 0 dùng để mỗi document hết hạn theo thời điểm riêng lưu trong field (expireAt), không làm TTL monitor chạy dày hơn. Xem mục "Thí nghiệm: nhìn TTL monitor làm việc".

  4. Session còn nếu lastSeenAt là string, nên chuyển sang string

    Ngược lại: field không phải Date thì không bao giờ hết hạn, và không báo lỗi. Lab: 10 session lưu string vẫn còn sau mọi lượt. Xem mục "Thí nghiệm: nhìn TTL monitor làm việc".

Collection có { tenantId: 1 } và { status: 1 }. Query { tenantId: "t0042", status: "pending" }. Trên 8.3.11 server làm gì?

  1. Chọn một index (tenantId_1), đọc 1.948 document để trả 182

    Không có plan intersection nào, kể cả trong rejectedPlans; hash intersection tắt mặc định. Compound { tenantId, status, ... } chỉ đọc 182 key. Hai index đơn không thay được một compound index. Xem mục "Quan sát trên 8.3.11".

  2. Quét cả hai index, giao RecordId bằng AND_SORTED, rồi FETCH 182

    Nghe hợp lý, nhưng AND_SORTED không xuất hiện trong mọi query thử. Và để có giao, server vẫn phải đọc hơn 100.000 key của cả hai luồng. Xem mục "Index intersection: ghép hai index đơn, và vì sao hiếm thấy".

  3. Dùng AND_HASH vì đó là chiến lược mặc định khi có hai index đơn

    internalQueryPlannerEnableHashIntersection đọc ra false. Chỉ khi bật nó lên trong lab, AND_HASH mới xuất hiện, và vẫn bị loại. Xem mục "Quan sát trên 8.3.11".

  4. Chọn status_1 vì status là điều kiện bằng có ít giá trị

    status_1 nằm trong rejectedPlans. "pending" khớp 99.639 đơn, so với 1.948 của t0042, nên tenantId_1 thắng. Xem mục "Quan sát trên 8.3.11".

Sổ cấp thẻ thư viện không cho ghi trùng số CMND. Ai không có CMND thì ô CMND để trống. Người thứ hai không có CMND đến làm thẻ. Theo cách sổ này hoạt động, chuyện gì xảy ra?

  1. Được cấp thẻ: không có số thì không có gì để trùng

    Đây là cách nghĩ tự nhiên (và là mặc định của PostgreSQL), nhưng sổ coi "ô trống" là một giá trị. Người đầu tiên đã chiếm nó. Xem mục "Bẫy null: field vắng mặt cũng là một giá trị" và "So với PostgreSQL".

  2. Bị từ chối, vì sổ bắt buộc mọi người phải có CMND

    Sổ không bắt buộc có CMND: người đầu tiên không có CMND vẫn được cấp. Lý do từ chối là trùng "ô trống". Xem mục "Bẫy null: field vắng mặt cũng là một giá trị".

  3. Bị từ chối, vì ô trống của người trước cũng tính là một số

    Field vắng mặt được ghi như một giá trị (null), và chỉ một người được giữ giá trị đó. Lab: người thứ hai đăng ký bằng số điện thoại gặp dup key: { email: null }. Cách sửa là chỉ đưa vào sổ những người thật sự có số. Xem mục "Bẫy null: field vắng mặt cũng là một giá trị".

Nếu phải giải thích bài này mà không dùng thuật ngữ MongoDB nào: ngoài tủ phiếu thường, thư viện có những cuốn sổ đặc biệt: sổ ghi một cuốn sách ở nhiều chỗ vì nó có nhiều tác giả, sổ chỉ ghi sách quá hạ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, hộp phiếu cho mọi thông tin lặt vặt. Mỗi cuốn tiện cho một việc, và mỗi cuốn có một kiểu sai riêng nếu dùng không đúng chỗ.

Bài tiếp theo

Bài Compound Indexes & ESR 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 nó chọn: total_1 thay vì tenantId_1 cho Q3 ở phần index intersection, attributes.color_1 thay vì wildcard, và luôn có một danh sách rejectedPlans. Ta cũng đã thấy hint() sai chỗ trả kết quả thiếu mà không báo lỗi.

Bài Query Planner & Plan Cache 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 của bài Compound Indexes & ESR sẽ được trả lời bằng số đo: khi cùng một query shape lúc thì khớp hàng chục nghìn đơ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