很多年前第一次看到线上日志里出现“Deadlock found when trying to get lock; try restarting transaction”的时候,我整个人是懵的。数据库还会自己锁死自己?后来做后端时间久了,尤其是处理过并发事务、搞过订单支付这类业务后,“MySQL死锁”这五个字基本就成了每个开发同学绕不开的坎。它不是数据库坏了,而是InnoDB在并发访问下为了保住数据一致性做的一种“强制介入”,但如果你不懂它的触发逻辑和排查方法,遇到它的时候真的会手足无措。
这篇文章完全围绕死锁来讲:死锁是怎么产生的,最常见的几个业务场景长什么样,怎么用一条命令把死锁现场拉出来,以及我在生产环境里踩过坑以后沉淀下来的一套解决和预防经验。写给自己项目总结也好,给团队分享也好,或者准备面试系统梳理一下都合适,不管你是刚写SQL的新人,还是已经在线上被死锁折磨过的老手,应该都能从中找到点有用的东西。
1. 死锁到底是什么:先搞懂锁和事务的关系
1.1 从一次线上报警说起
我第一次正式面对死锁,是线上一个订单支付接口突然频繁超时,错误日志里出现了上面那句经典的死锁提示。当时第一反应是“数据库崩了?”,但查了监控发现数据库CPU和IO都很正常,后来定位到是代码里两个事务互相锁住了对方的资源。那次之后我才深刻意识到,死锁不是数据库自己的问题,而是业务并发访问顺序的问题。
InnoDB里的锁机制是为了保证事务隔离性和数据一致性,行锁、表锁、间隙锁等都会占用资源。当多个事务同时操作同一批数据,并且锁的申请顺序出现交叉,就可能陷入一个谁都无法继续推进、只能互相等待的闭环。MySQL的InnoDB引擎会主动检测这个闭环,并通过回滚其中一个事务来打破僵局,然后抛出1213错误码,也就是我们常说的死锁。
这里要先理清一个概念:死锁和锁等待是不同的。锁等待是一个事务在等另一个事务释放锁,只要另一个事务最终提交了,等的人还能继续,顶多多等一会;而死锁是互相等,永远等不到,所以InnoDB必须主动干掉一个。这个区别在后面排查时非常关键,错误码1205和1213就是分水岭。
1.2 死锁的四个必要条件
教科书上讲死锁需要四个条件同时成立,放在MySQL里也一样,理解了它们,后续所有解决方案都能从这个框架里推导出来:
- 互斥:资源只能被一个事务独占,比如同一行记录的写锁不会同时分给两个事务。
- 持有并等待:一个事务已经持有了某些锁,同时又在等待其他事务持有的锁。
- 不可剥夺:已经获取的锁不能被别人强行拿走,只能等事务自己释放。
- 循环等待:多个事务之间形成了一条环形的等待链,比如事务A等事务B,事务B又在等事务A。
只要打破其中任何一个条件,死锁就能避免。这句话说起来简单,但真正要落地到代码和SQL级别,就需要反复审视事务里的每一步操作了。
我经常拿一个生活场景来类比死锁:一条只允许一个人通过的窄桥,你从东边走到中间,我从西边走到中间,两个人谁也不肯后退,谁也过不去。这时如果有个管理者(相当于InnoDB的死锁检测机制)出手,让其中一个人退回去,桥就通了。理解了这个类比,后面看死锁日志就不会觉得那些英文单词和锁记录是天书了。
2. 最常见的死锁场景:两个事务交叉更新
2.1 一个三天就能复现的案例
假设我们有一张用户账户表,里面有两个人,id=1的余额是100,id=2的余额是200。业务上有两个动作:
事务A在做转账,先扣id=1的钱,再给id=2加钱。 事务B也在做转账,但方向相反,先扣id=2的钱,再给id=1加钱。
当两个事务同时执行,就会出现交叉:事务A持有id=1的行锁,等待id=2的行锁;事务B持有id=2的行锁,等待id=1的行锁。这就是最经典的循环等待。用SQL模拟一下:
-- 事务A START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 1; -- 此时事务A持有id=1的锁 UPDATE account SET balance = balance + 100 WHERE id = 2; -- 等待事务B释放id=2的锁 COMMIT;-- 事务B START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 2; -- 此时事务B持有id=2的锁 UPDATE account SET balance = balance + 100 WHERE id = 1; -- 等待事务A释放id=1的锁 COMMIT;只要并发到达,两个事务各自执行完第一条update后,死锁就出现了。InnoDB会立刻检测到,然后把其中一个事务回滚,另一个事务才能继续。所以程序里如果你没有捕获1213异常并重试,你的接口就会直接报错,用户看到的就是操作失败。
实际业务里这种场景非常多,不只是转账,比如下单时扣库存和更新订单状态、取消订单时回库存和更新订单状态,一不小心就会形成这种交叉更新。很多人一开始觉得死锁离自己很远,其实只需要两个接口写得不规范,就可能在某一波流量高峰集中爆发。
2.2 为什么调整SQL顺序就解决了
很多人以为死锁是必须上很高深的技术才能解决的,其实很多情况下只是执行顺序的问题。如果两个事务都先操作id小的那个,再操作id大的那个,锁的申请顺序就一样了,不会形成等待环。比如约定所有转账都先更新id较小的账户,再更新id较大的账户,那么两个事务都会先锁id=1再锁id=2,最多是排队等待,而不是互相等。
这个方案简单有效,但要注意一点:约定必须覆盖所有业务路径。如果代码里哪条路径没按这个约定来,死锁就会换个姿势回来。所以更稳妥的做法是,在代码层面统一封装一个“按账户id排序后操作”的服务层,别让底层SQL各自为战。
这里还有一个容易被坑的点:update的where条件如果没有走到唯一索引,而是走了二级索引甚至全表扫描,锁的范围就不是一行,很可能把一批行都锁了,死锁概率会成倍上升。所以死锁排查时,第一件事就是看执行计划,确认每条update都用了最优索引。这个习惯帮我排掉过大量隐性坑。
3. 死锁排查与诊断:怎么从日志里找到真凶
3.1 用一条命令把死锁现场拉出来
MySQL有一个特别实用的命令,就是查看InnoDB引擎状态:
SHOW ENGINE INNODB STATUS\G输出结果里有一块叫LATEST DETECTED DEADLOCK,里面记录了最近一次死锁的详细信息:涉及的事务ID、执行过的SQL、持有和等待的锁、被回滚的是哪个事务。如果你是DBA或者有权限,直接跑这条命令,能省很多时间。
下面是我在测试环境复现一个死锁后拿到的日志片段(内容做了简化):
------------------------ LATEST DETECTED DEADLOCK ------------------------ 2024-05-14 10:02:33 0x7f... *** (1) TRANSACTION: TRANSACTION 310402, ACTIVE 0 sec MySQL thread id 102, OS thread handle 1234 UPDATE account SET balance = balance - 100 WHERE id = 1 *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 5 page no 3... *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 5 page no 4... *** (2) TRANSACTION: TRANSACTION 310403, ACTIVE 0 sec UPDATE account SET balance = balance + 100 WHERE id = 2 *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 5 page no 4... *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 5 page no 3... *** WE ROLL BACK TRANSACTION (2)从这个日志能很清楚看到:事务1持有page 3的锁,等待page 4;事务2持有page 4的锁,等待page 3。这里的page编号可能看不清楚具体是哪些行,你可以把row信息展开看主键值,但大多数场景下,看SQL的where条件基本就能猜个八九不离十。
如果你的死锁日志里出现的是LATEST DETECTED DEADLOCK为空的提示,说明你抓得不够快,或者当前实例从来没过死锁。这时候可以等下次发生后再看,或者用下面的方法主动监控。
3.2 除了死锁日志,还能从哪里看锁等待
有时候死锁发生得太快,SHOW ENGINE INNODB STATUS里可能只能看到最近一次记录;如果你想看全量的锁等待信息,可以用performance_schema系统库。MySQL 8.0里有几张现成的表:
performance_schema.data_locks:当前所有事务持有哪些锁、在等哪些锁。performance_schema.data_lock_waits:锁等待关系,可以看到谁在等谁。
可以写一条联合查询:
SELECT lw.PID, lw.WAITING_PID, lw.BLOCKING_PID, dl.LOCK_TYPE, dl.LOCK_MODE, dl.LOCK_STATUS FROM performance_schema.data_lock_waits lw JOIN performance_schema.data_locks dl ON lw.WAITING_PID = dl.THREAD_ID;不过这些表从MySQL 8.0开始才比较完整,5.7里可以用information_schema.innodb_lock_waits替代。实际排查时,优先看死锁日志,看完再去performance_schema补细节,大多数死锁都能定位到具体SQL,甚至具体行。
这里提醒一点:死锁日志默认只保留最近一次,如果线上死锁很频繁,建议把参数innodb_print_all_deadlocks设置为ON,这样所有死锁信息都会记录到MySQL错误日志中。这个参数是可以在线修改的,执行SET GLOBAL innodb_print_all_deadlocks = ON;就行,不过要注意你的日志级别和日志路径能存得下这些内容,否则死锁频繁的时候,错误日志会被刷得很大。
4. 解决与预防死锁的通用套路:从应用到SQL全链路
4.1 让事务变短、锁变少、等待变短
死锁本质上和锁持有时间、锁范围强相关。锁持有时间越长,另一个事务遇到锁等待的概率越大;锁范围越大,形成交叉的节点就越多。所以第一条经验就是:事务里不要塞无关操作,尤其不要在里面做远程调用、网络请求、用户交互、批量计算。这些操作耗时不可控,锁会一直被攥在手里。
我之前见过一个同事把“查询微信支付状态”的HTTP请求放在了事务里,结果一个update就把数据库锁住了一小段时间,其他业务并发一上来,隔三差五就死锁。后来把事务拆成两步:先本地更新,再回调通知,死锁基本绝迹。这不是什么高深技巧,就是让事务短一点,让锁早点释放。
减少锁范围的最好手段是索引。如果你的update语句更新了全表,相当于给所有行都加了锁,这跟锁表没什么区别。检查一下where条件是否走索引,explain里type是否为eq_ref或ref,如果不是,就要优化索引。记住:行锁只有在索引生效时才是真正的行级锁,否则会升级为更粗粒度的锁,死锁和并发阻塞的概率都会成倍上升。
4.2 用固定的访问顺序给事务“排队”
这是最简单也最有效的预防手段。在业务允许的情况下,让所有并发事务按同样的顺序访问表或行。比如前面说的账户转账,就按账户id排序后操作,或者按主键顺序操作。这个办法的原理很直接:死锁需要循环等待,而打破循环,死锁就无从谈起。
但要注意,固定顺序不只是“在SQL里排序”,而是代码调用的顺序也要一致。比如一个方法里先更新订单再更新用户,另一个方法里先更新用户再更新订单,哪怕SQL都走索引,照样死锁。所以团队里最好有统一的约定,或者封装统一的服务层,别让每个开发按自己的习惯写。我们团队现在有一条硬性规定:一个事务里涉及多个资源时,必须在方法外层做一次资源排序。
4.3 隔离级别的选择:READ COMMITTED能减少死锁?
MySQL默认的隔离级别是REPEATABLE READ,它会产生间隙锁,用来防止幻读,但也带来了一些独特的死锁场景。如果你把隔离级别调整成READ COMMITTED,InnoDB会放弃大部分间隙锁,只在唯一索引和外键场景下保留必要的锁,死锁概率会明显降低。这在很多高并发系统里都是一个有效的优化方向。
但这个方案是个取舍:READ COMMITTED下,同一个事务两次读取相同条件的数据,可能读到不同的结果(不可重复读),如果你的业务对一致性要求极高,就不能随便改。另外,binlog格式也要配套调整,在主从复制里,binlog_format如果是STATEMENT,READ COMMITTED可能还会带来其他问题。所以这个方案要跟架构团队一起评估,不要为了消灭死锁引入新的坑。如果业务允许,优先尝试调整事务顺序,而不是直接改隔离级别。
4.4 死锁发生后的兜底:应用层重试
即使做了很多预防,死锁依然难以百分之百避免。所以生产代码里必须对1213这个错误码做容错。比较常见的做法是捕获死锁异常,然后重试整个事务,重试次数可以设3次,中间加一个随机小延时,避免所有线程同时重试再次死锁。
我之前在支付回调里就是用的这种方式:先捕获SQLException,判断SQLState是不是40001或错误码是否为1213,如果是,就重试该业务方法。注意重试时要保证事务边界清晰,通常是重新开启一个新的事务,而不是在同一事务里继续执行。如果你用的是Spring,可以在service方法上标记@Retryable等重试注解,但要认真配置重试条件,别把其他异常也一并重试了,否则网络抖动、参数错误这类问题也会被无限重试,反而放大故障。
5. 一些容易被忽略的死锁隐藏场景
5.1 唯一键冲突引发的“假死锁”
很多人以为死锁只发生在多行更新交叉的时候,其实插入操作也可能死锁,尤其是唯一键冲突时。InnoDB在插入一条数据时,如果发现唯一键冲突,会先对那条已存在的记录加一个S锁(共享锁),然后决定是把冲突转换为UPDATE还是报错,这个过程可能需要把S锁升级为X锁。两个并发事务如果各自插入对方需要的唯一键,就可能出现两个事务都持有S锁、同时想升级成X锁的情况。S锁之间不互斥,所以都能拿到;但X锁与S锁互斥,就出现了互相等待。
这种死锁最典型的症状是日志里显示两条INSERT语句,但你找不到任何UPDATE冲突。我之前在批量导入用户标签数据时踩过这个坑,最后是通过“先查询再插入,或者使用INSERT IGNORE / ON DUPLICATE KEY UPDATE”配合重试解决的。要注意,ON DUPLICATE KEY UPDATE也不是完全免疫死锁,在高并发下仍可能出现,所以还是先查询一次,减少冲突概率更稳妥。
5.2 间隙锁和插入意向锁的相爱相杀
REPEATABLE READ下,如果一个事务对某个范围加了间隙锁(比如WHERE id > 100 AND id < 200),另一个事务想在这个间隙里插入新记录,就会被插入意向锁挡住;这个插入的事务又可能持有一些记录锁。如果两个事务都覆盖了同一个间隙,并且各有各的操作顺序,就会形成新的死锁模式。
这类死锁通过日志看往往是INSERT和SELECT或UPDATE的混合。排查时要注意:间隙锁锁的不是具体某一行,而是一个开区间,所以日志里看到的lock mode X, gap或者insert intention字样,就说明问题在间隙锁。预防手段包括:调整隔离级别为READ COMMITTED,或者缩小事务内的查询范围,避免大范围的SELECT ... FOR UPDATE。如果你必须在REPEATABLE READ下做范围更新,那就要对可能涉及到的间隙有预判,控制并发度。
5.3 外键约束与批量操作的影响
表上有外键时,在修改父表、子表记录的过程中,InnoDB会自动对关联数据加锁。例如,更新父表的一行,子表中所有引用该行的记录都可能被加锁;反过来,在子表插入或更新外键字段时,也会对父表的相应行加S锁。如果业务里频繁操作这些关联数据,锁的交互会变得很复杂,死锁风险也随之增加。
还有批量操作,比如一条update语句更新几千行,即使你的业务逻辑没写错,但两个事务更新范围有部分重叠,也可能因为锁申请顺序不一致而死锁。所以批量操作要尽量降低单条SQL的影响行数,必要时分成多个小批次,批次之间加一点时间间隔。批量更新时如果可能,也按主键升序排列,避免行锁请求顺序交叉。这个细节在导出、清洗数据、热点活动时特别容易踩到。
6. 一次真实死锁的排查复盘:从日志到修复
6.1 现场现象
有一段时间我们的订单系统经常在活动高峰报死锁,错误的action基本都集中在“取消订单”和“支付成功”这两个接口上。看代码发现,取消订单会先改订单状态,再退库存;支付成功会先扣库存,再改订单状态。这又是一次典型的顺序问题。
我通过SHOW ENGINE INNODB STATUS抓到的死锁日志里,两个事务的SQL分别是:
- 事务A:
UPDATE order SET status = 'CANCELLED' WHERE order_id = 10086 - 事务B:
UPDATE inventory SET stock = stock - 1 WHERE sku_id = 888
继续往下看,发现事务A在等待sku_id=888的库存行锁,而事务B在等待order_id=10086的订单行锁。两个事务先各自锁了一行,然后互相等待对方的另一行。这种情况下,其实不需要看特别复杂的锁记录,只要还原两个事务的执行时间线,问题就已经浮现出来了。
6.2 排查思路
当时我没有急着改代码,而是先确认两个接口的调用顺序,画出事务执行时间线:
- 取消订单:T1锁order 10086,T1锁inventory 888,提交。
- 支付成功:T2锁inventory 888,T2锁order 10086,提交。
两个事务在第二行锁上必然有一个会等待,当并发足够高时,等待链直接闭合。这种场景只要把其中一个接口的顺序调整成和另一个一致,让所有并发都先操作库存或先操作订单,就能解决。
画时间线这个习惯我一直保留到现在。死锁日志能告诉你谁在等谁,但不会直接告诉你业务逻辑哪里写坏了。只有把代码里的操作顺序还原出来,你才能看到循环是怎么形成的。多数死锁其实在纸面上画一画,就一目了然。
6.3 修复手段和效果
我把支付成功的顺序改成先更新订单状态,再扣减库存,和取消订单保持一致。同时,在扣库存的更新语句上加了一个条件:WHERE stock >= 1,防止超卖,也让锁竞争更快结束。上线观察了一周,死锁日志再也没出现。
这个案例里最值得沉淀的经验是:死锁排查并不是上来就调参数,而是先还原业务操作顺序,再用日志验证。我也是踩了好几次坑才总结出这套方法论。遇到死锁别慌,先看错误码,再拉现场,再画线,基本上能把九成问题都定位出来。
7. 日常运维里的锁相关配置与监控补充
7.1 死锁和锁等待要分清楚
很多同学会把死锁和锁等待时间超时混在一起。锁等待指的是一个事务在等另一个事务释放锁,但另一个事务最终会提交或回滚,等待方等到超时(默认innodb_lock_wait_timeout是50秒)后报错1205。而死锁是InnoDB检测到循环等待后立刻回滚一个事务,报错1213。
判断方法很简单:看错误码。1205是Lock wait timeout exceeded,1213是Deadlock found。它们的处理策略也不一样:锁等待超时要重点排查是不是某个事务迟迟不提交,比如事务里做了远程调用、长查询、或者程序里忘了提交;死锁则要看事务并发访问顺序和索引使用情况。把这两类问题分开对待,排查效率会高很多。
7.2 监控和预防工具
生产环境建议做两道防线:第一是开启innodb_print_all_deadlocks=ON,把死锁信息打印到MySQL错误日志,方便事后回溯;第二是使用外部监控工具,比如基于performance_schema的Prometheus或其他监控平台,提前对锁等待时长、死锁次数打点告警。MySQL 8.0自带sys库,里面有innodb_lock_waits视图,可以定期查询是否有长时间未解决的锁等待。
这里分享一个我自己的习惯:每次上线涉及事务的功能前,先拿压测工具模拟并发跑一遍,故意把事务顺序写反,看死锁日志是否符合预期。如果发现死锁,我会优先调整SQL顺序而不是加索引,因为顺序问题解决起来更快,索引优化则需要更细致的验证。上线前能主动发现并解决死锁,总比线上亮红灯再救火好。
8. 从制造死锁到理解死锁
8.1 写一个简单的死锁复现脚本
死锁这东西,光看文档和日志总有一种隔靴搔痒的感觉。我建议你在开发环境主动复现一次。写一个简单的并发脚本,开两个线程,分别执行两个事务的代码,加一个同步栅栏让它们在同一时间点开跑,几秒钟就能复现。这个脚本很简单,却比任何理论讲解都直观。
实现思路是这样的:两个线程先各自START TRANSACTION,然后执行第一条update并暂停一下,确保两个事务都拿到了自己的第一把锁,再继续执行第二条update,这时死锁就会出现,其中一个线程会抛1213异常,另一个线程提交成功。我经常把这个脚本直接丢给团队里新来的同学跑一遍,让他们亲眼看看死锁是怎么回事,效果比讲十遍PPT都好。
8.2 提高排查效率的三个习惯
平时处理死锁,我给自己总结了三件事,每次排查前先问一遍:第一,报错的是1205还是1213;第二,死锁日志里两条SQL分别持有哪些锁、等哪些锁;第三,代码里这两个事务的执行顺序是不是不一致。如果三个问题都有了答案,直接就能定位到工具或SQL层面。
另外一个习惯是,每次修改完事务代码,我都会顺手跑到测试环境用并发压一下,看死锁日志有没有新增。因为有些死锁不是必现的,它需要特定并发时序,如果你只跑一次单测,往往发现不了。只有让并发量达到一定阈值,才会露出马脚。这也是为什么很多死锁都是在活动大促、秒杀场景里集中爆出来的原因。
处理死锁的次数多了,你会发现死锁不是“数据库的bug”,而是“并发访问秩序”的问题。绝大多数死锁都能通过固定访问顺序、缩小事务范围、优化索引这三板斧解决。如果三板斧解决不了,再看间隙锁、唯一键冲突、外键这类隐藏场景。我个人最常用的路径就是:先看错误码,再拉死锁现场,接着画事务执行顺序图,然后回到代码里看每一条SQL的加锁顺序。这套流程我用了好几年,基本能在半小时内定位到问题。
最后说点真话:死锁是消灭不干净的,只要并发存在,理论上就一定有死锁的可能。我们要做的不是追求零死锁,而是让死锁频率低到可以接受,同时在它发生时能快速恢复、不影响用户体验。每次经历过这些之后,我对事务的理解都会更深一层,这种感觉,比单纯背概念舒服多了。希望这些经验能让你也少踩几个坑。