Schema Design Patterns: những hình dạng document có tên, và cách đổi schema khi hệ thống đang chạy

29 phút đọcSeries: MongoDB: từ gốc đến internals
Bạn đang ở đâu

Cần biết trước : bài 02 (document, atomicity, $jsonSchema, 16 MB), bài 03 (kiểu BSON và kích thước),
                 bài 04 (access pattern, embed hay reference, cardinality của quan hệ, denormalization)
Bài này        : document to tốn gì (đo thật 10 KB / 500 KB / 10 MB);
                 7 pattern: subset, extended reference, computed, bucket, outlier, polymorphic,
                 schema versioning; đổi schema khi hệ thống đang chạy
Dẫn tới        : bài 06, CRUD & Query Model

Bài 04 dạy cách chọn: embed hay reference, dựa trên access pattern. Trên đường đi, ta đã làm vài việc mà chưa gọi tên: chép name và phone của khách vào đơn, tính sẵn total lúc tạo đơn, giữ một mảng có trần cho vài đơn gần nhất. Bài này đặt tên cho chúng và cho vài hình dạng khác, rồi trả lời hai câu hỏi bài 04 để lại: document to thì tốn gì cụ thể, và làm sao đổi schema khi dữ liệu cũ vẫn nằm đó.

Như các bài trước, mỗi khẳng định quan trọng có nhãn: [tài liệu] là hành vi được tài liệu chính thức mô tả, [quan sát] là điều đo được trong lab của bài này, [hình dung] là mô hình đơn giản hoá để dễ nhớ, [chi tiết triển khai] là cách hệ thống đang làm hiện nay, không phải cam kết.

Vì sao cần pattern có tên

Hãy nghĩ tới mẫu nhà của một công ty xây dựng. Không ai vẽ lại từ đầu "nhà ống 4×16, ba tầng, giếng trời ở giữa". Mẫu đó đã được xây hàng nghìn lần, người ta biết nó hợp với lô đất nào, tốn gì, và hỏng ở đâu nếu đặt nhầm chỗ. Gọi tên mẫu giúp hai kỹ sư nói chuyện trong một câu thay vì một buổi chiều.

Schema pattern cũng vậy: một hình dạng document đã được dùng đủ nhiều để có tên. Nhưng mẫu nhà ống đặt trên lô đất góc rộng 20 mét thì phí. Pattern cũng thế. Vì vậy mỗi pattern trong bài đi theo đúng bốn câu hỏi:

Vấn đề        → access pattern nào đang đau?
Hình dạng     → document trông thế nào?
Giá           → ta trả bằng gì (ghi thêm, code thêm, dữ liệu cũ)?
Khi nào KHÔNG → dấu hiệu cho thấy pattern này đang bị dùng sai chỗ

Phần lớn pattern xoay quanh một chuyện: kích thước và hình dạng của document quyết định mỗi thao tác tốn bao nhiêu. Nên ta đo chuyện đó trước.

Document to tốn gì: đo 10 KB, 500 KB và 10 MB

Bài 02 nói rằng document to làm mọi thao tác trên nó đắt hơn, kể cả thao tác chỉ đụng một field. Ở đây ta đo chuyện đó từ đầu trong lab của bài này, với ba cỡ document và nhiều chỉ số hơn: latency, byte qua mạng, chỗ trong cache, công parse ở client, và byte phải ghi xuống đĩa.

Setup

MongoDB      : 8.3.11 (image mongo:8), standalone, mongosh 2.12.0
Container    : mongo-lab-05, 2 CPU, 3 GB RAM, WiredTiger cache 1 GB, host Apple M4
Database     : lab05
Client       : mongosh chạy trong cùng container (không có network thật)
Cùng máy     : một container lab khác chạy song song, nên số đo có nhiễu

Mỗi document là một đơn hàng kèm mảng events (lịch sử xử lý đơn, mỗi phần tử khoảng 155 byte). Chỉ số phần tử khác nhau:

CollectionSố documentevents / document$bsonSize thật
sz10k207010.802–10.823 B (~10,8 KB)
sz500k203.500541.974–542.135 B (~542 KB)
sz10m1070.00010.919.936–10.920.530 B (~10,9 MB)

Mảng được dựng ngay trên server bằng update pipeline. Shell không bao giờ giữ một mảng lớn trong bộ nhớ, vì đó là cách nhanh nhất làm container 3 GB bị OOM-kill:

// build05.js (rút gọn) — chạy: docker exec -i mongo-lab-05 mongosh --quiet --eval "$(cat build05.js)"
db = db.getSiblingDB("lab05");
db[coll].insertOne({ _id: i, tenantId: "t" + (i % 5), status: "SHIPPED",
                     views: NumberInt(0), total: 550000, createdAt: new Date() });
db[coll].updateOne({ _id: i }, [ { $set: { events: { $map: {
  input: { $range: [0, nEvents] }, as: "k",
  in: { seq: "$$k",
        at: { $dateAdd: { startDate: "$createdAt", unit: "second", amount: "$$k" } },
        type: { $arrayElemAt: [["CREATED", "PAID", "PICKED", "PACKED", "SHIPPED"], { $mod: ["$$k", 5] }] },
        by: { $concat: ["staff-", { $toString: { $floor: { $multiply: [{ $rand: {} }, 1000] } } }] },
        note: { $concat: ["Ghi chú xử lý đơn hàng, mã kiện ", { $toString: { $rand: {} } }, " kho Thủ Đức"] } } } } } } ]);

Bốn thao tác, cùng một hình dạng query, chỉ khác cỡ document:

// bench05.js (rút gọn)
const ops = {
  "findOne (cả document)":     (c, id) => c.find({ _id: id }).limit(1).toArray(),
  "findOne raw (không parse)": (c, id) => c.find({ _id: id }, {}, { raw: true }).limit(1).toArray(),
  "findOne { status: 1 }":     (c, id) => c.find({ _id: id }, { status: 1 }).limit(1).toArray(),
  "$inc views":                (c, id) => c.updateOne({ _id: id }, { $inc: { views: 1 } }),
};
// mỗi thao tác: 5 lần làm nóng, rồi đo từng lần bằng performance.now(), lấy median
// 400 lần (10 KB), 200 lần (500 KB), 40 lần (10 MB); xoay vòng qua các _id
// byte qua mạng: delta của serverStatus().network.bytesOut cho 10 lệnh,
// trừ phần byte của chính lệnh serverStatus

Phiên bản raw: true nhận document về dưới dạng một khối byte BSON, không dựng thành object JavaScript. Hiệu giữa nó và findOne thường cho thấy công parse phía client.

Kết quả: khi dữ liệu đã nằm trong cache

=== round 1
sz10k   findOne (cả document)       n=400 median=0.323ms bytesOut/op=10916
sz10k   findOne raw (không parse)   n=400 median=0.155ms bytesOut/op=10916
sz10k   findOne { status: 1 }       n=400 median=0.149ms bytesOut/op=137
sz10k   $inc views                  n=400 median=0.130ms bytesOut/op=60
sz500k  findOne (cả document)       n=200 median=2.576ms bytesOut/op=542189
sz500k  findOne raw (không parse)   n=200 median=0.469ms bytesOut/op=542189
sz500k  findOne { status: 1 }       n=200 median=0.168ms bytesOut/op=138
sz500k  $inc views                  n=200 median=0.234ms bytesOut/op=60
sz10m   findOne (cả document)       n=40 median=55.605ms bytesOut/op=10920281
sz10m   findOne raw (không parse)   n=40 median=3.885ms bytesOut/op=10920281
sz10m   findOne { status: 1 }       n=40 median=0.468ms bytesOut/op=137
sz10m   $inc views                  n=40 median=1.473ms bytesOut/op=60
=== round 2
sz10k   findOne (cả document)       n=400 median=0.151ms bytesOut/op=10916
sz10k   findOne raw (không parse)   n=400 median=0.104ms bytesOut/op=10916
sz10k   findOne { status: 1 }       n=400 median=0.100ms bytesOut/op=137
sz10k   $inc views                  n=400 median=0.127ms bytesOut/op=60
sz500k  findOne (cả document)       n=200 median=2.146ms bytesOut/op=542189
sz500k  findOne raw (không parse)   n=200 median=0.273ms bytesOut/op=542189
sz500k  findOne { status: 1 }       n=200 median=0.157ms bytesOut/op=138
sz500k  $inc views                  n=200 median=0.181ms bytesOut/op=60
sz10m   findOne (cả document)       n=40 median=50.713ms bytesOut/op=10920281
sz10m   findOne raw (không parse)   n=40 median=3.801ms bytesOut/op=10920281
sz10m   findOne { status: 1 }       n=40 median=0.482ms bytesOut/op=137
sz10m   $inc views                  n=40 median=1.442ms bytesOut/op=60

Vòng 1 của sz10k chậm hơn vì mongosh còn đang "nóng máy" (JIT). Gom vòng 2 thành bảng:

Median, vòng 2~10,8 KB~542 KB~10,9 MB
findOne cả document0,15 ms2,15 ms50,7 ms
findOne raw, không parse0,10 ms0,27 ms3,80 ms
→ công parse ở client (hiệu hai dòng trên)~0,05 ms~1,9 ms~47 ms
findOne projection { status: 1 }0,10 ms0,16 ms0,48 ms
$inc: { views: 1 }0,13 ms0,18 ms1,44 ms
byte server gửi đi, cả document10.916542.18910.920.281
byte server gửi đi, projection137138137

Kết quả: cache và đĩa

Muốn biết một document chiếm bao nhiêu chỗ trong WiredTiger cache, phải bắt đầu từ cache rỗng. Tôi restart container (cache trống, nhưng file dữ liệu có thể vẫn nằm trong page cache của hệ điều hành), rồi đọc lần đầu từng document và xem chỉ số cache của riêng collection đó:

// cache05.js (rút gọn): chạy ngay sau docker restart mongo-lab-05
const cache = c => db[c].stats().wiredTiger.cache;   // "bytes currently in the cache", "bytes read into cache", ...
db[c].find({ _id: 1 }, { status: 1 }).toArray();      // doc 1: chỉ lấy 1 field nhỏ
db[c].find({ _id: 2 }, {}, { raw: true }).toArray();  // doc 2: lấy cả document
db[c].updateOne({ _id: 3 }, { $inc: { views: 1 } });  // doc 3: chưa từng được đọc

Ba lần restart cho ra cùng các con số byte (thời gian thì dao động):

sz10k  | projection doc1: inCache+117087   readInto+108230   | full doc2: inCache+0        | $inc doc3: dirty=117657
sz500k | projection doc1: inCache+585637   readInto+542145   | full doc2: inCache+585628   | $inc doc3: dirty=586053
sz10m  | projection doc1: inCache+11793703 readInto+10919983 | full doc2: inCache+11793715 | $inc doc3: dirty=11794309

Lần đọc đầu (cache rỗng), median của 3 lần restart: projection trên document 10,9 MB mất 6,4 ms, đọc cả document (raw) mất 16,6 ms; với document 542 KB là 0,75 ms và 1,14 ms. Mỗi số là một lần gọi duy nhất sau restart, nên chỉ xem như độ lớn.

Cuối cùng: một lệnh $inc đổi 4 byte thì checkpoint kế tiếp phải ghi bao nhiêu?

// ckpt05.js (rút gọn)
db.adminCommand({ fsync: 1 });                          // xả hết dữ liệu dirty trước
const w0 = db[c].stats().wiredTiger.cache["bytes written from cache"];
db[c].updateOne({ _id: 5 }, { $inc: { views: 1 } });
db.adminCommand({ fsync: 1 });                          // ép một checkpoint
sz10k   bytes written from cache sau 1 $inc: 108294   | size: 216283    storageSize: 114688
sz500k  bytes written from cache sau 1 $inc: 542447   | size: 10841486  storageSize: 3338240
sz10m   bytes written from cache sau 1 $inc: 10920698 | size: 109201781 storageSize: 38645760

Chạy ba lần, số byte ghi giống nhau đến từng byte (±1).

Vì sao chi phí khác nhau như vậy

Hình dung đường đi của một document từ đĩa tới code của bạn:

đĩa (nén snappy)
   │  đọc + giải nén cả document              ← cold read: 16,6 ms cho 10,9 MB
   ▼
WiredTiger cache (chưa nén)                   ← ~11,8 MB cho một document 10,9 MB
   │  server lấy document, áp projection
   ▼
network / socket                              ← 10,9 MB hay 137 byte, tuỳ projection
   │
   ▼
driver: BSON → object của ngôn ngữ            ← ~47 ms ở mongosh cho 70.000 phần tử
   │
   ▼
code của bạn

1. Byte qua mạng tỉ lệ thẳng với thứ bạn lấy về. [quan sát] bytesOut bằng $bsonSize cộng khoảng 100 byte vỏ của reply. Projection cắt nó còn 137 byte cho cả ba cỡ. Lab này không có network thật (client và server cùng container). Trên mạng 1 Gbit/s, riêng việc đẩy 10,9 MB đã tốn khoảng 87 ms ([hình dung]: 10,9 MB × 8 bit ÷ 1 Gbit/s, chưa tính gì khác).

2. Công parse ở client là phần đắt nhất khi lấy cả document to. [quan sát] Với 10,9 MB, 47 trong 50,7 ms là để biến 70.000 phần tử BSON thành object JavaScript. Đó là con số của mongosh. Driver Go hay Java parse nhanh chậm khác nhau, nhưng không driver nào parse 70.000 object miễn phí, và GC của ứng dụng còn phải dọn chúng sau đó.

3. Projection giảm byte trả về, không giảm công ở server. [quan sát] Projection trên document 10,9 MB vẫn chậm hơn gần 5 lần so với trên document 10,8 KB (0,48 so với 0,10 ms), và khi cache rỗng nó kéo cả 10,9 MB vào cache (readInto+10919983) chỉ để lấy status. [hình dung] Storage engine lưu và đọc theo document, không theo field: muốn một field, phải có cả document trong cache trước.

4. Cache tính theo document, và ở cỡ nhỏ thì theo page. [quan sát] Mỗi document 10,9 MB chiếm khoảng 11,8 MB cache (dữ liệu trong cache chưa nén, cộng phần quản lý). Cache 1 GB của lab chỉ chứa được khoảng 90 document như thế, thực tế còn ít hơn vì WiredTiger không để cache đầy hẳn (bài 21). Ở cỡ 10 KB thì khác: đọc document 1 kéo vào một page khoảng 108 KB, và document 2 nằm sẵn trong page đó (inCache+0). Với document nhỏ, hàng xóm "đi nhờ" vào cache; với document to, mỗi document là một khối riêng đẩy dữ liệu khác ra. Working set, eviction và vì sao dữ liệu nóng phải vừa cache là chủ đề của bài 21.

5. Một $inc 4 byte làm dirty và phải ghi lại cả document. [quan sát] $inc trên document 10,9 MB chậm hơn khoảng 11 lần (1,44 so với 0,13 ms). Sau một $inc, collection có khoảng 11,8 MB "dirty" trong cache, và checkpoint kế tiếp ghi ra 10.920.698 byte. Con số này xấp xỉ $bsonSize của document, tức là tính trước khi nén (trên đĩa, sz10m nén còn khoảng 35%). [chi tiết triển khai] WiredTiger ghi xuống đĩa theo page, không theo field. Trong bộ nhớ, nó có thể giữ một thay đổi nhỏ dưới dạng delta, nhưng đến lúc ghi page, cả page được viết lại; với document 10,9 MB thì page đó gần như chính là document, còn với document 10 KB thì cả page ~108 KB chứa nó được ghi lại. Checkpoint và journal là bài 22, cơ chế cập nhật trong bộ nhớ là bài 23.

Tóm lại thành một bức tranh chi phí:

Document to (10 MB)
   │
   ├── ✗ đọc cả document: byte qua mạng + parse ở client (50 ms trong lab, chưa có network thật)
   ├── ✗ projection: vẫn kéo cả document vào cache khi cache lạnh
   ├── ✗ mỗi document chiếm ~11,8 MB cache → working set phình (bài 21)
   ├── ✗ $inc một field: dirty cả document, checkpoint ghi lại ~10,9 MB
   └── ✓ nếu ứng dụng thật sự luôn cần cả 10 MB cùng lúc, một lần đọc vẫn rẻ hơn 70.000 lần đọc nhỏ

Dòng cuối quan trọng: document to không sai. Nó chỉ đắt khi thao tác phổ biến nhất chỉ cần một phần nhỏ của nó. Gần hết các pattern dưới đây là cách tách phần ít dùng ra khỏi đường đi của thao tác phổ biến.

PostgreSQL làm phần này tự động hơn. [tài liệu PostgreSQL] Khi một row rộng hơn khoảng 2 kB, cơ chế TOAST nén và đẩy các giá trị lớn ra một bảng phụ. Giá trị đó chỉ được lấy khi được SELECT, và "an UPDATE of a row with out-of-line values incurs no TOAST costs if none of the out-of-line values change". MongoDB không có cơ chế tách theo field như vậy: document là đơn vị lưu trữ. Subset và outlier chính là cách bạn tự làm "TOAST bằng tay" ở tầng schema.

Bảy pattern

Subset: giữ phần hay đọc, tách phần ít đọc

Vấn đề. Trang chi tiết sản phẩm hiện 10 review mới nhất, nhưng một sản phẩm có thể có 20.000 review. Embed hết thì document phình như sz10m ở trên. Reference hết thì mỗi lần mở trang lại thêm một query. Bài 04 gặp cùng vấn đề ở dạng khác: một khách giữ danh sách đơn của mình, hay một sản phẩm kèm mô tả dài vài chục KB mà hiếm khi được xem.

Hình dạng. Document chính chỉ giữ phần nóng; toàn bộ nằm ở collection riêng. Phiên bản hay gặp nhất là mảng có trần (capped array), cắt bằng $slice mỗi lần $push:

// products: chỉ 10 review mới nhất
{ _id: 118, tenantId: "t43", name: "Sản phẩm 118", price: 230000,
  recentReviews: [ { reviewId: 9001, stars: 5, text: "...", at: ISODate("2026-10-01T08:00:00Z") } /* ≤ 10 */ ] }

// reviews: tất cả, phân trang khi bấm "xem thêm"
{ _id: 9001, productId: 118, tenantId: "t43", stars: 5, text: "...", at: ISODate("2026-10-01T08:00:00Z") }
db.reviews.insertOne(newReview);
db.products.updateOne({ _id: 118 },
  { $push: { recentReviews: { $each: [newReview], $position: 0, $slice: 10 } } });

Mảng orderIds của khách ở bài 04 cũng sửa theo đúng cách này: reference ở phía "nhiều", cộng (nếu thật sự cần) một mảng recentOrders có trần trong customer. Phần mô tả dài của sản phẩm cũng vậy: tách sang product_details 1-1, document chính chỉ giữ thứ trang danh sách cần. Cái lợi của việc tách chính là khoảng cách giữa các cột của bảng đo ở trên.

Giá. Hai lần ghi cho mỗi review, dữ liệu nằm hai chỗ, và hai lần ghi đó không atomic với nhau nếu không dùng transaction (bài 16). Làm lệnh ghi idempotent để retry an toàn. Đổi lại: [tài liệu] document nhỏ hơn thì working set nhỏ hơn; [quan sát] ở trên, đọc 10,8 KB thay vì 10,9 MB là 0,15 ms thay vì 50 ms.

Khi nào không. Khi màn hình chính thật sự luôn cần cả tập dữ liệu và tập đó có giới hạn nhỏ (5 dòng hàng của đơn). Khi phần "nóng" không xác định được (mỗi người dùng muốn một tập con khác nhau). Và khi review bị sửa hay xoá thường xuyên: mỗi lần sửa phải sửa hai chỗ.

Extended reference: reference kèm vài field hay đọc

Vấn đề. Màn chi tiết đơn cần tên và SĐT người mua. $lookup sang customers cho mỗi lần xem đơn thì tốn một lần tìm thêm (bài 04 đo khoảng 2 lần chậm hơn, có index).

Hình dạng. Chính là mô hình A của bài 04: giữ _id để tra hồ sơ đầy đủ khi cần, cộng vài field được đọc cùng.

{ _id: 4242, tenantId: "t43", status: "PAID", total: 550000,
  customer: { _id: 6393, name: "Nguyễn An", phone: "0901234567" },   // extended reference
  items: [ /* ... */ ] }

Giá. Bản chép có thể cũ. Trước khi chép, xếp field đó vào một trong các loại bài 04 đã phân loại (immutable, temporal, cần tươi, chấp nhận cũ), vì loại quyết định có phải đồng bộ hay không. Nếu phải đồng bộ: cần index trên customer._id và lệnh updateMany idempotent.

Khi nào không. Khi field được chép đổi thường xuyên (tồn kho, trạng thái online), hoặc khi bạn chép ngày càng nhiều field "cho tiện" tới mức bản chép thành một hồ sơ thứ hai. Dấu hiệu: đội phải viết job đồng bộ cho từng field mới.

Computed: tính lúc ghi, đọc ngay

Vấn đề. Danh sách đơn cần tổng tiền; dashboard cần "tổng chi tiêu của khách", "số đơn tháng này". Tính lại từ dữ liệu gốc mỗi lần đọc thì phí CPU, và càng nhiều đọc càng phí.

Hình dạng. total tính một lần lúc tạo đơn. Thống kê của khách cập nhật dần mỗi khi có đơn mới:

db.customers.updateOne({ _id: 6393, tenantId: "t43" },
  { $inc: { "stats.orderCount": 1, "stats.totalSpent": 550000 } });

Hoặc, khi chấp nhận số liệu trễ vài phút, một job định kỳ dùng $merge ghi kết quả aggregation vào một collection tổng hợp (bài 11).

Giá. Mỗi code path ghi phải nhớ cập nhật con số tính sẵn. Quên một chỗ (hủy đơn, hoàn tiền) là số lệch dần, lặng lẽ. Thực tế nên có thêm job đối soát tính lại từ dữ liệu gốc. Thêm nữa, mọi đơn mới cùng $inc vào một document khách: với khách B2B có hàng nghìn đơn mỗi phút, document đó thành điểm nóng (write conflict là bài 19).

Khi nào không. Khi tỉ lệ đọc/ghi thấp (con số được ghi nhiều hơn được đọc). Khi phép tính phụ thuộc tham số do người dùng chọn (khoảng ngày tùy ý): không thể tính sẵn mọi tổ hợp. Khi sai số không chấp nhận được mà bạn không có cách đối soát.

Bucket: gom chuỗi dài thành nhóm có trần

Vấn đề. Mỗi sản phẩm phát sinh hàng trăm sự kiện tồn kho mỗi ngày. Một document mỗi sự kiện thì số document và index entry tăng rất nhanh; một mảng mỗi sản phẩm thì phình không giới hạn.

Hình dạng. Một document cho mỗi nhóm (theo sản phẩm và ngày), có trần số phần tử:

db.stock_events.updateOne(
  { tenantId: "t43", productId: 118, day: ISODate("2026-10-08"), count: { $lt: 200 } },
  { $push: { events: { type: "OUT", qty: 2, at: new Date() } }, $inc: { count: 1 } },
  { upsert: true })                     // bucket đầy → filter không khớp → tạo bucket mới

Bucket còn mở đường cho computed: giữ sumOut, sumIn ngay trong bucket để báo cáo theo ngày khỏi phải đọc từng sự kiện.

Giá. Query phải hiểu cấu trúc bucket (thường cần $unwind, bài 11). Logic "bucket đầy thì mở bucket mới" nằm trong ứng dụng, filter của upsert cần một index (tenantId, productId, day), và hai upsert đồng thời có thể cùng mở bucket mới, nên phải chấp nhận vài bucket chưa đầy. Xoá hay sửa một sự kiện lẻ khó hơn nhiều so với một document mỗi sự kiện.

Khi nào không. Khi bạn hay truy vấn hay sửa từng sự kiện riêng lẻ, hoặc dữ liệu không có trục tự nhiên để gom (thời gian, nguồn). Và với dữ liệu chuỗi thời gian thuần túy, [tài liệu] time series collection để server tự gom measurement thành bucket bên dưới; bài 31 đo nó so với collection thường.

Outlier: thiết kế cho số đông, xử lý riêng số ít

Vấn đề. 99% sản phẩm có dưới 50 người theo dõi; vài sản phẩm "hot" có 200.000. Thiết kế cho trường hợp xấu nhất (tách mọi danh sách ra collection riêng) thì 99% trường hợp phải trả thêm một query không cần thiết. Thiết kế cho số đông (embed) thì vài document phình thành sz10m ở trên.

Hình dạng. [tài liệu] Embed tới một ngưỡng, đánh cờ document vượt ngưỡng, phần dư sang collection riêng:

// sản phẩm bình thường
{ _id: 118, tenantId: "t43", followers: ["u1", "u2", "u3"] }
// sản phẩm vượt ngưỡng 50
{ _id: 777, tenantId: "t43", followers: [ /* 50 người đầu */ ], hasExtras: true }
// phần dư, index trên { productId: 1 }
{ productId: 777, followers: [ /* tối đa 1.000 mỗi document */ ] }

Đường đọc: lấy document; chỉ khi hasExtras: true mới query thêm.

Giá. [tài liệu] Logic cập nhật phức tạp hơn: mỗi lần thêm phải kiểm tra cờ, chọn chỗ ghi, và bật cờ khi vượt ngưỡng (hai bước này không atomic với nhau nếu không có transaction). Mọi chỗ đọc phải nhớ kiểm tra cờ. Báo cáo kiểu "đếm tổng follower" phải gộp hai nơi.

Khi nào không. Khi "ngoại lệ" không còn là ngoại lệ: nếu 30% document vượt ngưỡng, bạn đang có hai schema chính chứ không phải một schema và một ngoại lệ, và subset hay reference thẳng sẽ đơn giản hơn. Khi dữ liệu được sửa liên tục, [tài liệu] cân nhắc pattern khác.

Polymorphic: nhiều hình dạng, một collection

Vấn đề. Sàn bán điện thoại, áo và sách. Màn tìm kiếm truy vấn mọi sản phẩm theo tên, giá, tenant; nhưng điện thoại có ramGb, áo có sizes, sách có isbn. Ba collection thì tìm kiếm chung phải query ba lần; một bảng với cột cho mọi thuộc tính thì đầy null.

Hình dạng. Một collection, phần chung giống nhau, phần riêng theo loại, và một field phân biệt loại:

{ _id: 1, tenantId: "t43", kind: "phone", name: "Điện thoại X", price: 8990000, specs: { ramGb: 8, storageGb: 256 } }
{ _id: 2, tenantId: "t43", kind: "shirt", name: "Áo thun",      price: 199000,  specs: { sizes: ["M", "L"], material: "cotton" } }
{ _id: 3, tenantId: "t43", kind: "book",  name: "Sách Y",       price: 120000,  specs: { isbn: "978..." } }

[tài liệu] Tài liệu MongoDB gọi đây là polymorphic pattern; khi các loại có chung một bộ field "cha" rõ ràng như trên, nó gọi là inheritance pattern. Index cho field chung dùng cho cả collection; field chỉ một loại có thì dùng partial index lọc theo kind (bài 09). Validator có thể dùng oneOf trong $jsonSchema để mỗi kind có luật riêng.

Giá. Code đọc phải rẽ nhánh theo kind. Validator phức tạp hơn. Index trên field riêng của một loại vẫn phải được cân nhắc cho cả collection.

Khi nào không. Khi các loại gần như không bao giờ được truy vấn chung (khi đó collection riêng rõ ràng hơn). Khi một loại lớn hơn hẳn và có access pattern khác hẳn (log so với sản phẩm), vì chúng tranh nhau cache và index. Và không dùng polymorphism như cái cớ để không viết schema: "hình dạng khác nhau có chủ đích, có kind" khác hẳn với "hình dạng khác nhau vì bug" của bài 02.

Schema versioning: ghi rõ document thuộc phiên bản nào

Vấn đề. Schema sẽ đổi. Ví dụ: customer.phone (một chuỗi) thành customer.phones (mảng). Collection 50 triệu đơn không thể đổi trong một đêm, nên trong một thời gian, document cũ và mới cùng tồn tại.

Hình dạng. [tài liệu] Thêm field schemaVersion để ứng dụng biết phải đọc document theo cách nào:

{ _id: 1, tenantId: "t1", customer: { name: "Khách 1", phone: "0901234510" } }                  // v1 (không có field)
{ _id: 7, tenantId: "t1", schemaVersion: 2, customer: { name: "Khách 7", phones: ["0909000070"] } }

Giá. [tài liệu] Query phải tìm ở mọi chỗ field có thể nằm, theo từng phiên bản ($or giữa customer.phone và customer.phones), và có thể cần index cho cả hai chỗ. Code đọc mang nhánh cho từng phiên bản còn sống.

Khi nào không. Khi collection nhỏ tới mức migrate một lần mất vài giây: cứ migrate. Và đừng để schemaVersion thành bộ sưu tập: mỗi phiên bản còn tồn tại là một nhánh code phải test. Pattern này là để đi qua một thay đổi, không phải để sống mãi với năm phiên bản. Làm sao "đi qua" là phần tiếp theo.

PatternĐổi cái gì lấy cái gì
Subsetđọc nhanh, document nhỏ ← ghi hai chỗ
Extended referencebớt một lần tìm khi đọc ← bản chép có thể cũ
Computedđọc không phải tính ← mọi code path ghi phải cập nhật, cần đối soát
Bucketít document, ít index entry ← query phải hiểu bucket
Outliersố đông đơn giản và nhỏ ← code xử lý ngoại lệ
Polymorphicmột query cho nhiều loại ← code và validator rẽ nhánh
Schema versioningđổi schema không cần dừng hệ thống ← nhiều phiên bản cùng sống

Đổi schema khi hệ thống đang chạy

Trước tiên: biết mình đang có gì

Bài 02 đã đếm "schema thật" của collection bằng $type. Đây là bước đầu tiên của mọi lần đổi schema, vì code ghi cũ thường đã tạo ra những hình dạng bạn không biết. Lab: 6 đơn v1, 1 đơn v2; đơn số 6 có views là int32 ở giá trị lớn nhất rồi bị $inc thêm 1:

db.orders.aggregate([{ $group: { _id: {
    v: { $ifNull: ["$schemaVersion", 1] },
    phone: { $type: "$customer.phone" }, phones: { $type: "$customer.phones" }, views: { $type: "$views" } },
  n: { $sum: 1 } } }])
{"_id":{"v":1,"phone":"string","phones":"missing","views":"int"},"n":5}
{"_id":{"v":1,"phone":"string","phones":"missing","views":"long"},"n":1}
{"_id":{"v":2,"phone":"missing","phones":"array","views":"int"},"n":1}

[quan sát] Dòng giữa là thứ không ai cố ý tạo ra: như bài 03 đã thấy, $inc tràn int32 đổi kiểu field thành long. Một validator bsonType: "int" cho views sẽ từ chối mọi lần ghi sau đó vào đơn này. Audit trước, viết validator sau.

Bốn chiến lược

1. Lazy: chuyển khi đọc, hoặc khi ghi. Ứng dụng đọc được cả v1 lẫn v2 và đổi v1 sang v2 trong bộ nhớ (migrate on read). Lần tới document được ghi, nó được ghi bằng hình dạng mới (migrate on write). Lệnh ghi nên tự lọc phiên bản để chạy lại vô hại:

const toV2 = [ { $set: { schemaVersion: 2, "customer.phones": ["$customer.phone"] } },
               { $unset: "customer.phone" } ];
db.orders.updateOne({ _id: 1, schemaVersion: { $exists: false } }, toV2);   // lần 1: modifiedCount: 1
db.orders.updateOne({ _id: 1, schemaVersion: { $exists: false } }, toV2);   // lần 2: matchedCount: 0

Update pipeline đổi cả document trong một lệnh, nên [tài liệu] nó atomic trên document đó: không ai thấy document "nửa v1 nửa v2". Cái bẫy lớn của lazy là query và index không biết gì về hàm chuyển đổi trong code. find({ "customer.phones": "0901234520" }) không bao giờ thấy đơn v1 chưa được đọc lại. Thêm vào đó, đơn không ai đụng tới thì không bao giờ được chuyển, và nhánh v1 trong code sống mãi.

2. Background batch. Một job đi qua collection theo lô nhỏ, chỉ chọn document chưa chuyển, cập nhật bằng cùng pipeline:

while (true) {
  const ids = db.orders.find({ schemaVersion: { $exists: false } }, { _id: 1 })
                       .limit(500).toArray().map(d => d._id);
  if (ids.length === 0) break;
  db.orders.updateMany({ _id: { $in: ids }, schemaVersion: { $exists: false } }, toV2);
  // nghỉ giữa các lô, theo dõi latency và replication lag
}

Trong lab (lô 2 cho dễ xem) nó chuyển 6 document còn lại qua 3 lô rồi dừng. Vì filter tự loại document đã chuyển, job có thể dừng giữa chừng và chạy lại. [tài liệu] updateMany không atomic trên toàn bộ, nên trong lúc job chạy, ứng dụng vẫn phải đọc được cả hai phiên bản. Giá của nó là tải ghi: mỗi document được viết lại cả (phần trên vừa đo: với document to, đó là cả document dirty và cả document ghi xuống đĩa), kéo dữ liệu nguội vào cache (bài 21), và trên replica set mỗi lần ghi phải được replicate (bài 25). Chạy chậm, có điểm dừng, và ngoài giờ cao điểm.

3. Dual-write. Trong giai đoạn chuyển, ứng dụng ghi cả hình dạng cũ lẫn mới (hai field, hoặc hai collection). Lý do: khi rolling deploy, phiên bản app cũ vẫn chạy song song vài phút đến vài ngày và chỉ đọc hình dạng cũ. Trình tự thường gặp là expand → migrate → contract: thêm field mới và ghi cả hai, chuyển dữ liệu cũ, chuyển mọi chỗ đọc sang field mới, rồi mới thôi ghi field cũ. Giá: ghi gấp đôi, và nếu hai bản ghi nằm ở hai document thì chúng có thể lệch khi một lần ghi lỗi; cần một bước đối soát trước khi "contract".

4. Validator staging. Đưa luật mới vào dần, như bài 02 đã đi qua. Lab với luật v2:

const v2 = { $jsonSchema: { bsonType: "object", required: ["schemaVersion", "customer"],
  properties: { schemaVersion: { enum: [2] },
    customer: { bsonType: "object", required: ["phones"], properties: { phones: { bsonType: "array" } } } } } };
db.runCommand({ collMod: "orders", validator: v2, validationLevel: "moderate", validationAction: "warn" });
db.orders.insertOne({ _id: 8, tenantId: "t1", customer: { name: "Khách 8", phone: "0909000080" } });
// { acknowledged: true, insertedId: 8 }       ← hình dạng v1 vẫn được nhận, log của mongod có cảnh báo:
{"s":"W","c":"STORAGE","id":20294,"msg":"Document would fail validation",
 "attr":{"namespace":"lab05.orders","document":{"_id":8,...},"errInfo":{...missingProperties":["schemaVersion"]...}}}
db.runCommand({ collMod: "orders", validationAction: "error" });
db.orders.insertOne({ _id: 9, /* hình dạng v1 */ });                 // 121 Document failed validation
db.orders.updateOne({ _id: 3 }, { $set: { note: "x" } });            // đơn v1 cũ: modifiedCount: 1 (moderate)
db.orders.updateOne({ _id: 1 }, { $unset: { "customer.phones": "" } });
// 121 ... Document failed validation   ← đơn đang hợp lệ thì phải giữ hợp lệ
// ... chạy batch migrate xong, còn 0 document vi phạm ...
db.runCommand({ collMod: "orders", validationLevel: "strict" });

[tài liệu] moderate kiểm tra insert và update trên document đang hợp lệ; document vốn đã sai được update mà không phải qua validator. warn cho thao tác đi qua và ghi vi phạm vào log; error (mặc định) từ chối; errorAndLog (từ 8.1) từ chối và ghi log. [quan sát] Trên 8.3.11, ở moderate + warn, update vào đơn v1 cũ cũng để lại một dòng cảnh báo trong log. Điều đó có ích: log chỉ ra code path nào còn đang đụng tới dữ liệu cũ. Còn đơn _id: 8 cho thấy mặt trái của warn: dữ liệu sai vẫn được lưu, nên phải có batch dọn sau. Mức constraint (đảm bảo mọi document thoả luật, quét dữ liệu cũ khi bật) được tài liệu đánh dấu mới ở 9.0, và không chạy trên 8.3 (bài 02 đã thử).

Ghép lại

Chiến lượcƯuGiáHợp khi
Lazy (đọc/ghi)không có job, không tải đột biếnquery/index không thấy document chưa chuyển; đuôi dài không bao giờ xongfield chỉ được đọc qua _id, không lọc hay sort theo nó
Background batchcó điểm kết thúc rõ ràngtải ghi, cache, replication laggần như mọi thay đổi, chạy có điều tiết
Dual-writeapp cũ và mới cùng chạy đượcghi gấp đôi, có thể lệchrolling deploy, đổi tên field, chuyển collection
Validator stagingdatabase chặn hình dạng cũ quay lạiwarn vẫn để dữ liệu sai lọt vàoluôn dùng, như "cửa chặn" ở cuối

Trong thực tế chúng đi cùng nhau:

1. audit $type                     → biết đang có những hình dạng nào
2. app mới đọc được v1 + v2        → lazy on read
3. app mới ghi v2 (+ dual-write nếu app cũ còn chạy)
4. validator: moderate + warn      → log chỉ ra ai còn ghi v1
5. validationAction: error         → chặn v1 mới
6. background batch                → chuyển nốt đuôi dài
7. validationLevel: strict, xoá nhánh v1 khỏi code

So với PostgreSQL. [tài liệu PostgreSQL] ALTER TABLE ... ADD COLUMN với default không thay đổi theo thời gian chỉ ghi default vào metadata, không viết lại bảng, nên rất nhanh. Nhưng đổi kiểu một cột "thường" viết lại cả bảng và index, có thể tốn gấp đôi chỗ đĩa trong lúc chạy, và các dạng ALTER TABLE này mặc định giữ khoá ACCESS EXCLUSIVE. Vì vậy đội PostgreSQL cũng làm expand → migrate → contract với cột mới và backfill theo lô. Khác biệt chính: PostgreSQL buộc mọi row có cùng cột tại mọi thời điểm, còn MongoDB cho phép hai hình dạng cùng sống, nên việc giữ thứ tự các bước ở trên là trách nhiệm của bạn chứ database không giữ hộ.

Những lỗi thường gặp

  • Lấy cả document khi chỉ cần vài field. Projection không giảm công đọc ở server, nhưng cắt được byte qua mạng và công parse, vốn là phần đắt nhất trong lab (47 trên 50,7 ms).
  • Tin rằng projection làm document to "nhẹ" đi. Cache lạnh thì nó vẫn kéo cả document vào cache. Muốn nhẹ thật thì đổi hình dạng (subset, outlier).
  • Bộ đếm hay trạng thái nhỏ nằm trong document to. Mỗi $inc làm dirty và ghi lại cả document. Đưa phần hay ghi ra document riêng nhỏ.
  • Áp pattern vì nó có tên. Bucket cho dữ liệu hay bị sửa lẻ, outlier khi 30% là "ngoại lệ", computed cho con số ít ai đọc: mỗi cái là một nhánh code phải bảo trì mà không có lợi.
  • Computed không có đối soát. Một code path quên $inc là số lệch vĩnh viễn.
  • Lazy migration rồi query theo field mới. Document chưa chuyển biến mất khỏi kết quả mà không có lỗi.
  • Batch migration không idempotent, không điều tiết. Chạy lại thì hỏng dữ liệu; chạy hết tốc lực thì latency production và replication lag tăng vọt.
  • Bật validator strict + error thẳng lên collection cũ. Mọi update vào document cũ đang sai bị từ chối. Đi qua moderate + warn trước.
  • Coi warn là đủ. Nó chỉ ghi log; dữ liệu sai vẫn được lưu.

Tóm tắt

  • Document to đắt theo năm cách khác nhau: byte qua mạng, parse ở client, chỗ trong cache, công đọc ở server dù có projection, và byte ghi lại khi chỉ đổi một field. Trong lab, 10,9 MB so với 10,8 KB: đọc cả document 50,7 so với 0,15 ms, $inc 1,44 so với 0,13 ms, 11,8 MB cache cho một document.
  • Document to chỉ sai khi thao tác phổ biến nhất cần một phần nhỏ của nó. Subset và outlier tách phần ít dùng ra khỏi đường đi đó.
  • Mỗi pattern là một trao đổi có giá: trả lời được "khi nào không dùng" thì mới nên dùng.
  • Đổi schema là một quy trình, không phải một lệnh: audit, đọc được hai phiên bản, ghi phiên bản mới, validator moderate/warn rồi error, batch, rồi strict.

Tự kiểm tra

  1. Trang danh sách đơn chỉ cần status và total, nhưng mỗi đơn có events 500 KB. Thêm projection có làm working set nhỏ đi không? (Không. Cache vẫn phải chứa cả document. Projection chỉ cắt byte qua mạng và công parse; muốn working set nhỏ phải tách events ra.)
  2. Vì sao $inc một field trên document 10 MB vẫn đắt dù chỉ đổi 4 byte? (Cả document thành dirty trong cache, và page chứa nó được ghi lại ở checkpoint: lab đo ~10,9 MB cho một lệnh.)
  3. 40% sản phẩm vượt ngưỡng 50 follower. Outlier còn hợp không? (Không hẳn. Ngoại lệ đã thành số đông; reference thẳng hoặc subset đơn giản hơn.)
  4. Bạn migrate phone → phones theo kiểu lazy, và có màn hình "tìm đơn theo SĐT". Chuyện gì xảy ra? (Query theo customer.phones bỏ sót đơn chưa chuyển. Phải query cả hai chỗ hoặc chạy batch cho xong.)

Bài tiếp theo

Đến đây Phần 1 đã cho bạn document (02), byte và kiểu (03), cách chọn embed hay reference (04), và những hình dạng có tên cùng cái giá của chúng (05). Suốt bài này ta đã dùng find, projection, $push với $slice, $inc, update pipeline và upsert mà chưa nói kỹ chúng hoạt động ra sao.

Bài 06, CRUD & Query Model, đi vào chính những công cụ đó: toán tử lọc, projection, sort/skip/limit, cursor và getMore, các toán tử update, upsert, findOneAndUpdate, bulkWrite, và vì sao điều kiện trên mảng hay cho kết quả bất ngờ. Bài đó kết thúc bằng một COLLSCAN trên 1 triệu document, lý do để Phần 2 bắt đầu với index.

Tài liệu tham khảo