NoSQL 4 — MongoDB: Index, hiệu năng & mở rộng
NoSQL 4 — MongoDB: Index, hiệu năng & mở rộng
Ở bài CRUD & Aggregation chúng ta đã thấy một pipeline có thể chạy trong giây hay trong phút tuỳ thứ tự stage. Nhưng thứ quyết định lớn nhất tới hiệu năng của mọi truy vấn — Mongo hay SQL — là index. Bài này gồm hai nửa: (1) index và hiệu năng truy vấn trên một node, (2) mở rộng ra nhiều node bằng replication (bền, sẵn sàng cao) và sharding (chia tải, chứa dữ liệu lớn).
Người viết giả định bạn đã quen index B-tree trong RDBMS. Tin tốt: index MongoDB cũng là B-tree, nên phần lớn trực giác của bạn dùng lại được. Khác biệt nằm ở chi tiết (multikey cho mảng, dot notation, covered query) và ở tầng mở rộng.
Lưu ý về code trong bài: mọi ví dụ
mongoshdưới đây là cú pháp MongoDB, không phải SQL chạy được trên sandbox PostgreSQL. Chỉ block đánh dấu-- ▶ Chạy đượcmới là SQL thật, dùng để đối chiếu khái niệm. Đừng dán nhầm lệnh Mongo vào ô SQL.
1. Index là gì và các loại trong MongoDB
Index là một cấu trúc B-tree lưu giá trị của một hay nhiều trường theo thứ tự đã sắp, cùng con trỏ tới document gốc. Không có index, mọi truy vấn phải quét toàn bộ collection (collection scan). Mọi collection luôn có sẵn một index trên _id.
| Loại index | Tạo bằng | Dùng khi |
|---|---|---|
| Single field | { field: 1 } | lọc/sort trên một trường |
| Compound | { a: 1, b: -1, c: 1 } | lọc/sort trên nhiều trường theo một mẫu |
| Multikey | tự động khi trường là mảng | lọc phần tử trong mảng (tags) |
| Text | { field: "text" } | tìm kiếm từ khoá trong chuỗi |
| Geospatial | { loc: "2dsphere" } | truy vấn theo toạ độ/khoảng cách |
| Hashed | { field: "hashed" } | phân tán đều (shard key hashed) |
| TTL | single field trên trường ngày + expireAfterSeconds | tự xoá document hết hạn |
1 là tăng dần, -1 là giảm dần (quan trọng với compound + sort). Xem index hiện có bằng db.transactions.getIndexes().
Single field
db.transactions.createIndex({ account_id: 1 })
db.transactions.createIndex({ created_at: -1 })
Với một index single field, chiều tăng/giảm không quan trọng cho việc lọc (B-tree đọc được cả hai chiều), nhưng có ý nghĩa khi kết hợp sort đa trường.
Compound index và quy tắc ESR
Compound index gộp nhiều trường vào một B-tree theo thứ tự khai báo — và thứ tự này quyết định index có dùng được hay không. Đây là chỗ nhiều người đặt sai và tự hỏi sao index "có mà không ăn".
Quy tắc vàng để sắp thứ tự trường là ESR — Equality, Sort, Range:
- Equality (điều kiện bằng,
field: valuehoặc$inhẹp) đặt trước. - Sort (trường dùng để
$sort/.sort()) đặt giữa. - Range (điều kiện khoảng:
$gt,$lt,$gte,$lte) đặt sau cùng.
Ví dụ truy vấn: "giao dịch của một account_id (bằng), amount trong khoảng (range), sắp theo created_at giảm dần (sort)":
// query
db.transactions.find({ account_id: 1042, amount: { $gte: 1000000 } })
.sort({ created_at: -1 })
// index theo ESR: Equality (account_id) → Sort (created_at) → Range (amount)
db.transactions.createIndex({ account_id: 1, created_at: -1, amount: 1 })
Vì sao Range đặt sau Sort? Nếu để trường range trước trường sort, sau khi B-tree quét qua một dải range, các bản ghi trả về không còn theo thứ tự của trường sort → Mongo phải sort lại trong bộ nhớ (một bước "SORT" tốn kém, có thể vỡ giới hạn 100MB). Đặt sort trước range giữ được thứ tự tự nhiên của B-tree, tránh sort trong RAM.
Một compound index còn phục vụ được các truy vấn trên tiền tố của nó: index { a, b, c } dùng được cho query lọc {a}, {a, b}, {a, b, c} — nhưng không dùng cho query chỉ lọc {b} hay {c} (giống nguyên tắc leftmost-prefix của index đa cột trong RDBMS/MySQL). Đây là lý do một compound index đặt đúng thứ tự có thể thay thế nhiều single-field index.
Multikey — index trên mảng
Khi bạn index một trường là mảng, MongoDB tự tạo multikey index: nó tạo một entry B-tree cho mỗi phần tử của mảng. Nhờ đó find({ tags: "vip" }) dùng được index.
db.transactions.createIndex({ tags: 1 }) // tự thành multikey
Giới hạn quan trọng: một compound index chỉ được có tối đa một trường là mảng (không thể multikey trên hai trường mảng cùng lúc, vì số tổ hợp bùng nổ). Multikey cũng làm index phình to hơn (nhiều entry/document).
Text index
Cho tìm kiếm từ khoá trong nội dung chuỗi (tên, mô tả, ghi chú):
db.customers.createIndex({ full_name: "text", note: "text" })
db.customers.find({ $text: { $search: "Nguyễn Hà Nội" } })
Mỗi collection chỉ được một text index (nhưng có thể phủ nhiều trường). Với nhu cầu tìm kiếm nghiêm túc (tiếng Việt có dấu, ranking, fuzzy), nên dùng công cụ chuyên biệt như Elasticsearch thay vì text index của Mongo.
Geospatial index
Cho dữ liệu toạ độ — ví dụ tìm ATM/chi nhánh gần một điểm:
db.branches.createIndex({ location: "2dsphere" })
db.branches.find({
location: { $near: {
$geometry: { type: "Point", coordinates: [105.85, 21.03] }, // [lng, lat]
$maxDistance: 2000 // mét
}}
})
TTL index — tự xoá document hết hạn
TTL (Time-To-Live) index là index single-field trên một trường kiểu ngày; MongoDB có một tiến trình nền quét định kỳ (mặc định ~60 giây/lần) và tự xoá những document đã quá hạn. Cực hợp cho session, OTP, cache, log — dữ liệu chỉ cần sống một thời gian.
// tự xoá session sau 30 phút (1800 giây) kể từ trường created_at
db.sessions.createIndex({ created_at: 1 }, { expireAfterSeconds: 1800 })
// hoặc đặt thời điểm hết hạn tuyệt đối: expireAfterSeconds: 0
// document bị xoá khi trường ngày <= thời điểm hiện tại
db.otp.createIndex({ expire_at: 1 }, { expireAfterSeconds: 0 })
Lưu ý: việc xoá không tức thời đúng giây (chạy theo chu kỳ nền), nên đừng dựa vào TTL cho yêu cầu chính xác tuyệt đối. TTL cũng không hoạt động trên collection có sẵn nhiều document mà trường TTL không phải kiểu Date.
Partial index & sparse index
Hai loại này giúp index nhỏ và rẻ hơn bằng cách chỉ lập chỉ mục một phần document:
- Sparse index: chỉ index các document có trường đó (bỏ qua document thiếu trường). Hữu ích cho trường thưa (chỉ vài % document có).
- Partial index: mạnh và linh hoạt hơn — chỉ index các document thoả một điều kiện bạn nêu. Thường thay thế sparse.
// sparse: chỉ index document CÓ trường vip_tier
db.customers.createIndex({ vip_tier: 1 }, { sparse: true })
// partial: chỉ index giao dịch lớn (giảm mạnh kích thước index)
db.transactions.createIndex(
{ amount: 1 },
{ partialFilterExpression: { amount: { $gte: 100000000 } } }
)
Đối chiếu RDBMS: partialFilterExpression chính là partial index của PostgreSQL (CREATE INDEX ... WHERE amount >= ...). Cùng ý tưởng: đừng index dữ liệu bạn không bao giờ truy vấn qua đó.
2. explain() và query planner — đọc để tối ưu
Không đoán mò về hiệu năng — hãy hỏi query planner. explain("executionStats") cho biết Mongo thực thi truy vấn thế nào và tốn bao nhiêu:
db.transactions.find({ account_id: 1042, amount: { $gte: 1000000 } })
.sort({ created_at: -1 })
.explain("executionStats")
Các trường phải biết đọc trong kết quả:
| Trường / giá trị | Ý nghĩa |
|---|---|
winningPlan.stage | kế hoạch được chọn — cái cần nhìn đầu tiên |
COLLSCAN | quét toàn bộ collection — thường là dấu hiệu thiếu index (xấu) |
IXSCAN | quét qua index — điều ta mong muốn (tốt) |
FETCH | sau IXSCAN, lấy document gốc từ đĩa theo con trỏ |
SORT | sort trong bộ nhớ (không dùng index) — cảnh báo, có thể vỡ 100MB |
keysExamined | số entry index đã đọc |
docsExamined | số document gốc đã đọc |
nReturned | số document trả về |
Chỉ số vàng để đánh giá: so sánh keysExamined/docsExamined với nReturned. Lý tưởng là gần 1:1 — đọc bao nhiêu, trả về bấy nhiêu. Nếu docsExamined gấp hàng nghìn lần nReturned, index đang không lọc tốt (hoặc chọn sai index). Thấy COLLSCAN với collection lớn, hoặc thấy SORT xuất hiện (nghĩa là sort trong RAM), là hai cờ đỏ cần xử lý ngay bằng index phù hợp.
MongoDB có thể có nhiều kế hoạch ứng viên; nó chạy thử ngắn rồi chọn winningPlan, các phương án còn lại nằm trong rejectedPlans. Kết quả kế hoạch được cache theo query shape nên lần sau không phải chọn lại.
Covered query — không cần chạm document gốc
Covered query là truy vấn mà mọi trường (cả trong filter lẫn trong kết quả trả về) đều nằm trong index — nên Mongo trả lời chỉ bằng index, không cần bước FETCH document gốc. Đây là truy vấn nhanh nhất có thể.
Điều kiện: các trường query + các trường projection đều thuộc index, và projection phải loại _id (_id: 0) trừ khi _id cũng nằm trong index.
// index phủ đủ cả filter lẫn projection
db.transactions.createIndex({ account_id: 1, created_at: -1 })
// covered: chỉ đọc index, KHÔNG fetch document → explain cho thấy không có FETCH
db.transactions.find(
{ account_id: 1042 },
{ _id: 0, account_id: 1, created_at: 1 }
).sort({ created_at: -1 })
Đối chiếu RDBMS: đây chính là index-only scan của PostgreSQL / covering index (INCLUDE) của SQL Server. Cùng nguyên lý: nếu index đã chứa mọi thứ câu hỏi cần, khỏi phải đọc bảng.
3. Hiệu năng: nguyên tắc thực chiến
- Chọn index theo mẫu truy vấn, không theo cảm tính. Gom log các truy vấn thực tế (query shape phổ biến), rồi thiết kế compound index theo ESR cho những mẫu đó. Một index đặt đúng phục vụ nhiều biến thể query nhờ leftmost-prefix.
- Tránh index thừa. Mỗi index phải được cập nhật ở mọi lần ghi (insert/update/delete) → nhiều index làm ghi chậm và tốn dung lượng. Định kỳ soi index không dùng (Mongo có
$indexStats) và bỏ bớt. Đây là đánh đổi kinh điển: index nhanh cho đọc, chậm cho ghi. - Working set nên nằm trong RAM. MongoDB (WiredTiger) dùng bộ nhớ để cache index + document nóng. Nếu working set (tập dữ liệu + index thường được truy cập) vượt RAM, hệ thống phải đọc đĩa liên tục → hiệu năng sụp. Với hệ thống lớn, giữ cho toàn bộ index vừa trong RAM là ưu tiên hàng đầu; đây cũng là một lý do phải shard khi dữ liệu vượt quá RAM một máy.
- Tránh regex không neo đầu và
$where.$regex: /abc/(không có^) và biểu thức JavaScript$wherekhông dùng được index → collection scan. - Index có chọn lọc cao (selective) là index tốt. Index trên trường chỉ có 2 giá trị (
is_active) lọc kém; kết hợp vào compound sau trường chọn lọc cao hoặc dùng partial index.
4. Replication — replica set
Một node đơn có hai rủi ro: mất dữ liệu khi hỏng đĩa và ngừng dịch vụ khi máy chết. Replica set giải quyết bằng cách giữ nhiều bản sao dữ liệu trên nhiều node.
Cấu trúc một replica set:
- Primary: node duy nhất nhận mọi lệnh ghi. Mọi thay đổi được ghi vào oplog (operations log — một capped collection ghi tuần tự mọi thao tác ghi).
- Secondary: các node sao chép oplog của primary và áp dụng lại để dữ liệu luôn theo kịp. Có thể phục vụ đọc (tuỳ read preference).
- Bầu primary tự động: các node "nhịp tim" (heartbeat) lẫn nhau; khi primary mất liên lạc, các node còn lại bầu một secondary lên làm primary mới (thuật toán dựa trên đa số — cần quá bán số node còn sống). Vì cần đa số, replica set thường có số node lẻ (3, 5); nếu chỉ đủ ngân sách 2 node dữ liệu, thêm một arbiter (node chỉ bỏ phiếu, không giữ dữ liệu) cho đủ lẻ.
Khi primary chết, ứng dụng không cần đổi cấu hình thủ công: driver biết topology của replica set và tự động trỏ ghi sang primary mới sau bầu cử (thường vài giây). Đối chiếu RDBMS: đây tương tự streaming replication + automatic failover của PostgreSQL (dùng Patroni/repmgr) — xem bài replication trong SQL để so sánh chi tiết cơ chế WAL vs oplog.
Read preference — đọc từ đâu
read preference quyết định driver gửi lệnh đọc tới node nào:
| readPreference | Đọc từ |
|---|---|
primary (mặc định) | luôn primary — nhất quán mạnh |
primaryPreferred | primary, fallback secondary khi primary chết |
secondary | luôn secondary — giảm tải primary |
secondaryPreferred | secondary, fallback primary |
nearest | node độ trễ mạng thấp nhất |
Đọc từ secondary giúp giảm tải nhưng có thể đọc dữ liệu cũ vài mili giây (replication lag) — chấp nhận được cho báo cáo/analytics, không cho nghiệp vụ đọc-sau-ghi cần thấy ngay (ví dụ vừa chuyển tiền xong hiển thị số dư).
Write concern & read concern — độ bền và nhất quán
Đây là hai "núm vặn" đánh đổi giữa tốc độ và độ chắc chắn:
- Write concern (
w): lệnh ghi được coi là thành công khi bao nhiêu node đã xác nhận.w: 1— chỉ primary xác nhận (nhanh, nhưng nếu primary chết trước khi sao chép, có thể mất bản ghi đó).w: "majority"— đa số node đã nhận (bền hơn: sống sót qua failover). Khuyến nghị cho dữ liệu quan trọng như tài chính.j: true— chờ ghi xuống journal trên đĩa (bền qua sự cố mất điện).
- Read concern: mức nhất quán khi đọc.
local— trả dữ liệu node hiện có (có thể chưa được đa số xác nhận).majority— chỉ trả dữ liệu đã được đa số xác nhận, không bị rollback khi failover.linearizable— nhất quán mạnh nhất, thấy được lần ghi mới nhất đã majority (chậm hơn).
db.accounts.updateOne(
{ id: 1042 }, { $inc: { balance: -1000000 } },
{ writeConcern: { w: "majority", j: true } } // bền cho giao dịch tiền
)
Cặp w: "majority" + readConcern: "majority" là cấu hình mặc định nên dùng cho hệ thống ngân hàng: chấp nhận chậm hơn một chút để không bao giờ mất/rollback một giao dịch đã báo thành công.
5. Sharding — chia dữ liệu theo chiều ngang
Replication nhân bản sao (mỗi node giữ toàn bộ dữ liệu) → giải quyết độ bền/HA nhưng không giải quyết việc dữ liệu quá lớn cho một máy. Sharding là chia dữ liệu thành nhiều mảnh (shard), mỗi shard chứa một phần dữ liệu và là một replica set riêng.
Kiến trúc một sharded cluster:
- Shard: một replica set giữ một tập con document (vẫn được nhân bản để HA trong nội bộ shard).
- mongos router: điểm vào duy nhất mà ứng dụng kết nối. Nó định tuyến truy vấn tới đúng shard dựa trên shard key, rồi gộp kết quả. Ứng dụng không biết dữ liệu nằm ở shard nào — mongos che giấu.
- Config server: một replica set nhỏ lưu metadata — bản đồ "dải shard key nào thuộc shard nào". mongos hỏi config server để định tuyến.
Shard key — quyết định thành bại
Dữ liệu được chia theo shard key (một hoặc vài trường). MongoDB cắt không gian shard key thành các chunk và phân bổ chunk cho các shard, tự cân bằng (balancer) khi lệch. Chọn shard key là quyết định khó đảo ngược nhất của sharding. Một shard key tốt cần:
- Cardinality cao (nhiều giá trị khác nhau) — để cắt được thành nhiều chunk nhỏ. Shard theo trường chỉ có vài giá trị thì không chia được.
- Phân tán đều write — tránh hotspot (một shard nhận phần lớn ghi). Đây là lý do không shard theo trường đơn điệu tăng như
created_athayObjectId: mọi document mới rơi vào cùng một chunk cuối → một shard gánh toàn bộ ghi, các shard khác nhàn rỗi. - Xuất hiện trong hầu hết truy vấn — để mongos định tuyến tới một shard (targeted query) thay vì hỏi mọi shard (scatter-gather, chậm).
Hashed vs Ranged — hai cách cắt shard key:
| Ranged (mặc định) | Hashed | |
|---|---|---|
| Cách cắt | theo dải giá trị liên tục | theo hash của giá trị |
Query khoảng ($gte..$lte) | tốt — trỏ ít shard | kém — hash phá thứ tự, phải hỏi mọi shard |
| Phân tán ghi | dễ hotspot nếu key đơn điệu | rất đều kể cả với key tăng dần |
| Dùng khi | shard key phân tán tự nhiên, cần range query | key đơn điệu (như account_id tự tăng), ưu tiên phân tán đều |
sh.shardCollection("bank.transactions", { account_id: "hashed" }) // hashed: phân tán đều
sh.shardCollection("bank.events", { region: 1, ts: 1 }) // ranged compound
Mẹo thực tế: một compound shard key kết hợp trường chọn lọc (định tuyến) với trường phân tán thường là lựa chọn cân bằng nhất — ví dụ { account_id: 1, created_at: 1 } cho phép cả targeted query theo tài khoản lẫn range theo thời gian trong tài khoản đó.
Khi nào cần shard?
Sharding thêm rất nhiều phức tạp (vận hành, backup, transaction xuyên shard tốn kém). Đừng shard sớm. Chỉ shard khi gặp một trong các giới hạn thật:
- Dung lượng dữ liệu vượt đĩa một node, hoặc
- Working set + index vượt RAM một node (đọc chậm vì phải xuống đĩa liên tục), hoặc
- Throughput ghi vượt sức một primary (một primary nhận mọi lệnh ghi — replica set không tăng khả năng ghi, chỉ sharding mới tăng).
Thứ tự trưởng thành thường là: 1 node → replica set (HA) → khi chạm trần thì shard. Nhiều hệ thống ngân hàng cỡ vừa dừng ở replica set với index tốt, không cần shard.
Đối chiếu khái niệm với RDBMS
| MongoDB | RDBMS tương đương |
|---|---|
| Index B-tree (single/compound) | Index B-tree, index đa cột (leftmost-prefix) |
| Multikey index | (không có sẵn — gần với GIN index trên mảng của PostgreSQL) |
| Partial / sparse index | Partial index (CREATE INDEX ... WHERE) |
| Covered query | Index-only scan / covering index (INCLUDE) |
explain() → IXSCAN/COLLSCAN | EXPLAIN → Index Scan / Seq Scan |
| Replica set + oplog + failover | Streaming replication (WAL) + failover (Patroni) |
Write concern w:"majority" | Synchronous replication (synchronous_commit) |
| Read preference secondary | Read replica cho báo cáo |
| Sharding (shard key) | Partitioning + phân tán thủ công / Citus |
Use case thực tế
Bài toán: Hệ thống ghi log giao dịch của NCB (collection txn_logs) nhận ~30 triệu bản ghi/ngày. Yêu cầu: (1) truy vấn nhanh log theo account_id trong một khoảng ngày, (2) chỉ giữ log 90 ngày (tuân thủ chính sách lưu trữ), (3) hệ thống phải chạy qua sự cố mất một máy.
Giải pháp và số liệu:
-
Index theo ESR cho mẫu truy vấn chính — lọc bằng
account_id, khoảngts, không sort riêng:db.txn_logs.createIndex({ account_id: 1, ts: 1 }) // Equality trước, Range sauexplain("executionStats")trước khi có index:COLLSCAN,docsExamined= 30.000.000 cho một câu tra cứu một tài khoản. Sau khi thêm index:IXSCAN,docsExamined≈nReturned≈ 240 — giảm hơn 5 bậc độ lớn, thời gian từ ~40 giây xuống ~15 mili giây. -
TTL index tự dọn log 90 ngày — không cần cron job xoá thủ công:
db.txn_logs.createIndex({ ts: 1 }, { expireAfterSeconds: 7776000 }) // 90 ngàyTiến trình nền tự xoá document quá hạn, giữ collection ổn định ở ~2,7 tỷ bản ghi thay vì phình vô hạn — và giữ working set trong RAM.
-
Replica set 3 node cho HA — primary + 2 secondary khác rack. Ghi log dùng
w: 1(log không phải giao dịch tiền, ưu tiên throughput ghi); nhưng bảngaccountscùng cluster dùngw: "majority", j: true. Khi diễn tập rút một node primary, cluster bầu primary mới trong ~4 giây, driver tự trỏ lại — ứng dụng chỉ thấy vài request lỗi tạm rồi tự retry, không mất dữ liệu đã majority. -
Chưa shard — 2,7 tỷ bản ghi × ~400 byte ≈ 1TB, vẫn vừa đĩa và index vừa RAM của node hiện tại. Quyết định: chưa cần sharding, chỉ theo dõi ngưỡng RAM/đĩa; nếu tương lai giữ log dài hơn hoặc lưu lượng gấp 5, sẽ shard
txn_logstheo{ account_id: "hashed" }(phân tán ghi đều, tránh hotspot dotsđơn điệu tăng).
Ghi nhớ
- Index MongoDB là B-tree như RDBMS. Compound index đặt thứ tự trường theo ESR — Equality, Sort, Range; dùng được cho các tiền tố trái (leftmost-prefix).
- Multikey tự bật khi index trường mảng (tối đa một trường mảng/compound). TTL index tự xoá document hết hạn — lý tưởng cho session/OTP/log. Partial/sparse index thu nhỏ index bằng cách chỉ lập chỉ mục một phần document.
- Đọc
explain(): mongIXSCAN(tránhCOLLSCAN); soikeysExamined/docsExaminedso vớinReturned(lý tưởng ~1:1); thấySORTnghĩa là sort trong RAM — sửa bằng index. Covered query trả lời chỉ bằng index, khôngFETCH. - Hiệu năng: chọn index theo mẫu truy vấn thật, tránh index thừa (làm chậm ghi), giữ working set + index trong RAM.
- Replica set = độ bền + HA: primary nhận ghi (ghi oplog), secondary sao chép, tự bầu primary khi lỗi (cần đa số → số node lẻ). Chỉnh write/read concern (
w:"majority",readConcern:"majority") cho dữ liệu tài chính. - Sharding = chia dữ liệu ngang qua shard key (mongos định tuyến, config server giữ metadata). Chọn shard key cardinality cao, phân tán đều, tránh hotspot; hashed cho key đơn điệu, ranged cho range query. Chỉ shard khi vượt trần dung lượng/RAM/throughput ghi của một node.
Bài tiếp theo chuyển sang nửa còn lại của series — Redis: bắt đầu với khái niệm cơ bản, rồi các pattern, vận hành và tổng hợp use case ngân hàng. Nếu cần ôn lại nền tảng, quay về bài tổng quan NoSQL.
Bài viết liên quan
Cách index hoạt động (B-Tree), đọc EXPLAIN, seq scan vs index scan và mẫu tối ưu truy vấn.
Khoá, ràng buộc, quan hệ và chuẩn hoá 1NF/2NF/3NF — thiết kế lược đồ đúng từ đầu.
Kết nhiều bảng đúng cách: các loại JOIN, bẫy thường gặp và phép hợp tập.
Truy vấn lồng, CTE (WITH), CTE đệ quy và hàm cửa sổ — vũ khí cho phân tích nâng cao.