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

รูปแบบการออกแบบ schema

เมื่อเลือกระหว่าง embed กับ reference ได้แล้ว คุณจะเจอปัญหาเดิม ๆ ซ้ำ: document ที่ถือมากกว่าที่หนึ่งหน้าจอต้องการ ค่าที่คำนวณใหม่ทุกครั้งที่อ่านแพง array ที่อยากโตไม่หยุด reference ที่บังคับให้ค้นเพียงเพื่อแสดงหนึ่ง field ชุมชน MongoDB ตั้งชื่อวิธีแก้ที่เกิดซ้ำว่า schema design pattern (Building with Patterns, ตรวจ 2026-09-30; pattern Bucket มี โพสต์ของตัวเอง) มันไม่ใช่ฟีเจอร์ที่เปิดสวิตช์ - แต่เป็นรูปทรงที่คุณให้ document บทนี้รันสี่แบบและวัดว่าแต่ละแบบได้อะไรเสียอะไร

จับจาก mongo 8.3.11, mongosh 2.11.1 และ Node driver 7.7.0 บน Mac เครื่องเดียว; แท็บ Python, Go และ Rust ถูกรันอย่างละครั้งในโปรแกรม scratch seed สร้างสินค้ายอดนิยมหนึ่งรายการที่มีรีวิว 10 000 รายการเก็บสองแบบ (document อ้วนหนึ่งอัน และ subset document บวก collection reviews)

mongosh/pat-seed.js
const rnd = (() => { let x = 11; return () => (x = (Math.imul(x, 1103515245) + 12345) >>> 0) / 4294967296; })();
// Subset: one popular product with 10 000 reviews, stored two ways
const reviews = Array.from({ length: 10000 }, (_, i) => ({ user: "u" + (i % 700), stars: 1 + Math.floor(rnd() * 5), text: "Review " + i + " " + "lorem ipsum ".repeat(12), at: new Date(1700000000000 + i * 3600000) }));
db.productsFat.insertOne({ _id: "BK-204", title: "Distributed Systems", reviews }); // everything embedded
db.reviews.insertMany(reviews.map((r) => ({ ...r, productId: "BK-204" })));
db.products.insertOne({ _id: "BK-204", title: "Distributed Systems", reviewCount: 10000, recentReviews: reviews.slice(-5).reverse() }); // subset + count
db.reviews.createIndex({ productId: 1, at: -1 });
const bsize = (c, id) => db[c].aggregate([{ $match: { _id: id } }, { $project: { b: { $bsonSize: "$$ROOT" } } }]).toArray()[0].b;
console.log("seeded. BK-204 as one fat document:", bsize("productsFat", "BK-204"), "bytes | subset document:", bsize("products", "BK-204"), "bytes");
seeded. BK-204 as one fat document: 2156196 bytes | subset document: 1154 bytes

ปัญหา: document embed array ขนาดใหญ่แต่การอ่านส่วนใหญ่ใช้แค่ไม่กี่สมาชิกแรก การโหลดทั้งหมดเปลืองหน่วยความจำและแบนด์วิดท์ (pattern นี้ “solves the problem of having the working set exceed the capacity of RAM due to large documents” ตามสรุป pattern) วิธีแก้: เก็บสมาชิกที่แสดงไม่กี่ตัว - subset - ใน document หลักและรายการเต็มใน collection ของมัน การเพิ่มรีวิวแตะทั้งสองที่ และ $push กับ $each, $sort และ $slice คงจำนวนใน document ไว้ที่ห้า:

mongosh/pat-subset.js
const time = (f) => { const t = Date.now(); f(); return Date.now() - t; };
console.log("1 000 product-page reads, fat document :", time(() => { for (let i = 0; i < 1000; i++) db.productsFat.findOne({ _id: "BK-204" }, { title: 1, reviews: { $slice: -5 } }); }), "ms (even with a $slice projection)");
console.log("1 000 product-page reads, subset :", time(() => { for (let i = 0; i < 1000; i++) db.products.findOne({ _id: "BK-204" }); }), "ms");
// a new review: write it to the full collection AND keep the subset current
const r = { productId: "BK-204", user: "Grace", stars: 5, text: "Indispensable.", at: new Date(1800000000000) };
db.reviews.insertOne(r);
db.products.updateOne({ _id: "BK-204" }, { $inc: { reviewCount: 1 }, $push: { recentReviews: { $each: [{ user: r.user, stars: r.stars, text: r.text, at: r.at }], $sort: { at: -1 }, $slice: 5 } } });
const p = db.products.findOne({ _id: "BK-204" });
console.log("after the new review:", p.reviewCount, "reviews counted;", p.recentReviews.length, "kept inline; newest is by", p.recentReviews[0].user);
console.log("the full list is still one indexed query away:", db.reviews.find({ productId: "BK-204" }).sort({ at: -1 }).skip(5).limit(3).toArray().length, "older reviews fetched");
assert.strictEqual(p.recentReviews.length, 5);
1 000 product-page reads, fat document : 1688 ms (even with a $slice projection)
1 000 product-page reads, subset : 235 ms
after the new review: 10001 reviews counted; 5 kept inline; newest is by Grace
the full list is still one indexed query away: 3 older reviews fetched
8 [ 'u8', 'u7', 'u6', 'u5', 'u4' ]

document อ้วนมี 2.16 MB, subset มี 1 154 ไบต์; อ่านหน้า 1 000 ครั้งใช้ 1 675 ms เทียบ 218 ms (projection แบบ $slice ไม่ช่วย - server ยังโหลด document) ต้นทุนคือการเขียนครั้งที่สอง และการรักษา reviewCount กับรายการในตัวให้ถูก

ปัญหา: ค่าที่ได้จาก document ต้นทางหลายอันและถูกอ่านบ่อยกว่าที่ต้นทางเปลี่ยนมาก วิธีแก้: คำนวณตอนเขียนและเก็บไว้ รายละเอียดสำคัญคือ atomicity: รายการและยอดรวมเปลี่ยนใน updateOne เดียว จึงขัดแย้งกันไม่ได้:

mongosh/pat-computed.js
db.orders.insertOne({ _id: "order_1", items: [], itemCount: 0, total: 0 });
const add = (id, sku, qty, price) => db.orders.updateOne({ _id: id }, { $push: { items: { sku, qty, price } }, $inc: { itemCount: qty, total: qty * price } }); // one atomic write keeps the sum right
add("order_1", "BK-1", 1, 42); add("order_1", "BK-2", 2, 18.5); add("order_1", "BK-3", 1, 55);
const o = db.orders.findOne({ _id: "order_1" });
const recomputed = db.orders.aggregate([{ $match: { _id: "order_1" } }, { $project: { t: { $sum: { $map: { input: "$items", in: { $multiply: ["$$this.qty", "$$this.price"] } } } } } }]).toArray()[0].t;
console.log({ stored_total: o.total, itemCount: o.itemCount, recomputed_from_items: recomputed });
assert.strictEqual(o.total, recomputed);
// the stale-field risk: change a price and forget the total
db.orders.updateOne({ _id: "order_1", "items.sku": "BK-2" }, { $set: { "items.$.price": 20 } });
const stale = db.orders.findOne({ _id: "order_1" });
console.log("after changing one price without touching total ->", { stored_total: stale.total, real_total: stale.items.reduce((n, i) => n + i.qty * i.price, 0) });
const drifted = db.orders.countDocuments({ $expr: { $ne: ["$total", { $sum: { $map: { input: "$items", in: { $multiply: ["$$this.qty", "$$this.price"] } } } }] } });
console.log("orders whose stored total no longer matches their items:", drifted);
{ stored_total: 134, itemCount: 4, recomputed_from_items: 134 }
after changing one price without touching total -> { stored_total: 134, real_total: 137 }
orders whose stored total no longer matches their items: 1

ข้อแลกเปลี่ยนคือสิ่งที่บรรทัดสุดท้ายแสดง: เส้นทางเขียนใดที่เปลี่ยนรายการโดยไม่แก้ยอดรวมจะทิ้งค่าล้าสมัย (เก็บ 134 จริง 137) โดยไม่มี error เพิ่ม query ตรวจสอบด้วย $expr แบบข้างบนใน health check

ปัญหา: สตรีมปริมาณมากของเรคคอร์ดเล็ก ๆ (ค่าเซนเซอร์ บรรทัด log) ที่เก็บ document ละหนึ่งหมายถึง document เล็กนับล้านและ index ใหญ่ วิธีแก้: รวมหลายเรคคอร์ดเป็นหนึ่ง document ต่อหนึ่งช่วงเวลา - ที่นี่ต่อเซนเซอร์ต่อชั่วโมง จำกัดที่ 200 ค่าอ่านเพื่อให้ bucket โตไม่จำกัดไม่ได้ (กับดัก array ไม่จำกัดจากบทที่แล้ว) ค่าอ่าน 20 000 รายการ ทั้งสองแบบ:

mongosh/pat-bucket.js
const N = 20000, base = 1700000000000, sensors = ["s1", "s2"];
const reading = (i) => ({ sensorId: sensors[i % 2], t: new Date(base + i * 5000), celsius: 20 + ((Math.imul(i, 2654435761) >>> 0) % 100) / 10 });
const plain = db.readingsPlain, buck = db.readingsBucket;
let t = Date.now();
for (let i = 0; i < N; i += 1000) plain.insertMany(Array.from({ length: 1000 }, (_, k) => reading(i + k)));
const plainMs = Date.now() - t;
t = Date.now();
const ops = [];
for (let i = 0; i < N; i++) { // one bucket per sensor per hour, capped at 200 readings
const r = reading(i), hour = new Date(Math.floor(r.t.getTime() / 3600000) * 3600000);
ops.push({ updateOne: { filter: { sensorId: r.sensorId, hour, count: { $lt: 200 } }, update: { $push: { readings: { t: r.t, celsius: r.celsius } }, $inc: { count: 1 }, $setOnInsert: { sensorId: r.sensorId, hour } }, upsert: true } });
if (ops.length === 1000) { buck.bulkWrite(ops, { ordered: true }); ops.length = 0; }
}
if (ops.length) buck.bulkWrite(ops, { ordered: true });
const buckMs = Date.now() - t;
plain.createIndex({ sensorId: 1, t: 1 }); buck.createIndex({ sensorId: 1, hour: 1 });
const ix = (c) => Math.round(Object.values(c.stats().indexSizes).reduce((a, b) => a + b, 0) / 1024);
console.log({ plain: { documents: plain.countDocuments(), ingestMs: plainMs, indexKB: ix(plain) }, bucketed: { documents: buck.countDocuments(), ingestMs: buckMs, indexKB: ix(buck) } });
const oneHour = buck.findOne({ sensorId: "s1", count: 200 });
console.log("a full bucket:", oneHour.count, "readings in one document; readings in the collection:", buck.aggregate([{ $group: { _id: null, n: { $sum: "$count" } } }]).toArray()[0].n);
assert.strictEqual(buck.aggregate([{ $group: { _id: null, n: { $sum: "$count" } } }]).toArray()[0].n, N);
{
plain: { documents: 20000, ingestMs: 140, indexKB: 760 },
bucketed: { documents: 112, ingestMs: 1903, indexKB: 28 }
}
a full bucket: 200 readings in one document; readings in the collection: 20000

การ bucket ลด collection เหลือ 112 document และ index จาก 760 KB เหลือ 28 KB แต่ ingest ช้าลงราว 14 เท่า (1 903 ms เทียบ 140 ms) เพราะทุกค่าอ่านเป็น upsert ที่ต้องหา bucket ก่อน นั่นคือข้อแลกเปลี่ยนตามจริง: เก็บและอ่านถูกกว่า เขียนแพงกว่า

สำหรับค่าที่วัดตามเวลา MongoDB สร้างและดูแล bucket ให้คุณ คู่มือ: “Compared to normal collections, storing time series data in time series collections improves query efficiency and reduces the disk usage” (time series collections, ตรวจ 2026-09-30) คุณประกาศ timeField และ metaField (ไม่บังคับ):

mongosh/pat-timeseries.js
db.createCollection("readingsTs", { timeseries: { timeField: "t", metaField: "sensorId", granularity: "seconds" } });
const base = 1700000000000;
for (let i = 0; i < 20000; i += 1000) db.readingsTs.insertMany(Array.from({ length: 1000 }, (_, k) => ({ sensorId: ["s1", "s2"][(i + k) % 2], t: new Date(base + (i + k) * 5000), celsius: 20 + ((Math.imul(i + k, 2654435761) >>> 0) % 100) / 10 })));
const s = db.readingsTs.stats();
console.log({ visibleDocuments: db.readingsTs.countDocuments(), buckets: s.timeseries.bucketCount, avgReadingsPerBucket: Math.round(20000 / s.timeseries.bucketCount) });
const kb = (c) => Math.round(db[c].stats().size / 1024); // data size in BSON bytes (storageSize is not meaningful this early)
console.log("data size KB - one document per reading:", kb("readingsPlain"), "| hand-made buckets:", kb("readingsBucket"), "| time series collection:", kb("readingsTs"));
console.log("query it like any collection:", db.readingsTs.aggregate([{ $match: { sensorId: "s1" } }, { $group: { _id: null, avg: { $avg: "$celsius" } } }, { $project: { _id: 0, avg: { $round: ["$avg", 2] } } }]).toArray());
assert.strictEqual(db.readingsTs.countDocuments(), 20000);
{ visibleDocuments: 20000, buckets: 56, avgReadingsPerBucket: 357 }
data size KB - one document per reading: 1301 | hand-made buckets: 731 | time series collection: 75
query it like any collection: [ { avg: 24.9 } ]

ค่าอ่าน 20 000 รายการเดิม: 1 301 KB แบบ document ธรรมดา, 731 KB แบบ bucket ที่ทำเอง, 75 KB ใน time series collection (ขนาดคือขนาดข้อมูล BSON และตัวเลขของ time series คือข้อมูล bucket แบบ columnar ที่บีบอัดแล้ว) ใช้ pattern ที่ทำเองก็ต่อเมื่อ collection แบบ native ไม่เข้ากับงาน (เช่นเมื่อต้องอัปเดตค่าอ่านในที่เดิม)

ปัญหา: คุณ reference อีก document แต่ต้องใช้หนึ่งหรือสอง field ของมันเพื่อแสดงตัวแม่เสมอ วิธีแก้: คัดลอก field ไม่กี่ตัวนั้นไว้ข้าง reference pattern นี้มีประโยชน์ที่สุด “when your application is experiencing lots of JOIN operations to bring together frequently accessed data” (สรุป pattern) คัดลอกเฉพาะ field ที่แทบไม่เปลี่ยน และวางแผนการรีเฟรช:

mongosh/pat-extended-reference.js
db.customers.insertMany([{ _id: "cust_77", name: "Grace Hopper", city: "Cambridge", notes: "long profile ..." }, { _id: "cust_78", name: "Linus", city: "Helsinki" }]);
db.ordersX.insertMany(Array.from({ length: 5000 }, (_, i) => ({ _id: "o" + i, customer: { id: i % 2 ? "cust_77" : "cust_78", name: i % 2 ? "Grace Hopper" : "Linus", city: i % 2 ? "Cambridge" : "Helsinki" }, total: 10 + (i % 90) }))); // id + the fields the page always shows
console.log("order list row, no lookup needed:", db.ordersX.findOne({ _id: "o1" }));
db.customers.updateOne({ _id: "cust_77" }, { $set: { city: "Arlington" } }); // the customer moves
console.log("orders now showing the old city:", db.ordersX.countDocuments({ "customer.id": "cust_77", "customer.city": "Cambridge" }));
const t = Date.now();
const r = db.ordersX.updateMany({ "customer.id": "cust_77" }, { $set: { "customer.city": "Arlington" } });
console.log("refresh copies:", r.modifiedCount, "orders updated in", Date.now() - t, "ms; still stale:", db.ordersX.countDocuments({ "customer.id": "cust_77", "customer.city": "Cambridge" }));
assert.strictEqual(r.modifiedCount, 2500);
order list row, no lookup needed: {
_id: 'o1',
customer: { id: 'cust_77', name: 'Grace Hopper', city: 'Cambridge' },
total: 11
}
orders now showing the old city: 2500
refresh copies: 2500 orders updated in 21 ms; still stale: 0
flowchart LR
  P1["Large array, only top few read"] --> S["Subset pattern"]
  P2["Expensive value, read often"] --> C["Computed pattern"]
  P3["High-volume stream of tiny records"] --> B["Bucket pattern"]
  P4["Reference needs one or two extra fields"] --> E["Extended Reference pattern"]
pattern การออกแบบแต่ละแบบแก้ปัญหาการออกแบบที่เกิดซ้ำแบบเฉพาะ

pattern รวมกันได้อิสระ: แอปเซนเซอร์ bucket ค่าอ่าน เก็บค่าเฉลี่ยรายชั่วโมงที่คำนวณไว้ในแต่ละ bucket และเก็บ extended reference ของชื่อเซนเซอร์ ซีรีส์บล็อกตั้งชื่อ pattern อื่นอีกมาก (Attribute, Outlier, Polymorphic, Schema Versioning, Tree, Approximation, Pre-allocation, Document Versioning) ที่บทนี้ไม่ได้ครอบคลุม

  • pattern คือข้อแลกเปลี่ยน ไม่ใช่ชัยชนะฟรี: Subset และ Extended Reference เพิ่มสำเนาที่ต้องรักษา Computed เพิ่มงานตอนเขียนและเสี่ยงค่าล้าสมัย Bucket ทำให้การเขียนแพงขึ้น 14 เท่าที่นี่
  • Computed และ Extended Reference ทำ denormalize; ต้องมีแผนรีเฟรช (query ตรวจสอบ, updateMany เป็นชุด)
  • สำหรับข้อมูลตามเวลา ลอง time series collection แบบ native ก่อนเขียน bucket เอง
  • จำกัด bucket (ที่นี่ 200 ค่าอ่าน) เพื่อไม่ให้กลายเป็น array ไม่จำกัดที่มันตั้งใจหลีกเลี่ยง
  • อย่าใช้ pattern แบบคาดเดา; ใช้เมื่อระบุปัญหาในงานของคุณได้

เมื่อถูกขอให้ “รักษายอดรวมของ order ให้ทันสมัย” AI เพิ่มรายการโดยไม่แตะยอดรวม - มันพึ่งว่ายอดรวมถูกอยู่แล้ว:

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

// AI comment: "total is a Computed field, so we only need to update the item"
await db.collection("orders").updateOne(
{ _id: "order_1", "items.sku": "BK-2" },
{ $set: { "items.$.price": 20 } }
);
เฉลย

pattern Computed ใช้ได้ก็ต่อเมื่อ ทุก เส้นทางเขียนดูแล field ที่คำนวณ โค้ดนี้เปลี่ยนราคาของรายการและไม่แตะ total: การรันข้างบนจบด้วยยอดรวมที่เก็บ 134 ขณะที่รายการรวมได้ 137 query ตรวจสอบ $expr พบ 1 order ที่ไม่ตรง และไม่มี error ที่ไหนเลย วิธีแก้คือเปลี่ยนทั้งสองในการอัปเดตแบบ atomic เดียว ($inc ส่วนต่าง หรือเขียนยอดรวมใหม่) และรัน query ตรวจสอบเป็นประจำ:

mongosh/pat-computed-fix.js
db.orders.updateOne(
{ _id: "order_1", "items.sku": "BK-2" },
[{ $set: { items: { $map: { input: "$items", in: { $cond: [{ $eq: ["$$this.sku", "BK-2"] }, { $mergeObjects: ["$$this", { price: 20 }] }, "$$this"] } } } } },
{ $set: { total: { $sum: { $map: { input: "$items", in: { $multiply: ["$$this.qty", "$$this.price"] } } } } } }] // items and total change in ONE update
);
const o = db.orders.findOne({ _id: "order_1" });
const real = o.items.reduce((n, i) => n + i.qty * i.price, 0);
console.log({ stored_total: o.total, real_total: real, ordersOutOfSync: db.orders.countDocuments({ $expr: { $ne: ["$total", { $sum: { $map: { input: "$items", in: { $multiply: ["$$this.qty", "$$this.price"] } } } }] } }) });
assert.strictEqual(o.total, real);
{ stored_total: 137, real_total: 137, ordersOutOfSync: 0 }

ทุก pattern ย้ายงานจากเวลาอ่านไปเวลาเขียน (Computed, Bucket, Subset) หรือย้าย join เข้าไปใน document (Extended Reference) ค่าใช้จ่ายมาในรูปสำเนาที่ต้องรักษาให้ถูกหรือเส้นทางเขียนที่ต้องดูแล ก่อนใช้ ให้ระบุการอ่านที่คุณจะเร่ง และเส้นทางเขียนที่ตอนนี้มีงานเพิ่มหนึ่งอย่าง

รูปแบบข้อดีต้นทุน
Subsetการอ่าน 2.16 MB กลายเป็น 1 154 ไบต์เขียนครั้งที่สอง; สำเนาในตัวต้องทันสมัย
Computedไม่ต้องคำนวณตอนอ่าน; atomic ไปกับรายการล้าสมัยถ้าเส้นทางเขียนใดลืม (134 เทียบ 137)
Bucket112 document และ index 28 KB แทน 20 000 และ 760 KBingest ช้ากว่า 14 เท่าที่นี่; ต้องเลือกขนาด bucket
time series collection75 KB ไม่ต้องเขียนโค้ด bucketเฉพาะค่าที่วัดตามเวลา; อัปเดตในที่เดิมได้จำกัด
Extended Referenceไม่ต้อง lookup สำหรับ field ที่ใช้บ่อยต้องรีเฟรชเมื่อเปลี่ยน: อัปเดต 2 500 order
  • “projection แบบ $slice ทำให้อ่าน document อ้วนได้ถูก” - server ยังโหลดมัน: 1 675 ms เทียบ 218 ms
  • “field ที่คำนวณดูแลตัวเอง” - การเขียนที่ลืมหนึ่งครั้งทำให้คลาด 3 โดยไม่มี error
  • “bucket ชนะล้วน ๆ” - การอ่านและการเก็บดีขึ้น; ingest ช้าลง 14 เท่า
  • “bucket ที่ทำเองคือวิธีเก็บ time series” - collection แบบ native เก็บข้อมูลเดียวกันใน 75 KB
  • “การคัดลอก field ผิดเสมอในฐานข้อมูล” - Extended Reference ทำโดยตั้งใจ สำหรับ field ที่แทบไม่เปลี่ยน

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

Bosch ใช้ MongoDB (ผ่าน Bosch IoT Insights) เก็บและวิเคราะห์ข้อมูล IoT แบบเรียลไทม์ และวางแผนย้ายไปใช้ Time Series Collections ของ MongoDB แทนการจัดกลุ่มข้อมูลด้วยมือ (mongodb.com/customers/bosch) - ตัวเลือกเดียวกับที่วัดข้างบนระหว่าง Bucket ที่ทำเองกับ collection แบบ native

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

หน้าสินค้าต้องการแค่ 5 รีวิวล่าสุดจาก 10 000 รูปแบบใดเหมาะ และประหยัดอะไร
อะไรทำให้ยอดรวมแบบ Computed ถูกต้องเมื่อเพิ่มรายการ
การ bucket 20 000 ค่าอ่านเป็น document รายชั่วโมงเปลี่ยนอะไร
เมื่อไรควรใช้ time series collection แบบ native แทน Bucket ที่ทำเอง
ลูกค้าย้ายเมือง ใน Extended Reference ราคาที่ต้องจ่ายคืออะไร
ทำไมควรใช้ pattern เมื่อระบุปัญหาที่มันแก้ได้เท่านั้น