1. Bối cảnh & Tại sao cần xử lý Deadlock
Lần đó team mình migrate database 100GB từ MySQL sang PostgreSQL. Mất 3 ngày lên plan, 1 ngày chạy cutover. Mọi thứ êm đẹp cho tới 20h tối — đúng khung giờ flash sale khi hàng nghìn user nạp ví và thanh toán cùng lúc. Connection pool 200 kết nối của backend cạn sạch trong 30 giây. API timeout hàng loạt, còn log PostgreSQL thì đỏ rực dòng lỗi kinh điển: ERROR: deadlock detected.
Nguyên nhân gốc rễ nằm ở cơ chế locking của PostgreSQL để bảo đảm tính toàn vẹn ACID. Khóa chết (deadlock) xảy ra khi Transaction A giữ khóa Record 1 rồi đòi khóa Record 2. Đúng lúc đó, Transaction B lại giữ Record 2 và đòi khóa Record 1. Không bên nào chịu nhượng bộ. Cả hai kẹt cứng trong vòng lặp chờ đợi vô tận.
PostgreSQL có tiến trình nền phát hiện vòng lặp này và tự động abort một transaction ngẫu nhiên để giải cứu hệ thống. Dù vậy, bạn đừng bao giờ coi cơ chế tự hủy này là giải pháp lâu dài. Khi deadlock xảy ra dồn dập, database phải liên tục quét lock graph khiến CPU vọt lên 90-100%, user bị văng lỗi thanh toán và kết nối ứng dụng sụp đổ như domino.
2. Tự tay tái hiện Deadlock trên máy local
Muốn trị được bệnh, trước hết phải hiểu rõ cách nó phát tác. Cách trực quan nhất là mở hai terminal và tự dàn dựng một vụ va chạm khóa.
Đầu tiên, tạo bảng tài khoản và thêm 2 bản ghi giả lập Alice và Bob:
CREATE TABLE accounts (
id SERIAL PRIMARY KEY,
owner_name VARCHAR(50) NOT NULL,
balance NUMERIC(12, 2) NOT NULL DEFAULT 0
);
INSERT INTO accounts (owner_name, balance) VALUES
('Alice', 1000.00),
('Bob', 2000.00);
Giờ bạn mở hai tab terminal (Session 1 và Session 2) kết nối vào Postgres, rồi chạy lần lượt theo timeline sau:
Bước 1: Tại Session 1, mở transaction và trừ 100$ của Alice:
-- Session 1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
Bước 2: Chuyển sang Session 2, mở transaction khác và trừ 200$ của Bob:
-- Session 2
BEGIN;
UPDATE accounts SET balance = balance - 200 WHERE id = 2;
Bước 3: Quay lại Session 1, thử cộng 100$ vào tài khoản Bob:
-- Session 1
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
Lúc này terminal 1 đứng im. Query bị treo vì dòng id = 2 đang bị Session 2 giữ RowExclusiveLock.
Bước 4: Tại Session 2, chạy lệnh chuyển tiền ngược lại cho Alice:
-- Session 2
UPDATE accounts SET balance = balance + 200 WHERE id = 1;
Bùm! Chỉ sau đúng 1 giây, chu trình khóa khép kín. PostgreSQL phát hiện deadlock và thẳng tay khai tử Session 2:
ERROR: deadlock detected
DETAIL: Process 14210 waits for ShareLock on transaction 85412; blocked by process 14205.
Process 14205 waits for ShareLock on transaction 85413; blocked by process 14210.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,1) in relation "accounts"
3. Bộ 3 cấu hình cứu cánh: deadlock_timeout, lock_timeout và log
PostgreSQL không kiểm tra deadlock ngay lập tức khi một query bị block. Lý do đơn giản: thuật toán duyệt đồ thị khóa ngốn rất nhiều CPU. Postgres sẽ đợi một khoảng thời gian nhất định rồi mới chạy bộ kiểm tra.
3.1. deadlock_timeout: Rút ngắn thời gian phát hiện
deadlock_timeout xác định khoảng thời gian một process phải chờ khóa trước khi Postgres kích hoạt worker quét deadlock. Con số mặc định của engine là 1s.
Với các hệ thống xử lý giao dịch cao tải (OLTP hàng nghìn TPS), 1 giây chờ đợi là quá lâu. Hàng trăm request đến sau sẽ ùn ứ theo. Bạn nên hạ giá trị này xuống 200ms - 300ms:
-- Áp dụng ngay trên cluster mà không cần restart server
ALTER SYSTEM SET deadlock_timeout = '300ms';
SELECT pg_reload_conf();
3.2. log_lock_waits: Bật radar cảnh báo sớm
Mặc định PostgreSQL chỉ ghi log khi câu chuyện đã biến thành thảm họa (lỗi deadlock). Nếu muốn bắt thóp các query chạy chậm chạp đang giam khóa người khác, hãy bật ngay log_lock_waits. Bất cứ khi nào một tiến trình phải đứng chờ khóa vượt mốc deadlock_timeout, server sẽ ghi chi tiết vào file log:
ALTER SYSTEM SET log_lock_waits = 'on';
SELECT pg_reload_conf();
3.3. lock_timeout: Cắt đứt dây cháy chậm
Đây là vũ khí quan trọng nhất để bảo vệ database. Mặc định lock_timeout = 0 (chờ đến thiên thu). Nếu gặp nghẽn khóa, query sẽ treo vô hạn cho đến khi cạn kiệt pool kết nối. Hãy đặt giới hạn để query tự hủy nếu không xin được khóa sau vài giây:
-- Gán 3s cho session hiện tại
SET lock_timeout = '3s';
-- Cực kỳ quan trọng khi chạy DDL Migration trên Production
BEGIN;
SET LOCAL lock_timeout = '2s';
ALTER TABLE accounts ADD COLUMN status VARCHAR(20) DEFAULT 'active';
COMMIT;
Nếu bảng accounts đang có giao dịch đọc/ghi dở dang, lệnh ALTER TABLE trên sẽ tự thoát sau 2 giây thay vì phong tỏa toàn bộ bảng.
4. Giám sát & Chiến lược xử lý tận gốc từ tầng Ứng dụng
4.1. Bắt quả tang root-blocker bằng truy vấn Lock Trees
Khi metric database báo động đỏ và latency vọt lên, bạn cần biết ngay: Ai là kẻ đầu sỏ đang giữ khóa (root blocker), và ai đang là nạn nhân? Câu query dưới đây bóc tách chính xác mối quan hệ chờ đợi giữa các PID:
SELECT
blocked_locks.pid AS blocked_pid,
blocked_activity.usename AS blocked_user,
blocking_locks.pid AS blocking_pid,
blocking_activity.usename AS blocking_user,
blocked_activity.query AS blocked_statement,
blocking_activity.query AS blocking_statement,
now() - blocked_activity.query_start AS waiting_duration
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;
Nếu thấy một PID đang ôm khóa quá lâu làm đơ cả hệ thống, bạn có 2 nấc xử lý khẩn cấp:
-- Mức 1: Hủy nhẹ nhàng query đang chạy của PID đó
SELECT pg_cancel_backend(<blocking_pid>);
-- Mức 2: Nếu lệnh trên không phản hồi, cưỡng chế ngắt kết nối
SELECT pg_terminate_backend(<blocking_pid>);
4.2. Khắc phục triệt để trong code Backend
Tối ưu database chỉ giúp giảm thiệt hại. Muốn chấm dứt deadlock, bạn phải sửa từ tư duy viết code nghiệp vụ:
- Luôn sắp xếp thứ tự cập nhật tài nguyên: Đây là nguyên lý sống còn. Cả Transaction A và B khi cần thao tác trên nhiều account phải luôn khóa tài nguyên theo cùng một trật tự (ví dụ sort ID tăng dần). Lưu ý: cú pháp
UPDATE ... ORDER BYkhông hợp lệ trong PostgreSQL. Bạn cần sort mảng ID ngay trong code backend trước khi thực thi, hoặc dùng lệnh khóa trước:SELECT id FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE. - Không nhét I/O ngoài vào trong Transaction: Tuyệt đối không gọi HTTP API bên thứ ba, không gửi email, không gen file PDF ở giữa khối
BEGIN ... COMMIT. Hãy chuẩn bị hết dữ liệu ở bộ nhớ ngoài. Mở transaction, ghi database thật nhanh rồi commit ngay lập tức (mục tiêu < 50ms). - Tận dụng SKIP LOCKED cho tác vụ Queue: Với các background worker lấy job ra xử lý, đừng dùng
SELECT FOR UPDATEthông thường. Hãy đổi sangSELECT ... FOR UPDATE SKIP LOCKED. Các worker sẽ tự động bỏ qua những dòng đang bị process khác xử lý, triệt tiêu 100% nguy cơ đụng độ khóa. - Triển khai Retry với Exponential Backoff: Deadlock là lỗi nhất thời (transient error). Tầng ứng dụng nên có middleware bắt mã lỗi Postgres
40P01. Khi bắt gặp mã này, tự động thử lại sau 50ms, 100ms, 200ms kèm một chút jitter ngẫu nhiên. Trải nghiệm người dùng sẽ hoàn toàn mượt mà.

