อ่าน-แก้-เขียน คือกับดัก: กำจัด race condition ด้วยการทำให้ mutation เป็น atomic
ทุกที่ที่คุณ read ค่ามาก่อนแล้วค่อย write กลับ บน resource ที่หลาย request ใช้ร่วมกัน คุณกำลังเลี้ยง lost-update ไว้รอวันระเบิด บทความนี้ว่าด้วยวิธีคิดที่ถูก คือดัน check+mutate ให้เป็นคำสั่งเดียวแล้วให้ DB serialize ให้เอง พร้อมวิธีเขียนเทสต์ที่จับบั๊กนี้ได้จริง
อ่าน ~8 นาที
ปัญหาที่ sequential test ไม่มีวันเจอ
ผมเคยไล่หาบั๊กประเภทที่ "ทุกอย่างดูถูกหมด แต่ตัวเลขในระบบมันเพี้ยน" อยู่หลายรอบ จนสรุปได้ว่าเกือบทั้งหมดมันมาจากรูปแบบเดียวกัน คือโค้ดที่ อ่านค่ามาก่อน แล้วค่อยเขียนกลับ (read-modify-write) บน resource ที่หลาย request แย่งกันใช้ ไม่ว่าจะเป็น stock สินค้า, quota/credit ของ user, counter, หรือ running number สำหรับออกเลขที่เอกสาร
pattern นี้ดูไร้เดียงสามาก และมันจะ ผ่านเทสต์ทุกตัว ตราบใดที่คุณยิง request ทีละอันเรียงกัน เพราะบั๊กมันไม่ได้อยู่ที่ logic แต่อยู่ที่ "ช่วงเวลาระหว่าง read กับ write" ที่ request อื่นแทรกเข้ามาได้ ถ้าไม่มีอะไรแทรก โค้ดก็ถูก 100% แต่พอ traffic จริงมาชนพร้อมกัน ค่ามันจะหายไปเงียบ ๆ โดยไม่มี error ให้เห็น
ทำไม read-then-write ถึงเป็น lost update รอเกิด
ลองดู pattern คลาสสิกของการหักโควตา ที่ผมเจอบ่อยที่สุด:
// ❌ อันตราย: มีช่องว่างระหว่าง read กับ write
async function consumeQuota(userId) {
const user = await db.user.findById(userId); // read: monthly_used = 99
if (user.monthly_used >= user.limit) { // check: 99 < 100 ผ่าน
throw new Error('quota exceeded');
}
user.monthly_used += 1; // modify ใน memory
await user.save(); // write: monthly_used = 100
}
ถ้ามี request สองอันเข้ามาพร้อมกันตอน monthly_used = 99 ทั้งคู่จะ read เห็น 99 เหมือนกัน ผ่าน check ทั้งคู่ แล้วต่างคน "เขียน 100 กลับ" ผลคือใช้ไป 2 ครั้งจริง แต่ตัวเลขขึ้นแค่ 1 นี่แหละ lost update การเขียนของอีก request หนึ่งถูกทับหายไป
ทางแก้ที่หลายคนนึกถึงคือ SELECT ... FOR UPDATE แล้วเขียน logic ใน app เหมือนเดิม ซึ่งใช้ได้ แต่ผมมองว่ามันแก้ผิดชั้น เพราะคุณยังต้องพึ่งให้ dev ทุกคน "จำ" ว่าต้อง lock ก่อนทุกครั้ง วิธีที่ทนทานกว่าคือ ดัน check+mutate ให้เป็นคำสั่งเดียว แล้วให้ database serialize ให้เราเอง
Quota / credit: ให้ WHERE clause ทำ check ให้
แทนที่จะ read แล้ว check ใน app ให้ยัด condition ลงไปใน UPDATE เลย แล้วอ่านจำนวนแถวที่ถูกแก้ (row count) เป็นคำตอบว่า "สำเร็จหรือไม่":
// ✅ atomic: check + mutate ในคำสั่งเดียว
const result = await db.query(
`UPDATE users
SET monthly_used = monthly_used + 1
WHERE id = $1
AND monthly_used < limit`,
[userId]
);
if (result.rowCount === 0) {
// ไม่มีแถวถูกแก้ = เกิน quota (หรือ user ไม่มี)
throw new Error('quota exceeded');
}
กุญแจอยู่ตรงนี้: database จะจับ row lock บนแถวนั้นตอน UPDATE ทำให้สอง request ที่แย่งแถวเดียวกันถูกบังคับให้ทำ "ทีละคน" คนแรกแก้ 99→100 คนที่สองมาเจอ monthly_used < limit เป็น false แล้ว rowCount เลยเป็น 0 เราไม่ได้เขียน locking logic เองเลย แต่ได้ correctness มาฟรีจาก engine
Overselling stock: ห่อ check + deduct + create ไว้ด้วยกัน
เคสขายเกินสต็อก (overselling) คือเรื่องเดียวกันแต่มีหลาย step มากขึ้น ปกติ flow จะเป็น "เช็คว่าของพอ → หักสต็อก → สร้าง order" ถ้าสาม step นี้แยกกัน สอง request จะอ่าน stock = 5 ผ่าน validation พร้อมกัน แล้วต่างคนต่างหัก จนสต็อกติดลบและมี order เกินของจริง
หลักคิดคือ อย่าให้ "การตัดสินใจว่าของพอ" กับ "การหัก" อยู่คนละจังหวะกัน ให้ condition คุมการหักในตัวมันเอง และห่อทั้งชุดไว้ใน transaction เดียว:
// ✅ deduct เกิดขึ้นเฉพาะเมื่อ stock ยังพอ
await db.transaction(async (tx) => {
const res = await tx.query(
`UPDATE products
SET stock = stock - $1
WHERE id = $2
AND stock >= $1`,
[qty, productId]
);
if (res.rowCount === 0) {
throw new Error('out of stock'); // rollback ทั้ง transaction
}
await tx.query(
`INSERT INTO orders (product_id, qty) VALUES ($1, $2)`,
[productId, qty]
);
});
ถ้าหักไม่ได้ (rowCount = 0) transaction จะ rollback ทั้งก้อน order ก็ไม่ถูกสร้าง ไม่มีทางที่ order จะโผล่มาโดยที่สต็อกไม่ถูกหัก เพราะทั้งสองอย่างผูกชะตากันในหน่วยเดียว
Sequential ID และ dual-write ก็คือ race แบบเดียวกัน
อีกกับดักที่เจอบ่อยคือการออกเลข running แบบ SELECT MAX(seq) + 1 แล้วเอาไป insert อันนี้ race ชัดเจน สอง request อ่าน max ได้ค่าเดียวกัน แล้วสร้างเลขซ้ำ วิธีแก้มีสองแนวที่ผมใช้:
- Atomic counter — ใช้ operation ที่ increment แล้วคืนค่าใหม่ในก้าวเดียว เช่น
$incของ MongoDB กับfindOneAndUpdate({...}, {$inc: {seq: 1}}, {returnDocument: 'after'})ทุก caller จะได้เลขไม่ซ้ำเพราะ engine serialize การ inc ให้ - Unique index กันชน — ประกาศ unique constraint บน field ที่ห้ามซ้ำ (เลขที่เอกสาร, slug, external_ref) แล้วปล่อยให้ insert ที่มาช้าชน error แทนที่จะป้องกันด้วย app logic วิธีนี้ทำให้ database เป็น "ผู้ตัดสินสุดท้าย" ที่ไม่มีทาง bypass
เคส dual-collection write (เขียนสองที่แล้วคาดหวังว่าจะ consistent) ก็รากเหง้าเดียวกัน ถ้าเลี่ยงการเขียนสองที่ไม่ได้ ให้มี key ที่ unique เป็นตัวยึด แล้วออกแบบให้เขียนซ้ำได้แบบ idempotent (upsert ด้วย key เดิม) เพื่อให้ retry ไม่สร้างของซ้ำ
Last-write-wins ที่กินงานคนอื่นหายเงียบ ๆ
อีกหน้าตาหนึ่งของปัญหานี้คือการแก้ document ทั้งก้อน สอง user โหลดหน้าเดียวกัน คนหนึ่งแก้ field A อีกคนแก้ field B แล้วต่างคน save ทับกันทั้ง object งานของคนที่ save ก่อนหายไปเงียบ ๆ โดยไม่มีใครรู้ ("last write wins")
สำหรับข้อมูลที่พลาดไม่ได้ (ยอดเงิน, คะแนนโหวต, สถานะที่มีผลทางธุรกิจ) ผมใช้ optimistic locking คือแนบเลข version ไปด้วยตอน read แล้วตอน write ให้เงื่อนไขว่า version ต้องยังเท่าเดิม:
// ✅ update สำเร็จเฉพาะเมื่อไม่มีใครแก้แซงระหว่างทาง
const res = await db.query(
`UPDATE documents
SET body = $1, version = version + 1
WHERE id = $2
AND version = $3`, // version ที่ client อ่านไปตอนแรก
[newBody, docId, clientVersion]
);
if (res.rowCount === 0) {
// มีคนแก้แซงไปแล้ว → บอก client ให้ reload
throw new HttpError(409, 'document was modified, please refresh');
}
Mongoose มี __v ให้อยู่แล้วและทำ logic คล้ายกันได้ ประเด็นสำคัญคือ แทนที่จะทับเงียบ ๆ เราคืน 409 Conflict กลับไปให้ผู้ใช้ตัดสินใจ ดีกว่ากลืน edit ของใครหายไปโดยไม่มีร่องรอย
ข้อสำคัญที่สุด: เทสต์ต้องยิงพร้อมกันจริง
บทเรียนที่ผมย้ำกับตัวเองเสมอคือ บั๊กพวกนี้มองไม่เห็นด้วย sequential test ถ้าคุณยิง request ทีละอันในเทสต์ โค้ดที่ผิดจะผ่านหมด เพราะไม่มีใครแทรกระหว่าง read กับ write คุณต้องจงใจสร้าง concurrency ขึ้นมาเอง:
// ✅ เทสต์ที่จับ race ได้จริง: ยิง N request พร้อมกัน
it('อนุญาตให้ซื้อของชิ้นสุดท้ายได้แค่คนเดียว', async () => {
await seedProduct({ id: 'p1', stock: 1 });
const attempts = Array.from({ length: 20 }, () =>
buyProduct('p1', 1)
);
const results = await Promise.allSettled(attempts);
const ok = results.filter((r) => r.status === 'fulfilled');
// แม้ยิง 20 ครั้ง สต็อกมี 1 → ต้องสำเร็จได้แค่ 1
expect(ok.length).toBe(1);
const product = await getProduct('p1');
expect(product.stock).toBe(0); // ต้องไม่ติดลบ
});
ใช้ Promise.all หรือ Promise.allSettled ยิงหลาย request ในจังหวะเดียว แล้ว assert invariant ตรง ๆ ว่า "สำเร็จได้แค่ 1" หรือ "ยอดรวมไม่เกินเพดาน" หรือ "ไม่มีค่าติดลบ" ถ้าโค้ดยังเป็น read-then-write เทสต์แบบนี้จะแดงทันที และถ้าคุณย้ายไป atomic แล้ว มันจะเขียวอย่างมั่นใจ เทสต์ตัวนี้แหละคือหลักฐานว่าคุณแก้ถูกจริง ไม่ใช่แค่ "น่าจะถูก"
สรุป
- ทุก pattern read-then-write บน shared resource (stock, quota, counter, sequence ID) คือ lost-update รอ concurrency มาชน — ช่องว่างระหว่าง read กับ write คือจุดตาย
- อย่าแก้ด้วย app logic + lock ที่ต้องจำเอง ให้ ดัน check+mutate เป็นคำสั่งเดียว แล้วให้ DB serialize ให้ผ่าน row lock
- Quota/credit:
UPDATE ... WHERE used < limitแล้วเช็คrowCountเป็นคำตอบว่าสำเร็จหรือไม่ - Overselling: ห่อ
UPDATE ... WHERE stock >= qty+ create order ไว้ใน transaction เดียว หักไม่ได้ = rollback ทั้งก้อน - Sequential ID / dual-write: ใช้ atomic counter (
$inc) หรือ unique index ให้ DB เป็นผู้ตัดสินการชน - Concurrent edit ที่ทับกันเงียบ ๆ: ใช้ optimistic locking (version/__v) แล้วคืน 409 สำหรับข้อมูลที่พลาดไม่ได้อย่างเงินหรือคะแนน
- เทสต์: sequential ผ่านหมดคือหลอกตา ต้องยิง Promise.all / allSettled N ครั้งพร้อมกัน แล้ว assert ว่า "สำเร็จได้แค่ 1"




