Skip to content

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

GĐ5 — MongoDB & NoSQL: schema design, aggregation, transaction

Vào đây sau GĐ4 (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Đ4 mục 14
Cần search thật sự (relevance, facet, typo)ElasticsearchGĐ10
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Đ4), 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$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.


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Đ3 và GĐ6 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Đ13
  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 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Đ18 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Đ9), đừ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Đ8).

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Đ13 — 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")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Đ12.

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