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

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 แต่ไม่ได้ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส

แต่ละบทเรียน seed ข้อมูลของตัวเอง สมาชิกหนึ่งคนรวมค่าปรับ หนังสือที่ยืมอยู่ และบันทึกกิจกรรมเล็ก ๆ ไว้ด้วยกัน:

mongosh/atomicity-seed.js
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 ให้จัดการ:

mongosh/atomicity-update.js
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);

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 ด้านล่าง)

Atomic หมายถึง all-or-nothing ตอนล้มเหลวด้วย ในตัวอย่างนี้ update ตั้ง city ก่อน แล้วพยายาม $inc ฟิลด์ string ชื่อ name server ปฏิเสธ update ทั้งก้อน — city ไม่เคยปรากฏ:

mongosh/atomicity-fail.js
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
การฝังข้อมูลที่เกี่ยวข้องไว้ใน document เดียว ทำให้การเขียนแบบ atomic ครั้งเดียวรักษาความสอดคล้องของทั้งหมด

ใน Compass: เปิด document ของสมาชิกในแท็บ Documents แก้หลายฟิลด์แล้วกด Update — Compass ส่ง update หนึ่งครั้งต่อ document ซึ่ง atomic เหมือนการเรียกผ่าน driver ข้างบน

Atomic operator สำคัญเมื่อมี client เขียนพร้อมกันหลายตัว ด้านล่าง client 100 ตัวพร้อมกันแต่ละตัวบวก 1 ให้ counter ครั้งหนึ่งแบบ อ่าน คำนวณ เขียนกลับ อีกครั้งด้วย $inc:

app/atomicity-lost-update.mjs
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 ของแอปพลิเคชันคุณ

“การเขียน atomic ครั้งเดียว” จบที่ขอบเขตของ document ด้านล่าง updateMany รันบน 300 000 document ขณะที่ลูป polling (อีก connection หนึ่งจาก pool) นับว่ามีกี่ตัวที่เป็น done: true แล้ว reader เห็น batch ที่ทำไปครึ่งทาง:

app/atomicity-updatemany.mjs
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 ที่ถูกขอ “ลด 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 เดียว:

app/atomicity-oversell.mjs
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: 10

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 ทุกครั้งที่ต้องเปลี่ยนร่วมกัน
  • “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; ไม่ได้ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส

update ตั้งสองฟิลด์และ pull จาก array ใน document เดียว reader อื่นสังเกตเห็นอะไรได้บ้าง?
update ทำ $set: { city } แล้ว $inc บนฟิลด์ string ชื่อ name หลังจากนั้นใน document มีอะไร?
client 100 ตัวแต่ละตัวทำ findOne แล้ว $set n = doc.n + 1 ทำไม counter จึงจบที่ค่าต่ำกว่า 100 มาก?
updateMany หนึ่งครั้งบนหลาย document เป็นขั้นตอน atomic เดียวหรือไม่?
จะทำให้ "ลด stock เมื่อ stock > 0 เท่านั้น" ปลอดภัยภายใต้ concurrency ได้อย่างไร?