Schema Patterns (P2/3): Bảy pattern có tên

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

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

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ớ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, 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 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

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?

  1. Giữ trang hay xem trong hồ sơ, chuyển biên bản sang tủ khác kèm ghi chú

    Đây là subset: tách phần ít dùng ra khỏi đường đi của thao tác phổ biến nhất. Document to không sai; nó chỉ đắt khi thao tác phổ biến nhất cần một phần nhỏ của nó. Xem mục "Subset"; chi phí của document to được đo ở Document to tốn gì.

  2. Dặn nhân viên chỉ nhìn trang đầu, không lật phần biên bản

    Đây là projection: bớt phần phải đọc bằng mắt, nhưng vẫn phải rút cả hồ sơ dày ra khỏi tủ mỗi lần. Trong lab, projection vẫn kéo cả document vào cache. Xem Document to tốn gì, điểm 3 trong phần giải thích vì sao chi phí khác nhau.

  3. Tách mỗi trang biên bản thành một hồ sơ riêng để hồ sơ nào cũng mỏng

    Xem số điện thoại thì không cần biên bản, còn khi cần đọc cả xấp thì phải mở 500 hồ sơ. Bài nhắc: nếu ứng dụng thật sự cần cả khối cùng lúc, một lần đọc vẫn rẻ hơn hàng chục nghìn lần đọc nhỏ. Xem mục "Subset", phần "Khi nào không", và phần tóm tắt chi phí ở Document to tốn gì.

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

  1. Bỏ outlier: ngoại lệ đã thành số đông, dùng reference hoặc subset

    Outlier đáng giá khi ngoại lệ thật sự hiếm. Khi một phần lớn document vượt ngưỡng, bạn đang có hai schema chính, và mọi chỗ đọc, ghi, báo cáo đều phải xử lý cả hai. Xem mục "Outlier", phần "Khi nào không".

  2. Giữ outlier: cờ hasExtras vẫn giữ đường đọc của 60% còn lại đơn giản

    60% đơn giản không bù được 40% phải query thêm, cộng logic cập nhật phải kiểm tra cờ và báo cáo phải gộp hai nơi. Bài lấy mốc 30% làm ví dụ cho lúc ngoại lệ không còn là ngoại lệ. Xem mục "Outlier".

  3. Chuyển sang bucket: gom follower theo ngày, mỗi bucket có trần

    Bucket hợp với chuỗi có trục tự nhiên để gom (thời gian, nguồn) và ít bị sửa lẻ. Danh sách follower bị thêm và bỏ từng người, không có trục đó. Xem mục "Bucket", phần "Khi nào không".