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

การออกแบบความสัมพันธ์

ทุกความสัมพันธ์เข้ากับหนึ่งในสามรูปแบบตาม cardinality - จำนวนของสิ่งหนึ่งที่สัมพันธ์กับจำนวนของอีกสิ่ง ผู้ใช้มีโปรไฟล์หนึ่งอัน (one-to-one) ผู้ใช้เขียนหลายโพสต์ (one-to-many) นักเรียนลงหลายคอร์สและคอร์สมีหลายนักเรียน (many-to-many) ใน relational database รูปแบบกำหนดกลไก: foreign key และ join table ใน MongoDB รูปแบบเพียงจำกัดตัวเลือก คุณยังเลือก embed หรือ reference สำหรับแต่ละอันโดยใช้ข้อแลกเปลี่ยนจากบทก่อน

บทนี้เตรียมข้อมูลของตัวเอง: users 1 000 รายการที่มี profile แบบ embed, posts 20 000 รายการที่อ้าง author และ collection เล็ก ๆ students / courses จับจาก mongo 8.3.11, mongosh 2.11.1 และ Node driver 7.7.0 บน Mac เครื่องเดียว (ตัวอย่าง transaction ต้องใช้ container replica set จากบท setup); แท็บ Python, Go และ Rust ถูกรันอย่างละครั้งในโปรแกรม scratch

mongosh/rel-seed.js
globalThis.plan = (cur) => {
const e = cur.explain("executionStats"), s = e.executionStats, stages = [];
for (let p = e.queryPlanner.winningPlan; p; p = p.inputStage) stages.push(p.stage + (p.indexName ? "(" + p.indexName + ")" : ""));
return stages.join(" > ") + " returned=" + s.nReturned + " keys=" + s.totalKeysExamined + " docs=" + s.totalDocsExamined;
};
db.users.insertMany(Array.from({ length: 1000 }, (_, i) => ({ _id: "user_" + i, email: "u" + i + "@example.test", profile: { displayName: "User " + i, bio: "Writes algorithms.", joinedYear: 2020 + (i % 7) } })));
db.posts.insertMany(Array.from({ length: 20000 }, (_, i) => ({ _id: "post_" + i, authorId: "user_" + (i % 1000), title: "Post " + i, tags: ["mongodb", "modeling", "tag" + (i % 5)], at: new Date(1700000000000 + i * 60000) })));
db.students.insertMany(Array.from({ length: 5 }, (_, i) => ({ _id: "student_" + i, name: "S" + i, courseIds: [] })));
db.courses.insertMany(Array.from({ length: 3 }, (_, i) => ({ _id: "c_" + i, title: "Course " + i, studentIds: [] })));
console.log("users:", db.users.countDocuments(), "| posts:", db.posts.countDocuments(), "| students:", db.students.countDocuments(), "| courses:", db.courses.countDocuments());
users: 1000 | posts: 20000 | students: 5 | courses: 3

เมื่อ document หนึ่งสัมพันธ์กับอีกอันเพียงอันเดียว การ embed มักเป็นทางที่เป็นธรรมชาติ - ข้อมูลที่เกี่ยวข้องมีขอบเขต เป็นของแม่ตัวเดียว และอ่านไปพร้อมกัน ผู้ใช้และโปรไฟล์กลายเป็น document เดียว:

flowchart LR
  U["User document"] --> P["profile sub-object embedded inside"]
One-to-one: embed ข้อมูลที่เกี่ยวข้องเป็น sub-object ใน document เดียวกัน
mongosh/rel-one-to-one.js
const u = db.users.findOne({ _id: "user_42" });
console.log(u);
console.log("one read returns the profile:", u.profile.displayName);
assert.strictEqual(u.profile.displayName, "User 42");
{
_id: 'user_42',
profile: {
displayName: 'User 42',
bio: 'Writes algorithms.',
joinedYear: 2020
}
}
one read returns the profile: User 42

แยก one-to-one ก็ต่อเมื่อส่วนที่เกี่ยวข้องใหญ่ ไม่ค่อยถูกอ่าน หรือเข้าถึงแยกต่างหาก - เช่นไบนารีขนาดใหญ่หรือบล็อก audit ใน collection ของมัน เพื่อให้ document ที่ใช้บ่อยเล็ก

กับแม่หนึ่งและลูกจำนวนมาก มีสองรูปแบบที่ดี ถ้าลูกมีน้อยและจำกัด embed เป็น array: tag สามอันของโพสต์คือ embed ที่สะอาด เมื่อฝั่ง “many” โตได้หรือถูก query แยก reference จากฝั่งลูก: โพสต์แต่ละอันเก็บ authorId reference นั้นต้องมี index ไม่เช่นนั้นการแสดงโพสต์ของผู้ใช้จะสแกนทุกโพสต์:

flowchart TB
  subgraph FewBounded["Few and bounded - embed array"]
    A0["Post document"] --> A1["tags: array of values"]
  end
  subgraph ManyUnbounded["Many or unbounded - reference from child"]
    B0["User document"]
    B1["Post: authorId points to user"]
    B2["Post: authorId points to user"]
    B1 --> B0
    B2 --> B0
  end
One-to-many: embed array เมื่อลูกมีน้อยและจำกัด; reference จากฝั่งลูกเมื่อฝั่ง many โตมาก
mongosh/rel-one-to-many.js
console.log("embedded tags (bounded) :", db.posts.findOne({ _id: "post_7" }).tags);
console.log("posts of user_42, no index on authorId :", plan(db.posts.find({ authorId: "user_42" })));
db.posts.createIndex({ authorId: 1, at: -1 });
console.log("posts of user_42, with the index :", plan(db.posts.find({ authorId: "user_42" }).sort({ at: -1 })));
console.log("page 1 (10 newest) :", plan(db.posts.find({ authorId: "user_42" }).sort({ at: -1 }).limit(10)));
embedded tags (bounded) : [ 'mongodb', 'modeling', 'tag2' ]
posts of user_42, no index on authorId : COLLSCAN returned=20 keys=0 docs=20000
posts of user_42, with the index : FETCH > IXSCAN(authorId_1_at_-1) returned=20 keys=20 docs=20
page 1 (10 newest) : LIMIT > FETCH > IXSCAN(authorId_1_at_-1) returned=10 keys=10 docs=10

คำถามที่แบ่งคือ “รายการนี้โตไม่จำกัดได้หรือไม่” tag ของโพสต์ไม่โต; โพสต์ของผู้ใช้อาจโต

แต่ละฝั่งสัมพันธ์กับหลายรายการของอีกฝั่ง - นักเรียนและคอร์ส แนวทางที่สะอาดคือ reference ด้วย array ของ id ฝั่งเดียวหรือสองฝั่ง array บนฝั่งที่อ่านคือหนึ่งการเขียนต่อการลงทะเบียน array ทั้ง สองฝั่ง ทำให้อ่านเร็วทั้งสองทิศทางแต่ทุกการลงทะเบียนเป็นสองการเขียนที่ต้องสำเร็จด้วยกัน - ไม่เช่นนั้น crash ระหว่างทางทิ้งข้อมูลที่ไม่สอดคล้องดังที่ครึ่งแรกของการรันนี้แสดง multi-document transaction (ครึ่งหลัง และแท็บ Node.js) แก้ได้:

flowchart TB
  S1["Student: courseIds array"]
  S2["Student: courseIds array"]
  C1["Course c_db"]
  C2["Course c_os"]
  S1 --> C1
  S1 --> C2
  S2 --> C1
Many-to-many: เก็บ array ของ id ที่อ้างอิงบนฝั่งที่อ่านบ่อยที่สุด
mongosh/rel-many-to-many.js
const enroll = (sid, cid, failBetween, session) => {
db.students.updateOne({ _id: sid }, { $addToSet: { courseIds: cid } }, { session });
if (failBetween) throw new Error("crash between the two writes");
db.courses.updateOne({ _id: cid }, { $addToSet: { studentIds: sid } }, { session });
};
try { enroll("student_0", "c_0", true); } catch (e) { console.log("without a transaction:", e.message); }
console.log(" student side:", db.students.findOne({ _id: "student_0" }).courseIds, "| course side:", db.courses.findOne({ _id: "c_0" }).studentIds, "<- inconsistent");
const session = db.getMongo().startSession();
const sdb = session.getDatabase(db.getName());
try { session.withTransaction(() => { sdb.students.updateOne({ _id: "student_1" }, { $addToSet: { courseIds: "c_1" } }); throw new Error("crash between the two writes"); }); }
catch (e) { console.log("with a transaction :", e.message); }
session.endSession();
console.log(" student side:", db.students.findOne({ _id: "student_1" }).courseIds, "| course side:", db.courses.findOne({ _id: "c_1" }).studentIds, "<- rolled back, consistent");
assert.strictEqual(db.students.findOne({ _id: "student_1" }).courseIds.length, 0);
without a transaction: crash between the two writes
student side: [ 'c_0' ] | course side: [] <- inconsistent
with a transaction : crash between the two writes
student side: [] | course side: [] <- rolled back, consistent
{ _id: 'student_7', name: 'Linus', courseIds: [ 'c_db' ] } { _id: 'c_db', title: 'Database Systems', studentIds: [ 'student_7' ] }

transaction ต้องใช้ replica set (container แบบ standalone จากบท setup ปฏิเสธ) และเพิ่มต้นทุนการประสานงาน เลือก reference สองฝั่งเฉพาะเมื่ออ่านทั้งสองทิศทางบ่อยจริง ๆ

ทุก document มีเพดานตายตัว 16 mebibyte (limits, ตรวจ 2026-09-30) เพียงพอสำหรับข้อมูลปกติ แต่ทำให้ embed กลายเป็นกับดักเมื่อ array ไม่มีขอบเขตตามธรรมชาติ ที่นี่ห้องแชต $push ข้อความขนาด 8 KB ลง document เดียว ที่แต่ละจุดตรวจเราวัดเวลาของ $set เล็ก ๆ ของ field ที่ไม่เกี่ยวข้อง:

mongosh/rel-16mb.js
db.rooms.insertOne({ _id: "room1", messages: [] });
const pad = "x".repeat(8000), size = () => db.rooms.aggregate([{ $match: { _id: "room1" } }, { $project: { b: { $bsonSize: "$$ROOT" } } }]).toArray()[0].b;
const marks = [1, 100, 500, 1000, 1500, 2000], rows = [];
let n = 0, err = null;
for (;;) {
try { db.rooms.updateOne({ _id: "room1" }, { $push: { messages: { n, text: pad } } }); } catch (e) { err = "code " + e.code + ": " + e.message.slice(e.message.indexOf("Resulting")); break; }
n++;
if (marks.includes(n)) { // time a tiny update ($set one field) on the document as it is now
const t = Date.now(); for (let i = 0; i < 5; i++) db.rooms.updateOne({ _id: "room1" }, { $set: { lastActive: i } });
rows.push({ pushes: n, docMB: +(size() / 1048576).toFixed(2), msPerTinyUpdate: (Date.now() - t) / 5 });
}
}
console.log(rows);
console.log("first failure after", n, "pushes ->", err);
assert.ok(err.includes("16777216"));
[
{ pushes: 1, docMB: 0.01, msPerTinyUpdate: 1.4 },
{ pushes: 100, docMB: 0.77, msPerTinyUpdate: 1 },
{ pushes: 500, docMB: 3.83, msPerTinyUpdate: 3.2 },
{ pushes: 1000, docMB: 7.66, msPerTinyUpdate: 5.8 },
{ pushes: 1500, docMB: 11.48, msPerTinyUpdate: 6.4 },
{ pushes: 2000, docMB: 15.31, msPerTinyUpdate: 7.6 }
]
first failure after 2089 pushes -> code 10334: Resulting document after update is larger than 16777216

ก่อนจะเกิด error ใด ๆ update เล็กตัวเดิมช้าลงราว ราวห้าเท่า (1.4 ms ที่ 0.01 MB เป็น 7.6 ms ที่ 15.3 MB) เพราะ server เขียน document ที่ใหญ่ขึ้นเรื่อย ๆ ใหม่ทุกครั้งที่เปลี่ยน - document ไม่ได้ “ย้าย” จริง ๆ (นั่นคือ storage engine MMAPv1 รุ่นเก่า); WiredTiger เขียนเวอร์ชันใหม่ของมัน push ครั้งที่ 2 090 ชนกำแพงด้วย Resulting document after update is larger than 16777216 ทางแก้คือ reference ข้อความจากฝั่งลูก เหมือนในหัวข้อ one-to-many:

mongosh/rel-16mb-fix.js
db.roomMessages.createIndex({ roomId: 1, n: -1 });
const pad = "x".repeat(8000);
db.roomMessages.insertMany(Array.from({ length: 2100 }, (_, n) => ({ roomId: "room2", n, text: pad }))); // more than the array version could hold
const t = Date.now(); for (let i = 0; i < 5; i++) db.rooms.updateOne({ _id: "room2" }, { $set: { lastActive: i } }, { upsert: true });
console.log("room document stays tiny; tiny update:", (Date.now() - t) / 5, "ms");
console.log("last 20 messages:", plan(db.roomMessages.find({ roomId: "room2" }).sort({ n: -1 }).limit(20)));
console.log("messages stored:", db.roomMessages.countDocuments({ roomId: "room2" }), "(no per-document ceiling)");
room document stays tiny; tiny update: 1.4 ms
last 20 messages: LIMIT > FETCH > IXSCAN(roomId_1_n_-1) returned=20 keys=20 docs=20
messages stored: 2100 (no per-document ceiling)

document ของห้องเล็กเสมอ การอัปเดตเสียเท่ากับวันแรก และ 20 ข้อความล่าสุดคือการอ่าน index 20 key ก่อนถึง 16 MiB นานแล้ว การโตคือศัตรู ไม่ใช่แค่เพดาน; รูปแบบ Bucket (บทถัดไป) คือทางสายกลางเมื่อต้องการหลายรายการต่อ document แต่จำกัดขนาดแต่ละอัน

  • cardinality จำกัดตัวเลือก; การเข้าถึงและการเติบโตเป็นตัวตัดสิน
  • one-to-one เกือบทั้งหมด embed; ส่วนย่อยที่ใหญ่หรือไม่ค่อยอ่านอาจมี collection ของตัวเอง
  • สำหรับ one-to-many ถามว่า “ฝั่ง many โตไม่จำกัดได้ไหม” ถ้าได้ ให้ reference จากฝั่งลูกและทำ index ให้ reference
  • many-to-many แบบ reference สองฝั่งเสียสองการเขียนต่อการเปลี่ยน; ใช้ transaction และเฉพาะเมื่อสองทิศทางร้อนทั้งคู่
  • ประสิทธิภาพเสื่อมก่อนถึง 16 MiB นานแล้ว (update เล็กช้าลงราว 5 เท่าที่ 15 MB ที่นี่)

เมื่อถูกขอให้ “เก็บประวัติแชตของห้อง” AI เก็บทุกอย่างไว้ใน document ของห้อง:

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

// AI comment: "one document per room - reads are a single lookup"
await db.collection("rooms").updateOne(
{ _id: roomId },
{ $push: { messages: { n, text } } }
);
เฉลย

ใช้ได้กับข้อความหลักร้อย ซึ่งเป็นเหตุผลที่ผ่านรีวิว การรันด้านบนไปต่อจนถึง push ที่ 2 090 แล้วล้มเหลวถาวร และทุก update ก่อนหน้าช้าลงเมื่อ array โต ไม่มีทางตัดทอนจากการเขียนที่ล้มเหลว ห้องจึงติดจนกว่าจะ migrate เก็บหนึ่ง document ต่อข้อความพร้อม roomId ที่มี index (บล็อกวิธีแก้ด้านบนเก็บ 2 100 ข้อความ มากกว่าที่ array จะเก็บได้) และเก็บแค่ตัวนับและ metadata ไว้ที่ห้อง

ถามทุกฝั่ง “many”: มีค่าสูงสุดตามธรรมชาติที่ระบุชื่อได้ไหม tag ต่อโพสต์: มี, embed ข้อความต่อห้อง: ไม่มี, reference การลงทะเบียน: ไม่จำกัดทั้งสองฝั่ง จึง reference และตัดสินว่าอ่านทิศทางไหน ขอบเขตที่คุณระบุไม่ได้คือขอบเขตที่คุณไม่มี

รูปแบบข้อดีต้นทุน
embed แบบ one-to-fewอ่านครั้งเดียว; เขียนแบบ atomicปลอดภัยเฉพาะเมื่อจำนวนลูกมีขอบเขตจริง
one-to-many, reference จากฝั่งลูกตัวแม่เล็กเสมอ; อ่านหน้า 10 key ด้วย indexต้อง query ที่สอง; ต้องมี index บน reference
many-to-many, array ทั้งสองฝั่งอ่านเร็วทั้งสองทาง ไม่ต้อง $lookupสองการเขียนต่อการเปลี่ยน; ต้องใช้ transaction เพื่อความสอดคล้อง
array ที่ embed แบบไม่จำกัดง่ายตอนแรกupdate ช้าลง 5 เท่าที่ 15 MB แล้วล้มเหลวถาวร
  • “cardinality ตัดสิน embed หรือ reference” - มันแค่จำกัด; การเข้าถึงและการเติบโตตัดสิน
  • “รายการที่เล็กวันนี้คือมีขอบเขต” - ถามว่าโตได้ไหม ไม่ใช่ตอนนี้ใหญ่แค่ไหน
  • “server ย้ายทั้ง document ทุกครั้งที่ update” - นั่นคือ MMAPv1; WiredTiger เขียนใหม่ และต้นทุนยังโตตามขนาด
  • “array ของ reference ทั้งสองฝั่งฟรี” - crash ระหว่างสองการเขียนทำให้ไม่ตรงกัน
  • “16 MiB คือปัญหาเดียว” - update ช้าลง 5 เท่าแล้วที่ 15 MB

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

คู่มือ MongoDB ระบุกรณีเดียวกับที่ใช้ที่นี่: reference เมื่อ “Your embedded data grows without bounds” และระบุขีดจำกัดขนาด BSON document 16 mebibyte ที่ตายตัว (Embedded Data Versus References; limits, ตรวจ 2026-09-30)

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

ผู้ใช้มีโปรไฟล์หนึ่งอันที่อ่านพร้อมผู้ใช้เสมอ โมเดลใดเหมาะ
one-to-many ที่ฝั่ง many โตไม่จำกัด reference ควรอยู่ที่ไหน
เกิดอะไรขึ้นกับห้องแชตที่ $push ทุกข้อความลง document เดียว
การลงทะเบียนนักเรียนใน many-to-many สองฝั่งต้องเขียนสองครั้ง อะไรทำให้สอดคล้อง
ทำไมจึงเก็บ array ของ reference ไว้เฉพาะฝั่งที่อ่าน