$lookup & Joins (P2/3): Hai cú pháp và `$lookup` → `$unwind` → `$match`
Ở phần trước: $lookup chạy như một vòng lặp, và chi phí phụ thuộc số document đi vào cùng index trên foreignField. Với 170 đơn, index thắng tuyệt đối; với 57.815 lần tra, HashJoin không còn kém hơn.
- Cần đọc trước:
$lookuplà vòng lặp; index và ba chiến lược join - Dẫn tới: Đặt
$lookupở đâu; embed hay join
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 sau (Đặt $lookup ở đâu; embed hay join).
Cột mốc: Bạn phân biệt được hai cú pháp $lookup và biết thứ tự $lookup, $unwind, $match ảnh hưởng tới số document đi vào stage. Tiếp theo: Đặt
$lookupở đâu; embed hay join.
Hỏi & đáp
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?
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ì?