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ênusers.id=1 - TX2 đang giữ lock trên
users.id=1, chờ lock trênorders.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
- Đọc log:
SHOW ENGINE INNODB STATUS\G— xác định hai transaction đang lock gì - Xác định thứ tự lock: hai nơi trong code có lock cùng bảng theo thứ tự ngược không
- Kiểm tra index:
EXPLAINcho cácUPDATE/DELETEcóWHERE— cótype=ALLkhông - Kiểm tra isolation level: đang dùng
REPEATABLE READvới nhiềuINSERTđồng thời không - Thêm retry: app đã handle
ERROR 1213chưa hay đang để exception bắn thẳng ra user