$lookup & Joins (P2/3): Hai cú pháp và `$lookup` → `$unwind` → `$match`

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

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

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 170

Cả 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)Engine170 đơn57.815 đơn
F1 localField/foreignFieldSBE (EQ_LOOKUP)1,1 / 1,0 ms189 / 185 ms
F2 let + $exprclassic2,1 / 2,1 ms607 / 623 ms
F3 kết hợp + $projectclassic2,3 / 2,3 ms769 / 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 cache

Dạ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] $group

Hai việc đã xảy ra:

  1. Điều kiện trên field của orders được kéo lên đầu. status và createdAt không phụ thuộc c, nên được tách khỏi $match và đư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.
  2. $unwind và phần còn lại của $match được gộp vào $lookup. Trường unwinding thay cho stage $unwind, và c.segment: "vip" thành pipeline: [{ $match: { segment: "vip" } }] chạy ngay trong lần tra. Không có mảng c nà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 $lookupEngine của $lookupmedian lượt 1median lượt 2
N1 viết "kiểu SQL"57.815 (optimizer kéo $match lên)classic520 ms531 ms
N2 "đúng sách"57.815classic522 ms532 ms
N4 $set chen giữa57.815classic508 ms551 ms
N3 $match trước $unwind57.815SBE229 ms237 ms
N11 bỏ $unwind57.815SBE252 ms256 ms
N9 $or trộn field1.000.000classic8.880 ms9.096 ms
N10 $or, rút createdAt ra trước82.358classic882 ms982 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ỐI

Bướ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?

  1. Ổn: đã dùng index thì cú pháp nào cũng cùng tốc độ, cùng số key và document

    Cùng số key và document, nhưng trên 57.815 đơn F2 (let + $expr) mất 607–623 ms so với 185–189 ms của localField/foreignField. Xem mục "Cùng câu hỏi, ba cách viết".

  2. Có vấn đề: indexesUsed ở stage riêng nghĩa là index bị dùng cho cả collection đầu vào

    indexesUsed ở stage $lookup là index của collection bị nối, đúng thứ ta muốn. Vấn đề nằm ở engine, không ở index. Xem mục "Cùng câu hỏi, ba cách viết".

  3. Có vấn đề: $lookup có pipeline thì không dùng được index, phải thêm hint()

    Trong $expr, $eq/$lt/$lte/$gt/$gte so field trần với biến let dùng được index của from, và explain đã cho thấy indexesUsed. Xem mục "Cùng câu hỏi, ba cách viết".

  4. Có index, nhưng chạy classic engine; localField/foreignField nhanh hơn vài lần

    $lookup là stage riêng ở nửa sau nghĩa là classic engine: $lookup có pipeline không chạy bằng SBE. Trong lab12, F1 (SBE, EQ_LOOKUP) nhanh hơn F2 khoảng 3–4 lần trên 57.815 đơn. Xem mục "Hai cú pháp, hai engine".

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?

  1. AND tách được: phần field gốc lên đầu, c.segment vào $lookup; $or thì không

    Với N1, optimizer kéo status/createdAt lên nửa đầu (dùng index {status, createdAt}) và gộp c.segment vào pipeline của $lookup. Với N9, không phần nào tách ra được: COLLSCAN, 1 triệu lần tra, 8,9–9,1 giây. Tự rút createdAt chung ra trước (N10) còn 82.358 lần tra. Xem mục "Khi optimizer không cứu được".

  2. $or buộc $lookup chuyển sang NestedLoopJoin, quét cả customers mỗi lần

    N9 vẫn dùng index customerId_1 cho mỗi lần tra. Thứ đổi là số đơn đi vào $lookup: 1.000.000 thay vì 57.815. Xem mục "Khi optimizer không cứu được".

  3. Optimizer luôn kéo mọi $match lên trước $lookup, nhưng $or bị tắt tối ưu từ 8.0

    Optimizer không kéo mọi $match lên: điều kiện trên field bị nối phải chờ $lookup. Việc kéo phần field gốc lên trước $lookup là quan sát trên 8.3.11, không phải luật được liệt kê. Xem mục "$lookup → $unwind → $match: thứ tự bạn viết và thứ tự server chạy".

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

  1. indexesUsed: [], 170 lần quét cả collection, 17.000.000 document

    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 vô dụng: y như không có index. Cách đúng là chuẩn hoá dữ liệu lúc ghi và join trên field đã toLower. Xem mục "Khi cú pháp pipeline làm mất index".

  2. Vẫn dùng customerId_1, vì $toLower chỉ áp lên biến bên trái

    $toLower áp lên $customerId, tức field của customers. Index chỉ dùng được khi so field trần với hằng số. Xem mục "Khi cú pháp pipeline làm mất index".

  3. Server chuyển sang HashJoin, đọc customers một lần cho cả 170 đơn

    HashJoin chỉ được cân nhắc khi collection bị nối dưới ngưỡng (10.000 document trong lab), và $lookup có pipeline chạy bằng classic engine. Explain ghi collectionScans 170. Xem mục "Khi cú pháp pipeline làm mất index".