Khó Database

Deadlock Trong Database: Phát Hiện, Chẩn Đoán Với Phòng Tránh

Database dùng wait-for graph để phát hiện deadlock ra sao, vì sao nó xảy ra, với mấy kỹ thuật thực tế để phòng tránh trong hệ thống concurrency cao.

10 Th08, 2026 8 phút 219 Lượt xem 4 Khối code
Sơ đồ
graph TD A[Transaction A] -->|waits for row 2| B[Transaction B] B[Transaction B] -->|waits for row 1| A[Transaction A] A -.cycle detected.-> C[Deadlock!] C --> D[Database picks a victim] D --> E[Victim transaction rolled back] E --> F[Survivor proceeds]

Deadlock xảy ra khi hai hay nhiều transaction cùng giữ một lock mà bên kia đang cần, và không bên nào đi tiếp được. Database không để tình trạng này treo mãi - nó phát hiện ra rồi giết một trong mấy transaction đó. Hiểu cơ chế phát hiện hoạt động ra sao, và làm sao tránh kích hoạt nó ngay từ đầu, là kỹ năng cốt lõi cho ai viết code database với concurrency cao.

Deadlock kinh điển

Hai transaction, hai row, thứ tự ngược nhau:

-- Transaction A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- ... tạm dừng ...
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- Transaction B (chạy đồng thời)
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
-- ... tạm dừng ...
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
COMMIT;

Nếu A lock row 1 và B lock row 2 gần như cùng lúc, lệnh thứ hai của A phải đợi B nhả row 2, còn lệnh thứ hai của B lại đợi A nhả row 1. Không bên nào đi tiếp được nữa.

Wait-For Graph: Database phát hiện kiểu gì

Đa số database (PostgreSQL, MySQL/InnoDB, SQL Server) mô hình hóa việc chờ lock thành một đồ thị có hướng: mỗi transaction là một node, cạnh từ T1 tới T2 nghĩa là "T1 đang đợi một lock mà T2 đang giữ". Deadlock tồn tại chính xác khi đồ thị này có một chu trình (cycle).

graph TD A[Transaction A] -->|đợi row 2| B[Transaction B] B[Transaction B] -->|đợi row 1| A[Transaction A] A -.phát hiện cycle.-> C[Deadlock!] C --> D[Database chọn nạn nhân] D --> E[Transaction nạn nhân bị rollback] E --> F[Transaction còn lại đi tiếp]

Định kỳ (hoặc mỗi khi có lock wait mới, tùy engine), database sẽ duyệt đồ thị này để tìm chu trình. Khi tìm thấy, nó chọn một nạn nhân - thường là transaction làm ít việc nhất, hoặc giữ ít lock nhất - rồi rollback nó, nhả lock ra để transaction còn lại tiếp tục chạy.

Lỗi thực tế trông như vầy

ERROR: deadlock detected
DETAIL: Process 1234 waits for ShareLock on transaction 5678; blocked by process 5678.
Process 5678 waits for ShareLock on transaction 1234; blocked by process 1234.
HINT: See server log for query details.

Việc của ứng dụng là bắt đúng lỗi này rồi retry lại transaction - một transaction bị chọn làm nạn nhân không phải là bug, đó là database đang xử lý đúng đắn một conflict không thể tránh khỏi.

import time

def run_with_retry(fn, max_attempts=3):
    for attempt in range(max_attempts):
        try:
            return fn()
        except DeadlockDetected:
            if attempt == max_attempts - 1:
                raise
            time.sleep(0.05 * (2 ** attempt))  # backoff kiểu tăng dần theo cấp số nhân

Deadlock khác gì với chờ lock bình thường

Chờ lock bình thường tự nó sẽ hết khi transaction đang giữ lock commit hoặc rollback - không cycle, không lỗi, chỉ là delay. Deadlock thì khác hẳn về bản chất: nếu không can thiệp, nó không bao giờ tự hết, vì mọi bên trong cycle đều đang đợi một bên khác cũng nằm trong chính cycle đó. Đây là lý do việc phát hiện phải chủ động (quét tìm cycle), chứ không thể thụ động (chỉ ngồi đợi).

Phòng tránh: Giữ thứ tự lock nhất quán

Kỹ thuật phòng tránh hiệu quả nhất là luôn lấy lock theo cùng một thứ tự ở mọi transaction đụng tới cùng một tập tài nguyên. Nếu cả hai transaction ở trên đều update row 1 trước row 2, thì bên đến sau chỉ đơn giản đợi bên đến trước làm xong - không thể tạo ra cycle.

-- Cả hai transaction giờ lock theo thứ tự id tăng dần -- không thể deadlock
UPDATE accounts SET balance = balance - 100 WHERE id = LEAST(1, 2);
UPDATE accounts SET balance = balance + 100 WHERE id = GREATEST(1, 2);

Trong thực tế, cách này thường có nghĩa là sắp xếp row theo primary key trước khi chạy một batch update, hoặc gom thứ tự lấy lock về một chỗ duy nhất trong code thay vì để mỗi nơi gọi tự làm theo ý mình.

Phòng tránh: Giữ transaction ngắn gọn

Transaction chạy lâu giữ lock lâu hơn, làm rộng khoảng thời gian mà một transaction xung đột có thể xuất hiện và tạo ra cycle. Đưa mấy việc không thiết yếu (gửi email, gọi API bên ngoài, tính toán nặng) ra khỏi phạm vi transaction giúp thu hẹp khoảng thời gian đó đáng kể.

Phòng tránh: Hạ isolation level khi an toàn

Isolation level càng cao thì lấy càng nhiều lock, giữ càng lâu. SERIALIZABLE dễ dính deadlock nhất; READ COMMITTED lấy ít lock hơn, giữ ngắn hơn. Không phải thao tác nào cũng cần đảm bảo mạnh nhất - nên đánh giá xem isolation level thấp hơn có an toàn cho một transaction cụ thể không, trước khi mặc định dùng mức nghiêm ngặt nhất cho mọi nơi.

Phòng tránh: Thu nhỏ phạm vi lock

Lock cả một table trong khi chỉ cần vài row làm tăng gấp bội khả năng va chạm. Dùng WHERE clause có index để database lấy lock ở cấp row (không phải cấp table), và tránh SELECT ... FOR UPDATE trên nhiều row hơn mức transaction thật sự định sửa.

Chẩn đoán deadlock lặp lại nhiều lần

Khi deadlock cứ xuất hiện lặp đi lặp lại trên production, xem log deadlock của database (PostgreSQL log đầy đủ chi tiết lock và query khi bật log_lock_waits; MySQL cho xem qua SHOW ENGINE INNODB STATUS). Pattern lặp lại gần như luôn luôn là cùng hai đoạn code lấy cùng hai tài nguyên theo thứ tự ngược nhau - tìm đúng hai chỗ gọi đó rồi sửa lại thứ tự, thay vì chỉ thêm logic retry rồi hy vọng.

Mấy cái cần tránh

Coi deadlock đơn thuần là thứ để retry vòng qua, mà không bao giờ sửa cái conflict thứ tự lock gốc rễ, chỉ dời cái giá phải trả sang tăng độ trễ với lãng phí công sức khi tải cao. Giả định pattern query mặc định của ORM là an toàn với deadlock khá rủi ro - nhiều ORM phát lệnh update liên quan theo bất kỳ thứ tự nào mà model association tình cờ được duyệt tới, và thứ tự đó có thể đổi giữa các request. Với quên thêm logic retry hoàn toàn khiến deadlock biến thành lỗi hiển thị cho người dùng thay vì một trục trặc vô hình, tự phục hồi được.

Deadlock không phải lỗi của database - đó là database đang từ chối đúng đắn việc để hai transaction chờ nhau mãi mãi. Cách sửa nằm ở thứ tự lock trong ứng dụng của mình, không phải ở việc tắt cơ chế phát hiện.

Cùng chủ đề Database