MongoDB là gì thật sự — document model, BSON, CRUD và những truy vấn chạy ngược trực giác

Câu "MongoDB là NoSQL lưu JSON, không cần schema" là câu trả lời trượt phỏng vấn senior. Nó không giải thích được vì sao hai document giống hệt về giá trị lại tốn RAM khác nhau, vì sao một truy vấn trả về đơn hàng mà lẽ ra không khớp, vì sao hai request đăng ký cùng email tạo ra hai tài khoản, hay vì sao một payload JSON {"$ne": null} đăng nhập được vào tài khoản admin. Bài này là nền cho cả series: nó chốt một ý trung tâm — "document là đơn vị" — rồi suy ra mọi thứ còn lại. Mọi con số trong bài là output thật đo trên MongoDB 9.0.2, so với 8.0.32; code ứng dụng chạy trên Laravel 13 + mongodb/laravel-mongodb 5.11 và Node.js driver 7.7 + Mongoose 9.10; so sánh với PostgreSQL 18.4.

Thuật ngữ dùng trong bài
document (một bản ghi dạng cây field–value, đơn vị cơ bản của MongoDB) · collection (nhóm document, tương đương "bảng") · BSON (Binary JSON — định dạng nhị phân MongoDB dùng để lưu và truyền document; có kiểu, có độ dài) · impedance mismatch (lệch mô hình: object trong code là cây lồng nhau, bảng SQL là phẳng → phải tách/ghép khi đọc/ghi) · ObjectId (khoá _id 12 byte mặc định) · type bracketing (toán tử so sánh chỉ so trong cùng một nhóm kiểu BSON, không so xuyên kiểu) · upsert (update nếu có, insert nếu chưa) · $elemMatch (buộc một phần tử mảng thoả tất cả điều kiện, thay vì mỗi điều kiện thoả bởi một phần tử khác nhau) · Decimal128 (kiểu số thập phân 128-bit cho tiền) · operator injection (chèn toán tử $ne, $gt, $regex… qua input để đổi ý nghĩa truy vấn) · WiredTiger (storage engine mặc định — bài 6) · shard (một mảnh của cluster giữ một phần dữ liệu — bài 9) · write concern (mức xác nhận ghi: w:1 một node, w:majority đa số — bài 7).
Kiểm chứng: tài liệu MongoDB qua Context7 ngày 05/10/2026 (BSON types, comparison/sort order, query arrays, index-unique, transactions) và release notes 9.0 / 9.0-compatibility. Đọc source server (src/mongo/bson/bson_depth.h, src/mongo/db/query/query_execution_knobs.idl, src/mongo/db/matcher/path.h) và source laravel-mongodb 5.11.0 (src/Query/Builder.php). Lab chạy thật: MongoDB 9.0.2 và 8.0.32 (container tmp-mdb-*, WiredTiger cache 0,25 GB, RAM 1 GB mỗi node), PHP 8.3.33 + ext-mongodb 2.5.3, Node 22, PostgreSQL 18.4. Output: labs/mongodb/out/01-*.txt. Máy 4 vCPU dùng chung — số thời gian chỉ xem bậc độ lớn, so tỉ lệ.

Trả lời 30 giây

MongoDB không phải "chỗ cất JSON không schema". Ý trung tâm: document là đơn vị của mọi thứ — đơn vị đọc, đơn vị ghi nguyên tử, đơn vị khoá/tranh chấp, và đơn vị phân tán. Từ đó suy ra tất cả: nhúng dữ liệu liên quan để đọc một phát (thay cho join); một thao tác trên một document luôn nguyên tử còn nhiều document thì cần transaction (bài 8); sharding chia theo document nên một document không bao giờ nằm trên hai shard; schema "mềm" vì mỗi document tự mang tên field của nó — nên BSON lặp lại tên field trong mọi document (lab: cùng giá trị, tên dài tốn 50,2 MB vs tên ngắn 23,9 MB cho 200k document). Những bẫy "ngược trực giác" đều từ chi tiết BSON/truy vấn: điều kiện mảng khớp chéo phần tử (cần $elemMatch); {x: null} khớp cả document thiếu field; so sánh bị type bracketing ({age: {$gt: 18}} không khớp chuỗi "20"); số mặc định là double nên tiền lệch; hai upsert song song không có unique index tạo bản trùng; và {"$ne": null} từ request đăng nhập được nếu truy vấn nhận thẳng mảng từ input.

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

Năm 2007, ba người từ DoubleClick — Dwight Merriman, Eliot Horowitz, Kevin Ryan — lập công ty 10gen ở New York. Ở DoubleClick họ phục vụ "hơn 400 000 quảng cáo mỗi giây" và thấy database quan hệ vừa đắt vừa cứng khi scale ngang (nguồn: mongodb.com/company; Wikipedia — MongoDB Inc.). Dự định ban đầu của 10gen là một nền tảng PaaS; MongoDB là lớp database của nó, sau đó tách ra làm sản phẩm chính và mở nguồn ngày 11/02/2009. Tên "Mongo" là viết tắt của humongous (khổng lồ). 10gen đổi tên thành MongoDB Inc. năm 2013, IPO trên NASDAQ (MDB) ngày 20/10/2017 (nguồn: Wikipedia).

Ba giới hạn của giải pháp trước mà MongoDB nhắm vào:

Tối ưu cho: dữ liệu được đọc/ghi theo từng document trọn vẹn, có thể nhúng phần liên quan vào cùng một chỗ; scale ngang theo document; schema đổi nhanh. Không tối ưu cho: truy vấn kết nối nhiều thực thể bất kỳ (join là $lookup đắt — bài 5); ràng buộc quan hệ chặt (foreign key, check constraint xuyên bảng); transaction đụng nhiều document rải rác là chuyện ngoại lệ chứ không phải mặc định (bài 8).

License — ảnh hưởng tới exit strategy. MongoDB Community từng dùng AGPLv3. Ngày 16/10/2018 đổi sang SSPL (Server Side Public License): mọi bản phát hành từ ngày đó, kể cả patch cho bản cũ, đều là SSPL; bản trước đó vẫn AGPL (nguồn: MongoDB SSPL FAQ). SSPL buộc bên cung cấp MongoDB như một dịch vụ phải mở toàn bộ mã nguồn stack dịch vụ; dùng MongoDB làm backend cho app của mình thì không bị ràng buộc này. OSI từ chối công nhận SSPL là "open source" (01/2021); Debian, Fedora, RHEL gỡ MongoDB khỏi kho. Hệ quả cho tech lead (fork Valkey-kiểu của MongoDB, DocumentDB/Cosmos, FerretDB) bàn ở bài 11.

MốcThời điểmViệc quan trọng (liên quan bài này)
3.02015Pluggable storage engine; WiredTiger xuất hiện (bài 6)
3.22015WiredTiger thành mặc định
4.02018Transaction nhiều document trên replica set; đổi sang SSPL
4.22019Transaction xuyên shard; server tự retry upsert trùng key (SERVER-37124 — mục 5)
5.02021Write concern mặc định thành majority; oplog dạng diff
6.0 / 7.0 / 8.02022–2024Nhiều cải tiến query engine, sharding, concurrency
8.32025Cost-Based Ranker thành mặc định cho query planner (bài 4)
9.028/09/2026Giới hạn bộ nhớ mỗi operation; JS phía server chạy WebAssembly; đổi ngữ nghĩa null trên dotted path (tắt mặc định — mục 5.6)

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

Một ý. Mọi tính năng khác là hệ quả, không phải danh sách học thuộc.

Document là đơn vị đọc, đơn vị ghi nguyên tử, đơn vị khoá/tranh chấp, và đơn vị phân tán.
Tính năng hay ngheThật ra là hệ quả của "document là đơn vị"
"Không cần join"Vì đơn vị đọc là document, ta nhúng dữ liệu luôn-đọc-cùng-nhau vào một document → một lần đọc. Cái giá: dữ liệu lặp, cập nhật lan toả (bài 2).
"Nguyên tử trên 1 document"Vì đơn vị ghi là document: một update trên một document luôn trọn vẹn, kể cả khi sửa nhiều field và mảng cùng lúc. Nhiều document thì phải transaction (bài 8).
"Sharding co giãn"Vì đơn vị phân tán là document: mỗi document nằm trọn trên một shard theo shard key; không bao giờ nửa document bên này nửa bên kia (bài 9).
"Schema linh hoạt"Vì mỗi document tự mang cấu trúc của nó → BSON phải ghi tên field vào mọi document (mục 3.1). Giá của tự do: tốn chỗ cho tên field, và không có ai ép kiểu giúp (mục 5).
"Ghi đồng thời nhanh"Vì đơn vị khoá là document: hai ghi vào hai document khác nhau không đụng nhau; hai ghi vào cùng một document thì tranh chấp (WriteConflict, mục 5.8).

Giữ đúng một ý này thì trả lời được gần hết câu hỏi thiết kế: "nên nhúng hay tách?" (bài 2) là "những gì luôn đọc cùng nhau và vừa trong giới hạn document thì nhúng"; "làm sao đảm bảo nhất quán?" là "gói vào một document nếu được, không thì transaction"; "vì sao query này chậm trên cluster?" là "nó phải chạm nhiều shard vì không lọc theo shard key".

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

3.1 BSON: tên field là byte thật, lặp trong mọi document

MongoDB không lưu JSON văn bản. Nó lưu BSON: một document bắt đầu bằng int32 độ dài cả document (length-prefix), rồi lần lượt mỗi element là 1 byte kiểu + tên field dạng cstring kết thúc bằng \0 + giá trị, và kết thúc bằng một byte 0x00 (nguồn: bsonspec.org). Scalar ghi little-endian.

Document {_id: 7, sku: "A1", qty: 2} được mã hoá thành 35 byte BSON Dải byte hiện dần từ trái sang phải: int32 độ dài, rồi với mỗi field một byte kiểu + tên field kết thúc \0 + giá trị, cuối cùng byte 0x00. Tên field "_id", "sku", "qty" là byte thật nằm trong document. { _id: 7, sku: "A1", qty: 2 } → 35 byte BSON 23 00 00 00len=35 10int32 _id\00tên 07 00 00 007 02string sku\00tên 03 00 00 00dài 3 41 31 00"A1"\0 10int32 qty\00tên 02 00 00 002 00hết "_id", "sku", "qty" là byte thật — ghi lại y nguyên trong TỪNG document của collection 200 000 document "sự kiện học tập": tên field dài → 50,2 MB; tên ngắn → 23,9 MB (dataSize). Chênh 26 MB chỉ vì tên field. type byte (vàng) · tên field cstring kết thúc \0 (xanh lá) · giá trị little-endian (xám) · độ dài (xanh dương) · byte kết thúc 0x00 (đỏ) Mảng cũng là document với key "0","1","2"… → mảng 1000 số int32 tốn 8903 byte cho 4000 byte dữ liệu (2,23×).
Hình 1 — Một document 3 field thành 35 byte. Phần "nhãn" (type byte + tên field + \0) chiếm 41,6% của document đơn hàng mẫu trong lab (01-bson.txt). Vì tên field nằm trong mọi document, đặt tên dài (learnerIdentifier) hay ngắn (u) đổi hẳn kích thước dữ liệu: 200k document giống nhau về giá trị tốn 50,2 MB vs 23,9 MB.

Thứ tự field là một phần của dữ liệu. BSON giữ nguyên thứ tự field ghi vào. So khớp nguyên một document nhúng thì thứ tự có nghĩa:

db.bson_order.find({ dim: { w: 10, h: 20 } })   → [1]        # khớp đúng cây {w,h} theo thứ tự
db.bson_order.find({ dim: { h: 20, w: 10 } })   → []         # cùng giá trị, đảo thứ tự → KHÔNG khớp
db.bson_order.find({ "dim.w": 10, "dim.h": 20 }) → [1, 2]    # dùng dotted path thì không phụ thuộc thứ tự
db.bson_order.distinct("dim").length             → 2          # {w,h} và {h,w} là 2 giá trị khác nhau

Đây là lý do thực chiến để luôn truy vấn bằng dotted path ("dim.w"), đừng so khớp cả object nhúng — client ở ngôn ngữ khác (JS object không giữ thứ tự) rất dễ gửi sai thứ tự và nhận về rỗng mà không có lỗi.

3.2 ObjectId: có thứ tự nhưng chỉ gần đúng theo thời gian

_id mặc định là ObjectId 12 byte, do driver sinh ở phía client trước khi gửi, gồm (nguồn: BSON types):

6ac31473  af079cbd1c  6a5e13
└4 byte┘  └─5 byte──┘  └3 byte┘
thời gian  ngẫu nhiên   bộ đếm
(giây,     theo process tăng dần,
 big-endian) (máy+pid)   khởi tạo ngẫu nhiên

ObjectId("6ac31473...").getTimestamp() → 2026-10-05T03:07:31.000Z

Tài liệu nói thẳng: "ObjectId nên tăng theo thời gian nhưng không nhất thiết đơn điệu (monotonic)", vì (1) chỉ có độ phân giải 1 giây — các ObjectId tạo trong cùng một giây không có thứ tự đảm bảo giữa các client, và (2) được client sinh ra, mà đồng hồ các client có thể lệch nhau. Phần 4 byte thời gian là 32-bit không dấu → tràn năm 2106.

Lab (01-oid.txt): 3 process chèn song song, mỗi process 400 insertOne, bắt đầu cùng một mốc:

12 document ghi đầu tiên (theo oplog, tức thứ tự GHI thật): B0 C0 A0 B1 A1 C1 B2 A2 C2 A3 B3 C3
12 document đầu theo sort({_id: 1})                       : A0 A1 A2 A3 A4 A5 A6 A7 A8 A9 A10 A11
trong giây đầu tiên, theo _id: A0…A33 (34) → C0…C32 (33) → B0…B37 (38)   # gom theo process, không xen kẽ
1196/1200 document nằm SAI vị trí so với thứ tự ghi thật; lệch xa nhất 414 vị trí

Trong cùng một giây, sort({_id: 1}) không ra thứ tự chèn mà gom theo 5-byte ngẫu nhiên của từng process rồi mới tới bộ đếm — nên các đơn của một process đứng liền một khối, xen kẽ giữa các process biến mất. Tệ hơn, một client có đồng hồ chậm 300 giây thì 5 đơn mới nhất của nó lại nằm ở đầu danh sách "cũ nhất":

5 document ghi SAU CÙNG (oplog)         : D0 D1 D2 D3 D4        # client đồng hồ chậm 300s
"5 đơn mới nhất" = sort({_id:-1}).limit(5): B399 B398 … B395     # D không lọt top, vì _id mang timestamp quá khứ
đơn đầu tiên của D đứng ở vị trí 0/1205 theo _id tăng dần  →  bị coi là "cũ nhất"
Bẫy (nổ trễ): dùng sort({_id: -1}) làm "sắp theo thời gian tạo" chạy đúng ở dev (một client, một đồng hồ). Lên production nhiều service/nhiều máy, thứ tự _id trong cùng giây là thứ tự ngẫu nhiên-theo-process, và một máy lệch giờ (NTP trục trặc) làm đơn mới thành "cũ nhất". Cần thứ tự thật thì thêm trường createdAt và index theo nó, đừng mượn _id.

3.3 dataSize, storageSize và cache: tên field dài tốn RAM dù đĩa nén được

WiredTiger nén dữ liệu trên đĩa (mặc định snappy), nhưng cache giữ trang ở dạng CHƯA nén (bài 6 đi sâu). Nên tên field dài tốn RAM thật, kể cả khi đĩa nén lại gần hết. Lab (01-bson.txt), 200k document giống nhau về giá trị, khác mỗi độ dài tên field, sau khi restart rồi quét hết mỗi collection:

collection | count  | avgObjSize | dataSize | storageSize | tỉ lệ nén | trong cache
ev_long    | 200000 |        263 |  50.2 MB |      9.4 MB | 5.33×     | 60.2 MB
ev_short   | 200000 |        125 |  23.9 MB |      7.7 MB | 3.11×     | 31.7 MB
Cân nhắc: tên field ngắn tiết kiệm RAM thật, nhưng đánh đổi độ dễ đọc. Thực tế đa số dự án giữ tên vừa phải và chấp nhận; chỉ tối ưu tên field khi collection có hàng chục–trăm triệu document và working set sát trần RAM. Đừng "tối ưu" u, c, t cho mọi collection — đó là tối ưu non (bài 2 bàn tiếp cùng pattern attribute/subset).

3.4 Giới hạn 16 MB và độ sâu lồng

Một document tối đa 16 MiB (16 777 216 byte). Đây là đơn vị đọc/ghi/truyền nên giới hạn này bảo vệ RAM và băng thông. Lab (01-limits.txt):

insert document đúng 16 777 216 byte → OK
insert document 16 777 217 byte      → MongoServerError 10334: object to insert too large. size in bytes: 16777217, max size: 16777216

$push 1 MiB mỗi lần vào mảng của 1 document, lần thứ 16:
  MongoServerError 10334: Resulting document after update is larger than 16777216
  document vẫn còn 15 phần tử, 15 728 801 byte  ←  update hỏng KHÔNG ghi gì (nguyên tử trên 1 document)

Chú ý hệ quả của "ghi nguyên tử trên 1 document": khi $push vượt 16 MB, cả update thất bại và document giữ nguyên trạng thái trước đó — không có chuyện ghi được một nửa. Mảng phình vô hạn là anti-pattern lớn, bài 2 đo chi tiết (update chậm dần, oplog phình).

Độ sâu lồng: tài liệu limits ghi "tối đa 100 cấp". Nhưng server 9.0.2 (và 8.0.32) thực tế nhận sâu hơn: lab chèn được 180 cấp, chỉ báo lỗi ở 181:

lồng 180 cấp → OK
lồng 181 cấp → MongoServerError 15: object exceeds 180 levels of nesting
getParameter maxBSONDepth → 200

Con số 180 khớp source: bson_depth.h đặt kDefaultMaxAllowableDepth = 200 và kExtraSystemDepthLevels = 20, nên giới hạn cho document của người dùng là 200 − 20 = 180 (20 cấp dư để server tự bọc thêm khi sinh oplog). Đây là ví dụ "tài liệu nói một đằng, server làm một nẻo" — nhớ số docs (100) để thiết kế cho an toàn, nhưng khi debug thì tin thông báo lỗi thật (180).

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

Khía cạnhĐảm bảoGiới hạn / điều kiện
Nguyên tửMọi update trên một document là trọn vẹn, kể cả sửa nhiều field + nhiều toán tử ($set, $inc, $push…) trong một lệnhNhiều document cần transaction (4.0 replica set, 4.2 sharded — bài 8). updateMany không isolated.
So sánhToán tử $gt/$lt/$gte/$lte chỉ so trong cùng nhóm kiểu (type bracketing){age: {$gt: 18}} không khớp chuỗi "20"; muốn xuyên kiểu phải $expr (mục 5.4)
Thứ tự kiểuSort trộn kiểu theo thứ tự cố định: MinKey < null < số < chuỗi < object < array < … < bool < dateCác kiểu số (int/long/double/decimal) coi là bằng nhau khi so; field thiếu xem như null khi sort
Kích thướcDocument ≤ 16 MiB; lồng ≤ 180 cấp (docs ghi 100)File lớn hơn phải dùng GridFS; mảng/object sâu quá là dấu hiệu mô hình sai
Phân tánMột document nằm trọn trên một shard theo shard keyUnique index trên sharded collection phải chứa shard key (bài 9)
Sốint32 / int64 / double / Decimal128 là các kiểu riêng; $inc tự nâng kiểu khi trànSố mặc định từ app thường là double → tiền phải Decimal128 (mục 5.4)

Đặt vào khung lý thuyết (PACELC): một replica set MongoDB với mặc định từ 5.0 (w:majority) nghiêng về PC/EC — khi mạng chia cắt thì ưu tiên nhất quán (ghi chỉ xác nhận khi đa số node nhận; bên thiểu số không nhận ghi), và cả lúc bình thường cũng ưu tiên nhất quán hơn độ trễ. Nhưng đây là tunable: hạ xuống w:1 + đọc từ secondary thì thành PA/EL (nhanh hơn, chấp nhận mất write khi failover và đọc dữ liệu cũ). Chi tiết write concern, read concern, rollback ở bài 7; so với Redis (PA/EL, bài Redis nền tảng).

5. Failure modes

#Tình huốngHậu quả (đo)Phát hiệnPhòng / chữa
1Nhiều request upsert cùng filter, không unique indexBản trùng im lặng: 15/10 (updateOne), 42/10 (firstOrCreate)Đếm được nhiều document cùng khoá; không có lỗi nàoUnique index + xử lý E11000; findOneAndUpdate
2Dùng _id/ObjectId làm thứ tự thời gianSai thứ tự trong cùng giây; đồng hồ lệch → đơn mới thành "cũ nhất"Bug thứ tự chỉ lộ khi nhiều client / NTP lỗiTrường createdAt + index
3Operator injection qua request JSON/query stringBỏ qua mật khẩu, lọc token (CVE-2026-88022)Khó thấy trong log; filter trông "hợp lệ"Validate kiểu string ở biên; $eq; sanitizeFilter
4Số mặc định double; số từ request là chuỗiTiền lệch (0.30000000000000004); $inc lỗi; where('qty','>',1) trượt$type trả về double/string; tổng tiền sai số lẻDecimal128 cho tiền; cast/ép kiểu ở biên
5FK lưu ObjectId, query bằng string (hoặc ngược lại)0 kết quả; quan hệ hasMany gãy sau migration nửa vờiQuery đúng cú pháp nhưng trả rỗngThống nhất 1 kiểu; cast ObjectId/string
6null vs missing; null trên dotted path{x:null} khớp cả document thiếu field; 9.0 đổi ngữ nghĩa (tắt mặc định)Query lọc "chưa có X" trả nhầm; partial index lệch kết quả$exists/$type; hiểu tham số 9.0 trước khi bật
7Mảng phình tới 16 MB ($push không giới hạn)Update hỏng hẳn ở ngưỡng; document đóng băngLỗi 10334 "larger than 16777216"Đặt trần mảng; bucket pattern (bài 2)
8Nhiều ghi vào cùng một document nóngWriteConflict + retry; throughput giảm (đo dưới)serverStatus.metrics.operation.writeConflicts tăngTách document; gộp bằng pre-aggregate

5.1 Upsert song song tạo bản trùng, và khi nào server tự cứu

Upsert là mẫu kinh điển: "tìm theo email, có thì update, chưa có thì insert". Hai request chạy song song đều qua pha tìm thấy "chưa có" trước khi ai kịp insert → cả hai cùng insert.

Hai upsert song song cùng thấy "chưa có" rồi cùng insert Bên trái không có unique index: process A và B cùng tìm email, cùng không thấy, cùng insert, kết quả 2 document trùng. Bên phải có unique index: A insert được, B bị lỗi E11000 và server tự chuyển thành update, còn 1 document. KHÔNG unique index → 2 bản trùng Có unique {email: 1} → server tự retry ABAB find{email} → chưa có find{email} → chưa có insert insert collection {email, _id:1} {email, _id:2} 2 tài khoản 1 email find{email} → chưa có find{email} → chưa có insert OK insert → E11000 → retry thành update collection {email, _id:1} 1 tài khoản
Hình 2 — Lab 01-upsert.txt (20 process × 10 vòng). Không unique index: updateOne(upsert) ra 15 document cho 10 email, firstOrCreate của Laravel ra 42/10 (vì nó là find-rồi-create ở tầng app, cửa sổ race rộng hơn). Có unique index: luôn còn đúng 10.
### updateOne upsert, KHÔNG unique index:   số document mỗi email: [1 1 1 2 1 2 2 3 1 1] → tổng 15 (đúng ra 10)
### firstOrCreate,     KHÔNG unique index:   [16 3 3 1 3 1 10 1 2 2] → tổng 42 (đúng ra 10)
### updateOne upsert,  unique {email: 1}:    [1 1 1 1 1 1 1 1 1 1] → 10;  insertFailedDueToDuplicateKeyError +5  (server tự nuốt, không ném ra)
### upsert filter {email, status:"active"}, unique {email:1}: 10 đúng, nhưng 6 process NHẬN lỗi E11000
### firstOrCreate, unique {email: 1}:        10 đúng;  insertFailedDueToDuplicateKeyError +19

Chi tiết đáng nhớ: từ MongoDB 4.2 (SERVER-37124, gộp từ SERVER-14322) server tự retry upsert khi gặp duplicate key — nhưng chỉ khi filter là một đẳng thức đơn hoặc AND các đẳng thức, và các field đó đúng bằng field của unique index (nguồn: SERVER-14322, db.collection.update — Upsert with Duplicate Values). Lab chứng minh ranh giới đó: filter {email} trùng field unique → server nuốt lỗi, app không thấy (+5 lần retry nội bộ); nhưng filter {email, status: "active"} có thêm field status không nằm trong index → server không retry, 6 process nhận E11000 ngửa mặt:

BulkWriteException: E11000 duplicate key error collection: lab.race_customers index: email_1 dup key: { email: "u4@lms.vn" }
Bẫy: tin rằng "có unique index là upsert an toàn tuyệt đối". Đúng cho filter đơn khớp đúng index. Sai ngay khi filter thêm một điều kiện (vd status, deletedAt: null) — bạn vẫn phải bắt E11000 và retry ở app. Và từ MongoDB 8.1, upsert trong transaction không tự retry (docs).

5.2 Mảng: khớp "chéo" phần tử, cần $elemMatch

Điều kiện trên một trường mảng khớp nếu bất kỳ phần tử thoả — và khi có nhiều điều kiện, mỗi điều kiện có thể do một phần tử khác nhau thoả. Đây là nguồn bug âm thầm nhất của MongoDB.

Dotted path khớp chéo 2 phần tử, $elemMatch buộc 1 phần tử thoả cả hai Đơn hàng có items gồm phần tử 0 sku A qty 1 và phần tử 1 sku B qty 5. Điều kiện items.sku bằng A thoả bởi phần tử 0, items.qty lớn hơn 3 thoả bởi phần tử 1, nên dotted path báo khớp dù không phần tử nào vừa là A vừa có qty lớn hơn 3. $elemMatch quét từng phần tử đòi cả hai điều kiện và không thấy, nên không khớp. order = { items: [ {sku:"A", qty:1}, {sku:"B", qty:5} ] } [0] sku:"A" qty:1 [1] sku:"B" qty:5 { "items.sku":"A", "items.qty": {$gt: 3} } sku=A ✓ (phần tử 0) qty>3 ✓ (phần tử 1) → KHỚP (nhưng sai ý định!) — hai điều kiện do hai phần tử khác nhau thoả { items: {$elemMatch: {sku:"A", qty:{$gt:3}}} } phần tử 0: A ✓ nhưng qty1 ✗ phần tử 1: qty5 ✓ nhưng B ✗ → KHÔNG khớp — không phần tử nào thoả cả hai (đúng ý định)
Hình 3 — Lab 01-array.txt. Document _id:1 có một đơn mua A số lượng 1 và B số lượng 5. Câu "khách mua A với số lượng > 3" viết bằng dotted path lại khớp document này — vì "có phần tử A" và "có phần tử qty>3" đúng, dù không phần tử nào vừa A vừa >3. $elemMatch mới đúng.
db.orders1.find({ "items.sku": "A", "items.qty": {$gt: 3} })            → [1, 2]   # _id:1 khớp NHẦM
db.orders1.find({ items: {$elemMatch: {sku: "A", qty: {$gt: 3}}} })    → [2]      # đúng: chỉ _id:2 có 1 phần tử A qty 4

# cùng kiểu bẫy với mảng số:
db.arr.find({ scores: {$gt: 5, $lt: 3} })             → [1]   # [1,10]: 10>5 và 1<3, hai phần tử khác nhau → "khớp"
db.arr.find({ scores: {$elemMatch: {$gt: 5, $lt: 3}} }) → []    # không số nào vừa >5 vừa <3

Cập nhật theo vị trí cũng dính: toán tử $ trỏ vào phần tử khớp điều kiện cuối cùng trong filter, không phải điều kiện bạn nghĩ:

updateOne({ "items.sku":"B", "items.qty":{$gte:1} }, {$inc: {"items.$.qty": 100}})
  → [{sku:"A", qty:101}, {sku:"B", qty:5}]   # $ trỏ nhầm sang phần tử A! (vì "items.qty">=1 khớp phần tử A trước)
updateOne({ items: {$elemMatch: {sku:"B", qty:{$gte:1}}} }, {$inc: {"items.$.qty": 100}})
  → [{sku:"A", qty:1}, {sku:"B", qty:105}]    # $elemMatch → $ trỏ đúng B

5.3 null vs missing, type bracketing, so sánh số

{x: null} khớp cả document có x bằng null lẫn document không có field x. Muốn phân biệt phải dùng $exists hoặc $type (lab 01-null.txt):

{x: null}             → [1,2,4,5,6,…]   # _id1 có x:null; _id2 KHÔNG có field x → vẫn khớp
{x: {$type: "null"}}  → [1]             # chỉ document thật sự có x = null
{x: {$exists: false}} → [2,4,5,…]       # chỉ document thiếu field x
{x: {$ne: null}}      → [3]             # chỉ document có x khác null (x=5)

Type bracketing (lab 01-types.txt): toán tử so sánh chỉ so trong cùng nhóm kiểu. {age: {$gt: 18}} không bao giờ khớp chuỗi "20" — một bẫy chết người khi dữ liệu lẫn lộn kiểu:

{age: {$gt: 18}}   → [int20, long25, dbl30.5, dec19, arr[15,25]]   # chỉ các giá trị SỐ
{age: {$gt: "18"}} → [str20]                                        # chỉ các CHUỖI
{$expr: {$gt: ["$age", 18]}} → [int20, str20, long25, …, true, date, obj]  # $expr KHÔNG bracketing, so xuyên kiểu theo thứ tự BSON
sort({age: 1}): arr[]  missing  null  arr[15,25]  dec19  int20  long25  dbl30.5  str20  obj  true  date

Các kiểu số bằng nhau khi so: 20 = 20.0 = Long(20) = Decimal128("20") = Decimal128("20.00") đều khớp find({v: 20}) và va vào nhau khi tạo unique index. Nhưng $type của chúng khác nhau (int / long / decimal) — nên "bằng khi so sánh" không có nghĩa "cùng kiểu lưu trữ".

5.4 Số mặc định là double — tiền phải Decimal128

Driver ghi số theo kiểu ngôn ngữ, và kết quả khác nhau (lab 01-numbers.txt):

cùng {one:1, big:2^40, dec:0.1, i31:2^31, twenty:20.0}:
  PHP (int 64-bit):  one:int   big:long    i31:long    dec:double  twenty:double
  mongosh / Node:    one:int   big:double  i31:double  dec:double  twenty:int      # JS chỉ có "number" → ≤2^53 nguyên là int32/int, còn lại double

Hệ quả tiền bạc: cộng dồn bằng double lệch, bằng Decimal128 thì không.

$inc 0.1 ba lần:  dbl → 0.30000000000000004     dec → "0.3"
find({t: 0.3})            → 0 document          # double 0.3 không khớp tổng-double-lệch
find({t: Decimal128("0.3")}) → 1 document
Bẫy số lớn (nổ trễ): PHP ghi 2^53+1 = 9007199254740993 thành long đúng. Node đọc mặc định ra Long object; nhưng field long nhỏ (2^40) thì đọc ra number JS — php.big + 1 cộng đúng, còn php.int53p1 + 1 (Long) biến thành nối chuỗi "90071992547409931". Muốn đọc an toàn: findOne(…, {useBigInt64: true}) (ra bigint) hoặc {promoteLongs: false}. Nhầm chỗ này âm thầm làm hỏng ID/counter 64-bit.

5.5 ObjectId vs string: field thường không được tự đổi

laravel-mongodb tự đổi _id/id qua lại giữa string và ObjectId, nhưng field khoá ngoại thì không (lab 01-idstr.txt). Lưu customer_id lẫn lộn 2 kiểu:

Kiểu customer_id trong ids_orders: objectId: 2, string: 3     # 3 đơn tạo qua Eloquent (string) + 2 đơn import (ObjectId)

Customer::find($hex)                              → tìm thấy    # _id được convertKey() tự đổi 24-hex → ObjectId
Order::where('customer_id', $hex)->count()        → 3           # khớp 3 đơn lưu string
Order::where('customer_id', new ObjectId($hex))->count() → 2    # khớp 2 đơn lưu ObjectId
$cus->orders()->count()  (hasMany)                → 3           # quan hệ dùng khoá string của cha → chỉ thấy 3

Chuẩn hoá bằng update pipeline $toObjectId rồi quan hệ string lại gãy tiếp — minh hoạ migration nửa vời để lại dữ liệu hai kiểu:

updateMany($type:"string" → $toObjectId): modified 3
$cus->orders()->count()  (hasMany, khoá cha là string) → 0      # giờ mọi FK là ObjectId, khoá cha string không khớp cái nào

Chữa: chọn một kiểu cho FK. Dùng $casts của package để cố định: 'customer_id' => \MongoDB\Laravel\Eloquent\Casts\ObjectId::class (lưu ObjectId) hoặc 'customer_id' => 'string' (lưu string); đặt protected $keyType = 'string' ở model cha cho khớp quan hệ (nguồn: laravel-mongodb — eloquent-models). Lab: sau khi gắn cast ObjectId, cả query string lẫn ObjectId đều khớp vì package tự đổi khi ghi/đọc.

5.6 null trên dotted path: 9.0 "đổi" nhưng tắt mặc định

Release notes 9.0 ghi một thay đổi ngữ nghĩa: một dotted path không dẫn tới giá trị non-null thì xem như null, kể cả khi mắt xích giữa đường là mảng — sửa chỗ lệch cũ giữa {$ne: null} và $exists (nguồn: 9.0 Compatibility, SERVER-36681). Nhưng lab cho thấy mặc định 9.0.2 ra kết quả Y HỆT 8.0.32 (lab 01-null.txt):

                       9.0.2 (mặc định)         8.0.32
{"a.b": null}          [1,2,3,7,8,9,12]         [1,2,3,7,8,9,12]      # giống nhau!
{"a.b": {$ne: null}}   [4,5,6,10,11]            [4,5,6,10,11]

Lý do: tham số internalQueryLegacyDottedPathNullSemantics mặc định true (giữ ngữ nghĩa cũ). Source giải thích vì sao tắt mặc định: "ngữ nghĩa mới đổi tập document mà một partial filter expression khớp, mà index thì không được dựng lại khi nâng cấp" (query_execution_knobs.idl). Bật lên mới thấy ngữ nghĩa mới:

setParameter internalQueryLegacyDottedPathNullSemantics: false
{"a.b": null}        → [1,2,3,4,5,7,8,9,10,11,12]   # giờ mảng rỗng / mảng scalar / mảng lồng cũng coi là a.b = null
{"a.b": {$ne: null}} → [6]                           # thu hẹp lại đúng document thực sự có a.b non-null
Bẫy nổ trễ nhất bài: dựng partial index theo ngữ nghĩa cũ rồi bật ngữ nghĩa mới → cùng một query cho kết quả khác nhau tuỳ plan:
partial index {sku:1} với partialFilterExpression {"promo.code": null}; query {sku:"A", "promo.code": null}
legacy=true : COLLSCAN [3]        IXSCAN [3]        # nhất quán
legacy=false: COLLSCAN [1,3,4]    IXSCAN [3]        # KHÁC nhau: index chứa document theo ngữ nghĩa cũ, COLLSCAN tính theo ngữ nghĩa mới
Query trả về đúng hay sai tuỳ query planner chọn dùng index hay không. Vì vậy MongoDB để tắt mặc định và yêu cầu dựng lại index trước khi bật. Đừng bật tham số này trên production nếu có partial index đụng dotted path — hãy rebuild index trước.

5.7 Operator injection: {"$ne": null} đăng nhập được

Nếu truy vấn nhận thẳng giá trị từ request, kẻ tấn công gửi một object thay cho chuỗi để biến giá trị thành toán tử. Trong PHP, body JSON {"password":{"$ne":null}} làm $request->input('password') trả về mảng ['$ne' => null]; query string ?token[$ne]=x cũng vậy. Đây là lỗ hổng thật, đã có CVE cho chính laravel-mongodb (lab 01-inject.txt).

body: {"email":"admin@lms.vn","password":{"$ne":null}}

### laravel-mongodb 5.10.0 (TRƯỚC khi vá):
where('email','=',$e)->where('password','=',$p)  → ĐĂNG NHẬP ĐƯỢC  MQL {password: {$ne: null}}

### laravel-mongodb 5.11.0 (đã vá CVE-2026-88022):
where('email','=',$e)->where('password','=',$p)  → từ chối (null)  MQL {password: {$eq: {$ne: null}}}
# nhưng dạng 2 tham số / dạng mảng VẪN dính ở cả hai bản:
where('password', $p)                             → ĐĂNG NHẬP ĐƯỢC  MQL {password: {$ne: null}}
where(['email'=>$e, 'password'=>$p])              → ĐĂNG NHẬP ĐƯỢC  MQL {password: {$ne: null}}

Bản vá 5.11.0 (PR 3581, CVE-2026-88022) chỉ bọc $eq cho dạng 3 tham số where($col, '=', $val); dạng 2 tham số và dạng mảng where([...]) vẫn hiểu object là toán tử (đây là hành vi cố ý để giữ where('f', ['$in' => [...]]) chạy). Chỉ có khoá _id/khoá quan hệ là bị chặn cứng: User::find(['$ne' => null]) và where('_id', ['$ne' => null]) ném InvalidArgumentException: "...cannot contain the MongoDB operator \"$ne\"".

Còn dò được cả token reset bằng $regex từng ký tự (mỗi request chỉ trả có/không, nhưng đủ để dò nhị phân):

token thật cb0789136b2703fd, dò ra cb0789136b2703fd sau 124 request   # {"token": {"$regex": "^cb07..."}}

Node (lab 01-inject.txt, phần Node): driver thuần findOne({email, password}) dính y hệt. Mongoose với schema password: String vẫn không chặn {$ne: null} — vì $ne là toán tử hợp lệ và toán hạng null cast được. Phải bật sanitizeFilter (bọc $eq) hoặc validate kiểu. sanitizeFilter + schema String thì {$eq: {$ne: null}} cast sang String lỗi CastError → bị chặn (dạng lỗi, không phải null).

Cách chặn (đặt ở biên, không phụ thuộc version package):
  1. Validate kiểu ngay khi nhận: 'password' => 'required|string' → request mảng bị 422 trước khi chạm query. Lab: {"password":["The password field must be a string."]}.
  2. Ép kiểu/so sánh tường minh: {password: {$eq: $p}} hoặc String($p) ở Node; $request->string('password') ở Laravel (ném lỗi nếu là mảng).
  3. Không bao giờ truyền thẳng mảng từ request vào where([...]) hay findOne(req.body).
  4. Mongoose: sanitizeFilter: true toàn cục + mongoose.trusted() cho filter tin cậy.
Đây là validate ở trust boundary — không được cắt. Xem thêm phân quyền ở bài RBAC.

5.8 Document là đơn vị tranh chấp

Hai ghi vào hai document khác nhau không đụng nhau; hai ghi vào cùng một document thì WiredTiger báo WriteConflict và tự retry (bài 6). Lab 01-hotdoc.txt (8 process × 2000 $inc):

w:majority (mặc định)  cùng 1 document        1131 update/s   writeConflicts +9505
w:majority             mỗi process 1 document 1034 update/s   writeConflicts +0
w:1                    cùng 1 document        5824 update/s   writeConflicts +16268
w:1                    mỗi process 1 document 7743 update/s   writeConflicts +0

Kết quả luôn đúng (tổng 16000) nhờ retry, nhưng document nóng đẻ ra hàng nghìn WriteConflict và throughput thấp hơn. Bài học thiết kế: counter nóng (lượt xem, tồn kho một SKU hot) đừng dồn vào một document — tách ra nhiều document rồi cộng lại, hoặc đệm ở Redis (Redis nền tảng). Optimistic concurrency bằng version field ở bài 8 và Optimistic locking.

6. Vận hành trong production

Cần biếtCông cụ / lệnhGhi chú
Kích thước 1 documentObject.bsonsize(doc); $project: {s: {$bsonSize: "$$ROOT"}}Theo dõi document sát 16 MB (mảng phình)
Kích thước collectiondb.coll.stats() / $collStats: {storageStats:{}}: size (dataSize), storageSize, avgObjSizeSo dataSize với storageSize để biết tỉ lệ nén; cache giữ bản chưa nén
Tranh chấp ghiserverStatus().metrics.operation.writeConflicts, …insertFailedDueToDuplicateKeyErrorTăng nhanh = document nóng hoặc upsert đua
Kiểu dữ liệu lẫn lộn$group: {_id: {$type: "$field"}}Phát hiện field vừa string vừa number/ObjectId
RAM / cacheserverStatus().wiredTiger.cache (bytes currently in the cache, maximum bytes configured)Working set phình theo tên field (mục 3.3)

Capacity — ví dụ minh hoạ (phải đánh dấu là ví dụ): collection lms.events 3 triệu document. Với dữ liệu lab, tên field dài tốn thêm ~132 byte/document so với tên ngắn (263 vs 125 byte) → 3M × 132 ≈ 378 MB dataSize dôi ra, và vì cache giữ bản chưa nén, ngần ấy RAM dôi ra cho working set nếu đọc rộng. Trên cache mặc định max(50%(RAM−1 GB), 256 MB), đó là khác biệt lớn khi RAM eo hẹp. Số liệu này suy ra từ lab 200k document, chưa đo trực tiếp ở quy mô 3M.

Debug lúc 3 giờ sáng — query trả rỗng/sai mà "cú pháp đúng": (1) db.coll.findOne() xem kiểu thật của field (string hay number? ObjectId hay string?); (2) chạy $group {_id: {$type: "$field"}} để lộ kiểu lẫn lộn; (3) nếu là mảng, nghĩ tới khớp-chéo → thử $elemMatch; (4) nếu lọc null, phân biệt $exists/$type; (5) với _id-như-thời-gian, kiểm đồng hồ các client. Vận hành sâu (metric FTDC, connection pool, backup) ở bài 11.

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

Người đọc của bài là dân Laravel + PostgreSQL, nên trục so sánh chính là document model vs PostgreSQL JSONB vs quan hệ thuần. Lab 01-jsonb.txt cho thấy JSONB giống và khác MongoDB ở đâu:

### 1. Kích thước: pg_column_size(jsonb) = 237 / 146 byte (đơn hàng rút gọn)   # không có ObjectId 12B, Decimal128 → nhỏ hơn BSON tương đương
### 2. JSONB KHÔNG giữ thứ tự field, GỘP key trùng:
     '{"b":1,"a":2}'::jsonb → {"a":2,"b":1}      '{"a":1,"a":2}'::jsonb → {"a":2}
     '{"w":10,"h":20}'::jsonb = '{"h":20,"w":10}'::jsonb → true     # NGƯỢC với BSON (thứ tự field có nghĩa)
### 3. @> trên mảng document: khớp CHÉO phần tử (giống dotted path của MongoDB, KHÁC $elemMatch)
     doc @> '{"items":[{"sku":"SKU-000001"},{"qty":4}]}' → id2 = true    # 2 phần tử khác nhau
     doc @> '{"items":[{"sku":"SKU-000001","qty":4}]}'   → false          # không phần tử nào thoả cả hai
### 4. null vs missing: PostgreSQL phân biệt 3 trạng thái (thiếu key / JSON null / có giá trị) rõ ràng hơn MongoDB
TrụcMongoDB (document)PostgreSQL JSONBPostgreSQL quan hệ
Thứ tự fieldGiữ, là một phần dữ liệuKhông giữ, gộp key trùngCột cố định
Nhúng documentMặc định; đọc một phátĐược, nhưng thường kèm cột quan hệPhải join
Ràng buộc / kiểuMềm; phải tự ép ($jsonSchema, bài 2)Mềm trong JSONB, cứng ngoài cộtCứng (type, FK, check)
Transaction nhiều thực thểCó nhưng là ngoại lệ (bài 8)ACID mặc địnhACID mặc định
Scale ngang (ghi)Sharding sẵn (bài 9)Thủ công / mở rộngThủ công / mở rộng
Join / truy vấn tuỳ ý$lookup đắt (bài 5)JOIN đầy đủJOIN đầy đủ, tối ưu tốt
Dùng MongoDB khi: dữ liệu tự nhiên là document được đọc/ghi trọn vẹn (catalog nhiều thuộc tính, event log, giỏ hàng, hồ sơ); access pattern biết trước và ổn định; cần scale ghi ngang; schema đổi nhanh. Tránh khi: cần join nhiều thực thể bất kỳ và truy vấn ad-hoc (BI, report); ràng buộc quan hệ chặt là trung tâm nghiệp vụ (kế toán, tài chính); transaction nhiều thực thể là chuyện thường ngày; đội đã mạnh PostgreSQL và JSONB đã đủ (đa số CRUD web). Ma trận đầy đủ với DynamoDB/Cassandra/Elasticsearch ở 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. "NoSQL nên không cần validate kiểu": dẫn tới field vừa string vừa number, query trượt, $inc lỗi (mục 5.4–5.5). Ép kiểu ở app hoặc $jsonSchema.
  2. Dùng _id làm thứ tự tạo: sai trong cùng giây, vỡ khi đồng hồ client lệch (mục 3.2). Thêm createdAt.
  3. Query mảng không dùng $elemMatch: khớp chéo phần tử, trả về document không mong muốn (mục 5.2).
  4. Mảng không có trần: phình tới 16 MB rồi update hỏng hẳn (mục 3.4; bài 2).
  5. Upsert tin tưởng mù vào unique index: server chỉ tự retry cho filter khớp đúng index; filter có thêm field thì vẫn E11000 (mục 5.1).
  6. Nhận input thô vào query: operator injection, đăng nhập bypass (mục 5.7).
  7. So khớp cả object nhúng thay vì dotted path: sai vì thứ tự field (mục 3.1).

CVE có nguồn: CVE-2026-88022 (laravel-mongodb, vá ở 5.11.0 bằng cách bọc $eq cho where($col,'=',$val) có giá trị mảng chứa toán tử); cùng đợt có CVE-2026-88027/88028 (chặn toán tử ở khoá _id/khoá quan hệ) và CVE-2026-96744 (cache lock owner). NoSQL/operator injection là lớp lỗ hổng được OWASP xếp riêng. Chưa tìm được postmortem công khai của một công ty cụ thể cho đúng các bẫy trong bài này — phần số liệu ở đây là lab tự dựng.

Điều docs không nói rõ mà người vận hành biết: thứ tự field có nghĩa khi so khớp object nhúng; {x: null} khớp cả field thiếu; số mặc định là double; độ sâu lồng thực tế 180 chứ không phải 100; ObjectId không đơn điệu; ngữ nghĩa null-dotted-path mới của 9.0 tắt mặc định và bật sai cách làm lệch partial index.

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

BSON khác JSON thế nào, vì sao không lưu JSON văn bản?
BSON là nhị phân, có kiểu (int32/int64/double/Decimal128/Date/ObjectId/Binary…), có length-prefix để nhảy qua field mà không parse, và có kiểu JSON không có. Đổi lại tốn chỗ cho type byte + tên field trong mọi document. JSON không phân biệt int/double, không có Date/Binary.
ObjectId gồm gì? Có đảm bảo tăng dần không?
4 byte thời gian (giây, big-endian) + 5 byte ngẫu nhiên theo process + 3 byte bộ đếm. Chỉ tăng gần đúng: phân giải 1 giây, client sinh nên phụ thuộc đồng hồ client. Trong cùng giây và nhiều client thì thứ tự _id không bằng thứ tự ghi.
Vì sao {scores: {$gt: 5, $lt: 3}} lại khớp [1, 10]?
Điều kiện trên trường mảng khớp nếu bất kỳ phần tử thoả từng điều kiện, và mỗi điều kiện có thể do phần tử khác nhau: 10 thoả >5, 1 thoả <3. Muốn một phần tử thoả cả hai thì $elemMatch.
Thao tác nào nguyên tử trong MongoDB?
Mọi update trên một document, kể cả nhiều field và nhiều toán tử trong một lệnh. Nhiều document cần transaction (4.0+, bài 8). updateMany không isolated — đọc khác có thể thấy nửa chừng.
Hai request đăng ký cùng email song song, chuyện gì xảy ra?
Không unique index → hai document trùng (lab 15/10). Có unique index trên email và filter đúng là {email} → server tự retry từ 4.2, còn 1 document; nhưng filter thêm field ngoài index thì vẫn phải bắt E11000 ở app.
Lưu tiền trong MongoDB thế nào?
Decimal128, không dùng double (double cộng dồn lệch: 0.1×3 = 0.30000000000000004, find({t: 0.3}) trượt). Ở Laravel cast decimal:2 → package ghi Decimal128.
{"password": {"$ne": null}} từ request làm được gì?
Nếu query nhận thẳng input, nó biến giá trị thành toán tử $ne → khớp mọi mật khẩu → đăng nhập bypass (CVE-2026-88022). Chặn: validate kiểu string ở biên, $eq tường minh, Mongoose sanitizeFilter.
Kích thước tối đa một document? Vì sao có giới hạn?
16 MiB. Vì document là đơn vị đọc/ghi/truyền — giới hạn bảo vệ RAM và băng thông. File lớn hơn dùng GridFS. Mảng phình tới 16 MB làm update hỏng hẳn.
Khi nào KHÔNG nên dùng MongoDB?
Khi nghiệp vụ cần join nhiều thực thể bất kỳ và report ad-hoc; khi ràng buộc quan hệ chặt là trung tâm; khi transaction nhiều thực thể là thường ngày; khi PostgreSQL JSONB đã đủ và đội mạnh SQL.

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 ngữ nghĩa null; mongosh 2.12.0; PHP 8.3.33 + ext-mongodb 2.5.3 + mongodb/laravel-mongodb 5.11.0 (và source 5.10.0 để so CVE) trên Laravel 13.34; Node 22 + driver mongodb 7.7.0 + mongoose 9.10.4; PostgreSQL 18.4 (my-test-pgdb, bảng tmp_mdb_*). Container tạm tmp-mdb-* trên network tmp-mdb-net, không đụng service khác. Máy 4 vCPU dùng chung — số thời gian chỉ xem bậc độ lớn.

cd labs/mongodb
./01-nen-tang.sh                 # chạy hết → out/01-bson … 01-jsonb
./01-nen-tang.sh array           # 1 phần: bson | limits | oid | array | null | types | numbers | idstr | hotdoc | upsert | inject | jsonb
(source lib.sh && mdb_down tmp-mdb-1 tmp-mdb-80)   # dọn container sau khi xong

Chưa verify: hành vi trên Atlas (managed); số liệu ở quy mô hàng triệu document (capacity mục 6 suy ra từ 200k); ObjectId tràn năm 2106 (suy ra từ cấu trúc 4 byte, không chạy tới đó được); postmortem công khai gắn đúng các bẫy bài này (chưa tìm được, đã ghi rõ ở mục 9).