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

Embed เทียบ reference

มีสองวิธีแสดงความสัมพันธ์ระหว่าง document และแทบทุกการตัดสินใจออกแบบคือการเลือกระหว่างสองวิธีนี้ คุณ embed ได้: วางข้อมูลที่เกี่ยวข้องไว้ใน document แม่ ซ้อนเป็น sub-object หรือ array หรือ reference: เก็บข้อมูลที่เกี่ยวข้องใน collection ของมันเองและเก็บ identifier - มักเป็น _id - ไว้ในตัวแม่ แล้วค้นเมื่อต้องการ การ embed เอื้อต่อการอ่าน; การ reference เอื้อต่อ single source of truth

บทนี้ใช้ข้อมูล order ของโมดูล: products 500 รายการ และ order 10 000 รายการสองเวอร์ชัน (ordersEmb ที่แต่ละรายการมีสำเนา title และราคา, ordersRef ที่มีแค่ productId) กับ helper revenue() ที่รวม qty x ราคา จับจาก mongo 8.3.11, mongosh 2.11.1 และ Node driver 7.7.0 บน Mac เครื่องเดียว; แท็บ Python, Go และ Rust ถูกรันอย่างละครั้งในโปรแกรม scratch

mongosh/evr-seed.js
const rnd = (() => { let x = 7; return () => (x = (Math.imul(x, 1103515245) + 12345) >>> 0) / 4294967296; })(); // repeatable pseudo-random numbers
db.products.insertMany(Array.from({ length: 500 }, (_, i) => ({ _id: "BK-" + i, title: "Book " + i, price: 10 + (i % 40) })));
const specs = Array.from({ length: 10000 }, (_, i) => ({
_id: "order_" + i, customerId: "cust_" + (i % 500), status: i % 3 ? "shipped" : "new",
lines: Array.from({ length: 2 + Math.floor(rnd() * 4) }, () => { const p = Math.floor(rnd() * 500); return { p, qty: 1 + Math.floor(rnd() * 3) }; }),
}));
db.ordersEmb.insertMany(specs.map(({ lines, ...o }) => ({ ...o, items: lines.map((l) => ({ sku: "BK-" + l.p, title: "Book " + l.p, qty: l.qty, price: 10 + (l.p % 40) })) }))); // embedded copies
db.ordersRef.insertMany(specs.map(({ lines, ...o }) => ({ ...o, items: lines.map((l) => ({ productId: "BK-" + l.p, qty: l.qty })) }))); // references only
console.log("products:", db.products.countDocuments(), "| orders:", db.ordersEmb.countDocuments(), "embedded /", db.ordersRef.countDocuments(), "referenced");
globalThis.revenue = (coll) => coll.aggregate([{ $unwind: "$items" }, { $group: { _id: null, total: { $sum: { $multiply: ["$items.qty", "$items.price"] } } } }]).toArray()[0].total;
products: 500 | orders: 10000 embedded / 10000 referenced

order แบบ embed มีรายการของมันอยู่ในตัว document จึงเป็นคำตอบครบของ “แสดง order นี้” (ดูตัวอย่าง ordersEmb ในบทก่อน) ข้อดีคือ read locality: อ่านครั้งเดียว ไม่ต้อง join ข้อมูลที่ embed ยังเปลี่ยนแบบ atomic ไปกับตัวแม่: คู่มือระบุว่า “write operations are atomic on the single-document level, even if modifying multiple values” (atomicity, ตรวจ 2026-09-30) updateOne เดียวที่เปลี่ยนจำนวนของรายการและสถานะ:

mongosh/evr-atomic.js
db.scratch.insertOne(db.ordersEmb.findOne({ _id: "order_1" })); // work on a copy so later totals stay untouched
const before = db.scratch.findOne({ _id: "order_1" });
const r = db.scratch.updateOne({ _id: "order_1" }, { $set: { status: "returned" }, $inc: { "items.0.qty": 1 } }); // two changes, one document: one atomic write
const after = db.scratch.findOne({ _id: "order_1" });
console.log({ matched: r.matchedCount, modified: r.modifiedCount }, "| items.0.qty", before.items[0].qty, "->", after.items[0].qty, "| status", before.status, "->", after.status);
assert.strictEqual(r.modifiedCount, 1);
{ matched: 1, modified: 1 } | items.0.qty 3 -> 4 | status shipped -> returned

ต้นทุนคือ ข้อมูลซ้ำ (title และราคาเป็นสำเนา) และ การโตของ document (ทุกรายการที่ embed ทำให้ order ใหญ่ขึ้น และ array ที่โตไม่หยุดจะชนเพดาน 16 MiB - ดูบทถัดไป)

เมื่อ reference ตัวแม่เก็บแค่ identifier และข้อมูลจริงอยู่ครั้งเดียวใน collection ของมันเอง ไม่มีข้อมูลซ้ำและ order เล็ก ต้นทุนเป็นภาพสะท้อนของ embed - การ resolve id ต้อง ค้นครั้งที่สอง ไม่ว่าในโค้ดแอปหรือด้วย $lookup คำถามรายได้รวมเดียวกัน ตอบจากแต่ละรูปแบบ (แท็บ Node.js resolve สินค้าของหนึ่ง order ด้วย $lookup):

mongosh/evr-lookup-cost.js
const time = (f) => { const t = Date.now(); const r = f(); return [r, Date.now() - t]; };
const [e, eMs] = time(() => revenue(db.ordersEmb));
const [r, rMs] = time(() => db.ordersRef.aggregate([{ $unwind: "$items" }, { $lookup: { from: "products", localField: "items.productId", foreignField: "_id", as: "p" } }, { $unwind: "$p" }, { $group: { _id: null, total: { $sum: { $multiply: ["$items.qty", "$p.price"] } } } }]).toArray()[0].total);
console.log({ embeddedRevenue: e, embeddedMs: eMs, referencedRevenue: r, referencedLookupMs: rMs });
assert.strictEqual(e, r); // same revenue both ways (before any price change)
const x = db.ordersRef.explain().aggregate([{ $lookup: { from: "products", localField: "items.productId", foreignField: "_id", as: "p" } }]);
console.log("how the server joins them:", JSON.stringify(x).match(/"strategy":"\w+"/)[0], "- the old lesson said $lookup had no strategy choice");
{
embeddedRevenue: 2040193,
embeddedMs: 26,
referencedRevenue: 2040193,
referencedLookupMs: 324
}
how the server joins them: "strategy":"IndexedLoopJoin" - the old lesson said $lookup had no strategy choice
[ 'Distributed Systems', 'The Mythical Man-Month' ]

ทั้งสองรูปแบบได้ยอดรวมตรงกัน (fence ยืนยัน) แต่การสแกนแบบ embed ใช้ 26 ms และแบบ $lookup ใช้ 324 ms - ราว 12 เท่า บทเดิมอ้างว่า $lookup “no optimizer choosing a join strategy”; explain รายงาน IndexedLoopJoin เพราะ products._id มี index (บท lookup แสดง HashJoin และ NestedLoopJoin ด้วย) reference ไม่ได้แพ้เพราะไม่มี optimizer แต่จ่ายค่า join จริงต่อรายการ

สำเนาเลื่อนไหล - และบางครั้งควรเป็นเช่นนั้น

หัวข้อที่มีชื่อว่า “สำเนาเลื่อนไหล - และบางครั้งควรเป็นเช่นนั้น”

ทีนี้ราคาแคตตาล็อกของ BK-7 เปลี่ยนจาก 17 เป็น 99:

mongosh/evr-drift.js
const line = (c, p) => db[c].aggregate([{ $match: { "items.sku": p } }, { $count: "orders" }]).toArray()[0]?.orders;
console.log("orders containing BK-7 (embedded):", db.ordersEmb.countDocuments({ "items.sku": "BK-7" }), "| (referenced):", db.ordersRef.countDocuments({ "items.productId": "BK-7" }));
console.log("catalogue price before:", db.products.findOne({ _id: "BK-7" }).price);
db.products.updateOne({ _id: "BK-7" }, { $set: { price: 99 } }); // the catalogue price changes
console.log("catalogue price after :", db.products.findOne({ _id: "BK-7" }).price);
console.log("an embedded order still shows :", db.ordersEmb.findOne({ "items.sku": "BK-7" }).items.find((i) => i.sku === "BK-7").price, "(its copy did not change)");
console.log("a referenced order resolves to :", db.products.findOne({ _id: "BK-7" }).price, "(one place to update)");
orders containing BK-7 (embedded): 69 | (referenced): 69
catalogue price before: 17
catalogue price after : 99
an embedded order still shows : 17 (its copy did not change)
a referenced order resolves to : 99 (one place to update)

รูปแบบ reference ตามแคตตาล็อกทันที - เปลี่ยนแค่ document เดียว สำเนาที่ embed ยังเป็น 17 นั่นเป็นบั๊กหรือไม่ขึ้นกับข้อมูล: ราคา ปัจจุบัน ของสินค้าควร reference แต่ราคา ที่จ่ายใน order เป็นข้อเท็จจริงในอดีต และสำเนาที่ embed คือ snapshot ที่คุณต้องการพอดี การเขียนทับคือบั๊ก:

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 หลีกเลี่ยงสำเนาแต่ต้องค้นครั้งที่สอง

กฎเบื้องต้นที่ใช้ได้: embed ข้อมูลที่ อ่านด้วยกัน เขียนด้วยกัน และมีขนาดจำกัด; reference ข้อมูลที่ ใหญ่ ใช้ร่วมกันหลายแม่ อัปเดตบ่อยด้วยตัวเอง หรือจำนวนไม่จำกัด

In Compass: เปิดแท็บ Schema ของ collection: Compass สุ่มตัวอย่าง document เพื่อแสดง field ที่มี ชนิด และความถี่ของ array และ object ซ้อน - วิธีเร็วที่จะดูว่าจริง ๆ คุณ embed หรือ reference

  • การ embed ให้การเขียนแบบ atomic ฟรี: แม่และลูกที่ embed เปลี่ยนในการดำเนินการเดียว
  • การ reference เก็บข้อมูลที่ใช้ร่วมกันไว้ที่เดียว - อัปเดตแล้วเห็นทุกที่ - แต่ทุกการอ่านที่ต้องใช้มันต้องจ่ายค่าค้น (324 ms เทียบ 26 ms ข้างบน)
  • ผสมทั้งสอง: embed snapshot เล็ก ๆ ที่แสดงเสมอ และเก็บ reference ไปยังเรคคอร์ดเต็ม
  • ถามทุกสำเนาที่ embed: เป็น snapshot (เก็บไว้) หรือกระจกเงา (ต้องซิงก์)
  • array ที่มีแต่โตคือวิธีที่พบบ่อยที่สุดที่การตัดสินใจ embed กลายเป็นปัญหา

หลังราคาแคตตาล็อกเปลี่ยน AI ถูกขอให้ “ทำให้ order สอดคล้องกัน” และใช้ updateMany กับ arrayFilters:

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

// AI comment: "propagate the new catalogue price so embedded prices are not stale"
await db.collection("ordersEmb").updateMany(
{ "items.sku": "BK-7" },
{ $set: { "items.$[i].price": 99 } },
{ arrayFilters: [{ "i.sku": "BK-7" }] }
);
เฉลย

มันรันได้โดยไม่ error และแม้แต่เป็นการใช้ arrayFilters ที่ถูกต้อง - ซึ่งเป็นเหตุผลที่มันอันตราย มันเขียนทับ order ในอดีต 69 รายการ; รายได้รวมเปลี่ยนจาก 2 040 193 เป็น 2 051 509 และ order_373 เปลี่ยนจาก 356 เป็น 602 หลังลูกค้าจ่ายไปแล้ว 356 ราคาที่ embed ใน order คือสิ่งที่เรียกเก็บจริง ไม่ใช่กระจกของแคตตาล็อก ปล่อยไว้ และ resolve เฉพาะข้อมูลแสดงผล (title) ผ่าน reference ถ้าต้องการให้ตามแคตตาล็อก:

mongosh/evr-bug.js
const total = () => +revenue(db.ordersEmb).toFixed(2);
const revenueBefore = total();
const order = db.ordersEmb.findOne({ "items.sku": "BK-7" });
const oldTotal = order.items.reduce((n, i) => n + i.qty * i.price, 0);
// AI: "keep embedded prices consistent with the catalogue" (the catalogue price of BK-7 is now 99)
const r = db.ordersEmb.updateMany({ "items.sku": "BK-7" }, { $set: { "items.$[i].price": 99 } }, { arrayFilters: [{ "i.sku": "BK-7" }] });
const newTotal = db.ordersEmb.findOne({ _id: order._id }).items.reduce((n, i) => n + i.qty * i.price, 0);
console.log({ ordersRewritten: r.modifiedCount, revenueBefore, revenueAfter: total() });
console.log("one past order:", order._id, "was worth", oldTotal, "and is now", newTotal);
assert.notStrictEqual(revenueBefore, total());
{ ordersRewritten: 69, revenueBefore: 2040193, revenueAfter: 2051509 }
one past order: order_373 was worth 356 and is now 602

ถามว่าแต่ละ field มีความหมายว่าอะไร ถ้าหมายถึง “ค่าปัจจุบันของสิ่งที่อยู่ที่อื่น” ให้ reference ถ้าหมายถึง “ค่า ณ ขณะที่ document นี้ถูกสร้าง” ให้ embed - มันเป็นข้อเท็จจริงของ document นี้ และสำเนาคือประเด็น จากนั้นตรวจขอบเขต: embed เฉพาะสิ่งที่ไม่โตไม่จำกัด

กรณีข้อดีของตัวเลือกที่แนะนำต้นทุน
1:1 (ผู้ใช้ + โปรไฟล์) - embedอ่านครั้งเดียว เขียนแบบ atomicdocument ใหญ่ขึ้น; แยกทีหลังยากขึ้น
1:many จำกัด (โพสต์ + tag) - embedเร็ว ง่าย ไม่มี joinต้องมั่นใจว่าขอบเขตจริง
1:many ไม่จำกัด (ผู้ใช้ + โพสต์) - reference จากฝั่งลูกตัวแม่เล็กเสมอ; ลูกแบ่งหน้าได้อิสระquery ที่สอง; ไม่มีการเขียน atomic ข้ามแม่และลูก
ราคาที่จ่ายใน order - embed snapshotประวัติยังจริง; สแกน 26 msไม่ตามแคตตาล็อก (โดยออกแบบ)
many:many - array ของ id ทั้งสองฝั่งอ่านเร็วทั้งสองทิศทางเขียนสองเท่า; ต้องรักษาสองฝั่งให้ตรงกัน
  • “embed เร็วกว่าเสมอ” - เฉพาะเมื่อ array มีขอบเขต; array ที่ไม่จำกัดทำให้ทุกการอ่านและเขียนช้าลง (ดูบทถัดไป มีตัวเลข)
  • “$lookup ไม่มีกลยุทธ์ join” - explain รายงาน IndexedLoopJoin
  • “สำเนา embed ที่ล้าสมัยเป็นบั๊กเสมอ” - ราคาที่จ่ายใน order คือประวัติ; การซิงก์เขียนทับ 69 order
  • “กฎเดียวใช้ได้ทุกความสัมพันธ์” - ตัดสินแต่ละอันจากรูปแบบการเข้าถึงและการเติบโต

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

แนวทางการออกแบบข้อมูลในคู่มือ MongoDB วางกรอบการตัดสินใจเดียวกัน: embed เมื่อ “Your application queries pieces of information together” และ reference เมื่อ “Your embedded data grows without bounds” หรือ “Data duplication is too complicated to manage and not preferred” (Embedded Data Versus References, ตรวจ 2026-09-30)

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

ข้อดีหลักของการ embed คืออะไร
ราคาแคตตาล็อกของ BK-7 เปลี่ยนจาก 17 เป็น 99 order แบบ embed แสดงอะไร
AI "ทำให้ราคาที่ embed สอดคล้อง" ด้วย updateMany หลังราคาเปลี่ยน ผิดตรงไหน
explain รายงานกลยุทธ์ join ใดสำหรับ $lookup จาก orders ไป products ด้วย _id
updateOne เดียวเปลี่ยนจำนวนของรายการที่ embed และสถานะ order เป็น atomic หรือไม่