1. 死锁的本质与触发场景1.1 锁的类型与死锁的形成过程聊死锁之前先把 MySQL 里最容易产生死锁的两个锁角色搞清楚排他锁X 锁也就是写锁和共享锁S 锁也就是读锁。排他锁在UPDATE、DELETE、INSERT ... ON DUPLICATE KEY UPDATE这类写操作里会被自动加上作用就是让当前事务独占这条记录其它事务想加任何锁都得排队等。共享锁则出现在普通的SELECT加LOCK IN SHARE MODE或者某些隔离级别下的读取场景它的特点是多个事务可以同时持有同一行数据的共享锁但任何一方想升级成排他锁时就得等其它共享锁全部释放。死锁的形成过程说白了就是两个或多个事务互相持有对方想要的锁谁都不肯放手。用一个生活化的类比一条单车道A 车从东往西走B 车从西往东走相遇之后谁也过不去如果有第三方车辆再加入整个路口就堵死了。数据库里的死锁比这个稍复杂一点因为它不一定是两把锁互相等还可能是三个、四个事务形成一个锁等待环。我在实际处理过的案例里最多碰到过四个事务同时纠缠在一个死锁里光看日志就得捋半天。MySQL 的 InnoDB 存储引擎内置了死锁检测机制。它维护着一张锁等待的依赖图每当事务请求一个被其它事务持有的锁时就会检查这张图里是否出现了循环等待。一旦检测到死锁它会立刻选一个“牺牲者”回滚被牺牲的事务会收到一个ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction的错误提示。这个机制保证数据库永远不会真的“卡死”但业务侧如果不处理这个报错用户的请求就会直接失败。需要注意的是死锁检测本身也有性能成本。当并发量极高、锁等待链很长的时候检测死锁的图遍历操作会消耗不少 CPU。所以 InnoDB 提供了一个开关innodb_deadlock_detect在高并发场景下可以关掉它让数据库直接依赖锁等待超时机制来兜底避免检测开销拖垮性能。这个取舍后面再细说但至少你得知道死锁不是数据库故障而是并发控制里的一种正常状态关键是怎么快速处理、怎么尽量避免。1.2 最容易触发死锁的三种典型场景这几年排查过的死锁案例绝大多数都逃不出下面三种典型场景。先看第一种两个事务以相反的次序更新多张表。比如下单流程里事务 A 先更新订单表再更新库存表事务 B 刚好先更新库存表再更新订单表。当两个事务并发执行时A 拿到了订单表的锁、B 拿到了库存表的锁接下来 A 想拿库存表的锁被 B 挡住B 想拿订单表的锁被 A 挡住死锁就在一瞬间形成。这种场景在高并发交易系统里非常常见尤其是前后端接口实现不规范、同一个业务操作走了不同的代码路径时。第二种同一张表上以不同条件更新不同范围的记录。举个例子事务 A 执行UPDATE account SET balance balance 100 WHERE user_id 1001事务 B 执行UPDATE account SET balance balance - 50 WHERE user_id 1005。如果这两个条件都能命中索引InnoDB 会对各自目标行加锁看起来互不干扰。问题出在有些 SQL 的WHERE条件无法高效使用索引优化器只能走全表扫描这时 InnoDB 会对扫描过程中经过的所有记录都加上锁实际锁定的范围远超目标行。一旦两个事务锁定的范围出现重叠死锁就出现了。这种场景我见过最多因为问题藏得深不打开执行计划看索引使用情况根本不知道 SQL 到底锁了多少行。第三种INSERT ... ON DUPLICATE KEY UPDATE与并发插入之间的锁竞争。插入操作在发生唯一键冲突时会先在重复的索引记录上加共享锁然后再尝试升级为排他锁做更新。如果两个事务同时插入同一个唯一键就会出现事务 A 持有共享锁等待升级排他锁事务 B 也持有共享锁等待升级排他锁双方谁都没法先拿到排他锁于是死锁。这种场景在批量导入、幂等写入这类业务里特别容易爆发有时候压力测试一跑日志里全是 1213 报错。理解死锁的高发场景比背一堆理论有用得多。当你接到死锁告警时第一反应不应该是翻书查定义而是对照线上业务的改动记录想清楚最近上线了什么功能、改了什么 SQL基本就能锁定目标。2. 快速定位死锁的排查手段2.1 开启死锁监控的配置清单处理死锁的第一步是确保数据库把现场证据完整地留下来了。我接手过不少项目上来就遇到死锁结果查日志发现监控压根没开只能对着应用端的报错信息瞎猜。以下是我在 MySQL 5.7 和 8.0 上都验证过的监控配置组合缺一不可。先设置innodb_print_all_deadlocksON。这个参数的作用是每次发生死锁时把完整的死锁信息涉及的事务、持有的锁、等待的锁、执行过的 SQL全部写入 MySQL 错误日志。我强烈建议所有线上环境都开这个配置因为它记录的信息比SHOW ENGINE INNODB STATUS看到的更全而且不会遗漏。注意SHOW ENGINE INNODB STATUS只会展示最近一次死锁的信息重启后历史会丢失错误日志却能保存完整的纵向记录方便对比多次死锁的规律。再把innodb_lock_wait_timeout设置为一个合理的值。这个参数控制事务等待锁的最长时间单位是秒默认是 50 秒。对大多数互联网业务来说50 秒太长了意味着用户请求会因为锁等待而长时间挂起。我通常建议调到 5 秒左右让锁冲突快速暴露避免拖垮整个连接池。但也不能调太低否则一些正常的锁排队会被误杀比如批量更新大表时一个事务持锁时间稍长后续事务就可能频繁报超时。最后务必确认log_error_verbosity3才能保证死锁日志的完整级别足够高。这个参数在 8.0 里控制错误日志的记录详细程度级别 3 表示记录所有信息包括死锁详情。MySQL 5.7 里默认就能记录8.0 之后有几个版本的默认值改过我踩过这个坑升级到 8.0 之后突然发现死锁日志变少了后来才确认是日志级别的问题。参数名推荐值作用innodb_print_all_deadlocksON把每次死锁详情写入错误日志innodb_lock_wait_timeout5控制锁等待的最大秒数log_error_verbosity3保证错误日志记录完整死锁信息2.2 解析 SHOW ENGINE INNODB STATUS 的输出拿到死锁信息后最核心的解析工作就从SHOW ENGINE INNODB STATUS开始。这条命令随时可以在 MySQL 客户端执行执行后会返回一大段引擎状态信息我们只需要关注LATEST DETECTED DEADLOCK这个片段。这个片段的结构大致如下------------------------ LATEST DETECTED DEADLOCK ------------------------ 2024-11-20 14:32:18 0x7f4a2b123700 *** (1) TRANSACTION: TRANSACTION 418504, ACTIVE 12 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 31845, OS thread handle 140182479312640, query id 625143 ... UPDATE user_account SET balance balance - 100 WHERE user_id 1001 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 28 page no 4 n bits 0 index PRIMARY of table test.user_account trx id 418504 lock_mode X locks rec but not gap waiting *** (2) TRANSACTION: TRANSACTION 418505, ACTIVE 10 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 31789, OS thread handle 140182467231488, query id 625155 ... UPDATE user_account SET balance balance 100 WHERE user_id 1005 *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 28 page no 4 n bits 0 index PRIMARY of table test.user_account trx id 418505 lock_mode X locks rec but not gap *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 28 page no 10 n bits 0 index PRIMARY of table test.user_account trx id 418505 lock_mode X locks rec but not gap waiting第一遍看这段输出先抓两个关键信息事务 ID 和锁的模式。TRANSACTION 418504是事务的标识用它可以在information_schema.innodb_trx表里查到事务当前的详细状态包括已运行时间、使用的 SQL 等。lock_mode X表示这是排他锁waiting表示这个事务正在等锁。先快速判断是不是两个事务互相等待如果是三个及以上的事务就得画依赖图了手工画容易错可以借助性能监控工具的可视化功能。第二遍就要看 SQL 了。注意*** (1) TRANSACTION:和*** (2) TRANSACTION:下面通常会包含当前正在执行的 SQL以及事务之前执行过的 SQL如果有的话。SQL 是定位死锁根因的钥匙看清每个事务到底执行了什么语句、按什么顺序执行才能判断是加锁顺序问题还是索引失效问题。解析死锁日志时我习惯把事务、持有的锁、等待的锁整理成一个简单的表格这样一眼就能看出循环等待关系事务 ID持有的锁等待的锁涉及 SQL418504user_id1001 行的 X 锁user_id1001 行的 X 锁被 418505 持有UPDATE balance-100 WHERE user_id1001418505user_id1001 行的 X 锁user_id1005 行的 X 锁被 418504 持有UPDATE balance100 WHERE user_id1005这个表格一旦列出来死锁的循环关系就非常清晰两个事务各持有一行锁然后分别想拿对方的锁形成了环。2.3 借助日志和工具收集证据链光靠SHOW ENGINE INNODB STATUS还是不够它只给出了一次死锁的瞬间快照。要分析高频死锁得把多份证据串成一条线。我一般在处理死锁时会同时做下面几件事。第一件事把错误日志里所有的死锁记录按时间排序统计出现频率。如果死锁集中在某个时间段比如每天上午 10 点到 11 点那就大概率跟某个定时任务或批量处理有关。我处理过一个模拟项目里的死锁日志显示每天凌晨 2 点左右必现一次排查后定位到是某个数据对账的批处理任务和白天产生的未提交长事务搅在了一起。第二件事用performance_schema里的events_statements_history_long表查历史 SQL。这个表会记录所有执行过的语句前提是performance_schemaON通过它可以看到死锁发生前后每个线程执行了哪些语句。这比死锁日志里显示的 SQL 更完整因为 InnoDB 的状态输出有时会截断 SQL特别是 SQL 特别长的时候。第三件事有条件的话用开源的监控工具自动收集死锁信息。我的个人经验是任何工具都只是帮你省去手工采集的麻烦最终根因分析还是得靠人。工具可以自动画出锁等待关系图、统计死锁频率、关联版本发布记录但真正定位到是哪一行代码引入的还是需要结合业务逻辑做判断。3. 死锁的快速解决方案与 SQL 层处理策略3.1 立即恢复可用性的急救措施遇到线上死锁爆发第一要务是止损而不是分析根因。我的标准处理顺序是先看死锁影响的业务范围如果只是少量请求报错直接让应用层自动重试业务影响几乎可以忽略如果出现大量超时、错误率飙升就得考虑更激进的手段。急救措施里最直接的一招是重置那些卡住的长时间事务。死锁检测虽然会自动回滚牺牲者但如果某些事务已经处于“锁等待”状态很久可能会拖住一堆后续请求。可以执行SELECT * FROM information_schema.innodb_trx WHERE TIME 10找出运行时间超过 10 秒的事务确认事务内容后用KILL命令终止指定连接释放它持有的所有锁。注意KILL一个事务会让该连接正在执行的操作回滚它之前修改的数据都会还原所以执行前一定要确认事务属于可回滚的临时操作否则会造成更大的业务影响。第二个急救措施是快速降低死锁发生的频率。实践中我试过最有效的是临时扩大连接池大小或者降低业务削峰时的并发度。比如某个入口接口因为秒杀活动导致死锁激增临时把这个接口的并发上限从 50 降为 20死锁次数会立竿见影地降下来。这个方法不解决根本问题但能争取排查时间。第三个急救措施是用SET GLOBAL innodb_lock_wait_timeout3临时把锁等待超时调得更低。这会让原本可能死锁的请求更快超时失败避免请求长时间挂在数据库上占着连接不释放。代价是部分正常的锁排队也会被波及但相比整个服务雪崩这个代价可以接受。待问题修复后记得把参数调回来。3.2 处理策略一固定加锁顺序解决死锁最经典也最有效的策略是让所有事务以相同的顺序获取锁。回到前面更新订单表和库存表的例子只要约定所有代码路径都先更新订单表、再更新库存表两个事务就不会出现互相持锁等待的局面。这个策略的原理很简单锁的获取顺序一致后即使并发执行也只是排队依次执行不会形成循环等待。落地到实际项目里固定加锁顺序需要做两件事。第一件梳理所有涉及多表更新的业务代码按表名或业务优先级制定一个全团队遵守的加锁顺序规范比如按表名字母序。第二件在代码审查环节把“跨表更新顺序是否一致”作为必查项目。我见过很多团队第一件事做了第二件事却没落实结果某个新同事写了个反向顺序的逻辑上线照样死锁。如果多表更新实在没法统一顺序退一步的方案是串行化。比如对并发量不高的低频操作在应用层加一个分布式锁让更新订单和库存的操作串行执行。虽然牺牲了并发度但死锁问题彻底消失。我个人的经验是遇到超高并发场景优先考虑统一加锁顺序遇到低频但对实时性要求高的操作直接串行化反而更省心。3.3 处理策略二控制锁的粒度与范围锁的范围越大冲突概率越高也就越容易死锁。很多死锁问题的根源其实不是业务逻辑而是 SQL 写得太大范围。最常见的坑有两个UPDATE语句的WHERE条件没走索引导致全表扫描并锁定大量行或者WHERE条件里的值范围太宽比如WHERE status 0命中了 1 万行一次更新就锁了上万条记录。控制锁粒度的核心手段是把大更新拆成小批量。举个例子假设需要把某个订单表里 10 万条待处理的记录状态改为“处理中”一条UPDATE order SET status processing WHERE status pending会把这 10 万行一次性锁住耗时可能只要几百毫秒但期间任何并发读写这些行的请求都会阻塞。拆成每次更新 500 行的循环之后每轮更新只锁 500 行持锁时间大幅缩短其它事务基本感受不到阻塞。再强调一次索引的作用。相同逻辑的 SQL走索引和全表扫描锁的范围天差地别。死锁日志里如果看到locks rec but not gap说明是精确行锁问题相对可控如果看到locks gap before rec、locks gap after rec或者locks gap说明出现了间隙锁锁覆盖的是一个范围需要重点检查索引设计。我处理过的一个案例就是某条 SQL 本来命中普通二级索引后来索引被误删SQL 退化成了全表扫描死锁频率直接翻了十倍。3.4 处理策略三进行索引与 SQL 的联合优化索引优化不只是为了查询速度它直接决定了 InnoDB 要加多少锁。理解这一点很多死锁问题就能从源头化解。InnoDB 的行锁是基于索引实现的如果 SQL 能精确命中索引锁的就是对应的行或小范围如果无法命中索引就会退化为锁住整张表的所有记录。所以排查死锁时我做的第一件事永远是执行EXPLAIN看执行计划里的key和rows字段。key为 NULL 或者rows显示几万行基本可以断定死锁跟索引失效有关。一个常见优化场景是UPDATE t SET ... WHERE a 1 AND b 2表里a有索引、b没有索引优化器选择了走a的索引结果只能通过索引定位到a 1的所有记录再在内存里过滤b 2这些被扫描到的中间记录全部会被加上锁。解决方式是建立(a, b)联合索引让 SQL 直接定位到目标行加锁范围从 N 行缩小到 1 行。联合索引的字段顺序也值得关注。只建了(a, b)索引但 SQL 的过滤条件是WHERE b 2 AND a 1优化器通常也能用上索引只是效率可能略低但如果条件是WHERE b 2单独出现联合索引就完全失效。至于死锁场景最好的做法是让唯一性约束和业务条件匹配比如订单系统里直接用order_id做更新条件而不是同时用user_id和order_no两个条件组合定位。经验提示死锁日志里的RECORD LOCKS ... index PRIMARY表示锁在主键索引上如果显示的是二级索引名比如index idx_user_id ... locks rec but not gap说明锁在二级索引上此时要留意是否同时存在回表操作导致主键上也加了锁。3.5 处理策略四缩短单事务的运行时间事务持锁时间越长锁冲突窗口越大死锁概率自然越高。很多死锁其实不是并发设计的问题而是某个事务把数据库连接当成了慢速 HTTP 接口来用在事务里执行了外部接口调用、文件读写、复杂的循环查询导致事务长时间不提交。我见过一个案例某团队在事务里调用了一个响应耗时 3 秒的外部接口这个事务平均持锁时间 3 秒多结果秒杀场景一压测死锁几乎每分钟都在发生。缩短事务时间的落地做法有这几条第一事务里只做必要的数据库操作把外部接口调用移出事务如果要依赖外部系统的返回结果来决定数据库更新值考虑先调用接口再开启事务做更新。第二减少事务内的SELECT查询数量特别是关联子查询和全表扫描。第三把耗时的大事务拆成多个小事务每个事务只处理一小批数据比如批量更新 1 万行拆成每批 200 行提交一次。autocommit的设置也值得检查。MySQL 默认开启autocommit1一条语句就是一个事务执行完自动提交这种情况基本不会出现长事务。但如果应用层开启了SET autocommit 0又忘记在业务代码里显式提交连接就会一直保持未提交事务直到连接被回收或重启。我排查过的一个慢查询问题最后定位到就是测试代码里漏了commit导致连接池里的连接一直持有锁不放拖垮整个业务。4. 架构层与应用层的长效预防策略4.1 业务拆分与异步化从设计上消灭锁竞争如果同一把锁的竞争来自不同业务模块比如订单模块和营销模块都在更新同一个积分账户表这种死锁单靠 SQL 优化很难根治因为它本质上是一个数据模型设计问题。我处理过的一个实际场景是用户签到领积分更新积分表和订单返还积分更新积分表在高峰期撞车两边事务互相阻塞最后通过把积分更新拆成独立的积分服务接口层通过消息队列串行化处理死锁彻底消失。拆分的方向可以有两种。第一种是垂直拆分把共用的热数据表拆到独立的库或服务里让不同业务模块不再直接共享一把锁。第二种是异步化把实时性要求不高的更新操作放到 MQ 队列里由消费端串行处理。比如“用户下单后更新优惠券状态”这个操作如果允许延迟 100 毫秒就可以丢进队列异步更新而不是在用户请求的同步链路里直接更新。异步化有一个要注意的点消费端的更新逻辑如果发生了死锁重试机制必须做好否则一部分消息会反复失败。推荐的做法是消息消费失败后放入延迟队列按指数退避策略重试超过最大重试次数后进入死信队列人工介入处理。这套机制建好了异步化带来的死锁问题就完全可控了。4.2 重试机制的工程化设计应用层重试是应对死锁的最后一道保险也是我个人认为每个后端团队都必须做的基础能力。死锁报错ERROR 1213的特点是重试成功概率很高因为死锁牺牲者回滚后它释放的锁会让其它事务顺利完成。所以对于偶发死锁应用层重试一次或两次业务就能正常继续。设计重试机制时有几个关键细节。第一个重试次数不宜过多。死锁重试一般控制在 3 次以内。超过 3 次还失败说明不是偶发死锁而是结构性锁冲突需要人工介入。盲目重试只会放大数据库压力让冲突更严重。我见过一个项目把重试次数设成了 10 次结果死锁期间数据库负载直接被重试流量打成平时的 3 倍。第二个重试之间要加间隔。最简单的策略是每次重试等待一个固定间隔比如 50 毫秒。更稳妥的是指数退避第 1 次重试等 50 毫秒第 2 次等 100 毫秒第 3 次等 200 毫秒。这样既不会因为同步重试导致冲突加剧也能让系统有时间恢复。第三个重试要以事务为粒度。Spring 的Transactional注解就是典型陷阱方法标注了事务事务是由代理对象开启的如果方法内部自己 catch 了死锁异常做重试但事务已经标记为 rollback-only重试时连的事务是同一个根本不可能成功。正确的做法是把事务边界放在最外层让事务管理器感知到死锁异常后回滚然后由外层调用者重试整个事务方法。下面是一个伪代码级的重试模板作为参考Transactional public void processOrder(Long orderId) { // 更新订单表 orderDao.updateStatus(orderId, paid); // 更新库存表 stockDao.deductStock(orderId, 1); }调用方需要写成public void handleOrderWithRetry(Long orderId, int maxRetries) { for (int i 0; i maxRetries; i) { try { processOrder(orderId); break; } catch (DeadlockLoserDataAccessException e) { if (i maxRetries - 1) { throw e; } Thread.sleep(50L * (i 1)); } } }注意catch 到的异常类型必须和目标数据库驱动的死锁异常对应。MySQL 驱动下Spring 会把底层死锁错误转换为DeadlockLoserDataAccessException如果你用的是原生 JDBC就需要捕获SQLException并通过getErrorCode() 1213或getSQLState().equals(40001)来判断是否死锁。我见过不少项目因为异常类型匹配错误重试代码根本没生效死锁依然疯狂报错。4.3 隔离级别与并发模型的取舍事务隔离级别跟死锁有直接关系在动手优化之前先确认当前数据库的隔离级别是什么。SELECT transaction_isolation在 MySQL 8.0 里能直接看到结果默认情况下一般是REPEATABLE-READ。可重复读在 InnoDB 里为了保证一致性会在索引范围查询时额外加上间隙锁这也意味着锁的范围比读已提交更大死锁的概率自然更高。READ-COMMITTED隔离级别下InnoDB 只保留记录锁不产生间隙锁锁的范围大幅缩小。MySQL 在READ-COMMITTED下也禁用了next-key lock死锁概率会低不少。所以如果你的业务对“可重复读”没有强需求比如大部分互联网业务数据一致性靠业务逻辑保证不依赖数据库隔离级别果断切换到READ-COMMITTED死锁率通常能下降好几个数量级。不过我要特别提醒不要为了规避死锁盲目降低隔离级别一定要先梳理业务需求。有些场景确实依赖可重复读比如说“一个事务内多次读取同一范围的数据要保证结果一致”这种情况下改隔离级别会导致数据不一致属于挖东墙补西墙。我的习惯是优先审视业务对隔离级别是否真的有要求如果没有切到READ-COMMITTED是性价比最高的预防手段。另外针对特定业务的并发模型也可以做设计上的规避。比如把“先查后写”改成“直接写”。很多死锁来自SELECT ... FOR UPDATE的预检查逻辑事务先按某个条件查出一批行加锁再逐行更新。并发下两个事务同时预检查同一批数据就可能互相死锁。如果改成直接执行UPDATE语句InnoDB 会自动对目标行加锁路径更短死锁概率也会小很多。当然直接写的前提是业务允许“无条件覆盖”需要结合场景评估。5. 一次真实死锁排查的完整复盘5.1 现象描述与初步信息收集去年我协助某团队处理过一个线上死锁问题虽然不是生产环境但问题的完整链路非常有代表性。现象是压测期间数据库的错误日志里持续出现死锁报错频率约每分钟 20 次集中在账务模块。压测脚本模拟的是 200 个并发用户同时执行“下单、支付、查询订单”三个操作出现了大量ERROR 1213接口成功率从 99.9% 掉到了 93%。接到问题后的前 30 分钟我们没急着改代码而是先收集了三类信息死锁日志、慢查询日志、应用端报错的时间分布。死锁日志直接用SHOW ENGINE INNODB STATUS拉取配合innodb_print_all_deadlocksON的持久化日志慢查询日志是为了确认是否有 SQL 在死锁发生前出现了全表扫描应用端报错时间分布则用来判断死锁是否有固定的触发时段。从死锁日志看涉及两张表订单表orders和账户表accounts。两个事务的操作方向刚好相反事务 A 先更新accounts表再更新orders表事务 B 先更新orders表再更新accounts表。双方在各自的第一步都成功取得了锁然后互相等待对方持有的锁形成了教科书级别的循环等待。5.2 逐步缩小范围从 SQL 到代码路径确认是加锁顺序问题后下一步就是在代码里找出这两个事务分别对应什么接口。这里用到了一个技巧死锁日志里记录了事务执行过的 SQL但看不到是哪个接口触发的所以我把 SQL 和先前的执行语句拼起来再去代码库搜索。比如日志里事务 A 的 SQL 是UPDATE accounts SET frozen_amount frozen_amount 100 WHERE account_id 2001搜代码时发现这个语句出现在“冻结账户资金”的接口里事务 B 的 SQL 是UPDATE orders SET status paid WHERE order_id 10001对应的接口是“支付订单”。顺着调用链梳理后发现问题出在一个通用服务上支付成功后业务方需要同时更新订单状态和账户余额但由于历史原因这两张表的更新被拆成了两个不同的服务调用而这两个服务内部的 SQL 更新顺序不一致。事务 A 从账户服务入口开始先锁账户再锁订单事务 B 从订单服务入口开始先锁订单再锁账户。两个服务之间没有统一约定加锁顺序压测时并发量一上来死锁就开始爆发。修复方案有两步。第一步立即止血把两个服务内部对账户和订单的更新顺序统一为“先账户后订单”保证所有入口路径加锁顺序一致。第二步长期优化把支付成功后“更新订单状态”和“更新账户余额”合并成同一个本地事务从根本上消除跨服务加锁顺序不一致的问题。合并后确实也发现了一个收益接口响应时间从平均 80 毫秒降到了 30 毫秒因为本地事务比远程调用链少了几次网络开销。5.3 修复后的效果与复测验证修复上线前我们做了三轮回归测试第一轮用同之前的压测脚本200 并发连续跑 30 分钟死锁日志为 0第二轮把并发提高到 500持续 10 分钟死锁日志仍为 0第三轮故意引入一个反向更新顺序的服务节点模拟旧代码残留结果死锁日志马上重现验证了根因判断准确。这个复盘案例最终给我的经验是处理死锁问题第一步要忍住直接改代码的冲动先花时间把死锁日志读透。死锁日志本身就会告诉你两个事务各自的 SQL、执行顺序和锁等待关系顺着这些信息去反查代码路径往往能在半小时内定位根因。相反如果一上来就凭感觉优化 SQL很容易在错误的方向上空转。6. 高频死锁问题速查与处理清单6.1 常见报错与对应的处理方向结合我自己的排查经验整理了下面这张速查表遇到死锁报错时可以直接对照处理方向。报错信息典型根因优先排查方向ERROR 1213: Deadlock found循环等待查SHOW ENGINE INNODB STATUS分析锁等待环Lock wait timeout exceeded单个事务持锁过久查innodb_trx表定位长事务并分析其是否在做慢操作Deadlock found when trying to get lock锁冲突检查索引是否失效、SQL 锁范围是否过大Table was marked as crashed非死锁问题备份后执行表修复检查服务器异常断电等因素Deadlock 频发于某时间段定时任务或批量任务对比任务列表检查是否与定时任务时间重叠并发读多写少场景死锁快照读加锁冲突整理SELECT ... FOR UPDATE使用范围每一类问题对应的排查动作我在前面各个章节里都拆开写过。这里想补充的几点经验是遇到Lock wait timeout exceeded时别急着调大innodb_lock_wait_timeout先查持锁的长事务是什么如果是某条慢 SQL 把连接占了 30 秒调大超时只会让请求挂在数据库上更久反而加剧连接池耗尽。遇到批量任务和实时交易并发时最好把批量任务安排在业务低峰期至少让它们错开。6.2 死锁处理优先级清单处理死锁的正确顺序我总结为五步先确认现象再读日志然后定位根因接着评估修复方案最后回归验证。每一步有个检查项全部通过才算处理完成。确认现象死锁只影响少量请求还是大面积报错影响出现的时间段有没有规律读日志开没开innodb_print_all_deadlocksSHOW ENGINE INNODB STATUS里锁等待环理清楚了没有定位根因每个事务的 SQL 是什么加锁顺序是否一致索引是否有效事务持锁时间为何这么长评估方案优先级从高到低是统一加锁顺序、缩小锁范围、优化索引、缩短事务时间、调整隔离级别。回归验证压测 30 分钟以上观察死锁日志是否清零接口错误率是否恢复正常。这套流程我用了很多年从几万元的服务器到上亿日活的业务逻辑都适用。死锁处理本质上是一个“用证据说话”的过程证据收集得越完整修复方案就越精准。6.3 一些写在最后的实操细节处理死锁这些年我积累了一些零散的操作习惯写在这里给有需要的同行参考。第一启用innodb_print_all_deadlocks之后错误日志可能会变得很大需要配置日志轮转。我给的建议是同时开启系统自带的日志轮转MySQL 8.0 的log_error_services或者操作系统层面的 logrotate并保证日志磁盘空间充足。否则死锁日志没存几天磁盘先满了反而引发新的故障。第二观察SHOW ENGINE INNODB STATUS里的LOCK WAIT和HOLDS THE LOCK时注意区分记录锁和间隙锁。记录锁locks rec but not gap锁的是单行间隙锁locks gap before rec锁的是索引记录之间的区间。间隙锁引发的死锁比记录锁更隐蔽因为两个事务加锁的目标虽然不同但它们的间隙范围重叠了。这种情况下解决方案通常是缩小扫描范围或者把隔离级别降到读已提交。第三线上排查死锁时不要忽略数据库版本差异。MySQL 8.0 对死锁检测算法做了一些改进某些 5.7 环境下出现的死锁模式在 8.0 里可能不再出现反过来也成立。升级大版本后如果发现死锁行为变化先别急着骂数据库检查一下隔离级别、锁行为相关参数的默认值有没有变化。第四关于SELECT ... FOR UPDATE的一个小细节它加的锁在事务结束提交或回滚后才会释放。如果业务里用它做“乐观锁”的兜底一个事务内只查不加锁的更新反而更安全。不要在图省事的情况下给所有读操作都加FOR UPDATE这是很多死锁问题的潜在来源。第五处理死锁时要有备用方案意识。即使做了各种预防死锁在极端高并发下仍可能发生。一个优秀的后端系统不是追求死锁数为 0而是追求死锁发生时业务无感。无感的关键就是前面提到的重试机制和合理的超时设置。只要重试做好、超时合理偶发死锁对用户来说就是一次几十毫秒的延时增加而不是一次失败的请求。我个人在实际操作中的一个深刻体会是死锁问题从来不是数据库的锅大多数时候是 SQL 设计或事务设计的问题。与其每次发生死锁后疲于应付不如在上线前就把执行计划打开看一眼。就这一步能帮你避免后面无数个查日志、改代码、熬夜上线的夜晚。