$lookup & Joins (P3/3): Đặt `$lookup` ở đâu; embed hay join
Ở 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.
- Cần đọc trước: Hai cú pháp và
$lookup→$unwind→$match - Dẫn tới: Pagination & Production Query Patterns, bài này khép lại ở đây.
Đặ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 ở 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. - PostgreSQL chọn hash join cho đầu vào lớn khi nó rẻ hơn. Thí nghiệm HashJoin ở phần đầu (
$lookuplà 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ênforeignFieldgầ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.
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?
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.