Single-document atomicity
การรับประกันที่แข็งแกร่งที่สุดที่ MongoDB ให้คุณก็เป็นการรับประกันที่เงียบที่สุดด้วย เพราะไม่มีต้นทุนและไม่ขออะไรจากคุณเลย ทุกการเขียนไปยัง document หนึ่งฉบับ เกิดขึ้นเป็นขั้นตอนเดียวที่แบ่งแยกไม่ได้ ถ้าการอัปเดตของคุณตั้งค่าสามฟิลด์ เพิ่มค่า counter และ pull จาก array ในคราวเดียว ทุก reader จะเห็นการเปลี่ยนแปลงเหล่านั้นทั้งหมดหรือไม่เห็นเลย — ไม่มีทางเห็นสภาพที่นำไปใช้ครึ่งทาง และถ้ามีอะไรผิดพลาดระหว่างการเขียน document จะอยู่ในสภาพเดิมเป๊ะ สิ่งนี้เป็นจริงไม่ว่าการเปลี่ยนแปลงจะลงลึกแค่ไหน: nested object และ array ภายใน document เป็นส่วนหนึ่งของหน่วย atomic เดียวกัน manual ของ MongoDB สรุปไว้ในประโยคเดียวว่า “write operations are atomic on the single-document level, even if modifying multiple values” (Atomicity in MongoDB, ตรวจเมื่อ 2026-09-30)
ทุก output ด้านล่างจับมาจากเครื่อง Mac นี้ บน mongo 8.3.11 (replica set โหนดเดียว), mongosh 2.11.1 และ Node driver 7.7.0 id, เวลา และตัวเลขของ race บนเครื่องคุณจะต่างออกไป แท็บ Python, Go และ Rust สะท้อนการเรียกที่ตรวจแล้วใน shell/Node แต่ไม่ได้ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส
document ของสมาชิก
หัวข้อที่มีชื่อว่า “document ของสมาชิก”แต่ละบทเรียน seed ข้อมูลของตัวเอง สมาชิกหนึ่งคนรวมค่าปรับ หนังสือที่ยืมอยู่ และบันทึกกิจกรรมเล็ก ๆ ไว้ด้วยกัน:
db.members.insertOne({ name: "Ada", fines: 4, borrowed: ["Algorithms"], activity: { lastVisit: 2025, visits: 12 },});assert.strictEqual(db.members.countDocuments({ name: "Ada" }), 1);เขียนครั้งเดียว เปลี่ยนหลายอย่าง ก็ยังคง atomic
หัวข้อที่มีชื่อว่า “เขียนครั้งเดียว เปลี่ยนหลายอย่าง ก็ยังคง atomic”สมมติว่า Ada คืนหนังสือ จ่ายค่าปรับสองหน่วย และมาเยือนห้องสมุด — สามการเปลี่ยนแปลงเชิงตรรกะในการมาครั้งเดียว เพราะทั้งหมดอยู่ใน document เดียว การอัปเดตครั้งเดียวจึงนำไปใช้พร้อมกัน ไม่มี transaction ให้เปิดและไม่มี session ให้จัดการ:
const res = db.members.updateOne( { name: "Ada" }, { $inc: { fines: -2, "activity.visits": 1 }, $set: { "activity.lastVisit": 2026 }, $pull: { borrowed: "Algorithms" } });console.log(res);const ada = db.members.findOne({ name: "Ada" }, { _id: 0 });console.log(ada);assert.strictEqual(ada.fines, 2);assert.strictEqual(ada.borrowed.length, 0);assert.strictEqual(ada.activity.visits, 13);const members = db.collection("members");await members.insertOne({ name: "Ada", fines: 4, borrowed: ["Algorithms"], activity: { lastVisit: 2025, visits: 12 } });const res = await members.updateOne( { name: "Ada" }, { $inc: { fines: -2, "activity.visits": 1 }, $set: { "activity.lastVisit": 2026 }, $pull: { borrowed: "Algorithms" } });console.log(res);const ada = await members.findOne({ name: "Ada" }, { projection: { _id: 0 } });console.log(ada);if (ada.fines !== 2 || ada.borrowed.length !== 0 || ada.activity.visits !== 13) throw new Error("update not applied as a unit");db.members.update_one( {"name": "Ada"}, { "$inc": {"fines": -2, "activity.visits": 1}, "$set": {"activity.lastVisit": 2026}, "$pull": {"borrowed": "Algorithms"}, },)_, err := coll.UpdateOne( ctx, bson.M{"name": "Ada"}, bson.M{ "$inc": bson.M{"fines": -2, "activity.visits": 1}, "$set": bson.M{"activity.lastVisit": 2026}, "$pull": bson.M{"borrowed": "Algorithms"}, },)if err != nil { return err}coll.update_one( doc! { "name": "Ada" }, doc! { "$inc": { "fines": -2, "activity.visits": 1 }, "$set": { "activity.lastVisit": 2026 }, "$pull": { "borrowed": "Algorithms" } },).await?;output จริง ของ mongosh ก่อนแล้วตามด้วย Node (รูปแบบ acknowledgement ต่างกัน แต่ document เหมือนกัน):
{ acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0}{ name: 'Ada', fines: 2, borrowed: [], activity: { lastVisit: 2026, visits: 13 }}{ acknowledged: true, modifiedCount: 1, upsertedId: null, upsertedCount: 0, matchedCount: 1}{ name: 'Ada', fines: 2, borrowed: [], activity: { lastVisit: 2026, visits: 13 }}ไม่มี reader คนไหนเห็น document ในสภาพที่ค่าปรับลดแล้วแต่หนังสือยังอยู่ในรายการยืมได้เลย ตัว update operator เองก็ atomic เช่นกัน: $inc อ่านและบวกเป็นขั้นตอนเดียวฝั่ง server (พิสูจน์ใน race ด้านล่าง)
update ที่ล้มเหลวไม่เปลี่ยนอะไรเลย
หัวข้อที่มีชื่อว่า “update ที่ล้มเหลวไม่เปลี่ยนอะไรเลย”Atomic หมายถึง all-or-nothing ตอนล้มเหลวด้วย ในตัวอย่างนี้ update ตั้ง city ก่อน แล้วพยายาม $inc ฟิลด์ string ชื่อ name server ปฏิเสธ update ทั้งก้อน — city ไม่เคยปรากฏ:
let message = null;try { db.members.updateOne({ name: "Ada" }, { $set: { city: "Arlington" }, $inc: { name: 1 } });} catch (e) { message = e.message; }console.log(message);const after = db.members.findOne({ name: "Ada" }, { _id: 0 });console.log(after);assert.ok(message.includes("Cannot apply $inc to a value of non-numeric type"));assert.strictEqual(after.city, undefined);Plan executor error during update :: caused by :: Cannot apply $inc to a value of non-numeric type. {_id: ObjectId('6abcc850aeeadcc10fdb2f05')} has the field 'name' of non-numeric type string{ name: 'Ada', fines: 2, borrowed: [], activity: { lastVisit: 2026, visits: 13 }}ออกแบบโมเดลให้ข้อมูลที่เกี่ยวข้องอยู่ด้วยกัน
หัวข้อที่มีชื่อว่า “ออกแบบโมเดลให้ข้อมูลที่เกี่ยวข้องอยู่ด้วยกัน”บทเรียนเชิงปฏิบัติคือเรื่องการออกแบบโมเดล เมื่อข้อมูลเปลี่ยนพร้อมกัน ให้เก็บไว้ด้วยกัน แล้ว atomicity ฟรีก็ครอบคลุมให้ ถ้าเราแยก activity log ไปเป็น collection activity ต่างหากที่อ้างอิงสมาชิก การมาเยือนครั้งเดียวกันนี้จะข้ามสอง document และบังคับให้เลือกระหว่างยอมเสีย atomicity หรือจ่ายค่า transaction (บทถัดไป)
flowchart LR A["One member document"] --> B["fines"] A --> C["borrowed array"] A --> D["activity object"] B --> E["A single update changes all three atomically"] C --> E D --> E
ใน Compass: เปิด document ของสมาชิกในแท็บ Documents แก้หลายฟิลด์แล้วกด Update — Compass ส่ง update หนึ่งครั้งต่อ document ซึ่ง atomic เหมือนการเรียกผ่าน driver ข้างบน
race ที่ atomic operator ป้องกันได้
หัวข้อที่มีชื่อว่า “race ที่ atomic operator ป้องกันได้”Atomic operator สำคัญเมื่อมี client เขียนพร้อมกันหลายตัว ด้านล่าง client 100 ตัวพร้อมกันแต่ละตัวบวก 1 ให้ counter ครั้งหนึ่งแบบ อ่าน คำนวณ เขียนกลับ อีกครั้งด้วย $inc:
const counters = db.collection("counters");await counters.insertMany([{ _id: "read-modify-write", n: 0 }, { _id: "inc", n: 0 }]);
await Promise.all(Array.from({ length: 100 }, async () => { const doc = await counters.findOne({ _id: "read-modify-write" }); await counters.updateOne({ _id: "read-modify-write" }, { $set: { n: doc.n + 1 } });}));await Promise.all(Array.from({ length: 100 }, () => counters.updateOne({ _id: "inc" }, { $inc: { n: 1 } })));
const rows = await counters.find().toArray();console.log(rows);const byId = Object.fromEntries(rows.map((r) => [r._id, r.n]));if (byId["inc"] !== 100) throw new Error("$inc must never lose an update");if (byId["read-modify-write"] >= 100) throw new Error("expected lost updates in the naive version");[ { _id: 'read-modify-write', n: 1 }, { _id: 'inc', n: 100 } ]ทุกการเขียน atomic แต่ละตัว — แต่การบวกส่วนใหญ่จาก 100 ครั้งก็ยังหายไป เพราะแต่ละ client เขียนกลับค่าที่คำนวณจากการอ่านที่ล้าสมัย atomicity อยู่ใน operator ไม่ใช่ใน read-then-write ของแอปพลิเคชันคุณ
updateMany คือการเขียน atomic หลายครั้ง
หัวข้อที่มีชื่อว่า “updateMany คือการเขียน atomic หลายครั้ง”“การเขียน atomic ครั้งเดียว” จบที่ขอบเขตของ document ด้านล่าง updateMany รันบน 300 000 document ขณะที่ลูป polling (อีก connection หนึ่งจาก pool) นับว่ามีกี่ตัวที่เป็น done: true แล้ว reader เห็น batch ที่ทำไปครึ่งทาง:
const big = db.collection("big");const docs = Array.from({ length: 300000 }, (_, i) => ({ i, done: false }));for (let i = 0; i < docs.length; i += 100000) await big.insertMany(docs.slice(i, i + 100000), { ordered: false });await big.createIndex({ done: 1 });
const seen = [];let running = true;const poll = (async () => { while (running) seen.push(await big.countDocuments({ done: true }));})();
const t0 = Date.now();const res = await big.updateMany({}, { $set: { done: true } });const ms = Date.now() - t0;running = false;await poll;
const partial = seen.filter((n) => n > 0 && n < 300000);console.log({ modifiedCount: res.modifiedCount, ms, samples: seen.length, partialSamples: partial.length, firstPartial: partial.slice(0, 5) });if (partial.length === 0) throw new Error("expected the reader to observe a partially applied updateMany");{ modifiedCount: 300000, ms: 2119, samples: 172, partialSamples: 170, firstPartial: [ 247, 609, 827, 1015, 1169 ]}manual พูดไว้เหมือนกันว่า “the modification of each document is atomic, but the operation as a whole is not atomic” (Atomicity in MongoDB) เมื่อ batch ต้องเป็น all-or-nothing ให้ใช้ multi-document transaction
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- Atomicity เป็นระดับ document ไม่ใช่ระดับ operation:
updateManyหนึ่งครั้งบนหนึ่งพัน document คือการเขียน atomic อิสระหนึ่งพันครั้ง - การฝังข้อมูลไม่ฟรีเสมอไป document ที่โตไม่จำกัด (activity log ที่มีเป็นล้านรายการ) จะเข้าใกล้ขีดจำกัด 16 MiB — แยกออกไปและยอมรับกลยุทธ์ความสอดคล้องอีกแบบ
- “Atomic” ไม่ได้แปลว่า “แยกขาดจาก document อื่น”: ถ้าตรรกะของคุณอ่าน document B เพื่อตัดสินว่าจะเขียนอะไรลง document A ก็ยังมี race
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”ผู้ช่วย AI ที่ถูกขอ “ลด stock อย่างปลอดภัย” เขียนโค้ดนี้ มันดูเหมือน guard และคอมเมนต์ฟังดูมั่นใจ:
🐛 หา bug ในโค้ดนี้
// AI comment: "single-document writes are atomic, so this can never oversell"async function buy(stock, sku) {const item = await stock.findOne({ _id: sku });if (item.left > 0) { await stock.updateOne({ _id: sku }, { $inc: { left: -1 } }); return true;}return false;}เฉลย
updateOne แต่ละครั้ง atomic แต่ การเช็ค กับ การเขียน เป็นสอง operation แยกกัน ผู้ซื้อ 50 คนพร้อมกันจึงผ่าน item.left > 0 ทั้งหมดก่อนที่การลดครั้งใดจะลงจริง ในการรันด้านล่าง สินค้า 10 ชิ้นถูก “ขาย” 50 ครั้งและ counter ติดลบ วิธีแก้คือย้ายการเช็คเข้าไปใน filter เพื่อให้ server ทดสอบและลดในขั้นตอน atomic เดียว:
const stock = db.collection("stock");await stock.insertMany([{ _id: "naive", left: 10 }, { _id: "guard", left: 10 }]);
await Promise.all(Array.from({ length: 50 }, async () => { const item = await stock.findOne({ _id: "naive" }); if (item.left > 0) await stock.updateOne({ _id: "naive" }, { $inc: { left: -1 } });}));
const results = await Promise.all(Array.from({ length: 50 }, () => stock.updateOne({ _id: "guard", left: { $gt: 0 } }, { $inc: { left: -1 } })));const sold = results.reduce((n, r) => n + r.modifiedCount, 0);
const rows = await stock.find().toArray();console.log(rows, "guarded sales:", sold);if (rows.find((r) => r._id === "naive").left >= 0) throw new Error("expected the naive version to oversell");if (rows.find((r) => r._id === "guard").left !== 0 || sold !== 10) throw new Error("guarded version must sell exactly 10");[ { _id: 'naive', left: -39 }, { _id: 'guard', left: 0 } ] guarded sales: 10Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”document คือหน่วยของ “all or nothing”: server ใช้ update document กับ record หนึ่งภายใต้ write lock ของ record นั้น และ reader ได้เวอร์ชันก่อนหรือหลัง ไม่เคยได้ตรงกลาง อะไรก็ตามที่ใหญ่กว่าหนึ่ง document (batch, read-then-write, สอง collection) คือ ลำดับ ของขั้นตอน atomic ที่ client อื่นแทรกได้ — เว้นแต่คุณฝังการเช็คไว้ใน filter หรือใช้ transaction
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | ประโยชน์ | ต้นทุน |
|---|---|---|
| ฝังข้อมูลใน document เดียว | atomicity ฟรี ไม่มี session ไม่มี commit latency ต่ำที่สุด | document โตตามข้อมูลที่ฝัง เข้าใกล้ขีดจำกัด BSON 16 MiB และทำให้ทุกการเขียนต้องเขียน document ที่ใหญ่ขึ้นใหม่ |
Guard ใน filter ({ left: { $gt: 0 } }) | เช็คและเขียนเป็นขั้นตอน atomic เดียว ไม่ต้อง transaction | ใช้ได้เฉพาะเมื่อเงื่อนไขเกี่ยวกับ document เดียวกัน |
| แยกข้อมูลข้าม document/collection และใช้ multi-document transaction เมื่อต้องเปลี่ยนพร้อมกัน | ยืดหยุ่นในการ query และ scale แต่ละส่วนอิสระ | เสีย atomicity ฟรี ต้องจ่ายค่า session และ commit ทุกครั้งที่ต้องเปลี่ยนร่วมกัน |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “read-then-write เป็น atomic เพราะจบด้วยการเขียน document เดียว” — พิสูจน์แล้วว่าผิด: client 100 ตัวทำให้ counter ได้น้อยกว่า 100 มาก ใช้
$inc, guard ใน filter หรือfindOneAndUpdate - “
updateManyเป็น operation atomic เดียว” — แต่ละ document atomic แต่ batch ไม่ใช่ และ reader เห็นมันทำไปครึ่งทาง - “Atomic แปลว่าแยกขาดจาก document อื่น” — atomicity ครอบคลุมเฉพาะ document ของคุณเอง การตัดสินใจเขียนจาก document อื่นยังเป็น race
- “ฝังทุกอย่าง atomicity ฟรี” — array ที่ฝังโดยไม่จำกัดขนาดมุ่งหน้าสู่ขีดจำกัด 16 MiB และทำให้ทุก operation บน document ช้าลง
แท็บ Python/Go/Rust สะท้อนพฤติกรรมที่ตรวจแล้วใน shell/Node; ไม่ได้ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส