Deadlock ในฐานข้อมูลคืออะไร

deadlock คือตอนที่สอง transaction ต่างฝ่ายต่างถือล็อกที่อีกฝ่ายรออยู่ ฐานข้อมูลจะยกเลิกฝั่งหนึ่งทิ้งให้ เราลดโอกาสเกิดได้ด้วยการล็อกตามลำดับเดียวกันและทำ transaction ให้สั้น แล้วเตรียมโค้ดให้ลองใหม่เมื่อเจอ

เผยแพร่
เวลาอ่าน
4 นาที
ในโพสต์นี้ 6 หัวข้อ
  1. ฐานข้อมูลล็อกแถวไว้ระหว่าง transaction
  2. deadlock คือการที่ต่างฝ่ายต่างรอกันไม่จบ
  3. ฐานข้อมูลยกเลิกฝั่งหนึ่งทิ้งให้ทันที
  4. ลดโอกาสเกิด deadlock ได้หลายทาง
  5. ลองใหม่อัตโนมัติด้วย DB::transaction
  6. deadlock ที่อาจเจอในการใช้งานจริง

ถ้าเคยเห็น error SQLSTATE[40001]: Deadlock found when trying to get lock; try restarting transaction ใน log ของ Laravel แล้วไม่แน่ใจว่าโค้ดเราผิดตรงไหน โพสต์นี้อธิบายว่า deadlock เกิดจากอะไร ฐานข้อมูลจัดการกับมันยังไง และเราทำอะไรได้บ้าง

ฐานข้อมูลล็อกแถวไว้ระหว่าง transaction

เวลาเราแก้ข้อมูลใน transaction เช่น ตัดสต๊อกสินค้า MySQL (InnoDB) และ PostgreSQL จะล็อกแถวที่แก้ไว้จนกว่า transaction จะ commit หรือ rollback คนอื่นที่อยากแก้แถวเดียวกันต้องรอ ล็อกนี้กันไม่ให้สองคำสั่งซื้อตัดสต๊อกชิ้นสุดท้ายไปพร้อมกัน

ใน Laravel เราขอล็อกตอนอ่านได้ด้วย lockForUpdate() ซึ่งก็คือ SELECT … FOR UPDATE อ่านแล้วล็อกไว้เลย ใครจะแก้แถวนั้นต้องรอเราเสร็จก่อน

การรอแบบนี้เป็นเรื่องปกติ ปกติก็รอกันแค่เสี้ยววินาที ปัญหาเกิดตอนที่การรอนั้นวนเป็นวงจร

deadlock คือการที่ต่างฝ่ายต่างรอกันไม่จบ

ลองนึกถึงเว็บขายของออนไลน์ที่มีคำสั่งซื้อสองรายการเข้ามาพร้อมกัน ทั้งคู่มีสินค้า ก และ ข แต่ลูกค้าหยิบใส่ตะกร้าคนละลำดับ โค้ดตัดสต๊อกไล่ตามลำดับในตะกร้า

สองคำสั่งซื้อล็อกสต๊อกคนละลำดับ ต่างฝ่ายต่างรอกัน

A ถือสต๊อก ก แล้วรอ ข ส่วน B ถือ ข แล้วรอ ก ถ้าไม่มีใครเข้ามาตัดสิน ทั้งคู่จะรอกันไปตลอด

  1. A ตัดสต๊อก ก ล็อกแถว ก ไว้
  2. B ตัดสต๊อก ข ล็อกแถว ข ไว้
  3. A จะตัดสต๊อก ข ต่อ แต่ B ถืออยู่ จึงรอ
  4. B จะตัดสต๊อก ก ต่อ แต่ A ถืออยู่ จึงรอ

ทั้งคู่ไม่มีทางไปต่อได้ เพราะต่างคนต่างรอให้อีกฝ่ายปล่อยก่อน อาการนี้คือ deadlock คู่มือ MySQL อธิบายว่ามักเกิดจากการล็อกหลายแถวหรือหลายตารางคนละลำดับแบบนี้ หรือจากการล็อกช่วงของ index ที่จังหวะต่างกัน

ฐานข้อมูลยกเลิกฝั่งหนึ่งทิ้งให้ทันที

ฐานข้อมูลไม่ปล่อยให้รอกันไปตลอด InnoDB ตรวจหา deadlock เองอยู่แล้ว (ค่าเริ่มต้น innodb_deadlock_detect เปิดอยู่) เมื่อเจอวงจรรอแบบนี้ ก็จะเลือก transaction ฝั่งหนึ่งมา rollback ทิ้ง อีกฝั่งจึงทำต่อได้ ฝั่งที่โดนยกเลิกจะได้ error 1213 ที่มี SQLSTATE 40001 ใน PostgreSQL ข้อความจะเป็น deadlock detected ซึ่งทำงานแบบเดียวกัน

ส่วน Lock wait timeout exceeded (error 1205) เป็นอีกเรื่องหนึ่ง ตรงนี้ไม่ได้รอกันเป็นวงจร แค่มีใครถือล็อกนานเกินไป จนอีกฝั่งรอครบ innodb_lock_wait_timeout (ค่าเริ่มต้น 50 วินาที) แล้วยอมแพ้ ต้นเหตุมักเป็น transaction ที่เปิดค้างไว้นาน เช่น ไปเรียก API ภายนอกระหว่างที่ยังไม่ commit

คู่มือ MySQL บอกไว้ชัดว่าแม้โค้ดจะเขียนถูกทุกอย่าง แอปก็ยังต้องพร้อมลอง transaction ใหม่เมื่อเจอ deadlock เพราะ deadlock ลดได้ แต่ไม่มีทางกันได้หมด

ลดโอกาสเกิด deadlock ได้หลายทาง

คู่มือ MySQL แนะนำไว้หลายข้อ ข้อที่ใช้กับงาน Laravel ทั่วไปได้บ่อยมีดังนี้

  • ล็อกตามลำดับเดียวกันทุกครั้ง ในตัวอย่างข้างบน ถ้าทั้งสองคำสั่งซื้อเรียงสินค้าตาม id ก่อนตัดสต๊อก ทั้งคู่จะล็อก ก ก่อน ข เสมอ B จะรอตั้งแต่ ก แล้วค่อยได้ทำต่อเมื่อ A เสร็จ วงจรรอจึงไม่เกิด
  • ทำ transaction ให้สั้น ใส่เฉพาะคำสั่งที่ต้องสำเร็จหรือล้มเหลวไปด้วยกัน การส่งอีเมล เรียก API ขนส่ง หรือสร้าง PDF ให้ทำหลัง commit
  • มี index ให้คอลัมน์ที่ใช้ใน where ของ UPDATE และ FOR UPDATE ถ้าไม่มี ฐานข้อมูลต้องไล่หลายแถวและล็อกแถวที่ไม่เกี่ยวไปด้วย
  • ไม่ล็อกถ้าไม่จำเป็น หน้าแสดงสต๊อกให้ลูกค้าดูไม่ต้องใช้ lockForUpdate() อ่านธรรมดาก็พอ

ข้อแรกเขียนใน Laravel ได้แบบนี้

$products = Product::whereIn('id', $productIds)
    ->orderBy('id')
    ->lockForUpdate()
    ->get();

ลองใหม่อัตโนมัติด้วย DB::transaction

DB::transaction() รับจำนวนรอบที่จะลองใหม่เมื่อเจอ deadlock เป็นอาร์กิวเมนต์ที่สอง

DB::transaction(function () {
    // ตัดสต๊อก สร้างคำสั่งซื้อ
}, attempts: 3);

ค่าเริ่มต้นคือ 1 รอบ ถ้าไม่ใส่ เจอ deadlock ครั้งเดียวก็โยน exception ออกมาเลย แต่การลองใหม่แบบนี้มีสี่จุดที่ทำงานต่างจากที่เราอาจคิด

  • Laravel ลองใหม่ทันที ไม่มีการเว้นจังหวะ ถ้าอีกฝั่งยังทำไม่เสร็จก็อาจชนซ้ำ
  • โค้ดทั้งก้อนใน closure รันใหม่ทั้งหมด อะไรที่ไม่ได้อยู่ในฐานข้อมูล เช่น ส่งอีเมล ควรย้ายไปไว้หลัง commit ด้วย DB::afterCommit() หรือสั่ง job ให้รอ commit ก่อน
  • ลองใหม่เฉพาะ transaction ชั้นนอกสุด ถ้า transaction ซ้อนกันอยู่ ชั้นในจะโยน exception ขึ้นไปให้ชั้นนอกจัดการ
  • lock wait timeout ก็นับด้วย Laravel ถือว่า error 1205 เป็นปัญหาแบบเดียวกัน (ดูใน ConcurrencyErrorDetector) ถ้าตั้ง attempts: 3 แล้วเจอ timeout ทุกรอบ ผู้ใช้จะรอนานถึงราว 150 วินาทีกว่าจะเห็น error

Mozex เสนอให้ใช้ retry() ครอบแทน เว้น 100 ms ก่อนลองใหม่ และลองใหม่เฉพาะ deadlock (SQLSTATE 40001) ส่วน lock wait timeout ให้โยนออกมาเลย

use Illuminate\Database\QueryException;

retry(3, fn () => DB::transaction(function () {
    // ตัดสต๊อก สร้างคำสั่งซื้อ
}), 100, fn (Throwable $e) => $e instanceof QueryException && $e->getCode() === '40001');

ในการทดลองของเขา ฝั่งที่แพ้รอราว 115 ms แล้ว commit ได้ในรอบที่สอง

deadlock ที่อาจเจอในการใช้งานจริง

  • ช่วงโปรโมชันหรือ flash sale คำสั่งซื้อจำนวนมากแย่งตัดสต๊อกสินค้าชุดเดียวกัน ถ้าโค้ดล็อกตามลำดับในตะกร้า deadlock จะโผล่เฉพาะวันที่คนเข้าเยอะ ทดสอบในเครื่องเราไม่เจอ
  • database queue บนฐานข้อมูลรุ่นเก่า worker หลายตัวแย่งล็อกแถวในตาราง jobs จน job ที่ทำเสร็จแล้วกลับมารันซ้ำ หรือไปค้างใน failed_jobs รายละเอียดอยู่ในโพสต์วิธีกัน job ที่ทำเสร็จแล้วไปค้างใน failed_jobs เพราะ deadlock
  • งาน import หรือ sync ข้อมูลที่รันทับกัน เช่น scheduler ที่ดึงข้อมูลจากระบบอื่นทุกนาทีแล้วรอบก่อนยังไม่จบ ทั้งสองรอบจะ update แถวชุดเดียวกัน ตั้ง withoutOverlapping() ให้ task นั้นช่วยได้

ถ้าอยากดูว่า deadlock ครั้งล่าสุดเกิดจากคำสั่งไหน ใน MySQL ใช้ SHOW ENGINE INNODB STATUS ได้ หรือถ้าเกิดบ่อย ให้เปิด innodb_print_all_deadlocks ให้ MySQL บันทึกทุกครั้งลง error log ไว้ดูย้อนหลัง

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →