Bỏ qua để đến nội dung
Knowledge Hub

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

Ứ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.
Deadlock: hai session khoá chéo nhau
Deadlock: hai session khoá chéo nhauBước 1/5: A cập nhật row 1 và giữ lock đến hết transaction.thời giangiữ lock row 1 …UPDATE row 1giữ row 1Session Achuyển 1 → 2Session Bchuyển 2 → 1
Bước 1/5

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.

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 A
await sessionA.query('UPDATE accounts SET balance = balance - 100 WHERE id = 1'); // A giữ row 1
// Session B
await 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ín
const 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ông

Hai 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.

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
  • Tìm 40P01 ở app và ERROR: deadlock detected trong log server; đọc DETAIL để nối các process.
  • Bật log_lock_waits: PostgreSQL ghi log khi một lần chờ lock vượt deadlock_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.

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 accounts
WHERE id IN ($1, $2)
ORDER BY id
FOR 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.

  1. 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á).
  2. Wrapper retry cho 40P01 (cùng 40001): chạy lại toàn bộ transaction, có giới hạn và jitter.
  3. Đặt lock_timeout và statement_timeout cho app, log_lock_waits = on cho server.
  4. Alert theo tốc độ tăng pg_stat_database.deadlocks và tỷ lệ retry sau mỗi lần deploy.
  5. Gom DETAIL của log theo endpoint để tìm đường code gây vòng chờ.
  6. Transaction ngắn: không gọi mạng chậm khi đang giữ lock.
  7. Side effect ngoài database đi qua outbox hoặc idempotency.

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_timeout biế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_timeout làm báo lỗi deadlock thật chậm hơn.