Index Fundamentals: mục lục của MongoDB và cái giá của nó
Bài 06 kết thúc bằng một con số khó chịu. Để trả về 21 đơn hàng "đang chờ xử lý của tenant t0042 từ 01/09", MongoDB đọc 1.000.000 document. Query trả về 0, 21 hay 700.000 document thì cũng tốn khoảng 140–150 ms, vì cả ba đều làm cùng một việc: lật hết collection. Filter chặt hơn, projection, limit đều không chạm được tới con số đó.
Bài này trả lời câu hỏi bỏ ngỏ: làm sao để server biết trước dữ liệu nằm ở đâu. Câu trả lời là index. Nhưng "thêm index đi" mới chỉ là nửa câu chuyện. Nửa còn lại là index tốn gì: thời gian build, công ghi thêm ở mỗi lệnh insert/update, RAM và đĩa. Và có những query mà index làm cho chậm hơn, với số đo thật.
Bài này nằm ở đâu
Cần biết trước : bài 01 (đường đi của query, COLLSCAN, WiredTiger cache)
bài 06 (filter, explain("executionStats"), COLLSCAN 1 triệu document)
Giới thiệu : index là gì (B-tree, key đã sắp xếp trỏ về record), IXSCAN + FETCH,
selectivity, index _id, tạo index và chi phí build,
chi phí ghi / RAM / đĩa của mỗi index, khi nào index KHÔNG giúp
Dẫn tới : bài 08, Compound & Advanced IndexingMôi trường lab: MongoDB 8.3.11 chạy trong Docker (image
mongo:8, standalone), container riêng cho bài này, giới hạn 2 CPU, 3 GB RAM, WiredTiger cache 1 GB, máy host Apple M4. Databaselab06. Mọi con số đều đo thật, trừ khi được ghi là minh hoạ.
Trong lúc đo, các lab của những bài khác chạy song song trên cùng máy host, nên thời gian có nhiễu. Để giảm ảnh hưởng, mỗi phép so sánh chạy xen kẽ hai phương án trong cùng một vòng lặp, lặp nhiều lần, lấy median, và chạy nhiều lượt. Bạn sẽ thấy con số tuyệt đối dao động giữa các lượt, nhưng tỉ lệ giữa hai phương án thì ổn định. Những con số không phụ thuộc thời gian (số key, số document, số byte) thì không bị nhiễu.
Như các bài trước, bài dùng ba nhãn: [tài liệu] là hành vi được tài liệu chính thức mô tả, [quan sát] là điều đo được trong lab nhưng không phải cam kết của MongoDB, [hình dung] là mô hình đơn giản hoá để dễ nhớ.
Hình dung trước: tủ phiếu mục lục của thư viện
Bài 01 nói kho hồ sơ mặc định chỉ có một cuốn sổ mục lục, đánh theo mã hồ sơ. Giờ hãy đổi sang một hình ảnh cụ thể hơn: tủ phiếu mục lục của một thư viện kiểu cũ.
Thư viện có 1 triệu cuốn sách xếp trên kệ theo thứ tự nhập về. Cuốn nhập hôm qua đứng cạnh cuốn nhập hôm nay, chẳng liên quan gì về nội dung. Bạn muốn mọi cuốn của tác giả "Nguyễn Nhật Ánh".
Không có tủ phiếu: bạn đi dọc mọi kệ, rút từng cuốn ra xem tên tác giả. Một triệu lần rút sách để lấy vài chục cuốn.
Có tủ phiếu xếp theo tên tác giả: mỗi cuốn sách có một tấm phiếu nhỏ ghi Nguyễn Nhật Ánh → kệ 712, ngăn 3. Các phiếu được xếp theo thứ tự chữ cái. Bạn mở đúng ngăn kéo chữ "N", lật tới "Nguyễn Nhật Ánh", đọc liền một dải phiếu kề nhau, rồi cầm danh sách vị trí đi lấy sách.
Không có tủ phiếu Có tủ phiếu (theo tác giả)
───────────────── ──────────────────────────
đi dọc mọi kệ mở ngăn "N", lật tới đúng chỗ ← IXSCAN
rút từng cuốn ra xem đọc dải phiếu liền nhau
1.000.000 lần rút sách đi tới đúng những kệ được ghi ← FETCH
chỉ rút những cuốn cần
= COLLSCAN = IXSCAN + FETCHCùng hình ảnh này còn kể trước được ba chuyện mà phần sau sẽ đo:
- Tủ phiếu chỉ giúp câu hỏi khớp với cách nó được xếp. Tủ xếp theo tác giả không giúp gì cho câu hỏi "mọi cuốn bìa xanh".
- Mỗi cuốn sách mới phải viết thêm một phiếu cho mỗi tủ. Thư viện có ba tủ (tác giả, tên sách, chủ đề) thì nhập một cuốn là viết ba phiếu và cắm vào đúng chỗ trong ba tủ. Tủ phiếu chiếm chỗ trong phòng đọc.
- Nếu bạn cần 90% số sách, cầm từng phiếu rồi chạy tới từng kệ (mỗi phiếu một chỗ khác nhau) chậm hơn là cứ đi dọc kệ mà lấy.
[hình dung] Đây chỉ là cách hình dung. MongoDB không có "phiếu giấy" và "ngăn kéo". Index thật là một cấu trúc B-tree do storage engine WiredTiger quản lý, và "vị trí trên kệ" là một giá trị nội bộ gọi là RecordId. Phần dưới sẽ cho xem chúng trong lab.
Giải thích trong 30 giây
Index là một cấu trúc riêng, nằm cạnh collection, chứa giá trị của một (vài) field, đã được sắp xếp, và với mỗi giá trị là "địa chỉ" của document chứa nó. Vì đã sắp xếp, MongoDB nhảy thẳng tới đúng đoạn cần tìm (IXSCAN), rồi chỉ lấy ra những document được trỏ tới (FETCH). Đổi lại, mỗi lệnh ghi phải cập nhật thêm mọi index liên quan, và mỗi index chiếm đĩa, chiếm cache. Khi query khớp phần lớn collection, nhảy qua index rồi lấy từng document có thể chậm hơn đọc thẳng cả collection.
Setup và dataset
Bài dùng lại đúng dataset của bài 06: cùng generator, cùng seed, nên cùng dữ liệu (các con số đếm bên dưới trùng với bài 06).
MongoDB version : 8.3.11 (Docker image mongo:8, standalone)
Hardware : Apple M4 host; container giới hạn 2 CPU, 3 GB RAM
Configuration : WiredTiger cache 1 GB (--wiredTigerCacheSizeGB 1)
Dataset : lab06.orders, 1.000.000 document
Kích thước : 215 MB chưa nén (205 MiB, avgObjSize 215 byte), 75,7 MiB trên đĩa
Indexes ban đầu : chỉ có _id_{
tenantId: "t0042", // 500 tenant
userId: "u0162", // 200 user mỗi tenant
status: "completed", // 70% completed, 10% pending, 10% cancelled, 10% refunded
createdAt: ISODate("2026-05-02T16:46:02.534Z"), // rải đều 365 ngày trước 01/10/2026
total: 8280000,
items: [ { sku: "SKU-4780", qty: 4, price: 910000 }, ... ] // 1–3 món
}total docs : 1000000
tenant t0042 : 2003
t0042 + pending : 196
t0042 + pending + từ 01/09 : 21
status completed / pending / cancelled / refunded : 699990 / 99432 / 100641 / 99937Các lệnh trong bài chạy bằng docker exec -i mongo-lab-06 mongosh --quiet lab06 --eval "...". Query mẫu của cả bài là query cuối bài 06:
const Q = {
tenantId: "t0042",
status: "pending",
createdAt: { $gte: ISODate("2026-09-01T00:00:00Z") }
};
db.orders.find(Q).sort({ createdAt: -1 })Index thật sự là gì
Hai thứ tự khác nhau
Trước khi có index, hãy nhìn cách document được cất. Mỗi document trong collection có một RecordId, một số nguyên nội bộ mà WiredTiger dùng làm khoá để cất document. Với collection thường, nó tăng dần theo thứ tự insert. showRecordId() cho ta nhìn thấy nó:
db.orders.find({}, { _id: 0, tenantId: 1 }).showRecordId().limit(4){"tenantId":"t0312","$recordId":1}
{"tenantId":"t0025","$recordId":2}
{"tenantId":"t0013","$recordId":3}
{"tenantId":"t0151","$recordId":4}Đây là "kệ sách": document xếp theo thứ tự nhập, tenant lẫn lộn. Đơn của t0042 rải rác khắp 1 triệu vị trí. Tìm chúng mà chỉ có thứ tự này thì phải đọc hết.
Giờ tạo một index trên tenantId:
db.orders.createIndex({ tenantId: 1 })tenantId_11 nghĩa là sắp xếp tăng dần. Tên index mặc định ghép từ field và chiều: tenantId_1. Đọc thử index theo thứ tự của chính nó, ở chỗ giáp ranh giữa t0041 và t0042:
db.orders.find({ tenantId: { $gte: "t0041" } }, { _id: 0, tenantId: 1 })
.hint({ tenantId: 1 }).showRecordId().skip(1995).limit(6){"tenantId":"t0041","$recordId":998877}
{"tenantId":"t0041","$recordId":999063}
{"tenantId":"t0041","$recordId":999067}
{"tenantId":"t0042","$recordId":840}
{"tenantId":"t0042","$recordId":862}
{"tenantId":"t0042","$recordId":1085}Đây chính là tủ phiếu. [quan sát] Các mục được xếp theo tenantId, và trong cùng một tenantId thì theo RecordId tăng dần. Hết t0041 (RecordId gần 1 triệu) là tới t0042 (RecordId quay về 840). 2.003 đơn của t0042 nằm liền nhau trong index, dù trên "kệ" chúng rải từ vị trí 840 tới gần 1.000.000.
Collection (thứ tự RecordId) Index tenantId_1 (thứ tự key)
┌────────┬─────────┐ ┌─────────┬──────────┐
│ rid 1 │ t0312 … │ │ t0041 │ → 998877 │
│ rid 2 │ t0025 … │ │ t0041 │ → 999067 │
│ … │ │ ├─────────┼──────────┤
│ rid 840│ t0042 … │ ◄───────────────────│ t0042 │ → 840 │
│ rid 862│ t0042 … │ ◄───────────────────│ t0042 │ → 862 │
│ … │ │ (2.003 mục │ … │ │
│ rid … │ t0042 … │ ◄──── liền nhau)────│ t0042 │ → … │
│ … │ │ ├─────────┼──────────┤
└────────┴─────────┘ │ t0043 │ → … │
└─────────┴──────────┘[tài liệu] Tài liệu MongoDB mô tả index đúng như vậy: index lưu giá trị của một field hoặc một nhóm field, sắp xếp theo giá trị đó, và thứ tự này giúp tìm bằng nhau (equality), tìm theo khoảng (range), và trả kết quả đã sắp xếp. Index của MongoDB dùng cấu trúc B-tree.
Vì sao là B-tree
Một danh sách đã sắp xếp thì tìm bằng binary search được, nhưng chèn thêm một phần tử vào giữa danh sách 1 triệu phần tử thì phải dời cả nửa danh sách. B-tree giải quyết chuyện đó bằng cách chia danh sách thành nhiều page nhỏ, xếp thành cây:
[ root page ]
┌──── t0170 ──── t0340 ────┐
▼ ▼ ▼
[ internal ] [ internal ] [ internal ]
t0040 t0085… … …
│
▼
[ leaf page ] (t0041,998877) (t0041,999063) … (t0042,840) (t0042,862) …
[ leaf page ] (t0042,…) (t0042,…) … (t0043,…)- Tìm một giá trị: đi từ root xuống leaf, mỗi tầng chỉ đọc một page. Mỗi page chứa rất nhiều key, nên cây rất "thấp". Ví dụ minh hoạ: nếu mỗi page chứa vài trăm key, ba tầng đã đủ cho hàng chục triệu key. Tìm t0042 trong 1 triệu key chỉ tốn vài lần đọc page, thay vì 1 triệu lần đọc document.
- Tìm một khoảng: tới được key đầu tiên rồi đọc tiếp các key kề nhau theo thứ tự, cho tới khi vượt khỏi khoảng.
- Chèn một key: đi xuống đúng leaf, chèn vào page đó. Page đầy thì tách đôi. Không phải dời cả cây.
[hình dung] Sơ đồ trên là mô hình B-tree giáo khoa. Hình dạng page, cách tách page, cách nén key là chi tiết triển khai của WiredTiger (bài 20). Điều bạn cần giữ là: index = các cặp (key, RecordId) đã sắp xếp, tổ chức để tìm nhanh một điểm hoặc một khoảng.
Chạy lại query của bài 06 với index
Explain sau khi có tenantId_1
Trong collection lúc này chỉ có _id_ và tenantId_1.
db.orders.find(Q).sort({ createdAt: -1 }).explain("executionStats")Output thật (đã cắt bớt):
winningPlan: {
stage: 'SORT', sortPattern: { createdAt: -1 }, memLimit: 104857600, type: 'simple',
inputStage: {
stage: 'FETCH',
filter: { '$and': [ { createdAt: { '$gte': ISODate('2026-09-01T00:00:00.000Z') } },
{ status: { '$eq': 'pending' } } ] },
inputStage: {
stage: 'IXSCAN',
keyPattern: { tenantId: 1 }, indexName: 'tenantId_1',
isMultiKey: false, direction: 'forward',
indexBounds: { tenantId: [ '["t0042", "t0042"]' ] }
}
}
}
executionStats: { nReturned: 21, totalKeysExamined: 2003, totalDocsExamined: 2003 }
SORT works 2026 advanced 21 needTime 2004
FETCH works 2004 advanced 21 needTime 1982 docsExamined 2003
IXSCAN works 2004 advanced 2003 needTime 0 keysExamined 2003 seeks 1Đọc từ dưới lên, đúng như dữ liệu chảy:
- IXSCAN với
indexBounds: ["t0042", "t0042"]: chỉ quét đoạn index mà key bằng đúng t0042.seeks: 1nghĩa là con trỏ index chỉ phải nhảy một lần (tới key t0042 đầu tiên), sau đó đọc liền 2.003 key kề nhau. Mỗi key đưa lên một RecordId (advanced: 2003). - FETCH lấy document theo từng RecordId (
docsExamined: 2003), rồi áp phần filter mà index không trả lời được:statusvàcreatedAt. Chỉ 21 document qua được. 1.982 lần còn lại làneedTime: lấy document ra, kiểm tra, vứt. - SORT chỉ còn phải xếp 21 document trong bộ nhớ, thay vì nhận đầu vào từ 1 triệu document.
Trước và sau
TRƯỚC (bài 06) SAU (index tenantId_1)
Query Query
↓ ↓
COLLSCAN IXSCAN tenantId_1
1.000.000 document đọc 1 seek + 2.003 key
↓ ↓
filter: vứt 999.979 FETCH 2.003 document
↓ filter: vứt 1.982
SORT 21 document ↓
↓ SORT 21 document
21 kết quả ↓
21 kết quảĐo lặp lại (mỗi lượt 9 lần, lấy median):
| Plan | totalKeysExamined | totalDocsExamined | nReturned | executionTimeMillis (median, 2 lượt) | find().toArray() (median, 2 lượt) |
|---|---|---|---|---|---|
SORT ← COLLSCAN (chỉ _id_) | 0 | 1.000.000 | 21 | 171 ms / 172 ms | 164 ms / 163 ms |
SORT ← FETCH ← IXSCAN tenantId_1 | 2.003 | 2.003 | 21 | 1 ms / 1 ms | 2 ms / 2 ms |
Từ khoảng 165 ms xuống khoảng 1–2 ms trên lab này. Lý do không bí ẩn: số document phải đọc giảm từ 1.000.000 xuống 2.003, khoảng 500 lần. Index không làm việc đọc một document nhanh hơn. Nó loại bỏ 998.000 lần đọc không cần thiết.
Hai chi tiết nhỏ:
- [quan sát] Lần chạy đầu tiên ngay sau khi build index mất 25 ms. Cách giải thích hợp lý nhất: các page của index chưa nằm trong cache nên lần đầu phải nạp lên. Phần "RAM" phía dưới cho thấy index chưa được dùng thì gần như không chiếm cache.
- Mức "1 ms" là giới hạn độ phân giải của
executionTimeMillis(tính bằng mili giây nguyên). Đừng so sánh hai query nhanh bằng con số này.
Tỉ lệ keys : docs : nReturned
Ba con số totalKeysExamined, totalDocsExamined, nReturned là cách đọc nhanh nhất xem một index "vừa" với query tới đâu:
Lý tưởng keys ≈ docs ≈ nReturned mọi thứ đọc ra đều có ích
Query này 2.003 : 2.003 : 21 đọc 95 document để được 1
Bài 06 (COLLSCAN) 0 : 1.000.000 : 21 đọc 47.600 document để được 1Index tenantId_1 đã cắt 99,8% công việc, nhưng vẫn còn đọc dư khoảng 95 lần, vì index chỉ biết tenantId, còn status và createdAt phải kiểm tra trên document. Một index ghép nhiều field, xếp theo đúng thứ tự, có thể đưa con số này về gần 21 : 21 : 21. Đó là compound index, chủ đề chính của bài 08.
Index _id: cuốn mục lục có sẵn
Mọi collection đều sinh ra cùng một index:
db.orders.getIndexes()[ { v: 2, key: { _id: 1 }, name: '_id_' } ][tài liệu] MongoDB tạo một unique index trên _id khi tạo collection. Nó ngăn hai document có cùng _id, và bạn không thể xoá nó. Thử xoá:
drop _id_: cannot drop _id indexTìm theo _id là đường nhanh nhất có thể:
db.orders.find({ _id: ObjectId("...") }).explain("executionStats")winningPlan: { stage: 'EXPRESS_IXSCAN', keyPattern: '{ _id: 1 }', indexName: '_id_' }
totalKeysExamined: 1 totalDocsExamined: 1 executionTimeMillis: 1[tài liệu] EXPRESS_IXSCAN là một trong các stage EXPRESS, mới từ MongoDB 8.0, dành cho một nhóm nhỏ query (như tìm bằng _id) có thể bỏ qua bước query planning thông thường và đi thẳng vào một plan quét index tối ưu. Trên các bản cũ hơn, cùng query này hiện stage IDHACK. Một key, một document: đây là trường hợp lý tưởng 1 : 1 : 1.
Index _id còn nhắc ta một điều mà bài 06 đã gợi ý: index không chỉ để tăng tốc. Unique index là cách duy nhất để database (chứ không phải code ứng dụng) đảm bảo không có hai document trùng khoá, kể cả khi hai request upsert chạy đồng thời. Unique index trên field khác _id thuộc về bài 09.
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 |
Hãy để ý: selectivity là thuộc tính của query cụ thể 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%. Tài liệu MongoDB đưa ví dụ y hệt: nếu 99% đơn có status: "processed", hỏi "processed" là selectivity thấp, nhưng hỏi "không phải processed" lại chỉ khớp 1%.
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 chi phí đơn giản hoá, không phải công thức của MongoDB. Nhưng nó nói đúng xu hướng: khi số key khớp nhỏ, vế trái thắng áp đảo. Khi số key khớp tiến gần tổng số document, vế trái làm cùng số lần đọc document như COLLSCAN, cộng thêm việc đọc key và nhảy. Lúc đó index thua.
Đo thì sẽ rõ.
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 executionTimeMillisOutput thật, lượt 1:
pending 10% | FETCH <- IXSCAN(status_1) keys 99432 docs 99432 n 99432 rej 0 | 55ms | COLLSCAN 209ms | ratio 0.26
completed 70% | FETCH <- IXSCAN(status_1) keys 699990 docs 699990 n 699990 rej 0 | 351ms | COLLSCAN 268ms | ratio 1.31
not-pending 90% | FETCH <- IXSCAN(status_1) keys 900569 docs 900568 n 900568 rej 0 | 452ms | COLLSCAN 249ms | ratio 1.82Ba 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 qua lại giữa các lượt (nhiễu từ lab khác), nhưng tỉ lệ thì rất ổn định:
- 10%: index nhanh hơn khoảng 4 lần.
- 70%: index chậm hơn khoảng 30–40%.
- 90%: index chậm hơn khoảng 1,8 lần.
Ở 90%, IXSCAN đọc 900.569 key và 900.568 document. COLLSCAN đọc 1.000.000 document và không đọc key nào. Số document gần như bằng nhau, nhưng phía index còn phải đọc key và nhảy tới từng RecordId. (Key thứ 900.569 thừa ra là vì $in ba giá trị tạo ba khoảng trong index, con trỏ phải đọc thêm một key để biết đã hết khoảng.)
Thêm một chi tiết đáng lo hơn con số: cột rej 0. [quan sát] Trong cả ba trường hợp, planner chọn IXSCAN và không có plan bị loại nào, tức COLLSCAN không hề được đưa ra cân nhắc, kể cả khi nó nhanh hơn 1,8 lần. Bạn tạo index 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. [tài liệu] Từ 8.3, MongoDB có thêm một bộ xếp hạng plan theo chi phí (cost-based ranker) làm phương án dự phòng, nhưng tài liệu ghi nó hiện chỉ được dùng cho một số ít query. Planner chọn plan thế nào, khi nào xét COLLSCAN, là chủ đề của bài 10.
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, với 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%, index vẫn thắng nhưng chỉ còn khoảng 1,5–2 lần.
- Điểm hoà vốn quanh 20%.
- Từ 30% trở lên, index chậm hơn COLLSCAN 1,3–2,6 lần.
Con số 20% không phải là một quy tắc. Nó phụ thuộc kích thước document, dữ liệu có nằm trong cache hay không, ổ đĩa, số CPU. Tài liệu MongoDB cũng chỉ nói chung: nếu MongoDB phải đọc một số lượng tương đối lớn document để trả kết quả, 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 của đườ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 một cơ chế tôi đã xác minh bên trong WiredTiger. Khi dữ liệu nằm trong cache, nhảy lung tung tốn thêm CPU và làm hỏng tính cục bộ của bộ nhớ. 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 sẽ còn lớn hơn (bài 21). Ở thư viện: phiếu chỉ tới các kệ gần nhau thì đi lấy nhanh, phiếu chỉ tới các kệ ở khắp toà nhà thì mất cả buổi.
Vậy làm gì với field có selectivity thấp
- Đừng tạo index chỉ trên một field ít giá trị như
status, trừ khi query luôn hỏi giá trị hiếm (như "pending"). Nếu đã có, hãy kiểm tra những query khớp phần lớn collection đang đi đường nào. - Kết hợp nó với field kén chọn hơn.
{ tenantId, status, createdAt }biến "pending" từ 10% của cả collection thành vài chục document của một tenant (bài 08). - Chỉ index phần hiếm. Partial index chỉ chứa các document
status: "pending"(bài 09). hint({ $natural: 1 })ép COLLSCAN được, nhưng hint gắn cứng plan vào code. Dùng để đo và chẩn đoán, cân nhắc kỹ trước khi để trong code production (bài 10).
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_ và index đang được build lại:
{"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 653Lượt sau, khi đã có thêm 4 index khác và máy host bận hơn:
{"tenantId":1} [1219,690,766] median 766
{"status":1} [916,535,829] median 829
{"createdAt":1} [1066,667,920] median 920
{"userId":1} [867,599,915] median 867
{"total":1} [953,604,839] median 839Khoảng 0,5–0,9 giây cho mỗi index trên 1 triệu document 215 MB, ở lab này, dữ liệu đã nằm trong cache.
Log của mongod cho thấy build chia thành các pha. Với createdAt_1:
07:16:00.852 Index build: starting method: "hybrid"
07:16:01.105 Index build: collection scan done totalRecords: 1000000, durationMillis: 253
07:16:01.538 Index build: done building index: "createdAt_1"createIndex
│
├── 1. Quét toàn bộ collection, sinh (key, RecordId) cho mỗi document ~253 ms
│ 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. Thời gian tăng theo kích thước collection. Trên collection hàng trăm triệu document, hoặc dữ liệu không nằm trong cache, 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: Index build: starting numSpecs: 5
Index build: collection scan done durationMillis: 2404
Index build: done building tenantId_1, status_1, createdAt_1, userId_1, total_1[quan sát] Một lần quét collection sinh key cho cả 5 index, tổng thời gian giảm khoảng 30%. [tài liệu] Đổi lại, giới hạn bộ nhớ dành cho build (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à có thể phải tràn ra đĩa nhiều 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 đang bị ghi nhiều sẽ làm chậm việc ghi và kéo dài thời gian build.
- Tuỳ chọn
backgroundcũ bị bỏ qua. - Mặc định server cho tối đa 3 build chạy đồng thời (
maxNumActiveUserIndexBuilds). - Trên replica set, index được build đồng thời trên mọi member mang dữ liệu. Primary chỉ commit khi đủ số member có quyền bầu đã build xong (commit quorum, mặc định
"votingMembers"). Nếu một member có quyền bầu không liên lạc được, build có thể treo cho tới khi nó quay lại. - Từ 7.1, build có thể tự dừng khi dung lượng đĩa trống thấp hơn
indexBuildMinAvailableDiskSpaceMB.
Replica set và commit quorum là chuyện của bài 25. Ở đây chỉ cần nhớ: 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ũ.
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Đọc kết quả:
- Insert: 6 key, một cho mỗi index (kể cả
_id_). - Update một field có index: xoá key cũ (
pending), chèn key mới (completed). Index là danh sách đã 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. Các index khác không bị đụng tới.
- Delete: xoá 6 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 với tổng số index. Đây là một lý do nữa để không index những 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ụ
Dataset : 200.000 document cùng schema, sinh trước trong shell (không tính thời gian sinh)
Ghi : 20 lần insertMany, mỗi lần 10.000 document, ordered: false
Index phụ : 0 → không có
1 → tenantId
3 → tenantId, status, createdAt
5 → tenantId, status, createdAt, userId, total
Lặp : 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 3, dữ liệu thô: extra=0 [119976,228571,148478,124688,162602,159490,144509], extra=5 [104330,22039,101937,68752,73233,54274,76104]. Độ dao động lớn, vì các lab khác đang chạy cùng máy, nên tôi chỉ tin median và xu hướng.
[quan sát] Trên lab này, 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ải thiện hiệu năng query nhưng có tác động tiêu cực tới thao tác ghi, và với collection có tỉ lệ ghi/đọc cao, index tốn kém vì mỗi lần insert cũng phải cập nhật mọi index.
Ba điều làm con số thực tế nặng hơn lab:
- Dữ liệu ở đây nằm gọn trong cache. Khi index lớn hơn cache, chèn một key vào giữa B-tree có thể phải nạp page đó từ đĩa trước.
- Index trên giá trị ngẫu nhiên (như
createdAtsinh ngẫu nhiên ở đây, hay UUID) chèn vào khắp cây, chạm tới nhiều page hơn so với index trên giá trị tăng dần (key mới luôn ở cuối cây). Bài 03 đã nói về chuyện ObjectId tăng dần. - Trên replica set, mỗi secondary cũng phải ghi đủ ngần ấy key khi áp dụng oplog (bài 25). Một index thừa bị trả giá ở mọi member.
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à mỗi index chiếm (indexDetails.<tên>.cache["bytes currently in the cache"]):
Sau khi khởi động lại mongod (cache lạnh, chưa query nào chạy):
_id_ 10.44 MB on disk | in cache MB 0.00
tenantId_1 7.82 MB on disk | in cache MB 0.04
status_1 4.76 MB on disk | in cache MB 0.03
createdAt_1 11.25 MB on disk | in cache MB 0.06
userId_1 6.27 MB on disk | in cache MB 0.03
total_1 6.00 MB on disk | in cache MB 0.03[quan sát] Index chưa được dùng thì gần như không chiếm cache. Một lần đo khác, ngay sau khi build userId_1 và total_1 (trước khi khởi động lại), cũng cho thấy hai index này chỉ có 0,03 MB trong cache: build xong thì index nằm trên đĩa, chỉ được nạp lên khi có query cần.
Rồi đọc toàn bộ từng index một lần (một count có hint, plan là COUNT_SCAN, chỉ đọc index, không đọc document):
Sau khi đọc hết mỗi index một lần:
_id_ 10.44 MB on disk | in cache MB 18.33
tenantId_1 7.82 MB on disk | in cache MB 15.91
status_1 4.76 MB on disk | in cache MB 12.90
createdAt_1 11.25 MB on disk | in cache MB 19.28
userId_1 6.27 MB on disk | in cache MB 14.38
total_1 6.00 MB on disk | in cache MB 14.13Tổng index trong cache: khoảng 95 MB, gấp đôi 46,55 MB trên đĩa. [tài liệu] Tài liệu WiredTiger nói index trong cache dùng cách biểu diễn khác với trên đĩa, nhưng vẫn hưởng prefix compression. Hệ số khoảng 1,7–2,7 lần ở đây là [quan sát] trên dataset này, không phải hằng số.
Ý nghĩa thực tế: 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 một phần "dữ liệu nóng" cạnh tranh chỗ trong cache. Khi tổng phần đang được dùng (working set) vượt cache, query bắt đầu phải đọc đĩa, và cả hệ thống chậm đi, không chỉ query dùng index đó. Working set và eviction là chủ đề của bài 21.
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 09), sắp xếp theo thứ tự index (bài 08)
│
├── ✗ 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 nhanh hơn (query kén chọn)
↑
│
Thêm index ─────────┼───────── Thêm RAM, thêm đĩa
│
↓
Ghi chậm hơnCâ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 phục vụ một 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à một 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 |
Hai dòng đáng dừng lại:
1. Bitmap Heap Scan giải quyết đúng vấn đề của thí nghiệm createdAt. [tài liệu PostgreSQL] Với bitmap scan, PostgreSQL dùng index để lấy danh sách vị trí các dòng khớp, sắp xếp chúng theo thứ tự vật lý rồi mới đọc bảng, để giảm chi phí của những lần đọ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. Đó là lý do ở mức selectivity trung bình, PostgreSQL thường 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.
2. Build index. [tài liệu PostgreSQL] CREATE INDEX thông thường chặn insert, update, delete trên bảng cho tới khi build xong (đọc vẫn được). Muốn không chặn ghi thì dùng CREATE INDEX CONCURRENTLY, đổi lại phải quét bảng hai lần và chạy lâu hơn đáng kể. MongoDB từ 4.2 mặc định chọn hướng "không chặn ghi suốt quá trình", với chi phí là khoá ngắn ở đầu và cuối và việc phải xử lý các lệnh ghi xen vào giữa.
Còn bài học "index không phải lúc nào cũng thắng" thì là chung. Tài liệu PostgreSQL cũng nói khi cần sắp xếp nhiều dòng, đọc tuần tự rồi sort thường thắng index scan, vì index scan phải đọc rời rạc. Không phải chuyện riêng của MongoDB, mà là chuyện của mọi database lưu dữ liệu tách khỏi index.
Những lỗi thường gặp
- Thêm index cho mọi field "phòng khi cần". Lab: 5 index phụ làm tốc độ insert còn một nửa, cộng thêm 61% dung lượng đĩa và gần 95 MB cache. Mỗi index phải có một query thật cần nó.
- Index một field ít giá trị (
status,type,isDeleted) rồi tin rằng mọi query trên field đó đều nhanh. Lab: query khớp 90% chậm hơn COLLSCAN 1,8 lần, và planner vẫn chọn index. - Nhìn thấy
IXSCANtrong explain là yên tâm. Hãy nhìn tỉ lệtotalKeysExamined : totalDocsExamined : nReturned. 900.569 : 900.568 : 900.568 là IXSCAN nhưng tệ hơn không có index. - Có index
tenantIdrồi nghĩ là xong. Index một field vẫn đọc 2.003 document để trả 21. Query có nhiều điều kiện cần index ghép đúng thứ tự (bài 08). - Đo query ngay sau khi tạo index, hoặc chỉ đo một lần. Lần đầu là cache lạnh (25 ms so với 1 ms). Một lần đo không nói gì khi máy đang chạy việc khác.
- Tạo index trên production giờ cao điểm mà không đo trước trên bản sao. Build là một lần quét toàn bộ collection, làm chậm việc ghi trong lúc chạy, và trên replica set có thể treo nếu một member có quyền bầu không phản hồi.
- Tạo nhiều index bằng nhiều lệnh riêng khi có thể gom vào một
createIndexes. Lab: 4,2 giây so với 2,9 giây cho 5 index. - Quên xoá index không còn query nào dùng. Index thừa vẫn bị trả giá ở mỗi lệnh ghi, trên mọi member của replica set.
Tóm tắt
- Index là các cặp (key, RecordId) đã sắp xếp, tổ chức thành B-tree. Nó cho phép nhảy thẳng tới một giá trị hoặc một khoảng, thay vì đọc mọi document.
- Query dùng index chạy IXSCAN (đọc key trong khoảng
indexBounds) rồi FETCH (lấy document theo RecordId và áp phần filter còn lại). Lab: query bài 06 từ 1.000.000 document, ~165 ms xuống 2.003 document, ~1–2 ms. - Đọc explain bằng tỉ lệ keys : docs : nReturned. Gần 1 : 1 : 1 là tốt.
_id_có sẵn, unique, không xoá được. Từ 8.0, tìm theo_iddùng stageEXPRESS_IXSCAN.- Selectivity là tỉ lệ document khớp query, phụ thuộc cả giá trị được hỏi. Lab: index thắng áp đảo dưới 1%, hoà vốn quanh 20%, thua 1,3–2,6 lần từ 30% trở lên. Ngưỡng này thay đổi theo hệ thống, hình dạng đường cong thì không.
- Build index = quét collection + sort key. Lab: ~0,5–0,9 s mỗi index trên 1 triệu document. Gom nhiều index vào một
createIndexeschỉ quét một lần. - Mỗi index là một lần ghi thêm ở mỗi insert/delete, và ở mỗi update đụng tới field đó. Lab: 5 index phụ làm throughput insert còn khoảng 50%.
- Mỗi index chiếm đĩa và cache. Lab: 6 index 46,55 MiB trên đĩa (61% collection), khoảng 95 MB trong cache.
Tự kiểm tra
- Explain cho thấy
totalKeysExamined: 50000, totalDocsExamined: 50000, nReturned: 12. Index đang được dùng có vấn đề gì, và hướng sửa là gì? (Index chỉ trả lời một phần điều kiện, 49.988 document bị FETCH rồi vứt. Cần index bao được nhiều điều kiện hơn, thường là compound index, bài 08.) - Một collection
eventsnhậ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. Bạn hỏi lại những gì? (Query đó có selectivity bao nhiêu, chạy bao lâu khi không có index, có index sẵn nào phục vụ được phần lớn không, và insert hiện còn bao nhiêu dư địa. Mỗi index thêm ít nhất một lần ghi cho mỗi insert.) - Vì sao
find({ status: "completed" })có indexstatus_1lại chậm hơn COLLSCAN, dù explain hiệnIXSCAN? (70% collection khớp. IXSCAN đọc gần như bằng ấy document như COLLSCAN, cộng thêm việc đọc 700.000 key và lấy document theo từng RecordId.) - Update
{ $set: { note: "..." } }trên collection có 8 index, không index nào chứanote. Có bao nhiêu index key bị ghi? (0. Update chỉ đụng index chứa field bị đổi.)
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 06 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 08, Compound & Advanced Indexing, đ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 09. Còn câu hỏi "vì sao planner chọn index chậm hơn COLLSCAN" chờ ở bài 10.
Tài liệu tham khảo
- Indexes
- Index Builds on Populated Collections
- Create Queries that Ensure Selectivity
- Explain Results
- Query Plans
- Database Profiler Output
- collStats (deprecated, dùng $collStats)
- WiredTiger Storage Engine (compression, memory use)
- PostgreSQL: Using EXPLAIN
- PostgreSQL: CREATE INDEX (Building Indexes Concurrently)