☕ 5-Min Tech Story #36

โอนเงินสลับฝั่ง A ➔ B กับ B ➔ A: ทำไม Database ถึงสั่งยิงตัวประกันเพราะ Deadlock?

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

ในฟังก์ชันโอนเงินของระบบธนาคาร คุณเขียนโค้ดครอบ @Transactional ไว้อย่างรัดกุมพร้อมล็อกแถวด้วย SELECT ... FOR UPDATE เพื่อป้องกันยอดเงินติดลบ

บ่ายวันหนึ่ง สมชาย (User 1) โอนเงิน 500 บาทให้ สมหญิง (User 2)
และในเสี้ยววินาทีเดียวกันเป๊ะ สมหญิง ก็กดโอนเงินคืน 200 บาทให้ สมชาย พอดี!

ทันใดนั้น หน้าจอของสมหญิงหมุนติ้วอยู่ 2 วินาที ก่อนจะระเบิดเป็น Error สีแดงแจ๋:

ERROR: 40P01: deadlock detected
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) ให้แถวนั้น เพื่อป้องกันไม่ให้คนอื่นเข้ามาแอบแก้ข้อมูลพร้อมกัน

โค้ดโอนเงินทั่วโลกมักเขียนตามสัญชาตญาณแบบตรงไปตรงมา:

  1. ล็อกแถวของ "ผู้โอน (From)" เพื่อตรวจยอดเงินและตัดยอดออก
  2. ล็อกแถวของ "ผู้รับ (To)" เพื่อเพิ่มยอดเงินเข้าไป

ถ้าคนหนึ่งโอน อีกคนยืนรอ ทุกอย่างทำงานราบรื่น 100%... แต่ความวิบัติจะเกิดขึ้นทันทีเมื่อสองฝั่งกระทำพร้อมกันในระดับมิลลิวินาที!

🔄 กอดคอกันตาย: วงจรลูกโซ่พิศวง (Deadly Embrace)

ลองดูไทม์ไลน์การทำงานของ 2 Transaction ที่วิ่งคู่ขนานกัน:

"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)":

// แทนที่จะล็อก (from, to) ตามลำดับที่ส่งมา
// ให้เรียงลำดับ ID จากน้อยไปมากเสมอ!
val firstId = minOf(fromAccountId, toAccountId)
val secondId = maxOf(fromAccountId, toAccountId)

accountRepository.lockAccountForUpdate(firstId)
accountRepository.lockAccountForUpdate(secondId)

ดูผลลัพธ์ของกฎข้อนี้:

💡 5-Min Takeaway

"Deadlock ไม่ได้เกิดจากฐานข้อมูลทำงานช้า แต่เกิดจากการแย่งทรัพยากรหลายชิ้นในลำดับที่ย้อนแย้งกัน" เมื่อใดก็ตามที่ Transaction ของคุณจำเป็นต้องถือครอง Lock มากกว่า 1 แถวขึ้นไป กฎทองข้อสำคัญที่สุดคือต้องจัดเรียงลำดับการขอล็อกทรัพยากรให้เป็นทิศทางเดียวกันเสมอ (Deterministic Ordering) เพื่อให้ทุกคำขอเข้าคิวอย่างเป็นระเบียบแทนที่จะหันหน้ามาล็อกคอกันตาย