ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

MySQL死锁排查与治理:从原理到实战

2026/9/3 19:00:53 拓冰建站 浏览量
MySQL死锁排查与治理:从原理到实战 线上MySQL出现死锁很多人的第一反应是“重启服务”。这个动作看起来能暂时恢复业务但真正的问题还埋在数据库里事务是怎么撞车的锁为什么没有被及时释放下一次流量高峰来的时候同样的死锁会不会再次出现如果这是一道Java面试题面试官问“MySQL死锁怎么办”其实不是在考察你会不会敲SHOW ENGINE INNODB STATUS这一条命令而是在考察你理解锁、事务和索引的程度。重启服务只能证明你知道“服务挂了要拉起来”证明不了你知道“为什么挂、怎么防、怎么快速定位”。本文就围绕这个场景展开先从死锁的基础原理讲清楚再给出线上环境真正能用的排查命令和监控方法然后拆解几个最常见的死锁案例最后给出Java代码、SQL语句和数据库设计三个层面的治理建议。不管你是正在准备Java面试还是在维护一个已经上线的MySQL业务库这篇文章都可以作为一份排查手册。1. 先搞清楚一件事死锁不是故障是机制在报警死锁看起来像故障但从数据库实现原理来看它其实是InnoDB在检测到循环等待后主动做出的保护动作。当两个或多个事务各自持有部分资源同时又互相等待对方释放资源时就会出现循环等待。InnoDB内部有一个后台线程持续监控锁等待关系一旦发现有死锁风险会立刻选中代价较小的事务进行回滚让另一个事务继续执行。所以你在业务日志里看到的Deadlock found when trying to get lock不只是错误更是InnoDB在“两害相权取其轻”。理解了这一点就能明白为什么“重启服务”是低效处理。数据库把死锁中的牺牲者已经挑出来了重启服务并不能改变表结构和SQL执行计划也不能消除事务交叉加锁的代码路径。重启之后同样的业务流量下死锁大概率会再次出现。面试官真正想听的是你能否从三个层面回答死锁在MySQL里是怎么产生的线上环境怎么快速确认死锁语句和事务从代码、SQL、表结构三个方向怎么避免这三个问题串起来就是一个完整的“处理死锁”思路。2. InnoDB锁机制理解死锁必须知道的基础概念要在Java业务中处理MySQL死锁光知道“死锁是循环等待”远远不够。还得理解几个底层概念。2.1 行锁、间隙锁与Next-Key LockInnoDB支持行级锁但行级锁不是只锁一行那么简单。Record Lock记录锁锁的是索引记录本身。Gap Lock间隙锁锁的是索引记录之间的区间防止其他事务在这个区间插入数据。Next-Key Lock记录锁和间隙锁的组合锁住一段左开右闭区间。很多死锁发生在REPEATABLE READ隔离级别下就是因为Range条件或等值条件命中了多个索引区间时间隙锁之间出现交叉等待。举一个典型的例子-- 事务A SELECT * FROM t WHERE id 10 FOR UPDATE;如果id10的这条记录不存在InnoDB在默认隔离级别下会对(某个范围)加间隙锁目的是防止其他事务插入id10的记录。此时如果事务B也想插入一条id10的新记录B就需要获取这个间隙的插入意向锁于是B被阻塞。如果A后续又需要访问B持有的资源就形成死锁。2.2 当前读与快照读“为什么我的事务里明明只是Update了一条记录没有查询却会死锁”这是Java业务开发里最常问的问题。原因是Update操作属于当前读它会读取记录的最新版本并加锁。而Select在没有FOR UPDATE、没有LOCK IN SHARE MODE且不是UPDATE子查询里时通常是快照读不加锁。很多死锁发生在这样的组合里事务A先做了一次条件Update锁定若干记录。事务B也做了一次Update恰好要更新同一批记录。两个事务分别对不同的索引加锁又因为回表顺序不一致形成环形等待。2.3 锁的粒度与索引的关系一个高频面试点InnoDB行锁是加在索引上的。也就是说如果更新语句没有命中索引行锁可能升级成表锁或者锁定大量记录。UPDATE user SET status 1 WHERE name 张三;如果name字段没有索引这条Update为了保证正确性会扫描主键聚簇索引并锁定大量记录。两个事务同时执行类似SQL时锁冲突概率会明显上升。这也是死锁排查时首先要看执行计划的原因。3. 死锁产生需要满足的4个条件MySQL死锁和操作系统进程死锁一样需要同时满足四个必要条件互斥资源只能被一个事务独占。持有并等待事务持有至少一个资源同时还在等待其他资源。不可剥夺事务持有的资源不能被其他事务强行抢占只能由持有者主动释放。循环等待多个事务之间形成了一条环形等待链。四条缺一不可。这个模型能用来做一件事分析死锁时在脑海里把“事务持有锁”和“事务等待锁”的依赖关系画出来。一旦形成了环就找到了根因。很多Java开发者在排查死锁时只盯着最后报错的两条SQL但死锁的双方往往不是同一个时间点开始执行的。比如事务A在一个方法里先更新订单再更新账户事务B在一个方法里先更新账户再更新订单这种交叉更新就是典型的循环等待来源。4. 线上死锁排查先用命令确认再谈修复收到死锁告警时不要急着重启也不要急着改代码第一步是抓现场。4.1 查看最近一次死锁信息MySQL提供了直接查看最近死锁信息的命令SHOW ENGINE INNODB STATUS;这条命令输出内容较长需要重点关注LATEST DETECTED DEADLOCK这一段。它会列出死锁发生的时间。涉及的事务ID。每个事务当前执行的SQL。事务持有和等待的锁记录。被回滚的事务ID。4.2 通过information_schema查询当前锁等待和事务SHOW ENGINE INNODB STATUS只能看最近一次死锁信息。如果死锁频繁就需要实时监控当前所有事务和锁等待关系。SELECT trx_id, trx_state, trx_started, trx_query, trx_rows_locked FROM information_schema.INNODB_TRX;这条SQL可以列出InnoDB当前运行的事务、状态、开始时间和影响行数。再看锁等待关系SELECT r.trx_id AS waiting_trx_id, r.trx_query AS waiting_sql, b.trx_id AS blocking_trx_id, b.trx_query AS blocking_sql FROM information_schema.INNODB_LOCK_WAITS w JOIN information_schema.INNODB_TRX r ON w.requesting_trx_id r.trx_id JOIN information_schema.INNODB_TRX b ON w.blocking_trx_id b.trx_id;在MySQL 8.0中还可以查询performance_schema.data_lock_waits和sys.innodb_lock_waits获得更直观的阻塞信息。4.3 开启死锁日志避免下次抓不到现场线上死锁发生时往往还没来得及执行SHOW ENGINE INNODB STATUS事务就已经被回滚了。所以更稳妥的方式是提前把死锁相关信息持久化。innodb_print_all_deadlocks ON把这项写入MySQL配置文件并重启后InnoDB会将每一次死锁的详细信息记录到错误日志中而不是只保留最近一次。这样排查历史问题会方便很多。从运维角度还需要配合慢查询日志和监控工具才能把“死锁与业务高峰的对应关系”看清楚。5. 死锁定位的核心LATEST DETECTED DEADLOCK 怎么读不要被SHOW ENGINE INNODB STATUS输出的大段日志吓到找对关键段落就能快速定位。下面是一段模拟死锁日志的关键结构LATEST DETECTED DEADLOCK ------------------------ 2025-01-12 10:23:45 0x7f1234abcd *** (1) TRANSACTION: TRANSACTION 123456, ACTIVE 15 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 222, OS thread handle 14000, query id 111 Sending data UPDATE t_order SET status 2 WHERE order_no A001 *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 10 page no 4 n bits 72 index PRIMARY of table test.t_order *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 10 page no 8 n bits 72 index idx_user_id of table test.t_order *** (2) TRANSACTION: TRANSACTION 123457, ACTIVE 10 sec updating or deleting mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 223, OS thread handle 14001, query id 112 updating UPDATE t_order SET user_id 3 WHERE order_no B001 *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 10 page no 8 n bits 72 index idx_user_id of table test.t_order *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 10 page no 4 n bits 72 index PRIMARY of table test.t_order *** WE ROLL BACK TRANSACTION (2)读这段日志的关键点每个事务都分“持有锁”和“等待锁”两段。看锁对象是主键索引还是二级索引。事务1持有主键索引锁等二级索引锁事务2持有二级索引锁等主键索引锁这就形成了一个循环。在这种情况下问题往往出在两条Update语句的更新条件和更新字段组合上。5.1 示例场景还原假设表结构是CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, status INT DEFAULT 0, KEY idx_user_id(user_id), UNIQUE KEY uk_order_no(order_no) ) ENGINEInnoDB;事务A执行UPDATE t_order SET status 2 WHERE order_no A001;这条SQL会通过uk_order_no找到记录然后回到主键索引更新数据加锁顺序是唯一索引记录锁 - 主键索引记录锁。事务B执行UPDATE t_order SET status 2 WHERE user_id 3;这条SQL通过idx_user_id找到目标行然后也回到主键索引更新记录加锁顺序是二级索引记录锁 - 主键索引记录锁。如果两个事务的目标记录刚好有重叠且InnoDB加锁时先锁二级索引再锁主键A已经锁了主键索引还没锁二级索引B已经锁了二级索引还没锁主键就会死锁。5.2 减少索引回表造成交叉等待的方法避免这种死锁最直接的手段是让SQL语句不要通过多个索引交叉加锁尽量使用主键或唯一索引作为更新条件。如果必须使用二级索引尽量让更新条件和更新字段一致减少回表。高并发更新场景下可以考虑先查出主键再用主键执行更新。-- 推荐做法先查主键再按主键更新 SELECT id FROM t_order WHERE order_no A001 FOR UPDATE; UPDATE t_order SET status 2 WHERE id 1001;当然这只是一个简化思路实际业务要根据事务边界整体设计。6. 常见死锁场景拆解从业务代码里找到源头死锁并不是只能靠DBA解决很多时候源头在Java业务代码里。6.1 场景一两个事务反向更新同一批数据经典场景是账户扣款和多笔订单更新。// 事务A先更新账户再更新订单 Transactional public void payOrder(Long accountId, Long orderId) { accountMapper.decreaseBalance(accountId); orderMapper.updateStatus(orderId); }// 事务B先更新订单再更新账户 Transactional public void refundOrder(Long orderId, Long accountId) { orderMapper.updateStatus(orderId); accountMapper.increaseBalance(accountId); }当payOrder和refundOrder并发执行且恰好分别持有对方需要的记录锁时就会死锁。解决方案也很直接强制统一加锁顺序。比如约定事务内先对accountId加锁再对orderId加锁。或者在代码里先对两个ID做排序再按固定顺序执行。// 固定顺序先锁accountId再锁orderId Transactional public void payOrder(Long accountId, Long orderId) { accountMapper.decreaseBalance(accountId); orderMapper.updateStatus(orderId); } Transactional public void refundOrder(Long accountId, Long orderId) { accountMapper.decreaseBalance(accountId); orderMapper.updateStatus(orderId); }这种“锁顺序一致性”是最容易理解也最容易被忽略的治理手段。6.2 场景二并发插入唯一键冲突导致死锁Java业务中经常会做“先查后插”或“直接插入”的逻辑并发插入时有可能因为唯一索引冲突形成死锁。具体过程是事务A插入一条记录或先删除再插入事务B也插入相同唯一键的记录。如果A先占用了唯一索引的锁但在等待其他锁B则在等待A释放唯一索引锁同时A又在等待B释放某把锁就可能出现循环等待。避免策略对“可能存在重复键”的数据使用INSERT ... ON DUPLICATE KEY UPDATE而不是先查再插。批量插入时保持数据顺序稳定避免多个并发事务以不同的顺序插入相同集合。如果允许忽略冲突可以使用INSERT IGNORE但要注意它也会导致自增主键空洞。6.3 场景三范围查询加间隙锁引起死锁在REPEATABLE READ隔离级别下范围查找会使用间隙锁两个事务的间隙锁一旦发生重叠就很容易死锁。-- 事务A UPDATE t_order SET status 1 WHERE amount BETWEEN 100 AND 200; -- 事务B UPDATE t_order SET status 2 WHERE amount BETWEEN 150 AND 250;如果两个区间有重叠InnoDB的间隙锁会互相阻塞。更稳妥的方式是把隔离级别修改为READ COMMITTED它是基于行锁的不使用间隙锁但需要业务逻辑能够接受不可重复读。这里要强调不要把“调低隔离级别”当成银弹它会影响业务的一致性语义必须经过充分测试。7. Java代码层面的治理方案重试、降级与事务边界如果死锁已经发生且无法完全避免那么业务代码应具备一定的容错能力。7.1 捕获死锁异常并进行重试死锁事务是被数据库主动回滚的所以Java代码中捕获到死锁异常后可以对“只读业务或短事务”做一次重试。关键是在重试前把当前事务标记为回滚确保之前的数据库连接已经恢复可用。Component public class DeadlockRetryHandler { private static final int MAX_RETRY_COUNT 3; private static final long RETRY_WAIT_MILLIS 100L; public T T executeWithDeadlockRetry(SupplierT action) { for (int i 0; i MAX_RETRY_COUNT; i) { try { return action.get(); } catch (DeadlockLoserDataAccessException ex) { if (i MAX_RETRY_COUNT - 1) { throw ex; } try { Thread.sleep(RETRY_WAIT_MILLIS * (i 1)); } catch (InterruptedException interruptedException) { Thread.currentThread().interrupt(); throw new RuntimeException(interruptedException); } } } throw new IllegalStateException(unreachable); } }7.2 缩短事务时间同一批锁被持有的时间越长等待方的阻塞时间就越长死锁概率越高。在Java事务方法中应避免在事务内进行远程调用、发送MQ消息、执行重量级计算等操作。Transactional public void createOrder(OrderDTO orderDTO) { Order order buildOrder(orderDTO); orderMapper.insert(order); // 不建议在事务内调用远程接口 // riskControlClient.check(orderDTO); }可以把远程调用移到事务提交之后使用TransactionSynchronizationManager注册回调或者使用ApplicationEventTransactionalEventListener。7.3 事务中避免锁顺序不一致的批量更新批量更新时如果多个事务处理的数据集合有重叠但顺序不一致也容易死锁。更好的做法是先将集合排序再按固定顺序更新避免锁顺序的随机性。8. 数据库和表结构层面的兜底措施8.1 合理设计索引避免行锁升级为表锁或区间锁更新语句如果没有命中索引InnoDB需要扫描聚簇索引来确定目标行锁定范围就会扩大。优先为WHERE条件中的列建立合适索引尤其避免“隐式类型转换导致索引失效”。-- 假设user_id是varchar类型 UPDATE t_order SET status 1 WHERE user_id 123;如果user_id是varchar但用数字常量查询MySQL可能无法使用索引。正确做法是UPDATE t_order SET status 1 WHERE user_id 123;8.2 控制长事务和并发度长事务持有的锁数量更多、时间更久。在监控中需要关注information_schema.INNODB_TRX里trx_started字段超过阈值的事务要及时告警并人工确认。同时应用层可以通过限流、队列等方式降低数据库并发冲突概率。8.3 辅助工具利用pt-deadlock-logger持续监控死锁Percona Toolkit中的pt-deadlock-logger可以定期从SHOW ENGINE INNODB STATUS中提取死锁信息把结果写入表或日志文件适合长期跟踪死锁趋势。这是DBA常用的手段Java开发可以配合使用。如果环境不允许安装额外工具也可以写一个定时任务执行SHOW ENGINE INNODB STATUS并解析日志但要注意频繁执行会影响数据库性能建议间隔不少于30秒。9. 常见问题与排查思路问题现象可能原因排查方式解决方案最近一次死锁无法复现事务并发条件特定且只在高峰期出现查看错误日志中innodb_print_all_deadlocks输出监控高峰时段锁等待开启死锁完整日志收集多个样本后再分析两个Update都命中主键但仍死锁业务存在批量更新索引回表顺序交叉解析EXPLAIN执行计划观察锁顺序固定加锁顺序或者改用确定性顺序更新插入数据时频繁出现死锁唯一索引冲突两个事务竞争间隙锁查看死锁日志中的索引名称改用INSERT ON DUPLICATE KEY UPDATE或INSERT IGNORE事务等待时间较长但没死锁存在长事务或大范围锁查询INNODB_TRX和INNODB_LOCK_WAITS找出阻塞源头拆分事务、优化SQL、减少锁范围重启服务后死锁消失但过段时间又出现问题在业务代码或SQL逻辑中重启只是暂时清空连接池和事务分析死锁日志按锁顺序和事务边界定位按代码治理方案调整而不是继续重启10. 面试时怎么回答“线上MySQL出现死锁怎么办”面试官抛出这个问题时答题节奏很重要。第一步简要解释死锁的本质InnoDB检测到循环等待后回滚其中一个事务导致业务收到异常。第二步说明线上排查步骤用SHOW ENGINE INNODB STATUS查看最近死锁信息或开启innodb_print_all_deadlocks保留历史记录。查询information_schema.INNODB_TRX、INNODB_LOCK_WAITS定位当前锁等待关系。根据死锁日志找出两个事务各自的持有锁与等待锁分析是否有多个索引交叉加锁。结合业务代码检查事务边界和SQL更新顺序。第三步给出解决方案统一事务内资源加锁顺序。减少事务耗时避免远程调用放入事务。利用唯一索引、主键索引优化SQL避免区间锁、间隙锁扩大化。必要时调整隔离级别但需要评估业务一致性。代码层面对死锁异常做有限重试。第四步表达工程判断死锁无法百分百避免关键是监控、快速定位和兜底策略。这样回答直接覆盖了“知道了原理、能操作、能预防、能容错”四个评价维度明显比“重启服务”要完整得多。11. 最佳实践清单从预防到止损把排查和治理中的经验整理成一份可以直接落地检查的清单规范索引设计所有Update和Delete的WHERE条件都必须有合适索引避免行锁退化为表锁。统一加锁顺序多条更新语句涉及多个表时在代码层约定固定的访问顺序。保持事务短小事务内只执行数据库必要操作远程调用放到事务外。关闭不必要的外键和级联更新外键约束会引入隐式加锁业务上如果不需要建议不用。批量操作要稳定排序批量Update前先对数据排序避免事务间锁顺序不一致。开启死锁日志innodb_print_all_deadlocks ON并确保日志目录有足够空间。建立监控告警对死锁错误次数和锁等待时间设置阈值告警。做好连接池配置合理设置maximumPoolSize、connectionTimeout避免连接耗尽扩大故障面。使用事务重试机制针对偶发死锁配置Spring事务重试或AOP切面重试。上线前做并发压测使用JMeter或内部压测工具模拟高并发更新提前验证是否存在死锁风险。这些建议里最容易被忽视的是第5条。很多Java团队在批量更新时直接使用foreach循环每次循环里再调用一条Update数据顺序完全取决于传入的List排序一旦并发事务处理不同顺序的列表死锁概率就会上升。更稳妥的方式是先将集合排序再执行更新。12. 总结死锁处理考验的是“全局视图”回到本文开头的问题线上MySQL出现死锁怎么办重启服务当然不行。真正需要做的是理解InnoDB加锁机制掌握死锁日志的定位方法然后回到Java代码和SQL层面寻找交叉等待的来源。死锁是数据库并发控制的一部分它提醒你系统正在以不符合预期的方式竞争资源。每一次死锁都是一次免费的代码review机会。先看有没有锁顺序不一致的问题再看有没有索引失效放大了锁范围最后看事务边界是否过长。把这三件事按顺序查完大部分死锁都能定位清楚。对Java程序员来说处理死锁的能力并不只是面试亮点它直接反映了一个人对数据库底层原理、事务隔离级别和业务并发模型的理解深度。下次再遇到死锁告警可以试着先跑一次SHOW ENGINE INNODB STATUS把日志读明白再决定要不要动代码。