MySQL Deadlock: Từ “cơn ác mộng” 2 giờ sáng đến bí kíp xử lý triệt để

MySQL tutorial - IT technology blog
MySQL tutorial - IT technology blog

“Cơn ác mộng” lúc 2 giờ sáng

Điện thoại rung liên hồi. Slack nhảy thông báo lỗi 500 liên tục. Sentry thì ngập ngụa log: Deadlock found when trying to get lock; try restarting transaction. Nếu bạn đang vận hành một hệ thống Backend, đây chính là tín hiệu báo động đỏ cho thấy database đang “nghẹt thở”.

Tôi từng quản lý một hệ thống MySQL 8.0 với DB khoảng 50GB, phục vụ hơn 2.000 đơn hàng mỗi phút. Có những lúc, vài câu lệnh Update tưởng chừng đơn giản lại khiến toàn bộ luồng xử lý bị treo cứng. Deadlock không phải lỗi cú pháp. Nó là trạng thái xung đột tài nguyên khi các transaction giằng co nhau. Thay vì hoảng loạn restart service, việc biết cách đọc log sẽ giúp bạn giải quyết gốc rễ vấn đề.

Nói nôm na: Deadlock là gì?

Hãy tưởng tượng tình huống “ông chờ tôi, tôi chờ ông”. Hai giao dịch (transaction) đang chạy song song và rơi vào thế kẹt:

  • Transaction A: Đang giữ khóa (lock) ở dòng 1 và muốn cập nhật tiếp dòng 2.
  • Transaction B: Đang giữ khóa ở dòng 2 và muốn cập nhật ngược lại dòng 1.

Cả hai bên đều không chịu nhả khóa và đứng đợi nhau mãi mãi. Lúc này, “trọng tài” InnoDB sẽ ra tay. Nó chủ động hủy một transaction (thường là cái ít thay đổi nhất) để giải phóng tài nguyên. Đó là lý do bạn thấy lỗi restarting transaction xuất hiện trong log ứng dụng.

Cách “bắt mạch” bằng InnoDB Status

Khi lỗi xảy ra, câu lệnh đầu tiên tôi gõ trên terminal luôn là:

SHOW ENGINE INNODB STATUS;

Lệnh này trả về một file text dài dằng dặc. Đừng bối rối, hãy cuộn ngay xuống phần LATEST DETECTED DEADLOCK. Đây chính là hiện trường vụ án với đầy đủ manh mối.

Phân tích log thực tế

Dưới đây là một đoạn log từ một bug thực tế tôi từng xử lý:

*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 5 sec
UPDATE orders SET status = 'processing' WHERE id = 10;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 50 index PRIMARY of table `shop`.`orders` trx id 12345 lock_mode X locks rec but not gap waiting

*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 3 sec
UPDATE orders SET status = 'shipped' WHERE id = 5;
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 50 index PRIMARY of table `shop`.`orders` trx id 12346 lock_mode X locks rec but not gap
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 50 index PRIMARY of table `shop`.`orders` trx id 12346 lock_mode X locks rec but not gap waiting

Chúng ta đọc được gì từ đây?

  1. Transaction (1) đang đợi khóa trên dòng có id = 10.
  2. Transaction (2) lại đang nắm giữ (HOLDS) chính cái khóa mà Transaction (1) cần.
  3. Ngược lại, (2) cũng đang đợi một khóa khác từ (1).

Nguyên nhân thường do thứ tự cập nhật dữ liệu không đồng nhất. Một luồng xử lý từ 10 đến 5, luồng kia lại đi ngược từ 5 về 10.

5 bí kíp “xương máu” để chặn đứng Deadlock

Sau nhiều đêm thức trắng fix bug, tôi rút ra 5 quy tắc giúp database chạy êm ru ngay cả khi tải cao.

1. Luôn cập nhật theo một thứ tự cố định

Đây là quy tắc vàng. Nếu cần update nhiều record (ví dụ trừ kho 3 sản phẩm), hãy luôn sắp xếp chúng theo ID tăng dần trước khi thực thi câu lệnh SQL.

# Code an toàn: Luôn sort ID trước khi update
ids = sorted([10, 5, 8])
for id in ids:
    cursor.execute(f"UPDATE products SET stock = stock - 1 WHERE id = {id}")

Khi cả hai transaction cùng muốn update ID 5 và 10, chúng sẽ cùng tranh nhau khóa ID 5 trước. Thắng thua sẽ rõ ràng, không còn chuyện đứng chờ nhau chéo cánh sẻ.

2. Rút ngắn thời gian thực thi Transaction

Đừng bao giờ nhồi nhét logic nặng, gọi API bên thứ ba hoặc gửi email vào trong một Database Transaction. Transaction càng kéo dài, thời gian giữ lock càng lâu. Điều này làm tăng xác suất đụng độ với các luồng khác lên gấp nhiều lần.

3. Tối ưu Index để tránh quét toàn bảng

Nếu câu lệnh UPDATE thiếu Index, MySQL phải quét toàn bộ bảng (Table Scan). Thay vì khóa một dòng, nó sẽ khóa cả một dải dữ liệu (Gap Lock). Hãy đảm bảo mọi câu lệnh thay đổi dữ liệu đều chạy dựa trên Primary Key hoặc Unique Index.

4. Hạ thấp Isolation Level nếu có thể

Mặc định MySQL dùng REPEATABLE READ với cơ chế Gap Locking rất chặt chẽ. Nếu ứng dụng của bạn không quá khắt khe về tính nhất quán hoàn hảo, hãy thử chuyển sang READ COMMITTED:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

Trong thực tế, thay đổi này giúp tôi giảm tới 80% lỗi Deadlock liên quan đến Gap Lock trên các hệ thống thương mại điện tử.

5. Triển khai cơ chế Retry ở tầng ứng dụng

Deadlock là thứ khó tránh khỏi 100% khi hệ thống đạt ngưỡng hàng nghìn request mỗi giây. Thay vì trả về lỗi cho người dùng, hãy viết một đoạn logic nhỏ để tự động thực hiện lại giao dịch.

max_retries = 3
for i in range(max_retries):
    try:
        # Thực hiện logic DB tại đây
        db.commit()
        break
    except Exception as e:
        if "Deadlock found" in str(e):
            time.sleep(0.1) # Nghỉ 100ms rồi thử lại
            continue
        raise e

Lời kết

Xử lý Deadlock không chỉ đơn thuần là sửa câu SQL. Nó đòi hỏi bạn hiểu cách dữ liệu được truy cập và tranh chấp. Hãy nhớ: đọc log bằng SHOW ENGINE INNODB STATUS, chuẩn hóa thứ tự update và luôn có phương án retry. Hy vọng kinh nghiệm này giúp bạn tự tin hơn khi đối mặt với những pha “treo hệ thống” bất ngờ.

Share: