Specialized Indexes (P2/2): TTL, wildcard, intersection và bảng tổng kết
Ở phần trước: multikey index đi theo từng phần tử của mảng, partial và sparse chỉ index một phần collection, và unique index là ràng buộc chứ không chỉ để tăng tốc.
- Cần đọc trước: Multikey, partial/sparse và unique
- Dẫn tới: Query Planner, bài tiếp theo. Phần này là phần cuối của bài Index đặc biệt.
TTL index: để database tự dọn dữ liệu hết hạn
Ý chính
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
Ý chính
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
Ý chính
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ử.
Cột mốc: Bạn đã chọn được TTL, wildcard hoặc index intersection cho một bài toán, và nêu cái giá cùng bẫy của từng loại theo bảng tổng kết. Bài Index đặc biệt khép lại ở đây.
Hỏi & đáp
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.