Index đặc biệt: mảng, một phần dữ liệu, ràng buộc unique, TTL và wildcard
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êngmongo-lab-07, giới hạn 2 CPU, 3 GB RAM, WiredTiger cache 1 GB, máy host Apple M4. Databaselab07. 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 → CQuan 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: 16681471.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.937Tà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:
| Sparse | Partial | |
|---|---|---|
| Lọc theo | chỉ "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 index | không | được (vd index tenantId, createdAt, lọc theo status) |
| Compound | document 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ại | ngầm | viế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: nullcũ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.ttlinserted 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 +10000Mộ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ắtlastSeenAtphải làdate. metrics.ttlcuố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: 0và 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
expireAfterSecondsbằngcollMod, không bằngcreateIndex. 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 SORTTấ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_1Khô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ại | Giải quyết | Cái giá | Bẫy |
|---|---|---|---|
| Multikey | query trên phần tử của mảng | mộ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ài | tố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 |
| Partial | chỉ index phần dữ liệu hay hỏi | gầ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 |
| Sparse | bỏ qua document thiếu field | như partial | không dùng cho sort/null; count có hint trả số sai; nên dùng partial |
| Unique | ràng buộc không trùng ở tầng database | một lần kiểm tra trùng mỗi lần ghi; không tạo được khi dữ liệu đã trùng | field vắng mặt là null, chỉ được một lần; phân biệt chữ hoa |
| TTL | database tự xoá dữ liệu hết hạn | mỗi lần xoá là một lần ghi, kéo theo xoá key ở mọi index | chạ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 |
| Wildcard | index cho field không biết trước tên | index 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 |
| Intersection | ghép hai index đơn cho một query | đọc cả hai luồng key | hầ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ủ đề | MongoDB | PostgreSQL |
|---|---|---|
| Unique và giá trị trống | field vắng/null chiếm một key, chỉ một document | NULL 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ần | partialFilterExpression (tập operator giới hạn) | CREATE INDEX ... WHERE <biểu thức> |
| Ghép nhiều index | index intersection hầu như không được chọn; hash intersection tắt mặc định | bitmap index scan (BitmapAnd/BitmapOr) là cơ chế bình thường và hay dùng |
| Mảng, thuộc tính tuỳ ý | multikey, wildcard | GIN trên mảng hoặc jsonb |
| Hết hạn tự động | TTL index | khô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 arraysxuấ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?
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?
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ì?
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?
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ì?
Collection có { tenantId: 1 } và { status: 1 }. Query { tenantId: "t0042", status: "pending" }. Trên 8.3.11 server làm gì?
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?
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.