MySQL死锁排查与根治:从定位到预防的完整指南

MySQL死锁排查与根治:从定位到预防的完整指南 线上 MySQL 出现死锁最忌讳的回答就是“重启服务”。死锁不是 MySQL 崩溃也不是服务卡死。它是两个或多个事务在并发执行时各自持有一部分锁又在等待对方持有的锁最后谁都没法继续推进。InnoDB 检测到死锁后会自动挑选一个事务回滚释放锁让另一个事务继续执行。此时你重启服务并不会清理掉导致死锁的 SQL、事务边界和加锁顺序重启反而可能把最关键的现场日志丢掉。真正要做的是先定位是哪两个事务在互等再分析为什么加锁顺序会冲突最后从代码、SQL、索引和事务边界上把问题修掉。下面按一条完整链路来讲发现问题、核实现场、定位原因、代码修复、长期预防。这也是 MySQL 死锁问题从“会出现”到“可控制”的关键路径。1. 面试官问线上MySQL死锁最终想听什么1.1 核心答法不是“重启”而是“定位 分析 改造”如果这题出现在 Java 面试里面试官大概率不是只想要一个“重启服务”的操作。他更想看到你有没有处理线上故障的经验能不能把数据库锁、事务、日志、代码调用串起来。比较完整的回答可以拆成四步先确认是不是真的死锁而不是普通的锁等待。用 MySQL 提供的命令查看死锁日志找到发生互等的事务和 SQL。根据日志判断加锁顺序、事务边界和索引使用情况分析死锁是怎么形成的。给出处理方案当前怎么让业务恢复后续怎么通过代码和 SQL 改造规避。这四步说出来面试官基本上能判断你对 MySQL 有没有实战认知。只回答“重启”反而会让整段对话掉到“没遇到过”的猜测区。1.2 MySQL 死锁、线程死锁、锁等待和慢查询不要混在一起很多 Java 开发会把“线程死锁”和“MySQL 死锁”混着说。线程死锁是 JVM 层面的问题两个线程互相持有对方需要的锁对象MySQL 死锁是数据库层事务之间对行锁、间隙锁的竞争。两者名字像分析工具完全不同。还有一类容易混淆的是锁等待。锁等待不是死锁它是一个事务在等另一个事务释放锁只要对方提交或回滚等待就会结束。死锁是等待关系形成环系统已经无法通过“等对方释放”自然结束必须让一方回滚。慢查询也和死锁有关但因果关系不同。慢查询会让事务持有锁的时间变长锁等待变多死锁的概率也会上升。所以排查死锁时慢查询日志、错误日志要一起看不能只看“有没有死锁”四个字。2. 拿到死锁现场MySQL 提供了哪些命令2.1 第一条命令SHOW ENGINE INNODB STATUS线上 MySQL 出现死锁场景时很自然会搜“mysql查看死锁的命令”。最常用的就是下面这条SHOW ENGINE INNODB STATUS\G执行后输出内容很多重点看LATEST DETECTED DEADLOCK这一段。这里会记录最近一次死锁发生时两个事务分别执行了什么 SQL。每个事务已经持有哪个锁。每个事务正在等待哪个锁。哪个事务被 InnoDB 当作受害者回滚了。实际输出会因 MySQL 版本、表结构、隔离级别不同而略有差异但结构基本一致。重点是找HOLDS THE LOCK(S)和WAITING FOR THIS LOCK这两类信息它们会直接告诉你锁环是怎么形成的。注意这条命令不会保留很久之前的死锁记录。所以一旦业务方反馈“刚才报死锁了”要尽快采集不要先讨论改不改代码。2.2 再看当前事务和锁等待信息死锁日志看的是“过去发生的死锁”如果要看“当前正在等锁的事务”需要查信息表。SELECT trx_id, trx_state, trx_mysql_thread_id, trx_query, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS run_seconds FROM information_schema.innodb_trx ORDER BY trx_started;这条 SQL 能列出当前未结束的事务、启动时间、状态和正在执行的 SQL。run_seconds 越大说明这个事务持有锁的时间越长。再看锁等待关系SELECT * FROM information_schema.innodb_lock_waits;如果版本是 MySQL 8.0还可以使用性能库里的锁表SELECT * FROM performance_schema.data_locks; SELECT * FROM performance_schema.data_lock_waits;这些命令不是为了让你背而是为了让你在线上能按顺序排查先看有没有长事务再看被堵住的语句再回到 InnoDB status 找真正的死锁原因。2.3 查看当前执行语句和连接状态死锁发生时业务侧通常会看到类似“Deadlock found when trying to get lock; try restarting transaction”的报错。此时还要看当前连接到底在做什么排查有没有一条慢 SQL 把其他语句都堵住了。SELECT id, user, host, db, command, time, state, info FROM information_schema.processlist WHERE command ! Sleep ORDER BY time DESC;这里time代表语句已经执行了多久state代表当前等待状态info是实际执行的 SQL。经验上如果某条 SQL 执行时间很长又正好是业务高峰期那它引发的锁等待和死锁概率会显著上升。3. 一个典型死锁场景加锁顺序相反3.1 最经典的库存或订单更新用一个简单例子说明。假设有张库存表事务 A 和事务 B 都需要更新 SKU 100 和 SKU 200 的库存。事务 A 这样执行BEGIN; UPDATE stock SET quantity quantity - 10 WHERE sku_id 100; UPDATE stock SET quantity quantity - 5 WHERE sku_id 200; COMMIT;事务 B 这样执行BEGIN; UPDATE stock SET quantity quantity - 5 WHERE sku_id 200; UPDATE stock SET quantity quantity - 10 WHERE sku_id 100; COMMIT;当 A 先拿到 SKU 100 的行锁B 先拿到 SKU 200 的行锁时A 下一步要 SKU 200B 下一步要 SKU 100。两者谁也不愿意释放自己手里的锁就形成了锁环。InnoDB 检测到这个环后会回滚其中一个小事务并把 1213 错误返回给应用。另一个事务如果能继续就会完成提交。这里的重点不是“表结构错了”而是两个事务对同一批资源的加锁顺序不一致。顺序相反是生产环境里最常见的死锁来源。3.2 不加索引会让死锁更频繁如果sku_id上有唯一索引或者普通索引InnoDB 可以通过索引定位到具体的行锁范围比较小。如果sku_id没有索引或者 WHERE 条件里写了对索引列做函数计算比如WHERE sku_id 1 100MySQL 可能无法走索引只能扫描更多数据。扫描更多数据意味着 InnoDB 要给扫描过程中命中的记录加更多的锁。在可重复读隔离级别下还可能涉及间隙锁和临键锁锁的范围会比我们想象中大很多。两个事务如果更新的是同一批“被扫描到的记录”即使其中某些行并不是真正的目标行也可能因为间隙锁重叠而出现死锁。所以很多死锁问题其实不是业务逻辑有多复杂而是基础 SQL 没有走索引。3.3 不要轻易把隔离级别调低来“解决死锁”有些团队一看死锁多了就把隔离级别从可重复读改成读已提交。这个方案确实能减少间隙锁带来的问题但它不是免费的。隔离级别降低后对业务一致性、主从环境、特定读取逻辑都会有影响。更稳妥的做法是先确认业务是否真的需要可重复读再确认所有 SQL 是否都走了高效索引最后才考虑要不要调整隔离级别。不要把隔离级别当成消灭死锁的唯一开关。4. 线上遇到死锁先止血还是先排查4.1 建议的线上处理顺序线上出现死锁时业务已经受影响。此时不要一上来就想“重启”而是按下面顺序操作。第一步保留现场。执行SHOW ENGINE INNODB STATUS\G把LATEST DETECTED DEADLOCK复制下来。同时查innodb_trx和innodb_lock_waits把当前正在运行的事务、锁等待关系记录下来。第二步看事务时长。如果发现某个事务已经运行几十秒甚至几分钟还没提交但它不是核心业务可以评估是否需要让这个事务回滚。第三步找到直接卡住业务的那条连接。通过 processlist 查看info和state确认是哪条 SQL 在等待锁等待的时间是多久。第四步根据日志判断要不要人工干预。如果等待时间已经超过业务容忍度可以KILL对应的连接让事务回滚释放锁。但不要一看到锁等待就乱杀先看事务是否还在执行是否有业务关联。4.2 哪些事务可以先 kill哪些不要动下面是判断是否 kill 的一些经验如果事务run_seconds很长但执行的 SQL 已经很久没有进展可以考虑 kill。如果事务正在写重要业务数据但没有其他办法推进必须与业务负责人确认后再处理。如果 kill 后应用层有重试机制风险会低很多。如果事务已经接近提交可以先等它再观察几秒。KILL 的命令是KILL 12345;这里的12345是线程 ID不是事务 ID。可以用SHOW PROCESSLIST或innodb_trx里的trx_mysql_thread_id找到。4.3 为什么“重启服务”治标不治本死锁发生后MySQL 自身已经做了处理它会选择一个事务回滚释放锁。也就是说不需要通过重启数据库来“解开死锁”。如果业务层没有处理死锁事务对应用来说就是一次失败的 SQL 请求。重启服务可能会让连接池重新建立看起来“好了”但代码里的事务顺序没有变SQL 没有走索引的问题没有变下一次高峰并发时依然会死锁。更麻烦的是重启服务会清掉内存里的现场信息。MySQL 的LATEST DETECTED DEADLOCK信息不是永久保存的重启后可能就找不到了。这样既没有解决根因也把定位问题的证据丢了。真正的止血是把当前事务和锁信息留下来必要时 kill 掉长时间占用资源的事务如果应用有重试机制让业务先从报错中恢复。等系统稳定后再处理根因。5. 真正根治优化SQL、统一锁顺序、应用层重试5.1 统一多行更新的加锁顺序最直接的办法是让所有事务访问同一批资源时都按相同顺序加锁。比如上面的库存例子两个事务都按 SKU ID 从大到小或从小到大执行就不会形成交叉等待。在 Java 代码里可以先排序再执行更新Long[] skuIds {100L, 200L}; Arrays.sort(skuIds);排序后所有事务都先更新 100再更新 200。这样即便两个事务并发也只会在同一把锁上等待形成的是锁等待而不是死锁。锁等待会随着第一个事务提交而结束不会把系统卡死。在 SQL 层如果是一次更新多条记录也可以尽量让查询按主键或唯一键排序。MySQL 的 UPDATE 语法支持 ORDER BY 和 LIMIT但更通用、更稳妥的还是把一次事务拆成确定顺序的多次操作。5.2 让 UPDATE/DELETE 的 WHERE 条件走索引每次更新、删除数据前先确认 SQL 能不能高效定位到目标行。EXPLAIN UPDATE stock SET quantity quantity - 10 WHERE sku_id 100;重点看key列是否使用了索引。如果key是 NULL说明这条 UPDATE 可能需要扫描较多行锁的范围也会变大。对 UPDATE 和 DELETE 来说走索引不只是为了速度更重要的是为了减少锁的覆盖范围。锁的范围越小两个事务之间发生交叉等待的概率就越低。另外要注意很多死锁不是单个 SQL 的问题而是多个 SQL 在一个事务里共同作用的结果。一条 UPDATE 加锁的范围可能不大但紧接着另一条 UPDATE 又去更新另外一批记录两条记录集合在另一个事务里以相反顺序操作死锁就会产生。5.3 应用层重试捕获 1213 后按退避策略重试死锁是数据库已经检测并回滚一个事务后才报给应用的所以应用层可以做重试。Java 中很多框架会把 MySQL 1213 错误转成DeadlockLoserDataAccessException或类似的异常。一个参考实现是TransactionTemplate txTemplate ...; for (int attempt 0; attempt 3; attempt) { try { return txTemplate.execute(status - { // 第一步校验业务状态 // 第二步执行 UPDATE / DELETE // 第三步记录流水或发送内部消息 return result; }); } catch (DeadlockLoserDataAccessException e) { if (attempt 2) { throw e; } try { Thread.sleep(50L * (attempt 1)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw e; } } }重试不是万能药有几点要注意。第一只有确认当前事务已经因死锁被回滚才应该重试。如果一个异常发生在事务提交前但事务并没有完全回滚直接重试可能造成更新两次。第二重试次数不要太多。通常 3 次左右足够次数太多会把并发压力放大。第三重试要带一点随机退避。固定间隔重试容易在下一轮再次撞车加一点偏移能让两个事务错开。第四如果使用 Spring 的Transactional不要把重试逻辑写在同一个类里。Spring 默认的自调用不会经过代理事务不一定能重新开启。更稳妥的做法是用TransactionTemplate或者把事务和重试放在单独的 Bean 里。5.4 事务边界越短死锁概率越低一个事务里如果既有远程调用又有文件操作还有多条 SQL事务持有锁的时间就会拉长。持锁时间越长其他事务等待越久死锁概率越高。建议把不必要的操作挪到事务外远程 HTTP 调用不要放在事务里。消息发送可以放在事务提交后或使用可靠消息表。批量更新如果数据量很大不要一个事务处理全部考虑分批提交。代码里先做参数校验再开启事务不要在事务里等待用户输入或做耗时计算。事务边界不是越短越好而是只保留真正需要原子性的操作。能少锁一行就少锁一行能少锁一秒就少锁一秒。6. 长期预防和面试回答清单6.1 监控和日志要做到什么程度线上死锁不能只靠业务方报错后再去查应该提前有监控。可以做这几项基础监控MySQL 错误日志里是否出现死锁相关关键字。SHOW ENGINE INNODB STATUS的死锁段是否有更新。慢查询数量是否突增尤其是 UPDATE、DELETE 类慢查询。information_schema.innodb_trx中是否有大量运行时间超过阈值的事务。锁等待事件、连接池活跃连接数是否达到高位。监控的价值不是实时发现死锁而是保留“死锁发生前数据库在发生什么”的线索。很多时候死锁不是单独出现的它前面会有慢查询、长事务、锁等待飙升等信号。6.2 面试回答时可以按这个顺序组织如果面试官问“线上 MySQL 出现死锁怎么办”一个比较完整的回答是先说明死锁的语义两个事务互相等待对方持有的锁InnoDB 会检测并回滚其中一个事务错误码是 1213。再说第一反应不是重启而是保留现场执行SHOW ENGINE INNODB STATUS查看最近一次死锁的两条事务和锁等待关系。接着查询information_schema.innodb_trx和innodb_lock_waits确认当前是否有长事务、锁等待和慢 SQL。根据日志判断是两个事务更新顺序相反还是 SQL 没走索引导致锁范围过大。最后说改造方案统一加锁顺序、缩短事务、走索引、小事务提交、应用层捕获异常后重试。这个回答既包含了现象又包含定位方法还包含处理动作和预防措施覆盖了面试官在考察 MySQL 死锁时最关心的几个点。6.3 一点实际操作建议真正踩过线上死锁之后会发现重启服务是最容易回答也最没有价值的一句话。需要坚持做的其实是两件事把日志和事务现场保留下来把任何一条会更新数据的 SQL 都当作需要明确锁范围的操作来检查。单事务能跑通不代表并发没问题默认配置能跑也不代表锁顺序正确。只要 SQL 在并发下还会以不同顺序访问同一批资源死锁就还在。能把这个问题讲清楚并且给出可执行的排查步骤才算真正把 MySQL 死锁这件事处理明白了。