死锁不是"数据库有 bug",而是两个事务各自拿着对方要的锁、谁也不肯先放。InnoDB 一旦检测到环,会立刻回滚代价较小的那个事务,报 ERROR 1213 (40001): Deadlock found when trying to get lock。这条错误本身不可怕,可怕的是它每天出现几千次却没人看。以下按 MySQL 8.0 InnoDB 核对。
1. 四个必要条件在 InnoDB 的落地
| 必要条件 | 在 InnoDB 里的具体形态 |
|---|---|
| 互斥 | 行锁(X 锁)本身即互斥,同一行同一时刻只能一个事务持写锁 |
| 持有并等待 | 事务 A 已锁 id=1,还在等 id=2 |
| 不可剥夺 | InnoDB 不会主动抢锁,只能等或回滚 |
| 循环等待 | A 等 B 的锁、B 等 A 的锁,形成环 |
理论上打破任一条即可。工程上真正能操作的只有两条:打破"持有并等待"(统一加锁顺序)和缩小持锁范围(缩短事务)。
2. 行锁的前提:走索引
InnoDB 锁的是索引记录,不是行。如果 WHERE 用不上索引,它只能扫全表并对扫到的每条记录加锁——一条 UPDATE ... WHERE no_index_col = 1 可能锁住整张表。
-- 假设 status 无索引
UPDATE t_order SET amount = 0 WHERE status = 1;
-- 等价于对这些行及间隙全部加锁,并发写几乎必然冲突
| 条件 | 加锁范围 |
|---|---|
| 唯一索引等值命中 | 单条记录 |
| 非唯一索引等值 | 命中的记录 + 间隙(RR 隔离级别下) |
| 无索引 | 扫描到的所有记录(近似全表) |
| 范围条件 | 范围覆盖的记录与间隙 |
所以"死锁率突然升高"的第一嫌疑永远是:新上线的 SQL 缺索引,或索引被改了。
3. 间隙锁与 Next-Key Lock
RR(可重复读)隔离级别下,InnoDB 用 Next-Key Lock 解决幻读:锁 = 记录锁 + 它前面的间隙。
-- 表内 id = 5, 10, 15
BEGIN;
SELECT * FROM t WHERE id BETWEEN 8 AND 12 FOR UPDATE;
-- 锁住 (5,10] 与 (10,15] 两段,含不存在的"间隙"
| 锁类型 | 锁什么 | 何时出现 |
|---|---|---|
| Record Lock | 索引记录本身 | 唯一索引等值且记录存在 |
| Gap Lock | 两条记录之间的空隙 | 范围查询、非唯一索引等值 |
| Next-Key Lock | 记录 + 前面的间隙 | RR 下的默认形态 |
| Insert Intention | 插入位置上的意向间隙锁 | INSERT 时 |
间隙锁之间互不冲突,这解释了那个反直觉现象:两个事务插不同主键,也能互相卡死——它们各自持有插入位置附近的间隙锁,再要对方的,就成环了。
4. 复现:同表不同顺序更新
-- 表:CREATE TABLE t_acct (id INT PRIMARY KEY, bal INT) ENGINE=InnoDB;
-- 数据:id=1, id=2
| 时刻 | 会话 A | 会话 B |
|---|---|---|
| T1 | BEGIN; |
BEGIN; |
| T2 | UPDATE t_acct SET bal=bal-1 WHERE id=1; |
|
| T3 | UPDATE t_acct SET bal=bal-1 WHERE id=2; |
|
| T4 | UPDATE ... WHERE id=2; → 等 B |
|
| T5 | UPDATE ... WHERE id=1; → 检测到环 |
|
| T6 | 收到 1213,A 被回滚 | B 继续提交 |
关键点:两条 SQL 单独看都只锁一行,冲突来自加锁顺序相反。这也解释了为什么"两个不同行更新"能死锁——它们最终都要对方的行。
5. 逐行读 LATEST DETECTED DEADLOCK
SHOW ENGINE INNODB STATUS\G
-- 看 LATEST DETECTED DEADLOCK 段
| 段落 | 含义 |
|---|---|
*** (1) TRANSACTION: |
被选为"牺牲者"或先被打印的事务 |
TRANSACTION 1234, ACTIVE 5 sec starting index read |
事务号、存活时长、当前动作 |
mysql tables in use 1, locked 1 |
用了几张表、几张表锁 |
LOCK WAIT 2 lock struct(s), heap size 1136 |
正在等锁,持有 2 个锁结构 |
MySQL thread id 88, ... update |
连接 id,可用于定位来源 |
RECORD LOCKS ... index PRIMARY of table shop.t_acct |
锁在哪张表哪个索引 |
lock_mode X locks rec but not gap waiting |
等一个记录锁(不含间隙) |
*** (1) HOLDS THE LOCK(S): |
它已持有的锁清单 |
*** (1) WAITING FOR THIS LOCK TO BE GRANTED: |
它在等什么 |
*** WE ROLL BACK TRANSACTION (1) |
最终回滚了谁 |
读的顺序是:先看 WE ROLL BACK 是谁 → 再看两个事务的 HOLDS / WAITING → 最后落到业务 SQL。lock_mode 里出现 gap 就是间隙锁参与的案子;出现 insert intention 多半是并发插入。
6. 检测与超时的分工
| 参数 | 作用 | 默认 | 代价 |
|---|---|---|---|
innodb_deadlock_detect |
主动检测环,发现即回滚 | ON | 高并发下每次等锁都要遍历等待图,CPU 开销可观 |
innodb_lock_wait_timeout |
等锁超过 N 秒放弃 | 50 | 不管有没有环,到点就报 1205 |
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
SET GLOBAL innodb_lock_wait_timeout = 5;
两者是"提前发现"与"兜底放弃"的关系。把 innodb_deadlock_detect 关掉(某些超高并发场景会这么做)之后,死锁不再被立即发现,而是靠 innodb_lock_wait_timeout 超时退出——故障形态从"秒级报错"变成"卡 50 秒再报错",通常更糟。
7. 死锁与锁等待超时的区别
| 维度 | 死锁(1213) | 锁等待超时(1205) |
|---|---|---|
| 触发条件 | 等待图成环 | 等锁超过 innodb_lock_wait_timeout |
| 发现速度 | 立即(检测到就回滚) | 必须等到超时 |
| 是否成环 | 是 | 否,只是排队 |
| 回滚范围 | 整个事务 | 当前语句(默认只回滚语句) |
| 日志位置 | LATEST DETECTED DEADLOCK |
无专门段落 |
| 处理方向 | 改加锁顺序 / 缩事务 | 查长事务、查缺索引、查未提交连接 |
把 1205 当死锁治是常见误判:1205 往往是某个事务持锁太久(比如应用里 BEGIN 之后去调了个 HTTP 接口),不是环。
8. 四条解法
其一,统一加锁顺序。 批量更新按主键排序后执行:
-- 应用层先把 id 排序,再用一条语句更新,天然避免交叉
UPDATE t_acct SET bal = bal - 1 WHERE id IN (1, 2) ORDER BY id;
其二,缩小事务。 事务里只放 SQL,不放 RPC、不放文件 IO、不放 sleep。持锁时间与冲突概率成正比。
其三,降隔离级别到 RC。 RC 下间隙锁基本消失(仅剩外键与唯一性检查相关),幻读靠语句级快照兜。代价是同一事务内两次读可能不一致。
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
其四,拆分热点行。 单行余额、库存、计数器是高危对象。改成多行分桶(slot 0–9),更新时 WHERE id = ? AND slot = RAND()*10,读时求和。
| 解法 | 治什么 | 代价 |
|---|---|---|
| 统一顺序 | 交叉更新 | 需要改代码,跨服务难统一 |
| 缩小事务 | 持锁过久 | 需要拆业务边界 |
| 降为 RC | 间隙锁引发的死锁 | 丢失 RR 语义,binlog 需 ROW |
| 拆分行 | 单行热点 | 读变复杂,需要聚合 |
9. 应用层重试的正确姿势
死锁是"可重试错误",但重试必须满足三条:幂等、有限次、有退避。
int maxAttempts = 3;
for (int i = 1; i <= maxAttempts; i++) {
try {
return txTemplate.execute(status -> doTransfer(from, to, amount));
} catch (DeadlockLoserDataAccessException | CannotAcquireLockException e) {
if (i == maxAttempts) throw e;
try {
Thread.sleep((long) (50 * Math.pow(2, i) + ThreadLocalRandom.current().nextInt(50)));
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new IllegalStateException(ie);
}
}
}
| 要点 | 反例 | 后果 |
|---|---|---|
| 幂等 | 重试又扣一次款 | 资损 |
| 有限次 | while(true) |
雪崩时无限放大 |
| 退避 + 抖动 | 固定 10ms 重试 | 所有实例同步重试,二次死锁 |
| 只重试可重试异常 | catch Exception |
把业务异常也重试 |
Spring 的 @Retryable 或 DeadlockLoserDataAccessException 判定都可以,但关键还是那句:重试的前提是这段逻辑重跑一次不会产生副作用。
10. 排错:五种"以为是死锁"
| 现象 | 真实原因 | 处理 |
|---|---|---|
报 1213,但 LATEST DETECTED DEADLOCK 是几小时前的 |
段落只保留最近一次 | 打开 innodb_print_all_deadlocks=ON 写 error log |
| 报 1205 而不是 1213 | 不是环,是等太久 | 查长事务与缺索引 |
单条 UPDATE 也死锁 |
无索引导致锁全表 / 间隙锁 | 补索引,或查执行计划 |
| 死锁只在高峰期出现 | 并发度上来后等待图成环概率上升 | 缩事务 + 拆热点 |
| 重试后依然 1213 | 加锁顺序没变 | 重试治不了顺序问题,必须改代码 |
innodb_print_all_deadlocks 是最实用的一条:默认只留最后一次,开启后每次死锁都进 error log,才有样本可分析。
11. 边界:什么情况不该在数据库层解决
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 库存扣减高并发 | Redis 原子扣减 + 异步落库 | 数据库行锁扛不住 |
| 跨服务资源争用 | 分布式锁 / 消息队列串行化 | 数据库看不到全局顺序 |
| 长事务报表 | 只读库 / 快照读 | 不该和 OLTP 抢锁 |
| 大批量导入 | 分批 + 按主键顺序 | 一次十万行必然锁出问题 |
| 计数类热点 | 分桶 / 异步合并 | 单行写入是瓶颈 |
死锁治不完,只能治少。目标不是 0 次,而是"偶发且能自动恢复"——1213 出现后业务重试成功、监控里有计数但不告警,就是健康状态。
12. 自测
- 两个事务更新不同的行,为什么也会死锁?
lock_mode X locks rec but not gap waiting说明它在等什么锁?- 死锁(1213)与锁等待超时(1205)在回滚范围上有什么区别?
- 为什么把隔离级别降到 RC 能减少死锁?
- 幂等性对"死锁重试"为什么是硬前提?
LATEST DETECTED DEADLOCK只剩一条旧记录时,该开哪个参数?