GĐ06 — MongoDB & NoSQL: schema design, aggregation, transaction
Vào đây sau GĐ05 (Postgres), không phải trước. Lý do rất cụ thể: MongoDB dễ bắt đầu nhưng khó làm đúng. Nếu học Mongo trước, bạn sẽ mang thói quen "cứ nhét vào cho xong" sang mọi database sau này. Học Postgres trước cho bạn khái niệm chuẩn hoá, khoá ngoại, transaction — rồi mới thấy Mongo cố tình bỏ cái gì và đổi lại được cái gì.
Mục tiêu giai đoạn: biết khi nào Mongo là lựa chọn đúng, thiết kế được document schema không tự bắn vào chân, viết được aggregation pipeline, và trả lời được câu phỏng vấn kinh điển "vì sao anh chọn Postgres chứ không phải Mongo?" bằng lý lẽ chứ không phải cảm tính.
Kiểm chứng ngày 2026-10-05. Mọi số đo "đã chạy" trong file này (cả phần cũ lẫn phần mới) lấy trên mongod 8.3.4 tự dựng (replica set một node), driver
mongodb7.7.0, Mongoose 9.10.4, Node 24, TypeScript 7.0.2--strict. Chưa đo trên MongoDB 9.0: trang Release notes liệt kê 9.0 là bản stable hiện tại (8.3, 8.0, 7.0 là các bản trước), nhưng ngày phát hành 9.0 không có trên trang đó, nên chưa xác minh; hành vi các lệnh bên dưới trên 9.0 cũng chưa xác minh. Giới hạn và khái niệm tra theo Limits and Thresholds và Causal Consistency and Read/Write Concerns. Tài liệu Prisma ở GĐ05 ghi Prisma 7.x không hỗ trợ MongoDB; mục 8 dùng Mongoose.
1. NoSQL là gì — và "No" nghĩa là gì#
Định nghĩa. NoSQL không phải "không có SQL", mà là "không chỉ SQL" (Not only SQL). Đó là một nhóm database từ bỏ mô hình quan hệ + schema cứng để đổi lấy thứ khác: khả năng scale ngang, schema linh hoạt, hoặc mô hình dữ liệu phù hợp hơn với một bài toán cụ thể.
Bốn họ chính:
| Họ | Đại diện | Mô hình dữ liệu | Hợp với |
|---|---|---|---|
| Document | MongoDB, CouchDB, DocumentDB | JSON lồng nhau, mỗi document tự chứa | Dữ liệu hình dạng thay đổi, đọc cả cụm một lần |
| Key-Value | Redis, DynamoDB, etcd | key → value thô | Cache, session, counter, feature flag |
| Wide-column | Cassandra, ScyllaDB, HBase | Hàng có số cột động, phân vùng theo khoá | Ghi cực nhiều, time-series, log ở quy mô lớn |
| Graph | Neo4j, Neptune | Node + cạnh có thuộc tính | Mạng xã hội, đề xuất, phát hiện gian lận, phả hệ |
Tại sao quan trọng. Câu hỏi phỏng vấn không bao giờ là "Mongo có gì hay". Nó là "vì sao anh chọn cái này". Muốn trả lời được, phải biết mình đang từ chối cái gì.
Pitfall #1 — dùng Mongo vì "không phải viết migration". Đây là lý do sai phổ
biến nhất. Mongo không có migration ở tầng DB, nhưng dữ liệu cũ vẫn tồn tại với
hình dạng cũ. Bạn không bỏ migration — bạn chuyển nó vào code ứng dụng, nơi nó
không được kiểm tra và không ai nhớ. Sáu tháng sau, collection users của bạn có
ba thế hệ schema sống chung, và mọi hàm đọc đều phải if (user.profile?.name ?? user.name).
Đánh số phiên bản schema trong document#
Cách làm có kiểm soát: mỗi document mang schemaVersion, code đọc có một hàm nâng mọi thế hệ lên dạng hiện tại, và một job nền backfill dần các document cũ. Ví dụ đổi name thành profile.name:
Đã chạy (mongod 8.3.4, tsc --strict): với hai document V1 và một V2, toV2 đọc ra đủ ba tên; backfill báo matched 2, modified 2; chạy lại khớp 0 document. Mẫu này cũng là cách giữ code và dữ liệu cũ sống chung an toàn khi deploy: code mới đọc được cả V1 lẫn V2 trước, backfill sau, và chỉ khi không còn V1 mới xoá nhánh đọc V1.
2. Khi nào Mongo đúng, khi nào Postgres đúng#
Đây là mục quan trọng nhất của cả giai đoạn. Học thuộc bảng này.
| Tình huống | Chọn | Vì sao |
|---|---|---|
| Có quan hệ rõ ràng, cần JOIN thường xuyên | Postgres | JOIN là thứ Mongo làm được nhưng làm dở ($lookup không dùng index tốt như JOIN) |
| Cần transaction nhiều bảng, tính đúng đắn tiền bạc | Postgres | ACID mặc định, không cần replica set, không giới hạn 60s |
| Dữ liệu là "cụm tự chứa", luôn đọc/ghi cả cụm | Mongo | Một document = một lần đọc đĩa, không JOIN |
| Schema thật sự biến thiên theo từng bản ghi | Mongo | Ví dụ: catalog sản phẩm mỗi ngành một tập thuộc tính |
| Event log / audit / telemetry ghi rất nhiều, đọc theo khoảng | Mongo hoặc Cassandra | Ghi nhanh, sharding sẵn, TTL index |
| Cần full-text search cơ bản | Postgres (tsvector) | → GĐ05 mục 14 |
| Cần search thật sự (relevance, facet, typo) | Elasticsearch | → GĐ11 |
| Chưa biết hình dạng dữ liệu cuối cùng | Postgres + cột JSONB | Có cả hai: cấu trúc ở nơi cần, linh hoạt ở nơi chưa rõ |
Câu chốt cho phỏng vấn:
"Tôi chọn Postgres vì dữ liệu của tôi có quan hệ và tôi cần transaction xuyên nhiều bảng. Postgres cũng có
JSONBnên phần dữ liệu chưa định hình vẫn linh hoạt được. Tôi sẽ cân nhắc Mongo nếu dữ liệu là cụm tự chứa đọc-ghi trọn gói, ví dụ document/CMS, hoặc event log ghi lớn."
Pitfall #2 — "Mongo scale tốt hơn". Ở quy mô một startup, không đúng. Postgres một node xử lý được hàng chục nghìn TPS. Bạn sẽ chạm giới hạn kỹ năng trước khi chạm giới hạn Postgres. Sharding Mongo cũng không miễn phí: chọn sai shard key là một trong những sai lầm khó sửa nhất trong nghề.
3. Mô hình dữ liệu: embed hay reference#
Đây là quyết định thiết kế duy nhất thực sự quan trọng trong Mongo.
Embed (nhúng) — đặt dữ liệu con vào trong document cha:
Reference (tham chiếu) — lưu id, ghép ở lần đọc sau:
Quy tắc quyết định — ba câu hỏi, theo thứ tự:
- Có luôn đọc cùng nhau không? Có → nghiêng về embed.
- Dữ liệu con có bị truy vấn độc lập không? Có → reference.
- Số lượng con có chặn trên không? Không chặn trên → bắt buộc reference.
Quy tắc kinh nghiệm (MongoDB gọi là "rule of thumb" one-to-N):
| Quan hệ | Số lượng | Cách làm |
|---|---|---|
| One-to-few | < ~100, biết chặn trên | Embed mảng con |
| One-to-many | hàng nghìn | Reference: con lưu parentId |
| One-to-squillions | không giới hạn (log, comment viral) | Reference một chiều từ con, không giữ mảng ở cha |
Pitfall #3 — mảng không chặn trên. Document Mongo giới hạn cứng 16 MB.
Một post.comments: [] nhúng trông đẹp cho tới khi một bài viral có 200k comment.
Còn trước cả khi chạm 16 MB, mỗi lần thêm comment là một lần ghi lại cả document,
và document càng phình thì mỗi lần ghi, mỗi lần đọc càng tốn (bộ nhớ đệm chứa được ít
document hơn). Đừng giải thích bằng chuyện "document mọc ra khỏi chỗ cũ trên đĩa gây phân
mảnh": đó là hành vi của engine lưu trữ MMAPv1 cũ, đã bị gỡ; MongoDB hiện dùng WiredTiger.
Pitfall #4 — nhúng dữ liệu hay đổi. Nếu bạn nhúng { authorName } vào mỗi post
và người dùng đổi tên, bạn phải cập nhật hàng nghìn document. Denormalize là hợp lệ,
nhưng phải cố ý và phải có job đồng bộ, không phải làm vì lười.
Sơ đồ: chọn embed hay reference
Ví dụ: addresses của user (tối đa vài cái, luôn đọc cùng user) là embed;
comments của post (không chặn trên, có trang riêng) là reference.
Code tham chiếu, chưa chạy: sơ đồ chỉ tóm lại ba câu hỏi ở trên.
4. _id, ObjectId và khoá tự nhiên#
_id là khoá chính bắt buộc, unique, index tự động. Mặc định là ObjectId — 12 byte:
Hệ quả có ích: ObjectId tăng dần theo thời gian, nên sort({_id: -1}) gần
như là "mới nhất trước" mà không cần index thêm, và _id.getTimestamp() cho biết
thời điểm tạo.
Hệ quả nguy hiểm: ObjectId lộ thời gian tạo và có phần đoán được. Đừng dùng nó làm token, mã mời, hay bất cứ thứ gì cần bí mật. Với id lộ ra ngoài (URL, API công khai), dùng UUIDv7 hoặc ULID nếu vẫn muốn tính sắp xếp theo thời gian.
5. Index trong Mongo#
Khái niệm giống Postgres (→ GĐ05), khác ở chi tiết.
Quy tắc ESR — thứ tự cột trong compound index. Đây là thứ hay bị hỏi:
Equality trước → Sort giữa → Range sau.
Query find({ userId: X, status: {$ne: 'cancelled'} }).sort({ createdAt: -1 }) thì index
đúng là { userId: 1, createdAt: -1, status: 1 } — userId là equality, createdAt
là sort, status là range. ($in dưới 201 phần tử đi kèm sort được Mongo tách thành
nhiều dải rồi trộn SORT_MERGE, nên gần như equality; ví dụ range thật là $ne, $gt,
$lt hoặc $in từ 201 phần tử.)
Vì sao ESR: so hai thứ tự index (kết quả mong đợi)
Mong đợi khi explain("executionStats"): với {userId, status, createdAt} kết quả của
$ne thành hai dải status nên createdAt không còn đúng thứ tự trên toàn bộ kết quả:
Mongo phải sắp xếp lại trong bộ nhớ (stage SORT chặn ở trên IXSCAN). Với
{userId, createdAt, status} index đã sẵn thứ tự createdAt, không có stage SORT;
status được lọc ngay trong index. Nếu đổi $ne thành $in dưới 201 phần tử thì
{userId, status, createdAt} lại dùng được SORT_MERGE (không sort chặn), nên đừng dùng
$in nhỏ làm ví dụ cho "sai thứ tự" (theo trang ESR của MongoDB, chưa chạy explain).
Sai thường gặp: đặt range trước sort rồi nghĩ "index có đủ cột là xong".
Code tham chiếu, chưa chạy với collection orders này (cơ chế đã đo ở bài tập mục 12 trên activity_events).
Đọc query plan:
Nhìn stage: IXSCAN (dùng index) tốt, COLLSCAN (quét cả collection) xấu trên
bảng lớn. So nReturned với totalDocsExamined — chênh lệch lớn nghĩa là index
lọc chưa đủ chặt.
Covered query. Nếu mọi field cần đến đều nằm trong index, Mongo trả kết quả
mà không chạm document → nhanh hơn nhiều. Kiểm tra: totalDocsExamined === 0.
TTL index là một tính năng thật sự tiện và Postgres không có sẵn: đặt
expireAfterSeconds thì Mongo tự xoá document hết hạn (một background job chạy
mỗi 60s). Hợp với session, OTP, cache, dữ liệu tạm.
Pitfall #5 — TTL không chính xác. Background job chạy mỗi 60 giây và có thể trễ
hơn khi tải cao. Không dùng TTL làm cơ chế bảo mật (kiểu "token tự hết hạn"). Vẫn
phải kiểm tra expiresAt trong code.
Time-series collection và collMod#
Với log ghi liên tục theo thời gian (đo đạc, hành vi người dùng), Mongo có collection kiểu time-series: khai báo timeField (bắt buộc), metaField (nhãn không đổi theo từng điểm, ví dụ userId, type) và granularity; TTL khai báo ngay lúc tạo. Dữ liệu được gom thành "bucket" nội bộ nên (theo tài liệu MongoDB, chưa đo ở đây) lưu gọn và quét theo khoảng thời gian nhanh hơn.
Đã chạy (mongod 8.3.4): listCollections báo type: "timeseries"; collMod đổi TTL từ 90 ngày xuống 30 ngày và granularity từ seconds sang minutes thành công. Các giới hạn quan sát được: hạ granularity ngược lại seconds bị từ chối (InvalidOptions, "Can only transition from 'seconds' to 'minu..."): chỉ được nới thô hơn, không được tinh lại; đổi timeField bằng collMod bị từ chối (IDLUnknownField); updateOne (không phải multi) trên collection này báo lỗi "Cannot perform a non-multi update on a...". Vì vậy time-series hợp với log chỉ-thêm; dữ liệu cần sửa từng bản ghi thì dùng collection thường như bài tập mục 12. Nếu chọn time-series thì validator $jsonSchema và TTL của bài tập mục 12 cần đặt lại theo cách khai báo ở trên (chưa kiểm validator trên collection time-series).
6. Aggregation pipeline#
Đây là "SQL của Mongo". Dữ liệu chảy qua từng stage, mỗi stage biến đổi rồi đưa sang stage sau.
Dữ liệu chảy qua pipeline (kết quả mong đợi)
Kết quả mong đợi: tối đa 10 doc dạng { email, total, count }. Đặt $limit trước
$lookup (như trên) làm số lần tra giảm từ số user xuống 10; đặt sau thì $lookup
chạy cho mọi user. Code tham chiếu, chưa chạy với dữ liệu orders; aggregation tương tự
đã chạy ở bài tập mục 12.
Đối chiếu với SQL:
| Aggregation | SQL |
|---|---|
$match | WHERE |
$group | GROUP BY + hàm tổng hợp |
$sort / $limit / $skip | ORDER BY / LIMIT / OFFSET |
$lookup | LEFT JOIN (một mình nó; xem ngay dưới khi đi kèm $unwind) |
$unwind | mở mảng thành nhiều hàng; mặc định bỏ document có mảng rỗng |
$lookup + $unwind: "$x" | INNER JOIN |
$lookup + $unwind: { path: "$x", preserveNullAndEmptyArrays: true } | LEFT JOIN |
$project | danh sách cột SELECT |
$facet | nhiều query song song trên cùng input |
Đã chạy (mongod 8.3.4): ba đơn của user 1, 2 và 3, trong đó user 3 đã bị xoá khỏi users (Mongo không có khoá ngoại nên đơn mồ côi tồn tại được). $group, $lookup, $unwind: "$user" trả về user 1 và 2; thêm preserveNullAndEmptyArrays: true thì trả về cả user 3. Pipeline ở đầu mục này vì vậy lặng lẽ bỏ các đơn mồ côi; muốn giữ hãy dùng dạng có preserveNullAndEmptyArrays.
Quy tắc vàng: $match càng sớm càng tốt. Stage đầu tiên là stage duy nhất dùng
được index (trừ vài trường hợp Mongo tự đẩy $match lên). $match sau $group
là quét toàn bộ kết quả trung gian trong RAM.
Pitfall #6 — $lookup là cái bẫy. Nó chạy về bản chất như một vòng lặp tra cứu
cho mỗi document đầu vào. Với 10k document đầu vào, đó là 10k lần tra. Nếu bạn
thấy mình viết nhiều $lookup lồng nhau, đó là tín hiệu dữ liệu của bạn là dữ
liệu quan hệ và bạn đã chọn sai database.
Pitfall #7 — giới hạn 100 MB RAM mỗi stage. $group và $sort trên tập lớn
vượt 100 MB thì từ 6.0 mặc định ghi tạm xuống đĩa (allowDiskUseByDefault, chậm hơn);
chỉ khi tham số này tắt hoặc truyền allowDiskUse: false mới lỗi
QueryExceededMemoryLimit. Ghi ra đĩa là băng cứu thương, không phải cách chữa. Cách chữa là $match sớm hơn
hoặc pre-aggregate.
Cheatsheet — query operators, update operators và Mongoose#
Các dấu $ không phải một nhóm duy nhất. Vị trí quyết định ý nghĩa: trong filter
thì $gte là điều kiện tìm kiếm; trong update thì $set sửa document; trong
aggregation thì $set là một stage. Mongoose thêm các method .find(), .select(),
.save() lên trên MongoDB driver, nhưng cú pháp object chứa $ vẫn là MongoDB.
Toán tử filter — dùng trong find(), findOne() và $match#
| Nhóm | Toán tử | Ý nghĩa / ví dụ |
|---|---|---|
| So sánh | $eq, $ne | Bằng / khác: { status: { $ne: 'deleted' } } |
| So sánh | $gt, $gte, $lt, $lte | Lớn hơn, lớn hơn hoặc bằng, nhỏ hơn, nhỏ hơn hoặc bằng: { age: { $gte: 18 } } |
| So sánh | $in, $nin | Giá trị nằm trong / ngoài danh sách: { role: { $in: ['admin', 'editor'] } } |
| Logic | $and, $or, $nor, $not | Tất cả đúng, ít nhất một đúng, không điều kiện nào đúng, phủ định một điều kiện. Các field trên cùng filter mặc định đã được nối bằng AND. |
| Mảng | $all | Mảng phải chứa tất cả giá trị: { tags: { $all: ['node', 'mongodb'] } } |
| Mảng | $elemMatch | Có một phần tử mảng thỏa mọi điều kiện bên trong. Dùng khi điều kiện cần đúng trên cùng một phần tử. |
| Mảng | $size | Mảng có đúng số phần tử: { tags: { $size: 3 } } |
| Field/type | $exists | Field có tồn tại không: { phone: { $exists: true } } |
| Field/type | $type | Lọc theo BSON type: { age: { $type: 'number' } } |
| Khác | $regex | Khớp biểu thức chính quy: { name: { $regex: '^an', $options: 'i' } } |
| Khác | $expr | Dùng expression để so sánh/tính toán giữa các field của cùng document. |
| Khác | $mod | Lọc theo phép chia lấy dư: { qty: { $mod: [5, 0] } } |
| Khác | $jsonSchema | So khớp document theo JSON Schema; cũng có thể dùng schema trong collection validator. |
| JavaScript | $where | Chạy JavaScript để lọc; deprecated từ MongoDB 8.0, không tận dụng index. Ưu tiên operator chuẩn hoặc $expr. |
| Bitwise | $bitsAllSet, $bitsAllClear | Tất cả bit chỉ định đang bật / tắt. |
| Bitwise | $bitsAnySet, $bitsAnyClear | Có ít nhất một bit chỉ định đang bật / tắt. |
| Địa lý | $geoWithin, $geoIntersects | Nằm trong vùng / giao với hình học chỉ định. |
| Địa lý | $near, $nearSphere | Tìm gần một điểm; cần geospatial index phù hợp. |
Ví dụ $expr so sánh hai field và $elemMatch bắt buộc hai điều kiện đúng trên
cùng một phần tử mảng:
null và field không tồn tại khác nhau. { phone: null } có thể khớp cả field
phone bằng null lẫn document không có field đó. $exists: true khớp field có
mặt, kể cả giá trị null; { phone: { $ne: null } } chỉ khớp giá trị khác null.
MongoDB $exists
· $where và lý do nên tránh
$text — full-text search cơ bản#
$text tìm từ trong field có text index. Mỗi collection chỉ có tối đa một text
index. Từ khóa cách nhau bằng khoảng trắng mặc định được tìm theo OR; dùng dấu
ngoặc kép để tìm cụm từ, dấu trừ để loại từ.
$text không tự sắp xếp theo độ liên quan; cần chiếu và sort theo textScore
như ví dụ. Nó có giới hạn kết hợp với một số toán tử/index đặc biệt. Với
autocomplete, fuzzy search, facets hoặc analyzer đa ngôn ngữ, xem MongoDB Search
và GĐ11 — Elasticsearch để so sánh lựa chọn.
MongoDB $text ·
Text index
Projection — chọn field hoặc phần tử mảng trong kết quả#
Trong native driver, projection là đối số thứ hai của find(). Trong Mongoose,
dùng .select() cho field thông thường.
| Cú pháp | Ý nghĩa |
|---|---|
{ name: 1, email: 1 } hoặc .select('name email') | Chỉ lấy các field được nêu. |
{ password: 0 } hoặc .select('-password') | Loại field được nêu. Không trộn include và exclude, trừ _id. |
{ 'items.$': 1 } | Chỉ lấy phần tử đầu tiên khớp điều kiện query. |
{ items: { $elemMatch: { price: { $gt: 10 } } } } | Chỉ lấy phần tử đầu tiên khớp điều kiện projection. |
{ items: { $slice: 5 } } | Chỉ lấy một số phần tử đầu/cuối của mảng. |
{ score: { $meta: 'textScore' } } | Lấy metadata như điểm liên quan của $text. |
Toán tử cập nhật field — dùng trong updateOne() / updateMany()#
| Toán tử | Ý nghĩa |
|---|---|
$set | Gán giá trị cho field; dùng dot notation cho field lồng nhau. |
$unset | Xóa field. |
$inc | Tăng hoặc giảm số; số âm làm giảm. Field chưa có sẽ được tạo. |
$mul | Nhân field kiểu số với một giá trị. |
$min, $max | Chỉ ghi giá trị mới nếu nó nhỏ hơn / lớn hơn giá trị hiện tại. |
$rename | Đổi tên field. |
$currentDate | Gán ngày giờ hiện tại. |
$setOnInsert | Chỉ gán khi thao tác upsert tạo document mới. |
$bit | Thực hiện phép AND, OR hoặc XOR trên field số nguyên. |
MongoDB Field Update Operators
Toán tử cập nhật mảng#
| Toán tử | Ý nghĩa |
|---|---|
$push | Thêm phần tử; phần tử trùng vẫn được thêm. |
$addToSet | Thêm phần tử nếu chưa có. |
$pull | Xóa mọi phần tử khớp giá trị/điều kiện. |
$pullAll | Xóa các giá trị trong danh sách. |
$pop | Xóa đầu mảng với -1, cuối mảng với 1. |
$each | Modifier của $push/$addToSet để thêm nhiều phần tử. |
$position | Modifier của $push để chọn vị trí chèn. |
$sort | Modifier của $push để sắp xếp phần tử sau khi thêm. |
$slice | Modifier của $push để giới hạn kích thước mảng sau khi thêm. |
Cập nhật một phần tử trong mảng — positional operators#
| Cú pháp | Ý nghĩa |
|---|---|
items.$.status | Sửa phần tử đầu tiên khớp điều kiện mảng trong filter. |
items.$[].status | Sửa mọi phần tử của mảng. |
items.$[item].status | Sửa các phần tử khớp điều kiện trong arrayFilters. |
MongoDB Array Update Operators · Filtered positional operator
Aggregation pipeline stages#
Mỗi stage nhận dữ liệu từ stage trước và đưa kết quả cho stage sau. Dùng trong
Model.aggregate([...]) hoặc db.collection.aggregate([...]).
| Stage | Tác dụng gần đúng trong SQL / ghi chú |
|---|---|
$match | WHERE; lọc document, dùng query operators như $gte, $in, $text. |
$project | SELECT; chọn, bỏ hoặc tính field đầu ra. |
$set / $addFields | Thêm hoặc tính field, giữ các field hiện có. |
$unset | Bỏ field khỏi kết quả. |
$group | GROUP BY; gom nhóm theo _id, dùng accumulator bên dưới. |
$sort, $skip, $limit | ORDER BY, OFFSET, LIMIT. |
$unwind | Tách từng phần tử mảng thành document riêng. |
$lookup | Join với collection khác. |
$count | Đếm document đi tới stage này. |
$facet | Chạy nhiều pipeline nhánh trên cùng dữ liệu đầu vào. |
$bucket, $bucketAuto, $sortByCount | Gom document vào nhóm theo khoảng cố định/tự tính, hoặc nhóm và đếm theo giá trị. |
$replaceRoot / $replaceWith | Thay document hiện tại bằng một document lồng bên trong. |
$unionWith | Kết hợp pipeline với kết quả từ collection khác. |
$merge, $out | Ghi kết quả cuối vào collection. |
$graphLookup | Thực hiện lookup đệ quy, ví dụ lần theo cây quan hệ cha-con. |
$geoNear | Tìm và sắp xếp theo khoảng cách; cần geospatial index và phải là stage đầu tiên. |
$sample | Lấy mẫu ngẫu nhiên từ luồng document. |
$changeStream | Mở luồng thay đổi; thường dùng qua .watch() và phải là stage đầu pipeline. |
$setWindowFields | Tính toán theo cửa sổ dữ liệu; có từ MongoDB 5.0. |
$search | Stage của MongoDB Search; khả năng dùng tùy deployment/version. Không nhầm với $text: { $search: ... }. |
$searchMeta | Trả metadata của truy vấn MongoDB Search; hỗ trợ tùy deployment. |
Aggregation expressions và accumulator#
Expression thường dùng bên trong $project, $set, $group hoặc $expr. Trong
expression, '$amount' là tham chiếu field amount; dùng $literal khi cần một
literal mà không muốn MongoDB diễn giải như field path.
| Nhóm | Toán tử thường gặp | Ví dụ / mục đích |
|---|---|---|
| So sánh / logic | $eq, $gt, $gte, $lt, $lte, $and, $or, $not | So sánh giá trị expression: { $gte: ['$total', 100] }. |
| Số học | $add, $subtract, $multiply, $divide, $mod, $round, $abs | Tính toán với số và một số phép toán với ngày. |
| Điều kiện | $cond, $ifNull, $switch | Chọn kết quả theo điều kiện hoặc dùng giá trị mặc định. |
| Chuỗi | $concat, $toLower, $toUpper, $trim, $split, $regexFind | Ghép, chuẩn hóa, tách hoặc tìm trong chuỗi. |
| Mảng | $size, $arrayElemAt, $filter, $map, $reduce, $concatArrays, $slice | Đếm, lấy, lọc, biến đổi hoặc ghép mảng. |
| Ngày | $year, $month, $dayOfMonth, $dateToString, $dateAdd, $dateDiff | Trích xuất và tính toán với ngày. |
| Chuyển kiểu | $toString, $toInt, $toDate, $convert, $type | Đổi hoặc kiểm tra BSON type. |
Accumulator trong $group | $sum, $avg, $min, $max, $push, $addToSet, $first, $last | Tổng, trung bình, min/max hoặc gom giá trị trong nhóm. |
Với $first và $last, thêm $sort trước $group nếu cần thứ tự xác định.
Ví dụ tổng tiền và số đơn theo khách hàng:
Mongoose methods: gọi method nào trên đối tượng nào?#
| Đối tượng | Method | Dùng để / kết quả |
|---|---|---|
| Model | .find(filter) | Query nhiều document; kết quả là mảng. |
| Model | .findOne(filter) / .findById(id) | Query một document; không thấy thì null. |
| Query | .select(fields) | Chọn hoặc loại field trong kết quả. |
| Query | .sort(), .skip(), .limit() | Sắp xếp và phân trang offset. Với offset rất lớn, cân nhắc keyset pagination. |
| Query | .populate(path) | Tải document được tham chiếu từ field ref. |
| Query | .lean() | Kết quả là plain JavaScript object; không có .save(), getter, virtual hoặc change tracking của document. |
| Query | .exec() | Thực thi rõ ràng và trả Promise; await query cũng thực thi query. |
| Query | .countDocuments(filter), .exists(filter), .distinct(field) | Đếm, kiểm tra tồn tại hoặc lấy giá trị duy nhất. |
| Query | .orFail() | Ném lỗi nếu query không tìm thấy document, thay vì nhận null. |
| Model | .create(data) | Tạo và lưu document; nhận document đã lưu. |
| Document | .save() | Lưu document mới hoặc các field đã sửa; chạy validation và save middleware đã cấu hình. |
| Document | .validate() | Chạy validation mà chưa lưu. |
| Model | .updateOne(), .updateMany() | Cập nhật theo filter; nhận kết quả như matchedCount, modifiedCount. |
| Model | .findOneAndUpdate(), .findByIdAndUpdate() | Tìm và cập nhật; dùng { returnDocument: 'after', runValidators: true } nếu cần document sau cập nhật và update validation. |
| Model | .deleteOne(filter), .deleteMany(filter) | Xóa theo filter. |
| Document | .deleteOne() | Xóa document cụ thể đã tải về. |
| Document | .toObject(), .toJSON() | Chuyển document thành object/JSON thông thường. |
| Document | .isModified(path) | Kiểm tra field đã bị sửa chưa. |
Chọn giữa .save() và updateOne(): nếu đã tải document và muốn thay đổi nó
theo validation/save middleware của Mongoose, sửa field rồi gọi .save(). Nếu chỉ
cần cập nhật trực tiếp theo điều kiện và không cần lấy document về, dùng
updateOne(). Với findOneAndUpdate(), mặc định kết quả là document trước khi
cập nhật; đặt returnDocument: 'after' để lấy bản mới.
Mongoose Documents ·
Mongoose findOneAndUpdate()
Lưu ý aggregation trong Mongoose: pipeline không được Mongoose cast theo
schema, và kết quả aggregate là object thường. Nếu lọc theo _id, truyền
ObjectId thay vì chuỗi:
Mongoose Aggregate API · MongoDB Query Predicates · MongoDB Aggregation Stages · MongoDB Aggregation Expressions
7. Transaction trong Mongo#
Từ 4.0, Mongo có multi-document ACID transaction — nhưng có điều kiện và có giá.
Phải kiểm matchedCount. updateOne với điều kiện không khớp không ném lỗi, chỉ trả matchedCount: 0; thiếu dòng if thì transaction vẫn commit và đơn được tạo dù hết hàng. Đã chạy (mongod 8.3.4, tsc --strict): kho còn 1, ba người mua song song: một fulfilled, hai rejected: OutOfStockError, cuối cùng qty bằng 0 và đúng 1 đơn. Bản thiếu kiểm matchedCount với kho 0 vẫn tạo ra 1 đơn. withTransaction tự thử lại khi gặp lỗi tạm thời (transient); lỗi của bạn (như OutOfStockError) làm nó abort và ném ra ngoài.
Dựng replica set cho dev (một node). Chạy mongod --replSet rs0 --port 27018 --dbpath <thư mục> --bind_ip 127.0.0.1, rồi một lần duy nhất mongosh --port 27018 --eval 'rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "127.0.0.1:27018" }] })'; URL kết nối là mongodb://127.0.0.1:27018/?replicaSet=rs0. (Đã chạy đúng cách này với cổng khác, trên mongod 8.3.4; cổng 27018 ở đây chỉ để tránh đụng 27017 của Mongo đang có sẵn trên máy.) Với Docker, cổng trong container phải trùng cổng client dùng và host của rs.initiate phải là địa chỉ client nối được, nếu không driver nhận danh sách thành viên với host nội bộ và không kết nối được: docker run -p 127.0.0.1:27018:27018 mongo:8 --replSet rs0 --port 27018 --bind_ip_all, rồi rs.initiate với host: "127.0.0.1:27018" như trên. Lệnh Docker chưa chạy.
Điều kiện và giới hạn phải nhớ:
- Chỉ chạy trên replica set hoặc sharded cluster — standalone không có transaction. Dev local phải dựng replica set 1 node. Đã chạy trên mongod standalone: driver 7.7.0 báo "This MongoDB deployment does not support retryable writes. Please add retryWrites=false to your connection string." Thông báo này dễ gây hiểu nhầm: thêm
retryWrites=falsevẫn ra đúng lỗi đó khi dùng transaction; cách sửa thật là chạy replica set. - Mặc định timeout 60 giây; quá thì abort.
- Đắt hơn Postgres đáng kể; Mongo được thiết kế để bạn hiếm khi cần transaction.
Nguyên tắc. Trong Mongo, thao tác trên một document là atomic sẵn. Nếu bạn thấy mình cần transaction thường xuyên, hãy xem lại mục 3 — có thể ranh giới document của bạn đang sai. Nếu ranh giới đúng mà vẫn cần transaction liên tục, xem lại mục 2 — có thể bạn cần Postgres.
8. Mongo với TypeScript: driver, Mongoose, Prisma#
Ba lựa chọn:
| Cách | Ưu | Nhược |
|---|---|---|
Driver chính thức (mongodb) | Sát API, không ma thuật, nhanh | Tự lo validate, tự lo type |
| Mongoose | Schema + validation + hook + populate; hệ sinh thái lớn | Lớp ma thuật dày, hook khó debug, type kém tự nhiên |
| Prisma | Type-safe thật sự, cùng API với Postgres | Prisma 7.x không hỗ trợ MongoDB (xem GĐ05): dùng Prisma cho Postgres, driver hoặc Mongoose cho Mongo |
Khuyến nghị cho lộ trình này: dùng driver chính thức + Zod để validate ở biên. Lý do: bạn đã dùng Zod ở GĐ04 và GĐ07 rồi, và nó giữ validation ở một chỗ (biên API) thay vì chia đôi giữa DTO và schema Mongoose.
Schema validation ở tầng DB. Mongo có thể ép cấu trúc, và bạn nên bật nó cho collection quan trọng — đây là hàng rào cuối cùng khi code có bug:
Bẫy bsonType: "int". "int" là số nguyên 32 bit (tối đa 2 147 483 647). Tiền tính bằng đơn vị nhỏ nhất vượt mức đó rất nhanh (21,5 triệu USD tính bằng cent), và driver Node còn gửi số JS lớn hơn 2^31 dưới dạng double, không phải long. Đã chạy (mongod 8.3.4, driver 7.7.0, lỗi 121 là từ chối của validator):
| Khai báo | Giá trị chèn | Kết quả |
|---|---|---|
"int" | số JS 100 | chấp nhận |
"int" | số JS 3_000_000_000 | từ chối |
"int" | Long(3_000_000_000) | từ chối |
["int", "long"] | số JS 3_000_000_000 | từ chối (là double) |
["int", "long"] | Long(3_000_000_000) | chấp nhận |
"number" + multipleOf: 1 | số JS 3_000_000_000 | chấp nhận |
"number" + multipleOf: 1 | 1.5 hoặc -1 (với minimum: 0) | từ chối |
Vì vậy đoạn trên dùng "number" kèm multipleOf: 1 và minimum: 0 để ép "số nguyên không âm" mà không phụ thuộc cách driver mã hoá. Khi đọc lại, cả Long lẫn double nguyên đều ra số JS (an toàn tới 2^53). Dùng ["int", "long"] chỉ khi bạn chủ động ghi Long.
Mongoose 9: schema và kết nối tối thiểu#
Khi vào một team đã dùng Mongoose, đây là khung nhỏ nhất chạy được cho collection activity_events (Mongoose 9.10.4, Node 20.19 trở lên). Schema Mongoose kiểm ở tầng ứng dụng; nó không thay validator $jsonSchema của DB.
Đã chạy (mongod 8.3.4, tsc --strict): create hợp lệ trả type: "login" và occurredAt là Date (lấy từ default); create với userId ngắn và type lạ ném ValidationError ở cả hai trường; indexes() liệt kê _id_ và userId_1_occurredAt_-1. InferSchemaType làm TypeScript từ chối type: "hack" ngay lúc compile (ví dụ chạy phải ép as never mới gửi được giá trị sai). Hàm tên model(name, schema, collection) nhận tên collection ở đối số thứ ba để khỏi bị Mongoose tự số nhiều hoá.
9. Đọc/ghi trên replica set: consistency thật sự bạn nhận được#
Mongo cho bạn chỉnh mức đảm bảo trên từng thao tác. Hầu hết lập trình viên không biết điều này và nhận mặc định.
Write concern — ghi xong nghĩa là gì:
w | Nghĩa |
|---|---|
0 | Không chờ xác nhận. Nhanh nhất, mất dữ liệu được. Đừng dùng cho dữ liệu thật |
1 | Primary đã nhận. Nếu primary chết ngay sau đó, có thể mất |
"majority" | Đa số node đã nhận. Mặc định từ 5.0 và là thứ bạn nên dùng |
+ j: true | Đã ghi xuống journal trên đĩa |
Read concern — đọc thấy cái gì:
| Mức | Nghĩa |
|---|---|
"local" | Dữ liệu trên node đang đọc, có thể bị rollback |
"majority" | Chỉ dữ liệu đã được đa số xác nhận — không bao giờ bị rollback |
"linearizable" | Mạnh nhất, chậm nhất, chỉ đọc từ primary |
Read preference — đọc từ đâu: primary (mặc định), secondaryPreferred
(giảm tải nhưng có replication lag).
Pitfall #8 — read-after-write trên secondary. Người dùng sửa hồ sơ, app đọc lại từ secondary, thấy dữ liệu cũ. Đây chính xác là cái bẫy đã gặp ở GĐ21 với read replica Postgres. Cách chữa như nhau: đọc từ primary sau khi ghi, hoặc dùng causal consistency session của Mongo:
Theo tài liệu Mongo, cặp readConcern: majority + writeConcern: majority cho đủ bốn đảm bảo (đọc thấy ghi của mình, đọc không lùi, ghi không đảo thứ tự, ghi theo sau đọc); cặp local + w:1 không đảm bảo cái nào. Đã chạy đoạn trên (tsc --strict, mongod 8.3.4 một node) và đọc lại ra bản vừa ghi; vì chỉ có một node nên không tái hiện được độ trễ secondary, đừng xem đó là bằng chứng cho đảm bảo.
10. Change Streams#
Mongo cho phép "nghe" thay đổi trên collection theo thời gian thực (đọc từ oplog):
Sơ đồ: vòng đời resume token
Mong đợi: lưu token sau khi xử lý cho at-least-once (sự kiện có thể lặp, không mất).
Lưu trước thì ngược lại: có thể mất sự kiện. Token chỉ dùng được khi oplog còn giữ điểm
đó; offline quá lâu thì resumeAfter lỗi và phải đồng bộ lại từ đầu. Code tham chiếu, chưa chạy.
Dùng để làm gì. Đồng bộ sang Elasticsearch, invalidate cache, kích hoạt notification, xây CDC (Change Data Capture) sang data warehouse.
Pitfall #9 — quên lưu resume token. Không lưu thì mỗi lần worker restart bạn mất toàn bộ sự kiện xảy ra lúc offline. Lưu token sau mỗi sự kiện đã xử lý xong, không phải trước.
Pitfall #10 — coi change stream là message queue. Nó không có retry, không có DLQ, không có concurrency control. Dùng nó để đẩy việc vào queue thật (→ GĐ10), đừng xử lý nghiệp vụ nặng ngay trong vòng lặp.
11. Vận hành: những thứ làm sập production#
Không bao giờ mở Mongo ra Internet không xác thực. Đây là nguyên nhân của hàng
loạt vụ rò rỉ dữ liệu lớn — Mongo phiên bản cũ mặc định không bật auth và bind
0.0.0.0. Luôn: bật auth, bind vào mạng riêng, bật TLS.
NoSQL injection là có thật. Truyền thẳng object từ request vào query:
Chống bằng cách validate bằng Zod trước (z.string() loại object), và không
bao giờ so sánh mật khẩu trong query — băm và so ở tầng app (→ GĐ09).
Kết quả mong đợi: validate chặn operator injection
Mong đợi: payload {"password":{"$ne":null}} bị từ chối ở Zod (422, theo quy ước validate của GĐ04) trước khi chạm DB.
Hai lớp phòng thủ: kiểu string ở biên, và mật khẩu không bao giờ là điều kiện query.
express.json() không tự chặn: qs/JSON vẫn cho phép object lồng nhau.
Code tham chiếu, chưa chạy.
Giới hạn cần nhớ: document 16 MiB · độ sâu lồng nhau 100 · tên database dưới 64 byte
· namespace (<db>.<collection>) tối đa 255 byte với collection không shard (235 byte nếu
shard), theo trang Limits. Mức
"120 ký tự" từng ghi ở đây đã lỗi thời. Đã chạy trên mongod 8.3.4: namespace 214 ký tự tạo
được; 255 được, 256 báo InvalidNamespace; tên database 64 ký tự bị từ chối ("must be at
most 63 characters").
Backup. mongodump/mongorestore cho DB nhỏ; snapshot filesystem hoặc Atlas
continuous backup cho DB lớn. Nguyên tắc và restore drill giống hệt
GĐ14 — backup chưa từng restore thử
thì không phải backup.
12. Bài tập — mở rộng DA3#
Thêm một collection Mongo vào Mini SaaS API mà không thay thế Postgres. Đây chính là kiến trúc thực tế phổ biến: dùng đúng công cụ cho đúng việc.
Yêu cầu.
-
Postgres vẫn giữ
users,orders,subscriptions(dữ liệu quan hệ + tiền).Đáp án
Không đụng vào schema Postgres. Điểm cần chốt: id người dùng ở Postgres là UUID dạng chuỗi, nên
activity_events.userIdcũng là chuỗi UUID, không phải ObjectId (nếu khác kiểu thì ghép dữ liệu hai DB ở tầng app không bao giờ khớp). Mongo không có khoá ngoại: việc user có tồn tại hay không do tầng app kiểm tra. -
Mongo giữ
activity_events— log hành vi người dùng, schema biến thiên theo loại sự kiện.Đáp án
Mỗi sự kiện có phần cố định (
userId,type,occurredAt) vàpayloadkhác nhau theo loại:typescriptReadypurchase.payload.orderIdtham chiếuorders.idcủa Postgres (reference, không nhúng đơn hàng). -
Bật
$jsonSchemavalidator cho collection.Đáp án
typescriptReadyĐã chạy (MongoDB 8.3.4 tự dựng, driver
mongodbtừ npm): chèn 3 tài liệu sai (userId ngắn,typelạ,occurredAtlà chuỗi) đều bị từ chối với mã lỗi121. Chỉ đo trên 8.3.4; các phiên bản server khác chưa kiểm. -
Index:
{ userId: 1, occurredAt: -1 }+ TTL 90 ngày trênoccurredAt.Đáp án
typescriptReadyIndex TTL phải là index một trường; không gắn
expireAfterSecondsvào compound index. Đã chạy: sự kiện 100 ngày tuổi bị xoá sau vài giây khi đặtttlMonitorSleepSecs=1(chỉ để thử; mặc định tác vụ nền chạy mỗi 60 giây, mục 5 Pitfall #5). -
Viết một aggregation trả về top 10 người dùng hoạt động nhiều nhất tuần qua, kèm loại sự kiện phổ biến nhất của mỗi người.
Đáp án
typescriptReadyHai
$grouplà chủ ý: lần một đếm theo cặp (user, type), lần hai gộp theo user và lấy$firstsau khi đã sắp xếp. Thiếu$sortgiữa hai lần group thì$firstkhông xác định. Đã chạy trên 100 000 sự kiện của 500 user: kết quả khớp phép tính JS thuần (userId, total, topType của 10 dòng trùng).$matchtheo thời gian dùngIXSCANcủattl_90d(không phảiuser_time), quét khoảng 50 000 trong 100 000 tài liệu (7 trong 14 ngày). -
Chạy
explain("executionStats")và lưu lại kết quả trước/sau khi thêm index vào README.Đáp án
typescriptReadyĐã chạy (100 000 tài liệu):
Plan totalKeysExaminedtotalDocsExaminedTrước index SORT > COLLSCAN0 100000 Sau user_timeLIMIT > FETCH > IXSCAN20 20 Dán số
totalDocsExaminedvào README, không dán ms (đổi theo máy). Điều đáng ghi: index{userId, occurredAt}không phục vụ truy vấn chỉ lọc theo thời gian; index TTL làm việc đó. Đừng đo trên collection vài chục tài liệu: COLLSCAN vẫn nhanh và không chứng minh gì. -
Test bằng Testcontainers (
mongodbmodule) — cùng nguyên tắc ở GĐ13.Đáp án
Chưa chạy: Testcontainers cần Docker. Code tham chiếu; tên module
@testcontainers/mongodbvà hàmgetConnectionString()theo tài liệu Testcontainers, chưa xác minh trên máy này:typescriptReady
Câu phải trả lời được trong README: "Vì sao activity_events ở Mongo mà
orders ở Postgres?" Nếu bạn không viết được đoạn đó, bạn chưa hiểu mục 2.
Khung và mã dùng chung
Đoạn mẫu cho README. orders có quan hệ với users/subscriptions và là tiền: cần JOIN,
ràng buộc, transaction nhiều bảng, nên ở Postgres. activity_events là log ghi rất nhiều,
không sửa, không JOIN, hình dạng payload đổi theo loại sự kiện, cần TTL để tự dọn: hợp Mongo.
Quan hệ giữa hai bên chỉ là userId/orderId ở dạng tham chiếu.
Lỗi hay gặp.
userIdlưu ObjectId trong khi Postgres dùng UUID: ghép dữ liệu ở tầng app không khớp.- Tin rằng TTL xoá đúng giây: nó chạy theo chu kỳ.
- Bỏ
$sortgiữa hai$grouprồi lấy$first: loại "phổ biến nhất" ngẫu nhiên. - Kiểm
explaintrên dữ liệu quá nhỏ.
Biến thể cho Todo API (nếu chưa làm DA3)#
Bài tập trên giả định Mini SaaS có users, orders, subscriptions. Nếu bạn mới có Todo API (GĐ05 Dự án 2), làm bản này: Postgres vẫn giữ User và Todo; Mongo giữ todo_events, log "todo được tạo, hoàn thành, đổi tên".
-
Tạo
todo_eventsvới validator:userIdvàtodoIdlà chuỗi UUID,typethuộccreated | completed | renamed,occurredAtlà date; thêm index{ userId: 1, occurredAt: -1 }và TTL 90 ngày.Đáp án
typescriptReadyĐã chạy (mongod 8.3.4,
tsc --strict): chèn{ userId: "x", type: "deleted" }bị từ chối với mã121.userIdvàtodoIdlà chuỗi UUID vìUser.idvàTodo.idở Postgres là UUID chuỗi, không phải ObjectId. -
Viết aggregation "mỗi ngày user hoàn thành bao nhiêu todo", theo múi giờ Việt Nam. Vì sao kết quả khác khi dùng UTC?
Đáp án
typescriptReadyĐã chạy với hai sự kiện
completedlúc 2026-10-02 20:00 UTC và 2026-10-03 03:00 UTC: múi giờUTCra hai ngày (2026-10-02và2026-10-03, mỗi ngày 1);Asia/Ho_Chi_Minhra một ngày2026-10-03với 2 (20:00 UTC là 03:00 sáng hôm sau ở Việt Nam). Mongo lưu thời điểm ở UTC; gom theo ngày phải chỉ rõ múi giờ của người dùng (xem GĐ14 mục 4). -
Giải thích bằng một câu vì sao
todo_eventsở Mongo màTodoở Postgres.Đáp án
Todolà dữ liệu quan hệ (thuộc vềUserqua khoá ngoại, sửa từng dòng, cần ràng buộc) nên ở Postgres;todo_eventslà log chỉ-thêm, ghi nhiều, không JOIN,payloadđổi theo loại sự kiện và cần TTL tự dọn nên hợp Mongo. Hai bên chỉ nối nhau quauserIdvàtodoIdở dạng tham chiếu; không có khoá ngoại, nên việc todo còn tồn tại hay không do tầng app kiểm.
Done khi#
-
Kể được 4 họ NoSQL và một use case đúng cho mỗi họ
Đáp án
Document (MongoDB: dữ liệu hình dạng thay đổi, đọc trọn cụm), key-value (Redis: cache, session, counter), wide-column (Cassandra: ghi cực nhiều, time-series), graph (Neo4j: quan hệ nhiều tầng, gợi ý, gian lận). Sai thường gặp: nói "NoSQL = không có SQL"; đúng là "không chỉ SQL". Xem mục 1.
-
Bảo vệ được lựa chọn Postgres-vs-Mongo cho một bài toán cụ thể, có lý lẽ
Đáp án
Mẫu: "Dữ liệu có quan hệ và tiền, cần transaction nhiều bảng nên Postgres; phần chưa định hình dùng
JSONB. Chọn Mongo khi dữ liệu là cụm tự chứa đọc-ghi trọn gói hoặc log ghi lớn." Sai thường gặp: "Mongo scale tốt hơn" ở quy mô nhỏ. Xem mục 2. -
Áp dụng đúng quy tắc embed-vs-reference; biết giới hạn 16 MB và bẫy mảng không chặn trên
Đáp án
Ba câu hỏi theo thứ tự: luôn đọc cùng nhau? con có bị truy vấn riêng? số con có chặn trên? Không chặn trên thì bắt buộc reference. Document tối đa 16 MB, và mỗi lần thêm phần tử vào mảng nhúng là ghi lại cả document. Xem mục 3.
-
Biết ObjectId gồm gì và vì sao không dùng làm token
Đáp án
12 byte: 4 byte timestamp, 5 byte ngẫu nhiên theo process, 3 byte bộ đếm. Lộ thời gian tạo và đoán được một phần nên không làm token hay mã mời; id công khai dùng UUIDv7/ULID. Xem mục 4.
-
Viết compound index theo quy tắc ESR; đọc được
explain("executionStats")Đáp án
Equality, rồi Sort, rồi Range:
find({userId, status:{$ne:'cancelled'}}).sort({createdAt:-1})dùng{userId:1, createdAt:-1, status:1}. Trongexplain("executionStats")xemstage(IXSCANtốt,COLLSCANxấu), sonReturnedvớitotalDocsExamined. Tự kiểm: bài tập mục 12 choCOLLSCAN100 000 tài liệu giảm còn 20 sau index. Xem mục 5. -
Dùng TTL index; biết vì sao nó không chính xác tới giây
Đáp án
createIndex({expiresAt:1},{expireAfterSeconds:0})trên một trường. Tác vụ nền xoá chạy theo chu kỳ (mặc định 60 giây) và có thể trễ, nên vẫn phải kiểmexpiresAttrong code. Sai thường gặp: dùng TTL làm cơ chế bảo mật. Xem mục 5, Pitfall #5. -
Viết aggregation có
$match→$group→$sort→$lookup; biết vì sao$matchphải sớmĐáp án
Mẫu ở đầu mục 6:
$match(lọc, dùng index) rồi$group,$sort,$limit,$lookup.$matchphải sớm vì chỉ stage đầu dùng được index và để giảm dữ liệu trung gian (mỗi stage giới hạn 100 MB RAM, từ 6.0 tràn thì mặc định ghi tạm xuống đĩa). Tự kiểm: bài tập mục 12 có planIXSCANcho$match. Xem mục 6. -
Giải thích vì sao nhiều
$lookuplà tín hiệu chọn sai databaseĐáp án
$lookupvề bản chất là tra cứu cho từng document đầu vào, không mạnh như JOIN có planner; cần nhiều$lookupnghĩa là dữ liệu có quan hệ, nên Postgres hợp hơn hoặc phải denormalize có chủ đích. Xem mục 6, Pitfall #6. -
Biết transaction Mongo cần replica set, timeout 60s, và vì sao nên hiếm khi cần
Đáp án
Chỉ chạy trên replica set hoặc sharded cluster (standalone không có; dev dựng replica set 1 node), timeout 60 giây, đắt hơn Postgres. Thao tác trên một document đã atomic, nên cần transaction thường xuyên là dấu hiệu sai ranh giới document (mục 3) hoặc sai database (mục 2). Xem mục 7.
-
Phân biệt write concern / read concern / read preference; biết
majoritygiải quyết gìĐáp án
Write concern = ghi xong khi nào coi là xong (
majoritychờ đa số node, mặc định từ 5.0); read concern = đọc thấy gì (majoritykhông bao giờ bị rollback); read preference = đọc từ node nào (secondaryPreferredcó replication lag).majorityloại bỏ mất dữ liệu khi primary chết ngay sau ghi. Xem mục 9. -
Dùng change stream có lưu resume token; biết vì sao nó không thay thế queue
Đáp án
Lưu resume token sau mỗi sự kiện đã xử lý, khởi động lại bằng
resumeAfter. Không thay queue vì không có retry, DLQ, kiểm soát concurrency: dùng nó để đẩy việc vào queue thật (GĐ10). Xem mục 10. -
Chặn được NoSQL injection bằng validate ở biên
Đáp án
Validate bằng Zod ở biên (
z.string()loại{ "$ne": null }) và không bao giờ đưa mật khẩu vào điều kiện query; băm và so ở tầng app. Tự kiểm: gửi{"password":{"$ne":null}}phải ra 422 (quy ước validate của GĐ04). Xem mục 11. -
DA3 có collection Mongo chạy thật, có validator, có index, có số liệu explain trước/sau
Đáp án
Tự kiểm:
db.getCollectionInfos({name:'activity_events'})thấyoptions.validator;getIndexes()thấyuser_timevàttl_90d; README có hai khối explain trước/sau. Lời giải đầy đủ ở bài tập mục 12. -
Viết transaction trừ kho có kiểm
matchedCount, và nói được lỗi bạn gặp khi chạy nó trên mongod standaloneĐáp án
updateOne({ _id: sku, qty: { $gte: 1 } }, { $inc: { qty: -1 } }, { session }), rồiif (matchedCount === 0) throw ...trước khiinsertOneđơn. Kho còn 1 và ba người mua song song phải ra đúng 1 đơn,qtybằng 0. Trên standalone driver báo "does not support retryable writes": thêmretryWrites=falsekhông sửa được, phải chạy replica set. Xem mục 7. -
Chọn đúng
bsonTypecho số tiền nguyên (không dùng"int"cho số có thể vượt 2^31)Đáp án
{ bsonType: "number", minimum: 0, multipleOf: 1 }. Tự kiểm: chèn số JS3_000_000_000phải được chấp nhận,1.5và-1bị từ chối (mã121); với"int"thì3_000_000_000bị từ chối vì driver gửi nó dưới dạng double. Xem mục 8. -
Giải thích vì sao
$lookup+$unwindmặc định làm mất document, và cách giữ chúngĐáp án
$lookupcho mảng (rỗng khi không khớp);$unwind: "$x"bỏ document có mảng rỗng nên thànhINNER JOIN. Giữ bằng$unwind: { path: "$x", preserveNullAndEmptyArrays: true }. Tự kiểm: tạo một đơn cóuserIdkhông tồn tại trongusersvà so số dòng hai dạng. Xem mục 6. -
Nói được cặp read/write concern cần để causal consistency đảm bảo "đọc thấy ghi của mình"
Đáp án
Cả
readConcern: majorityvàwriteConcern: majority(kèmcausalConsistency: truetrên session). Để mặc định thì không được đảm bảo;local+w:1không đảm bảo gì. Xem mục 9. -
Dùng
schemaVersionđể đổi hình dạng document mà không làm gãy dữ liệu cũĐáp án
Một hàm
toV2(doc)nâng mọi thế hệ khi đọc; jobupdateMany({ schemaVersion: { $ne: 2 } }, [pipeline])backfill, chạy lại được (lần hai khớp 0). Chỉ xoá nhánh đọc V1 khi không còn document V1. Xem mục 1.
Câu hỏi mở / chưa giải quyết#
-
Mongoose hay driver thuần? Tài liệu này chọn driver + Zod để tránh hai nguồn schema. Nếu bạn vào một team đã dùng Mongoose sâu, đừng chống lại — nhưng hiểu rằng
pre/posthook là nơi bug ẩn náu.Hướng trả lời hiện tại
tạm thời giữ driver + Zod cho code mới; với codebase Mongoose có sẵn thì theo Mongoose và hạn chế hook. Đây là cách làm hợp lý hiện nay, không phải kết luận cuối. Mongoose 9.x (theo bảng phiên bản kiểm chứng ngày 2026-10-05) vẫn là lựa chọn chính thống.
-
Khi nào Mongo Atlas Search thay được Elasticsearch? Atlas Search (Lucene nhúng trong Atlas) đủ cho phần lớn nhu cầu nếu bạn đã ở Atlas. So sánh ở GĐ11 mục 8.
Hướng trả lời hiện tại
nếu dữ liệu đã nằm trong Atlas và nhu cầu là full-text, autocomplete, facet cơ bản thì thử Atlas Search trước để khỏi vận hành thêm một cụm và khỏi đồng bộ; nếu cần kiểm soát ranking sâu, tự lưu trữ hoặc không dùng Atlas thì Elasticsearch. Chưa đo cụ thể trong lộ trình này nên coi là hướng, không phải kết luận.
-
Sharding chưa được nói đến ở đây — cố ý. Chọn shard key là quyết định gần như không đảo ngược được, và bạn cần dữ liệu thật để chọn đúng. Xem GĐ21 mục 5 để hiểu vì sao sharding luôn là bước cuối cùng.
Hướng trả lời hiện tại
trước khi sharding hãy hết cách rẻ hơn (index đúng, schema đúng,
$matchsớm, scale dọc, replica đọc). Khi buộc phải shard, cần dữ liệu truy vấn thật để chọn shard key (cardinality cao, phân phối đều, có mặt trong phần lớn truy vấn). Không đưa quy tắc cụ thể ở đây.