数据库死锁面试精讲:死锁产生的4个条件与MySQL的检测机制
数据库死锁面试精讲:死锁产生的4个条件与MySQL的检测机制
适读人群:Java后端开发、DBA | 难度:★★★★☆ | 出现频率:高
开篇故事
我做技术负责人的第二年,系统突然开始频繁出现死锁告警。每隔几分钟就有一条事务被回滚,日志里打满了 Deadlock found when trying to get lock; try restarting transaction。
排查了整整两天,最终找到了原因:有两个高频操作,A操作先锁订单表再锁库存表,B操作先锁库存表再锁订单表,两个操作并发时必然形成循环等待。
解决方法很简单——统一加锁顺序,但找到问题的过程极其痛苦,因为当时对死锁的理解不够深。
今天我把死锁的四个产生条件、MySQL的检测机制、以及预防策略全部讲清楚,让你以后遇到死锁问题能快速定位和解决。
一、高频考点拆解
死锁这道题考察三个层次:
第一层:知道死锁的四个必要条件(互斥、持有并等待、不可剥夺、循环等待) 第二层:知道MySQL是如何检测死锁的(wait-for graph有向图) 第三层:能给出预防死锁的具体策略,并结合实际业务场景举例
二、深度原理分析
2.1 死锁的四个必要条件
条件1:互斥条件(Mutual Exclusion)
资源(锁)一次只能被一个进程/事务使用。如果资源可以同时被多个事务使用(如读锁),就不会产生死锁。
条件2:持有并等待(Hold and Wait)
一个事务已经持有至少一个锁,但还在等待获取其他锁。
条件3:不可剥夺(No Preemption)
一个事务持有的锁,不能被强制剥夺,只能等该事务主动释放。
条件4:循环等待(Circular Wait)
存在一条事务等待链,形成循环:事务A等待事务B持有的锁,事务B等待事务A持有的锁(或更长的循环链)。
这四个条件必须同时满足才会产生死锁。只要破坏其中任意一个,就能预防死锁。
2.2 MySQL的死锁检测机制
MySQL InnoDB使用wait-for graph(等待图)来检测死锁。
等待图是一个有向图:
- 节点:每个活跃事务
- 边:如果事务A在等待事务B释放某个锁,则添加边 A→B
当等待图中存在回路(环)时,说明发生了死锁。
MySQL的检测时机:每次事务需要等待一个锁时,InnoDB就会触发一次死锁检测,遍历等待图查找环。如果发现环,选择代价最小的事务(通常是修改行数最少的事务)作为牺牲者,回滚其所有修改,让其他事务继续执行。
被回滚的事务会收到错误:ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction。
2.3 MySQL的两种锁等待机制
innodb_lock_wait_timeout(默认50秒):一个事务等待锁超过这个时间,自动超时回滚。这是防止死锁不被检测到时的兜底机制。
innodb_deadlock_detect(默认ON):是否开启死锁主动检测。开启时,每次加锁等待都会触发检测,能快速发现死锁。在高并发场景(数千个并发事务),死锁检测本身的CPU开销可能很大(每次检测都要遍历整个等待图),此时可以关闭自动检测,完全依赖超时机制,但代价是死锁恢复变慢。
三、标准答案 + 代码验证
3.1 构造一个典型死锁场景
-- 建表
CREATE TABLE accounts (
id INT PRIMARY KEY,
name VARCHAR(50),
balance DECIMAL(10,2)
) ENGINE=InnoDB;
INSERT INTO accounts VALUES (1, '张三', 1000);
INSERT INTO accounts VALUES (2, '李四', 2000);
-- =============================================
-- 死锁场景:两个事务以相反顺序锁定行
-- =============================================
-- 开两个MySQL连接,按时间顺序执行:
-- 时刻1:连接1(事务A)开始
START TRANSACTION; -- 连接1
-- 时刻2:连接2(事务B)开始
START TRANSACTION; -- 连接2
-- 时刻3:A先锁 id=1 这行
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 连接1,锁住id=1
-- 时刻4:B先锁 id=2 这行
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- 连接2,锁住id=2
-- 时刻5:A再尝试锁 id=2(被B持有,等待)
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- 连接1,阻塞!
-- 时刻6:B再尝试锁 id=1(被A持有,形成循环等待→死锁)
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 连接2,触发死锁检测
-- MySQL检测到死锁,回滚代价较小的事务(比如事务B),另一个事务继续
-- 报错:ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction3.2 查看最近一次死锁信息
-- 查看最近一次死锁的详细信息
SHOW ENGINE INNODB STATUS\G
-- 输出中找 LATEST DETECTED DEADLOCK 部分:
-- *** (1) TRANSACTION:
-- TRANSACTION 123, ACTIVE 5 sec starting index read
-- MySQL thread id 1, OS thread handle ..., query id ...
-- UPDATE accounts SET balance = balance + 100 WHERE id = 2
-- *** (1) HOLDS THE LOCK(S):
-- RECORD LOCKS space id ... page no ... n bits ... index PRIMARY of table accounts
-- *** (1) WAITING FOR THIS LOCK TO BE GRANTED:
-- RECORD LOCKS ...
-- *** (2) TRANSACTION:
-- ... (事务B的信息)
-- *** WE ROLL BACK TRANSACTION (2) -- 回滚了事务23.3 预防死锁的Java代码实践
// 方案1:统一加锁顺序(最重要!)
@Service
@Transactional
public class TransferService {
@Autowired
private AccountMapper mapper;
// 错误做法:加锁顺序不确定,可能死锁
public void transfer_bad(long fromId, long toId, BigDecimal amount) {
// 如果两个线程分别调用 transfer(1, 2, 100) 和 transfer(2, 1, 100)
// 一个先锁fromId=1,另一个先锁fromId=2,产生死锁
mapper.deduct(fromId, amount);
mapper.add(toId, amount);
}
// 正确做法:统一按ID从小到大的顺序加锁
public void transfer_good(long fromId, long toId, BigDecimal amount) {
long firstId = Math.min(fromId, toId);
long secondId = Math.max(fromId, toId);
// 无论调用顺序如何,总是先锁小ID,再锁大ID
// 这样不可能形成循环等待
if (fromId < toId) {
mapper.deduct(fromId, amount); // 先操作小ID
mapper.add(toId, amount); // 再操作大ID
} else {
mapper.add(toId, amount); // 先操作小ID(toId)
mapper.deduct(fromId, amount); // 再操作大ID(fromId)
}
}
// 方案2:锁升级(一次性获取所有需要的锁)
// 使用SELECT ... FOR UPDATE先锁定所有行,再执行操作
public void transfer_lockAll(long fromId, long toId, BigDecimal amount) {
// 一次性按顺序锁定两行,避免增量加锁
List<Long> ids = Arrays.asList(fromId, toId);
Collections.sort(ids); // 排序保证顺序
mapper.lockRows(ids); // SELECT * FROM accounts WHERE id IN (?) FOR UPDATE ORDER BY id
mapper.deduct(fromId, amount);
mapper.add(toId, amount);
}
}3.4 死锁后的重试机制
@Service
public class ResilientTransferService {
@Autowired
private TransferService transferService;
// 遇到死锁自动重试
public void transferWithRetry(long fromId, long toId, BigDecimal amount) {
int maxRetries = 3;
int retryCount = 0;
while (retryCount < maxRetries) {
try {
transferService.transfer_good(fromId, toId, amount);
return; // 成功,退出
} catch (DeadlockLoserDataAccessException e) {
// Spring将MySQL死锁异常包装为DeadlockLoserDataAccessException
retryCount++;
if (retryCount >= maxRetries) {
throw new RuntimeException("转账失败,已重试" + maxRetries + "次", e);
}
// 指数退避:等待时间随重试次数增加
try {
Thread.sleep((long) Math.pow(2, retryCount) * 100);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException("重试被中断", ie);
}
}
}
}
}四、面试官追问
追问1:MySQL InnoDB中有哪些类型的锁?
我的回答:InnoDB的锁可以按几个维度分类。按粒度:行锁(最细,锁单行)、表锁(整表)、间隙锁(Gap Lock,锁索引区间,防止幻读)、临键锁(Next-Key Lock,行锁+间隙锁的组合,RR级别默认)。按类型:共享锁(S锁,读锁,多事务可同时持有)、排他锁(X锁,写锁,独占)、意向锁(Intention Lock,表级锁,表示事务意向在行级加锁,加行锁前先加意向锁,让表锁检测更高效)。按是否等待:乐观锁(不实际加锁,用版本号控制)、悲观锁(真实加数据库锁)。
追问2:间隙锁(Gap Lock)是什么?什么情况下会产生间隙锁?
我的回答:间隙锁锁的不是某行数据,而是两行数据之间的"间隙",防止在间隙中插入新行,从而防止幻读。间隙锁只在RR(可重复读)隔离级别下存在。当执行范围查询(WHERE age BETWEEN 20 AND 30)时,会锁住这个范围内的所有记录和间隙。间隙锁之间不互斥(两个事务可以同时持有同一个间隙的间隙锁),但会和插入意向锁冲突(防止向间隙中插入行)。间隙锁是死锁的常见来源,特别是在INSERT操作中。
追问3:如何分析和排查生产环境的死锁?
我的回答:标准流程是:第一步,从日志中获取死锁信息,SHOW ENGINE INNODB STATUS\G,找到LATEST DETECTED DEADLOCK部分,查看两个事务分别持有哪些锁、等待哪些锁、在执行什么SQL。第二步,结合业务代码,找到这两个SQL对应的业务操作,分析它们的加锁顺序。第三步,确认是否是循环等待,确认加锁顺序相反。第四步,修复方案通常是:统一加锁顺序、减少事务粒度(持锁时间)、添加索引(让范围锁更精确)、或者在业务层控制并发(用分布式锁)。
五、同类题目举一反三
乐观锁和悲观锁的区别,各自适用什么场景?
悲观锁:假设并发冲突经常发生,每次操作都加锁(SELECT...FOR UPDATE),适合写操作频繁、冲突概率高的场景,缺点是并发度低、容易死锁。
乐观锁:假设冲突很少发生,不加数据库锁,在更新时检查版本号或时间戳是否变化(CAS思想),适合读多写少、冲突概率低的场景,优点是并发度高,缺点是更新失败时需要重试,且在冲突频繁时重试次数多,性能反而差。
六、踩坑实录
坑一:Insert也可能死锁
很多人以为死锁只发生在UPDATE/DELETE之间,忽略了INSERT。有次我们的代码并发往同一张表的同一个区间插入数据,两个事务都持有间隙锁,然后都尝试插入(插入需要插入意向锁),两个事务的插入意向锁互相等待对方释放间隙锁,产生死锁。解决方案是给相关列加唯一索引,用INSERT IGNORE或ON DUPLICATE KEY UPDATE来处理冲突,避免并发插入相同范围的间隙。
坑二:忘记索引导致锁范围扩大
有次更新操作写了WHERE status = 1,但status字段没有索引。InnoDB只能锁全表(实际上是锁所有主键行+间隙),导致本来只需要锁几行的操作锁了整张表,其他操作全部等待。加上索引后,锁范围缩小到具体的行,死锁消失,并发度大幅提升。
坑三:事务太大,持锁时间过长
有个同事的事务里,先查一批数据(加了FOR UPDATE),然后做了一个外部HTTP调用(调用风控接口,可能耗时1-2秒),HTTP调用完成后才更新数据释放锁。这1-2秒里,其他事务想锁相同行的操作全部阻塞,积压了大量等待,最终触发超时和死锁。
原则:事务里不要做外部调用,持锁操作要尽量短。如果必须调用外部接口,先做外部调用,再开事务加锁更新。
七、总结
死锁的四个必要条件:互斥、持有并等待、不可剥夺、循环等待。预防死锁的核心是破坏这四个条件之一,最实用的手段是:
- 统一加锁顺序:破坏循环等待条件,最有效
- 减少锁的持有时间:事务越短越好,避免在锁定期间做耗时操作
- 添加合适的索引:减少锁的范围,减少不必要的行锁和间隙锁
- 一次性获取所有锁:避免增量加锁
MySQL通过wait-for graph有向图检测死锁,发现环后回滚代价最小的事务。即使预防措施做得好,代码层面也要有死锁重试机制,让系统更健壮。
