$lookup & Joins: nối dữ liệu giữa các collection và cái giá của từng chuyến tra
Bài Aggregation Pipeline dừng lại ở một trạm đặc biệt của dây chuyền: trạm chạy sang kho bên cạnh lấy thêm thông tin. Đó là $lookup. Ở trong SQL, join là chuyện thường ngày, và planner tự lo phần lớn. Trong MongoDB, $lookup là một stage bạn tự đặt vào pipeline, ở vị trí bạn chọn, với cú pháp bạn chọn. Mỗi lựa chọn đó đổi chi phí, có khi gấp nghìn lần.
Trong lab của bài này, cùng một câu hỏi về 170 đơn hàng có thể đọc 340 document hoặc 17 triệu document. Cùng một báo cáo về khách VIP có thể mất 0,23 giây hoặc 0,52 giây chỉ vì thứ tự $unwind và $match, và một biến thể có $or mất 9 giây hay 0,9 giây tuỳ chỗ đặt điều kiện lọc. Bài này đi tìm lý do, rồi trả lời câu hỏi lớn hơn: khi nào không nên join.
Bài này nằm ở đâu
- Cần biết trước: Data Modeling (embed hay reference, lần đầu đo
$lookup), Index Fundamentals (IXSCAN, FETCH), Query Planner & Plan Cache (đọc explain), Aggregation Pipeline (stage, nửa đầu và nửa sau của pipeline, các luật tối ưu hoá) - Giới thiệu:
$lookupnhư một vòng lặp, cú pháplocalField/foreignFieldvà cú pháp pipeline (let+$expr, và dạng kết hợp từ 5.0), index phía bị nối, ba chiến lược join quan sát được (IndexedLoopJoin, HashJoin, NestedLoopJoin), luật gộp$lookup+$unwind+$match, đặt$lookuptrước hay sau$group/$limit, embed thay cho join, so sánh với nested loop / hash / merge join của PostgreSQL - Dẫn tới: Pagination & Production Query Patterns
Môi trường lab: MongoDB 8.3.11 trong Docker (image
mongo:8, standalone), máy host Apple M4. Bài dùng số đo từ hai môi trường, và mỗi bảng ghi rõ số nào từ đâu:
- lab09: container
mongo-lab-09, databaselab09. Đây là các số đo khi$lookupcòn là một phần của bài Aggregation Pipeline (thí nghiệm 126 đơn, chiến lược HashJoin sangtenants,$lookuptrước/sau$group). Giữ nguyên số và môi trường gốc.- lab12: container
mongo-lab-12, databaselab12, đo mới cho bài này.Cả hai container đều giới hạn 2 CPU, 3 GB RAM, WiredTiger cache 1 GB. Lúc đo, lab của các bài khác chạy song song trên cùng máy host, nên thời gian có dao động. Mỗi phép đo chạy một lần làm nóng rồi lặp lại n lần, bài ghi median của hai lượt. Hãy so sánh các con số với nhau, đừng coi chúng là latency production. Số nào không đo thì ghi là minh hoạ.
Bài dùng bốn nhãn. [tài liệu] là hành vi tài liệu MongoDB mô tả. [quan sát] là thứ đo được trong lab nhưng không phải cam kết của MongoDB. [chi tiết triển khai] là cơ chế bên trong server, nhìn thấy qua explain hay tham số nội bộ, có thể đổi giữa các phiên bản. [hình dung] là mô hình đơn giản hoá để dễ nhớ.
Giải thích trong 30 giây
$lookup lấy từng document đang chạy qua pipeline, tìm các document khớp ở một collection khác, rồi gắn chúng vào một field mảng. Nó giống LEFT OUTER JOIN của SQL, nhưng chạy như một vòng lặp: N document đi vào là N lần tra.
Ba điều quyết định chi phí. Một, mỗi lần tra rẻ hay đắt: có index trên field bị nối thì là một lần seek, không có thì có thể là quét cả collection. Hai, có bao nhiêu lần tra: tức là $lookup đứng ở chỗ nào trong pipeline, trước hay sau khi dòng document đã được lọc và cắt bớt. Ba, cú pháp và các stage xung quanh quyết định engine nào chạy nó, và server có gộp được điều kiện lọc vào trong lần tra hay không.
Hình dung trước: chạy sang kho bên cạnh
Quay lại cái xưởng đóng gói của bài Aggregation Pipeline. Mỗi đơn hàng trên băng chuyền cần ghi thêm "hạng thành viên" của khách. Hồ sơ khách nằm ở kho bên cạnh, 100.000 tập hồ sơ. Có ba cách làm việc:
Cách 1: có sổ tra theo mã khách Cách 2: không có sổ tra
mỗi đơn: mở sổ → đi thẳng tới kệ mỗi đơn: lật cả 100.000 tập hồ sơ
170 đơn = 170 lần mở sổ 170 đơn = 17 triệu lần lật
Cách 3: không có sổ, nhưng chép trước cả kho lên một bảng ghim theo mã khách
chép một lần (đọc 100.000 tập), rồi mỗi đơn chỉ nhìn bảng
tốn chỗ trên tường; kho quá lớn thì không đủ tường để ghimRồi thêm một chuyện nữa: bạn chạy sang kho cho đơn nào? Nếu cuối cùng chỉ cần hạng thành viên cho 20 đơn đứng đầu báo cáo, chạy sang kho cho cả 57.000 đơn là phí.
Cách thứ tư là không phải chạy sang kho chút nào: in sẵn hạng thành viên lên phiếu của đơn từ lúc tạo đơn. Đó là embed.
[hình dung] Chỉ là cách hình dung, nhưng ba cách trên khớp với ba chiến lược join mà explain cho thấy ở phần sau.
Mental model: $lookup là một vòng lặp
[tài liệu] $lookup thực hiện một left outer join sang một collection khác trong cùng database. Với mỗi document đầu vào, nó thêm một field mảng chứa các document khớp. Không có document nào khớp thì mảng rỗng, document vẫn đi tiếp.
for each doc in (dòng document đến $lookup): ← N lần
matches = tìm trong "from" các document có
foreignField == doc.localField ← chi phí mỗi lần: seek index hay quét?
doc[as] = matches ← một mảng mới, có thể lớn
đẩy doc sang stage sauTổng chi phí xấp xỉ N × chi phí một lần tra, cộng chi phí dựng mảng. Hầu hết lời khuyên trong bài này là cách giảm một trong hai thừa số.
Có hai cú pháp:
// 1. Equality match: so khớp bằng nhau giữa một field mỗi bên
{ $lookup: { from: "customers", localField: "customerId", foreignField: "customerId", as: "c" } }
// 2. Pipeline: chạy một sub-pipeline trên "from" cho mỗi document đầu vào
{ $lookup: { from: "customers", let: { cid: "$customerId" },
pipeline: [ { $match: { $expr: { $eq: ["$customerId", "$$cid"] } } } ],
as: "c" } }[tài liệu] Sub-pipeline không đọc thẳng được field của document đầu vào. Muốn dùng, bạn khai báo biến trong let rồi gọi bằng $$tên. Từ MongoDB 5.0 có thêm dạng kết hợp: vừa localField/foreignField, vừa pipeline. Server so khớp bằng nhau trước, rồi chạy pipeline trên các document đã khớp. Tài liệu gọi đây là "concise syntax" cho correlated subquery (subquery tham chiếu field của document bên ngoài).
Setup và dataset (lab12)
MongoDB version : 8.3.11 (Docker image mongo:8, standalone)
Hardware : Apple M4 host; container mongo-lab-12, 2 CPU, 3 GB RAM
Configuration : WiredTiger cache 1 GB (--wiredTigerCacheSizeGB 1)
Dataset : lab12.orders 1.000.000 document, 275,9 MB chưa nén
(avgObjSize 275 byte), 74,6 MB trên đĩa
lab12.customers 100.000 document, 14,0 MB chưa nén (avgObjSize 139 byte)
lab12.tenants 500 document
Indexes : orders { tenantId, status, createdAt }, orders { status, createdAt }
customers: ban đầu chỉ _id_, customerId_1 tạo trong bàiDữ liệu cùng kiểu với bài Aggregation Pipeline: 500 tenant, mỗi tenant 200 khách, 1 triệu đơn rải đều trong 365 ngày trước 2026-10-01, 70% completed. Khách có segment (regular 42.858, silver 28.571, gold 14.285, vip 14.286). Khác lab09 ở một điểm: mỗi đơn mang thêm bản chép nhỏ của khách, customer: { name, segment }, để so sánh embed với join ở cuối bài. Số được sinh từ PRNG có seed cố định, chèn theo lô 10.000 đơn để container 3 GB không hết bộ nhớ.
// trích gen.js (lab12): 100 lần insertMany, mỗi lần 10.000 đơn
docs.push({
tenantId: "t" + String(t).padStart(4, "0"),
customerId: "c" + String(i).padStart(6, "0"), // nối sang customers.customerId
status, createdAt, total, items,
customer: { name: "Khach " + i, segment: segOf(i) } // bản chép, chỉ dùng ở phần embed
});SEPT trong các pipeline là khoảng tháng 9/2026 theo giờ Việt Nam, như bài Aggregation Pipeline:
const SEPT = { $gte: ISODate("2026-08-31T17:00:00Z"), $lt: ISODate("2026-09-30T17:00:00Z") };Hai tập đầu vào được dùng xuyên suốt: tập nhỏ là đơn tháng 9 của tenant t0042 (170 đơn), tập lớn là mọi đơn completed tháng 9 của cả hệ thống (57.815 đơn).
Index phía bị nối: 127 lần tra hay 12,7 triệu document
Thí nghiệm gốc (lab09): doanh thu theo phân khúc khách
Thí nghiệm này đo trong lab09, khi còn nằm trong bài Aggregation Pipeline. Câu hỏi: doanh thu tháng 9 của tenant t0042 theo phân khúc khách.
const L = [
{ $match: { tenantId: "t0042", status: "completed", createdAt: SEPT } }, // 126 đơn
{ $lookup: { from: "customers", localField: "customerId",
foreignField: "customerId", as: "customer" } },
{ $unwind: "$customer" },
{ $group: { _id: "$customer.segment", revenue: { $sum: "$total" }, orders: { $sum: 1 } } }
];lab09: customers.customerId | $lookup stage trong explain | median lượt 1 | median lượt 2 |
|---|---|---|---|
| không có index | totalDocsExamined: 12.700.000, collectionScans: 127 | 2.137 ms | 2.832 ms |
có index customerId_1 | totalDocsExamined: 127, totalKeysExamined: 127, indexesUsed: ['customerId_1'] | 2,2 ms | 2,0 ms |
Không có index: mỗi đơn trong 126 đơn kéo theo một lần quét cả 100.000 khách. Explain đếm 127 lần quét (nhiều hơn số đơn một lần; số gốc của lab09, giữ nguyên) × 100.000 = 12,7 triệu document để trả lời một câu hỏi về 126 đơn. Thêm index thì nhanh hơn khoảng 1.000 lần.
BEFORE (không index) AFTER (index customerId_1)
126 đơn 126 đơn
↓ mỗi đơn: ↓ mỗi đơn:
quét 100.000 customers seek 1 key → 1 document
↓ ↓
12,7 triệu document 127 documentKiểm lại trong lab12
Cùng ý tưởng, cú pháp localField/foreignField, không có $unwind, trên 170 đơn tháng 9 của t0042:
const F1 = [ { $match: { tenantId: "t0042", createdAt: SEPT } },
{ $lookup: { from: "customers", localField: "customerId",
foreignField: "customerId", as: "c" } } ];không index customerId_1 có index customerId_1
EQ_LOOKUP strategy=NestedLoopJoin EQ_LOOKUP customerId_1 strategy=IndexedLoopJoin
FETCH FETCH
IXSCAN tenantId_1_status_1_createdAt_1 IXSCAN tenantId_1_status_1_createdAt_1
keys 175, docs 17.000.170 keys 345, docs 340
collectionScans 170 collectionScans 0
median 2.340 / 2.296 ms median 1,1 / 1,1 msCon số khớp với lab09: 170 lần quét × 100.000 khách + 170 đơn = 17.000.170 document. Có index thì còn 170 key trên orders, 170 key trên customers, mỗi bên 170 document (cộng vài key thừa của IXSCAN). Khoảng 2.000 lần nhanh hơn.
[tài liệu] Trang $lookup nói: 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ị nối có index trên foreignField. Không có index thì $lookup "nhiều khả năng có hiệu năng kém". Đầu trang còn cảnh báo: $lookup không có index, hoặc dạng correlated subquery, trên collection lớn có thể làm chậm query.
Điểm cần nhớ: index cần ở collection bị nối (from), trên field foreignField. Index trên localField của collection đầu vào không giúp gì cho phần tra.
Ba chiến lược join
Explain gọi tên chúng
Khi $lookup chạy bằng slot-based execution engine (SBE), explain hiện stage EQ_LOOKUP kèm trường strategy. Lab12 vừa gặp hai giá trị, NestedLoopJoin và IndexedLoopJoin. Lab09 gặp giá trị thứ ba khi nối 126 đơn sang tenants qua một field không có index (tenantKey):
lab09:
EQ_LOOKUP strategy=HashJoin from=lab09.tenants
FETCH
IXSCAN tenantId_1_status_1_createdAt_1
totalDocsExamined: 626 ← 126 đơn + 500 tenant, đọc tenants đúng MỘT lần
hash_lookup: { usedDisk: false, peakTrackedMemBytes: 58386 }| Strategy | Khi nào (quan sát) | Chi phí | Trong cái xưởng |
|---|---|---|---|
IndexedLoopJoin | có index trên foreignField | mỗi document: một lần seek index | mở sổ tra |
HashJoin | không có index, collection bị nối nhỏ | đọc collection bị nối một lần, dựng bảng băm trong RAM | chép kho lên bảng ghim |
NestedLoopJoin | không có index, collection bị nối lớn | mỗi document: quét cả collection bị nối | lật từng tập hồ sơ |
Tài liệu nói gì, và không nói gì
[tài liệu] Từ 6.0, MongoDB có thể chạy $lookup bằng SBE nếu mọi stage đứng trước nó cũng chạy được bằng SBE, và không rơi vào các trường hợp sau: $lookup chạy một pipeline trên collection bị nối; localField/foreignField có thành phần số (như "reviews.0.score"); from là view hoặc sharded collection. Khi đó explain có EQ_LOOKUP ("equality lookup").
[chi tiết triển khai] Trang $lookup, trang tối ưu hoá pipeline và trang giải thích explain không mô tả ba strategy này. Tên của chúng chỉ xuất hiện trong tài liệu dưới dạng bộ đếm metrics.query.lookup của serverStatus (hashLookup, indexedLoopJoin, nestedLoopJoin), được ghi là "chủ yếu dành cho MongoDB dùng nội bộ". Khi nào server chọn strategy nào thì tài liệu không nói. Trong lab có hai tham số nội bộ internalQueryCollectionMaxNoOfDocumentsToChooseHashJoin (10.000 document) và internalQueryCollectionMaxDataSizeBytesToChooseHashJoin (100 MiB). Chúng cho thấy server chỉ cân nhắc HashJoin khi collection bị nối dưới các ngưỡng đó. customers có 100.000 document, vượt ngưỡng, nên rơi vào nested loop. Tên, ngưỡng và cách chọn đều có thể đổi giữa các phiên bản. Vì sao $lookup lại có lúc chạy bằng SBE, lúc bằng classic engine, và SBE dựng plan ra sao, là chuyện của bài Query Execution Engine.
Thí nghiệm: nếu customers được phép dùng HashJoin
Để thấy HashJoin rẻ đến đâu, tôi tạm nâng ngưỡng số document lên 200.000 (chỉ trong lab, xoá index customerId_1 trước), rồi chạy lại cùng pipeline với hai tập đầu vào:
EQ_LOOKUP strategy=HashJoin from=lab12.customers
hash_lookup: totalDocsExamined 157.815 (57.815 đơn + 100.000 khách, đọc customers MỘT lần)
collectionScans 1, usedDisk false, peakTrackedMemBytes 14.780.320lab12, $lookup sang customers (kèm $count) | 170 đơn (t0042) | 57.815 đơn (cả hệ thống) |
|---|---|---|
| NestedLoopJoin (không index, ngưỡng mặc định) | 2.340 / 2.296 ms | không đo (≈ 5,8 tỷ document) |
| HashJoin (không index, ngưỡng nâng lên 200.000) | 45 / 44 ms | 166 / 167 ms |
IndexedLoopJoin (index customerId_1) | 1,1 / 1,1 ms | 189 / 185 ms |
Hai điều đáng chú ý:
- Với đầu vào nhỏ, index thắng tuyệt đối. 170 đơn chỉ cần 170 lần seek. HashJoin phải đọc cả 100.000 khách để dựng bảng băm, dù chỉ dùng 170 dòng.
- Với đầu vào lớn, HashJoin không index lại ngang, thậm chí nhỉnh hơn, IndexedLoopJoin. 57.815 lần seek index không rẻ hơn một lần đọc 100.000 document rồi tra trong bảng băm 14,8 MB. MongoDB 8.3.11 chọn theo ngưỡng kích thước collection bị nối, không theo số document đầu vào.
Đừng làm thế trên production. Tham số
internal*là công cụ cho thí nghiệm. Thí nghiệm này chỉ để hiểu cái giá của từng strategy. Cách đúng vẫn là: có index trênforeignField, và giảm số document đi vào$lookup.
Hai cú pháp, hai engine
Cùng câu hỏi, ba cách viết
Có index customerId_1. Ba pipeline sau trả về cùng kết quả (dạng 3 chỉ giữ hai field của khách):
// F1: equality match
{ $lookup: { from: "customers", localField: "customerId", foreignField: "customerId", as: "c" } }
// F2: pipeline + let + $expr
{ $lookup: { from: "customers", let: { cid: "$customerId" },
pipeline: [ { $match: { $expr: { $eq: ["$customerId", "$$cid"] } } } ], as: "c" } }
// F3: dạng kết hợp (5.0+): equality match + pipeline cho phần còn lại
{ $lookup: { from: "customers", localField: "customerId", foreignField: "customerId",
pipeline: [ { $project: { _id: 0, name: 1, segment: 1 } } ], as: "c" } }Explain trên tập nhỏ (170 đơn):
F1 EQ_LOOKUP customerId_1 strategy=IndexedLoopJoin ← SBE, nằm trong nửa đầu
keys 345, docs 340
F2 stages[0] $cursor FETCH ← IXSCAN tenantId_1_status_1_createdAt_1 docs 170
stages[1] $lookup totalKeysExamined 170, totalDocsExamined 170, ← classic engine
collectionScans 0, indexesUsed ['customerId_1']
F3 giống F2: stages[1] $lookup, indexesUsed ['customerId_1'], keys 170, docs 170Cả ba đều dùng index phía bị nối. [tài liệu] Trong cú pháp pipeline, các toán tử $eq, $lt, $lte, $gt, $gte đặt trong $expr dùng được index của collection from, với ba giới hạn: chỉ so field với hằng số (biến let phải ra một giá trị cụ thể cho mỗi lần tra), không dùng index khi biến let rỗng hoặc thiếu, và không dùng index multikey, partial hay sparse.
Điểm khác là engine. F1 hiện thành EQ_LOOKUP bên trong nửa đầu, tức SBE. F2 và F3 hiện thành stage $lookup riêng ở nửa sau, tức classic engine. Đúng như tài liệu: $lookup chạy một pipeline trên collection bị nối thì không chạy bằng SBE. Dạng kết hợp F3 cũng có pipeline, nên cũng rơi vào classic.
lab12, có index customerId_1 (kèm $count) | Engine | 170 đơn | 57.815 đơn |
|---|---|---|---|
F1 localField/foreignField | SBE (EQ_LOOKUP) | 1,1 / 1,0 ms | 189 / 185 ms |
F2 let + $expr | classic | 2,1 / 2,1 ms | 607 / 623 ms |
F3 kết hợp + $project | classic | 2,3 / 2,3 ms | 769 / 674 ms |
[quan sát] Cùng số key và document, F2 và F3 chậm hơn F1 khoảng 3–4 lần trên tập lớn. Khác biệt nằm ở chi phí cho mỗi lần tra: classic engine dựng và chạy một sub-pipeline cho từng document đầu vào, SBE chạy một vòng lặp đã được biên dịch sẵn. Với 57.815 đơn, phần chênh là 0,4 giây.
Khi cú pháp pipeline làm mất index
Giới hạn "so field với hằng số" dễ bị vi phạm mà không biết. Giả sử ai đó muốn so khớp không phân biệt hoa thường:
pipeline: [ { $match: { $expr: { $eq: [ { $toLower: "$customerId" }, "$$cid" ] } } } ]stages[1] $lookup totalDocsExamined 17.000.000, collectionScans 170, indexesUsed []Phía field của customers giờ là một biểu thức, không còn là field trần, nên index customerId_1 vô dụng. 170 đơn lại thành 170 lần quét cả collection, y như không có index. Muốn so khớp không phân biệt hoa thường thì chuẩn hoá dữ liệu lúc ghi: lưu sẵn một field đã toLower và join trên field đó.
Chọn cú pháp nào
Chỉ cần so khớp bằng nhau một field
└── localField / foreignField ✓ SBE, rẻ nhất mỗi lần tra
Cần thêm điều kiện, projection, sort/limit trên phía bị nối
└── localField / foreignField + pipeline (5.0+) ⚠ classic, vẫn dùng index cho phần bằng nhau
└── giữ pipeline ngắn: $match trên field trần, $project, $limit
Điều kiện không phải bằng nhau (khoảng thời gian, so sánh nhiều field)
└── let + pipeline + $expr ⚠ classic; chỉ $eq/$lt/$lte/$gt/$gte
trên field trần mới dùng index
Sub-pipeline không phụ thuộc document đầu vào (uncorrelated)
└── pipeline không có let ✓ tài liệu: chạy một lần rồi cacheDạng cuối đáng nhắc riêng. [tài liệu] Với uncorrelated subquery, mọi document đầu vào nhận cùng một kết quả, nên MongoDB chỉ cần chạy sub-pipeline một lần rồi cache lại. Ví dụ gắn "bảng giá hiện hành" vào mọi đơn: một lần đọc, không phải N lần.
$lookup → $unwind → $match: thứ tự bạn viết và thứ tự server chạy
Câu hỏi
"Doanh thu tháng 9 từ khách VIP, toàn hệ thống." Đơn nằm ở orders, phân khúc nằm ở customers. Cách viết tự nhiên nhất với người quen SQL là nối trước, lọc sau:
const L = { $lookup: { from: "customers", localField: "customerId", foreignField: "customerId", as: "c" } };
const U = { $unwind: "$c" };
const G = { $group: { _id: null, revenue: { $sum: "$total" }, n: { $sum: 1 } } };
// N1: viết "kiểu SQL": join hết, rồi WHERE
const N1 = [ L, U, { $match: { status: "completed", createdAt: SEPT, "c.segment": "vip" } }, G ];Đọc nguyên văn, N1 là 1 triệu lần tra customers rồi mới lọc ra 8.345 đơn. Explain cho thấy server chạy một pipeline khác hẳn:
stages[0] $cursor IXSCAN status_1_createdAt_1 → FETCH keys 57.815, docs 57.815
stages[1] $lookup { from: 'customers', localField: 'customerId', foreignField: 'customerId',
pipeline: [ { $match: { segment: { $eq: 'vip' } } } ],
unwinding: { preserveNullAndEmptyArrays: false } }
totalKeysExamined 57.816, totalDocsExamined 57.816, nReturned 8.345
stages[2] $groupHai việc đã xảy ra:
- Điều kiện trên field của
ordersđược kéo lên đầu.statusvàcreatedAtkhông phụ thuộcc, nên được tách khỏi$matchvà đưa vào nửa đầu, dùng index{status, createdAt}. Chỉ 57.815 đơn đi vào$lookup, không phải 1 triệu. $unwindvà phần còn lại của$matchđược gộp vào$lookup. Trườngunwindingthay cho stage$unwind, vàc.segment: "vip"thànhpipeline: [{ $match: { segment: "vip" } }]chạy ngay trong lần tra. Không có mảngcnào được dựng ra rồi bỏ đi.
[tài liệu] Việc thứ hai là luật "$lookup + $unwind + $match coalescence" trên trang tối ưu hoá: khi $unwind đứng ngay sau $lookup và tách đúng field as, optimizer gộp nó vào $lookup. Nếu sau đó là $match trên field con của as, $match cũng được gộp. Trường unwinding trong explain "khác với stage $unwind", nó cho thấy cách pipeline được tối ưu bên trong.
[quan sát] Việc thứ nhất thì trang tối ưu hoá mà tôi đọc không liệt kê cho 8.3. Luật duy nhất trên trang đó đưa $match lên trước $lookup là luật "computed field + $match", ghi rõ "new in 9.0". Nhưng 8.3.11 trong lab vẫn tách và kéo phần điều kiện trên field gốc lên trước $lookup. Đừng dựa vào nó: tự đặt $match trên field gốc ở đầu pipeline thì rõ ràng hơn và không phụ thuộc phiên bản.
Khi optimizer không cứu được
Thay đổi nhỏ trong cách viết có thể làm vỡ cả hai việc trên. Sáu cách viết nữa. N2, N3, N11, N4 trả đúng kết quả của N1 (8.345 đơn). N9 và N10 là một câu hỏi rộng hơn, trả cùng kết quả với nhau (10.838 đơn). Kết quả đã được so trùng khớp trong lab12.
// N2: thứ tự "đúng sách": lọc field gốc trước, rồi join, unwind, lọc field bị nối
[ M, L, U, { $match: { "c.segment": "vip" } }, G ] // M = { $match: { status: "completed", createdAt: SEPT } }
// N3: $match trên mảng, TRƯỚC $unwind
[ M, L, { $match: { "c.segment": "vip" } }, U, G ]
// N11: như N3 nhưng bỏ hẳn $unwind ($group không cần field nào của c)
[ M, L, { $match: { "c.segment": "vip" } }, G ]
// N4: chen một $set giữa $unwind và $match
[ M, L, U, { $set: { seg: "$c.segment" } }, { $match: { seg: "vip" } }, G ]
// N9: điều kiện viết dưới dạng $or, trộn field gốc và field bị nối
// (đơn completed của khách VIP, HOẶC đơn tháng 9 trên 5 triệu)
[ L, U, { $match: { $or: [ { status: "completed", createdAt: SEPT, "c.segment": "vip" },
{ createdAt: SEPT, total: { $gte: 5000000 } } ] } }, G ]
// N10: cùng câu hỏi với N9, tự rút phần chung (createdAt) ra trước $lookup
[ { $match: { createdAt: SEPT } }, L, U,
{ $match: { $or: [ { status: "completed", "c.segment": "vip" }, { total: { $gte: 5000000 } } ] } }, G ]Explain tóm tắt:
N2 giống hệt N1: $cursor 57.815 → $lookup (unwinding + pipeline $match) → $group
N3 toàn bộ $lookup nằm trong $cursor (SBE: nlj + ixseek customerId_1)
stages[1] $match c.segment stages[2] $unwind stages[3] $group
N11 như N3, không có $unwind
N4 $lookup { unwinding } nReturned 57.815 ← $match không gộp được, vì $set chen giữa
$set → $match seg nReturned 8.345
N9 $cursor COLLSCAN nReturned 1.000.000 ← $or không tách được → 1 triệu lần tra
$lookup { unwinding } → $match { $or ... }
N10 $cursor COLLSCAN nReturned 82.358 ← chỉ 82.358 đơn tháng 9 đi vào $lookup
$lookup { unwinding } → $match { $or ... }lab12, index customerId_1 | Đơn vào $lookup | Engine của $lookup | median lượt 1 | median lượt 2 |
|---|---|---|---|---|
| N1 viết "kiểu SQL" | 57.815 (optimizer kéo $match lên) | classic | 520 ms | 531 ms |
| N2 "đúng sách" | 57.815 | classic | 522 ms | 532 ms |
N4 $set chen giữa | 57.815 | classic | 508 ms | 551 ms |
N3 $match trước $unwind | 57.815 | SBE | 229 ms | 237 ms |
N11 bỏ $unwind | 57.815 | SBE | 252 ms | 256 ms |
N9 $or trộn field | 1.000.000 | classic | 8.880 ms | 9.096 ms |
N10 $or, rút createdAt ra trước | 82.358 | classic | 882 ms | 982 ms |
Đọc bảng theo ba nhóm:
N1 = N2. Optimizer đã viết lại N1 thành N2.
N9 so với N10: số document đi vào $lookup vẫn là thứ quyết định. Khi điều kiện là $or trộn field gốc với field bị nối, optimizer không tách được phần nào ra trước $lookup (nó không tự rút createdAt chung của hai nhánh ra ngoài). Kết quả: COLLSCAN và 1 triệu lần tra, gần 9 giây. Tự rút phần chung ra một $match riêng ở đầu thì còn 82.358 lần tra, nhanh hơn khoảng 10 lần. Vẫn COLLSCAN, vì không có index nào bắt đầu bằng createdAt. Index như vậy sẽ giảm tiếp phần đọc orders, nhưng đó là câu chuyện của bài Compound Indexes & ESR.
N3, N11 so với N2: điều bất ngờ. Luật gộp được thiết kế để tránh dựng mảng trung gian, và nó làm đúng việc đó. Nhưng trong lab này, bản không được gộp (N3, N11) lại nhanh hơn khoảng 2 lần. [quan sát] Explain cho lời giải: ở N3 và N11, $lookup nằm trong $cursor dưới dạng plan SBE (nlj + ixseek customerId_1). Ở N1, N2, N4, $lookup là stage classic riêng. Khi $match được gộp vào, $lookup có một pipeline bên trong, và theo tài liệu thì $lookup có pipeline không chạy bằng SBE. Với N4 (chỉ có unwinding, không có pipeline), $lookup cũng chạy bằng classic. Lab09 cũng thấy vậy: $lookup + $unwind chạy classic, bỏ $unwind thì thành EQ_LOOKUP. Tài liệu 8.3 không liệt kê $unwind trong các điều kiện loại SBE, nên phần này là quan sát trên 8.3.11, chưa phải quy tắc.
Quy tắc rút ra không phải "đừng $unwind sau $lookup", mà là đo bằng explain. Xem $lookup nằm trong $cursor (SBE) hay là một stage riêng, và xem có bao nhiêu document đi vào nó. Ở phiên bản khác, kết quả N2 so với N3 có thể đảo ngược: serverStatus của 9.0 đã có thêm nhóm bộ đếm metrics.query.lookupUnwind cho $lookup + $unwind chạy bằng SBE. Còn điều N9 dạy thì không đổi giữa các phiên bản: số lần tra là thừa số lớn nhất.
Thứ tự nên viết
1. $match trên field gốc, đặt ĐẦU pipeline → dùng index của collection đầu vào,
ít document đi vào $lookup
2. $group / $sort + $limit nếu báo cáo chỉ cần → càng ít lần tra càng tốt
kết quả đã gom hoặc top-n
3. $lookup với localField / foreignField → index trên foreignField
4. điều kiện trên field bị nối: ngay sau $lookup → gộp được, hoặc chạy trên mảng
(hoặc $unwind rồi $match ngay, không chen $set)
5. $project / $set định hình kết quả ở CUỐIBước 2 là phần lớn nhất, đo ở phần tiếp theo.
Đặt $lookup ở đâu: trước hay sau $group và $limit
Thí nghiệm này đo trong lab09. Báo cáo "top 20 tenant theo doanh thu tháng 9, kèm tên và gói dịch vụ". Tên nằm ở tenants. M là $match đơn completed tháng 9 của cả hệ thống (57.464 đơn trong lab09). Có hai cách viết:
// BEFORE: nối từng đơn, rồi mới gom
[ M, { $lookup: { from: "tenants", localField: "tenantId", foreignField: "_id", as: "t" } },
{ $unwind: "$t" },
{ $group: { _id: { tenantId: "$tenantId", name: "$t.name", plan: "$t.plan" }, revenue: { $sum: "$total" } } },
{ $sort: { revenue: -1 } }, { $limit: 20 } ]
// AFTER: gom trước, cắt top 20, rồi mới nối
[ M, { $group: { _id: "$tenantId", revenue: { $sum: "$total" } } },
{ $sort: { revenue: -1 } }, { $limit: 20 },
{ $lookup: { from: "tenants", localField: "_id", foreignField: "_id", as: "t" } },
{ $unwind: "$t" }, { $project: { revenue: 1, name: "$t.name", plan: "$t.plan" } } ]Cả hai đều join qua _id (luôn có index), nên mỗi lần tra đều rẻ. Khác nhau là số lần tra:
| lab09 | số lần $lookup tra | median lượt 1 | median lượt 2 |
|---|---|---|---|
| BEFORE | 57.464 | 494 ms | 428 ms |
AFTER, $lookup sau $sort+$limit | 20 | 22,8 ms | 21,7 ms |
AFTER, $lookup trước $sort+$limit | 500 | 28,5 ms | 21,9 ms |
Nhanh hơn khoảng 20 lần, và không cần index mới. Có thêm một lợi ích: ở bản AFTER, $group nằm ngay sau $match nên được đẩy xuống SBE và dùng index phủ (PROJECTION_COVERED, 0 document). Ở bản BEFORE, $lookup + $unwind chen vào giữa nên $group chạy ở nửa sau.
BEFORE AFTER
57.464 đơn 57.464 đơn
↓ $lookup × 57.464 ↓ $group (SBE, index phủ)
↓ $group → 500 500 tenant
↓ $sort + $limit ↓ $sort + $limit
20 dòng 20 tenant
↓ $lookup × 20
20 dòngNguyên tắc: $lookup tốn công theo số document đi qua nó, nên hãy đặt nó ở chỗ dòng document hẹp nhất. Phần lớn trường hợp là sau $match, sau $group, sau $limit. Optimizer sẽ không làm việc này hộ bạn: trang tối ưu hoá không có luật nào chuyển $lookup ra sau $group, và cũng không thể có, vì hai cách viết chỉ cho cùng kết quả khi bạn biết name và plan phụ thuộc hoàn toàn vào tenantId. Server không biết điều đó.
Khi nào nên embed thay vì join
Đo lại cùng câu hỏi VIP
Lab12 có sẵn bản chép customer.segment trong mỗi đơn. Câu hỏi "doanh thu tháng 9 từ khách VIP" giờ không cần $lookup:
// N5: embed (extended reference)
[ { $match: { status: "completed", createdAt: SEPT, "customer.segment": "vip" } },
{ $group: { _id: null, revenue: { $sum: "$total" }, n: { $sum: 1 } } } ]GROUP ← cả pipeline chạy bằng SBE
FETCH
IXSCAN status_1_createdAt_1
keys 57.815, docs 57.815 ← không đọc document nào của customers| lab12, cùng kết quả (8.345 đơn) | Đọc gì | median lượt 1 | median lượt 2 |
|---|---|---|---|
N5 embed customer.segment | 57.815 đơn | 68 ms | 58 ms |
| N3 join tốt nhất (SBE) | 57.815 đơn + 57.815 khách | 229 ms | 237 ms |
| N2 join "đúng sách" (classic) | 57.815 đơn + 57.816 khách | 522 ms | 532 ms |
Embed nhanh hơn join tốt nhất khoảng 3–4 lần, và không cần index nào trên customers. Bỏ một collection khỏi đường đọc là bỏ một nửa số document phải đụng.
Cái giá, và khi nào chấp nhận được
Bài Data Modeling đã đặt câu hỏi gốc: dữ liệu được đọc cùng nhau thì nên nằm cùng nhau. Bài Schema Design Patterns gọi cách làm ở N5 là extended reference: giữ customerId để tham chiếu, chép thêm vài field hay đọc. Cái giá:
Chép segment vào mỗi đơn
│
├── ✓ báo cáo theo segment không cần $lookup: 58–68 ms so với 229–532 ms
├── ✓ không phụ thuộc engine, luật gộp hay vị trí $lookup
├── ✗ khách đổi segment → phải quyết định: sửa mọi đơn cũ, hay giữ giá trị lúc đặt hàng?
├── ✗ mỗi đơn lớn thêm vài chục byte
└── ⚠ chỉ chép field ít đổi và hay đọc; chép cả hồ sơ khách là saiGạch đầu dòng thứ ba thường là câu hỏi nghiệp vụ, không phải kỹ thuật. "Doanh thu từ khách VIP" nghĩa là khách đang VIP, hay khách lúc đặt đơn là VIP? Nếu là lúc đặt đơn, bản chép không phải là dữ liệu trùng lặp, mà là dữ liệu đúng. Join sang customers khi đó còn cho kết quả sai.
Một bảng quyết định nhanh:
| Tình huống | Nên |
|---|---|
| Màn hình đọc thường xuyên, cần vài field ít đổi của bên kia | embed / extended reference |
| Field bên kia đổi thường xuyên và phải luôn mới nhất | $lookup có index |
| Báo cáo định kỳ, chấp nhận trễ | $lookup trong job, ghi kết quả bằng $merge (bài Aggregation Pipeline) |
| Cần thông tin bên kia cho top-n hay kết quả đã gom | $lookup sau $group/$limit, chỉ vài chục lần tra |
| Quan hệ many-to-many lớn, đọc theo cả hai chiều | reference + index hai phía; cân nhắc lại access pattern |
So với PostgreSQL
Ba chiến lược join của PostgreSQL
[tài liệu PostgreSQL] Chương về planner/optimizer của PostgreSQL (mục 51.5.1, bản 18) mô tả ba chiến lược join:
- Nested loop join: quét bảng bên phải một lần cho mỗi dòng của bảng bên trái. Dễ làm nhưng có thể rất tốn thời gian. Nếu bảng bên phải quét được bằng index scan, dùng giá trị của dòng bên trái làm khoá, thì đây có thể là chiến lược tốt.
- Merge join: sắp cả hai bảng theo khoá join (bằng một bước sort, hoặc đọc theo index trên khoá join), rồi quét song song hai bên. Mỗi bảng chỉ quét một lần.
- Hash join: quét bảng bên phải, nạp vào bảng băm theo khoá join, rồi quét bảng bên trái và tra từng dòng trong bảng băm.
Planner sinh mọi plan khả dĩ cho mỗi cặp join và chọn plan có chi phí ước lượng thấp nhất.
MongoDB 8.3.11 $lookup | PostgreSQL | |
|---|---|---|
| Nested loop có index | IndexedLoopJoin (quan sát) | nested loop + index scan bên trong |
| Nested loop không index | NestedLoopJoin: quét cả collection cho mỗi document | nested loop + seq scan, chỉ được chọn khi ước lượng thấy rẻ nhất |
| Hash join | HashJoin, chỉ khi collection bị nối dưới ngưỡng (quan sát: 10.000 document, 100 MiB) | chọn theo chi phí, kể cả khi bảng lớn; bộ nhớ cho bảng băm giới hạn bởi work_mem × hash_mem_multiplier (mặc định 2,0) |
| Merge join | không thấy trong lab | có |
| Ai chọn chiến lược | luật theo index và kích thước collection bị nối | cost model dựa trên thống kê |
Ai chọn thứ tự join và vị trí join so với GROUP BY | bạn, bằng thứ tự stage | planner (trong giới hạn của ngữ nghĩa SQL) |
Hai khác biệt đáng nhớ nhất:
- Trong PostgreSQL, viết
JOINtrước hay sau lọc gần như không đổi plan, vì planner tự đẩy điều kiện xuống và tự chọn thứ tự. Trong MongoDB, như N9 và thí nghiệm$group/$limitở trên, thứ tự bạn viết là một phần của plan. Optimizer chỉ sửa được một số trường hợp an toàn. - PostgreSQL chọn hash join cho đầu vào lớn khi nó rẻ hơn. Thí nghiệm HashJoin ở trên cho thấy với 57.815 đơn, hash join không index nhanh ngang nested loop có index. PostgreSQL sẽ tự cân nhắc điều này. MongoDB 8.3.11 thì không, nên ở MongoDB, index trên
foreignFieldgần như luôn là điều kiện cần.
Điểm chung: cả hai đều cần index ở phía bị nối cho nested loop. Bài Data Modeling đã nhắc rằng PostgreSQL cũng không tự tạo index cho cột foreign key. Và cả hai đều có lối thoát giống nhau khi join quá đắt: denormalize có chủ đích, hoặc tính trước (materialized view ở PostgreSQL, $merge ở MongoDB).
Những lỗi thường gặp
- Không có index trên
foreignField. Mỗi document đi vào là một lần quét cả collection bị nối: 12,7 triệu document cho 126 đơn (lab09), 17 triệu cho 170 đơn (lab12). Index phải nằm ở collectionfrom, không phải collection đầu vào. - Đặt
$lookuptrước$group/$limitkhi chỉ cần thông tin cho kết quả cuối: 57.464 lần tra thay vì 20, chậm khoảng 20 lần (lab09). Optimizer không chuyển$lookupra sau$grouphộ bạn. - Dùng cú pháp pipeline cho một phép so khớp bằng nhau đơn giản. Vẫn dùng index, nhưng chạy bằng classic engine: 607–623 ms so với 185–189 ms trên 57.815 đơn (lab12). Dùng
localField/foreignField, và dạng kết hợp khi cần thêm điều kiện. - Đặt biểu thức lên field phía bị nối trong
$expr($toLower,$substr, cộng trừ…). Index không còn dùng được: 17 triệu document cho 170 đơn. $ortrộn field gốc và field bị nối sau$lookup. Optimizer không tách được: COLLSCAN và 1 triệu lần tra, 8,9–9,1 giây. Rút phần điều kiện chung trên field gốc ra một$matchở đầu.- Chen
$set/$projectgiữa$unwindvà$match. Luật gộp không còn áp dụng,$lookuptrả về đủ mọi document rồi mới lọc. - Tin rằng một quy tắc về engine là vĩnh viễn. Trên 8.3.11,
$lookupgộp với$unwindchạy bằng classic và chậm hơn bản không gộp khoảng 2 lần. Điều này có thể đổi ở phiên bản sau. Kiểm tra bằng explain:$lookupnằm trong$cursorhay là stage riêng. - Dịch mỗi
JOINcủa SQL thành một$lookupcho những màn hình đọc thường xuyên. Nếu bên kia chỉ đóng góp vài field ít đổi, extended reference nhanh hơn khoảng 3–4 lần trong lab và không phụ thuộc engine. - Chỉnh tham số
internal*để ép HashJoin trên production. Đó là công cụ thí nghiệm, không phải cấu hình.
Tóm tắt
$lookuplà một vòng lặp: chi phí ≈ số document đi vào × chi phí một lần tra, cộng chi phí dựng mảng.- Chi phí một lần tra do index trên
foreignFieldcủa collection bị nối quyết định. Lab09: 12,7 triệu document → 127. Lab12: 17.000.170 → 340, 2,3 giây → 1,1 ms. - Explain của 8.3.11 gọi tên ba chiến lược:
IndexedLoopJoin,HashJoin(collection bị nối nhỏ),NestedLoopJoin. Đây là chi tiết triển khai: tài liệu chỉ nhắc tên chúng qua bộ đếm nội bộ, không mô tả cách chọn. Với đầu vào lớn, HashJoin (166 ms) ngang IndexedLoopJoin (185–189 ms) trong lab, nhưng server chỉ chọn nó theo ngưỡng kích thước collection bị nối. - Cú pháp pipeline (
let+$expr, và dạng kết hợp 5.0+) dùng được index khi so field trần với biến, nhưng không chạy bằng SBE: chậm hơn 3–4 lần trên 57.815 đơn. - Optimizer gộp
$lookup+$unwind+$match(tài liệu) và, trên 8.3.11, kéo điều kiện trên field gốc lên trước$lookup(quan sát). Nó không tách được$ortrộn hai phía (1 triệu lần tra, 9 giây). - Số document đi vào
$lookuplà thừa số lớn nhất: lọc trước, gom trước, cắt top-n trước.$lookupsau$group/$limitnhanh hơn khoảng 20 lần (lab09). - Embed có chủ đích (extended reference) bỏ hẳn join khỏi đường đọc: 58–68 ms so với 229–532 ms, đổi lại phải quyết định chuyện dữ liệu chép bị cũ.
- PostgreSQL chọn nested loop / hash / merge join theo chi phí ước lượng và tự sắp thứ tự join. MongoDB để bạn quyết định thứ tự, và chọn chiến lược theo luật.
Hỏi & đáp
Pipeline [ { $match: { tenantId: "t1" } }, { $lookup: { from: "customers", localField: "customerId", foreignField: "customerId", as: "c" } } ] chạy chậm. Bạn tạo index { customerId: 1 } trên orders. Có nhanh hơn không?
Explain của một pipeline hiện $lookup là stage riêng trong stages[1], có indexesUsed: ['customerId_1'], và $lookup dùng let + $expr chỉ để so khớp bằng nhau. Bạn đánh giá thế nào?
Vì sao [ $lookup, $unwind, { $match: { status: "completed", createdAt: SEPT, "c.segment": "vip" } } ] chỉ đưa 57.815 đơn vào $lookup, còn bản $match: { $or: [ ... ] } trộn field gốc và field bị nối lại đưa 1.000.000 đơn?
Báo cáo "top 10 khách theo doanh thu năm, kèm tên". Đặt $lookup sang customers ở đâu?
Có index customerId_1 trên customers. Sub-pipeline đổi thành $expr: { $eq: [ { $toLower: "$customerId" }, "$$cid" ] } để so khớp không phân biệt hoa thường. Explain trên 170 đơn cho thấy gì?
Báo cáo "doanh thu tháng 9 từ khách VIP" chạy thường xuyên. Bạn cân nhắc chép customer.segment vào mỗi đơn (extended reference). Nhận định nào đúng?
Mỗi món trên băng chuyền cần một dòng thông tin từ kho bên cạnh. Kho không có sổ tra, và cuối ca chỉ 20 món đứng đầu được giao đi. Cách làm nào tốn ít công nhất mà vẫn lấy thông tin từ kho?
Nếu phải giải thích bài này mà không dùng thuật ngữ MongoDB nào: mỗi món trên băng chuyền là một chuyến sang kho bên cạnh. Có sổ tra thì mỗi chuyến chỉ là mở sổ. Không có sổ thì mỗi chuyến là lật cả kho. Đừng chạy sang kho cho những món bạn sắp bỏ đi: lọc, gom, cắt bớt trước rồi mới chạy. Và nếu món nào cũng cần cùng một dòng thông tin ít đổi, hãy in nó lên phiếu ngay từ đầu.
Bài tiếp theo
Đến đây bạn đã có đủ công cụ cho một query: index, planner, aggregation pipeline và join. Bài Pagination & Production Query Patterns đưa chúng vào những màn hình mà app nào cũng có: danh sách đơn hàng phân trang. Vì sao skip lớn làm trang 10.000 chậm dần, vì sao cursor chỉ theo createdAt lại sót đơn và cần _id làm tie-breaker, vì sao tenantId phải đứng đầu index trong hệ thống multi-tenant. Và vì sao một query "đủ nhanh" khi chạy một mình chưa chắc vẫn nhanh khi tám request cùng chạy.