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 (fileWiredTigerHS.wtgiữ 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ùngcursor->modify),src/mongo/db/timeseries/README.md(bucket, BSONColumn, viewless time series). Sourcelaravel-mongodb5.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$jsonSchemaphía server (9.0 thêmconstraintđả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 choALTER 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 đọc | Giảm: 1 lệnh | Tăng: 2 lệnh hoặc $lookup | Khoá + 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ép | 1 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ả document | Nhỏ, ổn định | Mảng 50 000 reply = 9,5 MB (mục 3.1) |
| Oplog / đĩa mỗi lần ghi | Oplog nhỏ nếu dùng operator; đĩa theo cả document | Nhỏ | 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à:
- 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).
- Oplog ghi diff dạng
$v: 2— chỉ phần thay đổi. Lab: entry của một$pushchỉ 0,5 KB ở mọi kích thước document:{"$v":2,"diff":{"sreplies":{"a":true,"u50799":{…}}}}. - 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ằngcursor->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$pushdù document 9,5 MB. - 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 reply | N = 1 000 (0,19 MB) | N = 10 000 (1,91 MB) | N = 50 000 (9,49 MB) |
|---|---|---|---|
Mảng: $push | 6 ms · 2,45 MB | 9 ms · 19,38 MB | 32 ms · 89,50 MB |
Mảng: $push, w:1 | 3 ms · 4,35 MB | 7 ms · 18,88 MB | 24 ms · 89,88 MB |
| Bucket, filter như docs | 6 ms · 0,73 MB | 6 ms · 0,60 MB | 6 ms · 0,61 MB |
Bucket + sort {_id:-1} | 5 ms · 0,87 MB | 6 ms · 0,70 MB | 6 ms · 0,69 MB |
| 1 document/reply | 5 ms · 0,54 MB | 5 ms · 0,47 MB | 6 ms · 0,48 MB |
1 document/reply, w:1 | 2 ms · 0,61 MB | 2 ms · 0,54 MB | 2 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
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-commerce | Mặc định | Đổi khi |
|---|---|---|---|
| 1–1 | khách – hồ sơ giao hàng | Embed (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 – item | Embed mảng | Bà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êng | Không cần "mới nhất" → chỉ collection riêng |
| 1–vô hạn ("squillions") | khoá học – log học tập | Con giữ khoá cha, index {parentId, time}; hoặc time series | — |
| N–N | học viên – khoá học | Collection nối (enrollments) + extended reference vài field ít đổi | Hai 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ọc | Lệnh/khoá | Lượt 1 (ms) | Lượt 2 | Lượt 3 |
|---|---|---|---|---|
Embed, Eloquent embedsMany | 1 | 3,42 | 3,83 | 3,76 |
Reference, Eloquent with("lessons") | 2 | 6,33 | 5,77 | 5,54 |
Reference, $lookup | 1 | 2,26 | 2,11 | 2,60 |
| Embed, driver gốc | 1 | 0,86 | 1,14 | 1,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):
- Thêm phần tử gửi
$addToSetnguyên subdocument (kèm_idObjectId tự sinh, dù bài học cũ có_idsố):u: {$addToSet: {lessons: {title: "Bài 51", position: 51, _id: ObjectId("6a…")}}}. paginate()cắt mảng trong PHP sau khi đã tải cả document:paginate(10): trang 1/6, 0 lệnh DB cho phân trang (cắt mảng trong PHP sau khi đã tải đủ 51 bài).- Hydrate mọi phần tử thành model:
Khoá phình 5 000 bài: hydrate tốn 22 lần thời gian so với lấy 10 bài cuối bằng-- 1 khoá phình 5 000 bài: chi phí hydrate model EmCourse::find(9999)->lessons (hydrate 5 000 model) 152.9 ms → 5000 bài driver gốc + projection $slice: 10 bài cuối 7.0 ms → 10 bài bộ nhớ PHP giữ 1 khoá 5 000 bài đã hydrate: 9.6 MB (document BSON 0.67 MB)$slice, và RAM PHP gấp 14 lần kích thước BSON — với mảng lớn, đọc bằng projection$slicehoặc tách collection.
3.3 Danh mục pattern: giải gì, trả bằng gì, hỏng khi nào
| Pattern | Giải bài toán gì | Đổi lại | Hỏng khi nào |
|---|---|---|---|
| Attribute | Sả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 index | Truy vấn phải dùng $elemMatch (bài 1); v lẫn kiểu → type bracketing | Quên $elemMatch → khớp chéo; index multikey to (bài 3) |
| Bucket | Hàng triệu phần tử nhỏ (reply, số đo) → gom N phần tử/document | Logic ghi phức tạp hơn; phân trang theo bucket | Filter tìm bucket mở quét hết bucket (mục 3.4) |
| Subset | Cha cần "10 đánh giá mới nhất" → nhúng 10 cái, phần còn lại ở collection riêng | Ghi 2 chỗ; bản nhúng có thể lệch | Khô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ệnh | Fan-out khi field gốc đổi | Ché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ật | Không có | Ai đó viết job "đồng bộ tên sản phẩm vào đơn cũ" → sửa lịch sử |
| Computed | Lượt ghi danh, điểm trung bình tính sẵn khi ghi thay vì đếm mỗi lần đọc | Mỗi lần ghi thêm 1 update; có thể lệch nếu ghi lỗi giữa chừng | Counter nóng → WriteConflict (bài 1, mục 5.8); cần job đối soát |
| Approximation | Lượt xem chỉ cần đúng xấp xỉ → cộng 100 mỗi 1 % request | Số không chính xác | Dùng cho số liệu tiền/tồn kho |
| Outlier | 99,9 % khoá học < 1 000 học viên, vài khoá 300 000 → cờ hasOverflow + collection tràn | App xử lý 2 nhánh | Truy vấn ad-hoc quên nhánh tràn → số sai |
| Polymorphic / single collection | Nhiều loại (video, quiz, bài đọc) chung 1 collection + field type | Index phải phủ nhiều dạng; validator phức tạp | Không lọc type → trộn dữ liệu |
| Tree | Cây danh mục: parent ref / mảng ancestors / materialized path | Ghi 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_version | Code đọc 2 dạng; index cho cả 2 trong lúc chuyển | 2 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.
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 ref | Mảng ancestors | Materialized path |
|---|---|---|---|
| Mọi con cháu node tầng 1 (296 node) | BFS 4 query 17 ms · $graphLookup 4 ms | 1 query 4 ms | 1 query 4 ms |
| Mọi con cháu của gốc (2 376 node) | BFS 5 query 28 ms · $graphLookup 13 ms | 1 query 10 ms | 1 query 11 ms |
| Breadcrumb node lá | 5 query 12 ms · $graphLookup 3 ms | 2 query 12 ms | 2 query 3 ms |
| Chặn vòng lặp khi chuyển nhánh | đi ngược lên gốc: 3 query | 1 findOne | so tiền tố path |
| Chuyển nhánh 296 node sang gốc khác | 1 document · 1 entry oplog · 6 ms | 297 document · 297 entry · 30 ms | 297 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
- Đọc: ancestors và materialized path trả "mọi con cháu" bằng 1 truy vấn có index; parent ref cần BFS mỗi tầng 1 round-trip hoặc
$graphLookup(1 lệnh, đệ quy trên server, mỗi bước tra indexparent). Regex path phải neo^: không neo thì quét cả index (keysExamined 23 770 thay vì 297). - Ghi: chuyển một nhánh 296 node: parent ref sửa 1 document; ancestors và path sửa lại cả nhánh (297 document, 297 entry oplog), không nguyên tử nếu không có transaction.
- Vòng lặp: chuyển node vào dưới chính con cháu của nó tạo chu trình — BFS/
$graphLookupchạy mãi (lab đầu tiên của tôi treo vì đúng lỗi này). Ancestors kiểm bằng 1 truy vấnfindOne({_id: đích, ancestors: nguồn}); parent ref phải đi ngược lên gốc. Chọn: cây đọc nhiều, ít chuyển nhánh (danh mục sản phẩm, cây khoá học) → ancestors (thêmnamevào phần tử ancestors để breadcrumb chỉ 1 lệnh — extended reference) hoặc path; cây ghi nhiều, sâu không giới hạn (bình luận lồng) → parent ref.
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):
- BucketCatalog trong RAM giữ tối đa 1 bucket đang mở cho mỗi giá trị metaField. Bucket đóng khi đủ
timeseriesBucketMaxCount= 1 000 measurement hoặctimeseriesBucketMaxSize= 125 KB, hoặc khi measurement mới vượt khoảngbucketMaxSpanSeconds(granularityseconds= 1 giờ,minutes= 1 ngày,hours= 30 ngày). - Trong bucket, mỗi field là một cột nén BSONColumn (BinData kiểu 7);
control.min/maxcho phép bỏ qua cả bucket khi lọc thời gian; ghi thêm vào bucket sinh oplog dạng diff nhị phân (offset + byte mới). Truy vấn được viết lại qua stage$_internalUnpackBucket; index{meta, time}tạo sẵn từ 6.3. - 9.0 đổi cách lưu: 1 namespace chứa bucket nén; 8.0 là view
ts_course+ collection thậtsystem.buckets.ts_course(xem catalog dưới).
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ản | Cách lưu 1 triệu event | Chèn (lô 10 000) | storageSize + index | Bucket | Event/bucket |
|---|---|---|---|---|---|
| 9.0.2 | time series, meta = courseId (200) | 20,2 s (49 461/s) | 10,4 + 1,0 MB | 1 594 | 627,4 |
| 9.0.2 | collection thường + index {courseId, ts} | 17,9 s (55 788/s) | 32,8 + 56,3 MB | — | — |
| 9.0.2 | time series, meta = userId (50 000) | 196,1 s (5 099/s) | 37,8 + 92,5 MB | 276 751 | 3,6 |
| 9.0.2 | như trên, ngưỡng BucketCatalog 50 MB (300 000 event đầu) | 96,2 s (3 117/s) | 14,6 + 30,6 MB | 91 667 | 3,3 |
| 8.0.32 | time series, meta = courseId (200) | 29,6 s (33 769/s) | 11,9 + 1,2 MB | 1 594 | 627,4 |
| 8.0.32 | collection thường + index {courseId, ts} | 25,6 s (38 986/s) | 31,9 + 56,5 MB | — | — |
| 8.0.32 | time series, meta = userId (50 000) | 410,5 s (2 436/s) | 38,4 + 89,9 MB | 276 751 | 3,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ũ:
| validationLevel | Insert | Update document đang hợp lệ | Update document đang SAI | Document sai cũ |
|---|---|---|---|---|
off | không kiểm | không kiểm | không kiểm | giữ nguyên |
strict (mặc định) | kiểm | kiểm | bị chặn — kể cả $set field không liên quan | giữ nguyên, "nổ" khi bị chạm |
moderate | kiểm | kiểm | cho qua | giữ nguyên, sửa tiếp được |
constraint (9.0) | kiểm | kiểm | không tồn tại — lên level này phải quét sạch | khô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ạn | Hệ 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ước | 16 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 KB | Mảng phải có trần; reply 204 byte → chạm 16 MB ở ~80 000 phần tử |
| Oplog mỗi update | Diff $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 |
| Validator | strict/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 reference | Nhất quán cuối cùng (eventual) — có cửa sổ lệch | Chỉ chép field ít đổi hoặc snapshot |
| Số collection | Mỗ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:
- 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).
- 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
$pushrồi 1 checkpoint:
Đặ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à== 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 MBstorageSize12,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). - Đọc 20 reply mới nhất kéo cả document vào cache:
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).== Đọ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 - 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:
- Document sai cũ "nổ" khi bị chạm, không phải khi thêm validator.
collModtrảok; vài tuần sau một tính năng không liên quan$setfieldlevellên document cũ thì lỗi:
Trước khi bật, đếm bằng✗ 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"}db.c.countDocuments({$nor: [schema]})và sửa; trong lúc sửa dùngmoderate(update document sai cũ được cho qua, document đúng vẫn bị kiểm). - 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 theocode === 121. - 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:
Migration phải dừng ở lỗi đầu tiên và kiểm lại✗ 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.getCollectionInfos()sau khi chạy. warnghi 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.constraintchặn mọi đường lách — đúng ý, nhưng phải đi đủ 3 bước:
Muốn đổi luật: hạ về✗ 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'strict→ đổi validator → prepare → lên lạiconstraint(quét lại toàn bộ). Lưu ýbypassDocumentValidationbị chặn cả lúc prepare — job nhập liệu cũ dựa vào bypass sẽ lỗi.- 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 ghilong, Node ghidouble— bài 1). Lab: 4 000/400 000 document cóprogresskiểu double →strictkhông báo gì (7 ms, không quét),constraintdừng ở_id: 100. Dùng aliasbsonType: "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
}
}
}
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)
- 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$orhai dạng và có index cho cảinstructorlẫninstructors.name. - Query builder,
update()hàng loạt, aggregate, job ngoài model không chạymigrateSchema. 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ưngjson_encodecủa PHP in ra[{"email":…}]trông như mảng. Lần sau model đọc document,migrateSchemaghi đèinstructorsvà email biến mất. - Deploy cuốn chiếu: pod cũ ghi vào document đã là v2 → document mang cả
instructor: "GV Mới"lẫninstructorscũ,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. _idsố 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. Đặtprotected $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ết | Lệnh / metric | Cả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 ẩn | Document > 1 MB bị update thường xuyên |
| History store | serverStatus().wiredTiger.cache["history store table on-disk size"] | Tăng đều trong khi dữ liệu không tăng |
| Oplog theo collection | local.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 schema | countDocuments({$nor: [validator]}); $group: {_id: {$type: "$f"}}; log id 20294 / 7488700 | > 0 trước khi lên constraint |
| Collection / handle / fd | wiredTiger["data-handle"]["connection data handles currently active"]; /proc/<pid>/limits | nofile < 64000; handle tăng theo tenant |
| Bucket time series | 9.0: count với rawData: true; 8.0: system.buckets.<c>; serverStatus().bucketCatalog | Và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ục | MongoDB embed | MongoDB reference | PostgreSQL chuẩn hoá | PostgreSQL + JSONB |
|---|---|---|---|---|
| Đọc trang chi tiết | 1 lệnh, nhanh nhất | 2 lệnh / $lookup | JOIN, planner tốt | 1 hàng nếu nhúng JSONB |
| Cập nhật 1 sự thật dùng chung | Fan-out N document, không nguyên tử | 1 document | 1 hàng, nguyên tử | Như embed nếu chép vào JSONB |
| Phần tử con tăng mãi | Anti-pattern (16 MB, HS, đĩa) | Tốt | Tố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.0 | như embed; không có FK | Kiểu cột, FK, CHECK | CHECK + jsonb_typeof, tự viết |
| Đổi schema | Không ALTER: versioning + backfill | như embed | ALTER (nhiều cái nhanh từ PG 11), backfill khi đổi kiểu | Như Mongo trong cột JSONB |
| Truy vấn ad-hoc / báo cáo | Yếu nếu dữ liệu bị chép lệch | $lookup đắt | Mạnh nhất | Mạnh, trộn SQL + JSON |
| Dữ liệu theo thời gian | Time 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
- Schema vẫn là hợp đồng, chỉ là không ai ép giùm. Lưu
$jsonSchematrong repo như migration, review cùng PR đổi model; bật từ ngày đầu (moderatekhi còn dữ liệu cũ →strict→constrainttrên 9.0). - ADR cho mỗi quyết định embed/reference: access pattern (trang/API, QPS đọc/ghi, cardinality, có trần không) → quyết định (field nào được chép) → hệ quả (fan-out bao nhiêu document, document lớn nhất, ai chạy job đồng bộ) → cách đổi sau này (schema_version, backfill) → exit.
- Deploy expand → migrate → contract (như migration PostgreSQL zero-downtime): bản N+1 đọc được 2 dạng và không làm hỏng bản N; hết pod bản N mới ghi dạng mới; backfill về 0 mới xoá code cũ. Mất cập nhật ở mục 5.6 là do bỏ bước này.
- Multi-tenant: 1 collection +
tenantIdđứng đầu mọi index; tenant khổng lồ → outlier pattern hoặc cluster riêng; không 1 collection hay 1 database mỗi tenant (mục 5.4). - Tốc độ team: schema mềm nhanh 6 tháng đầu rồi tính lãi (field 3 kiểu, query phải
$or2 dạng). Reviewer hỏi 4 câu: mảng này có trần không, field chép này ai cập nhật, update này có ghi lại cả mảng không, validator đã sửa chưa. - Lock-in / exit: embed chuyển sang PostgreSQL JSONB khá thẳng (nhưng xem lab mục 7 — JSONB không chịu nổi mảng phình); extended reference và bucket phải chuẩn hoá lại; time series,
constraint,HasSchemaVersionlà riêng của MongoDB/laravel-mongodb. Luôn giữ một nguồn gốc cho dữ liệu bị chép để dựng lại được.
9. Anti-patterns và bài học thực tế
- 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. - ORM ghi lại cả mảng: đọc về,
pushtrong PHP/JS,$setcả mảng → oplog cỡ cả mảng mỗi lần (mục 5.2). - 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.
- 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).
- 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. - 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.
- Validator để
warnmã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". - 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_versionnên không biết document nào đã nâng cấp. - Time series với metaField cardinality cao (userId) → bucket gần rỗng, chèn chậm, index to (mục 3.7).
- 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 < Nkhông sort thì quét mọi bucket (lab 257 bucket/lần ghi); bucket đầy → quét hết rồi upsert;_idtheo 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_versiontrong 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ấmbypassDocumentValidationvà 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
nofilemặc định 1024 thì treo ở ~300 tenant. DùngtenantIdđứ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).