Data Modeling: embed hay reference? Thiết kế từ cách dữ liệu được đọc
Bạn đang ở đâu trong series
- Cần biết trước: bài 01 (đường đi của một query, COLLSCAN là mặc định), bài 02 (document lồng nhau, mảng, single-document atomicity, giới hạn 16 MB) và bài 03 (BSON: kiểu dữ liệu, kích thước, ObjectId 12 byte).
- Bài này giới thiệu: thiết kế theo access pattern, embed và reference, relationship cardinality (1-1, 1-few, 1-many, 1-squillions, many-many), denormalization và giá của nó khi update, anti-pattern mảng phình không giới hạn, chi phí
$lookup, và so sánh với PostgreSQL trên cùng một hệ thống đơn hàng. - Dẫn tới: bài 05 (Schema Design Patterns & Schema Evolution): đặt tên cho các hình dạng document hay gặp, thay đổi schema khi hệ thống đang chạy, và document to thì tốn gì.
Bài 02 trả lời câu hỏi "document là gì". Bài này trả lời câu hỏi khó hơn: nên nhét cái gì vào cùng một document, và cái gì nên để riêng?
Vì sao chuyện này quan trọng
Hãy tưởng tượng bạn đang xây backend cho một sàn bán hàng nhiều cửa hàng (multi-tenant). Màn hình được mở nhiều nhất là chi tiết đơn hàng: mã đơn, tên và số điện thoại người mua, 5 dòng hàng, tổng tiền.
Nếu bạn đến từ SQL, phản xạ đầu tiên là vẽ bốn bảng customers, orders, order_items, products rồi JOIN. Mang nguyên phản xạ đó sang MongoDB thì bạn sẽ có bốn collection và một pipeline $lookup. Nó chạy được. Câu hỏi là nó có hợp với cách ứng dụng của bạn đọc và ghi dữ liệu không.
Thiết kế schema trong MongoDB quyết định ba thứ mà về sau rất khó sửa:
- mỗi màn hình phải đọc bao nhiêu document (1 hay 9?),
- một lần sửa dữ liệu phải ghi vào bao nhiêu chỗ (1 hay hàng nghìn?),
- thao tác nào được atomic miễn phí, thao tác nào phải cần transaction.
Index (bài 07) có thể làm một thiết kế tệ chạy nhanh hơn. Nhưng nó không biến 9 lần đọc thành 1 lần đọc. Đó là việc của data modeling.
Mental model: tờ hóa đơn siêu thị
Bạn mua đồ ở siêu thị. Tờ hóa đơn in ra có:
- tên siêu thị, ngày giờ,
- tên thẻ thành viên của bạn (không in cả hồ sơ khách hàng),
- từng món: tên hàng, số lượng, giá tại thời điểm mua,
- tổng tiền.
Tờ hóa đơn không ghi "món số 1: xem sản phẩm #456 trong catalog". Nó chép tên và giá vào luôn. Tuần sau giá tăng, không ai đi in lại hóa đơn cũ. Tờ hóa đơn đúng vì nó là bản chụp.
Ngược lại, hồ sơ thẻ thành viên không kẹp toàn bộ hóa đơn bạn từng mua. Sau 5 năm, xấp giấy đó sẽ dày hơn cả cuốn danh bạ. Hồ sơ chỉ giữ thông tin của bạn. Muốn xem lịch sử, nhân viên tra theo số thẻ.
Ánh xạ sang MongoDB:
Tờ hóa đơn = document Order
Món hàng in sẵn trên hóa đơn = embed (mảng items nằm trong order)
Tên thẻ thành viên in trên bill = reference kèm bản chép vài field
Giá tại thời điểm mua = denormalization có chủ đích (snapshot)
Tra lịch sử theo số thẻ = reference (order giữ customerId)
Không kẹp mọi bill vào hồ sơ = tránh mảng phình không giới hạnĐây chỉ là cách hình dung. Hóa đơn giấy không bao giờ phải cập nhật, còn document thì có. Phần lớn bài này nói về chính cái giá cập nhật đó.
Bắt đầu từ access pattern, không từ thực thể
30 giây
Trong SQL, bạn thường mô hình hóa thực thể trước (khách, đơn, sản phẩm), chuẩn hóa cho hết trùng lặp, rồi mới viết query. Trong MongoDB, bạn làm ngược lại: liệt kê ứng dụng đọc và ghi gì, bao lâu một lần, cùng với cái gì, rồi tạo hình document cho khớp. Tài liệu MongoDB tóm lại thành một câu: dữ liệu được truy cập cùng nhau thì nên được lưu cùng nhau.
Access pattern đơn giản là "một kiểu thao tác mà ứng dụng thực hiện với dữ liệu": màn hình nào, API nào, đọc hay ghi, lọc theo gì, cần những field nào.
Liệt kê access pattern cho hệ thống đơn hàng
Tần suất dưới đây là minh họa, bạn cần thay bằng số thật của hệ thống mình:
| # | Thao tác | Tần suất (minh họa) | Đọc/ghi | Cần gì cùng lúc |
|---|---|---|---|---|
| 1 | Xem chi tiết đơn | ~10.000 lần/phút | đọc | đơn + dòng hàng + tên/SĐT người mua |
| 2 | Tạo đơn | ~500 lần/phút | ghi | đơn + dòng hàng, phải trọn vẹn |
| 3 | Danh sách đơn của một khách | ~1.000 lần/phút | đọc | header đơn, tổng tiền |
| 4 | Khách đổi tên / SĐT | vài lần/ngày | ghi | hồ sơ khách |
| 5 | Báo cáo "top sản phẩm tháng" | 1 lần/giờ, chạy nền | đọc | dòng hàng của mọi đơn |
Từ bảng này, các quyết định gần như tự hiện ra:
- Pattern 1 và 2 chiếm gần hết tải, và cả hai đều đụng "đơn + dòng hàng" như một khối. Đó là lý do mạnh để embed items vào order.
- Pattern 1 cần tên và SĐT người mua, nhưng không cần email, địa chỉ nhà hay lịch sử điểm thưởng. Vậy chỉ copy đúng hai field đó vào order.
- Pattern 4 hiếm. Cái giá phải sửa nhiều chỗ khi khách đổi tên được trả không thường xuyên.
- Pattern 5 đọc dữ liệu theo trục khác (theo sản phẩm, không theo đơn). Nó hiếm và chạy nền, nên chấp nhận chạy chậm hơn, hoặc tính sẵn con số cần báo cáo mỗi khi có đơn mới (cách làm này có tên riêng, bài 05 sẽ nói).
"Ai đọc dữ liệu này, bao lâu một lần, cùng với cái gì?"
│
┌────────────────────┴─────────────────────┐
▼ ▼
Đọc cùng nhau, số lượng có giới hạn, Đọc riêng, số lượng lớn/không giới hạn,
cùng vòng đời với cha hoặc được sửa độc lập, nhiều nơi dùng chung
│ │
▼ ▼
EMBED REFERENCE
✓ 1 document, 1 lần đọc ✓ một nguồn sự thật, sửa 1 chỗ
✓ ghi atomic không cần transaction ✓ document nhỏ, không phình
✗ document to ra, có thể trùng dữ liệu ✗ đọc thêm document ($lookup / query 2)
✗ ghi nhiều document thì không atomicEmbed và reference
Embed: nhét con vào trong cha
// orders — mô hình A (embed)
{
_id: 4242,
tenantId: "t43",
customer: { _id: 6393, name: "Nguyễn Hà", phone: "0910006393" }, // copy 2 field
status: "PAID",
createdAt: ISODate("2026-01-03T22:42:00Z"),
items: [
{ productId: 1951, name: "Sản phẩm 1951", qty: 3, price: 110000 },
{ productId: 1870, name: "Sản phẩm 1870", qty: 3, price: 40000 }
// ... thêm 5 dòng nữa
],
total: 2010000
}Đọc chi tiết đơn là đọc một document qua index _id. Tạo đơn là một lệnh insert. Như bài 02 đã nói, ghi một document là atomic. Vì vậy không thể xảy ra chuyện đơn được tạo mà thiếu một nửa số dòng hàng.
Reference: con đứng riêng, giữ "số thẻ" của cha
// orders — mô hình B (reference)
{ _id: 4242, tenantId: "t43", customerId: 6393, status: "PAID", createdAt: ISODate("...") }
// order_items — mỗi dòng hàng là một document
{ _id: ObjectId("..."), orderId: 4242, tenantId: "t43", productId: 1951, name: "Sản phẩm 1951", qty: 3, price: 110000 }Muốn hiển thị đơn, bạn ghép lại bằng $lookup (join phía server) hoặc bằng hai query từ ứng dụng. Muốn tạo đơn, bạn ghi 1 + N document. Nếu cần "tất cả hoặc không gì cả" thì phải dùng multi-document transaction (bài 16). Bản thân tài liệu MongoDB cũng nhắc rằng transaction thường tốn hơn ghi một document, và không nên dùng nó thay cho thiết kế schema tốt.
Giá của từng lựa chọn
EMBED REFERENCE
├── ✓ đọc 1 document ├── ✓ dữ liệu chung chỉ nằm 1 chỗ
├── ✓ ghi atomic miễn phí ├── ✓ con có thể sống độc lập, query riêng
├── ✗ document lớn hơn ├── ✓ document cha nhỏ, ổn định
├── ✗ dữ liệu copy phải được đồng bộ ├── ✗ mỗi lần đọc phải ghép nhiều document
└── ✗ không hợp khi con tăng mãi └── ✗ cần index ở phía được joinTài liệu MongoDB liệt kê các tín hiệu nên reference: phía con có cardinality cao, dữ liệu nhúng tăng không giới hạn, con được ghi vào những thời điểm khác nhau trong một workload ghi nhiều, hoặc con có thể tồn tại mà không cần cha. Mấy chữ "cardinality cao" và "tăng không giới hạn" cần được nói rõ hơn. Đó là phần tiếp theo.
Relationship cardinality: đếm "bao nhiêu" trước khi chọn
Relationship cardinality (cardinality của một quan hệ) là "một phần tử bên này ứng với bao nhiêu phần tử bên kia". Chữ cardinality còn được dùng theo nghĩa khác: ở bài 14, query cardinality là số document đi qua một stage của query, thứ mà planner phải ước lượng. Hai nghĩa không liên quan nhau; trong bài này, cardinality luôn là cardinality của quan hệ.
Với quan hệ, "one-to-many" trong SQL là một khái niệm quá rộng. Trong MongoDB, nhiều là 5 hay 5 triệu sẽ cho ra hai thiết kế khác hẳn nhau. Cộng đồng MongoDB (bài "6 Rules of Thumb" trên blog MongoDB) chia nhỏ ra như sau:
| Quan hệ | Ví dụ trong hệ thống đơn hàng | Thường chọn | Lý do |
|---|---|---|---|
| 1-1 | đơn ↔ địa chỉ giao hàng | embed | luôn đọc cùng nhau, cùng vòng đời |
| 1-few | đơn ↔ dòng hàng (thường dưới vài chục) | embed mảng | có giới hạn, đọc và ghi cùng đơn |
| 1-many | sản phẩm ↔ review (hàng trăm đến hàng nghìn) | reference, có thể giữ vài review mới nhất trong sản phẩm | review được phân trang, viết vào lúc khác |
| 1-squillions | khách ↔ toàn bộ đơn trong 5 năm; thiết bị ↔ log | reference, đặt ở phía "nhiều" | không có trần, mảng sẽ vỡ |
| many-many | đơn ↔ sản phẩm; sản phẩm ↔ danh mục | mảng id ở phía nhỏ và có giới hạn, hoặc copy snapshot | phải chọn phía nào giữ liên kết |
Vài điểm đáng chú ý:
1-1 không phải lúc nào cũng embed. Nếu một phần của document rất lớn mà hiếm khi đọc (ví dụ mô tả sản phẩm dài vài chục KB), tách nó ra giúp document chính nhỏ lại: giữ phần hay đọc trong document chính, phần ít đọc để riêng. Bài 05 đặt tên và đo cái lợi của cách tách này.
Với 1-squillions, reference phải đặt ở phía "nhiều". Đừng đặt orderIds: [...] trong customer. Hãy đặt customerId trong mỗi order. Tài liệu MongoDB nói đúng điều này với ví dụ nhà xuất bản và sách: nếu số sách của một nhà xuất bản không có giới hạn, giữ mảng id sách trong nhà xuất bản sẽ tạo ra "mutable, growing arrays". Hãy để mỗi cuốn sách giữ publisher_id.
Many-many giữa đơn và sản phẩm là ví dụ thú vị nhất. Trong SQL, order_items chính là bảng nối của quan hệ many-many này. Trong MongoDB, mỗi dòng hàng nhúng trong order giữ productId (reference) cộng một bản chụp tên và giá (copy). Cái bạn embed không phải "sản phẩm" mà là "sản phẩm như lúc được bán". Bản chụp đó không cần đồng bộ với catalog, giống tờ hóa đơn siêu thị.
Anti-pattern: mảng phình không giới hạn
Giả sử bạn nghĩ "khách có nhiều đơn, vậy cho customer một mảng orderIds". Mỗi đơn mới là một lần $push. Mảng này không có trần.
Bài 02 đã chạy thí nghiệm này với mảng comments của một bài viết: kích thước tăng tuyến tính theo số phần tử, và khi chạm 16 MB thì lệnh ghi bị từ chối. Mảng orderIds đi theo đúng con đường đó. Như bài 03 đã cho thấy, mảng trong BSON là một document có key là chỉ số dạng chuỗi ("0", "1", …), nên mỗi ObjectId trong mảng tốn khoảng 19–20 byte chứ không phải 12. Đo bằng bsonsize() trong mongosh trên lab của bài này: 1.000 phần tử khoảng 0,02 MB, 100.000 phần tử khoảng 1,8 MB, 1 triệu phần tử khoảng 19 MB, nghĩa là trần 16 MB bị vượt trước khi tới mốc 1 triệu. Khi chạm trần, mọi lệnh $push tiếp theo đều thất bại, và bạn phải migrate schema dưới áp lực production.
Nhưng 16 MB chỉ là bức tường cuối cùng. Tài liệu MongoDB nói mảng không giới hạn còn "làm căng tài nguyên ứng dụng và giảm hiệu năng index" từ rất lâu trước đó:
- Mỗi lần đọc customer (dù chỉ để lấy tên), server vẫn phải đọc cả document từ storage. Projection chỉ cắt bớt phần gửi qua mạng, không cắt phần phải đọc (trừ trường hợp covered query, nói ở bài 08).
- Nếu bạn đánh index trên field mảng, đó là multikey index: một index key cho mỗi phần tử. Bài 09 sẽ đi sâu vào loại index này.
- Document lớn chiếm nhiều chỗ trong WiredTiger cache. Phần cache đó lẽ ra dành cho dữ liệu nóng khác (bài 21 nói về working set).
Cách sửa: đặt reference ở phía "nhiều". Bỏ orderIds khỏi customer, để mỗi order giữ customerId, và đánh index trên customerId để lấy danh sách đơn của một khách. Mỗi đơn mới là một insert vào orders; document customer không lớn thêm byte nào. Nếu thật sự cần vài đơn gần nhất ngay trong customer, hãy giữ một mảng có trần (ví dụ 10 phần tử, cắt bằng $slice mỗi lần $push) bên cạnh collection orders đầy đủ. Bài 05 sẽ gọi tên cách làm này và tính cái giá ghi hai chỗ của nó.
Denormalization và cái giá khi update
30 giây
Normalization (chuẩn hóa) nghĩa là mỗi sự thật chỉ nằm một chỗ. Denormalization nghĩa là cố ý chép một sự thật ra nhiều chỗ để đọc nhanh hơn. Đọc được lợi, còn ghi phải trả giá: khi sự thật thay đổi, bạn phải đi sửa mọi bản chép.
Khách 6393 đổi tên
NORMALIZED (mô hình B) DENORMALIZED (mô hình A)
customers[6393].name = "..." customers[6393].name = "..."
│ │
▼ ▼
1 document + mọi order có customer._id = 6393
(10 đơn? 10.000 đơn?)Không phải bản chép nào cũng cần đồng bộ
Tài liệu MongoDB chia dữ liệu bị chép thành vài loại, và đây là phần nhiều người bỏ qua:
- Immutable (không đổi):
productId, ngày tạo khách. Chép thoải mái. - Temporal (giá trị theo thời gian, lịch sử có ý nghĩa): giá sản phẩm lúc bán, địa chỉ giao của đơn đó. Bạn không được cập nhật. Sửa giá trong đơn cũ là làm sai sổ sách.
- Sensitive to staleness (cũ một chút là sai): phải cập nhật ngay, có thể trong transaction.
- Not sensitive to staleness (cũ vài phút cũng không sao): cập nhật bằng background job.
Tên người mua in trên đơn thuộc loại nào? Đó là câu hỏi nghiệp vụ, không phải câu hỏi kỹ thuật. Nhiều hệ thống coi tên và SĐT trên đơn là thông tin giao hàng tại thời điểm đặt, tức là temporal, và không bao giờ sửa. Nếu đơn vị vận hành muốn tên mới hiển thị trên đơn cũ, đó là loại "chấp nhận cũ" và có thể đồng bộ dần.
Hai cái bẫy khi đồng bộ bản chép
Bẫy 1: không có index trên field dùng để tìm bản chép. updateMany({"customer._id": 6393}, ...) mà không có index thì phải quét toàn bộ collection orders để tìm khoảng 10 đơn. Phần thí nghiệm bên dưới đo đúng chuyện này.
Bẫy 2: updateMany không atomic trên toàn bộ. Tài liệu MongoDB ghi rõ: mỗi document được ghi atomic, nhưng updateMany() nói chung thì không. Nếu lỗi giữa chừng, các document đã sửa vẫn giữ nguyên, phần còn lại chưa được sửa. Vì vậy lệnh đồng bộ nên idempotent (chạy lại không gây hại, ví dụ $set tên mới) để có thể retry an toàn.
Quy tắc ngón tay cái từ blog MongoDB: hãy xét tỉ lệ đọc/ghi. Field được đọc nhiều mà hiếm khi sửa là ứng viên tốt để denormalize. Field bị sửa liên tục thì công đi tìm và sửa mọi bản chép sẽ nuốt mất cái lợi khi đọc.
Cái giá của $lookup
30 giây
$lookup là một stage trong aggregation pipeline. Với mỗi document đi vào, nó tìm các document khớp ở collection khác và gắn chúng vào thành một mảng mới. Nó giống LEFT OUTER JOIN, nhưng chạy từng document một.
Mental model: đi lấy hàng trong kho
Bạn là nhân viên soạn đơn. Mỗi đơn cần 5 món nằm ở kho bên cạnh.
- Embed: món hàng đã được đóng sẵn trong thùng của đơn. Bạn nhấc thùng lên là xong.
- Reference có sổ tra (index): bạn mở sổ tra "đơn 4242 → kệ B3, ô 1–5", đi thẳng đến đó lấy.
- Reference không có sổ tra: bạn đi dọc mọi kệ trong kho, nhìn từng món xem có ghi "đơn 4242" không. Rồi làm lại y như vậy cho đơn tiếp theo.
for each order đi vào $lookup:
tìm order_items có orderId = order._id
có index → seek thẳng vào vài key
không có → quét cả collection
gắn kết quả vào order.items (mảng)Tài liệu $lookup nói thẳng: phép so khớp bằng nhau với một điều kiện join chạy tốt hơn khi collection bị join có index trên foreignField. Nếu không có index đó, $lookup "nhiều khả năng có hiệu năng kém". Trang anti-pattern "Reduce $lookup Operations" bổ sung: $lookup hữu ích khi dùng thỉnh thoảng, nhưng có thể chậm và tốn tài nguyên hơn so với thao tác chỉ đụng một collection.
Câu hỏi hay hơn là: chậm hơn bao nhiêu, và vì sao? Hãy đo.
Thí nghiệm: một màn hình, hai mô hình
Setup
MongoDB version : 8.3.11 (image mongo:8), standalone
Hardware : Docker trên Apple M4, giới hạn 4 CPU, 4 GB RAM
Configuration : WiredTiger cache 1 GB
Database : lab03
Client : mongosh chạy trong cùng container (không có network thật)Toàn bộ dữ liệu và index (khoảng 125 MB chưa nén) nằm gọn trong cache 1 GB sau lần đọc đầu tiên. Nghĩa là đây là phép đo khi dữ liệu nóng. Khi dữ liệu không vừa cache, mỗi document phải đọc thêm có thể đồng nghĩa với một lần đọc đĩa, và khoảng cách giữa hai mô hình thường lớn hơn. Tôi không đo trường hợp đó ở đây.
Dataset
50 tenant, 10.000 khách, 2.000 sản phẩm, 100.000 đơn. Mỗi đơn có 1–9 dòng hàng (phân bố đều, trung bình 5). Cùng một dữ liệu được ghi theo hai mô hình:
- A (embed):
orders_emb. Mỗi đơn là một document chứaitems[], một bản chépcustomer {_id, name, phone}vàtotaltính sẵn. - B (reference):
orders_ref(header đơn cócustomerId) cộngorder_items(mỗi dòng hàng một document cóorderId) cộngcustomers.
Script tạo dữ liệu dùng PRNG có seed cố định, nên chạy lại sẽ ra đúng bộ dữ liệu này:
// seed.js — chạy: docker exec -i mongo-lab mongosh --quiet --eval "$(cat seed.js)"
db = db.getSiblingDB("lab03");
db.dropDatabase();
let seed = 42; // mulberry32: PRNG có seed cố định
function rnd() {
seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
const pick = n => Math.floor(rnd() * n);
const ho = ["Nguyễn", "Trần", "Lê", "Phạm", "Hoàng", "Võ", "Đặng", "Bùi"];
const ten = ["An", "Bình", "Chi", "Dũng", "Hà", "Khoa", "Linh", "Minh", "Nam", "Thảo"];
const customers = [];
for (let c = 1; c <= 10000; c++) customers.push({
_id: c, tenantId: "t" + String(c % 50).padStart(2, "0"),
name: ho[pick(8)] + " " + ten[pick(10)], email: "user" + c + "@example.com",
phone: "09" + String(10000000 + c), address: { street: c + " Lê Lợi", city: "TP.HCM" }
});
db.customers.insertMany(customers);
const products = [];
for (let p = 1; p <= 2000; p++) products.push({ _id: p, name: "Sản phẩm " + p, price: (1 + pick(50)) * 10000 });
const t0 = new Date("2026-01-01T00:00:00Z").getTime();
let emb = [], ref = [], items = [];
for (let o = 1; o <= 100000; o++) {
const cust = customers[pick(10000)], lines = [];
for (let i = 0, n = 1 + pick(9); i < n; i++) {
const p = products[pick(2000)];
lines.push({ productId: p._id, name: p.name, qty: 1 + pick(3), price: p.price });
}
const total = lines.reduce((s, l) => s + l.qty * l.price, 0);
const createdAt = new Date(t0 + o * 60000), status = ["PAID", "SHIPPED", "DELIVERED"][pick(3)];
emb.push({ _id: o, tenantId: cust.tenantId,
customer: { _id: cust._id, name: cust.name, phone: cust.phone },
status, createdAt, items: lines, total });
ref.push({ _id: o, tenantId: cust.tenantId, customerId: cust._id, status, createdAt });
for (const l of lines) items.push({ orderId: o, tenantId: cust.tenantId, ...l });
if (emb.length === 5000) {
db.orders_emb.insertMany(emb); db.orders_ref.insertMany(ref); db.order_items.insertMany(items);
emb = []; ref = []; items = [];
}
}Kết quả sau khi seed (db.collection.stats()):
customers count: 10000 avgObjSize: 164 B size: 1.6 MB
orders_emb count: 100000 avgObjSize: 511 B size: 48.8 MB
orders_ref count: 100000 avgObjSize: 86 B size: 8.3 MB
order_items count: 497736 avgObjSize: 115 B size: 54.8 MBPhân bố thực tế: mỗi khách có 1–26 đơn (trung bình 10), mỗi tenant có 1.888–2.101 đơn.
Experiment 1: xem chi tiết đơn 4242
Mọi query đều mang tenantId, như một ứng dụng multi-tenant thật phải làm, để tenant này không bao giờ đọc được đơn của tenant khác.
// A: embed
db.orders_emb.findOne({ _id: 4242, tenantId: "t43" })
// B: reference + 2 lần $lookup
db.orders_ref.aggregate([
{ $match: { _id: 4242, tenantId: "t43" } },
{ $lookup: { from: "order_items", localField: "_id", foreignField: "orderId", as: "items" } },
{ $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } }
])Cả hai đều trả về cùng một đơn với 7 dòng hàng. Xem explain("executionStats") (đã rút gọn: in cây stage và các con số chính):
--- A. embed: find
FETCH
IXSCAN _id_
{ nReturned: 1, totalKeysExamined: 1, totalDocsExamined: 1, executionTimeMillis: 0 }--- B. reference: aggregate + 2 x $lookup (order_items CHƯA có index trên orderId)
EQ_LOOKUP from=customers strategy=IndexedLoopJoin index=_id_
EQ_LOOKUP from=order_items strategy=NestedLoopJoin
FETCH
IXSCAN _id_
{
nReturned: 1,
totalKeysExamined: 2,
totalDocsExamined: 497738,
executionTimeMillis: 65,
collectionScans: 1,
indexesUsed: [ '_id_', '_id_' ]
}Đọc từ dưới lên:
IXSCAN _id_→FETCH: tìm đơn 4242 qua index_idrồi lấy document ra.tenantIdđược kiểm tra sau khi fetch, vì đơn này chỉ có index_id. Index ghép{tenantId, ...}là chuyện của bài 08.EQ_LOOKUP from=order_items: không có index trênorder_items.orderId, nên server quét toàn bộ 497.736 dòng hàng để tìm 7 dòng của đơn này (collectionScans: 1). Đó là lý dototalDocsExamined = 497.738(1 đơn + 497.736 dòng hàng + 1 khách).EQ_LOOKUP from=customers ... index=_id_: join sangcustomerstheo_idthì luôn có index, nên chỉ mất 1 key và 1 document.
EQ_LOOKUP là cách explain trên bản 8.3.11 hiển thị $lookup, và trường strategy cho biết server chọn cách join nào. Bài này chỉ cần đọc một điều từ đó: phía bị join có index hay bị quét. Các strategy là gì và server chọn chúng ra sao để dành cho bài 12 ($lookup & Joins); vì sao stage có tên EQ_LOOKUP (slot-based execution engine) là chuyện của bài 15.
Bây giờ thêm index trên foreignField. Bài 07 sẽ giải thích index hoạt động thế nào. Ở đây chỉ cần biết nó là "sổ tra" trong ví dụ cái kho:
db.order_items.createIndex({ orderId: 1 })--- B. reference: aggregate + 2 x $lookup (order_items CÓ index orderId_1)
EQ_LOOKUP from=customers strategy=IndexedLoopJoin index=_id_
EQ_LOOKUP from=order_items strategy=IndexedLoopJoin index=orderId_1
FETCH
IXSCAN _id_
{
nReturned: 1,
totalKeysExamined: 9,
totalDocsExamined: 9,
executionTimeMillis: 1,
collectionScans: 0,
indexesUsed: [ '_id_', 'orderId_1', '_id_' ]
}BEFORE (không index) AFTER (có index orderId_1) EMBED
$match đơn 4242 $match đơn 4242 IXSCAN _id_
↓ ↓ ↓
quét 497.736 order_items seek 7 key orderId 1 document
↓ ↓ ↓
7 dòng khớp 7 document xong
↓ ↓
seek customers _id seek customers _id
↓ ↓
docsExamined = 497.738 docsExamined = 9 docsExamined = 1Đo thời gian qua nhiều lần chạy
Một lần explain không đủ để kết luận. Tôi chạy cùng thao tác trên 2.000 đơn khác nhau (cố định, rải đều trên 100.000 đơn), làm nóng cache trước, và đo từng lần gọi bằng performance.now() trong mongosh:
function bench(label, fn, n) {
for (let i = 0; i < Math.min(n, 50); i++) fn(ids[i], tenantOf[ids[i]]); // làm nóng
const t = [];
for (let i = 0; i < n; i++) {
const a = performance.now(); fn(ids[i], tenantOf[ids[i]]); t.push(performance.now() - a);
}
t.sort((x, y) => x - y);
// in p50, p95, avg
}Container được chạy chung với lab của các bài khác. Tôi chỉ đo khi CPU của container đã về dưới 30% trong 3 lần kiểm tra liên tiếp, và chạy hai vòng; mỗi vòng đo mô hình A và B có index hai lần. Trường hợp không index chỉ chạy 30 đơn vì mỗi lần mất hàng chục ms. Kết quả thật:
=== round 1
B. reference + $lookup (no index) n=30 p50=76.98ms p95=99.53ms avg=79.50ms
A. embed (findOne theo _id) n=2000 p50=0.18ms p95=0.78ms avg=0.30ms
B. reference + $lookup (index) n=2000 p50=0.32ms p95=0.52ms avg=0.35ms
A. embed (findOne theo _id) n=2000 p50=0.15ms p95=0.35ms avg=0.19ms
B. reference + $lookup (index) n=2000 p50=0.29ms p95=0.36ms avg=0.30ms
=== round 2
B. reference + $lookup (no index) n=30 p50=72.23ms p95=78.87ms avg=72.58ms
A. embed (findOne theo _id) n=2000 p50=0.15ms p95=0.23ms avg=0.16ms
B. reference + $lookup (index) n=2000 p50=0.29ms p95=0.37ms avg=0.30ms
A. embed (findOne theo _id) n=2000 p50=0.15ms p95=0.27ms avg=0.17ms
B. reference + $lookup (index) n=2000 p50=0.28ms p95=0.34ms avg=0.29msThời gian này bao gồm cả phần mongosh dựng lệnh và nhận kết quả, không chỉ thời gian server. Hãy so sánh tương đối giữa các dòng, đừng xem đó là con số latency production.
Experiment 2: đọc 1.000 đơn một lượt
Màn hình danh sách, export hay job đồng bộ thường đọc nhiều đơn cùng lúc. Ở đây là 1.000 đơn qua $in trên _id, đều có index:
A embed keys=2000 docs=1000 nReturned=1000
B reference keys=7024 docs=7024 nReturned=1000
(các vòng chạy, p50 của 20 lần mỗi vòng)
A embed p50=6.5ms 9.2ms 7.6ms 6.1ms
B reference p50=15.7ms 16.1ms 15.5ms 16.6msHãy nhìn docsExamined. Mô hình B phải đụng 7.024 document (1.000 đơn + 5.024 dòng hàng + 1.000 khách) để trả về 1.000 kết quả. Mô hình A đụng đúng 1.000. Con số keys=2000 của A đến từ cách engine đếm key khi nhảy giữa các giá trị trong $in. Bài 07 sẽ nói kỹ hơn. Ở đây, docsExamined là con số phản ánh công việc thật.
Interpretation
Ba điều rút ra, theo thứ tự quan trọng:
- Thiếu index ở
foreignFieldmới là thảm họa, không phải bản thân$lookup. Không có index: khoảng 75 ms mỗi đơn, quét gần 500.000 document. Có index: khoảng 0,3 ms. Cùng một mô hình, khác nhau hơn 200 lần (đo trên lab này). Quét gần nửa triệu document cho mỗi đơn sẽ đốt CPU và cache theo số request, nên ở production nó không chỉ chậm một query mà kéo cả hệ thống xuống. - Có index rồi,
$lookupvẫn có giá, nhưng không khủng khiếp. Đọc một đơn: p50 khoảng 0,15–0,18 ms (embed) so với 0,28–0,32 ms (lookup), tức chậm hơn khoảng 2 lần. Đọc 1.000 đơn: khoảng 6–9 ms so với 15–17 ms. Nguồn gốc nằm ở số document phải đụng: 1 so với 9, hay 1.000 so với 7.024. - Con số này là khi mọi thứ nằm trong cache. Khi working set lớn hơn RAM, mỗi document thêm có thể là một lần đọc đĩa. Lúc đó "9 document rải ở 3 collection" sẽ đắt hơn "1 document" nhiều hơn tỉ lệ 2 lần ở trên. Đây là suy luận từ cách storage hoạt động, chưa được đo trong bài này. Bài 21 sẽ đo.
Dung lượng: copy dữ liệu có làm database to ra?
Trực giác bảo embed thì trùng lặp, nên tốn chỗ hơn. Số đo trên dataset này nói ngược lại:
orders_emb docs=100000 data=48.8MB onDisk=13.7MB indexes: _id_ 1.04MB
orders_ref docs=100000 data=8.3MB onDisk=2.5MB indexes: _id_ 1.04MB
order_items docs=497736 data=54.8MB onDisk=14.1MB indexes: _id_ 5.04MB, orderId_1 2.30MB| Mô hình A (embed) | Mô hình B (reference) | |
|---|---|---|
| Dữ liệu chưa nén | 48,8 MB | 63,1 MB (8,3 + 54,8) |
| Trên đĩa (đã nén) | 13,7 MB | 16,6 MB (2,5 + 14,1) |
| Index cần cho màn chi tiết đơn | 1,04 MB | 8,38 MB (1,04 + 5,04 + 2,30) |
Lý do: ở mô hình B, mỗi dòng hàng là một document riêng. Mỗi document đó mang thêm _id ObjectId của riêng nó, lặp lại orderId và tenantId, và cần thêm hai entry index. Gần 500.000 lần như vậy cộng lại còn nhiều hơn phần mô hình A chép tên và SĐT khách. Đừng tổng quát hóa thành "embed luôn nhỏ hơn". Nếu bạn chép một object lớn vào hàng nghìn document, kết quả sẽ ngược lại. Nhưng nó cho thấy "trùng lặp" và "tốn chỗ" không phải là một.
Experiment 3: cái giá của denormalization khi khách đổi tên
Ở mô hình A, tên khách nằm trong mọi đơn của khách đó. Đổi tên một khách (khách 777 có 14 đơn) trông như sau:
// B: sửa 1 chỗ
db.customers.updateOne({ _id: 777 }, { $set: { name: "Trần Thị Mai" } })
// A: sửa mọi bản chép
db.orders_emb.updateMany({ "customer._id": 777 }, { $set: { "customer.name": "Trần Thị Mai" } })explain của lệnh update (qua db.runCommand({ explain: { update: ... }, verbosity: "executionStats" })):
B customers.updateOne matched=1 modified=1
A no index: plan=UPDATE<-COLLSCAN keysExamined=0 docsExamined=100000 nWouldModify=14
A with index: plan=UPDATE<-FETCH keysExamined=14 docsExamined=14 nWouldModify=14Đo trên 20 khách khác nhau cho mỗi trường hợp (vòng chạy khi container yên):
B customers.updateOne (1 chỗ) docs modified/lượt≈1.0 p50=0.32ms max=6.23ms
A orders_emb.updateMany, không index docs modified/lượt≈10.2 p50=17.65ms max=49.63ms
A orders_emb.updateMany, có index docs modified/lượt≈9.9 p50=0.32ms max=1.23msCó index { "customer._id": 1 } thì sửa khoảng 10 bản chép nhanh ngang sửa 1 document. Phần đắt là đi tìm bản chép khi không có index: quét 100.000 đơn để sửa 10 đơn. Nhưng index đó không miễn phí: thêm 0,57 MB, và mỗi lần tạo đơn phải ghi thêm một entry index. Còn nếu một "khách" là một doanh nghiệp B2B có 50.000 đơn, mỗi lần đổi tên sẽ là 50.000 lần ghi document, và mỗi lần ghi đó còn phải được replicate (bài 25). Con số 50.000 ở đây là minh họa, không đo.
Conclusion
Đọc theo đơn là thao tác chủ đạo, nên mô hình A đọc ít document hơn, ghi đơn atomic, và trên dataset này còn nhỏ hơn. Cái giá của nó là phải đồng bộ bản chép tên khách, và cái giá đó chấp nhận được vì khách hiếm khi đổi tên. Nếu đảo ngược tần suất, ví dụ field được copy đổi mỗi phút, kết luận cũng đảo theo.
MongoDB vs PostgreSQL: cùng hệ thống đơn hàng
Bắt đầu từ bài toán, không phải từ database. Cùng năm access pattern ở đầu bài.
PostgreSQL (chuẩn hóa) MongoDB (mô hình A)
customers ──┐ Order
│ customer_id ├── customer {_id, name, phone}
orders ─────┤ ├── items [ {productId, name, qty, price} ]
│ order_id ├── status, createdAt
order_items ┤ └── total
│ product_id
products ───┘ customers, products: collection riêng-- PostgreSQL: màn chi tiết đơn
SELECT o.id, o.status, c.name, c.phone, i.product_id, i.product_name, i.qty, i.unit_price
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items i ON i.order_id = o.id
WHERE o.id = 4242 AND o.tenant_id = 't43';Vài điều đáng chú ý, theo thứ tự quan trọng:
1. PostgreSQL cũng cần index ở phía bị join. Tài liệu PostgreSQL ghi rõ: khai báo foreign key không tự tạo index trên cột tham chiếu. Thiếu index trên order_items(order_id), PostgreSQL cũng phải tìm cách quét bảng hoặc chọn kiểu join khác. Bài học "index ở phía bị join" ở Experiment 1 không phải chuyện riêng của MongoDB.
2. PostgreSQL cũng denormalize. Gần như mọi hệ thống đơn hàng tử tế đều lưu unit_price (và thường cả product_name) trong order_items, chứ không join sang giá hiện tại của products. Lý do giống tờ hóa đơn siêu thị: đó là dữ liệu temporal. Khác biệt nằm ở mức độ: MongoDB đẩy ý tưởng "lưu theo hình dạng khi đọc" đi xa hơn, đến mức gom cả đơn vào một document.
3. Mỗi bên tối ưu cho một kiểu câu hỏi.
| Workload | Mô hình chuẩn hóa (PostgreSQL, hoặc MongoDB mô hình B) | Mô hình document (MongoDB mô hình A) |
|---|---|---|
| Đọc trọn một đơn theo id | nhiều lần index lookup cộng join | ✓ một document |
| Tạo đơn trọn vẹn | transaction nhiều dòng (PostgreSQL làm rất tốt) | ✓ một lần insert, atomic sẵn |
| Khách đổi tên | ✓ sửa 1 dòng | ⚠ sửa mọi bản chép (hoặc quyết định không sửa) |
| Báo cáo "top sản phẩm tháng" | ✓ quét order_items, GROUP BY | ⚠ $unwind items của mọi đơn, hoặc tính sẵn số liệu mỗi khi có đơn mới |
| Câu hỏi ad-hoc theo trục bất kỳ | ✓ JOIN linh hoạt, planner trưởng thành | ⚠ trục nào chưa được thiết kế sẵn thì tốn hơn |
| Đơn có cấu trúc khác nhau theo ngành hàng | ⚠ thêm cột / bảng phụ / JSONB | ✓ schema linh hoạt (kèm $jsonSchema từ bài 02) |
PostgreSQL cũng có JSONB để lưu một đơn thành một giá trị JSON nếu muốn. Ranh giới "SQL thì phải chuẩn hóa" không cứng như người ta hay nghĩ.
Hai thiết kế này không phải một cái tốt, một cái xấu. Chúng đang tối ưu cho những cách sử dụng dữ liệu khác nhau. Mô hình chuẩn hóa tối ưu cho tính đúng và sự linh hoạt khi bạn chưa biết hết câu hỏi tương lai. Mô hình document tối ưu cho vài câu hỏi bạn đã biết là sẽ hỏi hàng triệu lần. Tôi không đo PostgreSQL trong bài này. So sánh hiệu năng định lượng giữa hai hệ để dành cho bài 37 (Anti-patterns & MongoDB vs PostgreSQL), với cùng dữ liệu và cùng điều kiện.
Những lỗi thường gặp
- Dịch thẳng schema SQL sang MongoDB. Mỗi bảng thành một collection, mỗi JOIN thành một
$lookup. Bạn nhận về cái giá của join mà không có lợi ích của document. - Embed mọi thứ "cho nhanh". Comments, logs, lịch sử đơn của khách được nhét vào mảng. Nó chạy tốt ở tuần đầu và vỡ khi dữ liệu thật đổ về.
- Copy field hay thay đổi. Ví dụ copy số lượng tồn kho của sản phẩm vào từng đơn, rồi phải cập nhật hàng nghìn đơn mỗi lần có hàng về.
$lookupmà không có index ởforeignField. Trên lab này, khác biệt là khoảng 75 ms so với 0,3 ms cho mỗi đơn.- Cập nhật bản chép mà không có index để tìm nó.
updateManyquét cả collection để sửa 10 document. - Quên rằng
updateManykhông atomic trên toàn bộ. Lệnh đồng bộ bản chép phải chạy lại được an toàn. - Thiết kế mà không viết access pattern ra giấy. Không có bảng "ai đọc gì, bao nhiêu lần" thì mọi tranh luận embed hay reference chỉ là cảm tính.
Tóm tắt
- Thiết kế bắt đầu từ access pattern: ai đọc, đọc gì cùng nhau, bao lâu một lần, ai ghi và ghi gì.
- Embed khi dữ liệu được đọc cùng, có giới hạn và cùng vòng đời. Reference khi nó tăng không giới hạn, được đọc hoặc sửa độc lập, hoặc dùng chung ở nhiều nơi.
- Relationship cardinality không chỉ là "1-n". Phải hỏi n bằng bao nhiêu. Với 1-squillions, liên kết đặt ở phía "nhiều".
- Denormalization đổi tốc độ đọc lấy công ghi. Phân loại bản chép (immutable, temporal, cần tươi, chấp nhận cũ) trước khi quyết định có đồng bộ hay không.
$lookupcần index ởforeignField. Có index, nó chỉ chậm hơn khoảng 2 lần trên lab này khi dữ liệu nằm trong cache. Không có index, nó quét cả collection cho mỗi document đầu vào.- Mảng phình không giới hạn sửa bằng cách đặt reference ở phía "nhiều"; nếu cần vài phần tử gần nhất trong cha, giữ một mảng có trần.
- PostgreSQL và MongoDB không có bên nào thắng tuyệt đối. Chúng tối ưu cho workload khác nhau.
Bài tiếp theo
Bài này dạy cách chọn: embed hay reference, dựa trên access pattern và cardinality của quan hệ. Trên đường đi, ta đã dùng vài cách làm mà chưa gọi tên: copy 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 phần tử gần nhất. Bài 03 cho biết mỗi field tốn bao nhiêu byte; bài này cho thấy document to dần lên khi bạn embed. Còn ba câu hỏi bài này cố tình để lại:
- Những hình dạng document hay gặp có tên là gì, mỗi cái giải vấn đề nào, tốn gì, và khi nào không nên dùng?
- Khi schema phải đổi trong lúc hệ thống đang chạy (thêm field, đổi kiểu, tách một field thành object), làm sao để document cũ và mới cùng sống được mà không cần downtime?
- Document to thì tốn gì cụ thể: một document 10 KB, 500 KB và 10 MB khác nhau bao nhiêu về latency, chỗ trong cache, byte qua mạng và công deserialize ở client?
Bài 05, Schema Design Patterns & Schema Evolution, trả lời cả ba, và đo câu hỏi cuối bằng thí nghiệm thay vì đoán.
Tài liệu tham khảo
- MongoDB Manual: Data Modeling
- MongoDB Manual: Embedded Data Versus References / Data Modeling Best Practices
- MongoDB Manual: Handle Duplicate Data
- MongoDB Manual: Model One-to-Many Relationships with Document References
- MongoDB Manual: Avoid Unbounded Arrays
- MongoDB Manual: Reduce $lookup Operations
- MongoDB Manual: $lookup (aggregation stage)
- MongoDB Manual: db.collection.updateMany()
- MongoDB Manual: MongoDB Limits and Thresholds
- MongoDB Blog: 6 Rules of Thumb for MongoDB Schema Design
- PostgreSQL Documentation: Constraints (Foreign Keys)