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á_id12 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:1mộ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à sourcelaravel-mongodb5.11.0 (src/Query/Builder.php). Lab chạy thật: MongoDB 9.0.2 và 8.0.32 (containertmp-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:
- Impedance mismatch. Một "đơn hàng" trong code là cây lồng (có mảng item, có địa chỉ). Trong SQL phải tách ra 3–4 bảng rồi join lại mỗi lần đọc. MongoDB lưu luôn cả cây thành một document → đọc một phát, không join.
- Scale ngang. Database quan hệ scale lên (máy to hơn) dễ, scale ra (nhiều máy) khó vì join và transaction xuyên bảng. MongoDB thiết kế để chia document ra nhiều shard ngay từ đầu (bài 9).
- Tốc độ đổi schema. Thêm một trường ở SQL là
ALTER TABLEkhoá bảng. MongoDB không có bước đó — mỗi document tự mang cấu trúc của mình.
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ốc | Thời điểm | Việc quan trọng (liên quan bài này) |
|---|---|---|
| 3.0 | 2015 | Pluggable storage engine; WiredTiger xuất hiện (bài 6) |
| 3.2 | 2015 | WiredTiger thành mặc định |
| 4.0 | 2018 | Transaction nhiều document trên replica set; đổi sang SSPL |
| 4.2 | 2019 | Transaction xuyên shard; server tự retry upsert trùng key (SERVER-37124 — mục 5) |
| 5.0 | 2021 | Write concern mặc định thành majority; oplog dạng diff |
| 6.0 / 7.0 / 8.0 | 2022–2024 | Nhiều cải tiến query engine, sharding, concurrency |
| 8.3 | 2025 | Cost-Based Ranker thành mặc định cho query planner (bài 4) |
| 9.0 | 28/09/2026 | Giớ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 nghe | Thậ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.
\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ùngsort({_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ự_idtrong 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ườngcreatedAtvà 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
- dataSize (BSON chưa nén): tên dài tốn gấp 2,1× tên ngắn — đúng phần tên field lặp lại.
- storageSize (đĩa, snappy): chênh ít hơn (9,4 vs 7,7 MB) vì tên field lặp đi lặp lại nén rất tốt. Nhìn đĩa thôi dễ tưởng "tên field không quan trọng".
- trong cache: 60,2 vs 31,7 MB — gần đúng dataSize. Đây mới là chỗ đau: RAM là tài nguyên khan, working set phình theo tên field. Trên cache 256 MB, collection tên dài ăn gần hết.
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,tcho 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ảo | Giớ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ệnh | Nhiều document cần transaction (4.0 replica set, 4.2 sharded — bài 8). updateMany không isolated. |
| So sánh | Toá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ểu | Sort trộn kiểu theo thứ tự cố định: MinKey < null < số < chuỗi < object < array < … < bool < date | Cá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ước | Document ≤ 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án | Một document nằm trọn trên một shard theo shard key | Unique 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àn | Số 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ống | Hậu quả (đo) | Phát hiện | Phòng / chữa |
|---|---|---|---|---|
| 1 | Nhiều request upsert cùng filter, không unique index | Bả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ào | Unique index + xử lý E11000; findOneAndUpdate |
| 2 | Dùng _id/ObjectId làm thứ tự thời gian | Sai 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ỗi | Trường createdAt + index |
| 3 | Operator injection qua request JSON/query string | Bỏ 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 |
| 4 | Số mặc định double; số từ request là chuỗi | Tiề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 |
| 5 | FK 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ời | Query đúng cú pháp nhưng trả rỗng | Thống nhất 1 kiểu; cast ObjectId/string |
| 6 | null 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 |
| 7 | Mảng phình tới 16 MB ($push không giới hạn) | Update hỏng hẳn ở ngưỡng; document đóng băng | Lỗi 10334 "larger than 16777216" | Đặt trần mảng; bucket pattern (bài 2) |
| 8 | Nhiều ghi vào cùng một document nóng | WriteConflict + retry; throughput giảm (đo dưới) | serverStatus.metrics.operation.writeConflicts tăng | Tá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.
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 (vdstatus,deletedAt: null) — bạn vẫn phải bắtE11000và 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.
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 ghi2^53+1 = 9007199254740993thành long đúng. Node đọc mặc định raLongobject; nhưng field long nhỏ (2^40) thì đọc ranumberJS —php.big + 1cộng đúng, cònphp.int53p1 + 1(Long) biến thành nối chuỗi"90071992547409931". Muốn đọc an toàn:findOne(…, {useBigInt64: true})(rabigint) 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: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.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
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):Đây là validate ở trust boundary — không được cắt. Xem thêm phân quyền ở bài RBAC.
- 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."]}.- Ép kiểu/so sánh tường minh:
{password: {$eq: $p}}hoặcString($p)ở Node;$request->string('password')ở Laravel (ném lỗi nếu là mảng).- Không bao giờ truyền thẳng mảng từ request vào
where([...])hayfindOne(req.body).- Mongoose:
sanitizeFilter: truetoàn cục +mongoose.trusted()cho filter tin cậy.
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ết | Công cụ / lệnh | Ghi chú |
|---|---|---|
| Kích thước 1 document | Object.bsonsize(doc); $project: {s: {$bsonSize: "$$ROOT"}} | Theo dõi document sát 16 MB (mảng phình) |
| Kích thước collection | db.coll.stats() / $collStats: {storageStats:{}}: size (dataSize), storageSize, avgObjSize | So dataSize với storageSize để biết tỉ lệ nén; cache giữ bản chưa nén |
| Tranh chấp ghi | serverStatus().metrics.operation.writeConflicts, …insertFailedDueToDuplicateKeyError | Tă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 / cache | serverStatus().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ục | MongoDB (document) | PostgreSQL JSONB | PostgreSQL quan hệ |
|---|---|---|---|
| Thứ tự field | Giữ, là một phần dữ liệu | Không giữ, gộp key trùng | Cột cố định |
| Nhúng document | Mặ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ểu | Mềm; phải tự ép ($jsonSchema, bài 2) | Mềm trong JSONB, cứng ngoài cột | Cứng (type, FK, check) |
| Transaction nhiều thực thể | Có nhưng là ngoại lệ (bài 8) | ACID mặc định | ACID mặc định |
| Scale ngang (ghi) | Sharding sẵn (bài 9) | Thủ công / mở rộng | Thủ 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
- Schema là hợp đồng ngầm trong dữ liệu. Vì BSON mang tên field trong mọi document và không có
ALTER TABLE, "schema" của bạn là quy ước giữa các service đọc/ghi cùng collection. Không ép ở DB thì phải ép ở app ($jsonSchema, bài 2) và ở code review. Người mới phải hiểu "document là đơn vị" (mục 2) trước khi viết query đầu tiên. - Kiểu dữ liệu là rủi ro kiến trúc, không phải tiểu tiết. ObjectId vs string, double vs Decimal128, number vs string-từ-request — mỗi thứ là một lớp bug im lặng. Chốt một quy ước trong base model dùng chung (cast FK, cast tiền sang Decimal128, cast số), và đưa vào checklist review.
- Operator injection là một lớp lỗ hổng, không phải một bug. Bản vá package (CVE-2026-88022) chỉ che một dạng gọi; dạng khác vẫn dính. Phòng thủ phải ở biên: validate kiểu mọi input vào query. Đây là chính sách bắt buộc, không phải "nhớ thì làm".
- Exit strategy gắn với SSPL. SSPL làm hẹp lựa chọn managed/self-host và khiến một số distro bỏ MongoDB. Nếu khoá chặt vào API riêng của MongoDB, chi phí rời đi cao. Bài 11 bàn FerretDB/DocumentDB/Cosmos và đường lui.
- Đoạn chép vào ADR: "Dùng MongoDB cho dữ liệu dạng document đọc/ghi trọn vẹn theo access pattern đã biết. Mọi collection có quy ước kiểu field (FK = ObjectId qua cast,
tiền = Decimal128). Không nhận input thô vào filter — validate kiểu ở biên. Không dùng
_idlàm thứ tự thời gian. Mảng có trần kích thước. Transaction nhiều document là ngoại lệ, mặc định thiết kế để vừa một document."
9. Anti-patterns và bài học thực tế
- "NoSQL nên không cần validate kiểu": dẫn tới field vừa string vừa number, query trượt,
$inclỗi (mục 5.4–5.5). Ép kiểu ở app hoặc$jsonSchema. - Dùng
_idlàm thứ tự tạo: sai trong cùng giây, vỡ khi đồng hồ client lệch (mục 3.2). ThêmcreatedAt. - 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). - 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).
- 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).
- Nhận input thô vào query: operator injection, đăng nhập bypass (mục 5.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ự
_idkhô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).
updateManykhô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
emailvà 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 castdecimal: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,$eqtường minh, MongoosesanitizeFilter. - 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).