Mô hình hoá dữ liệu — embed hay reference, các pattern, schema validation và đổi schema không có ALTER TABLE

Hai câu trả lời trượt phỏng vấn hay gặp nhất: "thiết kế như SQL, mỗi thực thể một collection" và "MongoDB thì nhúng hết vào một document". Câu trả lời đúng bắt đầu bằng một câu hỏi khác: trang/API quan trọng nhất cần đọc gì, ghi gì, bao nhiêu lần mỗi giây? Bài này biến "embed hay reference" thành phép tính có số đo thật: số round-trip khi đọc, số document phải ghi lại khi một sự thật thay đổi, kích thước document (cache, mỗi lần update), và lượng oplog/đĩa mỗi lần ghi. Sau đó đi qua các pattern (bucket, subset, extended reference, computed, tree, schema versioning…), time series collection, $jsonSchema với validationLevel: "constraint" mới ở 9.0, và cách đổi schema khi không có ALTER TABLE. Mọi số trong bài là output thật đo trên MongoDB 9.0.2 (so với 8.0.32), Laravel 13 + mongodb/laravel-mongodb 5.11, Node.js + Mongoose 9.10. Bài nối tiếp bài 1 (document là đơn vị đọc/ghi/khoá) — các chi tiết BSON, 16 MB, ngữ nghĩa mảng, upsert đua đã ở đó, ở đây chỉ nhắc lại khi cần.

Thuật ngữ dùng trong bài
access pattern (cách app đọc/ghi dữ liệu: truy vấn nào, lọc theo gì, bao nhiêu lần/giây) · embed (nhúng dữ liệu con vào trong document cha) · reference (lưu khoá trỏ sang document ở collection khác, lúc đọc phải tra thêm) · cardinality (số lượng phần tử ở phía "nhiều" của quan hệ) · denormalize (cố ý chép dữ liệu ra nhiều chỗ để đọc nhanh) · fan-out (một thay đổi lan ra nhiều document phải ghi lại) · extended reference (reference kèm vài field chép sẵn để khỏi tra) · bucket pattern (gom nhiều phần tử nhỏ vào một document có trần) · schema versioning (mỗi document mang số phiên bản schema của nó) · lazy migration (nâng cấp document khi app chạm vào, không chạy một lần cho cả bảng) · backfill (job chạy nền nâng cấp phần còn lại) · oplog (nhật ký thao tác của replica set, secondary đọc để chép lại — bài 7) · history store (file WiredTigerHS.wt giữ phiên bản cũ của document cho đọc snapshot — bài 6) · time series collection (collection đặc biệt tự gom số đo theo thời gian vào bucket) · data handle (đối tượng WiredTiger đại diện một bảng/file đang mở).
Kiểm chứng: tài liệu MongoDB qua Context7 và trang docs ngày 05–07/10/2026 (Schema Design Patterns/Anti-Patterns, Bucket Pattern, Specify Validation Level, Handle Invalid Documents, Time Series Limitations, release notes 9.0). Đọc source server: wiredtiger_record_store.cpp (khi nào dùng cursor->modify), src/mongo/db/timeseries/README.md (bucket, BSONColumn, viewless time series). Source laravel-mongodb 5.11.0 (EmbedsMany, HasSchemaVersion) và tài liệu Mongoose 9 (update validators). Lab: labs/mongodb/02-mo-hinh-du-lieu.sh, output ở labs/mongodb/out/02-*.txt. Máy 4 vCPU dùng chung — số mili-giây chỉ xem bậc độ lớn và tỉ lệ; số byte, số document, mã lỗi thì ổn định.

Trả lời 30 giây

SQL chuẩn hoá để mỗi sự thật nằm một chỗ rồi join lúc đọc. MongoDB lấy document làm đơn vị đọc và ghi nguyên tử, join ($lookup) đắt và không có foreign key, nên thiết kế đi ngược lại: document = kết quả của truy vấn quan trọng nhất. Nhúng thứ luôn đọc cùng nhau và có trần nhỏ; tách thứ tăng không giới hạn hoặc bị sửa độc lập. Mỗi lựa chọn có giá đo được: mảng phình làm mỗi lần ghi chậm dần và ghi đĩa theo cả document (lab: 200 lần thêm 1 reply vào document 9,5 MB ghi 90 MB xuống đĩa, history store phình theo cửa sổ snapshot 300 s); chép dữ liệu (extended reference) thì đổi 1 tên khoá học phải sửa 300 000 document, sinh 61,8 MB oplog và để người đọc thấy 2 tên trong ~5 s. Pattern (bucket, subset, computed, tree, schema versioning) là các cách trả giá có kiểm soát. Schema "mềm" không có nghĩa không có schema: ép bằng $jsonSchema phía server (9.0 thêm constraint đảm bảo mọi document cũ cũng hợp lệ), và đổi schema bằng lazy migration + backfill có giới hạn tốc độ thay cho ALTER TABLE — với bẫy là hai bản code cùng chạy lúc deploy.

1. Bài toán gốc và bối cảnh ra đời

Chuẩn hoá SQL giải bài toán của thời đĩa đắt và truy vấn không đoán trước. Mỗi sự thật lưu đúng một chỗ → sửa một chỗ, không lệch; câu hỏi mới nào cũng trả lời được bằng join. Cái giá trả lúc đọc: trang "chi tiết khoá học" ghép 4–5 bảng. MongoDB đặt cược ngược lại: app web có vài access pattern rất nóng, biết trước; RAM và đĩa rẻ hơn độ trễ. Vì document là đơn vị đọc/ghi nguyên tử (bài 1), cách rẻ nhất là để một truy vấn nóng chạm đúng một document.

Cách nói "embed hay reference theo cardinality" đến từ loạt bài 6 Rules of Thumb for MongoDB Schema Design của William Zola (MongoDB, 11/06/2014): one-to-few thì nhúng, one-to-many thì mảng reference, one-to-squillions thì con giữ khoá cha; "mảng không được tăng vô hạn"; "cân nhắc tỉ lệ đọc/ghi khi denormalize" (nguồn: mongodb.com blog). Năm 2019 Daniel Coupal và Ken Alger gom thành loạt Building with Patterns — 12 pattern kèm ưu/nhược (nguồn: Building With Patterns: A Summary); nay nằm trong manual ở mục Schema Design Patterns và Anti-Patterns. Phía server bổ sung dần các công cụ ép kỷ luật: validator (3.2, 2015), $jsonSchema (3.6, 2017), time series collection — bucket pattern dựng sẵn (5.0, 2021), và validationLevel: "constraint" (9.0, 2026).

Tối ưu cho: vài access pattern nóng biết trước; dữ liệu đọc cùng nhau thì nằm cùng nhau; ghi theo từng document. Không tối ưu cho: truy vấn ad-hoc nhiều chiều (báo cáo BI ghép tuỳ ý — đó là việc của SQL/warehouse), dữ liệu bị sửa thường xuyên mà lại được chép ra nhiều nơi, ràng buộc quan hệ chặt (foreign key, unique xuyên collection).

2. Mô hình tư duy cốt lõi

Thiết kế document = thiết kế kết quả của truy vấn quan trọng nhất. Mọi lựa chọn embed/reference là đổi chỗ chi phí giữa 4 đồng hồ đo.
Đồng hồEmbed / chép dữ liệu làm nó…Reference làm nó…Đo trong lab
Số round-trip khi đọcGiảm: 1 lệnhTăng: 2 lệnh hoặc $lookupKhoá + 50 bài học (mục 3.2)
Số document phải ghi khi 1 sự thật đổi (fan-out)Tăng: mọi bản chép1 documentĐổi tên khoá: 300 000 document (mục 3.5)
Kích thước document (cache, mỗi lần update)Tăng — mỗi update dựng lại cả documentNhỏ, ổn địnhMảng 50 000 reply = 9,5 MB (mục 3.1)
Oplog / đĩa mỗi lần ghiOplog nhỏ nếu dùng operator; đĩa theo cả documentNhỏ0,5 KB oplog nhưng 90 MB đĩa / 200 lần ghi
flowchart TD
  Q1["Dữ liệu con có được đọc CÙNG cha
ở truy vấn nóng không?"] -->|"không"| R1["Reference
collection riêng + index khoá cha"] Q1 -->|"có"| Q2["Số phần tử con có trần nhỏ?
(vài chục – vài trăm)"] Q2 -->|"không, tăng mãi"| Q3["Cần đọc phần mới nhất
theo trang?"] Q3 -->|"có"| R3["Bucket hoặc subset
+ collection riêng cho phần cũ"] Q3 -->|"không"| R1 Q2 -->|"có"| Q4["Phần con bị sửa độc lập
hoặc dùng chung (N-N)?"] Q4 -->|"có"| R4["Reference, hoặc extended reference
(chỉ chép field ít đổi)"] Q4 -->|"không"| R2["Embed"]

3. Internals và quyết định thiết kế

3.1 Một lần update trên document lớn thật sự tốn gì

Đọc source wiredtiger_record_store.cpp (9.0) và đo, đường đi của một $push là:

  1. Server áp operator lên document rồi dựng lại toàn bộ document mới (copy cả 9,5 MB dù chỉ thêm 204 byte).
  2. Oplog ghi diff dạng $v: 2 — chỉ phần thay đổi. Lab: entry của một $push chỉ 0,5 KB ở mọi kích thước document: {"$v":2,"diff":{"sreplies":{"a":true,"u50799":{…}}}}.
  3. WiredTiger so bản cũ với bản mới: document > 1 KB (kMinLengthForDiff = 1024) và thay đổi ≤ 10 % trong ≤ 16 mảnh thì ghi bằng cursor->modify (chỉ lưu phần khác trong cache) thay vì ghi đè cả giá trị — với điều kiện bảng không tự ghi journal. Trong replica set, collection người dùng không vào journal (journal chỉ chứa oplog, phục hồi dựa trên checkpoint + oplog): lab đo journal ~0,8 KB mỗi lần $push dù document 9,5 MB.
  4. Nhưng mọi phiên bản của document phải được giữ trong cửa sổ snapshot (minSnapshotHistoryWindowInSeconds = 300). Khi checkpoint/evict, bản mới được ghi đầy đủ xuống đĩa và các bản cũ đi vào history store.

Kết quả lab (02-array.txt, reply 204 byte, 200 lần ghi mỗi mốc, w:majority mặc định; dòng w:1 bỏ chờ journal để thấy riêng phần CPU):

Cách ghi 1 replyN = 1 000 (0,19 MB)N = 10 000 (1,91 MB)N = 50 000 (9,49 MB)
Mảng: $push6 ms · 2,45 MB9 ms · 19,38 MB32 ms · 89,50 MB
Mảng: $push, w:13 ms · 4,35 MB7 ms · 18,88 MB24 ms · 89,88 MB
Bucket, filter như docs6 ms · 0,73 MB6 ms · 0,60 MB6 ms · 0,61 MB
Bucket + sort {_id:-1}5 ms · 0,87 MB6 ms · 0,70 MB6 ms · 0,69 MB
1 document/reply5 ms · 0,54 MB5 ms · 0,47 MB6 ms · 0,48 MB
1 document/reply, w:12 ms · 0,61 MB2 ms · 0,54 MB2 ms · 0,54 MB

Mỗi ô: p50 độ trễ · byte ghi đĩa cho 200 lần ghi (kể cả 1 checkpoint ép bằng fsync). Oplog ở mọi dòng đều ~0,5 KB/lần ghi, journal ~0,6–0,8 KB/lần ghi.

== N = 50,000 reply  (document mảng: 9.49 MB, 254 bucket, 50,400 doc riêng)
  mảng: $push 1 reply                avg  39.73 ms  p50  32  max  154 | oplog 0.5 KB/op | journal 0.8 KB/op | disk+checkpoint 89.50 MB
  mảng: $push, w:1 (bỏ chờ journal)  avg  26.97 ms  p50  24  max  131 | oplog 0.5 KB/op | journal 0.7 KB/op | disk+checkpoint 89.88 MB
  bucket: filter như docs (không sort) avg   8.10 ms  p50   6  max   50 | oplog 0.5 KB/op | journal 0.8 KB/op | disk+checkpoint 0.61 MB
  bucket: + sort {_id:-1} (8.0+)     avg  10.15 ms  p50   6  max  201 | oplog 0.5 KB/op | journal 0.8 KB/op | disk+checkpoint 0.69 MB
  1 doc/reply: insertOne             avg   7.14 ms  p50   6  max   38 | oplog 0.5 KB/op | journal 0.7 KB/op | disk+checkpoint 0.48 MB
  1 doc/reply: insertOne, w:1        avg   2.29 ms  p50   2  max   11 | oplog 0.5 KB/op | journal 0.6 KB/op | disk+checkpoint 0.54 MB
Thêm 1 reply vào mảng phình so với vào bucket nhỏ Bên trái: một document chứa mọi reply lớn dần từ 0,19 MB lên 9,49 MB; mỗi lần thêm 204 byte, độ trễ và byte ghi đĩa tăng theo kích thước document, history store phình vì phải giữ mọi phiên bản trong 300 giây. Bên phải: reply đi vào bucket 200 phần tử đang mở; số bucket tăng nhưng chi phí mỗi lần ghi gần như không đổi. Cùng thao tác “thêm 1 reply 204 byte” ở 3 mốc: 1 000 · 10 000 · 50 000 reply A. Mọi reply trong 1 document post _id 7 1 000 reply · 0,19 MBp50 3 ms · đĩa 2,5 MB/200 lần 10 000 reply · 1,91 MBp50 7 ms · đĩa 19 MB/200 lần 50 000 reply · 9,49 MBp50 24 ms · đĩa 90 MB/200 lần B. Bucket 200 reply/document ở cả 3 mốc, mỗi lần ghi: • updateOne + sort {_id:-1}: đọc 1 bucket (filter như docs, không sort: 8 → 55 → 257) • đĩa ≈ 0,7–0,9 MB / 200 lần ghi • đọc 20 reply mới nhất, cache lạnh: 0,10 MB (mảng: 19,2 MB vào cache cho cùng 20 reply) 200200200200200200 đang mở 5 bucket 52 bucket 254 bucket history store (giữ mọi phiên bản trong cửa sổ snapshot 300 s), document mảng ~9,4 MB: 27 → 107 → 191 MB sau 2×200 $push · cửa sổ 0 s: đứng yên
Hình 1 — Lab 02-array.txt. Bên trái, mọi reply nằm trong một document: oplog mỗi lần chỉ ~0,5 KB, nhưng server dựng lại cả document, WiredTiger phải giữ từng phiên bản trong cửa sổ snapshot rồi ghi bản đầy đủ xuống đĩa — p50 (w:1) và byte ghi đĩa tăng theo kích thước. Bên phải, reply vào bucket đang mở: số bucket tăng, chi phí mỗi lần ghi gần như không đổi. Thanh dưới: history store của node chỉ chứa document mảng đó.

3.2 Embed vs reference theo cardinality

Quan hệVí dụ LMS/e-commerceMặc địnhĐổi khi
1–1khách – hồ sơ giao hàngEmbed (subdocument)Phần con to, ít đọc → tách (subset)
1–ít (≤ vài chục, có trần)khoá học – 50 bài học; đơn – itemEmbed mảngBài học bị sửa/đọc riêng nhiều → reference
1–nhiều (trăm – nghìn)sản phẩm – đánh giáSubset (N mới nhất trong cha) + collection riêngKhông cần "mới nhất" → chỉ collection riêng
1–vô hạn ("squillions")khoá học – log học tậpCon giữ khoá cha, index {parentId, time}; hoặc time series—
N–Nhọc viên – khoá họcCollection nối (enrollments) + extended reference vài field ít đổiHai phía đều nhỏ, có trần → mảng id hai phía

Lab 02-embed.txt: 2 000 khoá × 50 bài học, đọc 500 khoá ngẫu nhiên × 3 lượt (laravel-mongodb 5.11, cùng máy, mạng docker):

Đọc 1 khoá + 50 bài họcLệnh/khoáLượt 1 (ms)Lượt 2Lượt 3
Embed, Eloquent embedsMany13,423,833,76
Reference, Eloquent with("lessons")26,335,775,54
Reference, $lookup12,262,112,60
Embed, driver gốc10,861,141,23

Ở tầng driver, embed nhanh hơn $lookup khoảng 2 lần (1 document so với join 51 document). Qua Eloquent thì chi phí hydrate (dựng 50 model PHP) chiếm phần lớn thời gian — lớn hơn cả chênh lệch embed/reference. Lý do chọn embed ở đây là tính nguyên tử và đơn giản (khoá + bài học đổi cùng nhau), không phải vài mili-giây. Bẫy riêng của embedsMany (đọc source 5.11 + query log):

3.3 Danh mục pattern: giải gì, trả bằng gì, hỏng khi nào

PatternGiải bài toán gìĐổi lạiHỏng khi nào
AttributeSản phẩm 300 loại thuộc tính khác nhau → attrs: [{k, v}] + 1 index {"attrs.k":1, "attrs.v":1} thay vì 300 indexTruy vấn phải dùng $elemMatch (bài 1); v lẫn kiểu → type bracketingQuên $elemMatch → khớp chéo; index multikey to (bài 3)
BucketHàng triệu phần tử nhỏ (reply, số đo) → gom N phần tử/documentLogic ghi phức tạp hơn; phân trang theo bucketFilter tìm bucket mở quét hết bucket (mục 3.4)
SubsetCha cần "10 đánh giá mới nhất" → nhúng 10 cái, phần còn lại ở collection riêngGhi 2 chỗ; bản nhúng có thể lệchKhông có $slice khi $push → mảng nhúng lại phình
Extended referenceĐơn hàng chép tên/giá sản phẩm, enrollment chép tên khoá → đọc 1 lệnhFan-out khi field gốc đổiChép field hay đổi → updateMany hàng trăm nghìn document (mục 3.5)
Snapshot (biến thể)Giá/tên lúc mua là sự thật lịch sử — cố ý KHÔNG cập nhậtKhông cóAi đó viết job "đồng bộ tên sản phẩm vào đơn cũ" → sửa lịch sử
ComputedLượt ghi danh, điểm trung bình tính sẵn khi ghi thay vì đếm mỗi lần đọcMỗi lần ghi thêm 1 update; có thể lệch nếu ghi lỗi giữa chừngCounter nóng → WriteConflict (bài 1, mục 5.8); cần job đối soát
ApproximationLượt xem chỉ cần đúng xấp xỉ → cộng 100 mỗi 1 % requestSố không chính xácDùng cho số liệu tiền/tồn kho
Outlier99,9 % khoá học < 1 000 học viên, vài khoá 300 000 → cờ hasOverflow + collection trànApp xử lý 2 nhánhTruy vấn ad-hoc quên nhánh tràn → số sai
Polymorphic / single collectionNhiều loại (video, quiz, bài đọc) chung 1 collection + field typeIndex phải phủ nhiều dạng; validator phức tạpKhông lọc type → trộn dữ liệu
TreeCây danh mục: parent ref / mảng ancestors / materialized pathGhi lại cả nhánh khi chuyển nhánh (trừ parent ref)Chuyển nhánh vào chính con cháu → vòng lặp (mục 3.6)
Schema versioningĐổi schema không downtime: mỗi document mang schema_versionCode đọc 2 dạng; index cho cả 2 trong lúc chuyển2 bản code cùng chạy → mất cập nhật (mục 5.6)
ArchiveĐơn > 2 năm chuyển sang collection/cluster rẻTruy vấn lịch sử phải đi 2 chỗDi chuyển không idempotent → mất/trùng khi job chết giữa chừng

3.4 Bucket pattern: cái bẫy nằm ở câu lệnh tìm bucket đang mở

Ví dụ chính thức trong manual tìm bucket còn chỗ bằng updateOne({_id: /^123_/, count: {$lt: 10}}, {$push…, $inc: {count: 1}, $setOnInsert: {_id…}}, {upsert: true}). Lệnh này quét _id theo thứ tự tăng và dừng ở bucket đầu tiên chưa đầy — tức bucket cuối. Explain trong lab:

N = 50,000 reply, 254 bucket
có bucket đang mở, không sort: keysExamined 257, docsExamined 257
có bucket đang mở, sort {_id:-1}: keysExamined 1, docsExamined 1
bucket cuối vừa đầy, không sort: keysExamined 257, docsExamined 257
bucket cuối vừa đầy, sort {_id:-1}: keysExamined 257, docsExamined 257

Sửa: từ 8.0, updateOne nhận sort → {sort: {_id: -1}} chạy IXSCAN ngược + LIMIT 1, chỉ đọc 1 bucket. Nhưng khi bucket cuối vừa đầy (1/200 lần ghi), không bucket nào khớp count < 200 nên vẫn quét hết rồi mới upsert. Cách bền hơn: giữ số thứ tự reply ở document cha (computed pattern, $inc trả về số mới) và tính thẳng _id bucket = postId + "_" + floor(seq/200) — mỗi lần ghi là 2 lệnh theo _id, không quét. Bẫy khác của ví dụ docs: _id bucket gồm thời gian theo giây → bài nóng làm đầy bucket trong cùng 1 giây thì bucket kế tiếp trùng _id (lab, bucket 2 phần tử): Plan executor error during update :: caused by :: E11000 duplicate key error collection: lab.bk_dup index: _id_ dup key: { _id: "9_1791377773" }. Server không tự retry vì filter là regex chứ không phải equality trên unique key (bài 1, mục 5.1) — app phải bắt E11000 và thử lại, hoặc thêm phần ngẫu nhiên/bộ đếm vào _id.

3.5 Extended reference và fan-out: đổi 1 tên = ghi lại N document

Lab 02-fanout.txt: 400 000 enrollment, trong đó 300 000 thuộc khoá 1 (ví dụ minh hoạ một khoá MOOC rất đông), mỗi enrollment chép course: {_id, title} (129 byte/document). Đổi tên khoá bằng một updateMany có index trên course._id:

updateMany đổi tên → "Laravel 13 căn bản": 4373 ms, modified 300,000 | oplog 300,000 entry, 60.4 MB (211 B/entry) | journal 73.2 MB
1 entry oplog: {"op":"u","ns":"lab.fan_enroll","o":{"$v":2,"diff":{"scourse":{"u":{"title":"Laravel 13 căn bản"}}}},"o2":{"_id":1}}

Một sự thật đổi → 300 000 entry oplog, mỗi secondary phải áp lại từng cái (replication lag — bài 7). Và updateMany không nguyên tử trên nhiều document: một process khác lấy mẫu 5 document của khoá 1 trong lúc lệnh chạy:

  t=   31 ms  _id 1/75000/150000/225000/300000: cũ cũ cũ cũ cũ
  t= 3825 ms  _id 1/75000/150000/225000/300000: MỚI cũ cũ cũ cũ
  t= 5049 ms  _id 1/75000/150000/225000/300000: MỚI MỚI cũ cũ cũ
  t= 6663 ms  _id 1/75000/150000/225000/300000: MỚI MỚI MỚI cũ cũ
  t= 7750 ms  _id 1/75000/150000/225000/300000: MỚI MỚI MỚI MỚI cũ
  t= 8875 ms  _id 1/75000/150000/225000/300000: MỚI MỚI MỚI MỚI MỚI
  → 2073 lần đọc thấy LẪN tên cũ và mới (cùng 1 khoá học)

Đổi lại, đọc "20 enrollment của user kèm tên khoá" (300 user × 3 lượt): extended reference 1 lệnh 1,9–3,5 ms; chỉ lưu courseId rồi tra thêm 2 round-trip 4,2–6,2 ms; $lookup 3,4–9,7 ms. Mạng lab là docker bridge trên cùng máy — qua mạng thật mỗi round-trip đắt hơn nên chênh lệch còn lớn hơn.

Đổi tên 1 khoá học phải sửa 300 000 enrollment đã chép tên đó Document khoá học đổi tên. Lưới A: updateMany sửa dần 300 000 enrollment trong khoảng 5 giây; trong lúc đó người đọc thấy cùng một khoá mang cả tên cũ lẫn tên mới. Lưới B: cùng lệnh bị maxTimeMS cắt sau 300 ms, chỉ khoảng 5 phần trăm đã đổi và phần đó không bị rollback. 1 sự thật đổi (tên khoá) → N document phải ghi lại (mỗi ô ≈ 5 000 enrollment) fan_courses · _id: 1 "Laravel căn bản" "Laravel 13 – bản mới" → updateMany course.title A. chạy hết: 5,1 s B. maxTimeMS 300 ms → bị cắt MaxTimeMSExpired (50), không rollback:14 718 tên mới · 285 282 tên cũ đang chạy: cùng 1 khoá, 2 tên khác nhau(2 073 lần đọc thấy lẫn, trong ~5 s) xong: 300 000 document đã đổi oplog: 300 000 entry · 61,8 MB (216 B/entry) — mỗi document 1 entry journal 73,2 MB · updateMany KHÔNG nguyên tử trên nhiều document đổi lại: đọc kèm tên khoá 1 query (1,9–3,5 ms) thay vì 2 (4,2–6,2 ms)
Hình 2 — Lab 02-fanout.txt. Tên khoá học được chép vào 300 000 enrollment (extended reference). Đổi tên là một updateMany chạy ~5 s: trong lúc đó đọc thấy cùng một khoá mang 2 tên (lưới A đổi màu dần). Nếu lệnh bị ngắt (maxTimeMS, mất kết nối, primary step down) thì phần đã đổi giữ nguyên, phần còn lại giữ tên cũ (lưới B). Đổi lại, trang danh sách đọc kèm tên khoá chỉ cần 1 lệnh.

Quy tắc chọn field để chép (tỉ lệ đọc/ghi): chỉ chép field gần như không đổi (tên khoá, slug, ảnh đại diện) hoặc field mang nghĩa snapshot (giá lúc mua — không bao giờ cập nhật). Field đổi thường xuyên (tồn kho, giá hiện tại, trạng thái) thì reference. Nếu phải chép field có thể đổi, chấp nhận "lệch tạm thời" bằng job đồng bộ chạy nền theo lô, idempotent (bẫy ở mục 5.3), hoặc dùng change stream (bài 10).

3.6 Tree pattern: cây danh mục 23 770 node

Ba cách lưu cây danh mục, lab 02-tree.txt (5 tầng: 10 × 8 × 8 × 6 × 5 = 23 770 node). Document lá:

parent ref         {"_id":23770, "parent":23765}                                   index {parent: 1}
array of ancestors {"_id":23770, "parent":23765, "ancestors":[21394,23474,23734,23765]}  index {ancestors: 1} (multikey)
materialized path  {"_id":23770, "path":",21394,23474,23734,23765,"}                    index {path: 1}
Thao tác (lab, tốt nhất 5 lần)Parent refMảng ancestorsMaterialized path
Mọi con cháu node tầng 1 (296 node)BFS 4 query 17 ms · $graphLookup 4 ms1 query 4 ms1 query 4 ms
Mọi con cháu của gốc (2 376 node)BFS 5 query 28 ms · $graphLookup 13 ms1 query 10 ms1 query 11 ms
Breadcrumb node lá5 query 12 ms · $graphLookup 3 ms2 query 12 ms2 query 3 ms
Chặn vòng lặp khi chuyển nhánhđi ngược lên gốc: 3 query1 findOneso tiền tố path
Chuyển nhánh 296 node sang gốc khác1 document · 1 entry oplog · 6 ms297 document · 297 entry · 30 ms297 document · 297 entry · 28 ms
explain path regex có tiền tố:      keysExamined 297,   docsExamined 296, nReturned 296
explain path regex KHÔNG neo ^ (",2,"): keysExamined 23770, docsExamined 296, nReturned 296

3.7 Time series collection = bucket pattern dựng sẵn

Khai báo timeField (+ metaField, granularity) là server tự làm bucket pattern (nguồn: src/mongo/db/timeseries/README.md, IDL timeseries.idl, release notes 9.0):

Lab 02-ts.txt: 1 triệu event học tập trong 7 ngày (ví dụ minh hoạ), granularity: "minutes", khoá học phân bố lệch (vài khoá rất đông):

BảnCách lưu 1 triệu eventChèn (lô 10 000)storageSize + indexBucketEvent/bucket
9.0.2time series, meta = courseId (200)20,2 s (49 461/s)10,4 + 1,0 MB1 594627,4
9.0.2collection thường + index {courseId, ts}17,9 s (55 788/s)32,8 + 56,3 MB——
9.0.2time series, meta = userId (50 000)196,1 s (5 099/s)37,8 + 92,5 MB276 7513,6
9.0.2như trên, ngưỡng BucketCatalog 50 MB (300 000 event đầu)96,2 s (3 117/s)14,6 + 30,6 MB91 6673,3
8.0.32time series, meta = courseId (200)29,6 s (33 769/s)11,9 + 1,2 MB1 594627,4
8.0.32collection thường + index {courseId, ts}25,6 s (38 986/s)31,9 + 56,5 MB——
8.0.32time series, meta = userId (50 000)410,5 s (2 436/s)38,4 + 89,9 MB276 7513,6

Chọn metaField đúng (courseId) thì nhỏ hơn collection thường ~8 lần cả dữ liệu lẫn index. Chọn metaField cardinality cao (userId, mỗi user ~20 event/tuần) thì mỗi bucket chỉ vài event: chèn chậm hơn nhiều lần, index to, và BucketCatalog ngốn RAM ngoài WT cache — mỗi meta giữ 1 bucket mở (~7 KB):

      100,000 event | RSS 783 MB | BucketCatalog 303 MB, 43,484 bucket mở, đóng vì ngưỡng bộ nhớ 0 | WT cache 182 MB
      300,000 event | RSS 875 MB | BucketCatalog 351 MB, 50,073 bucket mở, đóng vì ngưỡng bộ nhớ 0 | WT cache 193 MB
    1,000,000 event | RSS 914 MB | BucketCatalog 352 MB, 50,200 bucket mở, đóng vì ngưỡng bộ nhớ 0 | WT cache 188 MB
ts_user insert 196106 ms (5,099 doc/s) | storageSize 37.8 MB, index 92.5 MB | 276,751 bucket, 3.6 event/bucket
      100,000 event | RSS 915 MB | BucketCatalog 67 MB, 9,087 bucket mở, đóng/lưu trữ vì ngưỡng bộ nhớ +124,540 | WT cache 181 MB
      300,000 event | RSS 909 MB | BucketCatalog 67 MB, 9,065 bucket mở, đóng/lưu trữ vì ngưỡng bộ nhớ +437,088 | WT cache 186 MB
ts_user_cap insert 96233 ms (3,117 doc/s) [300,000 event đầu] | storageSize 14.6 MB, index 30.6 MB | 91,667 bucket, 3.3 event/bucket

Ngưỡng mặc định timeseriesIdleBucketExpiryMemoryUsageThreshold = 5 (5 % "system memory"); BucketCatalog lên ~350 MB vẫn chưa đóng bucket nào vì ngưỡng bộ nhớ — tức ngưỡng đang tính theo RAM máy (7,9 GB → ~400 MB), không theo --memory 1g của container (suy từ số đo, chưa đọc đoạn code tính). Lần chạy đầu, mongod 9.0.2 bị OOM-kill ngay ở bước này: Memory cgroup out of memory: Killed process (mongod) … anon-rss:973408kB. Hạ ngưỡng xuống 50 MB (ts_user_cap) giữ BucketCatalog ~67 MB nhưng chèn chậm gấp đôi và bucket vẫn chỉ ~3 event — chỉnh ngưỡng không chữa được metaField sai.

Index trên field con của metaField: index tự tạo là {m: 1, ts: 1} — đánh index cả object m, nên lọc theo m.userId không dùng được nó:

  ts_user find {m.userId: 4242}, chỉ có index {m:1, ts:1}: COLLSCAN | bucket đọc 276751, keys 0 | 17 event, 164 ms
  … sau createIndex {"m.userId":1, ts:1}:              FETCH,IXSCAN | bucket đọc 5, keys 5 | 17 event, 11 ms
  ts_course khoá 7, 6 giờ, chỉ có index {m:1, ts:1}:   CLUSTERED_IXSCAN | bucket đọc 428, keys 0 | 507 event, 20 ms
  … sau createIndex {"m.courseId":1, ts:1}:            FETCH,IXSCAN,CLUSTERED_IXSCAN | bucket đọc 2, keys 3 | 507 event, 113 ms

Catalog 9.0 vs 8.0:

  ts_course                  type=timeseries timeseries={"timeField":"ts","metaField":"m","granularity":"minutes","bucketMaxSpanSeconds":86400,"fixedBucketing":true}
  listCollections tên chứa "ts_course": ["ts_course"]
  1 bucket thô: control.version=2, control.count=418, data.ts kiểu Binary 1205 byte, bsonsize 8452 B

  system.buckets.ts_course   type=collection timeseries={"timeField":"ts","metaField":"m","granularity":"minutes","bucketMaxSpanSeconds":86400}
  ts_course                  type=timeseries timeseries={"timeField":"ts","metaField":"m","granularity":"minutes","bucketMaxSpanSeconds":86400}
  listCollections tên chứa "ts_course": ["ts_course","system.buckets.ts_course"]

Truy vấn (tốt nhất trong 5 lần, trước khi tạo index field con): lấy event của khoá 7 trong 6 giờ — time series 14 ms, collection thường 10 ms (cùng 507 event); đếm theo ngày × loại trong 7 ngày — time series 16 ms vs 138 ms (8.0.32: 13 vs 42 ms) nhờ đọc ít bucket nén theo cột. Time series không làm truy vấn điểm nhanh hơn; nó thắng ở dung lượng và quét theo khoảng thời gian.

Giới hạn (9.0.2; lỗi thật — updateMany lọc theo metaField, deleteMany theo thời gian, distinct thì chạy được):

  ✗ updateOne field đo (không phải meta) → MongoServerError (72): Command error committing internal transaction :: caused by :: Cannot perform a non-multi update on a time-series collection
  ✗ updateMany field đo (multi) → MongoServerError (72): Cannot perform an update or delete on a time-series collection when querying on a field that is not the metaField 'm'
  ✗ createIndex unique → InvalidOptions (72): Unique indexes are not supported on time-series collections
  ✗ collMod validator $jsonSchema → InvalidOptions (72): option not supported on a time-series collection: validator
  ✗ watch() change stream → CommandNotSupported (115): Cannot run aggregation on timeseries with namespace lab.ts_course
  ✗ insert trong transaction → OperationNotSupportedInTransaction (263): time-series insert failed: lab.ts_course :: caused by :: Cannot insert into a time-series collection in a multi-document transaction: lab.ts_course
  ✓ renameCollection → 1

Khác trên 8.0.32: ✗ watch() change stream → CommandNotSupportedOnView (166): Cannot run aggregation on timeseries with namespace lab.ts_course | ✗ renameCollection → CommandNotSupportedOnView (166): cannot rename view: lab.ts_course. Ghi chú: trang Limitations của docs ghi distinct không hỗ trợ, nhưng cả hai bản đều chạy được trong lab. Phần 8.0 chạy lúc máy bận hơn (lab PostgreSQL chạy song song) — đừng so tốc độ chèn giữa 2 bản.

3.8 Schema validation: kiểm lúc ghi, không kiểm dữ liệu cũ (trừ constraint)

Validator chỉ chạy khi ghi (insert/update/replace/findAndModify). collMod thêm validator không quét dữ liệu đang có — lab: 7 ms trên 400 000 document (các lần chạy thử: 13–34 ms) dù 4 000 document đang sai. validationLevel quyết định update lên document cũ:

validationLevelInsertUpdate document đang hợp lệUpdate document đang SAIDocument sai cũ
offkhông kiểmkhông kiểmkhông kiểmgiữ nguyên
strict (mặc định)kiểmkiểmbị chặn — kể cả $set field không liên quangiữ nguyên, "nổ" khi bị chạm
moderatekiểmkiểmcho quagiữ nguyên, sửa tiếp được
constraint (9.0)kiểmkiểmkhông tồn tại — lên level này phải quét sạchkhông được phép có

validationAction: error (mặc định, mã 121 DocumentValidationFailure), warn (cho ghi, log id 20294 "Document would fail validation"), errorAndLog (8.1+, chặn và log id 7488700). Lên constraint là quy trình 3 bước, có quét toàn collection:

flowchart LR
  A["strict"] -->|"collMod prepareConstraintValidationLevel: true"| B["prepare
chặn bypassDocumentValidation
chặn đổi validator"] B -->|"collMod validationLevel: constraint"| C{"quét hết collection:
có document sai?"} C -->|"có"| E["lỗi 12370902
kèm _id sai đầu tiên"] C -->|"không"| D["constraint
mọi document hợp lệ"] D -->|"collMod validationLevel: strict"| A

Lab 9.0.2 trên 400 000 document: quét hết mất 250 ms (các lần chạy thử: 349, 488, 551 ms); có document sai thì dừng ở document sai đầu tiên sau 4 ms. Chi tiết lỗi thật ở mục 5.5.

4. Đảm bảo và giới hạn

Khía cạnhĐảm bảo / giới hạnHệ quả cho thiết kế
Nguyên tử1 document: luôn; nhiều document (updateMany, fan-out): không, trừ transaction (bài 8)Thứ cần nhất quán tuyệt đối nên nằm chung 1 document
Kích thước16 MiB/document, ~180 cấp lồng thực tế (bài 1); time series: 4 MB/measurement, bucket tối đa 1 000 measurement hoặc 125 KBMảng phải có trần; reply 204 byte → chạm 16 MB ở ~80 000 phần tử
Oplog mỗi updateDiff $v:2 nếu dùng operator hoặc pipeline update; $set cả mảng / replaceOne → cả giá trịORM ghi lại cả mảng là thảm hoạ replication
Validatorstrict/moderate không đảm bảo dữ liệu cũ hợp lệ; chỉ constraint (9.0) đảm bảo; time series không hỗ trợ validatorĐừng tin "đã có validator nên dữ liệu sạch" trên 8.0
Extended referenceNhất quán cuối cùng (eventual) — có cửa sổ lệchChỉ chép field ít đổi hoặc snapshot
Số collectionMỗi collection + mỗi index = 1 file .wt + 1 data handle; không có trần cứng, nhưng RAM/fd/thời gian khởi động tăng tuyến tính (mục 5.4)Multi-tenant: 1 collection + tenantId, không phải 1 collection/tenant

5. Failure modes

5.1 Mảng phình: chậm dần, history store phình, đọc lạnh kéo cả document

Ba triệu chứng, cùng một gốc là "document là đơn vị ghi" (bài 1), đo ở mục 3.1 và lab 02-array.txt:

  1. Ghi chậm dần, tail latency xấu — bảng mục 3.1: p50 tăng theo kích thước document, max lên hàng trăm ms (một lượt chạy trước có lần 3,8 s trên máy dùng chung).
  2. History store và đĩa phình dù dữ liệu gần như không tăng. Node mới, document mảng ~9,4 MB, mỗi bước 200 lần $push rồi 1 checkpoint:
    == History store khi document mảng ~9,5 MB bị sửa liên tục (hs, node mới)
    minSnapshotHistoryWindowInSeconds = 300
      200 × $push 1 reply + checkpoint       7286 ms | HS insert  400 (squashed 0) | HS trên đĩa 26.75 MB → 106.71 MB | node ghi đĩa 89.55 MB
      200 × $push 1 reply + checkpoint       7817 ms | HS insert  400 (squashed 0) | HS trên đĩa 106.71 MB → 191.30 MB | node ghi đĩa 89.18 MB
    == History store khi document mảng ~9,5 MB bị sửa liên tục (hs0, node mới)
    minSnapshotHistoryWindowInSeconds = 0
      200 × $push 1 reply + checkpoint       7328 ms | HS insert    0 (squashed 0) | HS trên đĩa 40.04 MB → 40.04 MB | node ghi đĩa 4.52 MB
      200 × $push 1 reply + checkpoint       6025 ms | HS insert    0 (squashed 0) | HS trên đĩa 40.04 MB → 40.04 MB | node ghi đĩa 8.80 MB
    Đặt cửa sổ snapshot về 0 thì history store đứng yên và đĩa chỉ ghi 4,52 MB thay vì 89,55 MB — nguyên nhân là phải giữ mọi phiên bản 300 s cho đọc snapshot, không phải oplog. Đừng "chữa" bằng cách hạ cửa sổ: nó phục vụ read concern snapshot và transaction (bài 8); chữa bằng mô hình dữ liệu. Collection chỉ có 1 document mà storageSize 12,85 MB cho 9,59 MB dữ liệu — chỗ trống sau mỗi lần viết lại không trả về hệ điều hành (bucket: 4,96 MB cho cùng dữ liệu).
  3. Đọc 20 reply mới nhất kéo cả document vào cache:
    == Đọc 20 reply mới nhất khi cache lạnh (ngay sau restart)
      mảng: find + $slice -20                    70 ms | trả về 20 reply | đọc vào cache 19.17 MB
      bucket: bucket mới nhất (+ bucket trước)   25 ms | trả về 400 reply | đọc vào cache 0.10 MB
      1 doc/reply: sort at -1 limit 20 (index)  110 ms | trả về 20 reply | đọc vào cache 1.27 MB
    Trang "bình luận mới nhất" của một bài nóng làm đầy cache bằng hàng chục MB của một document, đẩy dữ liệu khác ra (bài 6).
  4. Chạm 16 MB: reply 204 byte → khoảng 80 000 reply là update hỏng hẳn với lỗi 10334 (bài 1, mục 3.4). Đây là triệu chứng cuối cùng, ba cái trên đến trước.

Phát hiện: $bsonSize lớn nhất theo collection, avgObjSize tăng theo thời gian, history store tăng (mục 6). Phục hồi: tách mảng ra bucket hoặc collection riêng bằng backfill (mục 5.6), giữ subset N phần tử mới nhất trong cha bằng $push: {$each: [x], $slice: -N}.

5.2 ORM ghi lại cả mảng thay vì dùng operator

Cùng ý "thêm 1 reply" vào mảng 50,800 phần tử, viết theo 5 cách (20 lần mỗi cách):

== Cùng thay đổi "thêm 1 reply" nhưng viết khác nhau (mảng 50,800 phần tử)
  $push (operator)                               32.6 ms/op | oplog 0.5 KB/op | journal 0.8 KB/op | mongod RSS 394 → 422 MB
  $addToSet (embedsMany của laravel-mongodb)     39.8 ms/op | oplog 0.5 KB/op | journal 0.8 KB/op | mongod RSS 422 → 422 MB
  $set cả mảng (đọc về, thêm, ghi lại)          374.9 ms/op | oplog 9808.0 KB/op | journal 4377.0 KB/op | mongod RSS 422 → 626 MB
  replaceOne cả document                        384.9 ms/op | oplog 9811.9 KB/op | journal 4378.6 KB/op | mongod RSS 626 → 700 MB
  pipeline update $concatArrays                  57.0 ms/op | oplog 0.5 KB/op | journal 0.8 KB/op | mongod RSS 700 → 735 MB

$push, $addToSet và pipeline update ($concatArrays) để server tự tính diff → oplog 0,5 KB. Đọc cả mảng về app, thêm phần tử rồi $set cả mảng (hoặc replaceOne) → mỗi lần ghi một entry oplog ~9,8 MB và journal ~4,3 MB: secondary phải nhận và áp lại gần 10 MB cho mỗi reply, cửa sổ oplog co lại rất nhanh. RSS mongod tăng 422 → 626 MB chỉ sau 20 lần ghi như vậy; lần chạy đầu (mongosh chạy chung container, chung giới hạn 1 GB) mongod bị OOM-killer giết: Memory cgroup out of memory: Killed process (mongod) … anon-rss:735856kB. Đây là kiểu code ORM rất hay gặp: gán lại cả thuộc tính mảng rồi save() — laravel-mongodb gửi $set cả field (query log ở lab mục 5.6), Mongoose cũng vậy, còn doc.replies.push(x) thì Mongoose gửi $push (lab 02-mongoose.txt): {"$set":{"replies":[…cả mảng…]},"$inc":{"__v":1}} so với {"$push":{"replies":{"$each":[…]}},"$inc":{"__v":1}}. embedsMany của laravel-mongodb gửi $addToSet (mục 3.2) — đúng operator, nhưng vẫn là mảng phình.

5.3 Fan-out bị ngắt giữa chừng: không rollback

Cùng updateMany nhưng có maxTimeMS: 300 (giống app đặt timeout, hoặc primary step down giữa chừng):

  LỖI: MaxTimeMSExpired (50): Plan executor error during update :: caused by :: operation exceeded time limit
  sau lỗi, khoá 1 đang mang các tên: "Laravel căn bản" × 285,282, "Laravel 13 (bị ngắt)" × 14,718

14 718 document đã đổi giữ nguyên — không có rollback. Phát hiện: app nhận lỗi, nhưng nếu job "fire and forget" thì không ai biết. Phục hồi: chạy lại; lệnh phải idempotent (đặt giá trị tuyệt đối, filter title ≠ mới) để chạy lại an toàn. Cần đổi trên nhiều document mà không được thấy nửa chừng → transaction (giới hạn 60 s, bài 8) hoặc thiết kế lại để giá trị nằm ở 1 document (reference).

5.4 Quá nhiều collection: hết file descriptor rồi treo, RAM ngoài cache, khởi động chậm

"Mỗi tenant/khoá học một collection" nghe gọn, nhưng WiredTiger tạo 1 file cho mỗi collection và mỗi index. Lab 02-colls.txt: mỗi tenant 1 collection, 20 document, thêm index {sku: 1} → 3 file .wt/tenant. Node riêng tmp-mdb-c (9.0.2, cache 0,25 GB, RAM 1 GB).

Phần A — container Docker mặc định (nofile soft = 1024):

Max open files            1024                 524288               files
  (lệnh tạo 1000 collection bị timeout 150 s ngắt — client không nhận được lỗi nào)
  collection tạo được: 334 file collection-*.wt (gồm cả collection hệ thống)
  file .wt: 1306, fd mongod đang mở: 1018
  {"s":"W","c":"CONTROL","id":22184,"msg":"Soft rlimits for open file descriptors too low","attr":{"currentValue":1024,"recommendedMinimum":64000}}
  {"s":"W","c":"CONTROL","id":12006910,"msg":"Failed to collect disk stats", … "errmsg":"Failed to open file /proc/diskstats with error: Too many open files"}

Khoảng 300 tenant là mongod hết file descriptor: lệnh tạo collection treo tới khi bị timeout 150 s ngắt, client không nhận lỗi nào (lần chạy đầu, cả mongosh mở kết nối mới để ping cũng treo) — chỉ có cảnh báo lúc khởi động (id 22184) mà ít ai đọc. Đây là bẫy nổ trễ: dev/staging ít tenant chạy ngon.

Phần B — --ulimit nofile=64000:64000 (như production notes khuyên):

collections 0    | mongod RSS 186 MB, WT cache  0 MB | data handles    82 | checkpoint   488 ms | listCollections 10 ms
tạo inv_0 … inv_2499 (mỗi cái 20 doc + index sku): 484520 ms, 193.8 ms/collection
collections 2500 | mongod RSS 805 MB, WT cache 41 MB | data handles 13440, files open 5304 | checkpoint 3222 ms (handles walked 12270)
                 | listCollections nameOnly 57 ms, đầy đủ 85 ms, db.stats() 203 ms | file .wt 7798
tạo tiếp → MongoBulkWriteError: connection 3 to 172.21.0.3:27017 closed
  dmesg: Memory cgroup out of memory: Killed process (mongod) … anon-rss:1018188kB
restart với 3823 collection: MongoDB starting → Waiting for connections 15570 ms
---- cùng dữ liệu, 1 collection inv_all + index {tenantId, sku} ----
1 collection, 5000 tenant × 20 doc: 1856 ms | RSS 242 MB | data handles 93 | checkpoint 27 ms | file .wt 46
restart: Waiting for connections 863 ms

Ba con số cần nhớ: (1) RAM ngoài cache: 2 500 collection đẩy RSS lên 805 MB trong khi dữ liệu trong cache chỉ 41 MB — khoảng 0,25 MB mỗi collection (3 bảng) cho data handle và catalog; với RAM 1 GB thì mongod bị OOM-kill ở ~3 800 collection. (2) Checkpoint phải đi qua mọi handle đang mở: 3,2 s so với 27 ms. (3) Khởi động lại: 15,6 s so với 0,86 s — mỗi lần failover/restart đều trả giá này. Tạo tenant cũng đắt: ~194 ms cho mỗi lần tạo collection + insert 20 document + createIndex (chưa tách riêng từng bước) → onboarding 10 000 tenant mất ~30 phút. Tỉ lệ RAM/collection đo trên máy lab nhỏ; ở quy mô hàng trăm nghìn collection chưa verify. Câu chuyện công khai: Parse (2015) chạy MongoDB với hàng triệu collection và hàng chục triệu index (slide Storage Engine Wars at Parse, Charity Majors, MongoDB World 2015).

5.5 Validator: dữ liệu cũ, message khác nhau giữa bản, và bản 8.0 không hiểu option của 9.0

Lab 02-validate.txt, collection có sẵn 2 document sai (giá là chuỗi; thiếu title), rồi mới thêm validator. Các bẫy theo mức hay dính:

  1. Document sai cũ "nổ" khi bị chạm, không phải khi thêm validator. collMod trả ok; vài tuần sau một tính năng không liên quan $set field level lên document cũ thì lỗi:
    ✗ update doc SAI cũ (_id 2) — chỉ $set level (strict) → MongoServerError (121): Plan executor error during update :: caused by :: Document failed validation
      errInfo: {"failingDocumentId":2, … "propertyName":"price","description":"giá phải là Decimal128 ≥ 0", … "operatorName":"bsonType",
                "specifiedAs":{"bsonType":"decimal"},"reason":"type did not match","consideredValue":"299000","consideredType":"string"}
    Trước khi bật, đếm bằng db.c.countDocuments({$nor: [schema]}) và sửa; trong lúc sửa dùng moderate (update document sai cũ được cho qua, document đúng vẫn bị kiểm).
  2. Thông báo lỗi khác nhau giữa bản: 9.0.2 trả Plan executor error during update :: caused by :: Document failed validation, 8.0.32 trả Document failed validation. Code so khớp chuỗi sẽ vỡ khi nâng cấp — bắt theo code === 121.
  3. Script viết cho 9.0 chạy trên 8.0 để lại trạng thái nguy hiểm. Trên 8.0.32:
    ✗ collMod validationAction errorAndLog → BadValue (2): Enumeration value 'errorAndLog' for field 'collMod.validationAction' is not a valid value.
    ✓ insert sai khi errorAndLog → 5          ← collMod lỗi nên action vẫn là "warn" từ bước trước: document sai lọt vào
    ✗ collMod validationLevel constraint → BadValue (2): Enumeration value 'constraint' for field 'collMod.validationLevel' is not a valid value.
    ✗ collMod prepareConstraintValidationLevel:true → IDLUnknownField (40415): BSON field 'collMod.prepareConstraintValidationLevel' is an unknown field.
    Migration phải dừng ở lỗi đầu tiên và kiểm lại getCollectionInfos() sau khi chạy.
  4. warn ghi cả document vào log. Log id 20294 chứa nguyên document (lab: "document":{"_id":4,"title":"x","price":5}) — collection có email/số điện thoại thì log mongod thành nơi chứa dữ liệu cá nhân. errorAndLog (id 7488700) cũng vậy.
  5. constraint chặn mọi đường lách — đúng ý, nhưng phải đi đủ 3 bước:
    ✗ collection SẠCH, lên constraint thẳng → BadValue (2): … Cannot upgrade validationLevel to 'constraint' without first setting prepareConstraintValidationLevel to true
    ✗ còn document sai → Location12370902: Cannot upgrade validationLevel to 'constraint': the collection contains documents that do not satisfy
                         the validator. First offending document _id: 2 Run db.v_courses.find({"$nor": [{ $jsonSchema: … }]}) …
    ✓ sau prepare: insert + bypassDocumentValidation → n=0, writeErrors[0]: 2 bypassDocumentValidation is not permitted while prepareConstraintValidationLevel is set …
    ✗ đang constraint: đổi validator → BadValue (2): … Validator cannot be changed when Validation level is 'constraint'
    ✗ đang constraint: validationAction warn → BadValue (2): … Validation action of 'warn' is not allowed when Validation level is 'constraint'
    Muốn đổi luật: hạ về strict → đổi validator → prepare → lên lại constraint (quét lại toàn bộ). Lưu ý bypassDocumentValidation bị chặn cả lúc prepare — job nhập liệu cũ dựa vào bypass sẽ lỗi.
  6. Kiểu số: validator bsonType: "int" chặn số không nguyên (luôn là double ở mọi driver) và số lớn (PHP ghi long, Node ghi double — bài 1). Lab: 4 000/400 000 document có progress kiểu double → strict không báo gì (7 ms, không quét), constraint dừng ở _id: 100. Dùng alias bsonType: "number" nếu chỉ cần "là số".

5.6 Lazy migration với Laravel: 4 bẫy

Đổi schema khoá học từ 1 giảng viên sang nhiều: v1 {instructor: "GV 7"} → v2 {instructors: [{name: "GV 7"}], schema_version: 2}. laravel-mongodb 5.11 có sẵn trait HasSchemaVersion: ở event retrieved, nếu schema_version < SCHEMA_VERSION thì gọi migrateSchema() rồi gán version mới — chỉ trong RAM.

class Course extends Model
{
    use HasSchemaVersion;
    public const SCHEMA_VERSION = 2;
    protected $keyType = 'int';                       // _id số nguyên (xem bẫy 4)

    public function migrateSchema(int $fromVersion): void
    {
        if ($fromVersion < 2) {
            $this->instructors = [['name' => $this->instructor]];
            $this->unset('instructor');               // save() sẽ gửi $unset
        }
    }
}
Lazy migration: document v1 và v2 cùng tồn tại, được nâng cấp dần Hàng 12 document ban đầu ở dạng v1. Pha A: request đọc document 4 bằng model mới, model nâng cấp trong RAM rồi save ghi dạng v2. Pha B: job backfill quét trái sang phải theo lô, đổi các document còn lại sang v2. Pha C: pod còn chạy code cũ ghi field instructor vào document 3 đã là v2; code mới thấy schema_version 2 nên bỏ qua, thay đổi bị mất. v1 {instructor: "GV 3"} và v2 {instructors: [{name}], schema_version: 2} sống chung v1v2 _id 1 _id 2 _id 3 _id 4 _id 5 _id 6 _id 7 _id 8 _id 9 _id 10 _id 11 _id 12 A. Course::find(4) → migrateSchema() nâng lên v2 TRONG RAMDB vẫn v1 tới khi save() gửi $set instructors + $unset instructorlúc này where("instructors.name") chỉ thấy 1/400 document B. backfill: lô 1 000, ≤ 20 000 doc/s, filter schema_version $exists:falsepipeline update tính giá trị trên server → không đè dữ liệu vừa sửa100 000 doc: 6,2 s theo lô · 3,4 s một lệnh — oplog như nhau ~23 MB C. pod CŨ ghi instructor = "GV Mới" vào _id 3 (đã là v2)code mới thấy schema_version 2 → không migrate lại → vẫn đọc instructors cũmất cập nhật, không có lỗi nào pod CŨ (code v1) pod MỚI (HasSchemaVersion v2) cùng chạy khi deploy A → B → C lặp lại. Khung tĩnh: mọi document đã v2, _id 3 dính mất cập nhật.
Hình 3 — Lab 02-lazy.txt. Document v1 và v2 sống chung. (A) model mới nâng cấp document khi đọc, nhưng chỉ ghi xuống khi save(). (B) job backfill quét theo lô, chỉ đụng document chưa nâng cấp. (C) trong lúc deploy cuốn chiếu, pod chạy code cũ ghi field kiểu cũ vào document đã là v2 — code mới bỏ qua, thay đổi mất mà không có lỗi.

Lab 02-lazy.txt, 200 000 document v1 (chưa có field schema_version → trait coi là version 0):

bẫy _id số nguyên: model để keyType mặc định → find(1) = NULL, query: {"find":[{"_id":"1"},{"typeMap":{"root":"object","document":"array"}}]}
-- 1. đọc bằng model mới
: migrate trong RAM, DB chưa đổi tới khi save()
  model sau find(1): schema_version=2, instructors=[{"name":"GV 1"}]
  DB lúc này : {"_id":1,"title":"Khoá 1","instructor":"GV 1","price":499000}
  save() gửi : update lz_courses q {_id: 1} u {$unset: {instructor: true}, $set: {instructors: [{name: "GV 1"}], schema_version: 2}}  (rút gọn từ query log)
  DB sau save: {"_id":1,"title":"Khoá 1","price":499000,"instructors":[{"name":"GV 1"}],"schema_version":2}
-- 2. truy vấn theo field mới
 bỏ sót document chưa nâng cấp
  Course::where('instructors.name', 'GV 1')->count()          = 1
  … orWhere('instructor', 'GV 1') (đọc được cả 2 dạng)        = 400
-- 3. query builder
 / update hàng loạt KHÔNG chạy migrateSchema
  update(['instructors.0.email' => …]) lên doc v1 → DB: {"_id":2,"title":"Khoá 2","instructor":"GV 2","price":499000,"instructors":[{"email":"gv2@lms.vn"}]}
    kiểu BSON thật của instructors (server $type): object  — $set 'instructors.0.email' lên field chưa có tạo document con khoá "0", không phải mảng
  rồi model mới find(2)->save() (migrateSchema ghi đè instructors) → DB: {"_id":2,"title":"Khoá 2","price":499000,"instructors":[{"name":"GV 2"}],"schema_version":2}
-- 4. deploy cuốn chiếu
: pod CŨ và pod MỚI cùng chạy
  pod cũ đọc doc v2: instructor = NULL  (trang hiển thị trống)
  pod cũ save() → DB: {"_id":3,"title":"Khoá 3","price":499000,"instructors":[{"name":"GV 3"}],"schema_version":2,"instructor":"GV Mới"}
  pod mới đọc lại: instructors = [{"name":"GV 3"}]  ← thay đổi của pod cũ bị bỏ qua (schema_version đã là 2)
  1. Truy vấn theo field mới bỏ sót dạng cũ — where('instructors.name', 'GV 1') đếm được 1, thực tế có 400. Trong suốt giai đoạn chuyển, mọi truy vấn phải $or hai dạng và có index cho cả instructor lẫn instructors.name.
  2. Query builder, update() hàng loạt, aggregate, job ngoài model không chạy migrateSchema. Tệ hơn: update(['instructors.0.email' => …]) lên document v1 tạo ra document con khoá "0" (server báo $type: object) chứ không phải mảng — nhưng json_encode của PHP in ra [{"email":…}] trông như mảng. Lần sau model đọc document, migrateSchema ghi đè instructors và email biến mất.
  3. Deploy cuốn chiếu: pod cũ ghi vào document đã là v2 → document mang cả instructor: "GV Mới" lẫn instructors cũ, schema_version: 2; pod mới không migrate lại nên thay đổi biến mất, không có lỗi. Chữa: giai đoạn 1 chỉ deploy code đọc được v2 và vẫn ghi v1 (hoặc ghi cả hai), giai đoạn 2 mới bật ghi v2 — expand/contract.
  4. _id số nguyên + model mặc định: MongoDB\Laravel\Eloquent\Model đặt $keyType = 'string', find(1) gửi {"_id":"1"} và trả NULL — dữ liệu chuyển từ MySQL hay dính. Đặt protected $keyType = 'int'.

Backfill: chạy theo lô _id, filter lặp lại schema_version: {$exists: false} (không đè document vừa được lazy nâng cấp), biến đổi bằng pipeline update để giá trị được tính trên server từ dữ liệu hiện tại (đọc về PHP rồi ghi lại có thể đè thay đổi xảy ra giữa lúc đọc và ghi):

$col->updateMany(['_id' => ['$in' => $ids], 'schema_version' => ['$exists' => false]],
    [['$set' => ['instructors' => [['name' => '$instructor']], 'schema_version' => 2]], ['$unset' => 'instructor']]);
5a. lô 1000 + nghỉ (≤ 20 000 doc/s): 99997 doc, 100 lô, 6.2 s, oplog +23.1 MB
  5b. 1 lệnh updateMany pipeline: 100000 doc, 3.4 s, oplog +23.2 MB
  còn lại chưa nâng cấp: 0 | ví dụ doc: {"_id":150000,"title":"Khoá 150000","price":499000,"instructors":[{"name":"GV 0"}],"schema_version":2}

Cùng 100 000 document: theo lô có nghỉ mất 6,2 s, một lệnh updateMany mất 3,4 s — oplog như nhau (23,1 và 23,2 MB, ~242 byte/document). Giới hạn tốc độ không làm oplog nhỏ đi; nó trải oplog ra theo thời gian để secondary kịp áp và cửa sổ oplog không bị xoá sạch trong vài giây (mục 6). Lệnh một phát còn giữ một operation dài, khó dừng giữa chừng, và chết giữa chừng thì không biết đã tới đâu.

5.7 Mongoose schema ≠ validation phía DB

Mongoose validate trong process Node đó, và chỉ ở vài đường đi. Lab 02-mongoose.txt (Mongoose 9.10.4, schema price: {min: 0}, stock: {min: 0}, status: {enum: ["draft","active"]}), chưa có validator phía server:

✗ Product.create({price: -1}) → ValidationError: … Path `price` (-1) is less than minimum allowed value (0).
✓ updateOne {$set: {price: -5}} (mặc định)                → 1      ← update validators TẮT mặc định
✗ updateOne {$set: {price: -7}} + runValidators           → ValidationError
✓ updateOne {$inc: {stock: -100}} + runValidators         → 1      ← runValidators bỏ qua $inc
✓ findOneAndUpdate {$set: {status: "archived"}}           → "archived"
✗ updateOne {$set: {price: "abc"}}                        → CastError   ← cast luôn chạy, validate thì không
✓ Product.collection.insertOne (driver gốc) price "abc"   → true
  DB đang chứa: [{"sku":"A","price":-5,"stock":-95,"status":"archived"},{"sku":"B","price":"abc","stock":-1}]

Thêm $jsonSchema bằng collMod thì mọi đường trên (kể cả $inc, driver gốc, client PHP/mongosh khác) đều bị chặn ở server: MongoServerError (121): Plan executor error during update :: caused by :: Document failed validation. Kết luận: schema Mongoose/Eloquent cast là tiện ích của 1 app; luật dữ liệu phải ở server khi có từ 2 nơi ghi trở lên (job PHP, script mongosh, service khác).

6. Vận hành trong production

Cần biếtLệnh / metricCảnh giác khi
Document to nhất, mảng dài nhất$project: {s: {$bsonSize: "$$ROOT"}} + $sort — quét cả collection, chạy trên secondary ẩnDocument > 1 MB bị update thường xuyên
History storeserverStatus().wiredTiger.cache["history store table on-disk size"]Tăng đều trong khi dữ liệu không tăng
Oplog theo collectionlocal.oplog.rs: $group theo ns, cộng $bsonSize; rs.printReplicationInfo()Entry hàng MB (ORM ghi cả mảng); cửa sổ oplog co lại
Dữ liệu lệch schemacountDocuments({$nor: [validator]}); $group: {_id: {$type: "$f"}}; log id 20294 / 7488700> 0 trước khi lên constraint
Collection / handle / fdwiredTiger["data-handle"]["connection data handles currently active"]; /proc/<pid>/limitsnofile < 64000; handle tăng theo tenant
Bucket time series9.0: count với rawData: true; 8.0: system.buckets.<c>; serverStatus().bucketCatalogVài measurement/bucket; BucketCatalog hàng trăm MB

Runbook backfill: (1) deploy code đọc được cả 2 dạng, hết pod cũ; (2) lô 500–1 000 theo _id, w: "majority", nghỉ giữa lô, filter lặp lại điều kiện "chưa nâng cấp", lưu _id cuối để chạy tiếp; (3) theo dõi lag secondary và cửa sổ oplog, tự giảm tốc; (4) đếm về 0 rồi mới xoá code và index dạng cũ.

Capacity — ví dụ minh hoạ (suy từ ~240 byte oplog/document đo được; quy mô lớn chưa verify): backfill 50 triệu document ≈ 12 GB oplog. Oplog 10 GB thì backfill hết tốc lực xoá sạch cửa sổ oplog — secondary đang restart lúc đó phải initial sync (bài 7). Tốc độ backfill ≤ (kích thước oplog − dự phòng) / thời gian muốn giữ cửa sổ.

3 giờ sáng: "ghi chậm dần trên một collection" → document to nhất, history store, byte ghi đĩa so với số thao tác. "mongod khởi động vài phút / treo không lỗi" → đếm collection + index, kiểm nofile. "Một thứ hiển thị 2 tên" → job đồng bộ extended reference chết giữa chừng (mục 5.3).

7. So sánh và ma trận quyết định

TrụcMongoDB embedMongoDB referencePostgreSQL chuẩn hoáPostgreSQL + JSONB
Đọc trang chi tiết1 lệnh, nhanh nhất2 lệnh / $lookupJOIN, planner tốt1 hàng nếu nhúng JSONB
Cập nhật 1 sự thật dùng chungFan-out N document, không nguyên tử1 document1 hàng, nguyên tửNhư embed nếu chép vào JSONB
Phần tử con tăng mãiAnti-pattern (16 MB, HS, đĩa)TốtTốt (bảng con)Tệ hơn Mongo: sửa JSONB = ghi lại cả giá trị (lab dưới)
Ép schema$jsonSchema; constraint từ 9.0như embed; không có FKKiểu cột, FK, CHECKCHECK + jsonb_typeof, tự viết
Đổi schemaKhông ALTER: versioning + backfillnhư embedALTER (nhiều cái nhanh từ PG 11), backfill khi đổi kiểuNhư Mongo trong cột JSONB
Truy vấn ad-hoc / báo cáoYếu nếu dữ liệu bị chép lệch$lookup đắtMạnh nhấtMạnh, trộn SQL + JSON
Dữ liệu theo thời gianTime series collection (bucket nén sẵn)—Partition theo thời gian—

Cùng anti-pattern trên PostgreSQL 18.4 (out/02-jsonb.txt, 1 hàng có cột JSONB chứa 50 000 reply, 7 064 kB sau nén TOAST; nối 1 phần tử bằng replies || jsonb_build_array(…) 200 lần):

 wal_200_lan_noi | wal_moi_lan_noi_1_phan_tu
-----------------+---------------------------
 1770 MB         | 9063 kB
 bang_kem_toast_sau_200_lan → 1440 MB   (VACUUM xong vẫn 1440 MB — không trả đĩa)
 Time: 134301.885 ms (200 lần)

PostgreSQL không có update tại chỗ cho JSONB: mỗi lần sửa tạo phiên bản hàng mới (MVCC) và giá trị TOAST mới — ~9 MB WAL cho 1 phần tử, trong khi MongoDB ghi oplog 0,5 KB (dù vẫn trả giá ở history store và đĩa). Mảng phình là anti-pattern ở cả hai; với PostgreSQL thì bảng con luôn là đáp án.

Dùng embed khi: phần con luôn được đọc cùng cha, có trần nhỏ, ít bị sửa độc lập (item đơn hàng, bài học của khoá, địa chỉ giao hàng). Dùng reference khi: phần con tăng không giới hạn, được đọc/sửa riêng, hoặc dùng chung nhiều nơi. Dùng extended reference khi: field chép gần như không đổi hoặc là snapshot. Tránh MongoDB cho mô hình này khi: dữ liệu quan hệ chằng chịt, nhiều luật toàn vẹn xuyên thực thể, báo cáo ad-hoc là chính — PostgreSQL chuẩn hoá (cộng JSONB cho phần thuộc tính động) rẻ hơn về lâu dài. So sánh JSONB chi tiết ở bài 1 và bài 11.

8. Góc nhìn tech lead: tác động tổ chức

9. Anti-patterns và bài học thực tế

  1. Mảng không có trần (reply trong bài viết, thông báo trong user, log trong thiết bị): chậm dần, đĩa và history store phình theo cả document (mục 5.1). Thêm trần ($push + $slice), bucket, hoặc collection riêng.
  2. ORM ghi lại cả mảng: đọc về, push trong PHP/JS, $set cả mảng → oplog cỡ cả mảng mỗi lần (mục 5.2).
  3. Một collection mỗi tenant/khoá học: fd, RAM ngoài cache, checkpoint, khởi động chậm (mục 5.4). Manual: Reduce the Number of Collections.
  4. Chép field hay đổi (giá hiện tại, tồn kho, trạng thái) vào nhiều document → bão updateMany, dữ liệu lệch (mục 5.3).
  5. Tách rời thứ luôn đọc cùng nhau rồi bù bằng $lookup ở mọi trang — manual gọi là Reduce $lookup Operations.
  6. Document phình vì thứ ít đọc (lịch sử, bản nháp) → trang danh sách kéo cả MB vào cache: Reduce Bloated Documents; dùng subset/projection.
  7. Validator để warn mãi mãi — log đầy dữ liệu cá nhân mà dữ liệu vẫn sai; hoặc không có validator vì "schema-less".
  8. Lazy migration không có backfill → hai dạng sống mãi, mọi truy vấn mang $or; hoặc không có schema_version nên không biết document nào đã nâng cấp.
  9. Time series với metaField cardinality cao (userId) → bucket gần rỗng, chèn chậm, index to (mục 3.7).
  10. Cây parent ref không chặn vòng lặp khi chuyển nhánh (mục 3.6).

Nguồn công khai: loạt anti-pattern trong manual (link trên); quy tắc "mảng không được tăng vô hạn" của William Zola (2014, đã dẫn ở mục 1); Parse với hàng triệu collection (mục 5.4). Chưa tìm được postmortem công khai gắn đúng các con số history store/fan-out ở bài này — các số đó là lab tự dựng.

Điều docs không nói rõ mà người vận hành biết: oplog nhỏ ($v:2) không có nghĩa là ghi rẻ — đĩa và history store vẫn trả theo kích thước cả document; ví dụ bucket trong docs quét mọi bucket của khách hàng mỗi lần ghi; collMod thêm validator không quét dữ liệu cũ; container Docker mặc định nofile 1024 làm mongod treo không báo lỗi ở vài trăm collection; json_encode của PHP in document con khoá "0" giống hệt một mảng; model laravel-mongodb mặc định $keyType = 'string' nên find(1) không thấy _id số nguyên.

10. Câu hỏi phụ interviewer hay hỏi tiếp

Khi nào embed, khi nào reference?
Embed khi phần con luôn đọc cùng cha, có trần nhỏ, ít bị sửa riêng. Reference khi tăng không giới hạn, được đọc/sửa độc lập, hoặc dùng chung. Câu hỏi gốc luôn là access pattern, không phải "thực thể nào".
$push dùng diff trong oplog thì mảng to cũng không sao?
Sai. Oplog nhỏ (0,5 KB) nhưng server dựng lại cả document mỗi lần, WiredTiger phải giữ mọi phiên bản trong cửa sổ snapshot 300 s rồi ghi xuống history store và đĩa: lab document 9,5 MB, 200 lần thêm 204 byte ghi ~90 MB xuống đĩa; history store +80 MB (cửa sổ snapshot 0 s: chỉ 4,5 MB, history store không tăng).
Bucket pattern có nhược điểm gì?
Logic ghi phức tạp hơn; nếu tìm bucket mở bằng filter count < N không sort thì quét mọi bucket (lab 257 bucket/lần ghi); bucket đầy → quét hết rồi upsert; _id theo giây → E11000 khi 2 bucket mở cùng giây. Phân trang theo bucket chứ không theo phần tử.
Extended reference khác snapshot thế nào?
Cùng là chép field. Extended reference phải đồng bộ khi gốc đổi (fan-out, lệch tạm thời). Snapshot cố ý giữ giá trị lúc sự kiện xảy ra (giá lúc mua) — đồng bộ nó là bug.
Đổi schema không có ALTER TABLE làm sao?
schema_version trong mỗi document; code đọc được cả 2 dạng, nâng cấp khi chạm (lazy, laravel-mongodb có HasSchemaVersion); backfill theo lô có giới hạn tốc độ, idempotent, tính giá trị bằng pipeline update trên server; đếm về 0 rồi mới xoá code dạng cũ. Bẫy: hai bản code cùng chạy, query/index chỉ biết dạng mới, query builder không chạy migrate.
validationLevel strict có đảm bảo mọi document hợp lệ không?
Không. Nó chỉ kiểm document được ghi từ lúc bật; document sai cũ vẫn nằm đó (và chặn mọi update lên chính nó). Chỉ constraint (9.0) quét toàn collection khi bật và đảm bảo mọi document hợp lệ, đồng thời cấm bypassDocumentValidation và cấm đổi validator.
Vì sao không nên mỗi tenant một collection?
Mỗi collection + index là 1 file và 1 data handle: lab 2 500 collection → RSS 805 MB với cache 41 MB, checkpoint 3,2 s, khởi động lại 15,6 s, OOM ở ~3 800; Docker nofile mặc định 1024 thì treo ở ~300 tenant. Dùng tenantId đứng đầu index.
Time series collection khác tự làm bucket thế nào?
Server tự gom theo metaField + khoảng thời gian, nén cột (BSONColumn), tự viết lại truy vấn; đổi lại có giới hạn: update/delete chỉ lọc theo metaField, không unique index, không validator, không change stream, không ghi trong transaction. 9.0 lưu thành 1 namespace (8.0 là view + system.buckets). Chọn metaField sai (cardinality cao) thì mất hết lợi ích.

Phụ lục: lab đã chạy

Môi trường: MongoDB 9.0.2 (mongo:9.0.2, replica set 1 node, WiredTiger cache 0,25 GB, --memory 1g) và 8.0.32 (mongo:8.0.32) để so validator + time series; node riêng tmp-mdb-c cho phần "quá nhiều collection" (lần A không đặt ulimit, lần B --ulimit nofile=64000:64000); mongosh 2.12.0 chạy ở container client riêng (mfx); PHP 8.3 + ext-mongodb 2.5.3 + mongodb/laravel-mongodb 5.11.0 trên Laravel 13; Node 22 + mongodb 7.7.0 + mongoose 9.10.4. Container tạm tmp-mdb-* trên network tmp-mdb-net; PostgreSQL 18.4 (my-test-pgdb, bảng tmp_mdb_post xoá sau khi đo). Máy 4 vCPU dùng chung — mili-giây chỉ xem tỉ lệ (array chạy 4 lần, số trong bài là lần cuối; đọc/embed 3 lượt).

cd labs/mongodb
./02-mo-hinh-du-lieu.sh              # chạy hết (~50 phút) → out/02-*.txt
./02-mo-hinh-du-lieu.sh array        # 1 phần: array | fanout | colls | validate | ts | tree | lazy | embed | mongoose | jsonb
(source lib.sh && mdb_down tmp-mdb-1 tmp-mdb-80 tmp-mdb-c)   # dọn container

Bẫy hạ tầng gặp khi dựng lab (để người chạy lại khỏi mất thời gian): (1) mf chạy mongosh bên trong container mongod nên mongosh ăn chung cgroup 1 GB — lần đầu đo "ghi lại cả mảng 9,5 MB", OOM-killer giết mongod chứ không phải mongosh; lab bài này dùng mfx (mongosh ở container riêng). (2) Container Docker mặc định nofile soft 1024 → mongod treo ở vài trăm collection (mục 5.4). (3) LCG kiểu seed * 1103515245 trên số thực JS vượt 2^53, mất chính xác, rơi vào chu kỳ ngắn (chỉ ~10 000 userId khác nhau thay vì 50 000) — dùng PRNG số nguyên (mulberry32). (4) PHP giữ 100 000 bài học trong mảng vượt memory_limit 128M → thoát mã 255 không in lỗi.

Chưa verify: hành vi trên Atlas; tỉ lệ RAM/collection và thời gian khởi động ở quy mô hàng chục nghìn collection trở lên (lab dừng ở 3 823 vì OOM); capacity oplog mục 6 (suy từ byte/document đo được); cách server tính "system memory" cho ngưỡng BucketCatalog trong container (suy từ số đo, chưa đọc code tính); history store lưu phiên bản cũ dưới dạng nào (đo được kích thước, cơ chế chi tiết để bài 6).