☕ 5-Min Tech Story #47

Write Skew อาถรรพ์: Repeatable Read เอาไม่อยู่ ทำไมระบบถึงยอมให้หมอลาเวรพร้อมกันจนห้องฉุกเฉินร้าง?

⏱️ ใช้เวลาอ่านประมาณ: 4 นาที 30 วินาที

โรงพยาบาลแห่งหนึ่งมีกฎเหล็กที่ฝังไว้ในซอฟต์แวร์จัดเวรแพทย์: "ในเวรดึกแต่ละคืน ต้องมีแพทย์เข้าเวรอย่างน้อย 1 คนเสมอ ห้ามปล่อยให้ห้องฉุกเฉินว่างเปล่าเด็ดขาด"

คุณในฐานะวิศวกรระบบ ตั้งค่า Transaction Isolation Level ของฐานข้อมูลไว้ที่ Repeatable Read (Snapshot Isolation) ซึ่งสูงและปลอดภัยกว่า Read Committed ทั่วไป พร้อมมั่นใจเต็มร้อยว่าไม่มีทางเกิด Dirty Read หรือ Non-Repeatable Read แน่นอน

คืนวันเสาร์... มีแพทย์เข้าเวรอยู่ 2 ท่าน คือ หมออลิซ และ หมอบ็อบ

เวลา 22:00:00 น. ทั้งสองท่านเกิดป่วยกะทันหัน จึงกดปุ่ม "ขอลาเวรดึก" บนสมาร์ตโฟนในวินาทีเดียวกันเป๊ะ! โค้ดของทั้งสองฝั่งทำงานฉลุย ไม่ติด Lock, ไม่ฟ้อง Error, Database ตอบกลับ COMMIT SUCCESS ทั้งคู่...

แต่พอเปิดดูตารางเวร: สถานะของหมอทั้ง 2 คนกลายเป็น 'ลา' ทั้งคู่! ห้องฉุกเฉินไม่มีแพทย์เหลืออยู่เลยแม้แต่คนเดียว! เกิดอะไรขึ้น? ทำไมฐานข้อมูลระดับโลกถึงปล่อยให้กฎเหล็กพังทลายลงได้ต่อหน้าต่อตา?

👻 ปริศนาเงาเลือนลาง: ภาวะ Write Skew คืออะไร?

ปรากฏการณ์นี้ในทางวิทยาการคอมพิวเตอร์เรียกว่า Write Skew (ความเอียงของการเขียน) ซึ่งเป็นจุดบอดคลาสสิกของระบบฐานข้อมูลแบบ MVCC

ลองดูสิ่งที่เกิดขึ้นพร้อมกันในระดับ Transaction:

-- Transaction 1 (หมออลิซ ขอลา):
1. SELECT COUNT(*) FROM on_call WHERE is_active = true; -- ได้ผลลัพธ์ 2 คน (ปลอดภัย)
2. UPDATE on_call SET is_active = false WHERE doctor = 'Alice';
3. COMMIT; ✅

-- Transaction 2 (หมอบ็อบ ขอลา - รันขนานกัน):
1. SELECT COUNT(*) FROM on_call WHERE is_active = true; -- ได้ผลลัพธ์ 2 คนเช่นกัน!
2. UPDATE on_call SET is_active = false WHERE doctor = 'Bob';
3. COMMIT; ✅
"เพราะทั้งสอง Transaction ไม่ได้แก้ไขแถวเดียวกัน! อลิซแก้แถวอลิซ ส่วนบ็อบแก้แถวบ็อบ จึงไม่มี Row Lock ชนกัน... แต่ผลรวมของการกระทำทั้งคู่ กลับไปทำลายเงื่อนไขตั้งต้น (Invariant) ที่ทั้งสองฝ่ายเคยแอบอ่านมาก่อนหน้า!"

🛡️ ทำไม Repeatable Read ถึงป้องกันไม่ได้?

ในระดับ Repeatable Read หรือ Snapshot Isolation:

⚔️ 3 คาถาสยบ Write Skew: ล็อกแถวล่วงหน้า ยัน Serializable

1. ล็อกเป้าแบบชัดแจ้ง (Explicit Locking ด้วย SELECT ... FOR UPDATE):

ในเมื่อคำสั่ง SELECT COUNT(*) ปกติจะไม่ติด Lock เราจึงต้องสั่งให้ Database ล็อกแถวที่เกี่ยวข้องทั้งหมดไว้ก่อน:

-- บังคับล็อกแถวแพทย์ที่กำลังเข้าเวรทุกคน
SELECT * FROM on_call WHERE is_active = true FOR UPDATE;

เมื่อหมออลิซรันคำสั่งนี้ แถวของแพทย์ทั้งสองคนจะถูกกุญแจ Exclusive Lock ทันที หมอบ็อบจะต้องยืนรอจนกว่าอลิซจะทำรายการเสร็จ พออลิซลาเสร็จ บ็อบค่อยได้คิวอ่าน และจะเห็นว่าเหลือแพทย์แค่ 1 คน จึงโดนระบบปฏิเสธการลา

2. สร้างสมอเทียม (Materializing Conflicts):

ถ้าเงื่อนไขตรวจเช็กไม่ใช่ข้อมูลแถวที่มีอยู่จริง (เช่น การเช็กว่ามีห้องประชุมว่างไหม ซึ่งแถวที่ต้องการยังไม่เคยถูกสร้างขึ้นมาให้ล็อก) เราสามารถสร้างตารางสมอ เช่น ตาราง shift_lock เพื่อใช้ยึดล็อกแถวรอบเวรนั้นๆ ก่อนเริ่มคำนวณ

3. เลื่อนขั้นสู่ระดับสูงสุด: Serializable Isolation Level:

ระดับความปลอดภัยสูงสุดคือ SERIALIZABLE (ใน PostgreSQL จะใช้ SSI - Serializable Snapshot Isolation):

💡 5-Min Takeaway

"การป้องกันข้อมูลชนกัน ไม่ได้จบลงแค่การดูว่าไม่มีใครแก้แถวเดียวกัน" เมื่อใดก็ตามที่ตรรกะทางธุรกิจของคุณขึ้นอยู่กับรูปแบบ 'อ่านข้อมูลรวม เพื่อตัดสินใจว่าจะเขียนข้อมูลอีกแถวหรือไม่' จงระวัง Write Skew เสมอ อย่าไว้วางใจแค่ระดับ Repeatable Read แต่ต้องใช้ FOR UPDATE เพื่อล็อกเป้าหมายอย่างชัดเจน หรือปรับใช้ Serializable Isolation Level เพื่อป้องกันไม่ให้ช่องว่างระดับเสี้ยววินาทีกลายเป็นช่องโหว่ร้ายแรงในระบบ