Skip to content

HARRY-21 — GĐ06 — MongoDB & NoSQL: schema design, aggregation, transaction

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.


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


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:

js
// 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:

js
// 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à làm document "mọc" ra khỏi chỗ cũ trên đĩa → phân mảnh, chậm dần.

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.


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:

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

js
db.orders.createIndex({ userId: 1, createdAt: -1 })       // compound
db.users.createIndex({ email: 1 }, { unique: true })
db.sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 })  // TTL
db.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: {$in:[...]} }).sort({ createdAt: -1 }) thì index đúng là { userId: 1, createdAt: -1, status: 1 } — userId là equality, createdAt là sort, status là range.

Đọc query plan:

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


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.

js
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
      from: "users", localField: "_id", foreignField: "_id", as: "user"
  }},
  { $unwind: "$user" },
  { $project: { _id: 0, email: "$user.email", total: 1, count: 1 } }
])

Đối chiếu với SQL:

AggregationSQL
$matchWHERE
$groupGROUP BY + hàm tổng hợp
$sort / $limit / $skipORDER BY / LIMIT / OFFSET
$lookupLEFT JOIN
$unwindmở mảng thành nhiều hàng
$projectdanh sách cột SELECT
$facetnhiều query song song trên cùng input

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 sẽ lỗi QueryExceededMemoryLimit. Cách xử lý là bật { allowDiskUse: true } — nhưng đó 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.

js
// Filter: tìm bài đã đăng có ít nhất 100 lượt xem
const 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:

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

js
// Native MongoDB: khai báo index trên các field cần tìm
db.posts.createIndex({ title: 'text', body: 'text' })

// Tìm cụm "backend guide", loại kết quả có từ draft
db.posts.find({ $text: { $search: '"backend guide" -draft' } })

// Mongoose: khai báo tương đương trong schema
PostSchema.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() ​

js
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.
js
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.
js
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:

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

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

ts
const session = client.startSession()
try {
  await session.withTransaction(async () => {
    await orders.insertOne({ ... }, { session })
    await inventory.updateOne(
      { _id: sku, qty: { $gte: 1 } },
      { $inc: { qty: -1 } },
      { session }
    )
  })
} finally {
  await session.endSession()
}

Đ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.
  • 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 PostgresHỗ trợ Mongo hạn chế hơn (không $lookup phức tạp)

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.

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

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

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:

ts
const session = client.startSession({ causalConsistency: true })
// mọi thao tác trong session này thấy được thứ tự nhân quả đúng

10. Change Streams ​

Mongo cho phép "nghe" thay đổi trên collection theo thời gian thực (đọc từ oplog):

ts
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
}

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:

ts
// NGUY HIỂM
db.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).

Giới hạn cần nhớ: document 16 MB · độ sâu lồng nhau 100 · tên collection + database ≤ 120 ký tự.

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).
  2. Mongo giữ activity_events — log hành vi người dùng, schema biến thiên theo loại sự kiện.
  3. Bật $jsonSchema validator cho collection.
  4. Index: { userId: 1, occurredAt: -1 } + TTL 90 ngày trên occurredAt.
  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.
  6. Chạy explain("executionStats") và lưu lại kết quả trước/sau khi thêm index vào README.
  7. Test bằng Testcontainers (mongodb module) — cùng nguyên tắc ở GĐ13.

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.


Done khi ​

  • [ ] Kể được 4 họ NoSQL và một use case đúng cho mỗi họ
  • [ ] Bảo vệ được lựa chọn Postgres-vs-Mongo cho một bài toán cụ thể, có lý lẽ
  • [ ] Á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
  • [ ] Biết ObjectId gồm gì và vì sao không dùng làm token
  • [ ] Viết compound index theo quy tắc ESR; đọc được explain("executionStats")
  • [ ] Dùng TTL index; biết vì sao nó không chính xác tới giây
  • [ ] Viết aggregation có $match → $group → $sort → $lookup; biết vì sao $match phải sớm
  • [ ] Giải thích vì sao nhiều $lookup là tín hiệu chọn sai database
  • [ ] Biết transaction Mongo cần replica set, timeout 60s, và vì sao nên hiếm khi cần
  • [ ] Phân biệt write concern / read concern / read preference; biết majority giải quyết gì
  • [ ] Dùng change stream có lưu resume token; biết vì sao nó không thay thế queue
  • [ ] Chặn được NoSQL injection bằng validate ở biên
  • [ ] DA3 có collection Mongo chạy thật, có validator, có index, có số liệu explain trước/sau

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.
  • 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.
  • 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ọc bằng cách build. Chứng minh, đừng tin.

Đồng bộ tiến độ

Đăng nhập trực tiếp để tiếp tục học trên desktop và PWA.