Document Model: suy nghĩ bằng document thay vì bảng
Ở bài 01, ta đã đi qua bức tranh tổng thể: mongod chứa các database, database chứa collection, collection chứa document, và một query đi từ driver xuống query layer rồi xuống storage engine. Bài này phóng to vào mảnh nhỏ nhất của bức tranh đó: document.
Nghe thì đơn giản ("document là một cục JSON"), nhưng phần lớn các quyết định thiết kế MongoDB về sau đều quay về một câu hỏi: cái gì nằm chung một document, cái gì không. Muốn trả lời được, trước hết phải hiểu document thật sự là gì.
Bài này nằm ở đâu
Prerequisites : bài 01 (mongod → database → collection → document,
đường đi của một query)
Concepts introduced : document tự chứa, nested object, array, _id,
single-document atomicity, flexible schema,
schema validation ($jsonSchema), giới hạn 16 MB
Leads to : bài 03, nên gom gì vào một document (embed hay reference)Vì sao phải đổi cách nghĩ
Hãy tưởng tượng bạn làm hệ thống đơn hàng. Với PostgreSQL, một đơn hàng thường bị "xé" ra nhiều bảng:
orders (id, customer_id, status, total, created_at)
order_items (order_id, sku, qty, price) ← nhiều dòng
customers (id, name, email)
addresses (id, customer_id, city, street)Muốn hiển thị một đơn hàng, bạn JOIN các mảnh lại. Muốn đổi trạng thái đơn và trừ kho cùng lúc, bạn mở transaction vì dữ liệu nằm ở nhiều dòng, nhiều bảng.
Cách nghĩ của MongoDB thì ngược lại: lưu dữ liệu theo hình dạng mà ứng dụng dùng nó. Một đơn hàng là một thứ trọn vẹn, nên nó nằm trong một document.
Hai cách này không phải "một cái tốt, một cái xấu". Chúng tối ưu cho những cách dùng dữ liệu khác nhau. Nhưng nếu bạn mang nguyên tư duy "bảng" sang MongoDB (mỗi bảng thành một collection, mỗi foreign key thành một $lookup), bạn sẽ nhận về chi phí của cả hai thế giới mà chẳng được lợi ích nào.
Mental model: document là một bìa hồ sơ
Hãy hình dung một văn phòng công chứng. Mỗi khách hàng có một bìa hồ sơ. Trong bìa có tờ khai, bản sao giấy tờ, vài phụ lục kẹp chung. Muốn xem hồ sơ của ai, nhân viên rút đúng một bìa ra là có đủ, không cần chạy sang ba tủ khác để gom.
Bìa hồ sơ Document
─────────── ─────────
số hồ sơ trên gáy = _id
tờ khai = các field đơn giản (status, total)
phong bì kẹp bên trong = nested object (customer, shipping)
xấp phụ lục cùng loại = array (items)
cả bìa rút ra một lần = đọc một document là có đủ dữ liệu
đóng dấu cả bìa một lần = single-document atomicity
bìa có độ dày tối đa = giới hạn 16 MBĐây chỉ là cách hình dung. MongoDB không lưu "bìa giấy" nào cả. Bên dưới, document là một chuỗi byte BSON được storage engine lưu như một khối. Nhưng phép so sánh này đúng ở điểm quan trọng nhất: document là đơn vị mà MongoDB đọc, ghi và đảm bảo atomic.
Đây là một đơn hàng thật mà mình insert vào lab:
db.orders.insertOne({
orderNo: "ORD-1001",
status: "pending",
customer: { name: "Lan Nguyen", email: "lan@example.com" }, // nested object
items: [ // array of objects
{ sku: "KB-01", name: "Keyboard", qty: 1, price: 890000 },
{ sku: "MS-02", name: "Mouse", qty: 2, price: 250000 }
],
shipping: { city: "Ho Chi Minh", street: "12 Le Loi" },
total: 1390000,
createdAt: new Date("2026-10-08T03:00:00Z")
})Vẽ ra thì nó là một cái cây:
order (document)
├── _id : ObjectId(...) ← MongoDB tự thêm
├── orderNo : "ORD-1001"
├── status : "pending"
├── customer : { name, email } ← nested object
├── items : [ {...}, {...} ] ← array
├── shipping : { city, street }
├── total : 1390000
└── createdAt : DateVài điều cần nắm ngay:
- Nested object (object lồng) là một document nằm trong document. Bạn query vào nó bằng dot notation:
"customer.email". - Array có thể chứa giá trị đơn hoặc cả object. Query
"items.sku": "MS-02"nghĩa là "có ít nhất một phần tử trongitemscóskubằngMS-02". - Thứ bạn thấy trong shell trông giống JSON, nhưng thứ thật sự được lưu là BSON (Binary JSON), có nhiều kiểu hơn JSON như
Date,ObjectId,int,long,decimal. Bài 04 sẽ mổ xẻ BSON. Ở bài này chỉ cần nhớ: kiểu dữ liệu là một phần của dữ liệu.
db.orders.find(
{ "customer.email": "lan@example.com", "items.sku": "MS-02" },
{ orderNo: 1, _id: 0 }
)[ { orderNo: 'ORD-1001' } ]Một query chạm vào hai "tầng" khác nhau của đơn hàng mà không cần JOIN, vì mọi thứ nằm chung một bìa.
MongoDB cũng có giới hạn về độ sâu: tài liệu chính thức ghi rằng BSON document hỗ trợ tối đa 100 cấp lồng nhau, mỗi object hoặc array tính một cấp. Thực tế bạn sẽ gặp giới hạn về khả năng đọc hiểu schema từ rất lâu trước khi chạm con số này.
_id: số hồ sơ trên gáy bìa
30 giây
Mỗi document trong một collection thông thường bắt buộc có field _id, đóng vai trò primary key. Nếu bạn không đưa _id, driver (hoặc mongod) sẽ tự sinh một ObjectId. _id là duy nhất trong collection và không thể đổi sau khi insert.
Xem tận mắt
Kết quả findOne của đơn hàng ở trên, phần đầu:
{
_id: ObjectId('6ac7119ec576dfe982569580'),
orderNo: 'ORD-1001',
status: 'pending',
...
}Mình không hề gõ _id, vậy mà nó có mặt, lại còn đứng đầu tiên. Tài liệu chính thức nói rõ: _id luôn là field đầu tiên; nếu server nhận document mà _id không đứng đầu, server sẽ chuyển nó lên đầu. Mình thử insert { orderNo: "ORD-1003", _id: "ORD-1003" } và đọc lại thì đúng là _id đã đứng đầu.
Collection mới tạo đã có sẵn một index:
db.orders.getIndexes()[ { v: 2, key: { _id: 1 }, name: '_id_' } ]Index _id_ này chính là thứ đảm bảo tính duy nhất. Bây giờ thử phá luật:
// 1. Trùng _id
db.orders.insertOne({ _id: ObjectId('6ac7119ec576dfe982569580'), orderNo: "ORD-dup" })
// 2. Đổi _id
db.orders.updateOne({ _id: ObjectId('6ac7119ec576dfe982569580') }, { $set: { _id: 42 } })
// 3. _id là array
db.orders.insertOne({ _id: [1, 2], orderNo: "x" })E11000 duplicate key error collection: lab02.orders index: _id_ dup key: { _id: ObjectId('6ac7119ec576dfe982569580') }
Plan executor error during update :: caused by :: Performing an update on the path '_id' would modify the immutable field '_id'
The '_id' value cannot be of type arrayBa lỗi, ba luật:
_id
├── ✓ duy nhất trong collection (unique index _id_ có sẵn)
├── ✓ bất biến: muốn "đổi" _id thì phải xoá rồi insert lại
├── ✓ kiểu gì cũng được, trừ array, regex, undefined
└── ✓ không bắt buộc là ObjectId: _id: "ORD-1002" hoàn toàn hợp lệKhi nào tự chọn _id?
Nếu dữ liệu đã có một khoá tự nhiên, duy nhất và không bao giờ đổi (mã đơn hàng do hệ thống sinh, mã SKU), dùng nó làm _id giúp bạn tiết kiệm một field và một index phụ. Nếu khoá đó có thể đổi (email, số điện thoại), đừng dùng, vì _id không sửa được.
ObjectId bên trong gồm những gì, nó tăng dần đến mức nào, và khi nào nên dùng UUID: đó là chủ đề của bài 04.
Single-document atomicity: đóng dấu cả bìa một lần
Đây là phần quan trọng nhất của bài, vì nó ảnh hưởng trực tiếp đến cách bạn thiết kế dữ liệu.
30 giây
Mọi thao tác ghi lên một document trong MongoDB là atomic: hoặc toàn bộ thay đổi được áp dụng, hoặc không có gì thay đổi. Điều này đúng kể cả khi bạn sửa nhiều field, nhiều nested object và array trong cùng document đó. Không ai đọc được một document "sửa được một nửa".
Mental model
Hãy nghĩ tới quầy giao dịch ngân hàng. Khi bạn nộp một phiếu yêu cầu, giao dịch viên xử lý cả phiếu rồi mới đóng dấu. Không có chuyện phiếu bị đóng dấu khi mới ghi xong dòng thứ nhất.
updateOne({ _id: "KB-01" }, {
$inc: { stock: -1, sold: 1 },
$push: { buyers: {...} }
})
┌──────────────── một document ────────────────┐
│ stock: 100 → 99 │
│ sold: 0 → 1 │ áp dụng
│ buyers: [] → [ {...} ] │ cùng lúc ✓
└───────────────────────────────────────────────┘
Người khác đọc document này chỉ thấy: TRƯỚC (100, 0, [])
hoặc SAU (99, 1, [..])
không bao giờ thấy (99, 0, [])Nhưng phải hiểu chính xác cái gì là atomic. Tài liệu MongoDB viết: khi một thao tác như updateMany() sửa nhiều document, mỗi document được sửa atomic, nhưng cả thao tác thì không, và các thao tác khác có thể chen vào giữa.
Atomic ✓ KHÔNG atomic ✗
──────── ──────────────
1 lệnh ghi lên 1 document updateMany() nhìn như một khối
(bao nhiêu field cũng được) 2 lệnh liên tiếp từ ứng dụng
"đọc → tính ở app → ghi lại"Dòng cuối cùng là cái bẫy phổ biến nhất. Ta làm thí nghiệm để thấy nó.
Thí nghiệm: bán 100 bàn phím cho hai client cùng lúc
Setup. MongoDB 8.3.11 trong Docker (mongo:8, standalone), giới hạn 4 CPU, 4 GB RAM, WiredTiger cache 1 GB, máy host Apple M4, mongosh 2.12.0. Database lab02.
Một sản phẩm còn 100 cái trong kho:
db.products.insertOne({ _id: "KB-01", name: "Keyboard", stock: 100, sold: 0, buyers: [] })Hai tiến trình mongosh (A và B) chạy song song, mỗi bên cố mua 150 lần, tức tổng cộng 300 lần mua để giành 100 cái.
Cách 1: đọc, kiểm tra ở app, rồi ghi lại. Đây là kiểu code rất tự nhiên khi mới chuyển từ ORM sang:
for (let i = 0; i < 150; i++) {
const p = db.products.findOne({ _id: "KB-01" }); // 1. đọc
if (p.stock >= 1) { // 2. kiểm tra ở app
db.products.updateOne({ _id: "KB-01" }, // 3. ghi lại
{ $set: { stock: p.stock - 1, sold: p.sold + 1 } });
ok++; // app tin là đã bán
}
}Cách 2: đưa điều kiện vào filter, để thay đổi diễn ra trong một lệnh ghi duy nhất.
for (let i = 0; i < 150; i++) {
const r = db.products.updateOne(
{ _id: "KB-01", stock: { $gte: 1 } }, // điều kiện nằm trong filter
{ $inc: { stock: -1, sold: 1 },
$push: { buyers: { client: CLIENT, at: new Date() } } }
);
ok += r.modifiedCount; // 1 = mua được, 0 = hết hàng
}Kết quả thật (4 lần chạy, mỗi lần reset kho về 100):
| Lần | Cách 1: app A + B báo đã bán | Cách 1: DB ghi sold | Cách 2: app A + B báo đã bán | Cách 2: DB ghi sold |
|---|---|---|---|---|
| 1 | 99 + 96 = 195 | 100 | 50 + 50 = 100 | 100 |
| 2 | 98 + 70 = 168 | 100 | 47 + 53 = 100 | 100 |
| 3 | 94 + 97 = 191 | 100 | 49 + 51 = 100 | 100 |
| 4 | 95 + 95 = 190 | 100 | 47 + 53 = 100 | 100 |
Diễn giải. Ở cách 1, ứng dụng nói với 168 đến 195 khách rằng họ đã mua thành công, trong khi kho chỉ có 100 cái. Database không hề sai: mỗi lệnh updateOne vẫn atomic. Cái sai là khoảng trống giữa bước 1 và bước 3: A và B cùng đọc stock: 57, cùng tính ra 56, cùng ghi 56. Hai lần bán, kho chỉ giảm một. Đây là lost update.
Cách 1 Cách 2
A: đọc stock=57 A: updateOne(stock≥1) → $inc -1 ✓ (57→56)
B: đọc stock=57 B: updateOne(stock≥1) → $inc -1 ✓ (56→55)
A: ghi stock=56 ✓
B: ghi stock=56 ✓ ← mất một lần trừ kiểm tra + sửa nằm trong MỘT lệnh,
trên MỘT document → không có khe hởỞ cách 2, "kiểm tra còn hàng" và "trừ kho, ghi người mua" nằm trong một thao tác trên một document, nên không có khe hở cho ai chen vào. Kết quả luôn đúng 100, và mảng buyers có đúng 100 phần tử.
Vì sao điều này quyết định cách bạn model dữ liệu
Nếu stock, sold và buyers nằm ở ba collection khác nhau, bạn không còn dùng được một lệnh ghi đơn lẻ. Bạn sẽ cần multi-document transaction (bài 11). MongoDB có hỗ trợ transaction, nhưng chính tài liệu của họ khuyến nghị:
Trong hầu hết trường hợp, distributed transaction tốn chi phí hiệu năng hơn so với ghi một document, và việc có transaction không nên thay thế cho thiết kế schema hiệu quả. Với nhiều trường hợp, mô hình denormalized (embedded document và array) vẫn là tối ưu.
Nói gọn thành một nguyên tắc thiết kế:
Những dữ liệu PHẢI thay đổi cùng nhau
↓
nên nằm trong CÙNG một document
↓
để một lệnh ghi đơn lẻ (atomic sẵn) là đủNhưng nguyên tắc nào cũng có giá:
Gom nhiều thứ vào một document
│
├── ✓ atomic miễn phí, không cần transaction
├── ✓ đọc một lần là đủ, không cần $lookup
├── ✗ document to hơn → mỗi lần đọc/ghi tốn nhiều byte hơn (xem phần 16 MB)
└── ✗ document "nóng": mọi lệnh ghi cùng tranh nhau một documentÝ cuối cùng đáng nhấn mạnh. WiredTiger cung cấp document-level concurrency: hai lệnh ghi vào hai document khác nhau có thể chạy song song, còn hai lệnh ghi vào cùng một document thì phải lần lượt. Nếu bạn dồn, chẳng hạn, 10.000 lệnh ghi mỗi giây (con số ví dụ) vào một document "tổng kho", document đó thành nút thắt cổ chai. Bài 13 sẽ đi sâu vào write conflict, còn bài 03 sẽ bàn khi nào nên gom và khi nào nên tách.
Flexible schema: vì sao "schemaless" là một từ gây hiểu nhầm
30 giây
MongoDB không bắt mọi document trong một collection phải có cùng field hay cùng kiểu. Đó là flexible schema. Nhưng như vậy không có nghĩa là "không có schema". Schema vẫn tồn tại, chỉ là nó chuyển từ database sang code ứng dụng, nơi khó nhìn thấy và khó kiểm soát hơn.
Mental model
Một tủ hồ sơ không có mẫu tờ khai thống nhất. Năm đầu ai cũng ghi "Số điện thoại". Năm sau có người ghi hai số. Một nhân viên mới ghi nhầm thành "Số đt". Tủ vẫn nhận hết, không phàn nàn gì. Vấn đề chỉ lộ ra vào ngày bạn cần tìm "tất cả khách có số điện thoại".
Thí nghiệm: ba phiên bản app, một collection
db.customers.insertMany([
// app v1
{ name: "Lan", phone: "0901234567", createdAt: new Date("2025-01-10") },
// app v2: phone thành array, một chỗ gửi createdAt dạng string
{ name: "Binh", phone: ["0907654321", "0281234567"], createdAt: "2025-06-01" },
// app v3: hotfix gõ nhầm tên field
{ name: "Chi", phnoe: "0912345678", createdAt: new Date("2026-02-14") }
])MongoDB nhận cả ba, không một lời cảnh báo. Giờ hỏi một câu rất bình thường: khách nào tạo sau ngày 1/3/2025?
db.customers.find({ createdAt: { $gt: new Date("2025-03-01") } }, { _id: 0, name: 1 })[ { name: 'Chi' } ]Binh tạo ngày 1/6/2025, rõ ràng thoả điều kiện, nhưng biến mất khỏi kết quả mà không có lỗi nào. Lý do: createdAt của Binh là string, còn điều kiện so sánh với Date. Toán tử so sánh như $gt chỉ so với những giá trị cùng nhóm kiểu BSON với giá trị trong query (gọi là type bracketing), nên document có createdAt là string bị bỏ qua. Chi tiết về kiểu và thứ tự so sánh là chủ đề của bài 04.
Muốn biết collection của mình đã "trôi" (schema drift) tới đâu, bạn có thể đếm kiểu dữ liệu thật của từng field:
db.customers.aggregate([
{ $group: { _id: { phone: { $type: "$phone" }, createdAt: { $type: "$createdAt" } }, n: { $sum: 1 } } }
]){ _id: { phone: 'array', createdAt: 'string' }, n: 1 }
{ _id: { phone: 'missing', createdAt: 'date' }, n: 1 }
{ _id: { phone: 'string', createdAt: 'date' }, n: 1 }Ba document, ba "schema" khác nhau. Code đọc dữ liệu giờ phải xử lý cả ba trường hợp. Đó chính là schema, chỉ là nó nằm rải rác trong các if của ứng dụng.
Flexible schema có giá trị thật, nếu dùng có chủ đích
Flexible schema
│
├── ✓ thêm field mới không cần migration khoá bảng
├── ✓ một collection chứa được dữ liệu đa hình (sản phẩm điện tử vs quần áo
│ có thuộc tính khác nhau)
├── ✓ rollout dần: document cũ và mới cùng tồn tại trong lúc chuyển đổi
├── ✗ lỗi kiểu / lỗi chính tả được lưu im lặng
├── ✗ query trả thiếu kết quả mà không báo lỗi
└── ✗ mọi nơi đọc dữ liệu phải "phòng thủ" trước mọi phiên bản cũCâu hỏi đúng không phải "có schema hay không", mà là ai giữ schema, và giữ chặt tới mức nào. MongoDB cho bạn một công cụ để database cùng giữ: schema validation.
Schema validation với $jsonSchema
30 giây
Schema validation là bộ quy tắc gắn vào collection: field nào bắt buộc, field nào phải là kiểu gì, giá trị nào được phép. MongoDB kiểm tra quy tắc lúc ghi (insert, update). Bạn chọn mức độ: chặn hẳn, chỉ cảnh báo, áp cho mọi document hay chỉ cho document đang hợp lệ.
Mental model: bảo vệ ở cửa
Schema validation giống bảo vệ đứng ở cửa toà nhà. Ai vào (insert) hoặc sửa giấy tờ (update) đều bị kiểm tra theo một danh sách quy tắc. Nhưng bảo vệ mới đến ca không đi lục soát những người đã ở sẵn trong toà nhà. Đặc điểm cuối này rất quan trọng, ta sẽ kiểm chứng ngay bên dưới.
Tạo collection với validator
$jsonSchema dựa trên JSON Schema draft 4, kèm phần mở rộng bsonType để kiểm tra kiểu BSON ("int", "date", "objectId"...). Một số keyword như $ref, default, format không được hỗ trợ.
db.createCollection("users", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["tenantId", "email", "status", "createdAt"],
properties: {
tenantId: { bsonType: "string" },
email: { bsonType: "string", pattern: "^.+@.+$" },
status: { enum: ["active", "suspended", "deleted"] },
age: { bsonType: "int", minimum: 0, maximum: 150 },
createdAt: { bsonType: "date" }
}
}
}
})Chú ý: age không nằm trong required, nên có thể vắng mặt, nhưng nếu có thì phải là int từ 0 đến 150. Validator không cần mô tả mọi field. Bạn khoá những gì quan trọng và để phần còn lại linh hoạt.
Giờ insert một document sai bốn chỗ:
db.users.insertOne({ tenantId: "t1", email: "binh-at-example.com", status: "actve", age: "30" })Lỗi thật (code 121, đã rút gọn phần lặp):
Document failed validation
{
failingDocumentId: ObjectId('6ac711a775d15c817526dc14'),
details: {
operatorName: '$jsonSchema',
schemaRulesNotSatisfied: [
{ operatorName: 'properties',
propertiesNotSatisfied: [
{ propertyName: 'email', details: [ { operatorName: 'pattern',
reason: 'regular expression did not match', consideredValue: 'binh-at-example.com' } ] },
{ propertyName: 'status', details: [ { operatorName: 'enum',
reason: 'value was not found in enum', consideredValue: 'actve' } ] },
{ propertyName: 'age', details: [ { operatorName: 'bsonType', specifiedAs: { bsonType: 'int' },
reason: 'type did not match', consideredValue: '30', consideredType: 'string' } ] }
] },
{ operatorName: 'required', missingProperties: [ 'createdAt' ] }
]
}
}Lỗi chỉ ra từng quy tắc bị vi phạm, kèm giá trị gây lỗi. Ứng dụng có thể log nguyên errInfo này để debug, thay vì chỉ nhận một câu "invalid".
Một chi tiết nhỏ mà đáng giá, thấy được ngay trong lab: trong mongosh, age: 30 được lưu thành int nên qua validator, còn age: Double(30) thì bị từ chối với cùng lỗi Document failed validation. Mỗi driver, mỗi ngôn ngữ có quy tắc riêng khi chuyển số sang BSON. Validator theo bsonType sẽ bắt được những lệch kiểu như vậy, và bài 04 sẽ giải thích vì sao chúng quan trọng.
validationLevel: kiểm tra những document nào
Bây giờ là phần "bảo vệ không lục soát người đã ở trong". Mình tạo collection legacy không có validator, insert 3 document, trong đó 2 cái sai (status "ACTIVE" viết hoa, thiếu createdAt, dùng mail thay vì email). Sau đó gắn validator bằng collMod:
db.runCommand({ collMod: "legacy", validator: schema, validationLevel: "strict" }){ ok: 1 }Lệnh thành công ngay. MongoDB không quét dữ liệu cũ khi bạn thêm validator. Muốn biết document nào đang vi phạm, bạn tự hỏi bằng chính schema đó:
db.legacy.find({ $nor: [ schema ] }, { _id: 1 })[{"_id":2},{"_id":3}]Vậy điều gì xảy ra khi ứng dụng sửa document _id: 2, dù chỉ thêm một field vô hại?
db.legacy.updateOne({ _id: 2 }, { $set: { note: "hello" } })| validationLevel | Sửa document đang sai (_id: 2) | Làm hỏng document đang đúng (_id: 1, set status: "gone") | Insert mới |
|---|---|---|---|
strict (mặc định) | ✗ Document failed validation | ✗ bị từ chối | kiểm tra |
moderate | ✓ modifiedCount: 1 | ✗ bị từ chối | kiểm tra |
Kết quả trong bảng là output thật từ lab. Hiểu nôm na:
strict: mọi insert và update đều phải cho ra document hợp lệ. Dữ liệu cũ sai sẽ "kẹt": muốn sửa gì cũng phải sửa cho đúng luôn.moderate: document đang hợp lệ thì phải giữ hợp lệ, còn document vốn đã sai thì được cập nhật mà không bị kiểm tra. Đây là chế độ hợp lý khi bạn đưa validation vào một collection cũ và chưa kịp làm sạch dữ liệu.
Tài liệu MongoDB hiện tại còn liệt kê mức constraint (đảm bảo mọi document đều thoả quy tắc), đánh dấu mới ở phiên bản 9.0. Trên lab 8.3.11 của mình, lệnh này bị từ chối:
MongoServerError: Validation level 'constraint' is not supported with current FCVvalidationAction: vi phạm thì làm gì
| validationAction | Hành vi | Ghi chú |
|---|---|---|
error (mặc định) | từ chối thao tác | |
warn | cho qua, ghi cảnh báo vào log của mongod | dữ liệu sai vẫn được lưu |
errorAndLog | từ chối và ghi log | mới từ 8.1 |
Với warn, lệnh insertOne({ _id: 4, email: 123 }) trả về { acknowledged: true, insertedId: 4 }, còn log của mongod có dòng này (rút gọn):
{"s":"W","c":"STORAGE","id":20294,"msg":"Document would fail validation",
"attr":{"namespace":"lab02.legacy","document":{"_id":4,"email":123},"errInfo":{...}}}Với errorAndLog, insert bị từ chối (Document failed validation) và log có dòng "msg":"Document failed validation" kèm document vi phạm.
Một lộ trình thực tế để siết schema cho collection đang chạy:
1. collMod: validator + validationLevel "moderate" + validationAction "warn"
↓ theo dõi log vài ngày, tìm code nào còn ghi sai
2. sửa code ghi dữ liệu, làm sạch document cũ (tìm bằng $nor: [schema])
↓
3. chuyển validationAction "error" (hoặc "errorAndLog")
↓
4. khi dữ liệu cũ đã sạch: validationLevel "strict"Những chỗ dễ vấp
additionalProperties: false và _id. Nếu bạn muốn cấm field lạ, nhớ khai báo cả _id trong properties. Mình thử schema chỉ có email và additionalProperties: false, rồi insert { email: "a@x.com" }:
Document failed validation
{ operatorName: 'additionalProperties', specifiedAs: { additionalProperties: false },
additionalProperties: [ '_id' ] }_id do driver tự thêm, nên nó bị coi là "field lạ", và mọi lệnh insert đều thất bại.
Validation có thể bị bỏ qua có chủ đích. Lệnh ghi có option bypassDocumentValidation: true (cần quyền phù hợp). Trong lab, insert với option này nhận { email: 789 } mà không lỗi. Validator là hàng rào cho code thông thường, không phải bức tường tuyệt đối.
Validation không thay thế kiểm tra ở ứng dụng. Ứng dụng vẫn nên validate để trả lỗi thân thiện cho người dùng. Validator ở database là lớp phòng thủ cuối, bắt những gì lọt qua: script migration, service khác, một hotfix vội.
Chi phí. Mỗi insert/update phải chạy thêm bước kiểm tra schema. Mình không đo chi phí này trong bài, nên không đưa con số. Nếu workload ghi của bạn rất lớn, hãy tự đo với schema thật của mình.
Giới hạn 16 MB và cái giá của document phình to
30 giây
Một BSON document tối đa 16 mebibytes (16 × 1024 × 1024 = 16.777.216 byte). Tài liệu chính thức giải thích lý do: để một document không chiếm quá nhiều RAM hoặc băng thông khi truyền. Cần lưu file lớn hơn thì dùng GridFS. Nhưng điều đáng sợ hơn cái trần 16 MB là mọi thứ xảy ra trước khi chạm trần.
db.hello().maxBsonObjectSize16777216Thí nghiệm: một bài viết, comment cứ thế nhét vào array
Kiểu thiết kế rất hay gặp: mỗi bài viết là một document, comment được $push vào mảng comments. Mỗi comment có userId, text (một câu tiếng Việt có dấu), likes, at. Kích thước được đo bằng $bsonSize, tức kích thước BSON thật mà server thấy:
db.posts.aggregate([
{ $match: { _id: "post-1" } },
{ $project: { s: { $bsonSize: "$$ROOT" } } }
])| Số comment | Kích thước document |
|---|---|
| 0 | 87 B |
| 1 | 225 B |
| 10 | 1.467 B |
| 100 | 14.067 B |
| 1.000 | 141.757 B (~138 KB) |
| 10.000 | 1.426.777 B (~1,4 MB) |
| 50.000 | 7.177.977 B (~6,8 MB) |
| 100.000 | 14.366.977 B (~13,7 MB) |
Mỗi comment tốn khoảng 140 byte, và kích thước tăng tuyến tính. Ở tốc độ này, chạm trần quanh mức khoảng 116.000 comment (ước tính từ số đo, không đo trực tiếp). Một bài viết "viral" hoàn toàn có thể vượt mức đó.
Khi vượt trần, lệnh ghi bị từ chối. Mình nhân đôi mảng ngay trên server bằng một pipeline update (14,4 MB thành ~28,8 MB):
db.posts.updateOne({ _id: "post-1" },
[ { $set: { comments: { $concatArrays: ["$comments", "$comments"] } } } ])Plan executor error during update :: caused by :: Serializing Document failed :: caused by :: Size 28844977 exceeds maximum 16793600Còn insert thẳng một document 16,5 MB:
object to insert too large. size in bytes: 17301558, max size: 16777216Cả hai trả về code 10334, và document cũ vẫn nguyên vẹn (14.366.977 byte, 100.000 comment), đúng tinh thần atomic: thất bại thì không thay đổi gì. Để ý con số trong lỗi update là 16.793.600, lớn hơn 16 MiB đúng 16 KiB. Đó là chi tiết nội bộ mình quan sát được trong thông báo lỗi, không phải giới hạn được tài liệu cam kết. Hãy thiết kế theo con số 16 MiB.
Một quan sát phụ: khi mình thử gửi payload 17 MB từ mongosh, lỗi đến từ phía client (ERR_OUT_OF_RANGE khi serialize) trước khi request kịp tới server. Tuỳ driver, bạn có thể gặp lỗi ở client hoặc ở server. Đừng dựa vào việc lỗi luôn trông giống nhau.
Chi phí trước khi chạm trần
Vấn đề thật sự không phải lỗi 16 MB, vì lỗi thì ít ra còn ồn ào. Vấn đề là document to làm mọi thao tác trên nó đắt hơn, kể cả thao tác chỉ đụng tới một field nhỏ.
Mình đo trên cùng collection, hai document: post-2 (81 byte, không comment) và post-1 (~14,4 MB, 100.000 comment). Mỗi phép đo chạy lặp nhiều lần sau một lần warm-up, lấy trung bình, chạy 2 vòng. Thời gian đo từ mongosh nên bao gồm cả network và chi phí phía client.
| Thao tác | Vòng 1 | Vòng 2 |
|---|---|---|
$inc: { views: 1 } trên document 81 B | 0,29 ms | 0,14 ms |
$inc: { views: 1 } trên document ~14,4 MB | 4,14 ms | 2,10 ms |
findOne document 81 B | 0,21 ms | 0,15 ms |
findOne document ~14,4 MB, chỉ lấy { title: 1 } | 1,18 ms | 1,08 ms |
findOne document ~14,4 MB, lấy cả document | 96,40 ms | 63,60 ms |
(MongoDB 8.3.11, Docker 4 CPU / 4 GB RAM / WiredTiger cache 1 GB, Apple M4, standalone. Đây là số đo trong lab của mình, chỉ để thấy xu hướng, không phải benchmark tổng quát.)
Đọc bảng này:
- Tăng một con số trên document to chậm hơn khoảng 14–15 lần so với document nhỏ, dù lệnh y hệt và chỉ đổi đúng một field. Một cách hình dung (mô hình đơn giản hoá, không phải chi tiết được tài liệu cam kết): document là đơn vị mà storage engine lưu trữ, nên cập nhật một field nghĩa là tạo ra phiên bản mới của cả document. Document càng to thì càng nhiều byte phải xử lý.
- Projection (
{ title: 1 }) giúp cắt mạnh phần truyền về client, nhưng vẫn chậm hơn đọc document nhỏ khoảng 5–7 lần. Projection giảm byte trả về, chứ không biến document 14 MB thành document 80 byte ở phía server. - Lấy cả document tốn 64–96 ms, phần lớn có lẽ là chi phí truyền và giải mã 100.000 object ở mongosh. Phép đo này không tách được phần server và phần client. Nhưng ứng dụng thật của bạn cũng phải trả đúng chi phí đó mỗi lần render trang bài viết.
Đặt cạnh nhau thành BEFORE/AFTER. Ở đây post-2 đóng vai bài viết khi comment đã được tách ra chỗ khác (cách tách thế nào là chuyện của bài 03):
BEFORE: comment nhét trong bài viết AFTER: bài viết chỉ giữ phần của nó
$inc views $inc views
↓ ↓
document ~14,4 MB (100.000 comment) document 81 B
↓ ↓
2,10–4,14 ms / lệnh (lab) 0,14–0,29 ms / lệnh (lab)Thời gian giảm vì lệnh ghi không còn phải kéo theo 100.000 comment mà nó không hề đụng tới. Cái giá là comment phải nằm ở nơi khác, và trang bài viết cần thêm một lần đọc để lấy chúng.
Gộp lại thành bức tranh chi phí:
Document phình to (unbounded array)
│
├── ✗ mỗi lần ghi, kể cả $inc một field, xử lý nhiều byte hơn
├── ✗ mỗi lần đọc kéo nhiều byte hơn qua cache, network, driver
├── ✗ chiếm nhiều chỗ hơn trong WiredTiger cache (1 GB ở lab này),
│ đẩy dữ liệu khác ra ngoài (bài 15: working set)
├── ✗ index trên field trong array tạo một entry cho MỖI phần tử (bài 06)
├── ✗ document "nóng": mọi comment mới tranh nhau ghi vào CÙNG một document
└── ✗ cuối cùng: chạm trần 16 MB, lệnh ghi bị từ chốiTài liệu MongoDB xếp đây vào một anti-pattern có tên riêng: unbounded arrays (mảng không có giới hạn). Cách chữa như subset pattern hay tách comment ra collection riêng là nội dung chính của bài 03.
Nếu bạn đọc bài cũ nói rằng document lớn lên sẽ bị "di chuyển" trên đĩa và gây phân mảnh, đó là hành vi của storage engine MMAPv1 thời trước. Trang storage engine trong tài liệu hiện tại chỉ còn WiredTiger (mặc định) và In-Memory. Chi phí của document to ngày nay là những gì bạn thấy ở trên, không phải chuyện relocation.
So với PostgreSQL: row, document và JSONB
Quay lại bài toán đơn hàng. Đặt ba cách lưu cạnh nhau:
PostgreSQL chuẩn hoá PostgreSQL + JSONB MongoDB
───────────────────── ────────────────── ───────
orders (1 row) orders orders
order_items (N rows) id, tenant_id, status { _id, status,
customers (1 row) doc JSONB ← items, customer: {...},
addresses (1 row) customer, items: [...],
shipping shipping: {...} }
schema: cột + kiểu schema: cột cho phần cứng, schema: validator
+ constraint JSONB cho phần mềm ($jsonSchema), tuỳ chọnVí dụ SQL bên dưới chỉ để minh hoạ, không chạy trong lab (lab của series chỉ có MongoDB):
CREATE TABLE orders (
id bigserial PRIMARY KEY,
tenant_id text NOT NULL,
status text NOT NULL CHECK (status IN ('pending','paid','shipped')),
created_at timestamptz NOT NULL,
doc jsonb NOT NULL -- items, customer, shipping
);
CREATE INDEX orders_doc_gin ON orders USING GIN (doc jsonb_path_ops);
-- tìm đơn có item MS-02
SELECT id FROM orders WHERE doc @> '{"items": [{"sku": "MS-02"}]}';JSONB khiến PostgreSQL trông rất giống MongoDB. Những điểm giống và khác đáng để ý:
| Khía cạnh | PostgreSQL row (+ JSONB) | MongoDB document |
|---|---|---|
| Đơn vị ghi atomic không cần transaction tường minh | một câu lệnh SQL (mỗi câu lệnh tự là một transaction) | một lệnh ghi lên một document |
| Atomic qua nhiều row/bảng | transaction, là "công dân hạng nhất" | multi-document transaction, tốn chi phí hơn ghi một document |
| Schema | cột + kiểu + CHECK, luôn bắt buộc; phần JSONB thì tự do | tự do theo mặc định; $jsonSchema tuỳ chọn |
| Thứ tự key | JSONB không giữ thứ tự key, key trùng thì giữ giá trị cuối | giữ thứ tự field (trừ _id luôn đứng đầu) |
| Kiểu dữ liệu trong phần "linh hoạt" | kiểu của JSON (string, number, boolean, null, object, array) | đầy đủ kiểu BSON: Date, int, long, decimal, ObjectId... |
| Kích thước | row vượt khoảng 2 kB thì TOAST nén/đẩy giá trị lớn ra ngoài; một field tối đa 1 GB | cả document tối đa 16 MiB |
| Đơn vị tranh chấp khi ghi | cả row: tài liệu PostgreSQL nhắc rằng update JSON lấy row-level lock trên cả row | cả document (document-level concurrency) |
Dòng cuối cho thấy hai hệ thống gặp cùng một vấn đề vật lý. Tài liệu PostgreSQL khuyên giữ JSON document ở kích thước "vừa phải" để giảm tranh chấp lock giữa các transaction cùng update. Đó chính là bài học "document nóng và document to" ở trên, chỉ khác tên gọi. Dù ở hệ nào, khối dữ liệu mà bạn gom chung lại cũng là khối mà bạn sẽ phải đọc, ghi và tranh chấp chung.
Vậy khi nào JSONB là đủ? Khi phần lớn dữ liệu của bạn là quan hệ (cần JOIN, cần ràng buộc chặt) và chỉ một phần nhỏ là bán cấu trúc (thuộc tính sản phẩm, metadata), JSONB cho bạn cả hai trong một hệ thống. Khi gần như mọi entity chính đều là "một khối có cấu trúc lồng nhau" được đọc và ghi trọn vẹn, document model của MongoDB phù hợp tự nhiên hơn. Chi tiết so sánh sâu (transaction, isolation, scaling) sẽ có ở bài 20.
Những lỗi thường gặp
✗ Mỗi bảng SQL thành một collection, mỗi JOIN thành một $lookup
→ mất lợi thế "đọc một lần là đủ" lẫn atomic một document
✗ "Đọc → tính ở app → ghi lại" cho những giá trị cần nhất quán (kho, số dư)
→ lost update; dùng toán tử ($inc, $push...) + điều kiện trong filter
✗ Tin rằng MongoDB "không có schema"
→ schema trôi vào code; query trả thiếu kết quả mà không báo lỗi
✗ Thêm validator rồi nghĩ dữ liệu cũ đã sạch
→ validator không quét dữ liệu cũ; dùng find({ $nor: [schema] }) để kiểm tra
✗ additionalProperties: false mà quên khai báo _id
→ mọi insert đều thất bại
✗ Array không có giới hạn (comment, log, event) trong một document
→ mọi thao tác chậm dần, document nóng, cuối cùng chạm 16 MB
✗ Chọn email / số điện thoại làm _id
→ _id bất biến, đổi email nghĩa là xoá và insert lạiTóm tắt
- Document là một khối tự chứa: field đơn, nested object, array, tất cả trong một cây, được lưu dưới dạng BSON.
_idlà primary key bắt buộc: duy nhất (index_id_có sẵn), bất biến, không được là array, và không bắt buộc phải là ObjectId.- Single-document atomicity: một lệnh ghi lên một document là atomic dù sửa bao nhiêu field. Nhiều document, hoặc chuỗi "đọc rồi ghi" ở app, thì không. Trong lab, read-modify-write khiến app "bán" 168–195 sản phẩm từ kho 100 cái, còn một
updateOnecó điều kiện luôn bán đúng 100. - Flexible schema ≠ không có schema: schema hoặc do database giữ, hoặc nằm rải rác trong code.
$jsonSchemakiểm tra lúc ghi.validationLevel(strict/moderate) quyết định kiểm tra document nào,validationAction(error/warn/errorAndLogtừ 8.1) quyết định làm gì khi vi phạm. Dữ liệu cũ không bị quét.- 16 MiB là trần cứng, nhưng chi phí đến sớm hơn: trong lab,
$incmột field trên document ~14 MB chậm hơn khoảng 14–15 lần so với document 81 byte.
Nếu phải nói lại bằng một câu không dùng thuật ngữ: gom những thứ luôn được dùng và sửa cùng nhau vào một bìa hồ sơ, nhưng đừng để bìa dày tới mức mỗi lần mở ra là một gánh nặng.
Bài tiếp theo: vì sao bài này quan trọng cho bài 03
Bài này cho bạn biết document là gì và tốn gì. Câu hỏi tự nhiên tiếp theo: vậy nên gom cái gì vào một document? Comment nên embed hay tách collection? Một khách hàng có 3 địa chỉ khác gì một khách hàng có 3 triệu event? Bài 03 trả lời bằng access pattern và cardinality: embed hay reference, khi nào denormalize, $lookup tốn gì, và các pattern như subset, bucket, extended reference.
Song song đó, bài 04 sẽ mở "bìa hồ sơ" ra đến tận từng byte: BSON trông thế nào, vì sao 30 và Double(30) là hai giá trị khác nhau với validator, và ObjectId bên trong chứa gì.
Tài liệu tham khảo
- MongoDB: Documents
- MongoDB: Limits and Thresholds
- MongoDB: Atomicity and Transactions
- MongoDB: Schema Validation
- MongoDB: Specify JSON Schema Validation
- MongoDB: Specify Validation Level for Existing Documents
- MongoDB: Choose How to Handle Invalid Documents
- MongoDB:
$jsonSchema - MongoDB: Avoid Unbounded Arrays
- MongoDB: Storage Engines
- PostgreSQL: JSON Types
- PostgreSQL: TOAST