การออกแบบข้อมูล
ถ้าคุณมาจากโลก relational นิสัยที่ยากที่สุดที่ต้องเลิกคือนิสัยที่ช่วยคุณมากที่สุดใน SQL: การแยกข้อมูลเป็นตารางแคบ ๆ จำนวนมากแล้ว join กลับตอน query วินัยนั้นคือ normalization ซึ่งกำจัดข้อมูลซ้ำเพื่อให้ข้อเท็จจริงหนึ่งอยู่ในที่เดียว MongoDB ให้คิดกลับด้าน คุณเริ่มจาก “แอปของฉันขออะไร และการอ่านครั้งเดียวจะคืนทุกอย่างที่หนึ่งหน้าจอต้องการได้อย่างไร”
หน่วยเก็บคือ document - โครงสร้างซ้อนแบบ JSON ที่มี array และ sub-object เพราะ document เก็บข้อมูลที่เกี่ยวข้องไว้ในตัวได้ คำถามเบื้องหลังทุกการตัดสินใจออกแบบคือ “ฉันซ้อนข้อมูลที่เกี่ยวข้องไว้ใน document หรือแยกไว้และเก็บ reference” ตัวเลือกนี้ - embed เทียบ reference - คือแกนของโมดูลนี้
ชุดข้อมูลในโมดูลนี้ ทุกบทเตรียมข้อมูลของตัวเองด้วยรูปร่างเดียวกัน: products (_id: "BK-n", title, price) และ order สองเวอร์ชัน - ordersEmb (รายการมีสำเนา title และราคา) กับ ordersRef (รายการมีแค่ productId) บทนี้ใช้สินค้า 500 รายการและ order 10 000 รายการที่มี 2-5 ชิ้น จับจาก 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");products: 500 | orders: 10000 embedded / 10000 referencedนิสัยแบบ relational เทียบกับความคิดแบบ document
หัวข้อที่มีชื่อว่า “นิสัยแบบ relational เทียบกับความคิดแบบ document”ใน schema แบบ relational คุณออกแบบรอบรูปร่างของข้อมูลและให้ query จ่ายค่าประกอบกลับ ใน schema แบบ document คุณออกแบบรอบ query และตัดสินเรื่องข้อมูลซ้ำล่วงหน้า:
flowchart TB
subgraph Relational["Relational - model for storage"]
R0["Start: list the entities"] --> R1["Split into normalized tables"]
R1 --> R2["No duplication - one fact, one place"]
R2 --> R3["Reassemble with JOINs at read time"]
end
subgraph Document["Document - model for access"]
D0["Start: list the queries"] --> D1["Group data that is read together"]
D1 --> D2["Embed or reference based on access"]
D2 --> D3["One read returns a whole screen"]
end ไม่มีแบบไหนถูกต้องสากล สัญชาตญาณ relational ยอดเยี่ยมเมื่อข้อมูลสัมพันธ์กันมากและถูกอัปเดตจากหลายทาง สัญชาตญาณ document โดดเด่นเมื่อแอปดึงชุดข้อมูลรูปเดิมซ้ำ ๆ - ผู้ใช้กับการตั้งค่า order กับรายการสินค้า บทความกับ tag
ตัวอย่าง: order พร้อมตัวเลขที่วัดได้
หัวข้อที่มีชื่อว่า “ตัวอย่าง: order พร้อมตัวเลขที่วัดได้”order เดียวกันในทั้งสองรูปแบบ แล้วตามด้วยตัวเลข document แบบ embed คือทุกอย่างที่หน้า “รายละเอียด order” ต้องการ ส่วนแบบ reference เล็กกว่าแต่ต้องใช้ collection products จึงอ่านได้ แท็บ Node.js นับ round trip ของ query จริงด้วย database profiler; แท็บ Python, Go และ Rust แสดงการอ่านแบบ reference เป็นสอง query:
const one = (c) => db[c].findOne({ _id: "order_42" });console.log("order_42 embedded :", JSON.stringify(one("ordersEmb")));console.log("order_42 referenced:", JSON.stringify(one("ordersRef")));const avg = (c) => Math.round(db[c].aggregate([{ $group: { _id: null, b: { $avg: { $bsonSize: "$$ROOT" } } } }]).toArray()[0].b);console.log("average document bytes - embedded:", avg("ordersEmb"), "| referenced:", avg("ordersRef"), "| product:", avg("products"));const mb = (c) => +(db[c].stats().size / 1048576).toFixed(2);console.log("collection data MB - embedded:", mb("ordersEmb"), "| referenced:", mb("ordersRef"), "+ products", mb("products"));assert.ok(avg("ordersEmb") > avg("ordersRef"));const products = db.collection("products"), emb = db.collection("ordersEmb"), ref = 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 }, { _id: "BK-3", title: "Compilers", price: 55 }]);await emb.insertOne({ _id: "o1", items: [{ sku: "BK-1", title: "Distributed Systems", qty: 1, price: 42 }, { sku: "BK-2", title: "The Mythical Man-Month", qty: 2, price: 18.5 }, { sku: "BK-3", title: "Compilers", qty: 1, price: 55 }] });await ref.insertOne({ _id: "o1", items: [{ productId: "BK-1", qty: 1 }, { productId: "BK-2", qty: 2 }, { productId: "BK-3", qty: 1 }] });const count = async (label, fn) => { await db.command({ profile: 0 }); await db.collection("system.profile").drop().catch(() => {}); // start each measurement with an empty profile await db.command({ profile: 2 }); // record every operation on THIS database await fn(); await db.command({ profile: 0 }); const n = await db.collection("system.profile").countDocuments({ op: { $in: ["query", "command"] }, "command.find": { $exists: true } }); console.log(label.padEnd(32), n, "query round trip(s)"); return n;};const a = await count("embedded order", () => emb.findOne({ _id: "o1" }));const b = await count("referenced, one query per item", async () => { const o = await ref.findOne({ _id: "o1" }); for (const i of o.items) await products.findOne({ _id: i.productId }); });const c = await count("referenced, $in", async () => { const o = await ref.findOne({ _id: "o1" }); await products.find({ _id: { $in: o.items.map((i) => i.productId) } }).toArray(); });await db.command({ profile: 0 });if (a !== 1 || b !== 4 || c !== 2) throw new Error("unexpected round-trip counts " + [a, b, c]);order = db.ordersRef.find_one({"_id": "order_42"})ids = [i["productId"] for i in order["items"]]products = list(db.products.find({"_id": {"$in": ids}}))var order struct { Items []struct { ProductID string `bson:"productId"` Qty int `bson:"qty"` } `bson:"items"`}if err := coll.FindOne(ctx, bson.M{"_id": "order_42"}).Decode(&order); err != nil { return err}ids := bson.A{}for _, it := range order.Items { ids = append(ids, it.ProductID)}cur, err := db.Collection("products").Find(ctx, bson.M{"_id": bson.M{"$in": ids}})if err != nil { return err}var products []bson.Mif err := cur.All(ctx, &products); err != nil { return err}let order = coll.find_one(doc! { "_id": "order_42" }).await?.unwrap();let ids: Vec<_> = order .get_array("items")? .iter() .map(|i| i.as_document().unwrap().get_str("productId").unwrap().to_string()) .collect();let mut cursor = db .collection::<Document>("products") .find(doc! { "_id": { "$in": ids } }) .await?;order_42 embedded : {"_id":"order_42","customerId":"cust_42","status":"new","items":[{"sku":"BK-481","title":"Book 481","qty":2,"price":11},{"sku":"BK-375","title":"Book 375","qty":1,"price":25},{"sku":"BK-154","title":"Book 154","qty":1,"price":44},{"sku":"BK-319","title":"Book 319","qty":3,"price":49},{"sku":"BK-295","title":"Book 295","qty":1,"price":25}]}order_42 referenced: {"_id":"order_42","customerId":"cust_42","status":"new","items":[{"productId":"BK-481","qty":2},{"productId":"BK-375","qty":1},{"productId":"BK-154","qty":1},{"productId":"BK-319","qty":3},{"productId":"BK-295","qty":1}]}average document bytes - embedded: 303 | referenced: 216 | product: 52collection data MB - embedded: 2.89 | referenced: 2.06 + products 0.02embedded order 1 query round trip(s)referenced, one query per item 4 query round trip(s)referenced, $in 2 query round trip(s)การ embed เสียไบต์ต่อ order มากกว่าราว 40% (เฉลี่ย 303 เทียบ 216, collection 2.89 MB เทียบ 2.06 MB) เพราะทุกรายการซ้ำ title และราคา - สำเนาที่อาจเลื่อนไหลจาก products สิ่งที่ได้คือการอ่านที่ใช้ query เดียว เวลาที่วัดจากการอ่านรายละเอียด order 2 000 ครั้งบน server ในเครื่อง:
const ids = Array.from({ length: 2000 }, (_, i) => "order_" + ((i * 37) % 10000));const time = (f) => { const t = Date.now(); f(); return Date.now() - t; };const emb = time(() => ids.forEach((id) => db.ordersEmb.findOne({ _id: id })));const ref = time(() => ids.forEach((id) => { const o = db.ordersRef.findOne({ _id: id }); db.products.find({ _id: { $in: o.items.map((i) => i.productId) } }).toArray(); }));const lookup = time(() => ids.forEach((id) => db.ordersRef.aggregate([{ $match: { _id: id } }, { $unwind: "$items" }, { $lookup: { from: "products", localField: "items.productId", foreignField: "_id", as: "p" } }]).toArray()));console.log("2 000 order-detail reads (each needs titles and prices):");console.log({ embedded_1_query: emb + " ms", referenced_2_queries: ref + " ms", referenced_1_lookup_aggregate: lookup + " ms" });2 000 order-detail reads (each needs titles and prices):{ embedded_1_query: '519 ms', referenced_2_queries: '929 ms', referenced_1_lookup_aggregate: '684 ms'}หนึ่ง query ใช้ 591 ms สำหรับการอ่าน 2 000 ครั้ง; สอง query ต่อ order ใช้ 1 104 ms; aggregate $lookup เดียวต่อ order ใช้ 662 ms การทดสอบนี้ไม่มีเครือข่าย บนเครือข่ายจริงซึ่งแต่ละ round trip เสียหลายมิลลิวินาที ช่องว่างจะโตตามจำนวน round trip ไม่ใช่ CPU
โมดูลนี้ครอบคลุมอะไรบ้าง
หัวข้อที่มีชื่อว่า “โมดูลนี้ครอบคลุมอะไรบ้าง”- Embed เทียบ reference - read locality, ข้อมูลซ้ำที่เลื่อนไหล, การเขียนแบบ atomic และต้นทุนจริงของ
$lookup - การออกแบบความสัมพันธ์ - one-to-one, one-to-many, many-to-many และหน้าผา 16 MiB ที่วัดจริง
- รูปแบบการออกแบบ schema - Subset, Computed, Bucket และ Extended Reference รันจริงทุกแบบ
- Schema validation - กฎ
$jsonSchemaพร้อมการปฏิเสธจริง - Anti-pattern - ข้อผิดพลาดที่ดูโอเคตอนแรกแต่เจ็บตอนโต วัดผลทุกอัน
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- MongoDB ยืดหยุ่นเรื่อง schema ไม่ใช่ไม่มี schema: document แตกต่างกันได้ แต่รูปทรงที่จงใจและสม่ำเสมอทำให้ collection เร็วและดูแลง่าย
- “ออกแบบเพื่อ query ของคุณ” หมายถึงต้องรู้รูปแบบการเข้าถึงหลัก ก่อน ออกแบบ; schema ที่สมบูรณ์แบบสำหรับการอ่านหนึ่งอาจผิดสำหรับอีกอัน
- การซ้ำข้อมูลทำได้และบางครั้งควรทำ: ต้นทุนของสำเนาคือการรักษาให้ตรงกัน ผลได้คือการอ่านที่เร็วและง่ายกว่า
- การวนต่อรายการบน collection ที่ reference คือ N+1 แบบคลาสสิก: 4 query สำหรับ order 3 รายการ
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”เมื่อถูกขอให้ “แสดง order พร้อมชื่อสินค้า” AI normalize แบบ SQL และแก้ทีละรายการ:
🐛 หา bug ในโค้ดนี้
// AI comment: "products live in their own collection - single source of truth"const order = await db.collection("ordersRef").findOne({ _id });for (const item of order.items) {item.product = await db.collection("products").findOne({ _id: item.productId });}return order;เฉลย
ดีไซน์ไม่ผิด; ลูปคือบั๊ก มันคือรูปแบบ N+1: หนึ่ง query สำหรับ order บวกหนึ่งต่อรายการ profiler ในแท็บ Node.js นับได้ 4 query สำหรับ order 3 รายการ เทียบกับ 1 สำหรับรูปแบบ embed และ 2 สำหรับการค้นด้วย $in ของ id สินค้าทั้งหมดครั้งเดียว - และส่วนต่างคูณด้วยทุกการเปิดหน้า รวม id เป็นหนึ่ง find({ _id: { $in: [...] } }) (แบบสอง query ที่แสดงในแท็บ Python, Go และ Rust) ใช้ aggregate $lookup หรือ embed field ที่หน้าแสดงเสมอ
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุกความสัมพันธ์คือการเดิมพันว่าจะจ่ายที่ฝั่งไหนของการอ่าน การ embed จ่ายตอนเขียนและในไบต์ (303 เทียบ 216 ต่อ order, สำเนาที่ต้องรักษาให้ตรง) เพื่อให้การอ่านทั่วไปเป็นหนึ่ง query การ reference จ่ายตอนอ่าน (2 query หรือ $lookup) เพื่อเก็บแต่ละข้อเท็จจริงไว้ที่เดียว ตัดสินทีละความสัมพันธ์ จาก query
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
| การ embed | หนึ่ง query (591 ms สำหรับอ่าน 2 000 ครั้ง); เขียนแบบ atomic | document ใหญ่กว่า (303 เทียบ 216 ไบต์); สำเนาเลื่อนไหลได้; เสี่ยงโตไม่หยุด |
| การ reference | document เล็กและเสถียร; ข้อเท็จจริงมีสำเนาเดียว | 2 query หรือ $lookup ต่อการอ่าน (1 104 / 662 ms) |
| รูปแบบ Bucket | document น้อยลงสำหรับชุดข้อมูลปริมาณมาก | ต้องเลือกขนาด bucket ล่วงหน้า; ต้องมี logic จัดกลุ่ม |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “MongoDB ไม่มี schema” - ทุก document มีรูปทรง; ฐานข้อมูลบังคับให้ก็ต่อเมื่อคุณเพิ่ม validation
- “แต่ละ entity มี schema ที่ถูกต้องเพียงแบบเดียว” - order เดียวกันเป็น 303 หรือ 216 ไบต์ ขึ้นกับรูปแบบการเข้าถึง
- “schema เปลี่ยนภายหลังไม่ได้” - เปลี่ยนได้; คุณเขียน migration และวางแผน rollout (ดู schema validation)
- “reference คือวิธีที่สะอาด embed เป็นทางลัด” - order แบบ reference ต้องใช้ 2-4 query สำหรับหน้าเดียวกัน
ตัวอย่างจากของจริง
Bosch - ใช้ MongoDB (ผ่าน Bosch IoT Insights) เก็บและวิเคราะห์ข้อมูล IoT แบบเรียลไทม์ เช่น ข้อมูลแรงบิดจากเครื่องมือประกอบเครื่องบินและข้อมูลจาก ECU ของรถยนต์ ปัจจุบันจัดการข้อมูลเรียงตามเวลาด้วยตนเองและวางแผนใช้ Time Series Collections ของ MongoDB ในอนาคตเพื่อจัดกลุ่มอัตโนมัติ แทนที่จะเป็นรูปแบบ bucket ที่เขียนเองอย่างที่บางครั้งเข้าใจกัน (mongodb.com/customers/bosch).
แท็บ Python/Go/Rust สะท้อนพฤติกรรมที่พิสูจน์แล้วของ shell/Node; แต่ละแท็บถูกรันหนึ่งครั้งในโปรแกรม scratch กับ server เดียวกัน (order แบบ reference พร้อมการค้นสินค้าด้วย $in คืนรายการชุดเดียวกัน) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส