什么是数据库死锁,怎么解决?

#中间件#面试八股

什么是数据库死锁,怎么解决?

结论:死锁是两个及以上事务互相持有对方需要的锁、并等待对方释放,形成循环等待。InnoDB 有死锁检测,会主动回滚代价较小的事务并报 1213。解决思路不是「消灭死锁」,而是「让它少发生、发生时可自愈」:统一加锁顺序、缩短事务、用索引收窄锁范围、应用层重试。

一、死锁的四个必要条件

条件含义在 InnoDB 中对应什么
互斥资源一次只能被一个事务持有行锁的排他性
持有并等待持有一把锁的同时申请另一把事务内多条 UPDATE
不可剥夺锁不能被强行抢走只能等超时或回滚
循环等待等待关系形成环两个事务交叉更新两行

打破任意一个条件即可避免死锁,工程上最容易下手的是「循环等待」和「持有并等待」。

二、典型死锁时序

时刻   事务 A                          事务 B
 t1    BEGIN;
 t2    UPDATE t SET v=1 WHERE id=1;     -- 持有 id=1 的 X 锁
 t3                                     BEGIN;
 t4                                     UPDATE t SET v=2 WHERE id=2;  -- 持有 id=2
 t5    UPDATE t SET v=3 WHERE id=2;     -- 等 B 释放 id=2
 t6                                     UPDATE t SET v=4 WHERE id=1;  -- 等 A 释放 id=1
       ────────────── 循环等待 ──────────────
 t7    InnoDB 检测到环,回滚其中一个事务:
       ERROR 1213 (40001): Deadlock found when trying to get lock

高频触发场景还有:唯一键并发插入(间隙锁 + 插入意向锁)、INSERT ... ON DUPLICATE KEY UPDATE 并发同键、范围 UPDATE 的 next-key lock 交叠、以及大事务里混用 SELECT ... FOR UPDATE 与普通写。

三、怎么看到死锁

-- 最近一次死锁的完整现场(含两个事务的 SQL、持有的锁、等待的锁)
SHOW ENGINE INNODB STATUS\G
-- 关注 LATEST DETECTED DEADLOCK 段

-- 把每次死锁都写进 error log,而不是只保留最后一次
SET GLOBAL innodb_print_all_deadlocks = ON;

-- 8.0:实时看锁与等待关系
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;

-- 当前运行的事务
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;

死锁日志里要读三件事:两个事务各自执行到哪条 SQL、各自持有哪些锁、等待哪把锁。TRANSACTION 段的 HOLDS THE LOCK(S) 与 WAITING FOR THIS LOCK TO BE GRANTED 就是环的两端。

错误码含义处理
1213死锁,InnoDB 主动回滚捕获后重试整个事务
1205锁等待超时(innodb_lock_wait_timeout,默认 50 秒)检查长事务与锁范围,通常重试也有效

四、四类治理手段

1. 统一加锁顺序:所有涉及多行的写操作,按主键升序处理。

-- 转账:固定先锁小 id,再锁大 id
START TRANSACTION;
SELECT balance FROM account WHERE id = LEAST(1, 2) FOR UPDATE;
SELECT balance FROM account WHERE id = GREATEST(1, 2) FOR UPDATE;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;

2. 缩短事务、减少持锁时间:把 RPC、文件、消息发送挪到事务外;能一条 SQL 完成的不要拆成「先查再改」。

-- 反面:先查后改,中间隔着业务逻辑
SELECT stock FROM sku WHERE id = 88;
-- ... 计算 ...
UPDATE sku SET stock = 12 WHERE id = 88;

-- 正面:原子更新,锁只在本语句内
UPDATE sku SET stock = stock - 1 WHERE id = 88 AND stock > 0;

3. 用索引收窄锁范围:UPDATE 没走索引会退化成扫全表并锁住扫过的所有行,是死锁与锁等待的头号来源。

EXPLAIN UPDATE sku SET stock = stock - 1 WHERE sku_code = 'A001';
-- 若 type=ALL,先给 sku_code 建索引,否则每行都被加锁

4. 降低隔离级别或改重试策略:读已提交(RC)不加间隙锁,能消掉一大批插入型死锁;应用层把死锁当作可重试异常。

// 重试封装:只对 1213/1205 重试,且限制次数与退避
for (int i = 0; i < 3; i++) {
    try {
        return txTemplate.execute(status -> doTransfer(from, to, amount));
    } catch (DeadlockLoserDataAccessException e) {
        sleep(50L << i);   // 50ms / 100ms / 200ms
    }
}

常见追问

  • 死锁能完全避免吗? 不能,只能降低概率。检测与回滚是 InnoDB 的兜底能力,业务必须能处理 1213。
  • 死锁检测有代价吗? 有。innodb_deadlock_detect=ON 时每次等锁都要做图检测,热点行高并发下 CPU 开销明显,可改为依赖 innodb_lock_wait_timeout。
  • 为什么 RC 下死锁少? 没有间隙锁,插入不会互相阻塞;代价是可能出现不可重复读与幻读。
  • SELECT ... FOR UPDATE 没走索引会怎样? 扫过的每一行都加锁,等价于锁表,极易与其他事务形成环。
  • 重试要注意什么? 必须重试「整个事务」而不是单条 SQL,且业务要幂等;重试次数与退避要有上限。
  • 如何提前发现死锁趋势? 监控 Innodb_deadlocks 状态量、error log 中的 1213 频次,并定期 review 长事务。

小结

死锁是并发写入的固有现象。排查靠 SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK 与 data_locks;治理靠「顺序、短事务、走索引、可重试」四条。真正危险的从来不是死锁本身,而是没有重试机制、把 1213 直接抛给用户。

笔记加载中…