Schema Patterns (P3/3): Đổi schema khi hệ thống đang chạy
Ở phần trước: bảy pattern đều đổi một thứ lấy một thứ khác. Schema versioning cho phép hai phiên bản cùng sống, nên đổi schema có thể làm mà không cần dừng hệ thống.
- Cần đọc trước: Bảy pattern có tên
- Dẫn tới: CRUD & Query Model, bài tiếp theo. Phần này là phần cuối của bài về schema pattern.
Đổi schema khi hệ thống đang chạy
Trước tiên: biết mình đang có gì
Bài Document Model đã đế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 cấu trúc 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 BSON & ObjectId đã 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 cấu trúc 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: 0Update 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ả (số đo ở phần trước (Document to tốn gì: đo 10 KB, 500 KB và 10 MB): với document to, đó là cả document dirty và cả document ghi xuống đĩa), kéo dữ liệu chưa có trong cache vào cache (bài về cache và working set), và trên replica set mỗi lần ghi phải được replicate (bài Replica Set & Oplog). 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ả cấu trúc 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 cấu trúc 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 Document Model đã đ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 } ← cấu trúc 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, /* cấu trúc 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 dữ liệu 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 Document Model đã thử).
Ghép lại
| Chiến lược | Ưu | Giá | Hợp khi |
|---|---|---|---|
| Lazy (đọc/ghi) | không có job, không tải đột biến | query/index không thấy document chưa chuyển; đuôi dài không bao giờ xong | field chỉ được đọc qua _id, không lọc hay sort theo nó |
| Background batch | có điểm kết thúc rõ ràng | tải ghi, cache, replication lag | gần như mọi thay đổi, chạy có điều tiết |
| Dual-write | app cũ và mới cùng chạy được | ghi gấp đôi, có thể lệch | rolling deploy, đổi tên field, chuyển collection |
| Validator staging | database chặn cấu trúc cũ quay lại | warn vẫn để dữ liệu sai lọt vào | luô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 cấu trúc 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 codeSo 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 cấu trúc 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. Với cold cache thì nó vẫn kéo cả document vào cache. Muốn nhẹ thật thì đổi cấu trúc (subset, outlier).
- Bộ đếm hay trạng thái nhỏ nằm trong document to. Mỗi
$inclàm cả document thành dirty page và phải ghi lại. Đư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
$inclà 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+errorthẳng lên collection cũ. Mọi update vào document cũ đang sai bị từ chối. Đi quamoderate+warntrước. - Coi
warnlà đủ. Nó chỉ ghi log; dữ liệu sai vẫn được lưu.
Cột mốc: Bạn đã có thể chọn một trong bốn chiến lược đổi schema, nêu cái giá của nó, và tránh các lỗi thường gặp khi đổi schema. Bài Schema Design Patterns khép lại ở đây.
Hỏi & đáp
Bạn migrate customer.phone → customer.phones theo kiểu lazy (chuyển khi đọc/ghi). Màn hình "tìm đơn theo SĐT" query find({ "customer.phones": … }). Chuyện gì xảy ra?
Collection orders đang có validator luật v2 với validationLevel: "moderate", validationAction: "warn". App cũ insert một đơn cấu trúc v1. Kết quả?
Bài tiếp theo
Đến đây Phần Nền tảng đã cho bạn document, byte và kiểu, cách chọn embed hay reference, và (trong bài này) những cấu trúc có tên cùng cái giá của chúng. 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.
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 Index & Query bắt đầu với index.
Tài liệu tham khảo
- MongoDB Manual: Schema Design Patterns
- MongoDB Manual: Subset Pattern
- MongoDB Manual: Bucket Pattern
- MongoDB Manual: Outlier Pattern
- MongoDB Manual: Computed Values
- MongoDB Manual: Polymorphic Data
- MongoDB Manual: Schema Versioning (Maintain Versions)
- MongoDB Blog: Building with Patterns: The Extended Reference Pattern
- MongoDB Manual: Specify Validation Level for Existing Documents
- MongoDB Manual: Choose How to Handle Invalid Documents
- MongoDB Manual: db.collection.updateMany()
- MongoDB Manual: Time Series
- PostgreSQL Documentation: TOAST
- PostgreSQL Documentation: ALTER TABLE