TX1
LOCK
TX2
Deadlock
innodb • log • rollback

MySQL Deadlock: cách đọc log InnoDB và giảm xác suất xảy ra

Deadlock trong MySQL tự resolve nhưng rollback transaction ngẫu nhiên gây lỗi ở app layer. Cách đọc SHOW ENGINE INNODB STATUS để biết chính xác dòng nào bị lock, nguyên nhân phổ biến, và 5 cách giảm xác suất deadlock.

12 phút đọc18/06/2026

Triệu chứng

App trả về lỗi ngẫu nhiên, không tái hiện được khi test:

Deadlock found when trying to get lock; try restarting transaction
ERROR 1213 (40001)

Không phải lúc nào cũng xảy ra — chỉ khi nhiều request đến cùng lúc, hoặc vào giờ cao điểm. Retry một lần là qua.


Deadlock là gì

Deadlock xảy ra khi hai transaction giữ lock của nhau và cùng chờ:

Transaction A:
  1. Lock row users WHERE id = 1  ← giữ lock này
  2. Chờ lock row orders WHERE id = 100  ← bị chặn bởi TX B

Transaction B:
  1. Lock row orders WHERE id = 100  ← giữ lock này
  2. Chờ lock row users WHERE id = 1  ← bị chặn bởi TX A

Cả hai cùng chờ → không ai tiến được → InnoDB phát hiện vòng tròn, chọn một transaction làm victim và rollback nó (thường là transaction tốn ít tài nguyên hơn).

MySQL tự xử lý deadlock — không cần can thiệp. Vấn đề là app phải biết retry khi nhận ERROR 1213.


Đọc deadlock log

Bật innodb_print_all_deadlocks

-- Ghi mọi deadlock vào error log (MySQL 5.6.2+)
SET GLOBAL innodb_print_all_deadlocks = ON;

Hoặc trong my.cnf:

[mysqld]
innodb_print_all_deadlocks = ON

Xem deadlock gần nhất

SHOW ENGINE INNODB STATUS\G

Tìm section LATEST DETECTED DEADLOCK. Một deadlock log trông như thế này:

------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-06-18 10:23:45 0x7f1a2b3c4d50
*** (1) TRANSACTION:
TRANSACTION 421938, ACTIVE 0 sec starting index read
MySQL thread id 89, OS thread handle 139..., query id 12301 localhost app
UPDATE orders SET status = 'processing' WHERE id = 100

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 312 page no 4 n bits 72 index PRIMARY of table `mydb`.`orders`
lock_mode X locks rec but not gap

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 298 page no 3 n bits 64 index PRIMARY of table `mydb`.`users`
lock_mode X locks rec but not gap

*** (2) TRANSACTION:
TRANSACTION 421939, ACTIVE 0 sec starting index read
MySQL thread id 90, OS thread handle 139..., query id 12302 localhost app
UPDATE users SET balance = balance - 50 WHERE id = 1

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 298 page no 3 n bits 64 index PRIMARY of table `mydb`.`users`
lock_mode X locks rec but not gap

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 312 page no 4 n bits 72 index PRIMARY of table `mydb`.`orders`
lock_mode X locks rec but not gap

*** WE ROLL BACK TRANSACTION (2)

Cách đọc log này

Phần Ý nghĩa
TRANSACTION (1) Transaction thứ nhất trong deadlock
HOLDS THE LOCK(S) Lock mà TX này đang giữ
WAITING FOR THIS LOCK Lock mà TX này đang chờ
WE ROLL BACK TRANSACTION (2) MySQL chọn TX 2 làm victim, rollback nó
lock_mode X Exclusive lock (write lock)
lock_mode S Shared lock (read lock)
locks rec but not gap Row lock, không phải gap lock

Từ log trên đọc được:

  • TX1 đang giữ lock trên orders.id=100, chờ lock trên users.id=1
  • TX2 đang giữ lock trên users.id=1, chờ lock trên orders.id=100
  • MySQL rollback TX2

Lỗi xảy ra vì hai transaction lock cùng 2 bảng nhưng theo thứ tự ngược nhau.


Nguyên nhân thường gặp

1. Lock thứ tự ngược nhau

Nguyên nhân phổ biến nhất — code ở hai nơi khác nhau lock cùng bảng nhưng thứ tự khác:

-- Luồng A: users trước, orders sau
BEGIN;
UPDATE users SET balance = balance - 50 WHERE id = 1;
UPDATE orders SET status = 'paid' WHERE id = 100;
COMMIT;

-- Luồng B: orders trước, users sau
BEGIN;
UPDATE orders SET status = 'processing' WHERE id = 100;
UPDATE users SET last_order_at = NOW() WHERE id = 1;
COMMIT;

Nếu A và B chạy đồng thời và A đã lock users trong khi B đã lock orders → deadlock.

2. Thiếu index trên cột JOIN/WHERE trong UPDATE

-- Nếu orders.user_id không có index
UPDATE orders SET status = 'cancelled'
WHERE user_id = 1 AND created_at < '2026-01-01';

MySQL phải scan nhiều row để tìm, tạo nhiều row lock hơn cần thiết — tăng xác suất đụng độ với transaction khác.

3. Gap lock và phantom read

InnoDB dùng gap lock (khóa khoảng trống giữa các giá trị index) để đảm bảo REPEATABLE READ. Gap lock có thể gây deadlock dù hai transaction không động vào cùng một row:

-- TX A
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;
-- Tạo gap lock cho toàn bộ range status = 'pending'

-- TX B cùng lúc
INSERT INTO orders (status, ...) VALUES ('pending', ...);
-- Cũng cần gap lock → conflict

4. Foreign key constraint

-- TX A: update parent
UPDATE users SET id = 999 WHERE id = 1;
-- MySQL tự lock các row trong orders có user_id = 1 (FK check)

-- TX B đang update orders
UPDATE orders SET amount = 200 WHERE user_id = 1;
-- Conflict với lock FK check của TX A

MySQL tự động lock các row liên quan khi có foreign key — dễ tạo deadlock không ngờ tới.


5 cách giảm xác suất deadlock

1. Thống nhất thứ tự lock trong toàn app

Quy tắc đơn giản nhất: luôn lock theo cùng một thứ tự bảng.

// Đặt quy ước: users → orders → payments (luôn theo thứ này)
async function processPayment(userId: number, orderId: number) {
  await db.transaction(async (trx) => {
    // Đúng thứ tự: users trước
    const user = await trx("users").where({ id: userId }).forUpdate().first();
    const order = await trx("orders").where({ id: orderId }).forUpdate().first();
    // xử lý...
  });
}

// Không được làm ngược lại ở bất kỳ chỗ nào trong codebase

2. Giữ transaction ngắn và nhanh nhất có thể

// Sai: gọi external API trong transaction → giữ lock lâu
await db.transaction(async (trx) => {
  const order = await trx("orders").where({ id }).forUpdate().first();
  await sendEmailViaThirdPartyApi(order); // có thể mất 2-5 giây
  await trx("orders").where({ id }).update({ email_sent: true });
});

// Đúng: làm hết việc nặng trước, transaction chỉ để write
const order = await db("orders").where({ id }).first();
await sendEmailViaThirdPartyApi(order);
await db.transaction(async (trx) => {
  await trx("orders").where({ id }).update({ email_sent: true });
});

3. Thêm index cho các cột dùng trong WHERE của UPDATE/DELETE

-- Trước khi chạy UPDATE với WHERE user_id, kiểm tra
EXPLAIN UPDATE orders SET status = 'cancelled' WHERE user_id = 1;

-- Nếu type = ALL hoặc rows lớn → cần index
ALTER TABLE orders ADD INDEX idx_user_id (user_id);

Index giúp MySQL lock ít row hơn, giảm vùng conflict.

4. Dùng READ COMMITTED thay REPEATABLE READ nếu phù hợp

-- REPEATABLE READ (mặc định): dùng gap lock → deadlock dễ hơn
-- READ COMMITTED: không dùng gap lock → ít deadlock hơn

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
-- ...
COMMIT;

READ COMMITTED loại bỏ gap lock, giảm đáng kể deadlock từ INSERT đồng thời. Đánh đổi: không ngăn được phantom read — phù hợp với hầu hết use case OLTP thông thường, không phù hợp khi cần đếm chính xác trong transaction.

5. Retry khi nhận ERROR 1213

Deadlock không thể loại bỏ hoàn toàn — chỉ giảm xác suất. App cần retry:

async function withDeadlockRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err: any) {
      const isDeadlock =
        err.code === "ER_LOCK_DEADLOCK" || // MySQL error code
        err.message?.includes("Deadlock found");

      if (isDeadlock && attempt < maxRetries) {
        // Backoff ngắn trước khi retry
        await new Promise((r) => setTimeout(r, attempt * 50));
        continue;
      }
      throw err;
    }
  }
  throw new Error("Max retries exceeded");
}

// Dùng
await withDeadlockRetry(() =>
  db.transaction(async (trx) => {
    // logic...
  })
);

Checklist khi gặp deadlock

  1. Đọc log: SHOW ENGINE INNODB STATUS\G — xác định hai transaction đang lock gì
  2. Xác định thứ tự lock: hai nơi trong code có lock cùng bảng theo thứ tự ngược không
  3. Kiểm tra index: EXPLAIN cho các UPDATE/DELETEWHERE — có type=ALL không
  4. Kiểm tra isolation level: đang dùng REPEATABLE READ với nhiều INSERT đồng thời không
  5. Thêm retry: app đã handle ERROR 1213 chưa hay đang để exception bắn thẳng ra user