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 ทำงานอย่างไร
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 bookdb.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)รูปแบบ equality: localField และ foreignField
หัวข้อที่มีชื่อว่า “รูปแบบ equality: localField และ foreignField”$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"]
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)));const sales = db.collection("sales");await db.collection("books").insertOne({ _id: "Dune", author: "Frank Herbert", pages: 412, year: 1965 });await sales.insertMany([{ title: "Dune", quantity: 3, price: 12 }, { title: "Unknown", quantity: 1, price: 5 }]);const rows = await sales.aggregate([ { $lookup: { from: "books", localField: "title", foreignField: "_id", as: "book" } }, { $project: { _id: 0, title: 1, book: 1 } },]).toArray();console.log(JSON.stringify(rows));if (rows[0].book.length !== 1 || rows[1].book.length !== 0) throw new Error("left outer join expected");rows = list(db.sales.aggregate([ {"$lookup": { "from": "books", "localField": "title", "foreignField": "_id", "as": "book", }},]))pipeline := mongo.Pipeline{ {{Key: "$lookup", Value: bson.D{ {Key: "from", Value: "books"}, {Key: "localField", Value: "title"}, {Key: "foreignField", Value: "_id"}, {Key: "as", Value: "book"}, }}},}cur, err := coll.Aggregate(ctx, pipeline)if err != nil { return err}var rows []bson.Mif err := cur.All(ctx, &rows); err != nil { return err}let mut cursor = coll .aggregate(vec![doc! { "$lookup": { "from": "books", "localField": "title", "foreignField": "_id", "as": "book", } }]) .await?;[ { _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):
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
left outer และทำให้เป็น inner อย่างไร
หัวข้อที่มีชื่อว่า “left outer และทำให้เป็น inner อย่างไร”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 เก็บไว้
รูปแบบ pipeline
หัวข้อที่มีชื่อว่า “รูปแบบ pipeline”เมื่อต้องการมากกว่า equality เดียว - กรองหรือ project ฝั่ง foreign - ใช้ รูปแบบ pipeline: ประกาศตัวแปร let จาก document ฝั่ง local แล้วรัน sub-pipeline กับ collection ฝั่ง foreign เปรียบเทียบด้วย $expr $ เดี่ยวใน sub-pipeline คือ field ของ document ฝั่ง foreign; $$t คือตัวแปร let จากฝั่ง local แนบเฉพาะหนังสือที่ตีพิมพ์หลังปี 1950:
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":[]}]server รัน join แบบไหน
หัวข้อที่มีชื่อว่า “server รัน join แบบไหน”การทบทวนบทเดิมพบว่า $lookup ไม่มีกลยุทธ์ และ “queries the foreign collection once per input document” ข้อความนี้จริงเพียงบางกรณี explain รายงานกลยุทธ์ที่เลือกสำหรับรูปแบบ equality: IndexedLoopJoin (ค้นด้วย index ต่อ document ขาเข้า), HashJoin (สร้าง hash table ของฝั่ง foreign ครั้งเดียว) หรือ NestedLoopJoin (สแกน collection ฝั่ง foreign ต่อ document ขาเข้า) คำถามเดียวกัน field ฝั่ง foreign ต่างกัน:
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 104foreignField title, NO index : EQ_LOOKUP HashJoin | executionTimeMillis 23foreignField title, index title_1 : EQ_LOOKUP IndexedLoopJoin on title_1 | executionTimeMillis 107pipeline 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 ขาเข้า
$lookup + $unwind เป็นหนึ่ง stage
หัวข้อที่มีชื่อว่า “$lookup + $unwind เป็นหนึ่ง stage”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: unwindingrows with $unwind (inner join): 19240rows with preserveNullAndEmptyArrays (left join): 20000optimizer รวม $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 สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”เมื่อถูกขอให้ “แนบรายละเอียดหนังสือกับแต่ละการขาย” 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):
// 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 booksconst 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 mswith title_1 : EQ_LOOKUP IndexedLoopJoin on title_1 | executionTimeMillis 10 | 100 rows in 13 msMental Model
หัวข้อที่มีชื่อว่า “Mental Model”$lookup คือ loop ที่รัน query แทนคุณ: สำหรับ document ขาเข้าแต่ละรายการ หาที่ตรงอีกฝั่ง การตัดสินใจเดียวคือ query ด้านในถูกตอบอย่างไร - ค้นด้วย index, hash table ที่สร้างครั้งเดียว หรือสแกน - และ explain บอกชื่อทางเลือกนั้น มี index หรือฝั่งเล็ก = ถูก; ฝั่งใหญ่ที่ไม่มี index = สแกนต่อแถว
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
$lookup (อ้างอิง) | ไม่มีข้อมูลซ้ำ; อัปเดตที่เดียว | ทุกการอ่านจ่ายค่า join (ค้น index ต่อแถวขาเข้า) |
| การ embed | อ่านครั้งเดียว ไม่มี join | ข้อมูลซ้ำ; การอัปเดตต้องตามทุกสำเนา |
| รูปแบบ pipeline | กรองและปรับรูปฝั่ง foreign | ซับซ้อนกว่า; ไม่รายงานกลยุทธ์ join |
| รูปแบบ equality | สั้น; อ่านกลยุทธ์ใน explain ได้ | equality ได้เพียงเงื่อนไขเดียว |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
$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
fromcollection can be sharded” และ array ของ localField จับคู่กับ foreignField แบบ scalar ได้ “without an$unwindstage” ($lookup, ตรวจ 2026-09-30) - สองพฤติกรรมที่บทเรียนรุ่นเก่ายังบอกว่าทำไม่ได้
แท็บ Python/Go/Rust สะท้อนพฤติกรรมที่พิสูจน์แล้วของ shell/Node; แต่ละแท็บถูกรันหนึ่งครั้งในโปรแกรม scratch กับ server เดียวกัน ($lookup แบบ equality คืน array book ต่อ order) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส