Index Fundamentals (P3/3): Cái giá khi ghi và khi nằm yên

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

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

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 6

Insert 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 3So với 0 index (đợt 1 / đợt 3)
0135.777110.375148.478100%
1111.04995.648112.61382% / 76%
387.03265.23290.25364% / 61%
567.43162.73573.23350% / 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ầnTrên đĩa
Collection (storageSize, nén snappy)75,70 MiB
_id_10,44 MiB
tenantId_17,82 MiB
status_14,76 MiB
createdAt_111,25 MiB
userId_16,27 MiB
total_16,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):

IndexTrên đĩa (MB)Cold cache (MB)Sau khi đọc hết (MB)
_id_10,440,0018,33
tenantId_17,820,0415,91
status_14,760,0312,90
createdAt_111,250,0619,28
userId_16,270,0314,38
total_16,000,0314,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ạnhMongoDBPostgreSQL
Cấu trúc mặc địnhB-treeB-tree
Mục index trỏ tớiRecordId của documentvị trí của dòng trong heap (TID)
Index tạo sẵn_id_, unique, không xoá đượcKhông có index ngầm định trên mọi bảng. Index được tạo kèm PRIMARY KEY / UNIQUE
Đọc qua indexIXSCAN → FETCH, lấy document theo thứ tự keyIndex Scan, hoặc Bitmap Index Scan → Bitmap Heap Scan
Build trên bảng đang chạyhybrid build, khoá ngắn ở đầu và cuốiCREATE 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 IXSCAN trong 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?

  1. 0 key: không có index nào chứa note

    Update chỉ đụng index chứa field bị đổi. Profiler trong lab: $set trên items.0.qty (không có index) cho keysInserted 0, keysDeleted 0. Xem mục "Đếm từng key được ghi".

  2. 8 key, vì mỗi lệnh ghi chạm mọi index

    Đúng với insert và delete (lab: insert chèn 6 key, delete xoá 6 key với 6 index), không đúng với update. Xem mục "Đếm từng key được ghi".

  3. 1 key, vì _id_ luôn được cập nhật

    _id không đổi nên key _id_ cũng không đổi. Lab cho thấy update field không có index ghi 0 key. Xem mục "Đếm từng key được ghi".

  4. 16 key: xoá 8 key cũ và chèn 8 key mới

    Cặp xoá + chèn chỉ xảy ra với index chứa field bị đổi (lab: đổi status và total cho 2 chèn, 2 xoá). Không index nào chứa note. Xem mục "Đếm từng key được ghi".

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?

  1. Thêm luôn: index chưa dùng thì gần như không chiếm cache

    Lab cho thấy index chưa dùng chiếm rất ít cache (0,03 MB), nhưng chi phí chính ở đây là ghi: mỗi insert thêm một lần ghi cho mỗi index, trên mọi member. Xem mục "Mỗi index tốn gì khi ghi: write amplification".

  2. Thêm luôn, với background: true để không ảnh hưởng ghi

    Tuỳ chọn background cũ bị bỏ qua. Và dù build có chặn hay không, index vẫn tốn một lần ghi ở mỗi insert sau đó. Xem phần build có chặn ứng dụng không ở Selectivity, khi index không giúp, cái giá build.

  3. Từ chối: collection đã có 9 index thì không nên thêm nữa

    Đếm số index chưa trả lời được câu hỏi. Quyết định phụ thuộc vào selectivity, tần suất query, index sẵn có và dư địa ghi; có khi một index sẵn có đã phục vụ được phần lớn. Xem mục "Cây chi phí của một index".

  4. Hỏi lại trước: selectivity, thời gian chạy, index sẵn có, dư địa insert

    Câu hỏi đúng là query chạy bao nhiêu lần, selectivity bao nhiêu, collection bị ghi bao nhiêu lần mỗi giây. Lab: 5 index phụ đưa throughput insert còn khoảng 50%. Một index cho màn hình hiếm dùng trên collection ghi nhiều có thể là quyết định tồi. Xem mục "Cây chi phí của một index".

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.

Tài liệu tham khảo