Cẩm nang sống sót qua Deadlock: Phát hiện và Xử lý trong PostgreSQL và MySQL

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Cơn ác mộng lúc 2 giờ sáng: Khi các Transaction xung đột

2:15 sáng. Điện thoại của tôi reo inh ỏi trên bàn đầu giường. Các cảnh báo PagerDuty dồn dập đổ về, độ trễ (latency) của ứng dụng tăng vọt theo chiều thẳng đứng, và connection pool của database chính thức cạn kiệt. Tôi kiểm tra log và tìm thấy thủ phạm: ERROR: deadlock detected. Dù bạn đang chạy MySQL, PostgreSQL hay thậm chí MongoDB, deadlock là “kẻ san bằng” tất cả. Chúng không quan tâm đến thời gian uptime; chúng chỉ quan tâm đến các phụ thuộc vòng lặp (circular dependencies).

Deadlock xảy ra khi hai transaction giữ các khóa (lock) mà đối phương đang cần. Không bên nào có thể tiến bước, tạo ra một tình trạng đình trệ vĩnh viễn. Mặc dù các engine hiện đại sẽ tự động phá vỡ các chu kỳ này, nhưng hệ quả để lại là rất thực tế. Mỗi transaction bị hủy đồng nghĩa với một request thất bại, một người dùng khó chịu và khả năng tăng vọt tỷ lệ lỗi có thể kích hoạt các hệ thống circuit breaker.

Cách các Database Engine phản kháng: Những kịch bản khác nhau

Cả hai database đều xử lý deadlock, nhưng logic nội bộ của chúng khác nhau một cách đáng ngạc nhiên. Hiểu rõ các cơ chế này giúp bạn tinh chỉnh hệ thống để đạt hiệu năng tối ưu thay vì chỉ tìm cách sống sót.

PostgreSQL: Biểu đồ Wait-for

Postgres khá thận trọng. Nó không quét deadlock liên tục vì việc đó sẽ lãng phí tài nguyên CPU quý giá của các transaction đang hoạt động bình thường. Thay vào đó, nó đợi một khoảng thời gian chờ — được xác định bởi deadlock_timeout (thường là 1 giây). Khi một transaction nhàn rỗi đủ 1 giây đó, Postgres mới bắt đầu “đi săn”. Nó xây dựng một “biểu đồ wait-for” (wait-for graph) để lập bản đồ xem ai đang chặn ai. Nếu phát hiện một vòng lặp, it sẽ hủy một transaction — thường là transaction vừa mới kích hoạt bộ dò tìm — để giải phóng hàng đợi còn lại.

MySQL (InnoDB): Kẻ đi săn quyết liệt

InnoDB có cách tiếp cận chủ động hơn. Nó theo dõi các yêu cầu khóa trong thời gian thực và thường có thể tiêu diệt một vòng lặp deadlock ngay micro giây nó vừa hình thành. Theo mặc định, innodb_deadlock_detect được kích hoạt và engine này rất quyết liệt trong việc chọn “nạn nhân”. Nó thường rollback transaction có ít “công việc” nhất (được đo bằng kích thước của undo log) để giảm thiểu chi phí khôi phục.

Chi phí ẩn của việc xử lý tự động

Để database tự “sửa” deadlock cho bạn là một chiến lược đầy rủi ro. Dưới đây là lý do tại sao bạn nên giảm thiểu chúng thay vì chỉ dựa dẫm vào engine.

Ưu điểm

  • Sự sống còn của hệ thống: Nếu không có bộ dò tìm, một deadlock duy nhất có thể treo các kết nối vô thời hạn. Điều này cuối cùng sẽ làm cạn kiệt pool hơn 500 kết nối và làm sập toàn bộ dịch vụ.
  • Khả năng tự phục hồi: Database đảm bảo rằng ít nhất một bên trong cuộc xung đột sẽ sống sót để hoàn thành công việc.

Nhược điểm

  • Gánh nặng CPU: Khi bạn xử lý hơn 5.000 transaction mỗi giây, việc kiểm tra vòng lặp liên tục có thể tiêu tốn 10-15% tổng tài nguyên CPU.
  • Ảnh hưởng người dùng: Một transaction bắt buộc phải bị hủy. Nếu backend của bạn không bắt được mã lỗi cụ thể đó, người dùng sẽ nhận được lỗi 500 chung chung và quy trình thanh toán bị gián đoạn.
  • Hiệu ứng Domino: Thậm chí chỉ 1 giây chậm trễ trong PostgreSQL cũng có thể khiến các request bị dồn ứ, gây ra một chuỗi timeout trên khắp các microservice của bạn.

Củng cố hệ thống Production

Khả năng quan sát (visibility) là vũ khí tốt nhất của bạn. Bạn không thể tối ưu hóa những gì bạn không ghi log.

1. Chiến lược cho PostgreSQL

Hãy giữ deadlock_timeout ở mức 1 giây. Hạ xuống 100ms thường tạo ra nhiều nhiễu hơn là tín hiệu hữu ích, vì nhiều khóa sẽ tự giải quyết một cách tự nhiên trong vài mili giây. Thắng lợi thực sự đến từ việc bật log chi tiết:

# postgresql.conf
deadlock_timeout = 1s
log_lock_waits = on
log_recovery_conflict_waits = on

2. Chiến lược cho MySQL

Đảm bảo bộ dò tìm đang bật, nhưng hãy thiết lập một mạng lưới an toàn nghiêm ngặt với innodb_lock_wait_timeout. Mặc định thường là 50 giây, quá dài đối với một ứng dụng web hiện đại. Hãy hướng tới mục tiêu 5-10 giây để tiêu diệt các transaction “zombie” không bị deadlock nhưng lại chạy quá chậm.

# my.cnf
innodb_deadlock_detect = ON
innodb_lock_wait_timeout = 10
log_error_verbosity = 3

Thực thi: Phòng ngừa và Khả năng phục hồi

Một kỹ sư database giỏi viết mã để tránh các khóa và viết mã để sống sót khi chúng xảy ra.

Chiến lược A: Áp đặt thứ tự khóa nhất quán

Hầu hết các deadlock bắt nguồn từ việc cập nhật các hàng theo các trình tự khác nhau. Nếu Người dùng A cập nhật Tài khoản 1 rồi đến 2, và Người dùng B cập nhật Tài khoản 2 rồi đến 1, chúng cuối cùng sẽ xung đột. Cách khắc phục rất đơn giản: Luôn sắp xếp các ID trước khi khóa.

# Sắp xếp các ID để đảm bảo mọi thread đều lock theo cùng một trình tự
ids = sorted([account_src_id, account_dst_id])
with db.transaction():
    # Lock Tài khoản 1, sau đó đến Tài khoản 2 một cách nhất quán
    db.execute("SELECT * FROM accounts WHERE id = %s FOR UPDATE", ids[0])
    db.execute("SELECT * FROM accounts WHERE id = %s FOR UPDATE", ids[1])

Chiến lược B: Vòng lặp Retry thông minh

Trong các hệ thống có lưu lượng truy cập cao, deadlock đôi khi là một thực tế thống kê không thể tránh khỏi. Ứng dụng của bạn phải có tính idempotent. Tôi sử dụng kỹ thuật exponential backoff để ngăn chặn hiệu ứng “thăng hoa bầy đàn” (thundering herd) — nơi nhiều lượt retry chỉ gây ra thêm nhiều deadlock.

import time
import psycopg2
from psycopg2 import errorcodes

def execute_with_retry(query, params, max_retries=3):
    for attempt in range(max_retries):
        try:
            with connection.cursor() as cursor:
                cursor.execute(query, params)
                connection.commit()
                return
        except psycopg2.extensions.TransactionRollbackError as e:
            if e.pgcode == errorcodes.DEADLOCK_DETECTED:
                connection.rollback()
                # Đợi 100ms, 200ms, rồi 400ms
                time.sleep(0.1 * (2 ** attempt)) 
                continue
            raise
    raise Exception("Deadlock vẫn tồn tại sau 3 lần thử")

Chiến lược C: Đi sâu vào chẩn đoán

Nếu bạn đang bị gọi cảnh báo (paged), đừng đoán mò. Hãy sử dụng các truy vấn này để tìm chính xác các câu lệnh đang gây ra xung đột.

Truy vấn tìm tiến trình bị chặn trong PostgreSQL:

SELECT 
    blocked_locks.pid     AS blocked_pid,
    blocking_locks.pid    AS blocking_pid,
    blocked_activity.query  AS blocked_statement,
    blocking_activity.query AS blocking_statement
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.pid != blocked_locks.pid
JOIN  pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;

Kiểm tra trạng thái MySQL:

-- Lệnh này cung cấp toàn bộ lịch sử của lần deadlock gần nhất
SHOW ENGINE INNODB STATUS;

-- Xác định những transaction hiện đang bị đình trệ
SELECT * FROM information_schema.innodb_trx;
SELECT * FROM sys.innodb_lock_waits;

Deadlock không phải là dấu hiệu của sự thất bại; chúng là tác dụng phụ của quy mô và tính đồng thời. Bằng cách áp đặt thứ tự khóa nghiêm ngặt và xây dựng logic retry kiên cường, bạn có thể biến những cuộc khủng hoảng lúc 2 giờ sáng thành những dòng log nhỏ được giải quyết xong xuôi trước khi bạn uống hết tách cà phê đầu tiên.

Share: