MySQL 死锁分析完整指南,从理论到实战定位与解决
MySQL 死锁分析完整指南,从理论到实战定位与解决
下午三点,SRE 群炸了:
[ERROR] TransactionDeadlockException: Deadlock found when trying to get lock; try restarting transaction
订单系统开始出现大量事务回滚,用户下单失败率飙升到 15%。
这不是第一次死锁,但每次排查都要花很长时间。
后来我系统地学习了一下死锁的成因和排查方法,形成了标准化的流程,再遇到死锁基本能在 10 分钟内定位根因。
死锁的四个必要条件
MySQL 死锁要同时满足四个条件,破坏任一个就可以避免死锁:
- 互斥条件:资源只能被一个事务持有
- 持有并等待:持有资源的同时请求其他资源
- 不剥夺条件:已持有的资源不能被强制释放
- 环路等待条件:多个事务形成循环等待
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;
时序:
- 事务 A 锁定 user_id=1 的记录
- 事务 B 锁定 user_id=2 的记录
- 事务 A 请求 user_id=2 的锁,等待
- 事务 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
分析步骤:
- 看事务执行的 SQL:找到两个事务分别执行了什么
- 看持有的锁:每个事务已经锁住了什么
- 看等待的锁:每个事务在等什么锁
- 画等待图: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;
总结
死锁排查的标准流程:
- 捕获现场:
SHOW ENGINE INNODB STATUS查看LATEST DETECTED DEADLOCK - 还原事务:两个事务分别执行了什么 SQL
- 分析锁冲突:谁持有谁等待,形成什么环路
- 定位根因:加锁顺序不一致?范围太大?事务太长?
- 制定方案:统一顺序、缩小范围、减少事务、降低隔离级别
- 验证修复:重试 + 观察是否还有死锁
死锁是分布式系统中的正常现象,完全消除死锁成本很高。工程上追求的是把死锁概率降到可接受范围,并配备自动重试机制兜底。
关键是有一套快速定位的流程和标准化的修复方案,而不是每次都手忙脚乱地猜。
