MySQLNotes

第 20 章:死锁分析

zjc 于 2026-01-20 发布

这是《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);
-- 可能触发死锁

原因:

  1. 两个事务都持有部分间隙锁;
  2. 双方都想向同一间隙插入;
  3. 插入意向锁互相等待;
  4. 等待环形成。

处理:

  1. 评估是否使用 READ COMMITTED;
  2. 先插入唯一记录,再更新;
  3. 使用串行化任务或队列;
  4. 缩小事务;
  5. 用唯一键代替“先查是否存在再插入”;
  6. 对插入侧设置重试。

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 可能收到锁等待或死锁。

更稳方案:

  1. 使用固定幂等插入;
  2. 处理 Duplicate entry;
  3. ON DUPLICATE KEY UPDATE 表达结果;
  4. 分布式锁只做削峰,不作为一致性唯一依据;
  5. 避免多路径写同一业务对象;
  6. 冲突后重新读取状态。

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;
    }
}

要求:

  1. 事务内不发送外部消息;
  2. 状态更新可重试;
  3. 非幂等外部动作放到提交后;
  4. 重试次数和退避有上限;
  5. 记录完整上下文;
  6. 死锁率高时优先修根因。

20.9 死锁预防清单

风险 措施
多行更新顺序不一致 按 ID 或固定业务顺序
大事务 拆小、减少扫描
无索引更新 补索引并检查执行计划
RR 间隙锁 评估 RC 或改写业务
先查再插 用唯一键和 Upsert
热点账户 异步汇总、拆分账户
外键级联 拆分操作、避免高峰
应用随机顺序 排序后处理
锁等待时间长 降低锁超时并快速失败
无重试 增加幂等重试

20.10 死锁与锁等待超时的区别

项目 Deadlock Lock wait timeout
本质 等待环 单向等待超时
检测 InnoDB 主动检测 到达超时时间
默认结果 回滚 victim 语句或事务行为取决于配置
处理 修复顺序和冲突,重试 找阻塞源并缩短事务

锁等待超时不一定回滚整个事务;死锁检测会回滚被选中的事务。

本章小结

死锁分析的核心是还原等待环。互相更新要统一加锁顺序;间隙插入要评估隔离级别和业务写入方式;唯一键冲突要用幂等写入处理。生产必须开启死锁日志采集,为应用实现有限次幂等重试,并通过缩短事务、精准索引和稳定顺序降低冲突概率。

思考题

  1. InnoDB 如何发现和处理死锁?
  2. 互相转账为什么会死锁?如何设计锁顺序?
  3. RR 下间隙锁为什么可能导致插入死锁?
  4. SHOW ENGINE INNODB STATUS 中死锁日志哪些信息最关键?
  5. 给一个高死锁业务设计修复和重试方案。