死锁是数据库并发控制中最让开发者头疼的问题之一。简单来说,死锁就是两个或多个事务在执行过程中,分别持有对方需要的锁资源,又都在等待对方释放,形成一个闭环等待,导致谁都无法继续执行。数据库的死锁检测机制发现这种情况后,会主动回滚其中一个代价较小的事务,让另一个事务继续执行,但被回滚的事务如果没有做好异常处理,业务就会报错甚至失败。这篇文章就来系统讲讲死锁是怎么产生的、怎么排查、以及用什么手段从SQL层面预防和解决。

一、死锁到底是怎么产生的:从加锁机制说起
要理解死锁,得先明白事务加锁的基本规则。以MySQL的InnoDB引擎为例,当事务执行UPDATE、DELETE这类语句时,会对符合条件的记录加行锁;执行INSERT时可能加间隙锁防止幻读。行锁本身是好事,它保证了并发修改的数据一致性,但锁的获取顺序如果不一致,就容易出问题。
最经典的死锁场景是两个事务以相反的顺序更新两条记录。事务A先更新id=1的记录,再更新id=2的记录;事务B先更新id=2,再更新id=1。当A拿到id=1的锁、B拿到id=2的锁之后,A等待B释放id=2,B又在等待A释放id=1,闭环形成,死锁发生。这种加锁顺序不一致的问题在批量更新语句中非常常见,尤其是UPDATE ... WHERE id IN (1,2,3)这类语句,如果不同事务传入的id顺序不同,底层加锁顺序也可能不同。
另一个高频场景是间隙锁冲突。在可重复读(REPEATABLE READ)隔离级别下,InnoDB对范围查询命中的区间加间隙锁,两个事务同时对同一个不存在的区间插入数据,各自持有间隙锁又都想插入插入意向锁,就会互相阻塞形成死锁。此外,唯一索引冲突时的共享锁升级、二级索引与主键锁的交叉、以及SQL Server中的锁升级(行锁升级为表锁)也都会引发死锁。
二、如何定位死锁:日志查看与问题复现
遇到死锁报错不要急着改代码,第一步应该是拿到死锁的详细信息。MySQL中可以通过以下命令查看最近一次死锁的现场:
-- 查看InnoDB状态,包含LATEST DETECTED DEADLOCK部分 SHOW ENGINE INNODB STATUS\G -- 开启死锁日志,记录到错误日志中(需要重启或动态设置) SET GLOBAL innodb_print_all_deadlocks = ON;
SHOW ENGINE INNODB STATUS输出的死锁信息中,重点看三个部分:一是两个事务各自正在执行的SQL语句,二是各自持有的锁(HOLDS THE LOCK(S)),三是各自等待的锁(WAITING FOR)。通过对比持锁和等锁的记录,基本能还原出死锁的形成路径。需要注意的是,这个命令默认只显示最近一次死锁,生产环境建议开启innodb_print_all_deadlocks,把每次死锁都记录下来慢慢分析。
SQL Server的处理方式不同,它提供了专门的死锁图形报告。可以通过开启跟踪标志位或者扩展事件来捕获:
-- 开启1222标志位,死锁详情写入错误日志 DBCC TRACEON (1222, -1); -- 或者使用扩展事件捕获死锁图形(推荐,SQL Server版本较新时) CREATE EVENT SESSION [capture_deadlock] ON SERVER ADD EVENT sqlserver.xml_deadlock_report ADD TARGET package0.event_file ( SET filename = N'deadlock_log' );
拿到死锁报告后,还要结合业务代码定位到具体的事务逻辑。建议把死锁涉及的SQL语句、事务隔离级别、执行频率、涉及的数据范围整理成表格,逐项排查是否存在加锁顺序不一致、事务过长、索引缺失导致锁范围扩大等问题。很多时候看似随机的死锁,背后都有固定的规律,比如集中在某个高频接口的并发请求上。
三、从SQL层面预防死锁的实用技巧
预防死锁的核心思路是破坏死锁形成的四个必要条件之一,落实到SQL层面,主要有下面这些做法。
第一,保证加锁顺序一致。凡是需要批量更新多条记录的场景,先按主键排序再执行更新,让所有事务都以相同顺序获取锁。例如把UPDATE ... WHERE id IN (3,1,2)改成先在应用层排序为1、2、3,或者干脆改成循环单条更新并保证顺序。这一招能消灭大部分加锁顺序类死锁。
-- 事务A和事务B都按主键升序处理,避免交叉等待 START TRANSACTION; SELECT balance FROM account WHERE id = 1 FOR UPDATE; SELECT balance FROM account WHERE id = 2 FOR UPDATE; UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; COMMIT;
第二,缩短事务的持有时间。事务越长,持有锁的窗口越大,与其他事务碰撞的概率就越高。把事务里不必要的RPC调用、文件操作、耗时计算全部移出事务,只保留必要的数据库操作。同时避免在事务中等待用户输入,这类问题在长连接场景下尤其致命。
第三,为查询条件建立合适的索引。如果没有索引,UPDATE语句可能从行锁退化为扫描全表,锁的范围大幅扩大,并发时极易冲突。确保WHERE条件命中索引,让InnoDB只锁定目标行,能显著降低死锁概率。
第四,合理选择隔离级别。如果业务对幻读不敏感,可以把隔离级别从可重复读降到读已提交(READ COMMITTED),这样就没有间隙锁,很多插入类死锁会直接消失。MySQL 8.0之后读已提交模式下还可以配合合理的索引获得更好的并发性能。
第五,引入重试机制兜底。死锁被检测回滚后事务并非不能恢复,应用层捕获死锁异常后等待一小段随机时间再重试,通常就能成功。以常见的ORM或原生代码为例:
int retry = 0;
boolean success = false;
while (retry < 3 && !success) {
try {
executeTransferTransaction(); // 执行转账事务
success = true;
} catch (DeadlockException e) {
retry++;
Thread.sleep(50 + new Random().nextInt(100)); // 随机退避,避免再次碰撞
}
}
if (!success) {
throw new RuntimeException("重试多次仍死锁,转人工处理");
}
随机退避时间很关键,如果固定等待同样的时长,并发事务很可能在同一时刻再次碰撞。另外,对于极端高并发下的热点行更新,还可以考虑把一行拆成多行分散热点,或者用队列把并发写操作串行化,从根本上消除锁竞争。
四、几个容易踩坑的典型死锁案例
案例一:唯一键插入冲突死锁。两个事务同时向一张有唯一索引的表插入相同的键值,第一个事务插入成功持有排他锁,第二个事务检测到重复需要加共享锁被阻塞;随后第一个事务又因为其他操作回滚或删除该记录,第二个事务的共享锁转为排他锁时可能与第三个事务冲突,形成死锁链。解决办法是用INSERT ... ON DUPLICATE KEY UPDATE替代先查后插,或者在插入前用SELECT ... FOR UPDATE串行化检查。
案例二:二级索引引发的隐式加锁。开发者以为只更新一行就只锁一行,但UPDATE通过二级索引定位时,除了锁二级索引记录,还会锁对应的主键记录,两个语句的执行计划如果选择了不同的索引,加锁顺序可能不同。遇到这类问题可以在EXPLAIN中确认执行计划,必要时用FORCE INDEX固定索引。
案例三:SQL Server锁升级导致的表级死锁。大批量更新时行锁数量超过阈值(默认约5000个),会升级为表锁,其他事务瞬间被全部阻塞。应对办法是分批提交,用TOP子句把大事务拆成多个小批次:
DECLARE @rows INT = 1;
WHILE @rows > 0
BEGIN
UPDATE TOP (1000) orders
SET status = 'DONE'
WHERE status = 'PENDING';
SET @rows = @@ROWCOUNT;
END
总的来说,死锁不可能被百分之百消除,它是并发访问共享资源的必然代价。正确的目标是把死锁概率压到足够低,并为偶发的死锁准备好重试兜底。按固定顺序访问资源、保持事务短小、用好索引、选对隔离级别、加上重试机制,这五板斧用好了,绝大多数死锁问题都能得到有效控制。