Data Modeling (P2/3): Denormalization, `$lookup` và thí nghiệm hai mô hình
Ở phần trước: embed giữ dữ liệu liên quan ngay trong một document, còn reference giữ id và đi tra cứu riêng. Chọn bên nào phụ thuộc vào access pattern và số lượng document liên quan.
- Cần đọc trước: Access pattern, embed/reference, cardinality
- Dẫn tới: So với PostgreSQL và lỗi thường gặp
Cái giá của $lookup
Ý chính
$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ằm sẵn trong cache (warm cache). 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 Compound Indexes & ESR.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 $lookup & Joins; vì sao stage có tên EQ_LOOKUP (slot-based execution engine) là chuyện của bài Query Execution Engine.
Bây giờ thêm index trên foreignField. Bài Index Fundamentals 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), chạy warm-up 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]]); // warm-up
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 Index Fundamentals 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. Có thể "9 document rải ở 3 collection" sẽ đắt hơn "1 document" nhiều hơn tỉ lệ 2 lần ở trên, nhưng đây vẫn chỉ là suy luận, chưa được xác nhận: bài về cache đo thử và thấy tỉ lệ dao động 1,6–2,7 lần, khi tăng khi giảm. Chỉ số page là chắc chắn: 3,3 lần page được yêu cầu, 2,2 lần miss.
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 Replica Set & Oplog). 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.
Cột mốc: Bạn đã có thể nêu cái giá của
$lookup, đọc kết quả thí nghiệm hai mô hình, và biết vì sao số đo khi dữ liệu nằm trong cache chưa đủ. Tiếp theo: So với PostgreSQL và lỗi thường gặp.
Hỏi & đáp
Mô hình B, order_items chưa có index trên orderId. Chạy $match đơn 4242 rồi $lookup sang order_items và customers. explain cho totalDocsExamined khoảng bao nhiêu?
Mô hình A chép name của khách vào mọi đơn. Khi khách đổi tên, updateMany({ "customer._id": 777 }, …) chạy p50 khoảng 17,65 ms vì quét 100.000 đơn để sửa khoảng 10 đơn. Cách sửa hợp lý nhất?
"Embed thì dữ liệu bị chép nhiều chỗ, nên luôn tốn dung lượng hơn reference." Lab của bài cho thấy gì?
Đã có index orderId_1, dữ liệu nằm trọn trong cache. Đọc một đơn bằng $lookup (mô hình B) so với findOne (mô hình A) thế nào trên lab?