Index Fundamentals (P2/3): Selectivity, khi index không giúp, cái giá build

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

Ở phần trước: index là cấu trúc đã sắp xếp và trỏ tới document. Index tenantId_1 cắt 99,8% công việc, nhưng vẫn đọc dư khoảng 95 lần, vì nó chỉ biết tenantId.

Selectivity: index tốt đến đâu phụ thuộc vào câu hỏi

Định nghĩa

[tài liệu] Selectivity là tỉ lệ document khớp query so với tổng số document trong collection. Query có selectivity cao khi ít document khớp. Nghe hơi ngược, nhưng cứ nhớ: "selective" là "kén chọn". Query càng kén thì càng ít document lọt qua.

Trên dataset này:

Điều kiệnSố document khớpTỉ lệSelectivity
_id: X10,0001%rất cao
tenantId: "t0042"2.0030,2%cao
createdAt trong 3,6 ngày gần nhất10.0111%cao
status: "pending"99.43210%trung bình
status: "completed"699.99070%thấp
status thuộc completed, cancelled, refunded900.56890%rất thấp

Selectivity là thuộc tính của query với giá trị cụ thể, không chỉ của field: cùng index status_1, hỏi "pending" là 10%, hỏi "completed" là 70%.

Vì sao selectivity quyết định giá trị của index

Mỗi kết quả đi qua IXSCAN + FETCH tốn hai việc: đọc một key, rồi nhảy tới RecordId đó để lấy document. COLLSCAN thì chỉ đọc document, nhưng đọc tuần tự, document này nằm ngay sau document kia.

IXSCAN + FETCH, mỗi kết quả           COLLSCAN, mỗi document
  đọc 1 key (rẻ, liền nhau)             đọc document kế tiếp (tuần tự)
  nhảy tới RecordId (một lần tra
  vào cấu trúc lưu document)
  đọc document

chi phí ≈ (số key khớp) × (key + nhảy + doc)    chi phí ≈ (TẤT CẢ document) × (doc)

[hình dung] Đây là mô hình đơn giản hoá, không phải công thức của MongoDB, nhưng đúng xu hướng: số key khớp nhỏ thì vế trái thắng áp đảo. Khi số key khớp tiến gần tổng số document, vế trái đọc chừng ấy document như COLLSCAN, cộng thêm key và các cú nhảy. Lúc đó index thua.

Khi index không giúp, thậm chí làm chậm

Thí nghiệm 1: index trên status

Tạo index trên status, rồi so sánh plan mặc định (planner chọn) với COLLSCAN ép bằng hint({ $natural: 1 }). $natural nghĩa là "đọc theo thứ tự tự nhiên của collection", tức COLLSCAN.

db.orders.createIndex({ status: 1 });

const A = () => db.orders.find(q);                         // planner tự chọn
const B = () => db.orders.find(q).hint({ $natural: 1 });   // ép COLLSCAN
// mỗi vòng: explain("executionStats") A rồi B, xen kẽ 9 lần, lấy median executionTimeMillis

Plan của A trong cả ba trường hợp là FETCH ← IXSCAN status_1, keys = docs = nReturned (90%: 900.569 key, 900.568 document). Ba lượt, mỗi lượt median của 9 lần:

QueryKhớpIXSCAN status_1 (3 lượt)COLLSCAN (3 lượt)IXSCAN / COLLSCAN
status: "pending"10%55 / 60 / 65 ms209 / 226 / 249 ms0,26 – 0,27
status: "completed"70%351 / 339 / 383 ms268 / 264 / 267 ms1,28 – 1,43
status: { $in: [completed, cancelled, refunded] }90%452 / 573 / 532 ms249 / 317 / 296 ms1,80 – 1,82

Thời gian tuyệt đối nhảy giữa các lượt, tỉ lệ thì ổn định: ở 10% index nhanh hơn khoảng 4 lần, ở 70% chậm hơn 30–40%, ở 90% chậm hơn khoảng 1,8 lần. Ở 90%, IXSCAN đọc gần chừng ấy document như COLLSCAN, cộng thêm 900.569 key và một cú nhảy cho mỗi RecordId.

Chi tiết đáng lo hơn con số: [quan sát] trong cả ba trường hợp, explain không có plan bị loại nào (rejectedPlans rỗng). COLLSCAN không hề được cân nhắc, kể cả khi nó nhanh hơn 1,8 lần. Bạn tạo status_1 cho query "đơn pending" và nó giúp thật, nhưng từ đó mọi query lọc theo status đều đi qua index, kể cả query khớp 90% collection. Vì sao planner làm vậy là chủ đề của bài Query Planner & Plan Cache.

Thí nghiệm 2: dải selectivity trên createdAt

status chỉ có bốn giá trị nên chỉ thử được vài mức. createdAt thì cho phép quét mọi mức: "đơn trong N ngày gần nhất", N từ 0,4 ngày (0,1%) tới 182,5 ngày (50%).

db.orders.createIndex({ createdAt: 1 });
db.orders.find({ createdAt: { $gte: new Date(END - days * DAY) } })   // so với .hint({ $natural: 1 })

Hai lượt, mỗi ô là median của 7 lần chạy xen kẽ:

Khoảng thời gianKhớpKey = doc examinedIXSCAN (2 lượt)COLLSCAN (2 lượt)Tỉ lệ (2 lượt)
0,4 ngày0,1%9702 / 2 ms289 / 284 ms0,01 / 0,01
3,6 ngày1%10.01115 / 16 ms318 / 264 ms0,05 / 0,06
18,3 ngày5%50.12299 / 66 ms301 / 254 ms0,33 / 0,26
36,5 ngày10%100.176237 / 164 ms339 / 298 ms0,70 / 0,55
73 ngày20%199.736357 / 299 ms383 / 270 ms0,93 / 1,11
109,5 ngày30%299.675590 / 448 ms274 / 338 ms2,15 / 1,33
182,5 ngày50%499.901528 / 756 ms253 / 291 ms2,09 / 2,60
IXSCAN / COLLSCAN
 2.5 ┤                                         ●
 2.0 ┤                              ●
 1.5 ┤                                              ← index chậm hơn
 1.0 ┼ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ● ─ ─ ─ ─ ─ ─ ─ ─ ─   hoà vốn
 0.5 ┤                    ●                          ← index nhanh hơn
 0.0 ┤ ●  ●     ●
     └──┬──┬─────┬─────────┬──────┬──────┬──────────
       0.1 1     5        10     20     30     50   % collection khớp
     (giá trị trung bình của 2 lượt, minh hoạ hình dạng đường cong)

[quan sát] Trên lab này, dữ liệu nằm sẵn trong cache: dưới 1% index thắng áp đảo (2 ms so với gần 300 ms), ở 10% chỉ còn thắng 1,5–2 lần, hoà vốn quanh 20%, từ 30% trở lên index chậm hơn 1,3–2,6 lần.

Con số 20% không phải quy tắc: nó phụ thuộc kích thước document, cache, ổ đĩa, CPU. Tài liệu MongoDB cũng chỉ nói chung rằng khi phải đọc một lượng tương đối lớn document, một số query có thể nhanh hơn khi không dùng index. Điều chắc chắn là hình dạng đường cong: lợi ích của index giảm rất nhanh khi selectivity giảm.

Vì sao ở cùng mức 10%, status có lợi hơn createdAt

So hai bảng ở mức 10%: status: "pending" cho tỉ lệ khoảng 0,26, còn createdAt 36,5 ngày cho tỉ lệ 0,55–0,70. Cùng khoảng 100.000 document, sao lại khác nhau?

Nhớ lại phần "hai thứ tự": [quan sát] trong cùng một giá trị key, index xếp các mục theo RecordId tăng dần. Với status: "pending", 99.432 mục đều có cùng key, nên FETCH đi theo RecordId tăng dần, tức gần như theo đúng thứ tự cất trên "kệ". Với createdAt, key là thời điểm ngẫu nhiên không liên quan đến thứ tự insert, nên FETCH nhảy lung tung khắp collection.

FETCH theo status_1 (cùng key)        FETCH theo createdAt_1 (key ngẫu nhiên so với thứ tự insert)
(RecordId minh hoạ)
rid 3 → 9 → 17 → 24 → …               rid 512003 → 7418 → 883920 → 41 → …
đi dọc kệ, bỏ qua vài cuốn            chạy qua lại khắp thư viện

Đây là cách giải thích hợp lý cho khác biệt đo được, không phải cơ chế tôi đã xác minh bên trong WiredTiger. Khi dữ liệu không nằm trong cache, mỗi cú nhảy có thể là một lần đọc đĩa ngẫu nhiên, và khoảng cách còn lớn hơn (bài Cache, Eviction & Working Set).

Vậy làm gì với field có selectivity thấp

  • Đừng index riêng một field ít giá trị như status, trừ khi query luôn hỏi giá trị hiếm (như "pending").
  • Ghép nó với field kén chọn hơn: { tenantId, status, createdAt } biến "pending" từ 10% collection thành vài chục document của một tenant (bài Compound Indexes & ESR).
  • Chỉ index phần hiếm bằng partial index (bài Specialized Indexes).
  • hint({ $natural: 1 }) ép được COLLSCAN nhưng gắn cứng plan vào code: dùng để đo và chẩn đoán.

Tạo index tốn gì: thời gian build

Đo

Build một index trên 1 triệu document, xoá rồi tạo lại 3 lần mỗi index:

const s = Date.now(); db.orders.createIndex({ tenantId: 1 }); print(Date.now() - s);

Lượt đầu, khi collection chỉ có _id_:

{"tenantId":1}  build ms [693,575,573]  median 575
{"status":1}    build ms [474,477,468]  median 474
{"createdAt":1} build ms [571,653,697]  median 653

Một lượt sau, khi đã có thêm 4 index khác và máy host bận hơn, median của năm index (tenantId, status, createdAt, userId, total) là 766–920 ms. Tức khoảng 0,5–0,9 giây cho mỗi index trên 1 triệu document 215 MB, dữ liệu đã nằm trong cache.

Log của mongod (method: "hybrid") chia build createdAt_1 thành các pha:

createIndex
   │
   ├── 1. Quét toàn bộ collection, sinh (key, RecordId) cho mỗi document   253 ms (log: collection scan done)
   │      ghi vào bộ sắp xếp ngoài (external sorter), tràn ra đĩa nếu vượt giới hạn RAM
   ├── 2. Sắp xếp các key và nạp hàng loạt vào B-tree mới                    ~433 ms
   └── 3. Áp các thay đổi xảy ra trong lúc build, rồi commit index

Nghĩa là build index = một lần COLLSCAN + một lần sort toàn bộ key, và thời gian tăng theo kích thước collection. Trên collection hàng trăm triệu document, hãy đo trên bản sao trước khi đoán.

Build nhiều index trong một lệnh

Một chi tiết hữu ích: tạo 5 index bằng 5 lệnh createIndex riêng nghĩa là quét collection 5 lần. Gom vào một lệnh createIndexes:

db.orders.createIndexes([{ tenantId: 1 }, { status: 1 }, { createdAt: 1 }, { userId: 1 }, { total: 1 }]);
5 lệnh createIndex riêng       [4199,3638,4468] ms   median 4199
1 lệnh createIndexes với 5     [2919,2643,3730] ms   median 2919   (log: numSpecs: 5, một lần collection scan)

[quan sát] Một lần quét sinh key cho cả 5 index, tổng thời gian giảm khoảng 30%. [tài liệu] Đổi lại, maxIndexBuildMemoryUsageMegabytes (mặc định 200 MB cho mỗi lệnh createIndexes trên bản tự quản lý) được chia đều cho các index trong lệnh, nên mỗi index có ít RAM hơn để sort và dễ tràn ra đĩa hơn.

Build có chặn ứng dụng không

[tài liệu] Từ MongoDB 4.2, build index dùng một quy trình tối ưu (log ghi method: "hybrid"):

Bắt đầu   : khoá X (exclusive) trên collection   → chặn cả đọc lẫn ghi, rất ngắn
Trong lúc : hạ xuống IX, định kỳ nhường           → đọc ghi xen kẽ bình thường
Trước khi commit : nâng lên S                     → chặn ghi
Kết thúc  : nâng lên X                            → chặn cả đọc lẫn ghi, rất ngắn

Các điểm vận hành mà tài liệu nêu: build trên collection bị ghi nhiều làm chậm việc ghi và kéo dài build; tuỳ chọn background cũ bị bỏ qua; mặc định tối đa 3 build đồng thời (maxNumActiveUserIndexBuilds); từ 7.1, build tự dừng khi đĩa trống thấp hơn indexBuildMinAvailableDiskSpaceMB. Trên replica set, index được build đồng thời trên mọi member mang dữ liệu và chỉ commit khi đủ commit quorum (mặc định "votingMembers"), nên một member có quyền bầu mất liên lạc có thể làm build treo (bài Replica Set & Oplog).

Tóm lại: build index trên production không miễn phí và không tức thời, dù nó không chặn ứng dụng suốt quá trình như cách làm cũ.

Cột mốc: Bạn đã tính được selectivity của một field, biết khi nào index làm query chậm hơn COLLSCAN, và nêu vì sao build index tốn thời gian. Tiếp theo: Cái giá khi ghi và khi nằm yên.

Hỏi & đáp

Collection có index status_1. Query find({ status: "completed" }) khớp 70% collection. Lab cho thấy gì?

  1. IXSCAN nhanh hơn COLLSCAN khoảng 4 lần, như với "pending"

    Hệ số 4 lần là của status: "pending" (10%). Ở 70%, IXSCAN đọc gần chừng ấy document như COLLSCAN, cộng thêm key và một cú nhảy cho mỗi RecordId. Xem mục "Thí nghiệm 1: index trên status".

  2. Planner thử cả hai plan và chọn COLLSCAN vì nó nhanh hơn

    rejectedPlans rỗng: COLLSCAN không hề được cân nhắc. Lý do nằm ở bài Query Planner & Plan Cache. Xem mục "Thí nghiệm 1: index trên status".

  3. Planner chọn IXSCAN, và nó chậm hơn COLLSCAN ép bằng $natural 30–40%

    Lab: 339–383 ms qua IXSCAN so với 264–268 ms COLLSCAN, tỉ lệ 1,28–1,43. Explain vẫn ghi IXSCAN và không có plan bị loại nào. Xem mục "Thí nghiệm 1: index trên status".

  4. Hai cách gần như bằng nhau, vì index chỉ có bốn giá trị

    Số giá trị ít làm index nhỏ (4,76 MiB), không làm hai plan bằng nhau. Ở 70% index thua rõ, ở 90% thua khoảng 1,8 lần. Xem mục "Thí nghiệm 1: index trên status".

Ở cùng mức khoảng 10% collection, IXSCAN trên status: "pending" có tỉ lệ so với COLLSCAN khoảng 0,26, còn createdAt 36,5 ngày thì 0,55–0,70. Bài giải thích khác biệt này thế nào?

  1. Index status_1 nhỏ hơn createdAt_1 nên đọc key nhanh hơn

    status_1 nhỏ hơn thật (4,76 so với 11,25 MiB), nhưng phần tốn kém là FETCH khoảng 100.000 document, không phải đọc key. Xem mục "Vì sao ở cùng mức 10%, status có lợi hơn createdAt".

  2. Cùng key thì index xếp theo RecordId, FETCH đi gần thứ tự cất

    99.432 document "pending" có cùng key, nên FETCH đi theo RecordId tăng dần, gần như dọc kệ. Với createdAt, key không liên quan thứ tự insert, FETCH nhảy khắp collection. Bài ghi rõ đây là cách giải thích hợp lý, không phải cơ chế đã xác minh. Xem mục "Vì sao ở cùng mức 10%, status có lợi hơn createdAt".

  3. Query createdAt là range, mà range không dùng index hiệu quả

    Range dùng index tốt: ở 0,1% createdAt thắng áp đảo (2 ms so với gần 300 ms). Khác biệt ở mức 10% nằm ở thứ tự FETCH. Xem mục "Thí nghiệm 2: dải selectivity trên createdAt".

  4. Query createdAt đọc nhiều document hơn vì khớp nhiều hơn

    Hai query khớp gần bằng nhau: 99.432 so với 100.176 document. Số document không giải thích được chênh lệch gấp đôi. Xem mục "Vì sao ở cùng mức 10%, status có lợi hơn createdAt".

Cần tạo 5 index mới trên 1 triệu document. Gom vào một lệnh createIndexes thay vì 5 lệnh createIndex riêng thì được gì và mất gì?

  1. Không khác gì: mỗi index vẫn cần quét collection riêng

    Log ghi numSpecs: 5, một lần collection scan: một lần quét sinh key cho cả 5 index. Lab: median 2.919 ms so với 4.199 ms. Xem mục "Build nhiều index trong một lệnh".

  2. Nhanh hơn, và mỗi index có 200 MB RAM để sort

    200 MB của maxIndexBuildMemoryUsageMegabytes là cho mỗi lệnh createIndexes, được chia đều cho các index trong lệnh, không phải cho mỗi index. Xem mục "Build nhiều index trong một lệnh".

  3. Một lần quét nên nhanh hơn, nhưng chia RAM sort cho 5

    Lab: 2.919 ms so với 4.199 ms. Đổi lại, 200 MB mặc định chia đều cho các index trong lệnh, nên mỗi index dễ tràn ra đĩa hơn khi sort. Xem mục "Build nhiều index trong một lệnh".

  4. Chậm hơn, vì gom lệnh thì khoá collection suốt quá trình

    Build hybrid (từ 4.2) chỉ khoá X ngắn ở đầu và cuối, giữa chừng đọc ghi xen kẽ bình thường, dù build một hay nhiều index. Xem mục "Build có chặn ứng dụng không".