$lookup — การ join collection
MongoDB เก็บข้อมูลที่เกี่ยวข้องกันไว้คนละ collection บ่อยพอที่บางครั้งคุณต้องเย็บสอง collection กลับเข้าด้วยกัน stage $lookup ทำ join นั้นให้ในระหว่าง aggregation pipeline: ทุก document ที่ไหลเข้ามา จะไปหา document ที่ match ในอีก collection แล้วแนบผลกลับมาเป็นฟิลด์ array
โดยธรรมชาติแล้วนี่คือ left outer join ทุก document ที่เข้ามาจะผ่านออกไปหมด ถ้าไม่เจอคู่ก็ได้ array ว่างติดไปแทน
เราเพิ่ม collection ที่สองเข้ามาในร้าน นอกจาก sales ตอนนี้มี books ที่อธิบายหนังสือแต่ละเล่ม:
{ "_id": "Dune", "author": "Frank Herbert", "pages": 412, "year": 1965 }ออเดอร์ใน sales อ้างถึงหนังสือด้วย title:
{ "_id": "a1", "title": "Dune", "quantity": 3, "price": 12 }รูปแบบความเท่ากัน: localField และ foreignField
หัวข้อที่มีชื่อว่า “รูปแบบความเท่ากัน: localField และ foreignField”$lookup แบบง่ายที่สุดคือจับคู่ฟิลด์ฝั่ง local หนึ่งฟิลด์กับฟิลด์ฝั่ง foreign หนึ่งฟิลด์ คุณระบุ collection ที่จะ join (from) ฟิลด์บน document ที่เข้ามา (localField) ฟิลด์บน document ฝั่ง foreign (foreignField) และชื่อฟิลด์ array ที่จะเพิ่มเข้าไป (as)
flowchart LR S["sales: title = Dune"] --> L["$lookup from books on title"] B["books: _id = Dune"] --> L L --> O["sales doc with book array attached"]
db.sales.aggregate([ { $lookup: { from: "books", localField: "title", foreignField: "_id", as: "book" } }])const rows = await db.collection("sales").aggregate([ { $lookup: { from: "books", localField: "title", foreignField: "_id", as: "book", } },]).toArray();rows = list(db.sales.aggregate([ {"$lookup": { "from": "books", "localField": "title", "foreignField": "_id", "as": "book", }},]))pipeline := mongo.Pipeline{ {{"$lookup", bson.D{ {"from", "books"}, {"localField", "title"}, {"foreignField", "_id"}, {"as", "book"}, }}},}cursor, err := coll.Aggregate(ctx, pipeline)if err != nil { return err}let pipeline = vec![ doc! { "$lookup": { "from": "books", "localField": "title", "foreignField": "_id", "as": "book" } },];let mut cursor = coll.aggregate(pipeline).await?;ตอนนี้ document ใน sales แต่ละอันพา array book ที่เก็บ document book ที่ match ติดมาด้วย:
[ { "_id": "a1", "title": "Dune", "quantity": 3, "price": 12, "book": [ { "_id": "Dune", "author": "Frank Herbert", "pages": 412, "year": 1965 } ] }]ผลลัพธ์เป็น array เสมอ ต่อให้ match ได้มากสุดแค่อันเดียว เพราะโดยทั่วไปการ join match ได้หลายอัน ขั้นถัดไปที่ใช้บ่อยคือ $unwind (บทเรียนหน้า) เพื่อแบน array หนึ่งสมาชิกนั้นให้เป็น object ธรรมดา
ใน Compass: แท็บ Aggregations มี $lookup ให้เลือกจาก dropdown เลือก collection ฝั่ง foreign และฟิลด์ได้จากช่องกรอก ตัวอย่างผลลัพธ์จะโชว์ฟิลด์ array ใหม่ที่ต่อท้ายแต่ละ document ทำให้เห็นภาพพฤติกรรม “join แล้วแนบ array” ชัดขึ้น
รูปแบบ pipeline สำหรับ join ที่ซับซ้อนขึ้น
หัวข้อที่มีชื่อว่า “รูปแบบ pipeline สำหรับ join ที่ซับซ้อนขึ้น”เมื่อต้องการมากกว่าการเทียบ equality ฟิลด์เดียว — จะ filter document ฝั่ง foreign, project ให้เหลือบางฟิลด์ หรือ match หลายเงื่อนไข — ให้ใช้ แบบ pipeline คุณประกาศตัวแปรด้วย let จาก document ฝั่ง local แล้วรัน sub-pipeline บน collection ฝั่ง foreign โดยเทียบด้วย $expr ตรงนี้เราแนบเฉพาะหนังสือที่ตีพิมพ์หลังปี 1950 และเอามาแค่ author กับ year:
db.sales.aggregate([ { $lookup: { from: "books", let: { t: "$title" }, pipeline: [ { $match: { $expr: { $eq: ["$_id", "$$t"] } } }, { $match: { year: { $gt: 1950 } } }, { $project: { _id: 0, author: 1, year: 1 } } ], as: "book" } }])const rows = await db.collection("sales").aggregate([ { $lookup: { from: "books", let: { t: "$title" }, pipeline: [ { $match: { $expr: { $eq: ["$_id", "$$t"] } } }, { $match: { year: { $gt: 1950 } } }, { $project: { _id: 0, author: 1, year: 1 } }, ], as: "book", } },]).toArray();rows = list(db.sales.aggregate([ {"$lookup": { "from": "books", "let": {"t": "$title"}, "pipeline": [ {"$match": {"$expr": {"$eq": ["$_id", "$$t"]}}}, {"$match": {"year": {"$gt": 1950}}}, {"$project": {"_id": 0, "author": 1, "year": 1}}, ], "as": "book", }},]))pipeline := mongo.Pipeline{ {{"$lookup", bson.D{ {"from", "books"}, {"let", bson.D{{"t", "$title"}}}, {"pipeline", bson.A{ bson.D{{"$match", bson.D{{"$expr", bson.D{{"$eq", bson.A{"$_id", "$$t"}}}}}}}, bson.D{{"$match", bson.D{{"year", bson.D{{"$gt", 1950}}}}}}, bson.D{{"$project", bson.D{{"_id", 0}, {"author", 1}, {"year", 1}}}}, }}, {"as", "book"}, }}},}cursor, err := coll.Aggregate(ctx, pipeline)if err != nil { return err}let pipeline = vec![ doc! { "$lookup": { "from": "books", "let": { "t": "$title" }, "pipeline": [ { "$match": { "$expr": { "$eq": ["$_id", "$$t"] } } }, { "$match": { "year": { "$gt": 1950 } } }, { "$project": { "_id": 0, "author": 1, "year": 1 } } ], "as": "book" } },];let mut cursor = coll.aggregate(pipeline).await?;ตอนนี้ array ที่แนบมาเก็บเฉพาะ document ฝั่ง foreign ที่กรองและตัดฟิลด์แล้ว:
[ { "_id": "a1", "title": "Dune", "quantity": 3, "price": 12, "book": [ { "author": "Frank Herbert", "year": 1965 } ] }]สังเกต syntax $$t: $ ตัวเดียวหมายถึงฟิลด์ของ document ฝั่ง foreign ส่วน $$ หมายถึงตัวแปร let ที่ส่งเข้ามาจาก document ฝั่ง local
ทิปและกับดัก
หัวข้อที่มีชื่อว่า “ทิปและกับดัก”$lookupยิง query ไปที่ collection ฝั่ง foreign หนึ่งครั้งต่อ document ที่เข้ามา สร้าง index บนforeignField(หรือฟิลด์ที่ sub-pipeline ใช้ match) ไม่งั้น join จะช้าลงทันทีเมื่อข้อมูลโตขนาดจริง- อย่าลืมว่านี่คือ left outer join: document ที่ไม่มีคู่ก็ยังหลุดผ่านมาพร้อม array
asว่าง ถ้าอยากได้เฉพาะแถวที่ join ติด ให้$matchกรองทิ้งทีหลัง - join ทรงพลังแต่ไม่ฟรี ถ้าพบว่าตัวเอง join สอง collection เดิมทุกครั้งที่อ่าน นั่นคือสัญญาณว่าข้อมูลชุดนั้นน่าจะ embed ไว้ด้วยกันมากกว่าแยกแล้ว reference เป็นเรื่องการออกแบบ schema ที่จะพูดถึงต่อไป
- ฟิลด์ผลลัพธ์ที่ตั้งชื่อใน
asเป็น array เสมอ ถ้าคุณรู้ว่าจะ match ได้อันเดียวและอยากได้ object แบน ๆ ให้ต่อ$unwindท้าย$lookup
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
$lookup (reference ข้ามหลาย collection) | ข้อมูลไม่ซ้ำซ้อน อัปเดตที่เดียวจบ แชร์ข้าม document ได้ | ทุกครั้งที่อ่านต้องจ่ายค่า join เพิ่มงานให้ query engine หนึ่งรอบต่อ document ที่เข้ามา |
| Embedding (เก็บข้อมูลไว้ใน document เดียว) | อ่านครั้งเดียวจบ ไม่ต้อง join เร็วที่สุดสำหรับงาน read-heavy | ข้อมูลซ้ำถ้ามีหลายที่อ้างถึง อัปเดตต้องไล่แก้หลาย document และ document อาจโตจนชน limit |
$lookup แบบ pipeline (let + sub-pipeline) | กรองและจัดรูป document ฝั่ง foreign ได้ก่อน join match ด้วยเงื่อนไขซับซ้อนหลายฟิลด์ได้ | เขียนซับซ้อนกว่า อ่านยากกว่า และปรับ index ให้ sub-pipeline ใช้ได้เต็มที่ยากกว่า |
$lookup แบบ equality (localField/foreignField) | เขียนสั้น เข้าใจง่าย ปรับ index ตรงไปตรงมา | match ได้แค่เงื่อนไข equality เดียว ซับซ้อนกว่านั้นทำไม่ได้ |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”- ไม่สร้าง index บน
foreignField—$lookupยิง query ไปที่ collection ฝั่ง foreign หนึ่งครั้งต่อ document ที่เข้ามา ถ้าไม่มี index บนฟิลด์ที่ใช้ match การ join จะช้าลงมากเมื่อข้อมูลโต - ลืมว่า
$lookupเป็น left outer join เสมอ — document ที่ไม่มีคู่ยังผ่านมาพร้อม array ว่างในasไม่ได้โดนกรองทิ้ง ถ้าอยากได้เฉพาะแถวที่ join ติด ต้องเติม$matchกรอง array ว่างเอง - สับสน
$กับ$$ใน lookup แบบ pipeline —$ตัวเดียวหมายถึงฟิลด์ของ document ฝั่ง foreign ใน sub-pipeline ส่วน$$หมายถึงตัวแปรที่ประกาศด้วยletจากฝั่ง local สลับกันเมื่อไร query พังเงียบ ๆ หรือไม่ก็ error
💡 ตัวอย่างจากของจริง
Analytics dashboard — รายงานยอดขายที่ต้องโชว์ชื่อสินค้าและหมวดหมู่ควบคู่กับตัวเลขยอดขาย มักเก็บ
salesกับproductsแยก collection แล้วใช้$lookupjoin ตอน query แทนที่จะฝังข้อมูลสินค้าไว้ในทุก order เพราะข้อมูลสินค้าเปลี่ยนบ่อยกว่า orderKeller Williams — รายงานอสังหาริมทรัพย์ที่ต้อง join ธุรกรรมกับข้อมูลนายหน้าและข้อมูล listing ใช้
$lookupแบบ pipeline-style เพื่อกรองเฉพาะธุรกรรมในช่วงเวลาที่ต้องการก่อน join ลดขนาดข้อมูลที่ engine ต้องประมวลผลข้าม collection