Document Model (P3/3): Giới hạn 16 MB; so với PostgreSQL JSONB
Ở phần trước: schema vẫn tồn tại dù MongoDB không bắt buộc nó, và $jsonSchema kiểm tra quy tắc lúc insert và update. Validator mới không lục soát document đã có sẵn.
- Cần đọc trước: Schema linh hoạt và
$jsonSchema - Dẫn tới: BSON & ObjectId, bài tiếp theo. Phần này là phần cuối của bài Document Model.
Giới hạn 16 MB và cái giá của document phình to
Ý chính
Một BSON document tối đa 16 mebibytes (16 × 1024 × 1024 = 16.777.216 byte). Tài liệu chính thức giải thích lý do: để một document không chiếm quá nhiều RAM hoặc băng thông khi truyền. Cần lưu file lớn hơn thì dùng GridFS. Nhưng điều đáng sợ hơn cái trần 16 MB là mọi thứ xảy ra trước khi chạm trần.
db.hello().maxBsonObjectSize16777216Thí nghiệm: một bài viết, comment cứ thế nhét vào array
Kiểu thiết kế rất hay gặp: mỗi bài viết là một document, comment được $push vào mảng comments. Mỗi comment có userId, text (một câu tiếng Việt có dấu), likes, at. Kích thước được đo bằng $bsonSize, tức kích thước BSON thật mà server thấy:
db.posts.aggregate([
{ $match: { _id: "post-1" } },
{ $project: { s: { $bsonSize: "$$ROOT" } } }
])| Số comment | Kích thước document |
|---|---|
| 0 | 87 B |
| 1 | 225 B |
| 10 | 1.467 B |
| 100 | 14.067 B |
| 1.000 | 141.757 B (~138 KB) |
| 10.000 | 1.426.777 B (~1,4 MB) |
| 50.000 | 7.177.977 B (~6,8 MB) |
| 100.000 | 14.366.977 B (~13,7 MB) |
Mỗi comment tốn khoảng 140 byte, và kích thước tăng tuyến tính. Ở tốc độ này, chạm trần quanh mức khoảng 116.000 comment (ước tính từ số đo, không đo trực tiếp). Một bài viết "viral" hoàn toàn có thể vượt mức đó.
Khi vượt trần, lệnh ghi bị từ chối. Mình nhân đôi mảng ngay trên server bằng một pipeline update (14,4 MB thành ~28,8 MB):
db.posts.updateOne({ _id: "post-1" },
[ { $set: { comments: { $concatArrays: ["$comments", "$comments"] } } } ])Plan executor error during update :: caused by :: Serializing Document failed :: caused by :: Size 28844977 exceeds maximum 16793600Còn insert thẳng một document 16,5 MB:
object to insert too large. size in bytes: 17301558, max size: 16777216Cả hai trả về code 10334, và document cũ vẫn nguyên vẹn (14.366.977 byte, 100.000 comment), đúng tinh thần atomic: thất bại thì không thay đổi gì. Để ý con số trong lỗi update là 16.793.600, lớn hơn 16 MiB đúng 16 KiB. Đó là chi tiết nội bộ mình quan sát được trong thông báo lỗi, không phải giới hạn được tài liệu cam kết. Hãy thiết kế theo con số 16 MiB.
Một quan sát phụ: khi mình thử gửi payload 17 MB từ mongosh, lỗi đến từ phía client (ERR_OUT_OF_RANGE khi serialize) trước khi request kịp tới server. Tuỳ driver, bạn có thể gặp lỗi ở client hoặc ở server. Đừng dựa vào việc lỗi luôn trông giống nhau.
Chi phí trước khi chạm trần
Vấn đề thật sự không phải lỗi 16 MB, vì lỗi thì ít ra còn ồn ào. Vấn đề là document to làm mọi thao tác trên nó đắt hơn, kể cả thao tác chỉ đụng tới một field nhỏ. Đo bao nhiêu, tăng theo kích thước ra sao (10 KB, 500 KB, 10 MB: latency, cache, network, giải mã ở client) là phần "document size → performance" của bài Schema Design Patterns; document to chiếm chỗ trong WiredTiger cache và đẩy dữ liệu khác ra ngoài thế nào là chuyện của bài về cache và working set.
Ở đây chỉ cần sửa một hình dung sai rất phổ biến: "$inc một field nghĩa là MongoDB ghi lại nguyên document". Không hẳn vậy:
$inc: { views: 1 } trên document ~14 MB
│
├── KHÔNG: ghi lại nguyên 14 MB xuống đĩa ngay lúc đó
│ WiredTiger có thể giữ một update nhỏ dưới dạng "bản sửa" (delta)
│ trong bộ nhớ; oplog (replica set) ghi một diff chỉ gồm field đổi
│
└── NHƯNG vẫn tốn theo kích thước document:
├── document phải được đọc/dựng lên trong cache để áp dụng update
├── trang dữ liệu chứa nó về sau vẫn phải được ghi ra đĩa
├── lần đọc không projection kéo nguyên document qua network, driver
└── mọi lệnh ghi vào nó tranh nhau CÙNG một documentDelta trong update chain của WiredTiger và diff
$v: 2trong oplog là chi tiết triển khai, không phải hành vi được trang manual cam kết, và có thể khác giữa các phiên bản. Hãy coi sơ đồ trên là mô hình đơn giản hoá; cơ chế thật nằm ở bài WiredTiger MVCC (update chain) và bài Replica Set & Oplog (oplog).
Gộp lại: document phình to (unbounded array) thì mỗi lần đọc/ghi kéo nhiều byte hơn qua cache, network và driver, chiếm nhiều cache hơn (bài về cache), index trên field trong array tạo một entry cho mỗi phần tử (multikey, xem bài Specialized Indexes), document thành hot document (nhiều client cùng ghi), và cuối cùng chạm trần 16 MB.
Tài liệu MongoDB xếp đây vào một anti-pattern có tên riêng: unbounded arrays (mảng không có giới hạn). Tách comment ra collection riêng hay embed thế nào là nội dung của bài Data Modeling; các pattern có tên như subset thuộc bài Schema Design Patterns.
Nếu bạn đọc bài cũ nói rằng document lớn lên sẽ bị "di chuyển" trên đĩa và gây phân mảnh, đó là hành vi của storage engine MMAPv1 thời trước. Trang storage engine trong tài liệu hiện tại chỉ còn WiredTiger (mặc định) và In-Memory. Chi phí của document to ngày nay là những gì bạn thấy ở trên, không phải chuyện relocation.
So với PostgreSQL: row, document và JSONB
Quay lại bài toán đơn hàng. Đặt ba cách lưu cạnh nhau:
PostgreSQL chuẩn hoá PostgreSQL + JSONB MongoDB
───────────────────── ────────────────── ───────
orders (1 row) orders orders
order_items (N rows) id, tenant_id, status { _id, status,
customers (1 row) doc JSONB ← items, customer: {...},
addresses (1 row) customer, items: [...],
shipping shipping: {...} }
schema: cột + kiểu schema: cột cho phần cứng, schema: validator
+ constraint JSONB cho phần mềm ($jsonSchema), tuỳ chọnVí dụ SQL bên dưới chỉ để minh hoạ, không chạy trong lab (lab của series chỉ có MongoDB):
CREATE TABLE orders (
id bigserial PRIMARY KEY,
tenant_id text NOT NULL,
status text NOT NULL CHECK (status IN ('pending','paid','shipped')),
created_at timestamptz NOT NULL,
doc jsonb NOT NULL -- items, customer, shipping
);
CREATE INDEX orders_doc_gin ON orders USING GIN (doc jsonb_path_ops);
-- tìm đơn có item MS-02
SELECT id FROM orders WHERE doc @> '{"items": [{"sku": "MS-02"}]}';JSONB khiến PostgreSQL trông rất giống MongoDB. Những điểm giống và khác đáng để ý:
| Khía cạnh | PostgreSQL row (+ JSONB) | MongoDB document |
|---|---|---|
| Đơn vị ghi atomic không cần transaction tường minh | một câu lệnh SQL (mỗi câu lệnh tự là một transaction) | một lệnh ghi lên một document |
| Atomic qua nhiều row/bảng | transaction, là "công dân hạng nhất" | multi-document transaction, tốn chi phí hơn ghi một document |
| Schema | cột + kiểu + CHECK, luôn bắt buộc; phần JSONB thì tự do | tự do theo mặc định; $jsonSchema tuỳ chọn |
| Thứ tự key | JSONB không giữ thứ tự key, key trùng thì giữ giá trị cuối | giữ thứ tự field (trừ _id luôn đứng đầu) |
| Kiểu dữ liệu trong phần "linh hoạt" | kiểu của JSON (string, number, boolean, null, object, array) | đầy đủ kiểu BSON: Date, int, long, decimal, ObjectId... |
| Kích thước | row vượt khoảng 2 kB thì TOAST nén/đẩy giá trị lớn ra ngoài; một field tối đa 1 GB | cả document tối đa 16 MiB |
| Đơn vị tranh chấp khi ghi | cả row: tài liệu PostgreSQL nhắc rằng update JSON lấy row-level lock trên cả row | cả document (document-level concurrency) |
Dòng cuối cho thấy hai hệ thống gặp cùng một vấn đề vật lý. Tài liệu PostgreSQL khuyên giữ JSON document ở kích thước "vừa phải" để giảm tranh chấp lock giữa các transaction cùng update. Đó chính là bài học "hot document và document to" ở trên, chỉ khác tên gọi. Dù ở hệ nào, khối dữ liệu mà bạn gom chung lại cũng là khối mà bạn sẽ phải đọc, ghi và tranh chấp chung.
Vậy khi nào JSONB là đủ? Khi phần lớn dữ liệu của bạn là quan hệ (cần JOIN, cần ràng buộc chặt) và chỉ một phần nhỏ là bán cấu trúc (thuộc tính sản phẩm, metadata), JSONB cho bạn cả hai trong một hệ thống. Khi gần như mọi entity chính đều là "một khối có cấu trúc lồng nhau" được đọc và ghi trọn vẹn, document model của MongoDB phù hợp tự nhiên hơn. Chi tiết so sánh sâu (transaction, isolation, scaling) sẽ có ở bài Anti-patterns & MongoDB vs PostgreSQL.
Những lỗi thường gặp
✗ Mỗi bảng SQL thành một collection, mỗi JOIN thành một $lookup
→ mất lợi thế "đọc một lần là đủ" lẫn atomic một document
✗ "Đọc → tính ở app → ghi lại" cho những giá trị cần nhất quán (kho, số dư)
→ lost update; dùng toán tử ($inc, $push...) + điều kiện trong filter
✗ Tin rằng MongoDB "không có schema"
→ schema trôi vào code; query trả thiếu kết quả mà không báo lỗi
✗ Thêm validator rồi nghĩ dữ liệu cũ đã sạch
→ validator không quét dữ liệu cũ; dùng find({ $nor: [schema] }) để kiểm tra
✗ additionalProperties: false mà quên khai báo _id
→ mọi insert đều thất bại
✗ Array không có giới hạn (comment, log, event) trong một document
→ mọi thao tác chậm dần, thành hot document, cuối cùng chạm 16 MB
✗ Chọn email / số điện thoại làm _id
→ _id bất biến, đổi email nghĩa là xoá và insert lạiCột mốc: Bạn đã có thể nêu giới hạn 16 MB của một document, chỉ ra cái giá của document phình to trước khi chạm trần, và so sánh với JSONB trong PostgreSQL. Bài Document Model khép lại ở đây.
Hỏi & đáp
Mỗi bài viết là một document, comment được $push vào mảng comments, mỗi comment khoảng 140 byte. Nhận định nào đúng?
Một văn phòng giữ hồ sơ khách theo từng bìa, mỗi lần rút một bìa ra là đủ giấy tờ. Nên kẹp chung vào một bìa những gì?
Bài tiếp theo: vì sao bài này quan trọng cho BSON & ObjectId
Bài này cho bạn biết document là gì: một khối tự chứa, đơn vị atomic, có (hoặc không có) schema do database giữ, và có trần 16 MiB. Nhưng ta mới nhìn nó từ bên ngoài. Bài BSON & ObjectId mở "bìa hồ sơ" ra đến tận từng byte: BSON trông thế nào, mỗi kiểu dữ liệu tốn bao nhiêu byte so với JSON, vì sao 30 và Double(30) là hai giá trị khác nhau với validator, vì sao createdAt dạng string lặng lẽ biến mất khỏi query ở trên, và ObjectId bên trong chứa gì.
Biết kiểu và kích thước rồi, bài Data Modeling mới trả lời câu hỏi lớn: vậy nên gom cái gì vào một document? Comment nên embed hay tách collection? Một khách hàng có 3 địa chỉ khác gì một khách hàng có 3 triệu event? Câu trả lời đi từ access pattern và cardinality của quan hệ: embed hay reference, khi nào denormalize, $lookup tốn gì.
Tài liệu tham khảo
- MongoDB: Documents
- MongoDB: Limits and Thresholds
- MongoDB: Atomicity and Transactions
- MongoDB: Schema Validation
- MongoDB: Specify JSON Schema Validation
- MongoDB: Specify Validation Level for Existing Documents
- MongoDB: Choose How to Handle Invalid Documents
- MongoDB:
$jsonSchema - MongoDB: Avoid Unbounded Arrays
- MongoDB: Storage Engines
- MongoDB: WiredTiger Storage Engine (Document Level Concurrency)
- MongoDB: FAQ Concurrency
- PostgreSQL: JSON Types
- PostgreSQL: TOAST