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

$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 }

$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"]
document ใน sales แต่ละอัน join เข้ากับ books ที่ match แล้วพาผลไปในฟิลด์ array ใหม่
db.sales.aggregate([
{ $lookup: {
from: "books",
localField: "title",
foreignField: "_id",
as: "book"
} }
])

ตอนนี้ 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” ชัดขึ้น

เมื่อต้องการมากกว่าการเทียบ 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"
} }
])

ตอนนี้ 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
ตัวเลือกBenefitCost
$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 แล้วใช้ $lookup join ตอน query แทนที่จะฝังข้อมูลสินค้าไว้ในทุก order เพราะข้อมูลสินค้าเปลี่ยนบ่อยกว่า order

Keller Williams — รายงานอสังหาริมทรัพย์ที่ต้อง join ธุรกรรมกับข้อมูลนายหน้าและข้อมูล listing ใช้ $lookup แบบ pipeline-style เพื่อกรองเฉพาะธุรกรรมในช่วงเวลาที่ต้องการก่อน join ลดขนาดข้อมูลที่ engine ต้องประมวลผลข้าม collection

ใน $lookup แบบ equality option "as" ใช้ตั้งชื่ออะไร?
document ใน sales หนึ่งอัน match กับ book พอดีหนึ่งอัน ฟิลด์ที่ $lookup เพิ่มเข้ามาเป็น type อะไร?
index แบบไหนช่วย performance ของ $lookup ได้มากที่สุด?
ในแบบ pipeline $$t หมายถึงอะไร?