Bài trước học cách tìm truy vấn nào chặn truy vấn nào. Deadlock là tình huống tệ nhất của khóa: hai giao dịch chặn lẫn nhau thành một vòng tròn, không ai đi được. Tin tốt là PostgreSQL tự phát hiện và gỡ. Bài này kích một deadlock thật để xem chính xác lỗi và log, rồi chứng minh cách phòng tránh đơn giản loại bỏ nó hoàn toàn.

Deadlock là gì

Deadlock hình thành khi hai giao dịch giữ khóa mà bên kia đang cần, và chúng lấy khóa theo thứ tự ngược nhau:

  • Session A: UPDATE id=1 (khóa hàng 1), rồi UPDATE id=2 — nhưng hàng 2 đang bị B giữ.
  • Session B: UPDATE id=2 (khóa hàng 2), rồi UPDATE id=1 — nhưng hàng 1 đang bị A giữ.

A chờ B nhả hàng 2; B chờ A nhả hàng 1. Vòng chờ khép kín — không ai tiến được. Nếu không có gì can thiệp, cả hai treo vĩnh viễn.

Ảnh chụp đoạn mã SQL nền tối minh hoạ deadlock hai giao dịch chặn lẫn nhau PostgreSQL tự gỡ, deadlock là gì khóa chéo theo thứ tự ngược nhau session A BEGIN UPDATE id 1 khóa hàng 1 UPDATE id 2 chờ B nhả 2 session B BEGIN UPDATE id 2 khóa hàng 2 UPDATE id 1 chờ A nhả 1 vòng chờ lẫn nhau A chờ B B chờ A không ai đi được deadlock, PostgreSQL tự phát hiện và hủy một nạn nhân SHOW deadlock_timeout 1s sau khi chờ 1 giây chạy bộ dò deadlock nếu phát hiện vòng chờ PostgreSQL hủy một giao dịch nạn nhân với lỗi deadlock detected giao dịch kia chạy tiếp bình thường nạn nhân bị ROLLBACK ứng dụng phải bắt lỗi và thử lại, cách tránh 1 luôn khóa theo cùng một thứ tự mọi giao dịch đụng nhiều hàng phải khóa theo thứ tự nhất quán vd luôn theo id tăng dần khi đó giao dịch thứ hai chỉ chờ không deadlock UPDATE vi SET WHERE id 1 cả A lẫn B 1 trước UPDATE vi SET WHERE id 2 rồi mới 2, cách tránh khác giao dịch ngắn giữ khóa càng ít thời gian cửa sổ deadlock càng nhỏ khóa gộp một lần SELECT FOR UPDATE nhiều hàng cùng lúc ORDER BY id lock_timeout NOWAIT thất bại nhanh thay vì chờ dài rồi deadlock luôn có retry bắt SQLSTATE 40P01 và chạy lại giao dịch

Hình 1: Deadlock là khóa chéo theo thứ tự ngược nhau — A chờ B, B chờ A. PostgreSQL tự phát hiện sau deadlock_timeout (1s) và hủy một nạn nhân. Cách tránh chính: luôn khóa theo cùng một thứ tự.

Đo thật: kích một deadlock

Tôi cho hai session lấy khóa theo thứ tự ngược nhau (A: 1→2, B: 2→1). Đây là lỗi thật mà session nạn nhân nhận được:

Ảnh chụp bảng kết quả đo thật nền tối PostgreSQL bắt deadlock sau 1 giây và hủy nạn nhân A khóa 1 sang 2 B khóa 2 sang 1 thứ tự ngược deadlock_timeout 1s PostgreSQL 16, lỗi thật mà session nạn nhân B nhận được ERROR deadlock detected DETAIL Process 43579 waits for ShareLock on transaction 102871 blocked by process 43577 Process 43577 waits for ShareLock on transaction 102872 blocked by process 43579 vòng chờ khép kín HINT See server log for query details CONTEXT while updating tuple 0,1 in relation vi ROLLBACK giao dịch B bị hủy hoàn toàn session A không bị gì COMMIT thành công, log máy chủ chỉ rõ hai truy vấn xung đột Process 43579 UPDATE vi SET so_du bằng so_du cộng 10 WHERE id 1 Process 43577 UPDATE vi SET so_du bằng so_du cộng 10 WHERE id 2 đây là bằng chứng để sửa hai lệnh khóa theo thứ tự ngược nhau, sửa cả hai khóa cùng thứ tự 1 rồi 2 không deadlock session B giờ cũng 1 sang 2 BEGIN UPDATE 1 chờ A nhả hàng 1 UPDATE 1 rồi chạy tiếp bình thường COMMIT không có lỗi deadlock chỉ chờ rồi xong, bài học deadlock không làm sập DB PostgreSQL tự gỡ bằng cách hủy 1 nạn nhân nhưng nạn nhân mất công làm lại ứng dụng phải có retry bắt 40P01 phòng bệnh hơn chữa khóa theo cùng thứ tự trong mọi giao dịch

Hình 2: Lỗi thật ERROR: deadlock detected với DETAIL hiện rõ vòng chờ khép kín; nạn nhân (B) bị ROLLBACK, còn A COMMIT thành công. Log máy chủ chỉ ra hai truy vấn xung đột. Khi sửa cả hai về cùng thứ tự (1→2), B chỉ chờ rồi chạy — không deadlock.

Session B (nạn nhân) nhận:

ERROR:  deadlock detected
DETAIL:  Process 43579 waits for ShareLock on transaction 102871; blocked by process 43577.
         Process 43577 waits for ShareLock on transaction 102872; blocked by process 43579.
HINT:  See server log for query details.
CONTEXT:  while updating tuple (0,1) in relation "vi"
ROLLBACK

PostgreSQL sau khi một giao dịch chờ quá deadlock_timeout (mặc định 1 giây) sẽ chạy bộ dò deadlock, phát hiện vòng chờ, và hủy một nạn nhân — ở đây là process 43579 (session B) bị ROLLBACK toàn bộ giao dịch. Session A hoàn toàn không bị ảnh hưởng, COMMIT bình thường. Quan trọng: log máy chủ chỉ rõ hai truy vấn xung đột (UPDATE ... WHERE id=1 và UPDATE ... WHERE id=2) — đây là bằng chứng vàng để sửa: hai lệnh đang khóa theo thứ tự ngược nhau.

Cách tránh: khóa cùng một thứ tự

Cách phòng tránh mạnh nhất đơn giản đến bất ngờ: mọi giao dịch đụng nhiều hàng phải khóa theo cùng một thứ tự nhất quán (ví dụ luôn theo id tăng dần). Tôi đổi cả hai session để cùng khóa 1 trước, rồi 2, và đo lại: session B giờ chỉ chờ A nhả hàng 1 rồi chạy tiếp bình thường — BEGIN, UPDATE, UPDATE, COMMIT, không có lỗi deadlock. Vòng chờ không thể hình thành khi mọi bên xếp hàng theo cùng thứ tự.

Các cách bổ trợ:

-- Khóa gộp một lần theo thứ tự cố định thay vì từng hàng:
SELECT * FROM vi WHERE id IN (1,2) ORDER BY id FOR UPDATE;
-- Thất bại nhanh thay vì chờ dài:
SET lock_timeout = '2s';               -- hoặc SELECT ... FOR UPDATE NOWAIT

Đánh đổi cần cân nhắc

Deadlock KHÔNG làm sập database — nhưng bạn vẫn phải xử lý. PostgreSQL tự gỡ bằng cách hủy một nạn nhân, nên hệ thống không treo. Nhưng nạn nhân mất toàn bộ công việc của giao dịch đó, nên ứng dụng bắt buộc phải có retry: bắt mã lỗi SQLSTATE 40P01 (deadlock_detected) và chạy lại cả giao dịch từ đầu. Không có retry thì một deadlock thoáng qua biến thành lỗi trả về người dùng.

Giao dịch ngắn giảm mạnh xác suất deadlock. Cửa sổ deadlock là khoảng thời gian một giao dịch giữ khóa này trong khi chờ khóa khác. Giao dịch càng ngắn (ít lệnh giữa BEGIN và COMMIT, không có thao tác chậm hay chờ mạng bên trong) thì cửa sổ càng nhỏ. Đừng để logic ứng dụng chậm (gọi API, tính toán nặng) nằm giữa hai lệnh khóa.

deadlock_timeout là ngưỡng chờ trước khi DÒ, không phải trước khi hủy. Đặt deadlock_timeout thấp làm bộ dò chạy thường xuyên hơn (tốn CPU) và có thể báo deadlock giả với các cuộc chờ hợp lệ chỉ hơi lâu; đặt cao thì deadlock thật bị treo lâu hơn trước khi được gỡ. Mặc định 1 giây hợp lý cho hầu hết trường hợp — chỉnh chỉ khi có lý do đo được.

Ba ý mang về

  1. Deadlock là hai giao dịch chặn lẫn nhau theo thứ tự khóa ngược nhau: kích thật, session nạn nhân nhận ERROR: deadlock detected với DETAIL hiện rõ vòng chờ, bị ROLLBACK; session kia COMMIT bình thường — DB không sập.
  2. PostgreSQL tự phát hiện sau deadlock_timeout (1s) và log rõ hai truy vấn xung đột — dùng log máy chủ để tìm chính xác các lệnh khóa ngược thứ tự cần sửa.
  3. Phòng tránh bằng khóa cùng một thứ tự trong mọi giao dịch (đo thật: đổi về cùng thứ tự thì chỉ còn chờ, không deadlock); bổ trợ bằng giao dịch ngắn, khóa gộp ORDER BY ... FOR UPDATE, lock_timeout — và luôn có retry bắt SQLSTATE 40P01.

Phần sau ta xét hai công cụ khóa hàng mạnh cho hàng đợi và xử lý đồng thời: Phần sau mổ xẻ SELECT FOR UPDATE và SKIP LOCKED — cách khóa hàng chủ động và bỏ qua hàng đang bị khóa để làm hàng đợi công việc không tranh chấp.