MySQL 死锁排查实录:从日志定位到事务优化

MySQL 死锁是线上最让人头疼的并发故障之一:两个事务各自持有一把锁、又都在等对方释放,形成循环等待,InnoDB 检测到后只能挑一个”牺牲者”回滚并抛出 ER_LOCK_DEADLOCK (1213)。本文用一次真实的转账死锁案例,手把手教你从报错日志一路定位到事务层面的根治方案。建议先回顾我们的MySQL 索引底层原理MySQL 主从复制与读写分离,理解锁与事务的底层机制。

一、死锁是什么:一次”互相等锁”的事故

死锁的本质是循环等待。当多个事务以不同顺序访问同一批资源时,A 锁了行 1 等行 2,B 锁了行 2 等行 1,谁都走不下去。InnoDB 内置死锁检测器(wait-for graph)会在超时前主动介入,回滚代价较小的那个事务,让另一个继续提交。牺牲者收到 Deadlock found when trying to get lock; try restarting transaction 后,应用层通常需要重试。

二、现场还原:转账接口偶发失败

某支付服务在并发转账时,监控里每隔几天就冒出几条 1213 报错。表结构极简,accounts 存余额,关键在事务里对同一批账户做”扣款 + 入账”:

-- 账户表,id 为主键
CREATE TABLE accounts (
  id        BIGINT PRIMARY KEY,
  user_id   BIGINT NOT NULL,
  balance   DECIMAL(12,2) NOT NULL DEFAULT 0,
  INDEX idx_user (user_id)
);

-- 转账事务(伪代码):从 A 扣款、给 B 入账
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = :A;
UPDATE accounts SET balance = balance + 100 WHERE id = :B;
COMMIT;

问题出在更新顺序不确定:并发时一笔是”A→B”、另一笔是”B→A”,两事务分别锁住对方需要的行,瞬间死锁。这正是生产环境最常见的死锁诱因。

三、第一步诊断:错误消息与错误日志

应用抛出的 1213 只告诉你”发生了死锁”,但不告诉你哪两个事务、锁了什么。先到数据库侧拿详情:

-- 1) 查看最近一次死锁的详细信息
SHOW ENGINE INNODB STATUS\G

-- 2) 永久记录每一次死锁(写入 error log,需管理员配置)
SET GLOBAL innodb_print_all_deadlocks = ON;

-- 3) 确认当前隔离级别(默认 REPEATABLE READ 更容易产生间隙锁)
SELECT @@transaction_isolation;

SHOW ENGINE INNODB STATUS 输出里的 LATEST DETECTED DEADLOCK 段落最关键,它会列出事务 1 和事务 2 各自”已持有(HOLDS THE LOCK)”与”在等待(WAITING FOR)”的锁,以及触发回滚的 SQL。读懂这一段,基本就定位了根因。

四、MySQL 8.0+ 利器:performance_schema 实时锁视图

8.0 之后不再只能事后看 INNODB STATUS,可实时查询当前所有锁与等待关系,在死锁正在发生时也能抓现场:

-- 当前所有 InnoDB 锁
SELECT * FROM performance_schema.data_locks;

-- 当前的锁等待关系(谁等谁)
SELECT
  r.trx_id   AS waiting_trx,
  b.trx_id   AS blocking_trx,
  r.lock_table,
  r.lock_index
FROM performance_schema.data_lock_waits w
JOIN performance_schema.data_locks r ON w.requesting_engine_lock_id = r.engine_lock_id
JOIN performance_schema.data_locks b ON w.blocking_engine_lock_id   = b.engine_lock_id;

结合 information_schema.INNODB_TRX 还能看到每个事务正在执行的 SQL(trx_query),三者一交叉,死锁全貌一目了然。排查思路与我们PostgreSQL 慢查询优化里”先定位、再下药”的方法论一致。

五、死锁的四大常见成因

  • 访问顺序不一致:多个事务以不同顺序更新同一批行(本文案例)。
  • 间隙锁 / Next-Key 锁:REPEATABLE READ 下,范围更新会锁住行间的”间隙”,容易误伤。
  • WHERE 未走索引:全表扫描会升级为锁大量行甚至全表,放大冲突概率。
  • 事务过长:持有锁的时间越久,与其他事务撞车的概率越高。

六、根治方案:从事务设计下手

1. 统一加锁顺序

所有事务按固定顺序(如 id 升序)访问资源,A→B 和 B→A 永远变成 A→B,循环等待被打破:

-- 无论转账方向,始终先更新 id 较小的一方
SET @first  = LEAST(:A, :B);
SET @second = GREATEST(:A, :B);
START TRANSACTION;
UPDATE accounts SET balance = balance - IF(id=:A,100,0) WHERE id IN (@first, @second);
UPDATE accounts SET balance = balance + IF(id=:B,100,0) WHERE id IN (@first, @second);
COMMIT;

2. 缩短事务 + 保证索引覆盖

把非数据库操作(远程调用、计算)移出事务;确保 WHERE id = ? 走主键索引,避免间隙锁扩散。必要时把隔离级别降到 READ COMMITTED,可显著减少间隙锁。

3. 应用层重试

死锁无法 100% 根除,健壮的应用必须捕获 1213 并重试

for attempt in range(3):
    try:
        transfer(cur, from_id, to_id, amount)
        break
    except pymysql.err.OperationalError as e:
        if e.args[0] == 1213:   # Deadlock
            time.sleep(0.05 * (attempt + 1))
            continue
        raise

七、诊断与修复对照表

现象 / 手段作用适用阶段
应用报 1213确认死锁发生、需要重试发现
SHOW ENGINE INNODB STATUS看最近一次死锁的两个事务与锁事后定位
innodb_print_all_deadlocks=ON把所有死锁写入 error log持续采集
performance_schema.data_locks实时查看所有锁与等待现场抓取(8.0+)
统一加锁顺序 + 短事务 + 重试从设计上消除循环等待根治

八、总结

MySQL 死锁排查的核心套路是:先用 SHOW ENGINE INNODB STATUSperformance_schema 看清”谁持有了什么、在等什么”,再从事务设计层面统一加锁顺序、缩短事务、补好索引,最后在应用层加上 1213 重试作为兜底。死锁本质是并发顺序问题,顺序对了,锁就和解了。把监控与重试做成标准动作,这类故障就能从”偶发惊魂”变成”安静自愈”。

上一篇 Java Stream API 实战:集合处理与性能要点
下一篇 API 调试工具选型:Postman 与 Bruno 实战