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

การออกแบบข้อมูล

ถ้าคุณมาจากโลก 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

mongosh/dm-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");
products: 500 | orders: 10000 embedded / 10000 referenced

ใน 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 ปรับเพื่อการอ่านและตัดสินเรื่องข้อมูลซ้ำล่วงหน้า

ไม่มีแบบไหนถูกต้องสากล สัญชาตญาณ relational ยอดเยี่ยมเมื่อข้อมูลสัมพันธ์กันมากและถูกอัปเดตจากหลายทาง สัญชาตญาณ document โดดเด่นเมื่อแอปดึงชุดข้อมูลรูปเดิมซ้ำ ๆ - ผู้ใช้กับการตั้งค่า order กับรายการสินค้า บทความกับ tag

order เดียวกันในทั้งสองรูปแบบ แล้วตามด้วยตัวเลข document แบบ embed คือทุกอย่างที่หน้า “รายละเอียด order” ต้องการ ส่วนแบบ reference เล็กกว่าแต่ต้องใช้ collection products จึงอ่านได้ แท็บ Node.js นับ round trip ของ query จริงด้วย database profiler; แท็บ Python, Go และ Rust แสดงการอ่านแบบ reference เป็นสอง query:

mongosh/dm-size.js
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"));
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: 52
collection data MB - embedded: 2.89 | referenced: 2.06 + products 0.02
embedded 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 ในเครื่อง:

mongosh/dm-reads.js
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

  1. Embed เทียบ reference - read locality, ข้อมูลซ้ำที่เลื่อนไหล, การเขียนแบบ atomic และต้นทุนจริงของ $lookup
  2. การออกแบบความสัมพันธ์ - one-to-one, one-to-many, many-to-many และหน้าผา 16 MiB ที่วัดจริง
  3. รูปแบบการออกแบบ schema - Subset, Computed, Bucket และ Extended Reference รันจริงทุกแบบ
  4. Schema validation - กฎ $jsonSchema พร้อมการปฏิเสธจริง
  5. Anti-pattern - ข้อผิดพลาดที่ดูโอเคตอนแรกแต่เจ็บตอนโต วัดผลทุกอัน
  • MongoDB ยืดหยุ่นเรื่อง schema ไม่ใช่ไม่มี schema: document แตกต่างกันได้ แต่รูปทรงที่จงใจและสม่ำเสมอทำให้ collection เร็วและดูแลง่าย
  • “ออกแบบเพื่อ query ของคุณ” หมายถึงต้องรู้รูปแบบการเข้าถึงหลัก ก่อน ออกแบบ; schema ที่สมบูรณ์แบบสำหรับการอ่านหนึ่งอาจผิดสำหรับอีกอัน
  • การซ้ำข้อมูลทำได้และบางครั้งควรทำ: ต้นทุนของสำเนาคือการรักษาให้ตรงกัน ผลได้คือการอ่านที่เร็วและง่ายกว่า
  • การวนต่อรายการบน collection ที่ reference คือ N+1 แบบคลาสสิก: 4 query สำหรับ order 3 รายการ

เมื่อถูกขอให้ “แสดง 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 ที่หน้าแสดงเสมอ

ทุกความสัมพันธ์คือการเดิมพันว่าจะจ่ายที่ฝั่งไหนของการอ่าน การ embed จ่ายตอนเขียนและในไบต์ (303 เทียบ 216 ต่อ order, สำเนาที่ต้องรักษาให้ตรง) เพื่อให้การอ่านทั่วไปเป็นหนึ่ง query การ reference จ่ายตอนอ่าน (2 query หรือ $lookup) เพื่อเก็บแต่ละข้อเท็จจริงไว้ที่เดียว ตัดสินทีละความสัมพันธ์ จาก query

ทางเลือกข้อดีต้นทุน
การ embedหนึ่ง query (591 ms สำหรับอ่าน 2 000 ครั้ง); เขียนแบบ atomicdocument ใหญ่กว่า (303 เทียบ 216 ไบต์); สำเนาเลื่อนไหลได้; เสี่ยงโตไม่หยุด
การ referencedocument เล็กและเสถียร; ข้อเท็จจริงมีสำเนาเดียว2 query หรือ $lookup ต่อการอ่าน (1 104 / 662 ms)
รูปแบบ Bucketdocument น้อยลงสำหรับชุดข้อมูลปริมาณมากต้องเลือกขนาด bucket ล่วงหน้า; ต้องมี logic จัดกลุ่ม
  • “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 คืนรายการชุดเดียวกัน) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส

คำถามหลักของการออกแบบข้อมูลใน MongoDB คืออะไร
แนวคิดใดเริ่มจาก query ที่แอปจะรัน
คุณแสดง order ที่มี 3 รายการโดยเรียก products.findOne ต่อหนึ่งรายการ หน้านี้ใช้กี่ query
การ embed มีต้นทุนอะไรในชุดข้อมูลนี้
อ่านรายละเอียด order 2 000 ครั้ง แบบใดเร็วที่สุดที่นี่