โอนเงินสลับฝั่ง A ➔ B กับ B ➔ A: ทำไม Database ถึงสั่งยิงตัวประกันเพราะ Deadlock?
⏱️ ใช้เวลาอ่านประมาณ: 4 นาที 25 วินาที
ในฟังก์ชันโอนเงินของระบบธนาคาร คุณเขียนโค้ดครอบ @Transactional ไว้อย่างรัดกุมพร้อมล็อกแถวด้วย SELECT ... FOR UPDATE เพื่อป้องกันยอดเงินติดลบ
บ่ายวันหนึ่ง สมชาย (User 1) โอนเงิน 500 บาทให้ สมหญิง (User 2)
และในเสี้ยววินาทีเดียวกันเป๊ะ สมหญิง ก็กดโอนเงินคืน 200 บาทให้ สมชาย พอดี!
ทันใดนั้น หน้าจอของสมหญิงหมุนติ้วอยู่ 2 วินาที ก่อนจะระเบิดเป็น Error สีแดงแจ๋:
DETAIL: Process 14203 waits for ExclusiveLock on tuple (0, 2); blocked by process 14204.
Process 14204 waits for ExclusiveLock on tuple (0, 1); blocked by process 14203.
HINT: See server log for query details.
เงินของทั้งสองคนมีเหลือเฟือ โค้ดไม่ได้เขียนผิดไวยากรณ์ แล้วทำไม Database ถึงตัดสินใจ "สังหาร Transaction ของสมหญิงทิ้ง" ในฐานะตัวประกัน (Deadlock Victim)?
🔒 ธรรมชาติของ Row-Level Lock: ล็อกของฉัน ห้ามแตะ!
เมื่อ Transaction หนึ่งสั่งอัปเดตหรือล็อกแถวข้อมูล Database Engine จะแจก Exclusive Lock (X-Lock) ให้แถวนั้น เพื่อป้องกันไม่ให้คนอื่นเข้ามาแอบแก้ข้อมูลพร้อมกัน
โค้ดโอนเงินทั่วโลกมักเขียนตามสัญชาตญาณแบบตรงไปตรงมา:
- ล็อกแถวของ "ผู้โอน (From)" เพื่อตรวจยอดเงินและตัดยอดออก
- ล็อกแถวของ "ผู้รับ (To)" เพื่อเพิ่มยอดเงินเข้าไป
ถ้าคนหนึ่งโอน อีกคนยืนรอ ทุกอย่างทำงานราบรื่น 100%... แต่ความวิบัติจะเกิดขึ้นทันทีเมื่อสองฝั่งกระทำพร้อมกันในระดับมิลลิวินาที!
🔄 กอดคอกันตาย: วงจรลูกโซ่พิศวง (Deadly Embrace)
ลองดูไทม์ไลน์การทำงานของ 2 Transaction ที่วิ่งคู่ขนานกัน:
- ⏱️ T1: สมชายโอนหาหญิง (Tx 1) ➔ สั่งล็อกแถว User 1 (สมชาย) สำเร็จ ✅
- ⏱️ T2: สมหญิงโอนหาชาย (Tx 2) ➔ สั่งล็อกแถว User 2 (สมหญิง) สำเร็จ ✅
- ⏱️ T3: Tx 1 พยายามจะขอล็อก User 2 ต่อ ➔ ติดบล็อก! ต้องยืนรอเพราะ Tx 2 ถือกุญแจอยู่ ⏳
- ⏱️ T4: Tx 2 พยายามจะขอล็อก User 1 ต่อ ➔ ติดบล็อก! ต้องยืนรอเพราะ Tx 1 ถือกุญแจอยู่ ⏳
"Tx 1 รอให้ Tx 2 ปล่อยกุญแจสมหญิง... ในขณะที่ Tx 2 ก็กำลังยืนรอให้ Tx 1 ปล่อยกุญแจสมชาย! ทั้งสองฝ่ายไม่มีใครยอมใคร และจะไม่มีใครเดินหน้าต่อไปได้ตลอดกาล เกิดเป็นลูปวงกลม (Wait-for Graph Cycle)"
🚨 ศาลเตี้ยของ Database: Deadlock Detector
Database มี background thread ตัวหนึ่งคอยตรวจจับวงจรนี้อยู่เงียบๆ (ปกติทำงานทุก 1 วินาที หรือตามค่า deadlock_timeout)
ทันทีที่มันพบกราฟการรอคอยที่กลายเป็นงูกินหาง มันจะรู้ทันทีว่า "รอไปก็ไม่มีวันหลุด" สิ่งที่มันทำคือ สุ่มเลือก Transaction หนึ่งตัวมาเป็น 'เหยื่อสังเวย' (Victim) แล้วสั่ง ROLLBACK ทิ้งทันที เพื่อปลดปล่อยกุญแจให้อีก Transaction หนึ่งทำงานต่อจนจบได้
🛡️ ท่าแก้เผ็ด: กฎเหล็ก Deterministic Lock Ordering
วิธีป้องกันไม่ให้เกิด Deadlock ชนิดนี้ง่ายจนน่าตกใจ โดยไม่ต้องพึ่งเครื่องมือวิเศษใดๆ:
"ห้ามล็อกข้อมูลตามใจผู้ใช้ แต่ให้เรียงลำดับ ID เสมอ (Consistent Lock Order)":
// ให้เรียงลำดับ ID จากน้อยไปมากเสมอ!
val firstId = minOf(fromAccountId, toAccountId)
val secondId = maxOf(fromAccountId, toAccountId)
accountRepository.lockAccountForUpdate(firstId)
accountRepository.lockAccountForUpdate(secondId)
ดูผลลัพธ์ของกฎข้อนี้:
- ไม่ว่าใครจะเป็นคนโอน ทุก Transaction จะต้อง ขอล็อก User 1 ก่อนเสมอ
- Tx ใดที่มาถึงก่อนจะได้ล็อก User 1 ไป ส่วนอีก Tx หนึ่งจะติดคิวรอตั้งแต่บันไดขั้นแรก
- วงจรลูกโซ่งูกินหางจะไม่มีวันเกิดขึ้น และไม่มี Transaction ไหนต้องถูกสั่งประหารกลางอากาศอีกเลย!
💡 5-Min Takeaway
"Deadlock ไม่ได้เกิดจากฐานข้อมูลทำงานช้า แต่เกิดจากการแย่งทรัพยากรหลายชิ้นในลำดับที่ย้อนแย้งกัน" เมื่อใดก็ตามที่ Transaction ของคุณจำเป็นต้องถือครอง Lock มากกว่า 1 แถวขึ้นไป กฎทองข้อสำคัญที่สุดคือต้องจัดเรียงลำดับการขอล็อกทรัพยากรให้เป็นทิศทางเดียวกันเสมอ (Deterministic Ordering) เพื่อให้ทุกคำขอเข้าคิวอย่างเป็นระเบียบแทนที่จะหันหน้ามาล็อกคอกันตาย