Deadlock trong PostgreSQL: nguyên nhân, đọc log, khắc phục Trung cấp
Thời gian đọc ước tính: 7 phút
Cần đọc trước
Triệu chứng
Phần tiêu đề “Triệu chứng”Ứng dụng chuyển tiền đột nhiên báo lỗi từ PostgreSQL, SQLSTATE 40P01 (deadlock_detected). Từng transaction chạy riêng đều thành công; chạy đồng thời, chúng lấy lock theo thứ tự ngược nhau và tạo vòng chờ.
Phân biệt hai thứ:
- Lock wait: A giữ một row, B chờ A. Khi A commit, B đi tiếp.
- Deadlock: A chờ thứ B giữ đồng thời B chờ thứ A giữ. Không bên nào tự tiến lên được, nên PostgreSQL phải huỷ một transaction.
Nguyên nhân gốc
Phần tiêu đề “Nguyên nhân gốc”A cập nhật row 1 và giữ lock đến hết transaction.
| Thời điểm | Session A | Session B | Có thể tiến tiếp? |
|---|---|---|---|
| 1 | Giữ row 1 | Có | |
| 2 | Giữ row 2 | Có | |
| 3 | Muốn row 2, chờ B | B còn chạy được | |
| 4 | Chờ B | Muốn row 1, chờ A | Không: vòng khép kín |
Sau khi một lần chờ lock kéo dài đến deadlock_timeout (mặc định 1s), PostgreSQL kiểm tra deadlock. Đây là ngưỡng chờ trước khi kiểm tra, không phải job quét định kỳ. Thấy vòng chờ, nó huỷ một transaction. Tài liệu nói rõ transaction nào bị huỷ “khó dự đoán và đừng dựa vào”, nên code phải xử lý cả hai vai.
Tái hiện và đọc lỗi
Phần tiêu đề “Tái hiện và đọc lỗi”Chạy examples/docker-compose.yml rồi examples/src/postgres-locks/deadlock-demo.ts. Script mở hai kết nối và cho chúng cập nhật hai row theo thứ tự ngược nhau:
// Session Aawait sessionA.query('UPDATE accounts SET balance = balance - 100 WHERE id = 1'); // A giữ row 1// Session Bawait sessionB.query('UPDATE accounts SET balance = balance - 50 WHERE id = 2'); // B giữ row 2
// A cố cập nhật row 2 (B đang giữ) → A chờconst a2 = sessionA.query('UPDATE accounts SET balance = balance + 100 WHERE id = 2');// B cố cập nhật row 1 (A đang giữ) → B chờ → vòng chờ khép kínconst b2 = sessionB.query('UPDATE accounts SET balance = balance + 50 WHERE id = 1');Output thực tế từ lần chạy:
--- Kết quả ---Session A: LỖI — deadlock detected code: 40P01 detail: Process 528 waits for ShareLock on transaction 760; blocked by process 527.Process 527 waits for ShareLock on transaction 759; blocked by process 528. hint: See server log for query details.Session B: thành côngHai dòng DETAIL ghép lại thành vòng chờ: process 528 chờ 527 và 527 chờ 528. PID và transaction ID thay đổi ở mỗi lần chạy, chỉ cần đọc quan hệ “ai chờ ai”. Phân loại lỗi bằng mã 40P01, không so khớp chuỗi deadlock detected.
Bốn mẫu deadlock hay gặp ngoài đời
Phần tiêu đề “Bốn mẫu deadlock hay gặp ngoài đời”Hiếm khi deadlock là hai UPDATE tay như trên. Thường gặp hơn:
| Mẫu | Vì sao deadlock | Cách chữa |
|---|---|---|
Batch UPDATE/INSERT ... ON CONFLICT theo lô từ hai worker, mỗi worker duyệt row theo thứ tự khác nhau |
Hai lô khoá cùng tập row nhưng thứ tự khác | Sắp xếp theo khoá trước khi gửi, hoặc ORDER BY id trong câu lệnh |
Hai bảng, thứ tự ngược: luồng 1 sửa orders rồi inventory, luồng 2 sửa inventory rồi orders |
Vòng chờ giữa hai bảng | Quy ước thứ tự bảng toàn codebase |
Foreign key: một bên DELETE hoặc SELECT FOR UPDATE row cha, bên kia insert row con (lấy FOR KEY SHARE trên cha) rồi sửa row đó |
FOR UPDATE xung đột với FOR KEY SHARE (xem row-level locks) |
Khoá row cha trước trong mọi luồng; giữ transaction ngắn |
SELECT ... FOR UPDATE trên join |
Không kiểm soát được bảng nào khoá trước | FOR UPDATE OF <bảng> và khoá theo thứ tự quy ước |
Cách phát hiện
Phần tiêu đề “Cách phát hiện”- Tìm
40P01ở app vàERROR: deadlock detectedtrong log server; đọcDETAILđể nối các process. - Bật
log_lock_waits: PostgreSQL ghi log khi một lần chờ lock vượtdeadlock_timeout. Nó phục vụ điều tra chờ lock, không phải điều kiện để phát hiện deadlock hoạt động. - Theo dõi bộ đếm:
SELECT datname, deadlocks FROM pg_stat_database WHERE datname = current_database();Bộ đếm tích luỹ từ lần reset statistics gần nhất, nên cảnh báo theo tốc độ tăng và theo tỷ lệ retry của app, không theo giá trị tuyệt đối. Nó không cho biết query nào gây lỗi: gom DETAIL theo endpoint để tìm. Deadlock tăng đột ngột sau deploy gần như luôn do đường code vừa đổi.
Khắc phục ngay
Phần tiêu đề “Khắc phục ngay”Phòng ngừa lâu dài
Phần tiêu đề “Phòng ngừa lâu dài”Lock ordering: mọi luồng khoá cùng tập row theo cùng một thứ tự. Cách thô là hai câu SELECT ... FOR UPDATE theo id tăng dần trong app. Cách tốt hơn là một câu duy nhất:
SELECT id FROM accountsWHERE id IN ($1, $2)ORDER BY idFOR UPDATE;Một câu giảm một lượt round trip và để database khoá theo thứ tự sắp xếp. Lưu ý tài liệu cảnh báo ở Read Committed, ORDER BY kèm locking clause có thể trả row lệch thứ tự nếu row bị sửa trong lúc chờ lock.
Kiểm tra cả lock lấy gián tiếp qua trigger, foreign key hoặc bảng khác. Ví dụ 1 → 2 và 2 → 1: nếu code khoá “tài khoản gửi trước, nhận sau”, hai giao dịch lấy lock ngược nhau. Nếu cả hai khoá id nhỏ trước, giao dịch đến sau chờ ngay ở id 1 khi chưa giữ gì, nên vòng chờ không hình thành.
Các lớp bảo vệ bổ sung:
| Công cụ | Công dụng |
|---|---|
lock_timeout |
Giới hạn thời gian chờ một lock (mặc định 0, tức chờ vô hạn); nên đặt cho app |
statement_timeout |
Giới hạn thời gian cả câu lệnh |
deadlock_timeout |
Tăng lên (mặc định 1s) để giảm số lần kiểm tra thừa trên server rất bận, đổi lại báo deadlock thật chậm hơn. Lý tưởng là lớn hơn thời gian transaction điển hình |
| Retry có giới hạn | Chạy lại cả transaction cho 40P01; jitter, tối đa vài lần, rồi báo lỗi rõ ràng |
Retry phải chạy lại từ bước đọc đầu tiên: dữ liệu đọc trước đó có thể đã cũ. Nếu transaction kích hoạt side effect ngoài, cần idempotency.
Checklist production
Phần tiêu đề “Checklist production”- Mọi luồng khoá nhiều row hoặc nhiều bảng theo một thứ tự quy ước (sắp xếp theo khoá).
- Wrapper retry cho
40P01(cùng40001): chạy lại toàn bộ transaction, có giới hạn và jitter. - Đặt
lock_timeoutvàstatement_timeoutcho app,log_lock_waits = oncho server. - Alert theo tốc độ tăng
pg_stat_database.deadlocksvà tỷ lệ retry sau mỗi lần deploy. - Gom
DETAILcủa log theo endpoint để tìm đường code gây vòng chờ. - Transaction ngắn: không gọi mạng chậm khi đang giữ lock.
- Side effect ngoài database đi qua outbox hoặc idempotency.
Trade-offs
Phần tiêu đề “Trade-offs”Lock ordering và retry
- Lock ordering loại bỏ hẳn lớp deadlock do thứ tự khoá, không cần retry.
- Retry có giới hạn che các deadlock hiếm còn sót, với chi phí thấp.
lock_timeoutbiến chờ vô hạn thành lỗi có kiểm soát.
Chi phí cần quản lý
- Lock ordering phải được giữ trong toàn bộ codebase; một đường code lạc thứ tự là đủ gây lỗi.
- Retry làm lặp công việc và có thể lặp side effect nếu thiếu idempotency.
- Tăng
deadlock_timeoutlàm báo lỗi deadlock thật chậm hơn.