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:
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:
- ระบบการันตีแค่ว่า: "ถ้าคุณอ่านข้อมูลซ้ำ คุณจะเห็นภาพ Snapshot เดิมเสมอ"
- ระบบตรวจจับการชนกัน (Conflict Detection) เฉพาะกรณีที่มี 2 คนพยายามจะแย่ง UPDATE แถวข้อมูลแถวเดียวกัน (Dirty Write) เท่านั้น
- แต่ในกรณี Write Skew: ฝั่ง A เปลี่ยนแถวที่ 1 และฝั่ง B เปลี่ยนแถวที่ 2... ในสายตาของ MVCC ทั้งสองธุรกรรมจึงถือว่า ไม่มีความขัดแย้งกันในระดับแถว (No Row-Level Conflict) และปล่อยให้บันทึกผ่านทั้งคู่!
⚔️ 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):
- ระบบจะคอยตรวจจับร่องรอยการอ่านและเขียนที่ไขว้กัน (si-read lock) ตลอดทั้ง Transaction
- ถ้าพบว่าผลลัพธ์ไม่สามารถจัดเรียงลำดับการรันแบบทีละตัวได้ (Non-serializable execution) Database จะสั่ง Abort Transaction ตัวที่สองทิ้งทันที พร้อมฟ้อง Error
could not serialize access due to read/write dependenciesให้เรา Retry ใหม่
💡 5-Min Takeaway
"การป้องกันข้อมูลชนกัน ไม่ได้จบลงแค่การดูว่าไม่มีใครแก้แถวเดียวกัน" เมื่อใดก็ตามที่ตรรกะทางธุรกิจของคุณขึ้นอยู่กับรูปแบบ 'อ่านข้อมูลรวม เพื่อตัดสินใจว่าจะเขียนข้อมูลอีกแถวหรือไม่' จงระวัง Write Skew เสมอ อย่าไว้วางใจแค่ระดับ Repeatable Read แต่ต้องใช้ FOR UPDATE เพื่อล็อกเป้าหมายอย่างชัดเจน หรือปรับใช้ Serializable Isolation Level เพื่อป้องกันไม่ให้ช่องว่างระดับเสี้ยววินาทีกลายเป็นช่องโหว่ร้ายแรงในระบบ