Index Fundamentals (P3/3): Cái giá khi ghi và khi nằm yên
Ở phần trước: index có selectivity thấp như status thường không đáng giá. Gom nhiều index vào một lệnh createIndexes giảm khoảng 30% thời gian build so với tạo lần lượt.
- Cần đọc trước: Selectivity, khi index không giúp, cái giá build
- Dẫn tới: Compound Indexes, bài tiếp theo. Phần này là phần cuối của bài Index Fundamentals.
Mỗi index tốn gì khi ghi: write amplification
Mental model
Quay lại thư viện. Nhập một cuốn sách mới: đặt nó lên kệ (một lần ghi), rồi viết một phiếu cho mỗi tủ mục lục và cắm phiếu vào đúng chỗ theo thứ tự trong tủ đó. Ba tủ thì ba phiếu. Năm tủ thì năm phiếu. Đó là write amplification: một lệnh ghi logic biến thành nhiều lần ghi vật lý.
Đếm từng key được ghi
Profiler của MongoDB ghi lại số index key mà mỗi lệnh ghi phải chèn (keysInserted) và xoá (keysDeleted). Collection lúc này có _id_ và 5 index phụ (tenantId, status, createdAt, userId, total):
db.setProfilingLevel(2);
db.orders.insertOne({ _id: "probe1", tenantId: "t0042", userId: "u0001", status: "pending",
createdAt: new Date(), total: 500000, items: [ { sku: "SKU-1", qty: 1, price: 500000 } ] });
db.orders.updateOne({ _id: "probe1" }, { $set: { status: "completed" } });
db.orders.updateOne({ _id: "probe1" }, { $set: { "items.0.qty": 2 } });
db.orders.updateOne({ _id: "probe1" }, { $set: { status: "refunded", total: 100 } });
db.orders.deleteOne({ _id: "probe1" });insert keysInserted 6
update {"$set":{"status":"completed"}} keysInserted 1 keysDeleted 1
update {"$set":{"items.0.qty":2}} keysInserted 0 keysDeleted 0
update {"$set":{"status":"refunded",
"total":100}} keysInserted 2 keysDeleted 2
remove {"_id":"probe1"} keysDeleted 6Insert và delete chạm mỗi index (6 key, kể cả _id_). Update một field có index là xoá key cũ, chèn key mới: index đã sắp xếp, nên đổi giá trị nghĩa là dời mục sang chỗ khác. Update field không có index: 0 key. [quan sát] Với update, chi phí index tỉ lệ với số field có index bị thay đổi, không phải tổng số index; thêm một lý do để không index field hay đổi mà ít khi được tìm.
Ở mức storage engine, đếm số lần WiredTiger chèn một bản ghi (cursor insert calls trong serverStatus) khi insert 200.000 document:
extra=0 WT cursor insert calls: 400000 (2.00 per doc)
extra=1 WT cursor insert calls: 600066 (3.00 per doc)
extra=3 WT cursor insert calls: 1000000 (5.00 per doc)
extra=5 WT cursor insert calls: 1400000 (7.00 per doc)extra là số index ngoài _id_. Mỗi document = 1 lần chèn vào collection + 1 vào _id_ + 1 cho mỗi index phụ. [quan sát] 2, 3, 5, 7: đúng một lần ghi thêm cho mỗi index, không hơn không kém. (66 lần dư ở extra=1 là ghi nội bộ của server không liên quan tới thí nghiệm.)
Throughput insert với 0, 1, 3, 5 index phụ
200.000 document cùng schema, sinh trước trong shell, ghi bằng 20 lần insertMany 10.000 document (ordered: false). Index phụ: 1 = tenantId; 3 = thêm status, createdAt; 5 = thêm userId, total. Collection được drop và tạo lại mỗi lần, bốn cấu hình chạy xen kẽ. Ba đợt đo (3, 5 và 7 lần lặp mỗi cấu hình), median document/giây:
| Index phụ | Đợt 1 | Đợt 2 | Đợt 3 | So với 0 index (đợt 1 / đợt 3) |
|---|---|---|---|---|
| 0 | 135.777 | 110.375 | 148.478 | 100% |
| 1 | 111.049 | 95.648 | 112.613 | 82% / 76% |
| 3 | 87.032 | 65.232 | 90.253 | 64% / 61% |
| 5 | 67.431 | 62.735 | 73.233 | 50% / 49% |
Từng lần chạy dao động mạnh (đợt 3, extra=5 đi từ 22.039 tới 104.330 doc/s) vì các lab khác chạy cùng máy, nên tôi chỉ tin median và xu hướng.
[quan sát] Index phụ đầu tiên làm throughput insert giảm khoảng 20%, mỗi index sau lấy thêm khoảng 7–10 điểm phần trăm. Năm index phụ thì tốc độ insert còn khoảng một nửa. [tài liệu] Tài liệu MongoDB nói đúng chiều này: index có tác động tiêu cực tới thao tác ghi, vì mỗi insert phải cập nhật mọi index.
Thực tế thường nặng hơn lab, vì ba lẽ: dữ liệu ở đây nằm gọn trong cache (index lớn hơn cache thì chèn một key có thể phải nạp page từ đĩa); index trên giá trị ngẫu nhiên (như createdAt ở đây, hay UUID) chèn vào khắp cây, chạm nhiều page hơn giá trị tăng dần (bài BSON & ObjectId); và trên replica set, mỗi secondary cũng ghi đủ ngần ấy key khi áp oplog.
Mỗi index tốn gì khi nằm yên: đĩa và RAM
Trên đĩa
collStats đã deprecated từ 6.2. Cách được khuyên dùng là stage $collStats:
db.orders.aggregate([{ $collStats: { storageStats: { scale: 1048576 } } }])Với 1 triệu document và 6 index, sau fsync (số MiB, $collStats làm tròn xuống, cột MiB lấy từ byte chính xác):
| Thành phần | Trên đĩa |
|---|---|
Collection (storageSize, nén snappy) | 75,70 MiB |
_id_ | 10,44 MiB |
tenantId_1 | 7,82 MiB |
status_1 | 4,76 MiB |
createdAt_1 | 11,25 MiB |
userId_1 | 6,27 MiB |
total_1 | 6,00 MiB |
Tổng index (totalIndexSize) | 46,55 MiB |
Năm index phụ cộng _id_ đã bằng khoảng 61% dung lượng của chính collection trên đĩa. Collection 1 triệu đơn hàng không có gì đặc biệt. Với collection có document nhỏ và nhiều index, tổng index vượt cả dữ liệu là chuyện thường gặp.
[tài liệu] Mặc định WiredTiger nén collection bằng block compression (snappy) và nén index bằng prefix compression: các key liền nhau có chung phần đầu thì phần chung chỉ lưu một lần. [quan sát] Trong creationString của index ở lab, block_compressor= để trống, còn của collection là block_compressor=snappy: index không được nén khối, chỉ có prefix compression. Đó là lý do status_1 (bốn giá trị lặp lại 1 triệu lần) nhỏ nhất, còn createdAt_1 (gần như mọi giá trị khác nhau) lớn nhất.
Trong RAM
Index chỉ nhanh khi những page cần dùng nằm trong WiredTiger cache. Đo lượng cache mỗi index chiếm (indexDetails.<tên>.cache["bytes currently in the cache"]), lần đầu ngay sau khi khởi động lại mongod (cold cache, chưa có page nào), lần sau khi đã đọc hết từng index một lần (một count có hint, plan COUNT_SCAN, chỉ đọc index):
| Index | Trên đĩa (MB) | Cold cache (MB) | Sau khi đọc hết (MB) |
|---|---|---|---|
_id_ | 10,44 | 0,00 | 18,33 |
tenantId_1 | 7,82 | 0,04 | 15,91 |
status_1 | 4,76 | 0,03 | 12,90 |
createdAt_1 | 11,25 | 0,06 | 19,28 |
userId_1 | 6,27 | 0,03 | 14,38 |
total_1 | 6,00 | 0,03 | 14,13 |
[quan sát] Index chưa được dùng thì gần như không chiếm cache; ngay sau khi build cũng vậy (userId_1, total_1 chỉ 0,03 MB): index nằm trên đĩa, được nạp lên khi có query cần. Khi đã đọc hết, tổng index trong cache khoảng 95 MB, gấp đôi 46,55 MB trên đĩa. [tài liệu] Index trong cache dùng cách biểu diễn khác với trên đĩa, dù vẫn hưởng prefix compression. Hệ số 1,7–2,7 lần là [quan sát] trên dataset này, không phải hằng số.
Cache 1 GB của lab phải chia cho cả dữ liệu lẫn index. Mỗi index thêm vào là thêm dữ liệu tranh chỗ trong cache; khi working set vượt cache, cả hệ thống chậm đi, không chỉ query dùng index đó (bài Cache, Eviction & Working Set).
Cây chi phí của một index
Gom lại mọi thứ đã đo:
Thêm một index
│
├── ✓ Query có selectivity cao: đọc ít document hơn rất nhiều
│ (lab: 1.000.000 → 2.003 document, ~165 ms → ~1–2 ms)
├── ✓ Đảm bảo ràng buộc (unique: bài Specialized Indexes), sắp xếp theo thứ tự index (bài Compound Indexes)
│
├── ✗ Build: một lần quét collection + sort toàn bộ key
│ (lab: ~0,5–0,9 s mỗi index / 1 triệu document)
├── ✗ Mỗi insert/delete: thêm 1 key cho mỗi index
│ (lab: 5 index phụ → throughput insert còn ~50%)
├── ✗ Mỗi update field có index: xoá key cũ + chèn key mới
├── ✗ Đĩa: lab 46,55 MiB cho 6 index, bằng 61% collection
├── ✗ RAM: lab ~95 MB trong cache khi được dùng hết
│
└── ⚠ Query có selectivity thấp: index có thể CHẬM hơn COLLSCAN
(lab: 90% collection → chậm hơn ~1,8 lần, và planner vẫn chọn nó)Câu hỏi đúng khi thêm index không phải "query này có nhanh hơn không", mà là: query này chạy bao nhiêu lần, selectivity bao nhiêu, và collection bị ghi bao nhiêu lần mỗi giây? Một index cho báo cáo chạy mỗi tháng một lần, trên collection nhận 5.000 insert/giây, có thể là quyết định tồi dù nó làm báo cáo nhanh gấp trăm lần.
So với PostgreSQL
Index của hai hệ thống giống nhau ở gốc, khác nhau ở những chỗ mà thí nghiệm bài này vừa chạm tới.
| Khía cạnh | MongoDB | PostgreSQL |
|---|---|---|
| Cấu trúc mặc định | B-tree | B-tree |
| Mục index trỏ tới | RecordId của document | vị trí của dòng trong heap (TID) |
| Index tạo sẵn | _id_, unique, không xoá được | Không có index ngầm định trên mọi bảng. Index được tạo kèm PRIMARY KEY / UNIQUE |
| Đọc qua index | IXSCAN → FETCH, lấy document theo thứ tự key | Index Scan, hoặc Bitmap Index Scan → Bitmap Heap Scan |
| Build trên bảng đang chạy | hybrid build, khoá ngắn ở đầu và cuối | CREATE INDEX chặn ghi suốt quá trình, CREATE INDEX CONCURRENTLY thì không chặn nhưng tốn hai lần quét |
Bitmap Heap Scan giải quyết đúng vấn đề của thí nghiệm createdAt. [tài liệu PostgreSQL] PostgreSQL dùng index để lấy danh sách vị trí các dòng khớp, sắp xếp theo thứ tự vật lý rồi mới đọc bảng, để giảm chi phí đọc rời rạc. Nói theo thư viện: gom hết phiếu, xếp theo số kệ, rồi đi một vòng. Nhờ vậy ở selectivity trung bình, PostgreSQL có một phương án ở giữa "index scan" và "đọc hết bảng". [quan sát] Trong các explain của bài này, MongoDB không dùng stage nào tương tự: FETCH lấy document theo đúng thứ tự key.
Build index: [tài liệu PostgreSQL] CREATE INDEX thường chặn insert/update/delete tới khi xong; CREATE INDEX CONCURRENTLY không chặn nhưng quét bảng hai lần và lâu hơn đáng kể. MongoDB mặc định chọn "không chặn ghi suốt quá trình", đổi lấy khoá ngắn ở đầu và cuối.
Còn bài học "index không phải lúc nào cũng thắng" thì chung cho mọi database lưu dữ liệu tách khỏi index: tài liệu PostgreSQL cũng nói đọc tuần tự rồi sort thường thắng index scan khi cần nhiều dòng.
Những lỗi thường gặp
- Thêm index cho mọi field "phòng khi cần", và quên xoá index không còn ai dùng. Mỗi index phải có một query thật cần nó; index thừa vẫn bị trả giá ở mỗi lệnh ghi, trên mọi member.
- Index một field ít giá trị (
status,type,isDeleted) rồi tin mọi query trên field đó đều nhanh. - Thấy
IXSCANtrong explain là yên tâm. Hãy nhìn tỉ lệ keys : docs : nReturned. 900.569 : 900.568 : 900.568 là IXSCAN nhưng tệ hơn không có index. - Đo query ngay sau khi tạo index, hoặc chỉ đo một lần. Lần đầu là cold cache (25 ms so với 1 ms).
- Tạo index trên production giờ cao điểm mà không đo trước trên bản sao, hoặc bằng nhiều lệnh riêng thay vì một
createIndexes.
Cột mốc: Bạn đã đếm được số index key một lệnh ghi phải chèn, nêu cái giá của mỗi index về đĩa và RAM, và nói được cây chi phí của một index. Bài Index Fundamentals khép lại ở đây.
Hỏi & đáp
Collection có 8 index, không index nào chứa field note. Lệnh updateOne(..., { $set: { note: "..." } }) phải ghi bao nhiêu index key?
Collection events nhận 20.000 insert/giây và đã có 9 index. Team muốn thêm index thứ 10 cho một màn hình admin dùng vài lần mỗi ngày. Phản ứng hợp lý nhất?
Nếu phải giải thích bài này mà không dùng thuật ngữ MongoDB: thư viện xếp sách theo ngày nhập, nên muốn tìm theo tác giả thì phải có tủ phiếu xếp theo tác giả. Tủ phiếu giúp tìm vài cuốn trong một triệu cuốn rất nhanh. Nhưng mỗi cuốn mới phải viết thêm phiếu cho mọi tủ, tủ chiếm chỗ trong phòng, và nếu bạn cần gần hết sách thì cứ đi dọc kệ còn nhanh hơn cầm phiếu chạy khắp nơi.
Bài tiếp theo
Index tenantId_1 đưa query của bài CRUD & Query Model từ 1.000.000 xuống 2.003 document. Nhưng query chỉ cần 21. 1.982 document vẫn bị lấy ra rồi vứt, vì index không biết gì về status và createdAt. Query vẫn còn một stage SORT trong bộ nhớ.
Bài Compound Indexes & ESR đi tiếp từ đúng chỗ này: index ghép nhiều field { tenantId, status, createdAt }, vì sao thứ tự field trong index quan trọng (quy tắc ESR và những ngoại lệ của nó), làm sao để index trả luôn kết quả đã sắp xếp, và covered query. Các loại index đặc biệt (multikey cho mảng items, unique, partial chỉ index đơn "pending", sparse, TTL, wildcard) thuộc về bài Specialized Indexes. Còn câu hỏi "vì sao planner chọn index chậm hơn COLLSCAN" chờ ở bài Query Planner & Plan Cache.