Specialized Indexes (P2/2): TTL, wildcard, intersection và bảng tổng kết

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

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

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

Ý 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 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

Ý 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_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ử.

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ì?

  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 "So với PostgreSQL"; bẫy null được đo ở Multikey, partial/sparse và unique.

  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 Multikey, partial/sparse và unique, phần bẫy null.

  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 "So với PostgreSQL"; bẫy null được đo ở Multikey, partial/sparse và unique.

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