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
const rnd = (() => { let x = 7; return () => (x = (Math.imul(x, 1103515245) + 12345) >>> 0) / 4294967296; })(); // repeatable pseudo-random numbersdb.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 copiesdb.ordersRef.insertMany(specs.map(({ lines, ...o }) => ({ ...o, items: lines.map((l) => ({ productId: "BK-" + l.p, qty: l.qty })) }))); // references onlyconsole.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 referencedEmbed: เก็บไว้ด้วยกัน
หัวข้อที่มีชื่อว่า “Embed: เก็บไว้ด้วยกัน”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 เดียวที่เปลี่ยนจำนวนของรายการและสถานะ:
db.scratch.insertOne(db.ordersEmb.findOne({ _id: "order_1" })); // work on a copy so later totals stay untouchedconst 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 writeconst 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: แยกไว้
หัวข้อที่มีชื่อว่า “Reference: แยกไว้”เมื่อ reference ตัวแม่เก็บแค่ identifier และข้อมูลจริงอยู่ครั้งเดียวใน collection ของมันเอง ไม่มีข้อมูลซ้ำและ order เล็ก ต้นทุนเป็นภาพสะท้อนของ embed - การ resolve id ต้อง ค้นครั้งที่สอง ไม่ว่าในโค้ดแอปหรือด้วย $lookup คำถามรายได้รวมเดียวกัน ตอบจากแต่ละรูปแบบ (แท็บ Node.js resolve สินค้าของหนึ่ง order ด้วย $lookup):
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");const products = db.collection("products"), orders = db.collection("ordersRef");await products.insertMany([{ _id: "BK-1", title: "Distributed Systems", price: 42 }, { _id: "BK-2", title: "The Mythical Man-Month", price: 18.5 }]);await orders.insertOne({ _id: "o1", items: [{ productId: "BK-1", qty: 1 }, { productId: "BK-2", qty: 2 }] });const [o] = await orders.aggregate([ { $match: { _id: "o1" } }, { $lookup: { from: "products", localField: "items.productId", foreignField: "_id", as: "products" } },]).toArray();console.log(o.products.map((p) => p.title));if (o.products.length !== 2) throw new Error("lookup should resolve both products");rows = list(db.ordersRef.aggregate([ {"$match": {"_id": "o1"}}, {"$lookup": { "from": "products", "localField": "items.productId", "foreignField": "_id", "as": "products", }},]))pipeline := mongo.Pipeline{ {{Key: "$match", Value: bson.D{{Key: "_id", Value: "o1"}}}}, {{Key: "$lookup", Value: bson.D{ {Key: "from", Value: "products"}, {Key: "localField", Value: "items.productId"}, {Key: "foreignField", Value: "_id"}, {Key: "as", Value: "products"}, }}},}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! { "$match": { "_id": "o1" } }, doc! { "$lookup": { "from": "products", "localField": "items.productId", "foreignField": "_id", "as": "products", } }, ]) .await?;{ 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:
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 changesconsole.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): 69catalogue price before: 17catalogue price after : 99an 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 ข้อมูลที่ ใหญ่ ใช้ร่วมกันหลายแม่ อัปเดตบ่อยด้วยตัวเอง หรือจำนวนไม่จำกัด
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 สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”หลังราคาแคตตาล็อกเปลี่ยน 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 ถ้าต้องการให้ตามแคตตาล็อก:
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 602Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ถามว่าแต่ละ field มีความหมายว่าอะไร ถ้าหมายถึง “ค่าปัจจุบันของสิ่งที่อยู่ที่อื่น” ให้ reference ถ้าหมายถึง “ค่า ณ ขณะที่ document นี้ถูกสร้าง” ให้ embed - มันเป็นข้อเท็จจริงของ document นี้ และสำเนาคือประเด็น จากนั้นตรวจขอบเขต: embed เฉพาะสิ่งที่ไม่โตไม่จำกัด
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| กรณี | ข้อดีของตัวเลือกที่แนะนำ | ต้นทุน |
|---|---|---|
| 1:1 (ผู้ใช้ + โปรไฟล์) - embed | อ่านครั้งเดียว เขียนแบบ atomic | document ใหญ่ขึ้น; แยกทีหลังยากขึ้น |
| 1:many จำกัด (โพสต์ + tag) - embed | เร็ว ง่าย ไม่มี join | ต้องมั่นใจว่าขอบเขตจริง |
| 1:many ไม่จำกัด (ผู้ใช้ + โพสต์) - reference จากฝั่งลูก | ตัวแม่เล็กเสมอ; ลูกแบ่งหน้าได้อิสระ | query ที่สอง; ไม่มีการเขียน atomic ข้ามแม่และลูก |
| ราคาที่จ่ายใน order - embed snapshot | ประวัติยังจริง; สแกน 26 ms | ไม่ตามแคตตาล็อก (โดยออกแบบ) |
| many:many - array ของ id ทั้งสองฝั่ง | อ่านเร็วทั้งสองทิศทาง | เขียนสองเท่า; ต้องรักษาสองฝั่งให้ตรงกัน |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “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) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส