0

PostgreSQL Bài 32: Deadlock (Khóa chéo) và Công cụ chẩn đoán (Lock Monitoring)

Ở Bài 31, bạn đã biết cách sử dụng các loại khóa (Table Locks, Row Locks) để bảo vệ toàn vẹn dữ liệu khỏi các luồng cập nhật đồng thời. Tuy nhiên, khóa vật lý mang đến một rủi ro tiềm ẩn nguy hiểm bậc nhất trong lập trình đa luồng (multi-threading) và cơ sở dữ liệu: Deadlock (Khóa chéo / Bế tắc).

Trên môi trường production, Deadlock không chỉ làm một vài request bị lỗi mà có thể tạo ra hiệu ứng domino, làm treo cứng toàn bộ hệ thống nếu không được chẩn đoán và xử lý đúng cách. Bài học này sẽ giúp bạn hiểu rõ nguyên lý hình thành Deadlock, cơ chế tự giải cứu của PostgreSQL và các công cụ "bắt quả tang" query gây nghẽn.

1. Bản chất của Deadlock: Vòng lặp chờ đợi vô tận

Deadlock xảy ra khi hai (hoặc nhiều) Transaction cùng giữ một số tài nguyên và đang chờ đợi tài nguyên mà bên kia đang giữ. Cả hai không ai chịu nhường ai, dẫn đến bế tắc vĩnh viễn.

Kịch bản thực tế: Giao dịch chuyển tiền chéo

Giả sử có hai tài khoản ngân hàng: Alice (id = 1) và Bob (id = 2).

  • Transaction A: Alice chuyển tiền cho Bob (Trừ tiền Alice trước, cộng tiền Bob sau).

  • Transaction B: Bob chuyển tiền cho Alice (Trừ tiền Bob trước, cộng tiền Alice sau).

Hai Transaction này diễn ra đồng thời:

Thời điểm Transaction A (Alice → Bob) Transaction B (Bob → Alice)
T1T_1 BEGIN; BEGIN;
T2T_2 UPDATE accounts SET balance = balance - 50 WHERE id = 1;

(Thành công. Xin được Row Lock trên dòng của Alice)
T3T_3 UPDATE accounts SET balance = balance - 50 WHERE id = 2;

(Thành công. Xin được Row Lock trên dòng của Bob)
T4T_4 UPDATE accounts SET balance = balance + 50 WHERE id = 2;

(Bị CHẶN! Chờ Transaction B nhả khóa của Bob)
T5T_5 (Đang chờ B...) UPDATE accounts SET balance = balance + 50 WHERE id = 1;

(Bị CHẶN! Chờ Transaction A nhả khóa của Alice)
T6T_6 DEADLOCK! (Chờ vô tận) DEADLOCK! (Chờ vô tận)

2. Người phán xử: Deadlock Detector và tham số deadlock_timeout

Nếu không có cơ chế can thiệp, hai Transaction trên sẽ bị treo cho đến khi server bị khởi động lại. May mắn thay, PostgreSQL tích hợp sẵn một tiến trình theo dõi dưới nền gọi là Deadlock Detector.

Cơ chế hoạt động:

  1. Khi Transaction A bị chặn ở T4T_4, PostgreSQL không lập tức kiểm tra Deadlock vì thuật toán tìm chu trình trên đồ thị phụ thuộc (Dependency Graph) rất tốn CPU.

  2. Thay vào đó, PostgreSQL sẽ cho Transaction A chờ một khoảng thời gian quy định bởi tham số deadlock_timeout (mặc định là 1 giây).

  3. Nếu sau 1 giây mà Transaction A vẫn chưa xin được khóa, Deadlock Detector mới thức dậy, quét đồ thị các khóa hiện tại.

  4. Nếu phát hiện chu trình (vòng lặp A chờ B, B chờ A), nó sẽ hi sinh (Abort) một trong hai Transaction và ném ra lỗi mã 40P01:

Plaintext

ERROR:  deadlock detected
DETAIL:  Process 12345 waits for ShareLock on transaction 67890; blocked by process 54321.
Process 54321 waits for ShareLock on transaction 67891; blocked by process 12345.
HINT:  See server log for query details.

Transaction bị abort sẽ tự động ROLLBACK, nhả toàn bộ khóa nó đang giữ, giúp Transaction còn lại thoát khỏi trạng thái chờ và tiếp tục thực thi thành công.

Mẹo cấu hình Production:

Trái với suy nghĩ thông thường, bạn không nên giảm deadlock_timeout xuống quá thấp (dưới 500ms). Việc đặt quá thấp sẽ ép Deadlock Detector chạy liên tục mỗi khi có một query bị chậm nhẹ, làm lãng phí CPU nghiêm trọng. Mức 1 giây đến 3 giây là con số chuẩn mực.

3. Ba quy tắc vàng phòng chống Deadlock từ phía Backend

Deadlock không phải là lỗi của cơ sở dữ liệu; nó là lỗi logic của ứng dụng. Bạn không thể "cấu hình" database để hết Deadlock, bạn phải sửa code backend theo 3 quy tắc sau:

Quy tắc 1: Khóa tài nguyên theo một thứ tự cố định (Strict Ordering)

Đây là cách giải quyết triệt để nhất. Nếu mọi Transaction đều khóa các dòng theo cùng một thứ tự logic (ví dụ: luôn xử lý ID nhỏ trước, ID lớn sau), vòng lặp bế tắc sẽ không bao giờ xuất hiện.

Sửa lại luồng code chuyển tiền ở phần 1:

  • Dù Alice chuyển cho Bob hay Bob chuyển cho Alice, backend luôn gộp 2 ID lại, sắp xếp tăng dần: [1, 2].

  • Luôn UPDATE (hoặc SELECT ... FOR UPDATE) tài khoản có id = 1 trước, rồi mới đến id = 2.

SQL

-- Thay vì cập nhật riêng lẻ lộn xộn, hãy cập nhật theo thứ tự ID:
WITH sorted_ids AS (
    SELECT id FROM accounts WHERE id IN (1, 2) ORDER BY id
)
SELECT * FROM sorted_ids FOR UPDATE; 
-- Khóa id=1 trước, id=2 sau. Đảm bảo mọi transaction đều đi chung 1 chiều!

Quy tắc 2: Giữ vòng đời Transaction ngắn nhất có thể

Đừng bao giờ thiết kế một luồng xử lý:

  1. BEGIN Transaction.

  2. Chạy SQL trừ tiền (Giữ khóa).

  3. Gọi API gọi sang dịch vụ bên thứ 3 (HTTP Request chờ 5 giây).

  4. Chạy SQL lưu log.

  5. COMMIT.

Việc gọi I/O mạng hoặc các tác vụ tính toán lâu bên trong một Transaction đang giữ khóa sẽ làm tăng rủi ro đụng độ lên hàng ngàn lần. Hãy dời các tác vụ I/O ra khỏi khối BEGIN ... COMMIT.

Quy tắc 3: Sử dụng NOWAIT hoặc SKIP LOCKED

Như đã học ở Bài 31, nếu nghiệp vụ không bắt buộc phải chờ đợi (ví dụ: Job Queue), hãy dùng SELECT ... FOR UPDATE SKIP LOCKED để tự động lướt qua các tài nguyên đang có tranh chấp, triệt tiêu hoàn toàn nguy cơ Deadlock.

4. Công cụ chẩn đoán: Bắt quả tang Query gây nghẽn trên Production

Khi hệ thống báo động đỏ (CPU 100%, Active Connections chạm đỉnh), rất có thể một câu query đang giữ khóa bảng (AccessExclusiveLock) hoặc khóa dòng và làm kẹt hàng ngàn query khác phía sau. Bạn cần tìm ra "kẻ thủ ác" (Blocking Process) đang giữ khóa để tiêu diệt.

PostgreSQL cung cấp 2 công cụ cốt lõi:

  • pg_stat_activity: Hiển thị tất cả các kết nối hiện tại và câu query họ đang chạy.

  • pg_locks: Hiển thị danh sách các khóa đang được cấp phát hoặc đang đứng chờ.

Từ PostgreSQL 9.6+, engine cung cấp một hàm hệ thống cực kỳ tiện lợi là pg_blocking_pids() giúp bạn truy vấn nguyên nhân nghẽn chẽn chỉ bằng một lệnh đơn giản:

Query chẩn đoán khóa (The Ultimate Lock Monitoring Query)

Chạy câu lệnh sau bằng user có quyền admin:

SQL

SELECT 
    blocked_activity.pid AS blocked_pid,
    blocked_activity.query AS blocked_query,
    blocked_activity.state AS blocked_state,
    (now() - blocked_activity.query_start) AS blocked_duration,
    blocking_activity.pid AS blocking_pid,
    blocking_activity.query AS blocking_query,
    blocking_activity.state AS blocking_state
FROM pg_stat_activity AS blocked_activity
JOIN pg_stat_activity AS blocking_activity 
    ON blocking_activity.pid = ANY(pg_blocking_pids(blocked_activity.pid))
WHERE blocked_activity.pid != pg_backend_pid();

Kết quả trả về sẽ vạch trần luồng bế tắc:

Plaintext

blocked_pid      | 15200
blocked_query    | UPDATE accounts SET balance = balance + 50 WHERE id = 2;
blocked_duration | 00:00:45.123
blocking_pid     | 14999
blocking_query   | UPDATE accounts SET balance = balance - 50 WHERE id = 1;
blocking_state   | idle in transaction

Đọc hiểu kết quả:

  • Tiến trình 15200 đang cố gắng chạy lệnh UPDATE nhưng đã bị treo (block) suốt 45 giây.

  • Kẻ đang chặn nó là Tiến trình 14999. Tiến trình này đang ở trạng thái idle in transaction — nghĩa là nó đã chạy xong lệnh UPDATE của nó, nhưng phía code Backend bị lỗi hoặc đang bận làm việc khác nên chưa chịu gửi lệnh COMMIT, dẫn đến việc nó "ngâm" khóa mãi mãi.

5. Xử lý sự cố khẩn cấp: Tiêu diệt tiến trình (Kill Session)

Sau khi đã xác định được blocking_pid (kẻ đang ngâm khóa), nếu hệ thống đang nguy kịch và ứng dụng backend không tự thoát, bạn buộc phải dùng biện pháp mạnh từ phía Database.

PostgreSQL cung cấp hai cấp độ "bóp cò":

  1. Hủy câu query hiện tại (Mức độ nhẹ nhàng):

    SQL

    -- Gửi tín hiệu ngắt (SIGINT) đến query đang chạy của PID 14999
    SELECT pg_cancel_backend(14999);
    
    

    Lệnh này chỉ cố gắng dừng câu lệnh SQL đang chạy. Session TCP giữa backend và database vẫn giữ nguyên. Nếu process đang idle in transaction, lệnh này thường vô dụng.

  2. Tiêu diệt toàn bộ kết nối (Mức độ "Bàn tay sắt"):

    SQL

    -- Gửi tín hiệu terminate (SIGTERM) để đóng băng và ép chết hoàn toàn kết nối
    SELECT pg_terminate_backend(14999);
    
    

    Kết nối bị đứt tức khắc. PostgreSQL tự động kích hoạt tiến trình dọn dẹp: tự động ROLLBACK transaction đang chạy dở của PID 14999, giải phóng toàn bộ các khóa nó đang giữ. Lập tức, hàng ngàn query đang bị block phía sau sẽ được thông luồng!

6. Tóm tắt & Bài tiếp theo

  • Deadlock là hiện tượng khóa chéo vô tận do lỗi thiết kế luồng (logic) từ phía ứng dụng backend.

  • PostgreSQL tự động giải quyết Deadlock thông qua Deadlock Detector sau khi vượt quá deadlock_timeout (chủ động abort 1 transaction).

  • Để phòng tránh: Khóa tài nguyên theo thứ tự ORDER BY ID đồng nhất, tránh giữ transaction quá lâu, và dùng NOWAIT/SKIP LOCKED khi phù hợp.

  • Dùng hàm pg_blocking_pids() kết hợp với pg_stat_activity để chẩn đoán nguyên nhân nghẽn cổ chai thời gian thực.

  • Cấp cứu hệ thống bằng pg_terminate_backend(pid) khi một kết nối ngâm khóa làm sập diện rộng.

Bài 33 xem tiếp: Cơ chế WAL (Write-Ahead Logging), Checkpoint và kiến trúc khôi phục thảm họa (Crash Recovery) — chúng ta sẽ rời khỏi tầng giao dịch để đi sâu vào tầng hệ thống: tại sao PostgreSQL cam kết dù có sập nguồn (Cúp điện) đột ngột, dữ liệu đã COMMIT cũng không bao giờ mất? Dòng chảy của dữ liệu từ RAM shared_buffers xuống file WAL và tiến trình Checkpoint hoạt động ra sao để tối ưu I/O đĩa.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí