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 mongodb 7.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ệnMô hình dữ liệuHợp với
DocumentMongoDB, CouchDB, DocumentDBJSON lồng nhau, mỗi document tự chứaDữ liệu hình dạng thay đổi, đọc cả cụm một lần
Key-ValueRedis, DynamoDB, etcdkey → value thôCache, session, counter, feature flag
Wide-columnCassandra, ScyllaDB, HBaseHàng có số cột động, phân vùng theo khoáGhi cực nhiều, time-series, log ở quy mô lớn
GraphNeo4j, NeptuneNode + cạnh có thuộc tínhMạ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:

typescriptReady
type UserV1 = { _id: ObjectId; name: string; schemaVersion?: undefined };type UserV2 = { _id: ObjectId; profile: { name: string }; schemaVersion: 2 };type UserDoc = UserV1 | UserV2;export function toV2(doc: UserDoc): UserV2 {            // đọc: một chỗ duy nhất biết thế hệ cũ  if (doc.schemaVersion === 2) return doc;  return { _id: doc._id, profile: { name: doc.name }, schemaVersion: 2 };}// backfill nền: update dạng pipeline, chạy lại được (chỉ chạm document chưa lên V2)await users.updateMany(  { schemaVersion: { $ne: 2 } },  [{ $set: { profile: { name: "$name" }, schemaVersion: 2 } }, { $unset: "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ốngChọnVì sao
Có quan hệ rõ ràng, cần JOIN thường xuyênPostgresJOIN 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ạcPostgresACID 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ụmMongoMột document = một lần đọc đĩa, không JOIN
Schema thật sự biến thiên theo từng bản ghiMongoVí 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ảngMongo hoặc CassandraGhi nhanh, sharding sẵn, TTL index
Cần full-text search cơ bảnPostgres (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ùngPostgres + cột JSONBCó 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ó JSONB nê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:

typescriptReady
// users collection{  _id: ObjectId("..."),  email: "harry@example.com",  addresses: [                       // nhúng    { label: "home", city: "Da Nang", street: "..." },    { label: "work", city: "Ho Chi Minh", street: "..." }  ]}

Reference (tham chiếu) — lưu id, ghép ở lần đọc sau:

typescriptReady
// posts collection{ _id: ObjectId("p1"), title: "...", authorId: ObjectId("u1") }// users collection{ _id: ObjectId("u1"), name: "Harry" }

Quy tắc quyết định — ba câu hỏi, theo thứ tự:

  1. Có luôn đọc cùng nhau không? Có → nghiêng về embed.
  2. Dữ liệu con có bị truy vấn độc lập không? Có → reference.
  3. 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ượngCách làm
One-to-few< ~100, biết chặn trênEmbed mảng con
One-to-manyhàng nghìnReference: con lưu parentId
One-to-squillionskhô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
textReady
 Dữ liệu con luôn được đọc cùng cha?   |-- không --> REFERENCE (con giữ parentId)   `-- có        |        v  Con có bị truy vấn độc lập (list, search riêng)?   |-- có ----> REFERENCE   `-- không        |        v  Số con có chặn trên rõ ràng (< ~100)?   |-- không --> REFERENCE (một chiều từ con; KHÔNG giữ mảng ở cha)   `-- có -----> EMBED

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:

textReady
| 4 byte timestamp | 5 byte random per-process | 3 byte counter |

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.

typescriptReady
db.orders.createIndex({ userId: 1, createdAt: -1 })       // compounddb.users.createIndex({ email: 1 }, { unique: true })db.sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 })  // TTLdb.products.createIndex({ tags: 1 })                       // multikey (trên mảng)db.users.createIndex(  { email: 1 },  { unique: true, partialFilterExpression: { deletedAt: null } }      // partial unique)

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)
typescriptReady
// Query: equality userId, range status, sort createdAtdb.orders.find({ userId: U, status: { $ne: 'cancelled' } }).sort({ createdAt: -1 })db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 })   // E, R, S  (sai thứ tự)db.orders.createIndex({ userId: 1, createdAt: -1, status: 1 })   // E, S, R  (đúng ESR)

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:

typescriptReady
db.orders.find({ userId: ObjectId("...") }).explain("executionStats")

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.

typescriptReady
await db.createCollection("activity_ts", {  timeseries: { timeField: "occurredAt", metaField: "meta", granularity: "seconds" },  expireAfterSeconds: 90 * 86400,});// đổi sau khi tạo bằng collMod:await db.command({ collMod: "activity_ts", expireAfterSeconds: 30 * 86400 });await db.command({ collMod: "activity_ts", timeseries: { granularity: "minutes" } });

Đã 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.

typescriptReady
db.orders.aggregate([  { $match: { createdAt: { $gte: ISODate("2026-01-01") }, status: "paid" } },  { $group: {      _id: "$userId",      total: { $sum: "$amountMinor" },      count: { $sum: 1 },      lastAt: { $max: "$createdAt" }  }},  { $sort:  { total: -1 } },  { $limit: 10 },  { $lookup: {                       // ~ LEFT JOIN (kết quả là MẢNG, có thể rỗng)      from: "users", localField: "_id", foreignField: "_id", as: "user"  }},  { $unwind: "$user" },              // mặc định BỎ doc có mảng rỗng => thành INNER JOIN  { $project: { _id: 0, email: "$user.email", total: 1, count: 1 } }])
Dữ liệu chảy qua pipeline (kết quả mong đợi)
textReady
 orders (N doc)   | $match  createdAt >= 2026-01-01, status = "paid"   -> giữ các đơn đã trả   | $group  _id = userId, total, count, lastAt          -> 1 doc / user   | $sort   total giảm dần   | $limit  10                                          -> chỉ còn 10 doc   | $lookup users (10 lần tra, không phải N lần)   | $unwind user   | $project { email, total, count }

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:

AggregationSQL
$matchWHERE
$groupGROUP BY + hàm tổng hợp
$sort / $limit / $skipORDER BY / LIMIT / OFFSET
$lookupLEFT JOIN (một mình nó; xem ngay dưới khi đi kèm $unwind)
$unwindmở 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
$projectdanh sách cột SELECT
$facetnhiề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.

typescriptReady
// Filter: tìm bài đã đăng có ít nhất 100 lượt xemconst posts = await Post.find({  status: 'published',  views: { $gte: 100 }})  .select('title author createdAt')  .populate('author', 'name')  .sort({ createdAt: -1 })  .limit(20)  .lean()

Toán tử filter — dùng trong find(), findOne() và $match#

NhómToán tửÝ nghĩa / ví dụ
So sánh$eq, $neBằng / khác: { status: { $ne: 'deleted' } }
So sánh$gt, $gte, $lt, $lteLớ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, $ninGiá trị nằm trong / ngoài danh sách: { role: { $in: ['admin', 'editor'] } }
Logic$and, $or, $nor, $notTấ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$allMảng phải chứa tất cả giá trị: { tags: { $all: ['node', 'mongodb'] } }
Mảng$elemMatchCó 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$sizeMảng có đúng số phần tử: { tags: { $size: 3 } }
Field/type$existsField có tồn tại không: { phone: { $exists: true } }
Field/type$typeLọc theo BSON type: { age: { $type: 'number' } }
Khác$regexKhớp biểu thức chính quy: { name: { $regex: '^an', $options: 'i' } }
Khác$exprDùng expression để so sánh/tính toán giữa các field của cùng document.
Khác$modLọc theo phép chia lấy dư: { qty: { $mod: [5, 0] } }
Khác$jsonSchemaSo khớp document theo JSON Schema; cũng có thể dùng schema trong collection validator.
JavaScript$whereChạ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, $bitsAllClearTất cả bit chỉ định đang bật / tắt.
Bitwise$bitsAnySet, $bitsAnyClearCó ít nhất một bit chỉ định đang bật / tắt.
Địa lý$geoWithin, $geoIntersectsNằm trong vùng / giao với hình học chỉ định.
Địa lý$near, $nearSphereTì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:

typescriptReady
await Invoice.find({ $expr: { $gt: ['$spent', '$budget'] } })await Order.find({  items: { $elemMatch: { price: { $gt: 10 }, qty: { $gte: 2 } } }})

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

typescriptReady
// Native MongoDB: khai báo index trên các field cần tìmdb.posts.createIndex({ title: 'text', body: 'text' })// Tìm cụm "backend guide", loại kết quả có từ draftdb.posts.find({ $text: { $search: '"backend guide" -draft' } })// Mongoose: khai báo tương đương trong schemaPostSchema.index({ title: 'text', body: 'text' })const posts = await Post.find({ $text: { $search: '"backend guide" -draft' } })  .select({ score: { $meta: 'textScore' } })  .sort({ score: { $meta: 'textScore' } })

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

Find projection operators

Toán tử cập nhật field — dùng trong updateOne() / updateMany()#

typescriptReady
await User.updateOne(  { _id: userId },  {    $set: { name: 'An' },    $inc: { loginCount: 1 },    $currentDate: { updatedAt: true }  })
Toán tửÝ nghĩa
$setGán giá trị cho field; dùng dot notation cho field lồng nhau.
$unsetXóa field.
$incTăng hoặc giảm số; số âm làm giảm. Field chưa có sẽ được tạo.
$mulNhân field kiểu số với một giá trị.
$min, $maxChỉ 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.
$currentDateGán ngày giờ hiện tại.
$setOnInsertChỉ gán khi thao tác upsert tạo document mới.
$bitThự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
$pushThêm phần tử; phần tử trùng vẫn được thêm.
$addToSetThêm phần tử nếu chưa có.
$pullXóa mọi phần tử khớp giá trị/điều kiện.
$pullAllXóa các giá trị trong danh sách.
$popXóa đầu mảng với -1, cuối mảng với 1.
$eachModifier của $push/$addToSet để thêm nhiều phần tử.
$positionModifier của $push để chọn vị trí chèn.
$sortModifier của $push để sắp xếp phần tử sau khi thêm.
$sliceModifier của $push để giới hạn kích thước mảng sau khi thêm.
typescriptReady
await User.updateOne(  { _id: userId },  { $push: { recentScores: { $each: [80, 95], $sort: -1, $slice: 10 } } })

Cập nhật một phần tử trong mảng — positional operators#

Cú phápÝ nghĩa
items.$.statusSửa phần tử đầu tiên khớp điều kiện mảng trong filter.
items.$[].statusSửa mọi phần tử của mảng.
items.$[item].statusSửa các phần tử khớp điều kiện trong arrayFilters.
typescriptReady
await Order.updateOne(  { _id: orderId },  { $set: { 'items.$[item].approved': true } },  { arrayFilters: [{ 'item.price': { $gt: 100 } }] })

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([...]).

StageTác dụng gần đúng trong SQL / ghi chú
$matchWHERE; lọc document, dùng query operators như $gte, $in, $text.
$projectSELECT; chọn, bỏ hoặc tính field đầu ra.
$set / $addFieldsThêm hoặc tính field, giữ các field hiện có.
$unsetBỏ field khỏi kết quả.
$groupGROUP BY; gom nhóm theo _id, dùng accumulator bên dưới.
$sort, $skip, $limitORDER BY, OFFSET, LIMIT.
$unwindTách từng phần tử mảng thành document riêng.
$lookupJoin với collection khác.
$countĐếm document đi tới stage này.
$facetChạy nhiều pipeline nhánh trên cùng dữ liệu đầu vào.
$bucket, $bucketAuto, $sortByCountGom document vào nhóm theo khoảng cố định/tự tính, hoặc nhóm và đếm theo giá trị.
$replaceRoot / $replaceWithThay document hiện tại bằng một document lồng bên trong.
$unionWithKết hợp pipeline với kết quả từ collection khác.
$merge, $outGhi kết quả cuối vào collection.
$graphLookupThực hiện lookup đệ quy, ví dụ lần theo cây quan hệ cha-con.
$geoNearTìm và sắp xếp theo khoảng cách; cần geospatial index và phải là stage đầu tiên.
$sampleLấy mẫu ngẫu nhiên từ luồng document.
$changeStreamMở luồng thay đổi; thường dùng qua .watch() và phải là stage đầu pipeline.
$setWindowFieldsTính toán theo cửa sổ dữ liệu; có từ MongoDB 5.0.
$searchStage của MongoDB Search; khả năng dùng tùy deployment/version. Không nhầm với $text: { $search: ... }.
$searchMetaTrả 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ómToán tử thường gặpVí dụ / mục đích
So sánh / logic$eq, $gt, $gte, $lt, $lte, $and, $or, $notSo sánh giá trị expression: { $gte: ['$total', 100] }.
Số học$add, $subtract, $multiply, $divide, $mod, $round, $absTính toán với số và một số phép toán với ngày.
Điều kiện$cond, $ifNull, $switchChọ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, $regexFindGhé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, $dateDiffTrí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, $lastTổ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:

typescriptReady
const totals = await Order.aggregate([  { $match: { status: 'paid' } },  {    $group: {      _id: '$customerId',      totalSpent: { $sum: '$amount' },      orderCount: { $sum: 1 }    }  },  { $sort: { totalSpent: -1 } },  { $limit: 10 }])

Mongoose methods: gọi method nào trên đối tượng nào?#

Đối tượngMethodDù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:

typescriptReady
import mongoose from 'mongoose'await User.aggregate([  { $match: { _id: new mongoose.Types.ObjectId(id) } }])

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

typescriptReady
class OutOfStockError extends Error {}const session = client.startSession()try {  await session.withTransaction(async () => {    const stock = await inventory.updateOne(      { _id: sku, qty: { $gte: 1 } },      { $inc: { qty: -1 } },      { session }    )    if (stock.matchedCount === 0) throw new OutOfStockError(sku)  // ném lỗi => abort, rollback    await orders.insertOne({ sku, userId, createdAt: new Date() }, { session })  })} finally {  await session.endSession()}

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=false vẫ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ƯuNhược
Driver chính thức (mongodb)Sát API, không ma thuật, nhanhTự lo validate, tự lo type
MongooseSchema + validation + hook + populate; hệ sinh thái lớnLớp ma thuật dày, hook khó debug, type kém tự nhiên
PrismaType-safe thật sự, cùng API với PostgresPrisma 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.

typescriptReady
import { z } from 'zod'import { MongoClient, ObjectId } from 'mongodb'const OrderSchema = z.object({  _id:        z.instanceof(ObjectId),  userId:     z.instanceof(ObjectId),  amountMinor: z.number().int().nonnegative(),   // tiền = số nguyên → GĐ14  currency:   z.string().length(3),  status:     z.enum(['pending', 'paid', 'refunded']),  createdAt:  z.date(),})type Order = z.infer<typeof OrderSchema>const db = client.db('app')const orders = db.collection<Order>('orders')     // collection có type

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:

typescriptReady
db.createCollection("orders", {  validator: { $jsonSchema: {    bsonType: "object",    required: ["userId", "amountMinor", "currency", "status"],    properties: {      amountMinor: { bsonType: "number", minimum: 0, multipleOf: 1 },      currency:    { bsonType: "string", minLength: 3, maxLength: 3 },      status:      { enum: ["pending", "paid", "refunded"] }    }  }},  validationLevel: "strict",  validationAction: "error"})

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áoGiá trị chènKết quả
"int"số JS 100chấp nhận
"int"số JS 3_000_000_000từ chối
"int"Long(3_000_000_000)từ chối
["int", "long"]số JS 3_000_000_000từ chối (là double)
["int", "long"]Long(3_000_000_000)chấp nhận
"number" + multipleOf: 1số JS 3_000_000_000chấp nhận
"number" + multipleOf: 11.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.

typescriptReady
import mongoose, { Schema, type InferSchemaType } from "mongoose";const activityEventSchema = new Schema(  {    userId: { type: String, required: true, match: /^[0-9a-f-]{36}$/ },    type: { type: String, required: true, enum: ["login", "view_page", "purchase"] },    occurredAt: { type: Date, required: true, default: () => new Date() },    payload: { type: Schema.Types.Mixed },  },  { versionKey: false },);activityEventSchema.index({ userId: 1, occurredAt: -1 });export type ActivityEvent = InferSchemaType<typeof activityEventSchema>;export const ActivityEventModel = mongoose.model("ActivityEvent", activityEventSchema, "activity_events");await mongoose.connect(process.env.MONGO_URL!, { dbName: "app" }); // một lần, ở khởi động appawait ActivityEventModel.init();   // đợi tạo index; autoIndex mặc định bật, production thường tắt và tạo index bằng migration

Đã 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ì:

wNghĩa
0Không chờ xác nhận. Nhanh nhất, mất dữ liệu được. Đừng dùng cho dữ liệu thật
1Primary đã 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ứcNghĩ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:

typescriptReady
// Causal consistency chỉ đảm bảo "đọc thấy ghi của mình" khi CẢ read concern// lẫn write concern đều là majority; để mặc định thì KHÔNG được đảm bảoconst client = await new MongoClient(url, {  readConcern: { level: "majority" },  writeConcern: { w: "majority" },  readPreference: "secondaryPreferred",}).connect()const session = client.startSession({ causalConsistency: true })await profiles.updateOne({ _id }, { $set: { name } }, { session })const p = await profiles.findOne({ _id }, { session })   // secondary cũng phải thấy bản vừa ghi

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):

typescriptReady
const stream = db.collection('orders').watch(  [{ $match: { operationType: { $in: ['insert', 'update'] } } }],  { fullDocument: 'updateLookup', resumeAfter: savedToken })for await (const change of stream) {  await handle(change)  await saveResumeToken(change._id)   // BẮT BUỘC — để nối lại sau khi restart}
Sơ đồ: vòng đời resume token
textReady
 oplog ──► change stream ──► handle(change) ──► saveResumeToken(change._id)               ^                                        |               |   restart: watch({ resumeAfter: token })|               `----------------- đọc token đã lưu <-----' Chết GIỮA handle và save => sự kiện đó phát lại => handle phải idempotent Không lưu token          => mất mọi sự kiện lúc offline

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:

typescriptReady
// NGUY HIỂMdb.users.findOne({ email: req.body.email, password: req.body.password })// client gửi { "password": { "$ne": null } } → đăng nhập được với mọi tài khoản

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
typescriptReady
import { z } from 'zod'const Login = z.object({ email: z.email(), password: z.string().min(1) })const parsed = Login.safeParse({ email: 'a@b.com', password: { $ne: null } })// parsed.success === false: password phải là string, object { $ne: null } bị loạiconst ok = Login.parse(req.body)                    // chỉ tới đây khi cả hai là stringconst user = await users.findOne({ email: ok.email })  // KHÔNG đưa password vào queryif (!user || !(await argon2.verify(user.passwordHash, ok.password))) throw new Unauthorized()

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.

  1. 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.userId cũ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.

  2. 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à payload khác nhau theo loại:

    typescriptReady
    { userId, type: 'login',     occurredAt, payload: { ip: '10.0.0.7' } }{ userId, type: 'view_page', occurredAt, payload: { path: '/p/12' } }{ userId, type: 'purchase',  occurredAt, payload: { orderId, amountMinor: 49900 } }

    purchase.payload.orderId tham chiếu orders.id của Postgres (reference, không nhúng đơn hàng).

  3. Bật $jsonSchema validator cho collection.

    Đáp án
    typescriptReady
    await db.createCollection('activity_events', {  validator: { $jsonSchema: {    bsonType: 'object',    required: ['userId', 'type', 'occurredAt'],    properties: {      userId:     { bsonType: 'string', pattern: '^[0-9a-f-]{36}$' },      type:       { enum: ['login', 'view_page', 'purchase'] },      occurredAt: { bsonType: 'date' },      payload:    { bsonType: 'object' },    },  } },  validationLevel: 'strict', validationAction: 'error',})

    Đã chạy (MongoDB 8.3.4 tự dựng, driver mongodb từ npm): chèn 3 tài liệu sai (userId ngắn, type lạ, occurredAt là chuỗi) đều bị từ chối với mã lỗi 121. Chỉ đo trên 8.3.4; các phiên bản server khác chưa kiểm.

  4. Index: { userId: 1, occurredAt: -1 } + TTL 90 ngày trên occurredAt.

    Đáp án
    typescriptReady
    const ev = db.collection('activity_events')await ev.createIndex({ userId: 1, occurredAt: -1 }, { name: 'user_time' })await ev.createIndex({ occurredAt: 1 }, { name: 'ttl_90d', expireAfterSeconds: 90 * 86400 })

    Index TTL phải là index một trường; không gắn expireAfterSeconds vào compound index. Đã chạy: sự kiện 100 ngày tuổi bị xoá sau vài giây khi đặt ttlMonitorSleepSecs=1 (chỉ để thử; mặc định tác vụ nền chạy mỗi 60 giây, mục 5 Pitfall #5).

  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
    typescriptReady
    const since = new Date(Date.now() - 7 * 864e5)const top = await ev.aggregate([  { $match: { occurredAt: { $gte: since } } },  { $group: { _id: { userId: '$userId', type: '$type' }, n: { $sum: 1 } } },  { $sort: { n: -1, '_id.type': 1 } },            // loại nhiều nhất lên đầu mỗi user  { $group: { _id: '$_id.userId', total: { $sum: '$n' },              topType: { $first: '$_id.type' }, topTypeCount: { $first: '$n' } } },  { $sort: { total: -1, _id: 1 } },  { $limit: 10 },  { $project: { _id: 0, userId: '$_id', total: 1, topType: 1, topTypeCount: 1 } },]).toArray()

    Hai $group là chủ ý: lần một đếm theo cặp (user, type), lần hai gộp theo user và lấy $first sau khi đã sắp xếp. Thiếu $sort giữa hai lần group thì $first khô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). $match theo thời gian dùng IXSCAN của ttl_90d (không phải user_time), quét khoảng 50 000 trong 100 000 tài liệu (7 trong 14 ngày).

  6. 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
    // truy vấn "20 sự kiện mới nhất của một user"; chạy trước và sau createIndexconst plan = await ev.find({ userId }).sort({ occurredAt: -1 }).limit(20).explain('executionStats')

    Đã chạy (100 000 tài liệu):

    PlantotalKeysExaminedtotalDocsExamined
    Trước indexSORT > COLLSCAN0100000
    Sau user_timeLIMIT > FETCH > IXSCAN2020

    Dán số totalDocsExamined và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ì.

  7. Test bằng Testcontainers (mongodb module) — 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/mongodb và hàm getConnectionString() theo tài liệu Testcontainers, chưa xác minh trên máy này:

    typescriptReady
    import { MongoDBContainer } from '@testcontainers/mongodb'import { MongoClient } from 'mongodb'import { beforeAll, afterAll, it, expect } from 'vitest'let container: Awaited<ReturnType<MongoDBContainer['start']>>, client: MongoClientbeforeAll(async () => {  container = await new MongoDBContainer('mongo:8').start()  client = await new MongoClient(container.getConnectionString(), { directConnection: true }).connect()}, 120_000)afterAll(async () => { await client?.close(); await container?.stop() })it('validator từ chối type lạ', async () => {  const db = client.db('test')  await db.createCollection('activity_events', { validator: { $jsonSchema: { bsonType: 'object',    properties: { type: { enum: ['login', 'view_page', 'purchase'] } } } } })  await expect(db.collection('activity_events').insertOne({ type: 'hack' })).rejects.toMatchObject({ code: 121 })})

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
textReady
 API (Nest/Express)   |-- Postgres: users, orders, subscriptions   (quan hệ + tiền, ACID)   `-- Mongo:    activity_events                (ghi nhiều, schema biến thiên,                                                 TTL 90 ngày, đọc theo khoảng) Index: user_time {userId:1, occurredAt:-1} -> "sự kiện gần nhất của user X"        ttl_90d   {occurredAt:1} + TTL      -> tự xoá, và phục vụ $match                                               theo thời gian

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

  • userId lư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ỏ $sort giữa hai $group rồi lấy $first: loại "phổ biến nhất" ngẫu nhiên.
  • Kiểm explain trê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".

  1. Tạo todo_events với validator: userId và todoId là chuỗi UUID, type thuộc created | completed | renamed, occurredAt là date; thêm index { userId: 1, occurredAt: -1 } và TTL 90 ngày.

    Đáp án
    typescriptReady
    const ev = await db.createCollection("todo_events", {  validator: { $jsonSchema: {    bsonType: "object",    required: ["userId", "todoId", "type", "occurredAt"],    properties: {      userId: { bsonType: "string", pattern: "^[0-9a-f-]{36}$" },      todoId: { bsonType: "string", pattern: "^[0-9a-f-]{36}$" },      type: { enum: ["created", "completed", "renamed"] },      occurredAt: { bsonType: "date" },      payload: { bsonType: "object" },    },  } },});await ev.createIndex({ userId: 1, occurredAt: -1 }, { name: "user_time" });await ev.createIndex({ occurredAt: 1 }, { name: "ttl_90d", expireAfterSeconds: 90 * 86400 });

    Đã chạy (mongod 8.3.4, tsc --strict): chèn { userId: "x", type: "deleted" } bị từ chối với mã 121. userId và todoId là chuỗi UUID vì User.id và Todo.id ở Postgres là UUID chuỗi, không phải ObjectId.

  2. 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
    const perDay = (tz: string) => ev.aggregate([  { $match: { userId, type: "completed" } },  { $group: { _id: { $dateToString: { format: "%Y-%m-%d", date: "$occurredAt", timezone: tz } },              completed: { $sum: 1 } } },  { $sort: { _id: 1 } },]).toArray();

    Đã chạy với hai sự kiện completed lúc 2026-10-02 20:00 UTC và 2026-10-03 03:00 UTC: múi giờ UTC ra hai ngày (2026-10-02 và 2026-10-03, mỗi ngày 1); Asia/Ho_Chi_Minh ra một ngày 2026-10-03 vớ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).

  3. Giải thích bằng một câu vì sao todo_events ở Mongo mà Todo ở Postgres.

    Đáp án

    Todo là dữ liệu quan hệ (thuộc về User qua khoá ngoại, sửa từng dòng, cần ràng buộc) nên ở Postgres; todo_events là 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 qua userId và 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}. Trong explain("executionStats") xem stage (IXSCAN tốt, COLLSCAN xấu), so nReturned với totalDocsExamined. Tự kiểm: bài tập mục 12 cho COLLSCAN 100 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ểm expiresAt trong 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 $match phải sớm

    Đáp án

    Mẫu ở đầu mục 6: $match (lọc, dùng index) rồi $group, $sort, $limit, $lookup. $match phả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ó plan IXSCAN cho $match. Xem mục 6.

  • Giải thích vì sao nhiều $lookup là tín hiệu chọn sai database

    Đáp án

    $lookup về 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 $lookup nghĩ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 majority giải quyết gì

    Đáp án

    Write concern = ghi xong khi nào coi là xong (majority chờ đa số node, mặc định từ 5.0); read concern = đọc thấy gì (majority không bao giờ bị rollback); read preference = đọc từ node nào (secondaryPreferred có replication lag). majority loạ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ấy options.validator; getIndexes() thấy user_time và 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ồi if (matchedCount === 0) throw ... trước khi insertOne đơn. Kho còn 1 và ba người mua song song phải ra đúng 1 đơn, qty bằng 0. Trên standalone driver báo "does not support retryable writes": thêm retryWrites=false không sửa được, phải chạy replica set. Xem mục 7.

  • Chọn đúng bsonType cho 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ố JS 3_000_000_000 phải được chấp nhận, 1.5 và -1 bị từ chối (mã 121); với "int" thì 3_000_000_000 bị từ chối vì driver gửi nó dưới dạng double. Xem mục 8.

  • Giải thích vì sao $lookup + $unwind mặc định làm mất document, và cách giữ chúng

    Đáp án

    $lookup cho mảng (rỗng khi không khớp); $unwind: "$x" bỏ document có mảng rỗng nên thành INNER JOIN. Giữ bằng $unwind: { path: "$x", preserveNullAndEmptyArrays: true }. Tự kiểm: tạo một đơn có userId không tồn tại trong users và 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: majority và writeConcern: majority (kèm causalConsistency: true trên session). Để mặc định thì không được đảm bảo; local + w:1 khô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; job updateMany({ 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/post hook 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, $match sớ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.