Index Fundamentals (P2/3): Selectivity, khi index không giúp, cái giá build
Ở 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.
- Cần đọc trước: Index là gì, IXSCAN và
_id - Dẫn tới: Cái giá khi ghi và khi nằm yên, phần tiếp theo.
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ện | Số document khớp | Tỉ lệ | Selectivity |
|---|---|---|---|
_id: X | 1 | 0,0001% | rất cao |
tenantId: "t0042" | 2.003 | 0,2% | cao |
createdAt trong 3,6 ngày gần nhất | 10.011 | 1% | cao |
status: "pending" | 99.432 | 10% | trung bình |
status: "completed" | 699.990 | 70% | thấp |
status thuộc completed, cancelled, refunded | 900.568 | 90% | 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 executionTimeMillisPlan 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:
| Query | Khớp | IXSCAN status_1 (3 lượt) | COLLSCAN (3 lượt) | IXSCAN / COLLSCAN |
|---|---|---|---|---|
status: "pending" | 10% | 55 / 60 / 65 ms | 209 / 226 / 249 ms | 0,26 – 0,27 |
status: "completed" | 70% | 351 / 339 / 383 ms | 268 / 264 / 267 ms | 1,28 – 1,43 |
status: { $in: [completed, cancelled, refunded] } | 90% | 452 / 573 / 532 ms | 249 / 317 / 296 ms | 1,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 gian | Khớp | Key = doc examined | IXSCAN (2 lượt) | COLLSCAN (2 lượt) | Tỉ lệ (2 lượt) |
|---|---|---|---|---|---|
| 0,4 ngày | 0,1% | 970 | 2 / 2 ms | 289 / 284 ms | 0,01 / 0,01 |
| 3,6 ngày | 1% | 10.011 | 15 / 16 ms | 318 / 264 ms | 0,05 / 0,06 |
| 18,3 ngày | 5% | 50.122 | 99 / 66 ms | 301 / 254 ms | 0,33 / 0,26 |
| 36,5 ngày | 10% | 100.176 | 237 / 164 ms | 339 / 298 ms | 0,70 / 0,55 |
| 73 ngày | 20% | 199.736 | 357 / 299 ms | 383 / 270 ms | 0,93 / 1,11 |
| 109,5 ngày | 30% | 299.675 | 590 / 448 ms | 274 / 338 ms | 2,15 / 1,33 |
| 182,5 ngày | 50% | 499.901 | 528 / 756 ms | 253 / 291 ms | 2,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 653Mộ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 indexNghĩ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ắnCá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ì?
Ở 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?
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ì?