Embedding กับ referencing
มีสองวิธีเท่านั้นในการแสดงความสัมพันธ์ระหว่าง document ใน MongoDB และเกือบทุกการตัดสินใจในการ modeling คือการเลือกระหว่างสองวิธีนี้ คุณสามารถ embed: วางข้อมูลที่เกี่ยวข้องไว้ภายใน parent document โดย nest เป็น sub-object หรือ array หรือ reference: เก็บข้อมูลที่เกี่ยวข้องไว้อีก collection แล้วเก็บตัวระบุ (ปกติคือ _id ของ document นั้น) ไว้ใน parent แล้วค่อยยิง query รอบสองไปดึงตอนต้องใช้ Embedding เอื้อต่อการอ่าน ส่วน referencing เอื้อต่อความยืดหยุ่นและหลีกเลี่ยงสำเนา การรู้ว่าควรหยิบอันไหนมาใช้คือสัญชาตญาณที่มีประโยชน์ที่สุดอย่างหนึ่งในการ modeling แบบ document
เราจะใช้คำสั่งซื้อและรายการสินค้าในนั้นเป็นตัวอย่างที่ใช้ต่อเนื่อง เพราะข้อมูลเดียวกันสามารถไปได้ทั้งสองทางอย่างสมเหตุสมผล ขึ้นอยู่กับว่าแอปพลิเคชันทำงานอย่างไร
Embedding: เก็บไว้ด้วยกัน
หัวข้อที่มีชื่อว่า “Embedding: เก็บไว้ด้วยกัน”เมื่อคุณ embed ข้อมูลที่เกี่ยวข้องจะอยู่ภายใน parent คำสั่งซื้อด้านล่างพารายการสินค้ามาด้วยเป็น array ที่ nest อยู่ ทำให้ document คือคำตอบที่สมบูรณ์ของ “แสดงคำสั่งซื้อนี้ให้ฉันดู”:
{ "_id": "order_5001", "customer": "Grace Hopper", "status": "shipped", "items": [ { "sku": "BK-204", "title": "Distributed Systems", "qty": 1, "price": 42.0 }, { "sku": "BK-991", "title": "The Mythical Man-Month", "qty": 2, "price": 18.5 } ]}สิ่งที่ได้คือ read locality: ทุกอย่างที่คุณต้องการมาถึงในการอ่านครั้งเดียว ไม่มี join ไม่มี round trip รอบสอง ข้อมูลที่ embed ยัง update แบบ atomic ไปพร้อม parent ได้ด้วย — การเขียนครั้งเดียวแตะทั้งคำสั่งซื้อและรายการสินค้าทั้งหมดไปพร้อมกัน ต้นทุนมีสองอย่าง อย่างแรกคือ ความซ้ำซ้อน: ชื่อและราคาของสินค้าเป็นสำเนา หากราคาสินค้าตัวจริง (canonical) เปลี่ยน สำเนานี้จะไม่เปลี่ยนตาม อย่างที่สองคือ การเติบโตของ document: ทุกรายการที่ embed ทำให้คำสั่งซื้อใหญ่ขึ้น และ document ที่เติบโตโดยไม่มีขอบเขตในที่สุดจะชนเพดานแข็ง 16 MB
Referencing: เก็บแยกไว้
หัวข้อที่มีชื่อว่า “Referencing: เก็บแยกไว้”เมื่อคุณ reference parent จะเก็บแค่ตัวระบุ ส่วนข้อมูลจริงอยู่ที่อื่น ตรงนี้คำสั่งซื้อเก็บลูกค้าเป็น reference และเก็บ id ของสินค้าสำหรับรายการต่าง ๆ แทนที่จะคัดลอกรายละเอียดสินค้า:
{ "_id": "order_5001", "customerId": "cust_77", "status": "shipped", "items": [ { "productId": "BK-204", "qty": 1 }, { "productId": "BK-991", "qty": 2 } ]}รายละเอียดสินค้าอยู่ที่เดียว คือใน collection ของสินค้า:
{ "_id": "BK-204", "title": "Distributed Systems", "price": 42.0 }ตอนนี้ ไม่มีความซ้ำซ้อน — การเปลี่ยนราคาใน products collection ถูกเห็นทุกที่ทันที — และ document คำสั่งซื้อยังคงเล็ก ต้นทุนคือภาพสะท้อนกลับด้านของ embedding: การ render คำสั่งซื้อต้องใช้ การค้นหาครั้งที่สอง (join ฝั่งแอปพลิเคชัน หรือ $lookup ใน aggregation pipeline) เพื่อแปลง id เหล่านั้นให้เป็นชื่อและราคา คุณแลกการอ่านครั้งเดียวที่รวดเร็วกับการรับประกันว่าข้อมูลจะไม่มีวันเก่าค้าง
เปรียบเทียบทั้งสองแบบเคียงข้างกัน
หัวข้อที่มีชื่อว่า “เปรียบเทียบทั้งสองแบบเคียงข้างกัน”การตัดสินใจมาลงที่ว่าคุณอยากเจ็บแบบไหนมากกว่า: ต้นทุนของสำเนาที่อาจคลาดเคลื่อน หรือต้นทุนของการค้นหาเพิ่มในทุกการอ่าน
flowchart TB
subgraph Embed["Embedding — one document"]
E0["Read the order"] --> E1["Order document"]
E1 --> E2["items array nested inside"]
E2 --> E3["Done — one read, no join"]
end
subgraph Reference["Referencing — linked documents"]
F0["Read the order"] --> F1["Order document with productId values"]
F1 --> F2["Second lookup into products"]
F2 --> F3["Join results to render"]
end กฎคร่าว ๆ ที่มีประโยชน์: embed ข้อมูลที่ อ่านด้วยกัน เขียนด้วยกัน และมีขนาดจำกัด ส่วน reference ข้อมูลที่ ใหญ่ ถูกแชร์ข้าม parent หลายตัว ถูกอัปเดตบ่อยด้วยตัวเอง หรือมีจำนวนไม่จำกัด ชื่อลูกค้าและรายการสินค้าที่เป็นของคำสั่งซื้อเดียวเท่านั้นคือการ embed ที่เป็นธรรมชาติ ส่วน product catalog ที่แชร์โดยคำสั่งซื้อนับพัน หรือรายการ comment ที่อาจโตได้ไม่จำกัด คือ reference ที่เป็นธรรมชาติ
ใน Compass: เปิดแท็บ Schema บน collection แล้ว Compass จะสุ่มตัวอย่าง document ของคุณเพื่อบอกว่ามี field อะไรบ้าง เป็น type ไหน และ array กับ nested object โผล่บ่อยแค่ไหน — เป็นวิธีรวดเร็วในการดูว่าจริง ๆ แล้วคุณได้ embedding หรือ referencing อยู่ และ array ที่ embed ของคุณโตขึ้นมากแค่ไหน
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- Embedding ให้การเขียนแบบ atomic แก่คุณฟรี ๆ: parent และลูกที่ embed ไว้เปลี่ยนในการทำงานครั้งเดียว โดยไม่ต้องประสานงานแบบ multi-document
- Referencing เก็บข้อมูลที่แชร์กันไว้ที่เดียว ดังนั้นการอัปเดตจึงถูกเห็นทุกที่ — แต่ทุกการอ่านที่ต้องการข้อมูลที่ resolve แล้วต้องจ่ายค่าค้นหาเพิ่ม
- คุณสามารถผสมทั้งสองแบบใน document เดียวได้ เป็นเรื่องปกติที่จะ embed snapshot ขนาดเล็กแบบ denormalize (ไม่กี่ field ที่คุณแสดงบ่อย ๆ) และ เก็บ reference ไปยังเรคคอร์ดฉบับเต็มไว้ใช้เมื่อต้องการทุกอย่าง
- จับตาการเติบโตของ document บน array ที่ embed array ที่มีแต่โตขึ้นเรื่อย ๆ คือวิธีที่พบบ่อยที่สุดที่การตัดสินใจ embed จะค่อย ๆ กลายเป็นปัญหาอย่างเงียบ ๆ
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สถานการณ์ | Benefit ของตัวเลือกที่แนะนำ | Cost |
|---|---|---|
| 1:1 (เช่น user + profile) — embed | อ่านครั้งเดียว เขียนแบบ atomic | document ใหญ่ขึ้นเล็กน้อย แยกออกทีหลังยากถ้าส่วนนั้นต้อง scale เดี่ยว ๆ |
| 1:many bounded (เช่น post + tags) — embed | เร็ว เรียบง่าย ไม่ต้อง join | ต้องมั่นใจว่าขอบเขตจำกัดจริง ไม่งั้นย้อนกลับมาแก้ยากภายหลัง |
| 1:many unbounded (เช่น user + posts) — reference จากฝั่งลูก | parent เล็กตลอดเวลา ลูก query และแบ่งหน้าได้อิสระ | ต้อง query ที่สองเพื่อดึงลูกทั้งหมด ไม่มี atomic write ข้าม parent-child |
| many:many (เช่น student ↔ course) — reference array ทั้งสองฝั่ง | อ่านเร็วทั้งสองทิศทางโดยไม่ต้อง $lookup | เขียนสองเท่า ต้องคอย sync ทั้งสองฝั่งให้ตรงกันเสมอ |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”- คิดว่า embedding เร็วกว่า referencing เสมอ — ถ้า array ที่ embed โตไม่มีขอบเขต การอ่านและเขียนจะช้าลงเรื่อย ๆ เพราะ server ต้องจัดการ document ทั้งก้อนทุกครั้ง สุดท้ายช้ากว่าการ reference เสียอีก
- คิดว่า
$lookupทำงานเหมือน JOIN ใน SQL ทุกประการ —$lookupไม่มี query optimizer แบบ relational database คอยเลือก join strategy ให้ ยิง$lookupบน hot path บ่อย ๆ แล้ว performance จะแย่กว่าที่คิดไว้เยอะ - ยึดกฎ embed-or-reference เดียวกับทุกความสัมพันธ์ใน schema — ต้องตัดสินทีละความสัมพันธ์ตาม access pattern และการเติบโตของความสัมพันธ์นั้น ๆ ไม่ใช่นโยบายเดียวที่ใช้กับทุก field
💡 ตัวอย่างจากของจริง
eBay — จัดการ product listing จำนวนมหาศาลด้วยการ reference แทนที่จะ embed ทุกอย่างไว้ที่เดียว เพื่อให้ seller อัปเดตราคาหรือสต๊อกได้โดยไม่ต้องแก้ document อื่นที่ point มาที่สินค้าเดียวกัน
Forbes — embed field ของบทความ เช่น tag และ metadata สั้น ๆ ไว้ใน document เดียวกัน เพื่อให้หน้าแสดงบทความโหลดได้ในการอ่านครั้งเดียว โดยไม่ต้อง join ไปหลาย collection