การออกแบบความสัมพันธ์
ทุกความสัมพันธ์เข้ากับหนึ่งในสามรูปแบบตาม 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
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: 3One-to-one
หัวข้อที่มีชื่อว่า “One-to-one”เมื่อ document หนึ่งสัมพันธ์กับอีกอันเพียงอันเดียว การ embed มักเป็นทางที่เป็นธรรมชาติ - ข้อมูลที่เกี่ยวข้องมีขอบเขต เป็นของแม่ตัวเดียว และอ่านไปพร้อมกัน ผู้ใช้และโปรไฟล์กลายเป็น document เดียว:
flowchart LR U["User document"] --> P["profile sub-object embedded inside"]
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', email: '[email protected]', profile: { displayName: 'User 42', bio: 'Writes algorithms.', joinedYear: 2020 }}one read returns the profile: User 42แยก one-to-one ก็ต่อเมื่อส่วนที่เกี่ยวข้องใหญ่ ไม่ค่อยถูกอ่าน หรือเข้าถึงแยกต่างหาก - เช่นไบนารีขนาดใหญ่หรือบล็อก audit ใน collection ของมัน เพื่อให้ document ที่ใช้บ่อยเล็ก
One-to-many
หัวข้อที่มีชื่อว่า “One-to-many”กับแม่หนึ่งและลูกจำนวนมาก มีสองรูปแบบที่ดี ถ้าลูกมีน้อยและจำกัด 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 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=20000posts of user_42, with the index : FETCH > IXSCAN(authorId_1_at_-1) returned=20 keys=20 docs=20page 1 (10 newest) : LIMIT > FETCH > IXSCAN(authorId_1_at_-1) returned=10 keys=10 docs=10คำถามที่แบ่งคือ “รายการนี้โตไม่จำกัดได้หรือไม่” tag ของโพสต์ไม่โต; โพสต์ของผู้ใช้อาจโต
Many-to-many
หัวข้อที่มีชื่อว่า “Many-to-many”แต่ละฝั่งสัมพันธ์กับหลายรายการของอีกฝั่ง - นักเรียนและคอร์ส แนวทางที่สะอาดคือ 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
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);const students = db.collection("students"), courses = db.collection("courses");await students.insertOne({ _id: "student_7", name: "Linus", courseIds: [] });await courses.insertOne({ _id: "c_db", title: "Database Systems", studentIds: [] });const session = client.startSession();try { await session.withTransaction(async () => { // both sides or neither await students.updateOne({ _id: "student_7" }, { $addToSet: { courseIds: "c_db" } }, { session }); await courses.updateOne({ _id: "c_db" }, { $addToSet: { studentIds: "student_7" } }, { session }); });} finally { await session.endSession(); }console.log(await students.findOne({ _id: "student_7" }), await courses.findOne({ _id: "c_db" }));const s = await students.findOne({ _id: "student_7" }), c = await courses.findOne({ _id: "c_db" });if (!s.courseIds.includes("c_db") || !c.studentIds.includes("student_7")) throw new Error("both sides should be updated");with client.start_session() as s: def enroll(s): db.students.update_one({"_id": "student_7"}, {"$addToSet": {"courseIds": "c_db"}}, session=s) db.courses.update_one({"_id": "c_db"}, {"$addToSet": {"studentIds": "student_7"}}, session=s) s.with_transaction(enroll)session, err := client.StartSession()if err != nil { return err}defer session.EndSession(ctx)_, err = session.WithTransaction(ctx, func(sc context.Context) (any, error) { if _, err := db.Collection("students").UpdateOne(sc, bson.M{"_id": "student_7"}, bson.M{"$addToSet": bson.M{"courseIds": "c_db"}}); err != nil { return nil, err } return db.Collection("courses").UpdateOne(sc, bson.M{"_id": "c_db"}, bson.M{"$addToSet": bson.M{"studentIds": "student_7"}})})if err != nil { return err}let mut session = client.start_session().await?;session.start_transaction().await?;db.collection::<Document>("students") .update_one(doc! { "_id": "student_7" }, doc! { "$addToSet": { "courseIds": "c_db" } }) .session(&mut session) .await?;db.collection::<Document>("courses") .update_one(doc! { "_id": "c_db" }, doc! { "$addToSet": { "studentIds": "student_7" } }) .session(&mut session) .await?;session.commit_transaction().await?;without a transaction: crash between the two writes student side: [ 'c_0' ] | course side: [] <- inconsistentwith 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 สองฝั่งเฉพาะเมื่ออ่านทั้งสองทิศทางบ่อยจริง ๆ
ขีดจำกัด 16 MiB และ array ที่ไม่จำกัด
หัวข้อที่มีชื่อว่า “ขีดจำกัด 16 MiB และ array ที่ไม่จำกัด”ทุก document มีเพดานตายตัว 16 mebibyte (limits, ตรวจ 2026-09-30) เพียงพอสำหรับข้อมูลปกติ แต่ทำให้ embed กลายเป็นกับดักเมื่อ array ไม่มีขอบเขตตามธรรมชาติ ที่นี่ห้องแชต $push ข้อความขนาด 8 KB ลง document เดียว ที่แต่ละจุดตรวจเราวัดเวลาของ $set เล็ก ๆ ของ field ที่ไม่เกี่ยวข้อง:
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:
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 holdconst 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 mslast 20 messages: LIMIT > FETCH > IXSCAN(roomId_1_n_-1) returned=20 keys=20 docs=20messages 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 สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”เมื่อถูกขอให้ “เก็บประวัติแชตของห้อง” 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 ไว้ที่ห้อง
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ถามทุกฝั่ง “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 แล้วล้มเหลวถาวร |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “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 ทั้งสองข้าง) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส