这是《MySQL 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 死锁不是异常玄学,而是并发事务以相反顺序请求对方持有的锁,形成等待环。分析死锁要还原 SQL、参数、索引、隔离级别和事务时序。
20.1 死锁的定义
最小模型:
Session A 持有资源 1,等待资源 2;
Session B 持有资源 2,等待资源 1;
InnoDB 死锁检测发现等待环后,会选择一个代价较小的事务回滚,称为 victim。
常见报错:
ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction
这不是业务 SQL 语法错误,而是需要重试或减少冲突。
20.2 经典互相更新死锁
准备:
CREATE TABLE deadlock_accounts (
id INT PRIMARY KEY,
balance DECIMAL(12, 2) NOT NULL
) ENGINE=InnoDB;
INSERT INTO deadlock_accounts VALUES (1, 1000), (2, 1000);
复现:
-- Session A
START TRANSACTION;
UPDATE deadlock_accounts SET balance = balance - 10 WHERE id = 1;
-- Session B
START TRANSACTION;
UPDATE deadlock_accounts SET balance = balance - 10 WHERE id = 2;
-- Session A,等待 B 持有的 id=2
UPDATE deadlock_accounts SET balance = balance + 10 WHERE id = 2;
-- Session B,形成环
UPDATE deadlock_accounts SET balance = balance + 10 WHERE id = 1;
其中一个事务会回滚并收到 1213。
修复:同一业务路径按固定顺序更新。
所有转账先处理小 ID,再处理大 ID;
或者按账户 + 流水号排序后逐个处理。
20.3 间隙锁插入死锁
准备:
CREATE TABLE deadlock_gap (
id INT PRIMARY KEY,
value INT NOT NULL,
KEY idx_value (value)
) ENGINE=InnoDB;
INSERT INTO deadlock_gap VALUES (1, 100), (2, 300);
RR 隔离级别下:
-- Session A
START TRANSACTION;
SELECT * FROM deadlock_gap WHERE value = 200 FOR UPDATE;
-- Session B
START TRANSACTION;
SELECT * FROM deadlock_gap WHERE value = 250 FOR UPDATE;
两者都可能等待或锁定 (100, 300) 相关间隙:
-- Session A
INSERT INTO deadlock_gap VALUES (3, 200);
-- 等待 Session B
-- Session B
INSERT INTO deadlock_gap VALUES (4, 250);
-- 可能触发死锁
原因:
- 两个事务都持有部分间隙锁;
- 双方都想向同一间隙插入;
- 插入意向锁互相等待;
- 等待环形成。
处理:
- 评估是否使用 READ COMMITTED;
- 先插入唯一记录,再更新;
- 使用串行化任务或队列;
- 缩小事务;
- 用唯一键代替“先查是否存在再插入”;
- 对插入侧设置重试。
20.4 唯一键冲突死锁
三个事务同时尝试插入相同唯一键时,可能出现插入冲突和排他锁等待:
-- Session A
INSERT INTO users (username, nickname, mobile)
VALUES ('dave', 'Dave', '13800000099');
-- Session B 等待 A
INSERT INTO users (username, nickname, mobile)
VALUES ('dave', 'Dave2', '13800000098');
-- Session C 等待 A
INSERT INTO users (username, nickname, mobile)
VALUES ('dave', 'Dave3', '13800000097');
-- Session A 回滚
ROLLBACK;
B 或 C 可能收到锁等待或死锁。
更稳方案:
- 使用固定幂等插入;
- 处理 Duplicate entry;
- 用
ON DUPLICATE KEY UPDATE表达结果; - 分布式锁只做削峰,不作为一致性唯一依据;
- 避免多路径写同一业务对象;
- 冲突后重新读取状态。
20.5 查看死锁日志
查看最近死锁:
SHOW ENGINE INNODB STATUS\G
找到:
------------------------
LATEST DETECTED DEADLOCK
------------------------
重点信息:
| 字段 | 说明 |
|---|---|
TRANSACTION 1 / 2 |
参与事务 |
ACTIVE ... sec |
事务持续时间 |
lock mode |
锁模式 |
index ... of table ... |
锁的索引和表 |
holds the lock |
已持有锁 |
waiting for this lock |
正在等待 |
ROLLBACK |
被回滚的事务 |
| SQL | 触发语句 |
将所有死锁记录到错误日志:
SET GLOBAL innodb_print_all_deadlocks = ON;
生产建议开启,并接入日志采集和告警。
20.6 结合 Performance Schema
查看当前等待:
SELECT
r.ENGINE_TRANSACTION_ID AS waiting_trx,
b.ENGINE_TRANSACTION_ID AS blocking_trx,
r.OBJECT_SCHEMA,
r.OBJECT_NAME,
r.INDEX_NAME,
r.LOCK_MODE,
r.LOCK_DATA
FROM performance_schema.data_lock_waits w
JOIN performance_schema.data_locks r
ON r.ENGINE_LOCK_ID = w.REQUESTING_ENGINE_LOCK_ID
JOIN performance_schema.data_locks b
ON b.ENGINE_LOCK_ID = w.BLOCKING_ENGINE_LOCK_ID;
关联线程和 SQL:
SELECT
p.PROCESSLIST_ID,
p.PROCESSLIST_USER,
p.PROCESSLIST_HOST,
p.PROCESSLIST_TIME,
p.PROCESSLIST_INFO
FROM performance_schema.threads p
WHERE p.PROCESSLIST_ID IS NOT NULL
ORDER BY p.PROCESSLIST_TIME DESC;
MySQL 8.0 可以查看事务摘要:
SELECT *
FROM sys.innodb_lock_waits;
sys.innodb_lock_waits 输出更接近人读的阻塞关系,适合快速定位。
20.7 死锁分析流程
1. 收集死锁时间、应用、接口、trace id;
2. 找到两个事务的 SQL;
3. 确认隔离级别;
4. 查看涉及的索引和锁模式;
5. 还原事务执行顺序;
6. 判断是互相更新、间隙插入还是唯一冲突;
7. 检查事务是否过长;
8. 检查执行计划是否全表扫描;
9. 设计统一加锁顺序;
10. 增加重试、监控和回归验证;
只看最后一条 SQL 通常无法得出结论,因为死锁是时序问题。
20.8 应用重试策略
死锁被数据库回滚后,应用可以安全重试整个事务。
伪代码:
int maxRetry = 3;
for (int i = 1; i <= maxRetry; i++) {
TransactionStatus tx = transactionManager.getTransaction(definition);
try {
doBusiness();
transactionManager.commit(tx);
return;
} catch (DeadlockLoserDataAccessException | MySQLTransactionRollbackException e) {
transactionManager.rollback(tx);
if (i == maxRetry) {
throw e;
}
sleepWithJitter(i * 50);
} catch (Exception e) {
transactionManager.rollback(tx);
throw e;
}
}
要求:
- 事务内不发送外部消息;
- 状态更新可重试;
- 非幂等外部动作放到提交后;
- 重试次数和退避有上限;
- 记录完整上下文;
- 死锁率高时优先修根因。
20.9 死锁预防清单
| 风险 | 措施 |
|---|---|
| 多行更新顺序不一致 | 按 ID 或固定业务顺序 |
| 大事务 | 拆小、减少扫描 |
| 无索引更新 | 补索引并检查执行计划 |
| RR 间隙锁 | 评估 RC 或改写业务 |
| 先查再插 | 用唯一键和 Upsert |
| 热点账户 | 异步汇总、拆分账户 |
| 外键级联 | 拆分操作、避免高峰 |
| 应用随机顺序 | 排序后处理 |
| 锁等待时间长 | 降低锁超时并快速失败 |
| 无重试 | 增加幂等重试 |
20.10 死锁与锁等待超时的区别
| 项目 | Deadlock | Lock wait timeout |
|---|---|---|
| 本质 | 等待环 | 单向等待超时 |
| 检测 | InnoDB 主动检测 | 到达超时时间 |
| 默认结果 | 回滚 victim | 语句或事务行为取决于配置 |
| 处理 | 修复顺序和冲突,重试 | 找阻塞源并缩短事务 |
锁等待超时不一定回滚整个事务;死锁检测会回滚被选中的事务。
本章小结
死锁分析的核心是还原等待环。互相更新要统一加锁顺序;间隙插入要评估隔离级别和业务写入方式;唯一键冲突要用幂等写入处理。生产必须开启死锁日志采集,为应用实现有限次幂等重试,并通过缩短事务、精准索引和稳定顺序降低冲突概率。
思考题
- InnoDB 如何发现和处理死锁?
- 互相转账为什么会死锁?如何设计锁顺序?
- RR 下间隙锁为什么可能导致插入死锁?
SHOW ENGINE INNODB STATUS中死锁日志哪些信息最关键?- 给一个高死锁业务设计修复和重试方案。