Document Model (P3/3): Giới hạn 16 MB; so với PostgreSQL JSONB

9 phút đọcSeries: MongoDB: từ gốc đến internals

Ở 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.

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().maxBsonObjectSize
16777216

Thí 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ố commentKích thước document
087 B
1225 B
101.467 B
10014.067 B
1.000141.757 B (~138 KB)
10.0001.426.777 B (~1,4 MB)
50.0007.177.977 B (~6,8 MB)
100.00014.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 16793600

Còn insert thẳng một document 16,5 MB:

object to insert too large. size in bytes: 17301558, max size: 16777216

Cả 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 document

Delta trong update chain của WiredTiger và diff $v: 2 trong 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ọn

Ví 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ạnhPostgreSQL row (+ JSONB)MongoDB document
Đơn vị ghi atomic không cần transaction tường minhmộ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ảngtransaction, là "công dân hạng nhất"multi-document transaction, tốn chi phí hơn ghi một document
Schemacột + kiểu + CHECK, luôn bắt buộc; phần JSONB thì tự dotự do theo mặc định; $jsonSchema tuỳ chọn
Thứ tự keyJSONB không giữ thứ tự key, key trùng thì giữ giá trị cuốigiữ 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ướcrow 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 GBcả document tối đa 16 MiB
Đơn vị tranh chấp khi ghicả row: tài liệu PostgreSQL nhắc rằng update JSON lấy row-level lock trên cả rowcả 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ại

Cộ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?

  1. Chỉ có vấn đề khi chạm trần 16 MiB, khoảng 116.000 comment; trước đó không tốn gì thêm

    Lỗi 16 MB ít ra còn ồn ào. Vấn đề thật là chi phí đến sớm hơn: document càng to thì đọc, cache và network càng đắt. Xem mục "Chi phí trước khi chạm trần".

  2. $inc: { views: 1 } trên document 14 MB ghi lại nguyên 14 MB xuống đĩa ngay lúc đó

    Không hẳn: WiredTiger có thể giữ update nhỏ dưới dạng delta trong bộ nhớ (chi tiết triển khai). Nhưng update vẫn trả giá theo kích thước document. Xem sơ đồ ở mục "Chi phí trước khi chạm trần".

  3. Khi vượt 16 MiB, MongoDB tự chuyển document sang GridFS

    Không có chuyển đổi tự động: lệnh ghi bị từ chối với code 10334, document cũ giữ nguyên 100.000 comment. GridFS là thứ bạn tự chọn để lưu file lớn. Xem mục "Thí nghiệm: một bài viết, comment cứ thế nhét vào array".

  4. Đọc, ghi đắt dần từ trước trần; vượt 16 MiB thì lệnh ghi bị từ chối

    Kích thước tăng tuyến tính (100.000 comment ≈ 13,7 MB). Đây là anti-pattern unbounded arrays: mỗi thao tác đắt dần, mọi lệnh ghi tranh nhau một document, và lệnh làm document vượt trần bị từ chối nguyên vẹn. Xem mục "Giới hạn 16 MB và cái giá của document phình to".

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ì?

  1. Mọi giấy tờ có liên quan tới khách, càng đủ càng tốt, để khỏi phải sang tủ khác

    Kẹp mọi thứ thì bìa phình mãi: mỗi lần mở là một gánh nặng, và ai cũng phải tranh nhau đúng bìa đó. Bài viết sau 100.000 comment đã 13,7 MB. Xem mục "Giới hạn 16 MB và cái giá của document phình to".

  2. Những giấy tờ luôn được xem và sửa cùng nhau, miễn là bìa đừng dày quá

    Thứ phải thay đổi cùng nhau nằm chung một bìa thì một lần đóng dấu là đủ (một lệnh ghi atomic), nhưng bìa quá dày thì mọi lần đọc, ghi đều đắt. Xem mục "Chi phí trước khi chạm trần"; phần atomic nằm ở Document là bìa hồ sơ, _id và atomicity.

  3. Mỗi loại giấy tờ một tủ riêng, như sổ sách kế toán, để không bìa nào bị dày

    Đó là mang tư duy "bảng" sang: muốn sửa nhiều loại giấy cùng lúc thì phải chạy qua nhiều tủ (transaction), và mất lợi thế đọc một lần là đủ. Cách nghĩ theo bảng và theo document được so ở Document là bìa hồ sơ, _id và atomicity.

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