ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MySQL死锁排查与预防:从原理到实战的完整指南

MySQL死锁排查与预防:从原理到实战的完整指南 很多年前第一次看到线上日志里出现“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 一个三天就能复现的案例假设我们有一张用户账户表里面有两个人id1的余额是100id2的余额是200。业务上有两个动作事务A在做转账先扣id1的钱再给id2加钱。 事务B也在做转账但方向相反先扣id2的钱再给id1加钱。当两个事务同时执行就会出现交叉事务A持有id1的行锁等待id2的行锁事务B持有id2的行锁等待id1的行锁。这就是最经典的循环等待。用SQL模拟一下-- 事务A START TRANSACTION; UPDATE account SET balance balance - 100 WHERE id 1; -- 此时事务A持有id1的锁 UPDATE account SET balance balance 100 WHERE id 2; -- 等待事务B释放id2的锁 COMMIT;-- 事务B START TRANSACTION; UPDATE account SET balance balance - 100 WHERE id 2; -- 此时事务B持有id2的锁 UPDATE account SET balance balance 100 WHERE id 1; -- 等待事务A释放id1的锁 COMMIT;只要并发到达两个事务各自执行完第一条update后死锁就出现了。InnoDB会立刻检测到然后把其中一个事务回滚另一个事务才能继续。所以程序里如果你没有捕获1213异常并重试你的接口就会直接报错用户看到的就是操作失败。实际业务里这种场景非常多不只是转账比如下单时扣库存和更新订单状态、取消订单时回库存和更新订单状态一不小心就会形成这种交叉更新。很多人一开始觉得死锁离自己很远其实只需要两个接口写得不规范就可能在某一波流量高峰集中爆发。2.2 为什么调整SQL顺序就解决了很多人以为死锁是必须上很高深的技术才能解决的其实很多情况下只是执行顺序的问题。如果两个事务都先操作id小的那个再操作id大的那个锁的申请顺序就一样了不会形成等待环。比如约定所有转账都先更新id较小的账户再更新id较大的账户那么两个事务都会先锁id1再锁id2最多是排队等待而不是互相等。这个方案简单有效但要注意一点约定必须覆盖所有业务路径。如果代码里哪条路径没按这个约定来死锁就会换个姿势回来。所以更稳妥的做法是在代码层面统一封装一个“按账户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 COMMITTEDInnoDB会放弃大部分间隙锁只在唯一索引和外键场景下保留必要的锁死锁概率会明显降低。这在很多高并发系统里都是一个有效的优化方向。但这个方案是个取舍READ COMMITTED下同一个事务两次读取相同条件的数据可能读到不同的结果不可重复读如果你的业务对一致性要求极高就不能随便改。另外binlog格式也要配套调整在主从复制里binlog_format如果是STATEMENTREAD 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分别是事务AUPDATE order SET status CANCELLED WHERE order_id 10086事务BUPDATE inventory SET stock stock - 1 WHERE sku_id 888继续往下看发现事务A在等待sku_id888的库存行锁而事务B在等待order_id10086的订单行锁。两个事务先各自锁了一行然后互相等待对方的另一行。这种情况下其实不需要看特别复杂的锁记录只要还原两个事务的执行时间线问题就已经浮现出来了。6.2 排查思路当时我没有急着改代码而是先确认两个接口的调用顺序画出事务执行时间线取消订单T1锁order 10086T1锁inventory 888提交。支付成功T2锁inventory 888T2锁order 10086提交。两个事务在第二行锁上必然有一个会等待当并发足够高时等待链直接闭合。这种场景只要把其中一个接口的顺序调整成和另一个一致让所有并发都先操作库存或先操作订单就能解决。画时间线这个习惯我一直保留到现在。死锁日志能告诉你谁在等谁但不会直接告诉你业务逻辑哪里写坏了。只有把代码里的操作顺序还原出来你才能看到循环是怎么形成的。多数死锁其实在纸面上画一画就一目了然。6.3 修复手段和效果我把支付成功的顺序改成先更新订单状态再扣减库存和取消订单保持一致。同时在扣库存的更新语句上加了一个条件WHERE stock 1防止超卖也让锁竞争更快结束。上线观察了一周死锁日志再也没出现。这个案例里最值得沉淀的经验是死锁排查并不是上来就调参数而是先还原业务操作顺序再用日志验证。我也是踩了好几次坑才总结出这套方法论。遇到死锁别慌先看错误码再拉现场再画线基本上能把九成问题都定位出来。7. 日常运维里的锁相关配置与监控补充7.1 死锁和锁等待要分清楚很多同学会把死锁和锁等待时间超时混在一起。锁等待指的是一个事务在等另一个事务释放锁但另一个事务最终会提交或回滚等待方等到超时默认innodb_lock_wait_timeout是50秒后报错1205。而死锁是InnoDB检测到循环等待后立刻回滚一个事务报错1213。判断方法很简单看错误码。1205是Lock wait timeout exceeded1213是Deadlock found。它们的处理策略也不一样锁等待超时要重点排查是不是某个事务迟迟不提交比如事务里做了远程调用、长查询、或者程序里忘了提交死锁则要看事务并发访问顺序和索引使用情况。把这两类问题分开对待排查效率会高很多。7.2 监控和预防工具生产环境建议做两道防线第一是开启innodb_print_all_deadlocksON把死锁信息打印到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的加锁顺序。这套流程我用了好几年基本能在半小时内定位到问题。最后说点真话死锁是消灭不干净的只要并发存在理论上就一定有死锁的可能。我们要做的不是追求零死锁而是让死锁频率低到可以接受同时在它发生时能快速恢复、不影响用户体验。每次经历过这些之后我对事务的理解都会更深一层这种感觉比单纯背概念舒服多了。希望这些经验能让你也少踩几个坑。
返回列表