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

Document เทียบกับ relational

ถ้าคุณรู้จัก relational database อยู่แล้ว วิธีที่เร็วที่สุดในการคล่อง MongoDB คือการแปลคำที่คุณรู้ให้เป็นคำที่ MongoDB ใช้ แนวคิดส่วนใหญ่เรียงตรงกัน มีสองสามอันที่ต่างออกไปในแบบที่เป็นแก่นของโมเดล document บทเรียนนี้คือตารางแปลนั้น บวกกับการมองครั้งแรกไปที่คำถามที่โมดูล Data Modeling ตอบไว้อย่างครบถ้วน: เมื่อไรควร embed ข้อมูลที่เกี่ยวข้อง และเมื่อไรควรเก็บไว้ใน collection แยกต่างหาก

ฝั่งซ้ายคือโลก relational ฝั่งขวาคือของที่เทียบเคียงกันใน MongoDB:

  • Database → Database อันนี้คำเดียวกันและแนวคิดเดียวกัน: ภาชนะที่มีชื่อสำหรับบรรจุ collection
  • Table → Collection collection เก็บ document แบบเดียวกับที่ table เก็บ row แต่ไม่ได้บังคับให้ทุก document มี column เหมือนกันเป๊ะ
  • Row → Document document คือหนึ่งระเบียน ต่างจาก row ตรงที่ซ้อน array และ sub-object ได้ ไม่ใช่แค่แถวของ cell แบน ๆ
  • Column → Field field คือคู่ key-value ภายใน document field เป็นแบบต่อ document ดังนั้นสอง document ใน collection ไม่จำเป็นต้องมี field ชุดเดียวกัน
  • JOIN → Embedding หรือ $lookup ความสัมพันธ์ถูกจัดการด้วยการ embed ข้อมูลที่เกี่ยวข้องไว้ภายใน document เดียว หรือลิงก์ document ไว้แล้วค่อยรวมตอนอ่านด้วย aggregation stage $lookup
  • Schema → Flexible schema (พร้อม validation ที่เป็น optional) รูปร่างของข้อมูลคุณยืดหยุ่นโดยค่าเริ่มต้น และคุณสามารถเพิ่มกฎ validation เมื่อต้องการให้ database บังคับโครงสร้าง
  • Primary key → _id ทุก document มี _id ที่ไม่ซ้ำกัน บทบาทเดียวกับที่ primary key เล่นให้กับ row
flowchart LR
  subgraph SQL["Relational"]
    direction TB
    R1["Database"]
    R2["Table"]
    R3["Row"]
    R4["Column"]
    R5["JOIN"]
  end
  subgraph MDB["MongoDB"]
    direction TB
    M1["Database"]
    M2["Collection"]
    M3["Document"]
    M4["Field"]
    M5["Embedding or lookup"]
  end
  R1 --> M1
  R2 --> M2
  R3 --> M3
  R4 --> M4
  R5 --> M5
แนวคิดฝั่ง relational เทียบกับของที่ตรงกันใน MongoDB

ใน relational database คุณเกือบจะ normalise เสมอ: สมาชิกอยู่ในตาราง members และการยืมของพวกเขาอยู่ในตาราง loans ที่ join กันด้วย key คุณประกอบภาพเต็มด้วย JOIN ทุกครั้งที่ query

MongoDB ให้ทางเลือกแก่คุณ คุณ embed ข้อมูลที่เกี่ยวข้องไว้ภายใน document พ่อแม่โดยตรง ดังนั้นการอ่านครั้งเดียวคืนทุกอย่าง หรือเก็บข้อมูลใน collection แยก แล้วลิงก์ด้วย id จากนั้นรวมกันตอน query ด้วย $lookup — join ตอนอ่านของ MongoDB fence นี้สร้างทั้งสองรูปแบบสำหรับสมาชิกคนเดียวกันและอ่านกลับ (output จาก mongo 8.3.11, mongosh 2.11.1, Mac เครื่องนี้):

mongosh/dvr-embed-vs-lookup.js
const ada = ObjectId("65f0b3d4e4b0a1c2e4b0a1d1");
// Shape 1: embedded - one document holds the loans
db.embedded.insertOne({ _id: ada, name: "Ada", borrowed: [
{ title: "Compilers", due: "2026-07-01" }, { title: "Algorithms", due: "2026-07-15" },
] });
// Shape 2: referenced - two collections linked by member_id
db.members.insertOne({ _id: ada, name: "Ada" });
db.loans.insertMany([
{ member_id: ada, title: "Compilers", due: "2026-07-01" },
{ member_id: ada, title: "Algorithms", due: "2026-07-15" },
]);
console.log(db.embedded.findOne({ name: "Ada" })); // one read
const joined = db.members.aggregate([
{ $lookup: { from: "loans", localField: "_id", foreignField: "member_id", as: "borrowed" } },
{ $project: { "borrowed._id": 0, "borrowed.member_id": 0 } },
]).toArray(); // one aggregation, two collections
console.log(joined);
console.log("embedded doc bytes:", bsonsize(db.embedded.findOne()));
assert.strictEqual(joined[0].borrowed.length, 2);
{
_id: ObjectId('65f0b3d4e4b0a1c2e4b0a1d1'),
name: 'Ada',
borrowed: [
{ title: 'Compilers', due: '2026-07-01' },
{ title: 'Algorithms', due: '2026-07-15' }
]
}
[
{
_id: ObjectId('65f0b3d4e4b0a1c2e4b0a1d1'),
name: 'Ada',
borrowed: [
{ title: 'Compilers', due: '2026-07-01' },
{ title: 'Algorithms', due: '2026-07-15' }
]
}
]
embedded doc bytes: 150

การอ่านทั้งสองคืน ภาพเดียวกัน — ต่างกันที่งานเกิดขึ้นที่ไหน: รูปแบบ embed ทำ findOne ครั้งเดียว ส่วนรูปแบบอ้างอิงรัน $lookup ข้ามสอง collection

สัญชาตญาณของ relational คือ “database จะกันไม่ให้ฉันทิ้ง loan กำพร้า” MongoDB ไม่ทำ:

mongosh/dvr-no-foreign-key.js
db.members.drop(); db.loans.drop(); // start clean: this fence reuses the names above
const ada = ObjectId("65f0b3d4e4b0a1c2e4b0a1d1");
db.members.insertOne({ _id: ada, name: "Ada" });
db.loans.insertMany([{ member_id: ada, title: "Compilers" }, { member_id: ada, title: "Algorithms" }]);
// 1. insert a loan for a member that never existed - accepted
console.log(db.loans.insertOne({ member_id: ObjectId("000000000000000000000001"), title: "Orphan" }).acknowledged);
// 2. delete the member while loans still point at them - accepted
db.members.deleteOne({ _id: ada });
// 3. $lookup does not error - it returns an empty array
console.log(db.loans.aggregate([
{ $lookup: { from: "members", localField: "member_id", foreignField: "_id", as: "member" } },
{ $project: { _id: 0, title: 1, member: 1 } },
]).toArray());
console.log("loans still pointing at Ada:", db.loans.countDocuments({ member_id: ada }));
assert.strictEqual(db.loans.countDocuments({ member_id: ada }), 2);
true
[
{ title: 'Compilers', member: [] },
{ title: 'Algorithms', member: [] },
{ title: 'Orphan', member: [] }
]
loans still pointing at Ada: 2

“การละเมิด” ทั้งสามสำเร็จอย่างเงียบ ๆ ความสมบูรณ์ระหว่าง collection เป็นหน้าที่ของ คุณ (ในโค้ดแอปพลิเคชัน หรือด้วยการ embed เพื่อให้ลูกอยู่รอดเกินพ่อแม่ไม่ได้)

การลงรายละเอียดเต็มคือโมดูล Data Modeling แต่สัญชาตญาณที่ต้องสร้างตอนนี้คือ:

  • Embed เมื่อ ข้อมูลที่เกี่ยวข้องเป็นของพ่อแม่อันเดียว ถูกอ่านไปพร้อมกัน และไม่โตแบบไร้ขีดจำกัด หนังสือที่สมาชิกยืม, รายการสินค้าในออเดอร์, ความคิดเห็นล่าสุดของโพสต์ — เหล่านี้อ่านเป็น document เดียวในการเดินทางครั้งเดียว
  • เก็บแยกเมื่อ ข้อมูลถูกแชร์ระหว่างพ่อแม่หลายตัว ถูกอ้างอิงอย่างอิสระ หรือเติบโตใหญ่ แคตตาล็อกหนังสือที่สมาชิกหลายคนยืมควรเป็น collection ของตัวเอง อ้างอิงด้วย id ไม่ใช่คัดลอกเข้าไปในทุกสมาชิก

คำถามนำทางคือ ฉันอ่านข้อมูลนี้อย่างไร? ถ้าคุณเกือบจะต้องการพ่อแม่และลูกพร้อมกันเสมอ การ embed ก็คุ้มค่า ถ้าลูกมีชีวิตเป็นของตัวเอง ก็แยกออกไปแล้วลิงก์

  • อย่า normalise โดยอัตโนมัติ เมื่อมาจาก SQL แรงกระตุ้นคือการแยกทุกอย่างออกเป็น collection ที่ต่างกัน ใน MongoDB ข้อมูลที่ถูกอ่านพร้อมกันมักจะควรอยู่ด้วยกัน ใน document เดียว
  • collection ไม่ใช่ table เพราะไม่ได้บังคับชุด column ตายตัว สอง document สามารถพก field ต่างกันได้ และนั่นเป็นสิ่งที่ออกแบบให้อนุญาต
  • $lookup มีให้ใช้ แต่ไม่ใช่ join ฟรี ๆ การ embed โดยทั่วไปเร็วกว่าสำหรับข้อมูลที่คุณอ่านพร้อมกัน; เอื้อมไปหา $lookup เมื่อข้อมูลควรอยู่ใน collection แยกต่างหากจริง ๆ
  • _id คือ primary key ของคุณแบบฟรี ๆ คุณได้ identifier ที่ไม่ซ้ำและถูก index บนทุก document โดยไม่ต้องประกาศ

ผู้ช่วย AI ที่พอร์ต schema จาก SQL มักเขียน delete ราวกับมี ON DELETE CASCADE:

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

// AI comment: "loans reference members; deleting the member removes their loans (like ON DELETE CASCADE)"
await db.collection("members").deleteOne({ _id: memberId });
// loans are cleaned up automatically by the foreign key
เฉลย

MongoDB ไม่มี foreign key และไม่มี cascade เรารันโค้ดนี้จริง: สมาชิกถูกลบ แต่ loan สองรายการยังอยู่ (loans still pointing at Ada: 2 ด้านบน) และทุก $lookup จาก loans ไป members ตอนนี้คืน member: [] วิธีแก้คือทำการล้างให้ชัดเจน — ลบลูกใน unit of work เดียวกัน (หรือ embed ให้ลูกไปพร้อมพ่อแม่):

mongosh/dvr-cascade-fix.js
db.members.drop(); db.loans.drop();
const ada = ObjectId("65f0b3d4e4b0a1c2e4b0a1d1");
db.members.insertOne({ _id: ada, name: "Ada" });
db.loans.insertMany([{ member_id: ada, title: "Compilers" }, { member_id: ada, title: "Algorithms" }]);
const loansGone = db.loans.deleteMany({ member_id: ada }).deletedCount; // children first
const memberGone = db.members.deleteOne({ _id: ada }).deletedCount;
console.log({ loansGone, memberGone, orphans: db.loans.countDocuments({ member_id: ada }) });
assert.strictEqual(db.loans.countDocuments({ member_id: ada }), 0);
{ loansGone: 2, memberGone: 1, orphans: 0 }

การลบสองครั้งแยกกันไม่ atomic — ถ้า crash ระหว่างสองอันก็ยังมีกำพร้า บท transactions แสดงวิธีทำให้เป็นหน่วยเดียว

relational database บังคับความสัมพันธ์ระหว่างตาราง; MongoDB เก็บความสัมพันธ์ไว้ข้างในหรือข้าง ๆ document และปล่อยให้คุณบังคับเอง การ embed ทำให้ความสัมพันธ์เป็นกายภาพ (ไบต์ของลูกอยู่ในไบต์ของพ่อแม่ 150 ไบต์สำหรับ Ada และ loan สองรายการ) การอ้างอิงทำให้เป็นตรรกะ (ค่า member_id ที่ไม่มีอะไรตรวจ) เลือกตามวิธีอ่าน: อ่านด้วยกัน -> embed; อ่านอิสระ -> อ้างอิง

ตัวเลือกBenefitCost
Denormalized document model (embed)อ่าน document เดียวได้ทุกอย่าง ไม่ต้อง join — เร็วสำหรับ workload ที่อ่านพ่อแม่และลูกพร้อมกันเสมอข้อมูลที่ซ้ำข้าม document ต้องอัปเดตหลายที่เมื่อเปลี่ยน และ document อาจโตเข้าใกล้ขีดจำกัด 16 MiB ถ้า array ไม่จำกัด
Referenced model + $lookupข้อเท็จจริงแต่ละอย่างมีสำเนาเดียว ลูกอ่านแยกได้ไม่มี referential integrity (กำพร้าพิสูจน์ด้านบน); join ตอนอ่านทุก query ที่ต้องการทั้งสอง
Normalized relational model (foreign key)ไม่มีข้อมูลซ้ำ แก้ที่เดียว และ foreign key ให้ referential integrity จาก databaseเกือบทุก query ต้อง join หลายตาราง ซึ่งแพงขึ้นเมื่อจำนวนตารางที่เกี่ยวข้องเพิ่ม
  • “$lookup คือ SQL JOIN ที่มีการรับประกันเหมือนกัน” — ไม่มี foreign-key integrity: การรันจริงด้านบน insert loan กำพร้า ลบสมาชิกที่ถูกอ้างอิง และ $lookup คืน member: [] โดยไม่มี error
  • “embed เร็วกว่าเสมอ ดังนั้น embed ทุกอย่าง” — array ลูกที่ไม่จำกัด (log กิจกรรมที่โตไม่หยุด) จะชนขีดจำกัด document 16 MiB ในที่สุดและทำให้พ่อแม่อ่าน/เขียนช้า เก็บแยกตั้งแต่แรก
  • “การเรียงตาม _id เหมือนการเรียงตามเวลาสร้างที่แม่นยำ” — ภายใน process เดียว id เพิ่มขึ้นตามลำดับการสร้าง (บท documents-and-bson) ข้าม process counter 3 ไบต์เริ่มจากค่าสุ่ม ดังนั้นสอง id ที่สร้างใน วินาทีเดียวกัน โดยต่าง process เรียงได้ทั้งสองทาง: ใน 40 คู่ของ id จากสอง process Node ที่แยกกัน 37 คู่ใช้ timestamp วินาทีเดียวกัน และใน 20 จาก 37 นั้น id ที่ตามมาเรียงมาก่อน เก็บ field timestamp ของตัวเองเมื่อลำดับสำคัญ
  • “collection คือ table” — ไม่บังคับชุด column ตายตัว สอง document พก field ต่างกันได้
  • “มาจาก SQL ต้อง normalise โดยอัตโนมัติ” — ข้อมูลที่อ่านพร้อมกันมักควรอยู่ด้วยกันใน document เดียว

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

คู่มือ data modeling ของ MongoDB เองวางการตัดสินใจเดียวกัน: embed สำหรับความสัมพันธ์แบบ “contains” และ “one-to-few” อ้างอิงเมื่อข้อมูลถูกแชร์หรือไม่จำกัด (Data modeling introduction, อ้างอิงเมื่อ 2026-09-30) โมดูล Data Modeling ของคอร์สนี้ผ่านแต่ละกรณีพร้อมการวัด

บทนี้ไม่ใช้ Python/Go/Rust; fence รันใน mongosh

อะไรคือสิ่งที่ตรงกับ table ของ relational ใน MongoDB?
ใน MongoDB ความสัมพันธ์ระหว่างระเบียนถูกจัดการอย่างไร?
เมื่อไรที่การ embed มักเป็นทางเลือกที่ดีกว่า collection แยก?
คุณ deleteOne สมาชิกขณะที่ document loan สองรายการยังถือ member_id นั้นอยู่ แล้ว $lookup จาก loans ไป members ได้อะไรกลับมา?
สอง process ต่างกันแต่ละอันสร้าง ObjectId ในวินาทีเดียวกัน ข้อใดจริง?