Schema Patterns (P2/3): Bảy pattern có tên
Ở phần trước: document 10,9 MB đắt vì thao tác chỉ cần vài field vẫn kéo cả document qua cache và mạng: đọc khoảng 50 ms, còn bản 10,8 KB chỉ khoảng 0,1 ms.
- Cần đọc trước: Document to tốn gì: đo 10 KB, 500 KB và 10 MB
- Dẫn tới: Đổi schema khi hệ thống đang chạy, phần tiếp theo.
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 ở phần trước (Document to tốn gì: đo 10 KB, 500 KB và 10 MB). Reference hết thì mỗi lần mở trang lại thêm một query. Bài Data Modeling 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.
Cấu trúc. Document chính chỉ giữ phần được đọc thường xuyên; 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 Data Modeling 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 ở phần trước (Document to tốn gì: đo 10 KB, 500 KB và 10 MB).
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 Transactions). 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] ở phần trước (Document to tốn gì: đo 10 KB, 500 KB và 10 MB), đọ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 được đọc thường xuyên 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 Data Modeling đo được chậm hơn khoảng 2 lần, có index).
Cấu trúc. Chính là mô hình A của bài Data Modeling: 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 mà bài Data Modeling đã 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í.
Cấu trúc. 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 Aggregation Pipeline).
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 hot document (nhiều client cùng ghi) (write conflict có bài riêng).
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.
Cấu trúc. 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ớiBucket 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, xem bài Aggregation Pipeline). 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 Data Lifecycle đ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 ở phần trước (Document to tốn gì: đo 10 KB, 500 KB và 10 MB).
Cấu trúc. [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 cấu trúc, 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.
Cấu trúc. 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 Specialized Indexes). 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: "cấu trúc khác nhau có chủ đích, có kind" khác hẳn với "cấu trúc khác nhau vì bug" trong bài Document Model.
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.
Cấu trúc. [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 reference | bớ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 |
| Outlier | số đông đơn giản và nhỏ ← code xử lý ngoại lệ |
| Polymorphic | mộ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 |
Cột mốc: Bạn đã kể tên được bảy pattern, nêu cái giá mỗi pattern đổi lấy, và biết khi nào không nên dùng từng cái. Tiếp theo: Đổi schema khi hệ thống đang chạy.
Hỏi & đáp
Một tủ hồ sơ: mỗi hồ sơ khách kẹp cả xấp biên bản 500 trang. Nhân viên mở hồ sơ hàng trăm lần mỗi ngày chỉ để xem số điện thoại. Cách cải thiện nào đúng tinh thần bài?
Bạn dùng outlier cho danh sách follower (embed tới 50, phần dư sang collection riêng). Sau một năm, 40% sản phẩm đã vượt ngưỡng. Nên làm gì?