Bài trước cho thấy khi một giao dịch giữ khóa, giao dịch khác phải chờ. Chờ thì bình thường — nhưng điều gì xảy ra nếu hai giao dịch chờ lẫn nhau? A chờ khóa mà B đang giữ, đồng thời B chờ khóa mà A đang giữ. Không ai nhả, không ai đi tiếp — deadlock. Nếu không có cơ chế xử lý, cả hai sẽ treo mãi mãi.

Bài này (phần 6 loạt PostgreSQL) tạo một deadlock thật trên pg-lab, cho thấy PostgreSQL tự phát hiện và phá vòng bằng cách hủy một nạn nhân, và chỉ ra cách tránh deadlock triệt để — một kỹ thuật đơn giản mà nhiều bug production bỏ qua.

Cơ chế: vòng chờ chết

Deadlock xảy ra khi các giao dịch tạo thành một vòng chờ (circular wait): A giữ khóa hàng 1 và xin khóa hàng 2; B giữ khóa hàng 2 và xin khóa hàng 1. A chờ B, B chờ A — vòng tròn khép kín.

Deadlock PostgreSQL khóa chéo: vòng chờ chết circular wait A giữ khóa hàng 1 xin khóa hàng 2 B đang giữ chờ, B giữ khóa hàng 2 xin khóa hàng 1 A đang giữ chờ, A chờ B B chờ A vòng tròn không ai thoát deadlock; cách tạo hai tx khóa hai hàng theo thứ tự ngược nhau Tx A BEGIN UPDATE acc WHERE id 1 UPDATE acc WHERE id 2 COMMIT, Tx B BEGIN UPDATE acc WHERE id 2 UPDATE acc WHERE id 1 COMMIT mỗi bên khóa 1 hàng rồi xin hàng kia; PostgreSQL phát hiện và xử lý tự động khi một tx chờ khóa quá deadlock_timeout mặc định 1 giây PostgreSQL kiểm tra đồ thị chờ nhau wait-for graph nếu thấy vòng chọn 1 nạn nhân hủy rollback nó ERROR deadlock detected tx còn lại đi tiếp bình thường ứng dụng nạn nhân phải thử lại

Hình 1: Deadlock = vòng chờ chết. A giữ hàng 1 xin hàng 2, B giữ hàng 2 xin hàng 1 → chờ nhau mãi. PostgreSQL phát hiện vòng (sau deadlock_timeout) và hủy một nạn nhân. Tránh triệt để bằng cách luôn khóa theo cùng một thứ tự.

-- Tx A:                          -- Tx B:
BEGIN;                            BEGIN;
UPDATE acc ... WHERE id = 1;      UPDATE acc ... WHERE id = 2;   -- moi ben khoa 1 hang
UPDATE acc ... WHERE id = 2;      UPDATE acc ... WHERE id = 1;   -- roi xin hang kia -> ket
COMMIT;                           COMMIT;

Đo thật: tạo deadlock và xem PostgreSQL xử lý

Mình chạy hai giao dịch đồng thời trên pg-lab, mỗi cái khóa một hàng rồi xin hàng kia theo thứ tự ngược nhau. Kết quả thật:

Bảng kết quả đo thật deadlock trên pg-lab PostgreSQL 16.15 với 2 tx đồng thời acc 1 100 cộng 2 100 khóa chéo: kịch bản và kết quả A UPDATE id 1 khóa ngủ UPDATE id 2 thành công COMMIT, B UPDATE id 2 khóa ngủ UPDATE id 1 bị hủy, ERROR deadlock detected DETAIL Process 18698 waits for ShareLock on transaction 857330 blocked by process 18699 HINT See server log for query details; trạng thái sau deadlock chỉ A được áp dụng id 1 bal 90 A trừ 10 id 2 bal 110 A cộng 10 B bị rollback hoàn toàn mọi thay đổi của B biến mất. Badge output thật màu xanh

Hình 2: Kết quả thật. B bị ERROR deadlock detected (nạn nhân, bị rollback), A hoàn thành (COMMIT). Sau đó chỉ thay đổi của A được áp dụng: id=1 bal=90, id=2 bal=110. PostgreSQL tự phát hiện vòng chờ và phá nó.

  • A hoàn thành, B bị hủy: A khóa hàng 1, ngủ, rồi khóa hàng 2 → thành công, COMMIT. B khóa hàng 2, ngủ, rồi xin hàng 1 (A đang giữ) → tạo thành vòng → ERROR: deadlock detected. B là nạn nhân, bị rollback.
  • Thông báo lỗi thật: ERROR: deadlock detected, DETAIL: Process 18698 waits for ShareLock on transaction 857330; blocked by process 18699, HINT: See server log for query details. PostgreSQL cho biết chính xác tiến trình nào chờ tiến trình nào.
  • Trạng thái cuối nhất quán: chỉ thay đổi của A được áp dụng — id=1 bal=90 (A trừ 10), id=2 bal=110 (A cộng 10). Mọi thay đổi của B biến mất (rollback hoàn toàn). Dữ liệu không bị để lại ở trạng thái dở dang.

PostgreSQL phát hiện deadlock thế nào? Khi một giao dịch chờ khóa quá deadlock_timeout (mặc định 1 giây), nó kiểm tra đồ thị chờ-nhau (wait-for graph). Nếu thấy một vòng, nó chọn một nạn nhân và hủy. Nếu không có cơ chế này, A và B sẽ treo vô hạn — deadlock detection là thứ biến "treo chết" thành "một giao dịch thất bại, thử lại được".

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

Deadlock trong demo xảy ra vì A khóa theo thứ tự (1, 2) còn B theo (2, 1). Nếu cả hai luôn khóa theo cùng một thứ tự — ví dụ luôn id nhỏ trước — thì không bao giờ có vòng chờ: cái nào lấy được hàng 1 trước sẽ lấy luôn hàng 2, cái kia chờ (không chéo). Đây là cách tránh deadlock triệt để và đơn giản nhất:

-- Luon khoa theo thu tu id tang dan, du nghiep vu yeu cau thu tu nao:
SELECT * FROM acc WHERE id IN (1, 2) ORDER BY id FOR UPDATE;  -- khoa ca hai theo thu tu
-- hoac trong code: sap xep danh sach id truoc khi khoa tung cai

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

Deadlock không thể loại bỏ hoàn toàn — ứng dụng PHẢI bắt và thử lại. Dù sắp thứ tự khóa cẩn thận, deadlock vẫn có thể phát sinh từ các nguồn khó lường: foreign key, index unique, trigger, hay các câu lệnh khóa ngầm. Vì vậy, mọi ứng dụng dùng database đồng thời phải bọc giao dịch trong vòng lặp bắt lỗi deadlock (SQLSTATE 40P01) và thử lại. Xem deadlock như một lỗi thoáng qua (transient) cần retry, không phải lỗi logic — giống serialization_failure ở bài 4.

deadlock_timeout là đánh đổi giữa phát hiện nhanh và chi phí kiểm tra. Mặc định 1 giây: PostgreSQL không kiểm tra deadlock ngay khi một giao dịch bắt đầu chờ (vì phần lớn chờ là chờ bình thường, sẽ được nhả sớm), mà chỉ kiểm sau khi chờ quá timeout. Hạ deadlock_timeout giúp phát hiện deadlock nhanh hơn nhưng làm việc kiểm tra wait-for graph chạy thường xuyên hơn (tốn CPU) ngay cả với các chờ vô hại. Mặc định 1s hợp cho hầu hết; chỉ chỉnh khi bạn đo được deadlock thường xuyên và cần phản hồi nhanh hơn.

Giữ giao dịch ngắn và khóa ít hàng giảm xác suất deadlock. Deadlock cần hai giao dịch cùng giữ nhiều khóa và chờ chéo. Giao dịch càng ngắn, càng ít hàng bị khóa cùng lúc, cửa sổ để deadlock xảy ra càng nhỏ. Các mẹo ở bài trước (khóa muộn, commit sớm, không giữ khóa qua I/O ngoài) cũng giảm deadlock. Kết hợp: thứ tự khóa nhất quán + giao dịch ngắn + retry — đó là bộ ba chống deadlock trong thực tế.

Ba ý mang về

  1. Deadlock = vòng chờ chéo giữa các giao dịch. Đo thật: A giữ hàng 1 xin hàng 2, B giữ hàng 2 xin hàng 1 → PostgreSQL phát hiện và hủy B với ERROR deadlock detected, A hoàn thành. Nếu không có cơ chế phát hiện, cả hai treo vĩnh viễn.
  2. PostgreSQL tự phát hiện (deadlock_timeout 1s) và rollback nạn nhân hoàn toàn. Đo thật: sau deadlock, chỉ thay đổi của A được áp dụng (id=1→90, id=2→110), B rollback sạch. Dữ liệu luôn nhất quán, không để trạng thái dở dang.
  3. Tránh triệt để bằng thứ tự khóa nhất quán; vẫn phải bắt lỗi và thử lại. Luôn khóa các hàng theo cùng một thứ tự (vd id tăng dần) thì không có vòng chờ. Nhưng deadlock không thể loại bỏ 100% — bọc giao dịch trong retry bắt SQLSTATE 40P01, giữ giao dịch ngắn.

Nguồn

Phần sau ta quay lại hệ quả của MVCC: UPDATE để lại hàng chết khiến bảng phình to (bloat), đo thật kích thước bảng tăng dần sau nhiều lần cập nhật, và vì sao dead tuples tích tụ.