死锁
问题
什么是死锁?数据库死锁是如何产生的?如何检测、排查和预防死锁?
答案
什么是死锁
死锁(Deadlock)是指 两个或多个事务互相持有对方需要的锁,导致所有事务都无法继续执行的状态。
死锁的四个必要条件
| 条件 | 说明 | 数据库中的体现 |
|---|---|---|
| 互斥 | 资源一次只能被一个事务持有 | 排他锁(X Lock) |
| 持有并等待 | 持有锁的同时等待其他锁 | 事务内有多次加锁操作 |
| 不可抢占 | 已持有的锁不能被强制释放 | 锁只在事务结束时释放 |
| 循环等待 | 事务之间形成等待环路 | A 等 B,B 等 A |
死锁的典型场景
场景 1:交叉更新
-- 事务 A -- 事务 B
BEGIN; BEGIN;
UPDATE accounts SET balance = 900
WHERE id = 1; -- 锁住行 1
UPDATE accounts SET balance = 900
WHERE id = 2; -- 锁住行 2
UPDATE accounts SET balance = 1100
WHERE id = 2; -- 等待行 2 的锁 ⏳
UPDATE accounts SET balance = 1100
WHERE id = 1; -- 等待行 1 的锁 ⏳
-- 💀 死锁!
场景 2:间隙锁冲突(MySQL RR 特有)
-- 表 t: id = 1, 5, 10
-- 事务 A -- 事务 B
BEGIN; BEGIN;
SELECT * FROM t WHERE id = 3
FOR UPDATE;
-- 加间隙锁 (1, 5)
SELECT * FROM t WHERE id = 7
FOR UPDATE;
-- 加间隙锁 (5, 10)
INSERT INTO t VALUES (4);
-- 需要插入到 (1,5) 区间
-- 被 B 的间隙锁阻塞 ⏳
INSERT INTO t VALUES (6);
-- 需要插入到 (5,10) 区间
-- 被 A 的间隙锁阻塞 ⏳
-- 💀 死锁!
场景 3:索引顺序不同
-- 假设 name 和 age 分别有索引
-- 两个事务通过不同索引锁定同一行,但加锁顺序不同
-- 事务 A: 先通过 name 索引锁定,再通过 age 索引锁定
-- 事务 B: 先通过 age 索引锁定,再通过 name 索引锁定
InnoDB 的死锁处理
死锁检测
InnoDB 默认开启 wait-for graph(等待图) 死锁检测:
回滚策略:InnoDB 选择 undo log 最少的事务 回滚(代价最小)。
相关参数:
-- 查看死锁检测是否开启
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
-- ON(默认开启)
-- 锁等待超时(秒)
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 50(默认 50 秒)
高并发下的死锁检测性能
死锁检测需要遍历等待图,时间复杂度 。在热点行高并发更新时(如秒杀扣库存),大量事务等待同一行锁,死锁检测本身会消耗大量 CPU。
优化方案:
- 关闭死锁检测(
innodb_deadlock_detect = OFF),依赖innodb_lock_wait_timeout超时 - 减少热点行竞争:分桶库存、队列削峰
死锁排查
查看最近的死锁信息
-- 查看最后一次死锁
SHOW ENGINE INNODB STATUS\G
-- 在 LATEST DETECTED DEADLOCK 部分查看
输出示例解读:
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 100, query id 5678
UPDATE accounts SET balance=900 WHERE id=2
*** (1) HOLDS THE LOCK(S): -- 事务1持有的锁
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY
-- 持有 id=1 的行锁
*** (1) WAITING FOR THIS LOCK: -- 事务1等待的锁
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY
-- 等待 id=2 的行锁
*** (2) TRANSACTION:
-- 事务2 持有 id=2 的锁,等待 id=1 的锁
*** WE ROLL BACK TRANSACTION (2) -- 被回滚的事务
监控死锁
-- MySQL 8.0+ 查看锁等待
SELECT * FROM performance_schema.data_lock_waits;
-- 查看当前正在等待的锁
SELECT * FROM performance_schema.data_locks
WHERE LOCK_STATUS = 'WAITING';
-- 查看事务信息
SELECT * FROM information_schema.innodb_trx;
死锁预防策略
| 策略 | 说明 | 效果 |
|---|---|---|
| 固定加锁顺序 | 多个表/行总是按相同顺序操作 | 消除循环等待 |
| 减少事务范围 | 事务尽量短,减少持锁时间 | 减少冲突概率 |
| 合理使用索引 | 确保 UPDATE/DELETE 走索引 | 避免锁全表 |
| 降低隔离级别 | 使用 RC 代替 RR | 消除间隙锁 |
| 避免大事务 | 不要在一个事务中操作太多行 | 减少锁数量 |
| 乐观锁 | 用版本号替代排他锁 | 避免长时间持锁 |
固定加锁顺序
-- ❌ 可能死锁:事务 A 先锁 1 再锁 2,事务 B 先锁 2 再锁 1
-- 事务 A
UPDATE accounts SET ... WHERE id = 1;
UPDATE accounts SET ... WHERE id = 2;
-- 事务 B
UPDATE accounts SET ... WHERE id = 2;
UPDATE accounts SET ... WHERE id = 1;
-- ✅ 统一按 id 升序加锁
-- 事务 A 和 B 都先锁 1 再锁 2
UPDATE accounts SET ... WHERE id = 1;
UPDATE accounts SET ... WHERE id = 2;
乐观锁替代
-- 用 version 字段做乐观锁,避免 SELECT FOR UPDATE
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 5;
-- 如果返回 affected rows = 0,表示有冲突,重试
各数据库的死锁处理
| 数据库 | 检测机制 | 处理方式 |
|---|---|---|
| MySQL InnoDB | 等待图检测 | 回滚 undo 最小的事务 |
| PostgreSQL | 等待图检测 | 回滚最后申请锁的事务 |
| Oracle | 等待图检测 | 回滚语句(不是整个事务) |
| SQL Server | 锁监控线程 | 回滚资源消耗最小的事务 |
常见面试问题
Q1: 如何排查线上死锁?
答案:
- 查看死锁日志:
SHOW ENGINE INNODB STATUS,找到LATEST DETECTED DEADLOCK - 分析两个事务:找到各自持有的锁和等待的锁
- 定位 SQL:找到引起死锁的具体 SQL 语句
- 分析加锁顺序:检查两个事务对同一组行的加锁顺序是否相反
- 修复:统一加锁顺序 / 拆分事务 / 降低隔离级别
Q2: 如何减少死锁的发生?
答案:
- 统一加锁顺序:多表操作时按固定顺序(如表名字母序、主键升序)
- 缩短事务:事务中只包含必要操作,不要在事务中执行慢查询或外部调用
- 使用合理索引:确保 WHERE 条件走索引,避免行锁升级为表锁
- RC 替代 RR:RC 没有间隙锁,减少间隙锁死锁
- 乐观锁:对于冲突率低的场景,用 version 字段替代 FOR UPDATE
- 应用层重试:死锁回滚后自动重试
Q3: MySQL 是如何检测死锁的?
答案:
InnoDB 使用 等待图(Wait-for Graph) 算法:
- 每当有事务开始等待锁时,在等待图中添加边(A → B 表示 A 等待 B 持有的锁)
- 检测图中是否存在 环路(cycle)
- 如果有环路 → 死锁
- 选择 undo log 量最少的事务回滚
检测是即时的(每次加锁等待都触发),所以死锁一旦发生会立即被检测到,不需要等超时。
Q4: 间隙锁为什么容易造成死锁?
答案:
间隙锁是 RR 级别下为了防止幻读而引入的锁,锁定的是一个范围(而不是具体的行)。
间隙锁之间不互斥(两个事务可以同时锁住同一个间隙),但间隙锁会与插入意向锁互斥。
典型死锁场景:
- 事务 A 对范围 (5, 10) 加间隙锁
- 事务 B 对范围 (5, 10) 加间隙锁(不冲突)
- 事务 A 要在 (5, 10) 范围插入 → 被 B 的间隙锁阻塞
- 事务 B 也要在 (5, 10) 范围插入 → 被 A 的间隙锁阻塞
- 死锁!
解决:降级到 RC 级别(不使用间隙锁),或调整业务逻辑避免并发插入。
Q5: 死锁和锁等待超时有什么区别?
答案:
| 对比 | 死锁 | 锁等待超时 |
|---|---|---|
| 原因 | 循环依赖,永远无法解开 | 长事务持锁不释放 |
| 检测方式 | 等待图检测(即时) | 超时检测(默认 50s) |
| 处理方式 | 回滚一个事务 | 等待超时报错 |
| 错误码 | ERROR 1213 (Deadlock) | ERROR 1205 (Lock wait timeout) |
| 可避免性 | 可以通过设计避免 | 可以通过缩短事务避免 |