MySQL 死锁分析完整指南,从理论到实战定位与解决

MySQL 死锁分析完整指南,从理论到实战定位与解决

下午三点,SRE 群炸了:

[ERROR] TransactionDeadlockException: Deadlock found when trying to get lock; try restarting transaction

订单系统开始出现大量事务回滚,用户下单失败率飙升到 15%。

这不是第一次死锁,但每次排查都要花很长时间。

后来我系统地学习了一下死锁的成因和排查方法,形成了标准化的流程,再遇到死锁基本能在 10 分钟内定位根因。

死锁的四个必要条件

MySQL 死锁要同时满足四个条件,破坏任一个就可以避免死锁:

  1. 互斥条件:资源只能被一个事务持有
  2. 持有并等待:持有资源的同时请求其他资源
  3. 不剥夺条件:已持有的资源不能被强制释放
  4. 环路等待条件:多个事务形成循环等待

InnoDB 的行锁是排他锁,满足互斥条件。破坏环路等待是最常见的解决方案。

InnoDB 锁的类型

理解死锁要先理解锁:

-- 查看锁信息
SHOW ENGINE INNODB STATUS;

-- 查看当前锁等待
SELECT * FROM information_schema.INNODB_LOCK_WAITS;

-- 查看当前事务
SELECT * FROM information_schema.INNODB_TRX;

记录锁(Record Lock)

锁定索引记录:

SELECT * FROM orders WHERE id = 100 FOR UPDATE;

锁住 id = 100 这条记录。

间隙锁(Gap Lock)

锁定索引记录之间的间隙:

SELECT * FROM orders WHERE id BETWEEN 50 AND 100 FOR UPDATE;

锁定 (50, 100) 这个范围,防止其他事务插入 id 在这个范围内的新记录。

Next-Key Lock

记录锁 + 间隙锁的组合。InnoDB 在 REPEATABLE READ 隔离级别下,默认使用 Next-Key Lock。

记录 100 的 Next-Key Lock 范围是:(前一个记录, 100]

插入意向锁(Insert Intention Lock)

插入操作在等待时释放的锁,标识"有人在等这个间隙"。

经典死锁案例分析

案例一:订单并发扣款

-- 事务 A
BEGIN;
UPDATE account SET balance = balance - 100 WHERE user_id = 1;
UPDATE account SET balance = balance + 100 WHERE user_id = 2;
COMMIT;

-- 事务 B
BEGIN;
UPDATE account SET balance = balance - 100 WHERE user_id = 2;
UPDATE account SET balance = balance + 100 WHERE user_id = 1;
COMMIT;

时序:

  1. 事务 A 锁定 user_id=1 的记录
  2. 事务 B 锁定 user_id=2 的记录
  3. 事务 A 请求 user_id=2 的锁,等待
  4. 事务 B 请求 user_id=1 的锁,等待

形成环路,死锁。

案例二:库存扣减

-- 库存表
CREATE TABLE inventory (
    id BIGINT PRIMARY KEY,
    sku_id BIGINT NOT NULL,
    available INT NOT NULL DEFAULT 0,
    UNIQUE KEY uk_sku (sku_id)
);

-- 事务 A:扣减 SKU 1001
BEGIN;
SELECT * FROM inventory WHERE sku_id = 1001 FOR UPDATE;
UPDATE inventory SET available = available - 1 WHERE sku_id = 1001;
COMMIT;

-- 事务 B:扣减 SKU 1002
BEGIN;
SELECT * FROM inventory WHERE sku_id = 1002 FOR UPDATE;
UPDATE inventory SET available = available - 1 WHERE sku_id = 1002;
COMMIT;

如果两条 SQL 顺序不同但 SKU 相邻,Next-Key Lock 可能锁住相邻区间,形成死锁。

案例三:范围查询引入的锁扩大

-- 事务 A
BEGIN;
SELECT * FROM orders WHERE user_id BETWEEN 100 AND 200 FOR UPDATE;
-- Next-Key Lock 可能锁住 (负无穷, 200]

-- 事务 B
BEGIN;
SELECT * FROM orders WHERE user_id = 150 FOR UPDATE;
-- 等待事务 A 的锁
COMMIT;

非唯一索引的范围查询会锁定很大的区间,冲突概率大增。

死锁现场分析

死锁发生后,第一时间看 SHOW ENGINE INNODB STATUS

SHOW ENGINE INNODB STATUS;

输出中 LATEST DETECTED DEADLOCK 部分是关键:

*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 5 sec inserting
mysql thread id 23456, OS thread handle 0x7f8a9c, query id 78901 localhost root update
UPDATE orders SET status = 2 WHERE order_id IN (100, 101, 102)

*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 3 sec inserting
mysql thread id 23457, OS thread handle 0x7f8a9d, query id 78902 localhost root update
UPDATE orders SET status = 3 WHERE order_id IN (101, 103, 105)

*** (4) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 123 page no 456 n bits 768 index PRIMARY of table `ads`.`orders` trx id 12345 lock_mode X locks rec but not gap waiting

分析步骤:

  1. 看事务执行的 SQL:找到两个事务分别执行了什么
  2. 看持有的锁:每个事务已经锁住了什么
  3. 看等待的锁:每个事务在等什么锁
  4. 画等待图:A 等 B,B 等 A,形成环路

死锁解决方案

1. 统一加锁顺序

最有效的方案。如果多个事务要操作相同的资源,必须按相同顺序获取锁。

// 错误的做法
public void transferA(Long fromId, Long toId, BigDecimal amount) {
    if (fromId < toId) {
        lock(fromId);
        lock(toId);
    } else {
        lock(toId);
        lock(fromId);
    }
}

// 正确的做法:统一顺序
public void transferA(Long fromId, Long toId, BigDecimal amount) {
    Long first = fromId < toId ? fromId : toId;
    Long second = fromId < toId ? toId : fromId;
    lock(first);
    lock(second);
}

2. 减少锁的粒度

用唯一索引替代范围查询:

-- 范围查询,锁一片区间
SELECT * FROM orders WHERE user_id BETWEEN 100 AND 200 FOR UPDATE;
-- 锁住 (负无穷, 200]

-- 唯一索引精确查找,只锁一行
SELECT * FROM orders WHERE order_id = ? FOR UPDATE;

3. 减少事务内的操作

把不必要的东西移出事务:

BEGIN;
-- 非必要:查商品信息、用户信息
-- SELECT * FROM product WHERE id = ?;
-- SELECT * FROM user WHERE id = ?;

-- 核心:扣库存、写订单
UPDATE inventory SET available = available - 1 WHERE sku_id = ?;
INSERT INTO orders (...) VALUES (...);

COMMIT;

-- 事务后:发消息、写日志
sendMessage(orderId);
logOperation(orderId);

4. 使用低隔离级别

在允许的情况下,用 READ COMMITTED 替代 REPEATABLE READ

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

READ COMMITTED 下,Next-Key Lock 只在唯一索引等值查询时生效,范围查询只加记录锁不加间隙锁。

5. 事务重试

死锁是数据库主动解决环路的方式,业务层做有限重试是合理的:

public void createOrder(OrderCmd cmd) {
    for (int i = 0; i < 3; i++) {
        try {
            doCreateOrder(cmd);
            return;
        } catch (DeadlockLoserDataAccessException e) {
            log.warn("Deadlock detected, retrying {}/3", i + 1);
            sleep(50L * (i + 1));
        }
    }
    throw new BizException("下单失败,请稍后重试");
}

注意:

  • 重试次数要有限制,避免死循环
  • 重试间隔要指数退避,避免加剧竞争
  • 重试要记录日志,便于分析问题

死锁预防措施

上线前做压力测试

SHOW ENGINE INNODB STATUS 监控死锁:

# 监控脚本
while true; do
    mysql -e "SHOW ENGINE INNODB STATUS" | grep "LATEST DETECTED DEADLOCK"
    sleep 5
done

添加死锁监控

-- 创建死锁记录表
CREATE TABLE deadlock_log (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    detected_at DATETIME NOT NULL,
    info TEXT NOT NULL,
    INDEX idx_detected_at (detected_at)
);

-- 定期抓取死锁信息
INSERT INTO deadlock_log (detected_at, info)
SELECT NOW(), SHOW ENGINE INNODB STATUS;

定期分析锁等待

-- 找出长时间等待的事务
SELECT
    r.trx_id,
    r.trx_mysql_thread_id,
    r.trx_query,
    r.trx_state,
    r.trx_started,
    TIMESTAMPDIFF(SECOND, r.trx_started, NOW()) AS wait_seconds
FROM information_schema.INNODB_TRX r
WHERE r.trx_state = 'LOCK WAIT'
ORDER BY wait_seconds DESC;

-- 找出持有锁最多的事务
SELECT
    trx_id,
    trx_mysql_thread_id,
    COUNT(*) AS lock_count,
    SUM(LENGTH(lock_data)) AS lock_data_size
FROM information_schema.INNODB_LOCKS
GROUP BY trx_id
ORDER BY lock_count DESC
LIMIT 10;

总结

死锁排查的标准流程:

  1. 捕获现场SHOW ENGINE INNODB STATUS 查看 LATEST DETECTED DEADLOCK
  2. 还原事务:两个事务分别执行了什么 SQL
  3. 分析锁冲突:谁持有谁等待,形成什么环路
  4. 定位根因:加锁顺序不一致?范围太大?事务太长?
  5. 制定方案:统一顺序、缩小范围、减少事务、降低隔离级别
  6. 验证修复:重试 + 观察是否还有死锁

死锁是分布式系统中的正常现象,完全消除死锁成本很高。工程上追求的是把死锁概率降到可接受范围,并配备自动重试机制兜底。

关键是有一套快速定位的流程和标准化的修复方案,而不是每次都手忙脚乱地猜。

最后更新 4/20/2026, 4:48:48 AM