ข้ามไปยังเนื้อหา

Lookup join

MongoDB เก็บข้อมูลที่เกี่ยวข้องกันไว้ใน collection แยกกันบ่อยพอที่บางครั้งคุณต้องเย็บสองอันกลับเข้าด้วยกัน stage $lookup ทำ join นั้นใน aggregation pipeline: สำหรับ document ขาเข้าแต่ละรายการ มันหา document ที่ตรงใน collection อื่นและแนบเป็น field แบบ array คู่มืออธิบายว่าเป็น “a left outer join to a collection in the same database” ($lookup, ตรวจ 2026-09-30): ทุก document ขาเข้าผ่านออกมา โดยมี array ว่างเมื่อไม่มีที่ตรง

จับจาก mongo 8.3.11, mongosh 2.11.1 และ Node driver 7.7.0 บน Mac เครื่องเดียว; แท็บ Python, Go และ Rust ถูกรันอย่างละครั้งในโปรแกรม scratch บทนี้เตรียมรูปร่าง sales ของโมดูล (20 000 order ที่นี่ เพื่อให้ join ต่อ document เร็ว) บวก collection books ที่ใช้ title เป็น key (500 เล่ม; title T500-T519 มีขายแต่ไม่มีหนังสือ) สำเนา booksByTitle ที่มี field title ธรรมดา และ lplan() helper ที่รายงานว่า join ทำงานอย่างไร

mongosh/lk-seed.js
const genres = ["fiction", "science", "history", "poetry", "comics"];
globalThis.lplan = (pipeline, coll = "sales") => { // how the $lookup was executed
const e = db.getCollection(coll).explain("executionStats").aggregate(pipeline);
const q = e.queryPlanner?.winningPlan?.queryPlan;
if (q) { for (let p = q; p; p = p.inputStage) if (p.stage === "EQ_LOOKUP") return "EQ_LOOKUP " + p.strategy + (p.indexName ? " on " + p.indexName : "") + " | executionTimeMillis " + e.executionStats.executionTimeMillis; }
const l = (e.stages ?? []).find((s) => s.$lookup);
return l ? "$lookup (no strategy reported) | keys " + l.totalKeysExamined + ", docs " + l.totalDocsExamined + ", indexes " + JSON.stringify(l.indexesUsed) : "no lookup stage";
};
db.sales.insertMany(Array.from({ length: 20000 }, (_, i) => ({ title: "T" + (i % 520), genre: genres[(i % 520) % 5], quantity: 1 + (i % 5), price: 5 + (i % 21), buyer: "b" + (i % 1000) })));
db.books.insertMany(Array.from({ length: 500 }, (_, i) => ({ _id: "T" + i, author: "A" + (i % 50), year: 1900 + (i % 120) }))); // T500..T519 have NO book
db.booksByTitle.insertMany(Array.from({ length: 500 }, (_, i) => ({ title: "T" + i, author: "A" + (i % 50), year: 1900 + (i % 120) })));
console.log("sales:", db.sales.countDocuments(), "| books:", db.books.countDocuments(), "(20 titles sold have no book)");
sales: 20000 | books: 500 (20 titles sold have no book)

$lookup ที่ง่ายที่สุดจับคู่ field ฝั่ง local หนึ่งตัวกับ field ฝั่ง foreign หนึ่งตัว: from (collection), localField, foreignField และ as (field array ที่จะเพิ่ม) ด้านล่างเรา join กลุ่ม order ต่อ title สองกลุ่มกับหนังสือของมัน T510 ไม่มีหนังสือ array จึงว่าง แท็บ Node.js แสดงสิ่งเดียวกันบนสอง order:

flowchart LR
  S["sales: title = T7"] --> L["$lookup from books on _id"]
  B["books: _id = T7"] --> L
  L --> O["sales doc with book array attached"]
แต่ละ document ของ sales ถูก join กับหนังสือที่ตรงและมีมันอยู่ใน field array ใหม่
mongosh/lk-equality.js
const rows = db.sales.aggregate([
{ $match: { title: { $in: ["T7", "T510"] } } },
{ $group: { _id: "$title", orders: { $sum: 1 } } },
{ $sort: { _id: 1 } },
{ $lookup: { from: "books", localField: "_id", foreignField: "_id", as: "book" } },
]).toArray();
console.log(rows);
assert.ok(rows.every((r) => Array.isArray(r.book)));
[
{ _id: 'T510', orders: 38, book: [] },
{
_id: 'T7',
orders: 39,
book: [ { _id: 'T7', author: 'A7', year: 1907 } ]
}
]
[{"title":"Dune","book":[{"_id":"Dune","author":"Frank Herbert","pages":412,"year":1965}]},{"title":"Unknown","book":[]}]

ผลลัพธ์เป็น array เสมอ แม้จะตรงไม่เกินหนึ่ง document เพราะโดยทั่วไป join ตรงได้หลายรายการ ถ้า localField เองเป็น array ทุกสมาชิกจะถูกจับคู่กับ field ฝั่ง foreign โดยไม่ต้อง $unwind ($lookup, ตรวจ 2026-09-30):

mongosh/lk-array-local.js
db.carts.insertOne({ _id: "c1", titles: ["T1", "T2", "T900"] });
const c = db.carts.aggregate([{ $lookup: { from: "books", localField: "titles", foreignField: "_id", as: "books" } }]).toArray()[0];
console.log("localField is an array ->", c.books.map((b) => b._id), "(T900 has no book; no $unwind needed)");
assert.strictEqual(c.books.length, 2);
localField is an array -> [ 'T2', 'T1' ] (T900 has no book; no $unwind needed)

In Compass: แท็บ Aggregations มี $lookup ในดรอปดาวน์และให้เลือก collection กับ field ฝั่ง foreign ด้วยฟอร์ม ตัวอย่างแสดง field array ที่ถูกต่อท้ายแต่ละ document

mongosh/lk-outer.js
const total = db.sales.countDocuments();
const joined = db.sales.aggregate([{ $lookup: { from: "books", localField: "title", foreignField: "_id", as: "book" } }, { $match: { book: { $size: 0 } } }, { $count: "noBook" }]).toArray()[0].noBook;
const inner = db.sales.aggregate([{ $lookup: { from: "books", localField: "title", foreignField: "_id", as: "book" } }, { $unwind: "$book" }, { $count: "n" }]).toArray()[0].n;
console.log({ orders: total, ordersWithNoBook: joined, afterUnwind: inner });
assert.strictEqual(total, joined + inner);
{ orders: 20000, ordersWithNoBook: 760, afterUnwind: 19240 }

order 760 จาก 20 000 รายการอ้าง title ที่ไม่มีหนังสือ: ผ่านออกมาพร้อม book: [] $unwind: "$book" ที่ตามมาทิ้งพวกมัน (inner join); preserveNullAndEmptyArrays: true เก็บไว้

เมื่อต้องการมากกว่า equality เดียว - กรองหรือ project ฝั่ง foreign - ใช้ รูปแบบ pipeline: ประกาศตัวแปร let จาก document ฝั่ง local แล้วรัน sub-pipeline กับ collection ฝั่ง foreign เปรียบเทียบด้วย $expr $ เดี่ยวใน sub-pipeline คือ field ของ document ฝั่ง foreign; $$t คือตัวแปร let จากฝั่ง local แนบเฉพาะหนังสือที่ตีพิมพ์หลังปี 1950:

mongosh/lk-pipeline-form.js
const rows = db.sales.aggregate([
{ $match: { title: { $in: ["T7", "T60"] } } },
{ $group: { _id: "$title" } },
{ $sort: { _id: 1 } },
{ $lookup: { from: "books", let: { t: "$_id" },
pipeline: [{ $match: { $expr: { $eq: ["$_id", "$$t"] } } }, { $match: { year: { $gt: 1950 } } }, { $project: { _id: 0, author: 1, year: 1 } }],
as: "book" } },
]).toArray();
console.log(JSON.stringify(rows));
assert.ok(rows.every((r) => r.book.length === 0 || r.book[0].year > 1950));
[{"_id":"T60","book":[{"author":"A10","year":1960}]},{"_id":"T7","book":[]}]

การทบทวนบทเดิมพบว่า $lookup ไม่มีกลยุทธ์ และ “queries the foreign collection once per input document” ข้อความนี้จริงเพียงบางกรณี explain รายงานกลยุทธ์ที่เลือกสำหรับรูปแบบ equality: IndexedLoopJoin (ค้นด้วย index ต่อ document ขาเข้า), HashJoin (สร้าง hash table ของฝั่ง foreign ครั้งเดียว) หรือ NestedLoopJoin (สแกน collection ฝั่ง foreign ต่อ document ขาเข้า) คำถามเดียวกัน field ฝั่ง foreign ต่างกัน:

mongosh/lk-strategy.js
const eq = (from, foreignField) => [{ $lookup: { from, localField: "title", foreignField, as: "book" } }];
console.log("foreignField _id (always indexed) :", lplan(eq("books", "_id")));
console.log("foreignField title, NO index :", lplan(eq("booksByTitle", "title")));
db.booksByTitle.createIndex({ title: 1 });
console.log("foreignField title, index title_1 :", lplan(eq("booksByTitle", "title")));
const pipe = [{ $lookup: { from: "books", let: { t: "$title" }, pipeline: [{ $match: { $expr: { $eq: ["$_id", "$$t"] } } }], as: "book" } }];
console.log("pipeline form ($expr on _id) :", lplan(pipe));
foreignField _id (always indexed) : EQ_LOOKUP IndexedLoopJoin on _id_ | executionTimeMillis 104
foreignField title, NO index : EQ_LOOKUP HashJoin | executionTimeMillis 23
foreignField title, index title_1 : EQ_LOOKUP IndexedLoopJoin on title_1 | executionTimeMillis 107
pipeline form ($expr on _id) : $lookup (no strategy reported) | keys 19240, docs 19240, indexes ["_id_"]
  • IndexedLoopJoin - field ฝั่ง foreign มี index (_id_ มีเสมอ)
  • HashJoin - ไม่มี index แต่ collection ฝั่ง foreign เล็ก: รอบเดียวสร้าง hash table ที่นี่เร็วกว่า (23 ms) แบบวน index (107 ms)
  • รูปแบบ pipeline - ไม่รายงานกลยุทธ์; stage $lookup แสดง totalKeysExamined: 19240, totalDocsExamined: 19240 และ indexesUsed: ["_id_"] แทน - ค้นด้วย index หนึ่งครั้งต่อ document ขาเข้า
mongosh/lk-unwind.js
const p = [{ $lookup: { from: "books", localField: "title", foreignField: "_id", as: "book" } }, { $unwind: "$book" }];
const e = db.sales.explain().aggregate(p);
const l = e.stages.find((x) => x.$lookup).$lookup;
console.log("explain stages:", e.stages.map((x) => Object.keys(x)[0]).join(" > "), "(no separate $unwind stage)");
console.log("$lookup.unwinding:", JSON.stringify(l.unwinding), "| strategy fields:", Object.keys(l).filter((k) => /strategy|index|unwind/i.test(k)).join(", "));
console.log("rows with $unwind (inner join):", db.sales.aggregate([...p, { $count: "n" }]).toArray()[0].n);
console.log("rows with preserveNullAndEmptyArrays (left join):", db.sales.aggregate([p[0], { $unwind: { path: "$book", preserveNullAndEmptyArrays: true } }, { $count: "n" }]).toArray()[0].n);
explain stages: $cursor > $lookup (no separate $unwind stage)
$lookup.unwinding: {"preserveNullAndEmptyArrays":false} | strategy fields: unwinding
rows with $unwind (inner join): 19240
rows with preserveNullAndEmptyArrays (left join): 20000

optimizer รวม $lookup + $unwind เป็นหนึ่ง stage (unwinding บน $lookup) document ที่ตรงจึงถูกสร้างทีละรายการแทนการสร้าง array ก่อน

collection ที่ shard: collection from เป็น sharded ได้ตั้งแต่ MongoDB 5.1 ($lookup, ตรวจ 2026-09-30) คอร์สนี้รันแบบ unsharded จึงไม่ได้ทดลองเส้นทางนั้นที่นี่

  • ทำ index ให้ foreignField (หรือสิ่งที่ sub-pipeline ใช้ match): NestedLoopJoin บนหนังสือ 50 000 เล่มใช้ 1 162 ms และ IndexedLoopJoin 10 ms สำหรับ join เดียวกัน
  • เป็น left outer join: กรองรายการว่างด้วย $match หรือทำให้เป็น inner ด้วย $unwind
  • as เป็น array เสมอ; จับคู่กับ $unwind เมื่อคาดว่าตรงหนึ่งรายการ
  • ถ้า join สอง collection เดิมทุกครั้งที่อ่าน อาจแปลว่าข้อมูลควรถูก embed - ดูโมดูล data-modeling

เมื่อถูกขอให้ “แนบรายละเอียดหนังสือกับแต่ละการขาย” AI เขียน lookup แบบ equality กับแคตตาล็อกที่ใช้ field title ธรรมดา:

🐛 หา bug ในโค้ดนี้

// AI comment: "join is O(1) per order - foreignField lookup"
db.sales.aggregate([
{ $lookup: { from: "booksNoIdx", localField: "title", foreignField: "title", as: "book" } },
]);
เฉลย

ไม่มีอะไรใน $lookup ทำให้การค้นเป็น O(1): ทั้งหมดขึ้นกับ index บน foreignField เมื่อไม่มี index บนแคตตาล็อก 50 000 เล่ม explain รายงาน NestedLoopJoin และการ join แค่ 100 order ใช้ 1 006 ms; หลัง createIndex({ title: 1 }) pipeline เดิมรายงาน IndexedLoopJoin และใช้ 13 ms ทางแก้คือ index (สร้างครั้งเดียวตอน deploy):

mongosh/lk-bug.js
// AI: "join sales to the book details by title" - books have a `title` field but nobody indexed it (booksByTitle2 is a fresh copy)
db.booksNoIdx.insertMany(Array.from({ length: 50000 }, (_, i) => ({ title: "T" + i, author: "A" + (i % 50) }))); // a bigger catalogue: 50 000 books
const join = [{ $match: { buyer: { $in: ["b1", "b2", "b3", "b4", "b5"] } } }, { $lookup: { from: "booksNoIdx", localField: "title", foreignField: "title", as: "book" } }];
let t = Date.now(); const a = db.sales.aggregate(join).toArray().length; const noIdx = Date.now() - t;
console.log("no index on booksNoIdx.title:", lplan(join), "|", a, "rows in", noIdx, "ms");
db.booksNoIdx.createIndex({ title: 1 });
t = Date.now(); const b = db.sales.aggregate(join).toArray().length; const withIdx = Date.now() - t;
console.log("with title_1 :", lplan(join), "|", b, "rows in", withIdx, "ms");
no index on booksNoIdx.title: EQ_LOOKUP NestedLoopJoin | executionTimeMillis 1162 | 100 rows in 1006 ms
with title_1 : EQ_LOOKUP IndexedLoopJoin on title_1 | executionTimeMillis 10 | 100 rows in 13 ms

$lookup คือ loop ที่รัน query แทนคุณ: สำหรับ document ขาเข้าแต่ละรายการ หาที่ตรงอีกฝั่ง การตัดสินใจเดียวคือ query ด้านในถูกตอบอย่างไร - ค้นด้วย index, hash table ที่สร้างครั้งเดียว หรือสแกน - และ explain บอกชื่อทางเลือกนั้น มี index หรือฝั่งเล็ก = ถูก; ฝั่งใหญ่ที่ไม่มี index = สแกนต่อแถว

ทางเลือกข้อดีต้นทุน
$lookup (อ้างอิง)ไม่มีข้อมูลซ้ำ; อัปเดตที่เดียวทุกการอ่านจ่ายค่า join (ค้น index ต่อแถวขาเข้า)
การ embedอ่านครั้งเดียว ไม่มี joinข้อมูลซ้ำ; การอัปเดตต้องตามทุกสำเนา
รูปแบบ pipelineกรองและปรับรูปฝั่ง foreignซับซ้อนกว่า; ไม่รายงานกลยุทธ์ join
รูปแบบ equalityสั้น; อ่านกลยุทธ์ใน explain ได้equality ได้เพียงเงื่อนไขเดียว
  • “$lookup ไม่มีกลยุทธ์ join” - explain แสดง IndexedLoopJoin, HashJoin และ NestedLoopJoin สำหรับรูปแบบ equality
  • “มัน query collection ฝั่ง foreign หนึ่งครั้งต่อ document ขาเข้าเสมอ” - เฉพาะกรณี loop; HashJoin สร้างตารางครั้งเดียวและเร็วกว่า 4-5 เท่าบนฝั่ง 500 เล่ม
  • “document ที่ไม่ตรงถูกกรองออก” - order 760 รายการผ่านมาพร้อม book: []
  • “as ที่มีสมาชิกเดียวคือ object” - มันเป็น array; ใช้ $unwind
  • “index ฝั่ง local เร่ง join” - การค้นไปที่ฝั่ง foreign

ตัวอย่างจากของจริง

คู่มือระบุว่าตั้งแต่ MongoDB 5.1 “the from collection can be sharded” และ array ของ localField จับคู่กับ foreignField แบบ scalar ได้ “without an $unwind stage” ($lookup, ตรวจ 2026-09-30) - สองพฤติกรรมที่บทเรียนรุ่นเก่ายังบอกว่าทำไม่ได้

แท็บ Python/Go/Rust สะท้อนพฤติกรรมที่พิสูจน์แล้วของ shell/Node; แต่ละแท็บถูกรันหนึ่งครั้งในโปรแกรม scratch กับ server เดียวกัน ($lookup แบบ equality คืน array book ต่อ order) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส

$lookup เป็น join ชนิดใด
field ที่ระบุใน as มีชนิดใด
$lookup บน foreignField ที่ไม่มี index ใน collection 50 000 document explain รายงานอะไร
server เลือก HashJoin เมื่อไร
$lookup ตามด้วย $unwind: "$book" ทำอะไรกับ order ที่ไม่ตรง
ในรูปแบบ pipeline $$t หมายถึงอะไร