$lookup & Joins (P3/3): Đặt `$lookup` ở đâu; embed hay join

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

Ở phần trước: $lookup là một stage bạn tự đặt, nên thứ tự bạn viết ảnh hưởng tới số document đi vào. Ví dụ N9 và N10 cho thấy số document đi vào $lookup vẫn là thứ quyết định.

Đặ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:

lab09số lần $lookup tramedian lượt 1median lượt 2
BEFORE57.464494 ms428 ms
AFTER, $lookup sau $sort+$limit2022,8 ms21,7 ms
AFTER, $lookup trước $sort+$limit50028,5 ms21,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òng

Nguyê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 1median lượt 2
N5 embed customer.segment57.815 đơn68 ms58 ms
N3 join tốt nhất (SBE)57.815 đơn + 57.815 khách229 ms237 ms
N2 join "đúng sách" (classic)57.815 đơn + 57.816 khách522 ms532 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à sai

Gạ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ốngNên
Màn hình đọc thường xuyên, cần vài field ít đổi của bên kiaembed / 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ềureference + 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 $lookupPostgreSQL
Nested loop có indexIndexedLoopJoin (quan sát)nested loop + index scan bên trong
Nested loop không indexNestedLoopJoin: quét cả collection cho mỗi documentnested loop + seq scan, chỉ được chọn khi ước lượng thấy rẻ nhất
Hash joinHashJoin, 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 joinkhông thấy trong labcó
Ai chọn chiến lượcluật theo index và kích thước collection bị nốicost model dựa trên thống kê
Ai chọn thứ tự join và vị trí join so với GROUP BYbạn, bằng thứ tự stageplanner (trong giới hạn của ngữ nghĩa SQL)

Hai khác biệt đáng nhớ nhất:

  1. Trong PostgreSQL, viết JOIN trướ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 ở phần trước (Hai cú pháp và $lookup → $unwind → $match) 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.
  2. PostgreSQL chọn hash join cho đầu vào lớn khi nó rẻ hơn. Thí nghiệm HashJoin ở phần đầu ($lookup là vòng lặp; index và ba chiến lược join) 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 foreignField gầ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 ở collection from, không phải collection đầu vào.
  • Đặt $lookup trước $group/$limit khi 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 $lookup ra sau $group hộ 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.
  • $or trộ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/$project giữa $unwind và $match. Luật gộp không còn áp dụng, $lookup trả 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, $lookup gộp với $unwind chạ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: $lookup nằm trong $cursor hay là stage riêng.
  • Dịch mỗi JOIN của SQL thành một $lookup cho 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.

Cột mốc: Bạn biết đặt $lookup sau $group và $limit khi báo cáo chỉ cần top-n, và biết khi nào nên embed thay vì join. Bài $lookup & Joins khép lại ở đây.

Hỏi & đáp

Báo cáo "top 10 khách theo doanh thu năm, kèm tên". Đặt $lookup sang customers ở đâu?

  1. Ngay sau $match, để $group có sẵn tên khách làm một phần khoá nhóm

    Đó là bản BEFORE: mỗi đơn một lần tra. Trong lab09, cách này tra 57.464 lần và mất 428–494 ms, so với 21,7–22,8 ms khi tra sau. Xem mục "Đặt $lookup ở đâu: trước hay sau $group và $limit".

  2. Ở đâu cũng được, optimizer sẽ tự chuyển $lookup ra sau $group

    Không có luật nào như vậy, và không thể có: server không biết name phụ thuộc hoàn toàn vào customerId. Xem mục "Đặt $lookup ở đâu: trước hay sau $group và $limit".

  3. Sau $group, $sort và $limit: 10: chỉ còn 10 lần tra vào customers

    $lookup tốn công theo số document đi qua nó. Trong lab09, đặt nó sau $sort + $limit giảm từ 57.464 xuống 20 lần tra, nhanh hơn khoảng 20 lần, không cần index mới. Xem mục "Đặt $lookup ở đâu: trước hay sau $group và $limit".

  4. Trước $match, để $lookup chạy bằng SBE ở nửa đầu pipeline

    Đặt $lookup trước $match thì mọi đơn đi vào nó. Số document đi vào $lookup là thừa số lớn nhất, quan trọng hơn engine. Xem mục "Đặt $lookup ở đâu: trước hay sau $group và $limit".

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?

  1. Không đáng: join tốt nhất (SBE) đã nhanh ngang embed

    Trong lab12, embed mất 58–68 ms, join tốt nhất (N3, SBE) mất 229–237 ms, join "đúng sách" 522–532 ms. Embed nhanh hơn khoảng 3–4 lần. Xem mục "Đo lại cùng câu hỏi VIP".

  2. Nên chép cả hồ sơ khách vào đơn để mọi báo cáo sau không cần join

    Chỉ chép field ít đổi và hay đọc; chép cả hồ sơ khách là sai. Mỗi field chép thêm là một field phải quyết định chuyện bị cũ. Xem mục "Cái giá, và khi nào chấp nhận được".

  3. Embed luôn cho dữ liệu sai khi khách đổi segment, nên chỉ dùng cho báo cáo xấp xỉ

    Nếu câu hỏi là khách VIP lúc đặt đơn, bản chép là dữ liệu đúng, còn join sang customers mới cho kết quả sai. Xem mục "Cái giá, và khi nào chấp nhận được".

  4. Nhanh hơn 3–4 lần, nhưng phải chọn: VIP lúc đặt đơn hay VIP hiện tại

    Embed bỏ một collection khỏi đường đọc (58–68 ms so với 229–532 ms). Cái giá là câu hỏi nghiệp vụ khi khách đổi segment: sửa mọi đơn cũ hay giữ giá trị lúc đặt hàng. Xem mục "Cái giá, và khi nào chấp nhận được".

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?

  1. Chạy sang kho cho mọi món ngay khi món vừa lên băng chuyền, khỏi phải chờ

    Không có sổ tra thì mỗi chuyến là lật cả kho, và phần lớn chuyến là cho món sẽ bị bỏ đi. Trong lab, đó là 17 triệu document cho 170 đơn khi thiếu index. Xem $lookup là vòng lặp; index và ba chiến lược join.

  2. Làm sổ tra cho kho, rồi chỉ chạy sang kho cho 20 món cuối cùng

    Hai thừa số của chi phí: mỗi chuyến rẻ (index trên foreignField) và ít chuyến (đặt $lookup sau $group/$limit). Lab09: 20 lần tra thay vì 57.464, nhanh hơn khoảng 20 lần. Xem mục "Đặt $lookup ở đâu: trước hay sau $group và $limit".

  3. Chép cả kho lên bảng ghim mỗi ca, kho lớn đến đâu cũng vậy

    Đó là HashJoin: rẻ khi kho nhỏ, nhưng tốn chỗ, và server 8.3.11 chỉ chọn nó dưới ngưỡng kích thước. Với 170 đơn, nó vẫn phải đọc cả 100.000 khách (44–45 ms so với 1,1 ms có index). Xem $lookup là vòng lặp; index và ba chiến lược join.

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.

Tài liệu tham khảo