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 STATUS 和 performance_schema 看清”谁持有了什么、在等什么”,再从事务设计层面统一加锁顺序、缩短事务、补好索引,最后在应用层加上 1213 重试作为兜底。死锁本质是并发顺序问题,顺序对了,锁就和解了。把监控与重试做成标准动作,这类故障就能从”偶发惊魂”变成”安静自愈”。




