2673 字
约 8 分钟
21
S11-X05 · 数据库死锁:成因、复现与解法
2024-12-31
2026-11-08

死锁不是"数据库有 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. 自测

  1. 两个事务更新不同的行,为什么也会死锁?
  2. lock_mode X locks rec but not gap waiting 说明它在等什么锁?
  3. 死锁(1213)与锁等待超时(1205)在回滚范围上有什么区别?
  4. 为什么把隔离级别降到 RC 能减少死锁?
  5. 幂等性对"死锁重试"为什么是硬前提?
  6. LATEST DETECTED DEADLOCK 只剩一条旧记录时,该开哪个参数?
S11-X05 · 数据库死锁:成因、复现与解法
https://www.clxhxhhr.top/posts/5672/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。