NoSQL 4 — MongoDB: Index, hiệu năng & mở rộng

13 thg 7, 2026 2 lượt xem
#sql
#replication
#sharding
#index
#nosql
#mongodb

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ụ mongosh dướ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 được mớ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 indexTạo bằngDù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
Multikeytự động khi trường là mảnglọ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)
TTLsingle field trên trường ngày + expireAfterSecondstự 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:

  1. Equality (điều kiện bằng, field: value hoặc $in hẹp) đặt trước.
  2. Sort (trường dùng để $sort/.sort()) đặt giữa.
  3. 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 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.stagekế hoạch được chọn — cái cần nhìn đầu tiên
COLLSCANquét toàn bộ collection — thường là dấu hiệu thiếu index (xấu)
IXSCANquét qua index — điều ta mong muốn (tốt)
FETCHsau IXSCAN, lấy document gốc từ đĩa theo con trỏ
SORTsort trong bộ nhớ (không dùng index) — cảnh báo, có thể vỡ 100MB
keysExaminedsố entry index đã đọc
docsExaminedsố document gốc đã đọc
nReturnedsố 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, 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 $where khô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 đĩangừ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
primaryPreferredprimary, fallback secondary khi primary chết
secondaryluôn secondary — giảm tải primary
secondaryPreferredsecondary, fallback primary
nearestnode độ 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 độđộ 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:

  1. 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.
  2. 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_at hay ObjectId: 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.
  3. 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ắttheo dải giá trị liên tụctheo hash của giá trị
Query khoảng ($gte..$lte)tốt — trỏ ít shardkém — hash phá thứ tự, phải hỏi mọi shard
Phân tán ghidễ hotspot nếu key đơn điệurất đều kể cả với key tăng dần
Dùng khishard key phân tán tự nhiên, cần range querykey đơ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

MongoDBRDBMS 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 indexPartial index (CREATE INDEX ... WHERE)
Covered queryIndex-only scan / covering index (INCLUDE)
explain() → IXSCAN/COLLSCANEXPLAIN → Index Scan / Seq Scan
Replica set + oplog + failoverStreaming replication (WAL) + failover (Patroni)
Write concern w:"majority"Synchronous replication (synchronous_commit)
Read preference secondaryRead 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:

  1. Index theo ESR cho mẫu truy vấn chính — lọc bằng account_id, khoảng ts, không sort riêng:

    db.txn_logs.createIndex({ account_id: 1, ts: 1 })   // Equality trước, Range sau
    

    explain("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, docsExaminednReturned ≈ 240 — giảm hơn 5 bậc độ lớn, thời gian từ ~40 giây xuống ~15 mili giây.

  2. 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ày
    

    Tiế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.

  3. 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ảng accounts cùng cluster dùng w: "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.

  4. 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_logs theo { account_id: "hashed" } (phân tán ghi đều, tránh hotspot do ts đơ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(): mong IXSCAN (tránh COLLSCAN); soi keysExamined/docsExamined so với nReturned (lý tưởng ~1:1); thấy SORT nghĩa là sort trong RAM — sửa bằng index. Covered query trả lời chỉ bằng index, không FETCH.
  • 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ànhtổ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.

13 thg 7, 2026 4

Khoá, ràng buộc, quan hệ và chuẩn hoá 1NF/2NF/3NF — thiết kế lược đồ đúng từ đầu.

13 thg 7, 2026 4

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.

13 thg 7, 2026 3

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.

13 thg 7, 2026 3