$lookup & Joins: nối dữ liệu giữa các collection và cái giá của từng chuyến tra

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

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: $lookup như một vòng lặp, cú pháp localField/foreignField và 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 $lookup trướ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, database lab09. Đây là các số đo khi $lookup còn là một phần của bài Aggregation Pipeline (thí nghiệm 126 đơn, chiến lược HashJoin sang tenants, $lookup trước/sau $group). Giữ nguyên số và môi trường gốc.
  • lab12: container mongo-lab-12, database lab12, đ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 để ghim

Rồ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 sau

Tổ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ài

Dữ 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 explainmedian lượt 1median lượt 2
không có indextotalDocsExamined: 12.700.000, collectionScans: 1272.137 ms2.832 ms
có index customerId_1totalDocsExamined: 127, totalKeysExamined: 127, indexesUsed: ['customerId_1']2,2 ms2,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 document

Kiể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 ms

Con 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 }
StrategyKhi nào (quan sát)Chi phíTrong cái xưởng
IndexedLoopJoincó index trên foreignFieldmỗi document: một lần seek indexmở sổ tra
HashJoinkhông có index, collection bị nối nhỏđọc collection bị nối một lần, dựng bảng băm trong RAMchép kho lên bảng ghim
NestedLoopJoinkhông có index, collection bị nối lớnmỗi document: quét cả collection bị nốilậ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.320
lab12, $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 mskhông đo (≈ 5,8 tỷ document)
HashJoin (không index, ngưỡng nâng lên 200.000)45 / 44 ms166 / 167 ms
IndexedLoopJoin (index customerId_1)1,1 / 1,1 ms189 / 185 ms

Hai điều đáng chú ý:

  1. 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.
  2. 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ên foreignField, 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 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 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:

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

Tóm tắt

  • $lookup là 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 foreignField củ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 $or trộn hai phía (1 triệu lần tra, 9 giây).
  • Số document đi vào $lookup là thừa số lớn nhất: lọc trước, gom trước, cắt top-n trước. $lookup sau $group/$limit nhanh 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?

  1. Có, vì $lookup dùng index trên localField để tìm nhanh hơn

    Index trên localField của collection đầu vào không giúp gì cho phần tra. Mỗi lần tra tìm trong customers, nên index phải nằm ở đó. Xem mục "Index phía bị nối: 127 lần tra hay 12,7 triệu document".

  2. Không, index phải nằm ở customers.customerId

    Chi phí một lần tra do index trên foreignField của collection from quyết định. Trong lab12, thêm customerId_1 trên customers đưa 170 đơn từ 17.000.170 document xuống 340, từ 2,3 giây xuống 1,1 ms. Xem mục "Kiểm lại trong lab12".

  3. Có, nhưng chỉ khi đổi sang cú pháp let + $expr

    Cú pháp không đổi chỗ index cần nằm: cả hai cú pháp đều tra trong customers. Cú pháp pipeline còn chạy bằng classic engine, chậm hơn. Xem mục "Hai cú pháp, hai engine".

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

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 "Thứ tự nên viết".

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

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 mục "Hình dung trước: chạy sang kho bên cạnh".

  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 "Mental model: $lookup là một vòng lặp".

  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 mục "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