MySQL死锁排查指南:从原理到实战,一次讲透InnoDB锁机制与预防策略

MySQL死锁排查指南:从原理到实战,一次讲透InnoDB锁机制与预防策略 凌晨一点业务群里突然炸开一张截图Deadlock found when trying to get lock; try restarting transaction。旁边还有一句运营的追问“线上怎么又报错了”如果你经历过这种场景应该能体会那种头皮发麻的感觉。MySQL死锁并不是什么罕见的疑难杂症恰恰相反它在高并发业务里几乎是必然出现的“日常事故”。但正因为常见很多人反而处理得特别随意——把错误日志发群里问一圈或者干脆让代码自动重试一次下次遇到接着懵。这篇文章我打算从一次真实死锁的发现、定位、修复和预防讲起把InnoDB锁机制、死锁日志的读法、system view的用法、以及我在生产环境里踩过的坑全部串起来。内容主要面向需要自己排查线上问题的后端开发、DBA和运维同学也适合准备Java后端面试时被问到“线上数据库怎么避免死锁”的应试者。看完之后你至少能独立完成一次死锁分析并且知道该从哪里下手去预防。1. 死锁的本质先搞清楚InnoDB为什么会“卡死”1.1 死锁到底是怎么发生的以转账场景做一次现场还原死锁这个概念操作系统课程里会讲数据库面试里也会问但真正在MySQL里见过的同学往往不多。其实用一句话概括就是两个或多个事务互相持有对方需要的锁资源彼此都在等对方释放结果谁都没法继续往下走。我习惯用一个转账场景来模拟这个过程。假设有一张账户表t_account两个事务分别做转账事务A从账户1转出1000转入账户2事务B从账户2转出1000转入账户1如果两个事务严格按顺序执行一点问题都没有。但线上是并发执行的可能出现这种交错序列事务A执行第一条UPDATE锁住了账户1的行事务B执行第一条UPDATE锁住了账户2的行事务A接着执行第二条UPDATE想要锁账户2发现账户2已经被事务B锁住了于是它进入等待状态事务B接着执行第二条UPDATE想要锁账户1发现账户1已经被事务A锁住了于是它也进入等待状态走到这里事务A等事务B释放账户2事务B等事务A释放账户1两边谁也不肯先放手。这就是死锁的现场还原。死锁的成立通常依赖于操作系统教材里说的四个必要条件互斥、持有并等待、不可剥夺、循环等待。在MySQL的InnoDB引擎里互斥和不可剥夺是锁机制自带的天性开发者基本上改变不了能主动控制的是“持有并等待”和“循环等待”这两条。所以我们后面聊的所有预防方案本质上都是在打破“持有并等待”或者“循环等待”这两条路径。InnoDB对死锁的处理也很有意思它默认开启死锁检测每次加锁排队都会检查是否存在循环等待一旦发现死锁就立刻把其中一个事务整个回滚掉让另一个事务继续执行。MySQL返回给客户端的错误码是1213对应的SQLSTATE是40001。注意“死锁检测把其中一个事务回滚了”是因为它判定“必须牺牲一个才能解开局面”这和单纯的锁等待超时是两个完全不同的机制。1.2 InnoDB的锁类型与兼容规则X锁、S锁、意向锁要真正看懂死锁日志必须先把InnoDB的锁类型和兼容规则弄清楚。InnoDB的锁可以按照两个维度去分一个是从读写上区分一个是从粒度上区分。从读写角度InnoDB提供了两种基本行锁共享锁S锁读的时候加多个事务可以同时持有同一行的S锁谁都能读但谁都不能写。排他锁X锁写的时候加持有X锁的事务可以读也可以写其他事务既不能读也不能写。这两种锁的兼容关系很简单S锁和S锁兼容S锁和X锁不兼容X锁和任何锁都不兼容。你可以把它类比成教室座位S锁是“占了个座位看书”别人还能坐同一张桌子看自己的书X锁是“这个位置我要用白板写写画画”其他人只能站着等。从锁粒度上InnoDB在表级别还有意向锁Intention Lock。意向锁本身不是直接锁住某一行数据它更像是“我准备在这个表里的某个行加锁”的声明。其中意向共享锁IS锁表示事务准备对某些行加S锁意向排他锁IX锁表示事务准备对某些行加X锁。InnoDB在加行锁之前会先在表上加上对应的意向锁。意向锁与意向锁之间互不冲突但意向锁与表级锁之间是有兼容性规则的。下面的矩阵是面试里很容易考到的锁类型XSIXISX不兼容不兼容不兼容不兼容S不兼容兼容不兼容兼容IX不兼容不兼容兼容兼容IS不兼容兼容兼容兼容这张表记忆起来有个技巧只要看到X几乎就等于“压倒性不兼容”因为排他锁不允许任何其他锁同时存在剩下的格子基本遵循“相同性质才兼容”的直觉。光知道行锁和表锁还不够。InnoDB在可重复读REPEATABLE READ隔离级别下范围查询会额外使用间隙锁Gap Lock和Next-Key Lock。间隙锁锁的是一个区间而不是具体某一行Next-Key Lock则是行锁和间隙锁的组合锁住的是“从某个键值到下一个键值之间的左开右闭区间”。间隙锁存在的意义是解决幻读但也正因为有了间隙锁死锁的可能性被放大了——很多看起来很普通的范围更新互相覆盖的间隙就会造成等待链。2. 现场排查三板斧日志、状态表、进程列表2.1 第一板斧快速拿到死锁现场排查死锁的第一步是先拿到现场。MySQL里最常见的查看死锁信息命令是SHOW ENGINE INNODB STATUS\G注意这个命令的输出内容非常多其中有一节叫LATEST DETECTED DEADLOCK这里记录了最近一次死锁的完整信息包括两个事务各自的SQL、持有锁情况、等待锁情况以及InnoDB最终选择了回滚哪个事务。但这里有个很麻烦的问题SHOW ENGINE INNODB STATUS只保留最近一次死锁的信息。如果线上死锁发生得频繁你跑到现场的时候看到的可能已经是五分钟前另一次死锁的记录甚至被后续其他事务的信息覆盖掉了。所以我的建议是在排查之前先把参数innodb_print_all_deadlocks打开。这个参数默认值是OFF改成ON之后每次死锁都会直接打印到MySQL的错误日志文件里不会再被覆盖。SET GLOBAL innodb_print_all_deadlocks ON;这个参数是动态的不需要重启MySQL在线就能生效。错误日志的路径可以通过SHOW VARIABLES LIKE log_error;查看。实测在有死锁预警的环境里这个参数几乎是必开的它能帮你把死锁发生的时序还原出来而不是只看到最后一次。拿到错误日志之后建议立刻做两件事一是把死锁发生前后的业务日志、慢SQL日志一起拉出来二是在information_schema里查一下当时的活跃事务和锁等待情况。虽然事后查询可能拿不到历史数据但如果死锁当前仍在持续这些视图能给出现场快照。2.2 第二板斧逐段解析死锁日志很多人打开死锁日志时一脸懵因为格式确实不算友好。我来做个逐段拆解。假设你执行SHOW ENGINE INNODB STATUS\G后在LATEST DETECTED DEADLOCK一节看到类似下面的内容*** (1) TRANSACTION: TRANSACTION 28329, ACTIVE 1 sec mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 11, OS thread handle 13977, query id 133 localhost 127.0.0.1 root /* ApplicationNameDBeaver */ UPDATE t_account SET balance balance 1000 WHERE id 2 *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 3 page no 5 n bits 72 index PRIMARY of table test.t_account ... *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 3 page no 6 n bits 72 index PRIMARY of table test.t_account ... *** (2) TRANSACTION: TRANSACTION 28330, ACTIVE 1 sec mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 12, OS thread handle 13978, query id 134 localhost 127.0.0.1 root /* ApplicationNameDBeaver */ UPDATE t_account SET balance balance 1000 WHERE id 1 *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 3 page no 6 n bits 72 index PRIMARY of table test.t_account ... *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 3 page no 5 n bits 72 index PRIMARY of table test.t_account ... *** WE ROLL BACK TRANSACTION (2)看上去很长但我们需要抓的核心信息就四个。第一看TRANSACTION后面的ID。28329和28330分别是两个事务的ID后续如果要在information_schema里继续追踪这个ID就是关联线索。第二看每个事务对应的SQL。日志里会直接打印出正在执行的SQL语句。比如这个例子事务1执行的SQL是UPDATE t_account SET balance balance 1000 WHERE id 2事务2执行的SQL是UPDATE t_account SET balance balance 1000 WHERE id 1。结合业务代码马上就能知道这两个操作分别来自哪个服务、哪个接口。第三看HOLDS THE LOCK(S)和WAITING FOR THIS LOCK TO BE GRANTED两段。HOLDS THE LOCK(S)表示这个事务已经拿到了哪些锁WAITING FOR ...表示它在等哪一行锁。这两个小节下面会注明锁的索引类型和空间范围。比如space id 3 page no 5配合index PRIMARY说明锁落在主键索引的某个页上。第四看最后一行WE ROLL BACK TRANSACTION (2)。这一行告诉你InnoDB最后牺牲了哪个事务。在这个例子里被回滚的是事务2。知道这个信息之后可以回看业务日志确认是否有部分请求因为死锁报错而需要重试。日志读完之后整个链条基本就清晰了事务1持有了账户1的行锁想要账户2事务2持有了账户2的行锁想要账户1。两边互等InnoDB选择回滚事务2让事务1继续。2.3 第三板斧结合系统表与进程列表还原全链路死锁日志本身能解决“哪两个事务互锁”的问题但往往还不能回答“是哪些业务代码引发的”。如果业务系统做了分库分表或者连接池复用同一张表可能被很多接口操作这时就需要用系统表来还原完整链路。MySQL的information_schema里有一张很关键的视图叫INNODB_TRX存的是当前正在运行的事务。你可以用事务ID去关联SELECT * FROM information_schema.INNODB_TRX\G这里重点关注trx_id、trx_mysql_thread_id、trx_query、trx_started、trx_rows_locked这几个字段。trx_mysql_thread_id可以直接对应到SHOW PROCESSLIST里的Id再用这个Id查当前连接正在执行的SQL就能定位到代码入口。如果死锁事故还在持续sys.innodb_lock_waits这个视图也很有用SELECT * FROM sys.innodb_lock_waits\G它能列出当前所有锁等待关系包括等待的事务、被等待的事务、等待的锁类型、等待时长。当死锁不只涉及两个事务而是形成了更复杂的等待链时这个视图比一份日志更直观。性能相关的辅助排查也可以补上慢查询日志。开启慢查询并记录没有使用索引的语句SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; SET GLOBAL log_queries_not_using_indexes ON;长时间运行的大事务、全表扫描的UPDATE语句很容易出现在慢日志里这些往往是死锁的温床。把死锁日志、活跃事务视图、慢查询日志三份数据交叉对比基本就能把一次线上死锁从“现象”还原到“代码行”了。3. 三类高发死锁场景的成因与修复3.1 场景一交换更新顺序引发的循环等待这应该是死锁里最典型的教科书案例。两个事务操作同一批数据但加锁顺序相反形成了循环等待。前面转账场景就是最标准的例子。实际业务里这样的问题比想象的普遍常见于订单改状态、账户加减积分、库存扣减这类涉及“先操作A再操作B”的接口。比如有一个接口是“订单支付成功给买家加积分给卖家扣平台代金券”代码里先更新订单表再更新账户表。如果另一个接口是“订单退款卖家账户加回代金券买家账户扣减积分”那么两个接口同时跑的时候就可能出现一个事务先锁买家账户、另一个事务先锁卖家账户的交叉等待。这种问题的修复方案很直接让所有事务都按相同的顺序加锁。定一个全局的“加锁顺序规范”比如“固定先更新买家人相关的表再更新卖家人相关的表”或者按表名字母序、按主键ID升序总之要保证所有访问路径一致性。我在实践里还有一个额外建议对于账户、库存这类高频热点数据把“更新操作”做一次排序再执行。比如Java里可以用Stream把要更新的ID排序后再逐个处理。这样做的好处是彻底消除“代码路径不同导致加锁顺序不同”的可能代价只是排序的CPU开销几乎可以忽略。3.2 场景二范围批量更新引发的间隙锁冲突比单行锁交叉更隐蔽的是批量更新引发的死锁。在可重复读隔离级别下InnoDB对范围查询会加间隙锁或Next-Key Lock这本身是为了防止幻读但也导致两个范围并不完全重合的更新操作互相锁住对方的“间隙”。举个实际例子。电商后台有定时任务批量更新订单状态UPDATE orders SET status shipped WHERE status pending;另一个并发任务在按订单ID更新某些字段UPDATE orders SET remark vip WHERE id IN (1001, 1002, 1003);虽然这两个UPDATE目标看起来不相关但第一个UPDATE会锁定所有status为pending的订单行及其间隙第二个UPDATE需要锁定的行如果恰好落在第一个UPDATE覆盖的区间里就可能要等待。如果两个任务的加锁顺序有交错死锁就出现了。这种问题的应对思路比单行锁场景复杂一些。最常见的手段是给批量任务加“分片处理”逻辑不要一个事务更新太大数据量每次只处理一定数量的主键ID处理完一批提交一批避免锁范围覆盖过大。另一种思路是把批量任务串行化通过分布式锁或者MQ消费队列保证同一时刻只有一个任务在跑。如果这些方案都改不动还可以考虑把隔离级别从REPEATABLE READ降到READ COMMITTED可以显著减少间隙锁导致死锁的可能。但是隔离级别是全局配置改动影响面很大需要DBA牵头评估不能只为了一个场景就拍板切换。3.3 场景三唯一键冲突引发的“隐蔽死锁”唯一键冲突死锁是新手最容易忽略的因为表面SQL看起来是在“INSERT”而你一般不会把插入操作和死锁联系到一起。但在高并发插入相同唯一键的场景下死锁特别容易出现。InnoDB在插入数据前需要做唯一性检查。如果两条并发事务同时插入相同的唯一键比如相同用户名注册、相同订单号落库它们都会先尝试加一个S锁用于检查发现唯一键已存在之后S锁需要升级为X锁再去更新或报错这个过程就可能形成循环等待事务A持有S锁等待X锁事务B也持有S锁等待X锁两边互相等。典型SQL就像这样INSERT INTO t_user (id, mobile) VALUES (1001, 13800138000) ON DUPLICATE KEY UPDATE mobile VALUES(mobile);两个事务同时执行参数相同就可能触发死锁。日志里通常能看到两个事务的SQL几乎一模一样这也是判断“唯一键冲突死锁”的关键特征。解决思路有几个方向。第一个思路是尽量使用INSERT IGNORE或先查后插减少重复键冲突的概率第二个思路是在业务层面对关键唯一键做去重保护比如注册场景加分布式锁或幂等表第三个思路是用REPLACE INTO替代ON DUPLICATE KEY UPDATE但要注意REPLACE是先删后插可能带来其他副作用。优先推荐前两种因为它们不改变数据语义风险更可控。4. 预防死锁的落地手段从表设计到事务编排4.1 索引与锁粒度让锁尽量小、尽量准死锁的预防从来不是只在“出事之后调参数”更重要的战场在表结构和索引设计阶段。锁的粒度直接由语句命中的索引决定Explain分析里如果出现了ALL类型的扫描说明这条UPDATE语句很可能把整张表的所有行锁都扫了一遍。锁的范围越广死锁的概率就指数上升。所以第一条预防原则是所有UPDATE和DELETE语句的WHERE条件必须能命中索引。为高频过滤字段建立合适的二级索引尽量把锁锁定在少数几行上。如果一张表经常按status批量扫描但status区分度又特别低可以改写为“先通过主键查出来需要更新的主键ID列表再按ID逐条更新”从根上避免全表扫描。第二条原则是尽量用主键聚簇索引做点查。点查只锁一行是最理想的加锁方式。即使一个事务要更新多行也要保证这些行来自同一个索引范围且能提前排序减少不同事务之间加锁顺序冲突的可能。还有一点容易被忽视给索引字段的冗余设计留好空间。有些表为了省空间把字段类型设置得很短导致维护成本高、索引区分度低反而在更新数据时扩大了锁范围。这个权衡需要业务经验来把握但在设计评审阶段多花五分钟看索引往往能省掉后面无数个深夜。4.2 事务编排统一加锁顺序并缩短持有时间就算索引建得再好事务编排出了问题死锁照样防不住。我在生产环境里见到的多数死锁其实都跟“一个事务里做的事情太多”有关。很多后端同学会把事务当成万能工具在一个事务里同时更新订单、扣库存、加积分、记录日志最后还要调一次远程服务。锁的持有时间被拉得很长另一个事务进来等待的概率自然就高。事务编排上我总结了几个比较实用的经验。第一能拆的小事务绝不合并。每个事务只围绕一个业务用例做必要的数据变更明确列出“必须在一个事务里的操作”和“可以放到事务外的操作”。日志、通知、消息发送这些尽量放到事务提交之后不要占用连接和锁资源。第二所有事务内使用的数据都按固定顺序去访问。你可以把它理解成数据库层面的“交通规则”所有人都从左往右走才不会堵在路口。建立一张“表访问顺序表”任何新功能上线前对照检查。第三事务内不要做远程调用。这个错误我见过太多次了。事务未提交时调用外部接口外部接口响应超时连接一直不返回锁一直不释放其他请求全部堵住最终引发连锁死锁甚至连接池耗尽。远程调用必须挪到事务提交之后执行。第四控制单个事务的执行时间。线上测量下来一个普通事务从begin到commit在100毫秒内是比较健康的。超过1秒的事务极有可能已经锁了比较多的资源需要重点排查慢查询或者是不是在事务里做了不该做的事。4.3 参数与监控该开的开、该调的调、别乱关排查预防都做完了还可以通过参数把“死锁的影响面”压到最小。这里我按重要程度排序说几个高频参数。innodb_print_all_deadlocks必须打开这个前面已经强调过不再赘述。它不直接影响死锁会不会发生但决定了你下回能不能快速定位问题。innodb_lock_wait_timeout控制的是普通锁等待超时时间默认是50秒。如果业务请求本身对响应时间要求很高比如网关超时设置是3秒那么50秒的锁等待显然过长。很多团队会把它调成5秒甚至3秒。但要注意这个参数是把双刃剑调太小会让正常的锁等待也被误杀调太大又会让请求长时间挂起。一般来说建议结合业务接口的P99耗时间去设定不要拍脑袋。innodb_deadlock_detect默认是ON一般不建议关闭。关闭死锁检测后InnoDB不再主动检测循环等待死锁会退化成普通的锁等待最终由innodb_lock_wait_timeout兜底。也就是说原本能秒回一个1213错误的死锁关闭检测后可能变成一个几十秒才返回的锁等待超时对用户体验的伤害更大。只有在极高并发场景、死锁检测本身成为瓶颈、且业务完全能容忍锁等待超时的前提下才考虑关闭。监控告警层面推荐在错误日志里持续采集死锁关键字配合Zabbix、Prometheus或者云厂商的日志服务做告警。也可以使用Percona Toolkit里的pt-deadlock-logger它能把死锁日志解析成结构化字段输出长期观察哪些表、哪些SQL是死锁高发点。有了数据预防才能精准发力。5. 常见问题速查表与踩坑实录5.1 死锁相关高频问题速查表日常帮同事排查死锁时大家问得最多的问题基本可以汇总成下面这张速查表先按场景对照着查能少走很多弯路。现象可能原因快速确认方式处理建议两个事务互相等对方持有的行锁加锁顺序不一致死锁日志中HOLDS和WAITING对应不同行全局统一加锁顺序批量UPDATE任务并发时频繁死锁间隙锁冲突日志中出现GAP锁分批处理、串行化任务、评估RR隔离级别INSERT语句也报死锁唯一键冲突日志中两个事务SQL相同均有S锁等待X锁应用层去重、幂等表、INSERT IGNORE死锁日志只有最近一次未开启全量打印SHOW VARIABLES LIKE innodb_print_all_deadlocksSET GLOBAL innodb_print_all_deadlocks ON锁等待超时和死锁同时出现长事务持锁过久结合慢日志定位长时间事务拆分事务、远程调用移出事务同样的SQL手动执行正常线上死锁并发量触发检查并发测试结果从业务入口做并发控制这张表不是万能的但能帮你在脑子里形成一个基本方向。遇到一个死锁报错先判断它属于“顺序问题”还是“范围锁问题”还是“唯一键问题”再决定往哪个方向深挖。5.2 我踩过的坑与几条实在建议第一别一上来就关死锁检测。我见过一个项目组为了提升吞吐直接把innodb_deadlock_detect关掉了结果线上原本几毫秒就能报错的死锁全部变成几十秒的锁等待堆积的请求把连接池拖垮最后花了两个小时才恢复。死锁检测本身是保护机制关掉它的代价远比想象中高。第二事务里远程调用是死锁放大器。这个错误我在不同公司都见过不止一次。事务里调用外部接口外部接口又回查这个系统的数据库而回查的SQL恰好需要已经被锁住的行就形成了跨系统的隐式死锁。这类问题排查起来非常费劲与其事后痛苦不如在设计阶段就强制规定事务内不允许远程调用。第三重试机制别乱写。MySQL死锁错误码是1213SQLSTATE是40001。应用层可以针对这个错误码写重试但重试的前提是整个事务是做过的需要完整回滚后重新执行。如果只是在报错的那一条SQL上重试事务里前序操作可能已经造成数据不一致。重试逻辑必须是“事务级重试”不是“语句级重试”。第四监控一定要先于事故发生。很多团队是出了几次线上问题之后才想起把死锁日志接进告警平台而那时候已经被折腾了好几轮。真正靠谱的做法是上线第一天就把死锁指标接到监控大屏上哪怕频率是“一周一次”也要告警出来看一下防微杜渐比事后救火轻松得多。5.3 应用层补偿如何写一个靠谱的重试最后分享一个我常用的应用层补偿模板很多项目里我都会放这样一个工具方法。核心思路是捕获DeadlockLoserDataAccessException或者MySQL错误码1213后对整个事务做有限次重试。public T T executeWithDeadlockRetry(SupplierT businessOperation, int maxRetries) { int retryCount 0; while (true) { try { return businessOperation.get(); } catch (DeadlockLoserDataAccessException ex) { if (retryCount maxRetries) { throw ex; } // 退避一小段时间降低再次碰撞的概率 try { Thread.sleep(ThreadLocalRandom.current().nextLong(10, 50)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw ex; } } } }上面的写法用的是Spring的异常抽象如果项目用的是原生JDBC可以把异常判断改成检查SQLException的errorCode是否为1213或者SQLSTATE是否为40001。重试次数我一般建议不超过3次因为死锁检测本身是毫秒级的如果连续3次都还死锁说明大概率不是偶发而是程序结构有问题盲目重试只会掩盖真实故障。回滚策略也要提前约定事务内部任何异常都以RuntimeException向外抛让Spring或业务框架统一回滚。死锁发生后被牺牲事务里所有已经执行的UPDATE、INSERT都会回滚干净所以重试时整个事务重新走一遍是安全的。把重试放在事务边界之外千万别写在事务方法内部否则重试的每一次尝试都会把上一个未提交的半成品事务混进来。我在实际使用中还会把每次死锁重试都打印成一条WARN日志带上事务涉及的业务参数。因为死锁日志描述的是数据库内部状态而业务参数能告诉我们用户路径。两者结合下一次再做预防优化的时候就有据可依。死锁这件事光祈祷不发生没有意义真正有意义的是每次发生后都能快速“破案”并且让同类问题在后续版本里越来越少。