资讯动态

MySQL死锁排查与预防:从InnoDB锁机制到实战案例

发布时间:2026/10/22 10:59:52 来源:尧图企业网站定制
MySQL死锁这个话题做过几年后端的人多少都跟它打过照面。你大概率见过类似报错Deadlock found when trying to get lock; try restarting transaction。第一次在线上看到这个异常时我第一反应是业务代码写错了后来排查多了才发现死锁不一定是业务逻辑的锅但一定跟事务、索引和锁顺序有关。这篇文章我想把死锁的定位思路和预防经验一次性讲清楚适合正在被死锁困扰的开发也适合想系统了解InnoDB锁机制的后端同学。我会尽量少讲空泛理论多放能直接落地的排查手法和案例复盘。1. 先把死锁的老家底搞明白InnoDB到底在锁什么1.1 行锁、间隙锁和Next-Key Lock别被名词唬住InnoDB默认隔离级别是REPEATABLE READ在这个级别下锁的类型不只是行锁。很多人看到死锁日志里出现lock_mode X locks gap before rec就懵了其实这就是间隙锁。间隙锁锁的不是某一行而是两个索引记录之间的空隙目的是防止其他事务在这个空隙里插入新记录从而保证幻读不会出现。这里有个很容易忽略的细节间隙锁本身是相互兼容的。两个事务可以同时持有同一个间隙的间隙锁但如果其中一个事务想在间隙里插入记录就需要获取插入意向锁插入意向锁会跟已有的间隙锁冲突于是就开始等。这个等待关系一旦形成闭环死锁就出现了。很多死锁都是在这个环节发生的而不是经典教科书里说的那种“两个事务各拿一行锁然后互相要对方的行锁”。Next-Key Lock则是行锁和间隙锁的组合锁的是“当前记录前面的间隙”。比如你在REPEATABLE READ下执行SELECT ... WHERE id 100 FOR UPDATE即使有些记录不存在也会把索引区间内的间隙锁住。这意味着别人不能往这个区间插入数据更新也会受限。理解了这个基础后面分析死锁案例会顺很多。1.2 数据库场景里“死锁四条件”怎么翻译先看看操作系统课本里的死锁四必要条件互斥、持有并等待、不可剥夺、循环等待。放到MySQL里四个方面都对应得很具体互斥InnoDB的行锁、间隙锁本身是排他的一个事务锁住了记录另一个事务就不能再持有同一个锁。持有并等待事务A持有某行的X锁还想获取另一行的X锁但这一行被事务B持有着。不可剥夺InnoDB不会强行回收事务已经持有的锁除非事务提交、回滚或者被死锁检测机制判定为牺牲者后回滚。循环等待事务A等事务B的锁事务B又在等事务A的锁形成一个环。把这个四条件翻译成业务语言就是两条更新链路都在往同一个资源上加锁但加锁顺序恰好相反而且谁也等不到谁释放。比如订单支付流程里先更新订单主表再更新账户余额另一个流程先更新账户余额再更新订单主表高并发下撞在一起就是经典死锁。这里想强调一个容易被忽略的点死锁发生的概率跟事务持有的锁数量正相关。事务越大、更新的行数越多持有锁的时间越长撞车的概率就越高。所以很多人问“为什么小流量压测时不报错一上活动就死锁”答案往往不是SQL写错了而是并发度上来了锁竞争的被放大。1.3 为什么没走索引的更新最容易惹事死锁日志里经常能看到index PRIMARY或者某个二级索引的名字但如果执行计划是全表扫描InnoDB会先给所有记录加上锁然后一条条过滤。这时候死锁的严重性就变大了本来只应该锁一行结果整张表都进入了锁的范围。我记得之前排查过一个案例某业务方要对一批用户的消息状态做批量更新更新条件是WHERE user_name ?但这个字段没有索引同一时刻两个批处理任务各跑各的结果把整个表的记录锁了个七七八八稍微有点交集就开始互相卡。后来给user_name加了二级索引死锁报警立刻沉寂了。所以遇到死锁第一件事不是改事务隔离级别而是看执行计划。EXPLAIN里如果是全表扫描、type是ALL就算只是更新一行也会先锁大量记录再过滤这种场景不仅性能差死锁概率也直线上升。索引的意义在这里不仅是加速查询也是缩小锁粒度。2. 收到死锁报警该从哪里下手查2.1 先开日志别手动复现半天看不清死锁排查的第一步不是看代码是看现场。MySQL自己会在死锁发生时记录日志但默认开关不一定开着。建议在配置文件里显式设置innodb_print_all_deadlocks ON这个参数的作用是把所有死锁都记录到错误日志里而不是只记录最新的那条。否则你用SHOW ENGINE INNODB STATUS只能看到最近一次死锁的信息要复现第二次、第三次就只能靠运气。生产环境开这个参数代价很小因为死锁本来就不是高频事件日志量完全可控。另外如果业务用的是云数据库一般控制台都有慢日志、错误日志下载入口把错误日志拉下来搜deadlock或者LATEST DETECTED DEADLOCK关键字就行。这一步先把案发现场固定住再来谈分析。2.2 死锁日志长什么样怎么逐段读拿一段典型的死锁日志来拆解内容做了精简保留结构------------------------ LATEST DETECTED DEADLOCK ------------------------ 2025-01-10 14:32:18 0x7f8c1a2b3700 *** (1) TRANSACTION: TRANSACTION 823456, ACTIVE 12 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 3847392, query id 99183721 ... UPDATE coupon SET status1 WHERE user_id100 AND coupon_id1001 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 58 page no 123 n bits 72 index PRIMARY of table db_order.coupon trx id 823456 lock_mode X locks rec but not gap waiting *** (2) TRANSACTION: TRANSACTION 823460, ACTIVE 9 sec starting index read LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 3847410 ... UPDATE coupon_batch SET used_countused_count1 WHERE batch_id2001 *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 58 page no 130 n bits 72 index PRIMARY of table db_order.coupon_batch trx id 823460 lock_mode X locks rec but not gap waiting *** WE ROLL BACK TRANSACTION (2)读这段日志关键看三处第一处是TRANSACTION下面的SQL语句日志会打印出导致死锁事务最后执行的SQL。这里的SQL不一定是死锁开始时的第一条语句而是事务在死锁发生那一刻正在执行的语句。排查时需要结合事务上下文还原整个执行序列。第二处是WAITING FOR THIS LOCK TO BE GRANTED它告诉你这个事务正在等哪一把锁锁在哪个表的哪个索引上。这里看table和index就行比如上面日志中两个事务分别等待coupon表和coupon_batch表的PRIMARY索引锁。第三处是末尾的WE ROLL BACK TRANSACTION (2)表示InnoDB的死锁检测器选择了事务2作为牺牲者回滚了它。业务方看到异常的就是这个事务对应的代码会报Deadlock异常。理解这个机制很重要死锁发生时总有一个事务会被回滚不是两个都回滚开发只需要保证业务对报错的事务做重试。2.3 从锁对象反推业务代码定位到具体方法日志只能告诉你锁冲突发生在哪张表、哪个索引、哪条SQL上但不能直接告诉你是哪个代码方法发起的。我惯用的排查方式是这样的先看SQL把UPDATE、DELETE、SELECT ... FOR UPDATE这类语句在代码仓库里全局搜索找到所有可能执行到它的方法。这里有个小技巧日志里SQL的WHERE条件经常带着具体参数值直接用参数值反查业务记录回看这个时间点附近的操作日志能更快锁定是哪条业务链路。再结合时间点。死锁日志自带时间去应用日志里捞同一时间段内的异常调用链看线程ID在做什么配合链路追踪里的traceId很快就能对应到具体方法。我们团队曾经用这个方法三分钟就从日志定位到是“优惠券发放”和“优惠券核销”两个接口在互相抢锁。假如业务没有接入链路追踪也至少要保证应用日志里打了异常堆栈和当前事务的SQL参数。没有参数的死锁日志就像只有车牌没有监控画面的交通事故能看但要猜很久。3. 几个我看过的真实死锁场景复盘3.1 支付流水和批次库存互相等锁第一个案例是模拟项目X里的一个结算场景。系统里有settlement_order支付单表和batch_stock批次库存表。支付单审核通过后要更新订单状态同时扣减批次库存。简化后的事务逻辑是这样事务ABEGIN; UPDATE settlement_order SET status1 WHERE order_id1001; UPDATE batch_stock SET available_qtyavailable_qty-1 WHERE batch_id2001; COMMIT;事务BBEGIN; UPDATE batch_stock SET available_qtyavailable_qty-1 WHERE batch_id2001; UPDATE settlement_order SET status1 WHERE order_id1001; COMMIT;两个事务加锁顺序完全相反。当A锁了order_id1001B锁了batch_id2001之后A想继续锁batch_id2001B想继续锁order_id1001互相等待死锁成立。复盘这个案例时我们发现两个事务出现在不同服务里一个叫“订单审核服务”一个叫“库存扣减服务”它们都涉及两行数据但顺序没有统一约定。生产环境并发不高的时候很难复现一旦大促压测或者某个商品爆单死锁就频繁跳出来。解决办法也直白统一加锁顺序。所有涉及这两张表更新的业务都按“先订单表后批次表”的顺序获取锁。代码评审时把这个顺序写进规范后续新增需求照着执行就行。3.2 库存扣减里范围查询撞上间隙锁第二个案例更有迷惑性。某个抢购系统里商品库存表stock_sku按sku_id分组存库存表结构大致是这样CREATE TABLE stock_sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id BIGINT NOT NULL, qty INT NOT NULL, version INT NOT NULL, KEY idx_sku_id (sku_id) );业务侧扣减库存的逻辑是在事务里先查一下当前SKU的总可用量再决定是否扣减。由于并发高查询语句用上了FOR UPDATEBEGIN; SELECT SUM(qty) FROM stock_sku WHERE sku_id 12345 FOR UPDATE; -- 业务判断后执行扣减 UPDATE stock_sku SET qty qty - 1, version version 1 WHERE sku_id 12345 AND id 1008; COMMIT;问题就出在SELECT ... FOR UPDATE上了。在REPEATABLE READ隔离级别下这句查询锁住的不是一行而是idx_sku_id上sku_id12345对应的所有记录以及这个值前后可能存在的间隙。两个并发事务同时执行这条查询时如果记录区间存在交错就会各自持有部分间隙锁然后插入新的库存记录时需要获取插入意向锁又互相等待就死锁了。这类死锁的隐蔽之处在于从业务日志看两个事务执行的是完全一样的SQL没有加锁顺序问题可还是死锁。本质上是因为FOR UPDATE的范围查询把锁的范围从行扩大到了间隙。后期我们的处理方式不让库存扣减先做范围FOR UPDATE改成在事务内直接对确定的主键ID加锁必要时对SKU汇总信息单独建一行汇总记录来加锁。如果没有汇总记录就用SELECT ... FOR UPDATE只锁单行主键而不是锁整个sku_id区间。这个改动之后抢购场景的死锁就再没出现过。3.3 批量更新大事务锁等待滚成死锁第三个案例来自一个对账系统。某定时任务会扫描一批待处理的流水把它们的状态从0改成1同时插入一条对账明细。原来的实现是把5000条流水放在一个事务里循环更新每条流水更新之前还要查一次关联的账户余额。问题在于同一时刻有两个定时任务实例在跑扫描到的流水范围存在重叠。任务1先更新了流水1-1000任务2先更新了流水500-1500两边各持有一部分行锁随后双方继续往后更新时就产生了交叉等待。因为事务太大持有锁的时间非常长死锁检测器介入后其中一个任务直接回滚回滚代价很大还影响了别的事务。这类大事务批量更新的解法不复杂但需要下决心改代码一是拆分事务。把5000条流水按500条一批提交切分成多个小事务每个小事务独立提交行锁持有时间大幅缩短。二是按主键顺序更新。批量更新前先对ID排序保证所有任务实例都按同一个顺序处理流水从源头上消掉循环等待。三是失败重试机制。哪怕已经做了前两步还是建议在应用层对死锁异常做重试比如捕获异常后休眠100毫秒再重试最多重试3次。这个兜底策略几乎是必须的因为任何设计都无法100%保证在高并发下不死锁。4. 预防死锁的设计经验与配置取舍4.1 事务内统一加锁顺序收益最大我见过太多死锁案例的根因就是加锁顺序不统一。这个顺序问题在单体应用里还好控制一旦拆成微服务每个服务各写各的事务很容易出现同一个业务对象在不同服务里被以不同顺序更新。一个比较实用的约定所有事务内涉及多表更新时先按照表名字典序加锁。比如先更新account再更新order不管业务语义是先下订单还是先动账锁的获取顺序都按这个约定走。这样做虽然看起来有点“不自然”但能极大降低死锁概率。如果更新的是同一张表的多个行也建议按主键排序后批量更新。例如在事务内要更新多个销售订单不要按照业务处理顺序逐条更新先ORDER BY id排序再更新两个并发事务就基本不会因为更新顺序相反而死锁。4.2 索引、执行计划与SQL改写配合索引对死锁的影响主要体现在锁粒度上。一个好的执行计划能只锁必要的记录一个全表扫描的执行计划可能把全表都卷入锁竞争。日常SQL评审时我建议把EXPLAIN作为必查项尤其关注type列和key列。type出现ALL或者key为NULL时就要警惕锁范围扩大。SQL改写也是预防死锁的重要手段。举个例子有些更新语句写的是UPDATE table SET status1 WHERE create_time 2025-01-01如果create_time没有索引就会全表扫描。改成先查出主键ID列表再按ID批量更新锁范围就小得多。虽然多一条查询的开销但换来了更低的锁冲突概率。另外要避免在事务中做太耗时的操作。远程调用、外部接口、大量计算这些都应该放到事务外完成。事务只保留必要的数据库操作减少锁持有时间。锁持有时间缩短了死锁窗口期自然就窄了。4.3 隔离级别和死锁检测参数怎么权衡常见的调法是把隔离级别从REPEATABLE READ降到READ COMMITTED。在RC下InnoDB只保留行锁不再使用间隙锁幻读问题需要业务自己兜底。这确实能消灭一部分死锁尤其是间隙锁引发的死锁但要注意副作用如果业务强依赖RR下的间隙锁来防止并发插入降级后可能引入新的正确性问题。还有两个参数经常一起被讨论innodb_deadlock_detect和innodb_lock_wait_timeout。InnoDB默认开启了死锁检测发生死锁时会立刻回滚牺牲者。如果业务场景极其特殊——比如单机并发非常高、热点行更新频繁——死锁检测本身也会消耗性能。官方文档和很多实践都提到极端场景下可以关闭死锁检测让锁等待直到innodb_lock_wait_timeout超时再报错。但我个人不太建议在生产环境关掉死锁检测除非你能接受超时时间内所有锁等待都阻塞在那里并且有完善的监控报警兜底。常规业务保持默认就好了。innodb_lock_wait_timeout默认是50秒对OLTP业务来说有点长。很多团队会把它调到5秒甚至3秒这样即使死锁检测没及时介入锁等待也不会把线程池拖垮。这个参数要根据业务对延迟的容忍度来设置不是越小越好。5. 死锁排查速查表和避坑清单5.1 常见死锁场景速查表我把平时排查过程中比较容易碰到的死锁场景整理成了一个速查表方便你在收到报警时快速对照场景特征典型日志/现象常见根因优先处理思路两个事务更新不同表但顺序相反日志里两条SQL分属不同表互相等待PRIMARY锁加锁顺序不一致统一表内加锁顺序必要时按字典序相同SQL范围查询后互相等待日志里出现lock_mode X locks gap before rec间隙锁冲突缩小锁范围避免FOR UPDATE范围锁一条SQL更新大量行日志显示多个row lock事务耗时长大事务持有过多行锁拆批、排序、缩短事务时间单条更新没走索引执行计划typeALL或key为NULL全表扫描导致锁全表记录加索引改写SQL条件唯一键冲突日志出现insert intention等待插入意向锁冲突先查后插或改分布式锁避免重叠这张表只是排查起点最终还是要结合具体SQL和执行计划做判断。不过大多数死锁根因都能归到前面这几类里。5.2 避坑清单都是踩过之后的经验第一条不要靠SELECT SLEEP()去复现死锁。死锁需要精确的并发时序手工加sleep模拟出来的结果只能证明“这个姿势可能死锁”并不能证明线上就是这么触发的。更靠谱的方式是把线上死锁日志里的两条SQL原样提取出来用两个会话并发执行配合SHOW ENGINE INNODB STATUS观察锁等待。第二条不要一收到Deadlock异常就改隔离级别为READ UNCOMMITTED。这是最危险的调优方向因为这等于用正确性换性能。先定位是间隙锁问题再考虑RC而且RC下面也要重新评估业务的唯一性和幻读需求。第三条不要把try restart transaction当摆设。很多ORM框架和数据库驱动已经内置了死锁重试机制但有些框架默认关闭或者重试间隔不合理。建议在业务代码层面对“Deadlock”相关的异常做显式捕获和重试约定一个幂等键保证重试时不会重复处理。第四条排查死锁时多看一眼performance_schema.data_locks。如果系统是MySQL 8.0且当前死锁还处于等待状态直接查这张表能看到实时的锁持有和等待关系比翻历史日志更直观。不过要留意查询权限生产库一般需要单独授予权限。最后分享两个小技巧我自己在排查死锁时养成了两个习惯一个是把死锁日志里的SQL带上参数格式化后排到代码仓库全局搜索先找调用入口再沿调用链看事务范围另一个是每次大促前把核心链路的死锁日志打开提前压测。压测时宁可在测试环境多报几次死锁也不要等到线上大流量来了才懵。还有一个小细节如果用的是连接池尽量保证事务内的所有SQL走同一个连接不要在事务中间去获取新连接否则事务隔离性和锁的持有都会变得不可控。死锁这东西刚遇到时觉得玄学看多了就会发现它背后就是锁顺序、锁粒度、锁时间三件事的组合。把这三点掰开揉碎绝大多数死锁都能在几分钟内定位清楚。希望这篇文章能让你遇到死锁时少走点弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑