线上死锁到支付超时?这份锁调优指南帮你救场
线上死锁到支付超时这份锁调优指南帮你救场前年618大促晚8点峰值的时候我们的支付接口突然大面积报错超时率直接飙到40%监控显示数据库CPU不高但连接数瞬间打满一堆请求卡着不动。我当时紧急登上去排查发现光等待行锁的事务就堆了300多个还有好几个死锁被回滚最后查了半天根源就是两个支付相关的事务更新表的顺序写反了高峰期流量大了之后直接触发死锁后面的请求全堵在锁等待上整个故障持续了12分钟影响了几十万笔支付。很多开发写事务的时候只关心能不能正确回滚根本不关心锁的问题直到线上出了事故才追悔莫及。我做了这么多年后端处理过的锁相关事故不下三十次今天把事务和锁调优的所有实战经验分享给你从锁原理、排查方法到避坑指南全给你讲透看完你也能从容应对线上的锁问题。事务锁调优与线上冲突排查实战一、先搞懂InnoDB的锁到底是怎么加的很多人写了好几年代码对锁的认知还停留在“写会加锁读不会加锁”的层面连锁到底加在哪、什么情况下会加什么锁都搞不清楚写出导致锁冲突的代码是必然的。1、行锁不是锁记录本身是锁索引InnoDB的行锁是加在索引上的不是加在数据行本身的这个点90%的新手都不知道。如果你更新数据的时候where条件没有走索引InnoDB会进行全表扫描扫描到每一行的时候都会加上行锁最后等于把整个表都锁了这就是很多人疑惑“我明明加的是行锁为什么把整个表锁死了”的根本原因。举个最简单的例子你有张100万行的用户表phone字段建了普通索引你执行update user_info set status0 where phone13800138000这个语句走phone的索引只会锁phone等于这个值的那一行其他行的读写完全不受影响。但如果你写的时候参数传了数字类型导致隐式转换索引失效MySQL全表扫描的时候会给所有100万行都加上行锁这时候其他所有对这张表的写操作都会被堵死直到这个事务提交我之前就被这个坑害过一次故障锁表18秒整个用户中心的写请求全超时最后还是紧急kill掉事务才恢复。2、你以为的读不加锁其实是分场景的很多人以为select语句永远不会加锁实际上select分两种快照读和当前读。普通的select语句是快照读基于MVCC读历史版本不会加任何锁不管你怎么查都不会阻塞别人也不会被别人阻塞。但是如果你用了select ... for update、select ... lock in share mode或者你执行update、delete、insert语句这些都是当前读会读取数据的最新版本并且会加对应的锁是会产生锁冲突的。这里还要注意哪怕是普通select在Serializable隔离级别下也会加共享 next-key 锁所以我们线上基本不会用最高隔离级别锁冲突会特别严重。3、RR级别下的间隙锁是死锁的重灾区InnoDB在可重复读RR隔离级别下为了解决幻读问题会加临键锁Next-Key Lock临键锁是行锁间隙锁的组合不仅会锁住匹配到的行还会锁住行前后的间隙防止别的事务在间隙里插入新数据。很多死锁都是这个间隙锁导致的因为间隙锁之间是不冲突的间隙锁和插入意向锁是冲突的很容易出现两个事务互相持有间隙锁等待对方释放的死锁场景。我给你举个特别常见的场景我们有张用户领券表给user_id和coupon_id建了联合唯一索引防止用户重复领券。两个请求同时过来领同一张券都是先select查一下有没有领过没有的话就insert。RR隔离级别下两个事务的select当前读都会给(负无穷, 这个user_idcoupon_id)的间隙加上共享间隙锁然后两个事务都要执行insert都需要拿这个间隙的插入意向锁但是插入意向锁和对方持有的共享间隙锁冲突于是两个事务就互相等对方释放锁直接死锁。这种场景我在线上见过不下十次每次高并发发券的时候必出后来我们把隔离级别改成读提交RCRC下没有间隙锁直接就解决了这个问题。为了方便你理解我把InnoDB最常见的几种锁类型整理成了表格表格锁类型 锁范围 触发场景 冲突情况记录锁 单条索引记录 等值查询精确匹配到唯一索引/主键的记录 和其他事务的X锁、S锁冲突间隙锁 两个索引记录之间的间隙 RR隔离级别下的范围查询、等值查询未匹配到记录时 和插入意向锁冲突间隙锁之间不冲突临键锁 记录前面的间隙 RR隔离级别下的普通索引范围查询默认的行锁算法 和其他事务的插入、写锁冲突表锁 整张表 没走索引的全表更新/删除、DDL操作 阻塞所有其他写操作插入意向锁 插入位置的间隙 insert执行前加的锁 和间隙锁、临键锁冲突二、线上90%的锁问题逃不出这几个场景我梳理了这些年处理过的锁故障发现90%的锁冲突、死锁问题本质上都是几个固定的写法错误导致的你写代码的时候避开这些坑基本不会遇到大的锁问题。1、最常见的死锁多事务加锁顺序不一致死锁的四个必要条件大家都背过互斥、持有并等待、不可剥夺、循环等待线上90%的死锁都是因为不同的业务逻辑对多张表或者多行记录的加锁顺序不一致最后形成循环等待。比如我们之前的支付故障就是两个事务的加锁顺序反了支付回调的逻辑是先更新支付流水表再更新订单表再扣减库存而用户取消订单的逻辑是先更新订单表再更新库存表再回滚支付流水。高峰期的时候刚好有个事务在更新支付流水等订单表的锁另一个事务更新了订单表等支付流水的锁两个事务就互相卡住形成死锁。MySQL检测到死锁之后会回滚代价小的那个事务但是如果请求量特别大就会不断有事务进入等待队列最后把连接池打满。还有多行更新的顺序问题比如你一个事务要更新id1、id2、id3三个订单的状态另一个事务要更新id3、id2、id1三个订单的状态刚好交叉执行也会死锁。所以我们组里有硬性规定同一个业务里更新多张表必须固定顺序永远按照 订单表→支付表→库存表→用户表的顺序更新多行更新的时候永远按照主键升序的顺序更新从根源上避免循环等待。2、最隐蔽的坑大事务长时间持锁这个问题出现的频率特别高而且特别隐蔽很多人写代码的时候图方便把一堆无关的逻辑都包在一个Transactional注解里什么查数据库、调第三方接口、发MQ消息、查缓存全扔在事务里导致事务执行时间特别长持有的锁很久都不释放后面所有要更新这些行的请求全堵在锁等待上最后雪崩。我刚工作的时候就踩过这个坑当时写一个批量给用户发优惠券的功能直接给整个方法加了事务里面循环10万次给用户发券还在循环里调了短信接口给用户发短信那个短信接口高峰期超时要3秒结果整个事务跑了5分钟锁了优惠券表的10万行数据导致用户端领券的接口全超时最后我还没跑完数据库连接池就打满了整个服务雪崩被领导骂了一晚上。从那之后我写事务永远先看事务里有没有耗时操作所有RPC调用、发消息、调外部接口全挪到事务提交之后再执行事务里只留最必要的数据库增删改操作尽量让事务在几毫秒内提交尽快释放锁。我给你举个反面例子和正确例子的对比java// 反面写法事务里包了RPC调用大事务持锁时间长Transactionalpublic void paySuccess(Long orderId) {// 1、更新订单状态持锁开始orderMapper.updateStatus(orderId, 2);// 2、调积分接口给用户加积分这个接口可能超时3秒锁被持有3秒pointsClient.addPoints(order.getUserId(), 100);// 3、发短信通知用户耗时1秒smsClient.sendSms(order.getMobile(), 支付成功);// 4、事务提交锁才释放一共持锁4秒}java// 正确写法事务里只做数据库操作耗时操作移到事务外public void paySuccess(Long orderId) {// 1、事务内只做必要更新几毫秒提交释放锁Order order doPaySuccess(orderId);// 2、事务提交后再做耗时操作不持锁try {pointsClient.addPoints(order.getUserId(), 100);smsClient.sendSms(order.getMobile(), 支付成功);} catch (Exception e) {// 失败了用MQ重试不影响主流程mqClient.sendRetryMsg(orderId);}}Transactionalpublic Order doPaySuccess(Long orderId) {orderMapper.updateStatus(orderId, 2);return orderMapper.selectById(orderId);// 事务立刻提交锁只持有几毫秒}就这么一个小小的改动持锁时间从几秒降到几毫秒锁冲突的概率直接降低了99%根本不会出现锁等待堵一堆请求的情况。3、最容易被忽略的问题间隙锁导致的死锁前面讲过RR隔离级别下的间隙锁问题除了先查后插的场景还有个特别常见的场景就是批量insert和delete冲突。比如你定时删7天前的日志delete from log where create_time 2025-06-01这个语句在RR级别下会给所有符合条件的记录加临键锁还会给最后一个记录后面的间隙加锁如果这时候刚好有新的日志插入到这个间隙里就会和delete的锁冲突甚至出现死锁。我们之前就遇到过凌晨跑删除日志的定时任务刚好赶上夜间批量导入数据导入的insert请求和delete请求互相等锁死锁了一晚上早上上班看监控一晚上有200多个死锁。后来我们做了两个优化一是把隔离级别改成RC去掉间隙锁二是删除的时候不要一个delete删几万条每次删1000条删完立刻提交避免长时间持锁改完之后再也没出现过这个场景的死锁。4、最低级的错误索引失效导致锁全表这个错误虽然低级但是线上出现的频率特别高很多人写update、delete的时候不注意where条件的字段类型、函数导致索引失效全表扫描锁全表。就像我之前举的例子varchar类型的字段传数字参数导致隐式转换对字段加函数导致索引失效where条件的字段根本没建索引这些情况都会导致全表扫描扫一行锁一行最后等于锁了整张表。很多人对索引失效的认知还停留在“查询会变慢”根本没意识到还会导致锁表这才是最危险的查询慢最多是接口响应慢锁表直接会导致整个服务的写请求全堵是P0级别的故障。所以我们组里有规定所有update、delete语句上线前必须跑EXPLAIN确认type不是ALL确实走了索引才能上线从流程上避免这种低级错误。三、线上锁问题的排查方法拿来就能直接用很多人线上遇到锁问题就慌不知道从哪下手其实锁问题排查是有固定流程的只要记住几个常用的SQL和工具5分钟就能定位到问题根源。1、紧急恢复快速找到阻塞的事务并kill线上遇到锁等待导致连接池打满的时候第一步先恢复业务不要先想着查根因。你直接跑下面这个SQL就能查出当前所有的锁等待关系谁堵了谁、堵了多久、执行的是什么SQL一目了然sql-- 直接查询锁等待链路拿来就能用SELECTr.trx_id AS waiting_trx_id,r.trx_mysql_thread_id AS waiting_thread,TIMESTAMPDIFF(SECOND, r.trx_wait_started, NOW()) AS wait_seconds,r.trx_query AS waiting_query,b.trx_id AS blocking_trx_id,b.trx_mysql_thread_id AS blocking_thread,b.trx_query AS blocking_query,b.trx_started AS blocking_trx_start_timeFROM information_schema.innodb_lock_waits wINNER JOIN information_schema.innodb_trx b ON b.trx_id w.blocking_trx_idINNER JOIN information_schema.innodb_trx r ON r.trx_id w.requesting_trx_idORDER BY wait_seconds DESC;如果看到堵了很久的事务而且是那种异常的大事务或者写错的SQL直接执行kill 加上blocking_thread的线程id把阻塞的事务杀掉锁立刻就释放了业务几秒钟就能恢复然后再慢慢查根因。这里要注意kill线程的时候要先看一下blocking_trx_start_time确认那个事务真的是异常的不要把正常跑的长事务杀掉了比如后台统计的任务。2、死锁排查看懂死锁日志如果是死锁问题你直接执行show engine innodb status找到LATEST DETECTED DEADLOCK这一段就是最近一次死锁的详细日志里面会记录两个事务分别持有什么锁、等待什么锁、执行的什么SQL、最后回滚了哪个事务。我给你截一段典型的死锁日志片段教你怎么看textLATEST DETECTED DEADLOCK------------------------2025-06-18 20:00:12 7f8a1c3d7700*** (1) TRANSACTION: // 第一个事务TRANSACTION 2345678, ACTIVE 2 sec starting index readmysql tables in use 1, locked 1LOCK WAIT 2 lock struct(s), heap size 360, 1 row lock(s)MySQL thread id 1234, OS thread handle 0x7f8a1c3d9700, query id 98765 localhost root updatingUPDATE order_info SET status 2 WHERE id 1001 // 事务1在执行更新id1001的订单*** (1) WAITING FOR THIS LOCK TO BE GRANTED:RECORD LOCKS space id 12 page no 34 n bits 72 index PRIMARY of table test.order_info trx id 2345678 lock_mode X locks rec but not gap waitingRecord lock, heap no 4 PHYSICAL RECORD: n_fields 5; ... // 事务1在等主键1001的行锁*** (2) TRANSACTION: // 第二个事务TRANSACTION 2345679, ACTIVE 3 sec starting index readmysql tables in use 1, locked 13 lock struct(s), heap size 360, 2 row lock(s)MySQL thread id 1235, OS thread handle 0x7f8a1c457700, query id 98766 localhost root updatingUPDATE pay_flow SET status 1 WHERE order_id 1001 // 事务2在更新支付流水*** (2) HOLDS THE LOCK(S): // 事务2持有主键1001的行锁RECORD LOCKS space id 12 page no 34 n bits 72 index PRIMARY of table test.order_info trx id 2345679 lock_mode X locks rec but not gapRecord lock, heap no 4 PHYSICAL RECORD: n_fields 5; ...*** (2) WAITING FOR THIS LOCK TO BE GRANTED:RECORD LOCKS space id 15 page no 45 n bits 80 index PRIMARY of table test.pay_flow trx id 2345679 lock_mode X locks rec but not gap waitingRecord lock, heap no 6 PHYSICAL RECORD: n_fields 6; ... // 事务2在等支付流水表的行锁*** WE ROLL BACK TRANSACTION (1) // 回滚了事务1你看这个日志看得很清楚事务1先更新了支付流水拿到了支付流水的锁然后要更新订单1001等订单的锁事务2先更新了订单1001拿到了订单的锁然后要更新支付流水等支付流水的锁两个循环等待形成死锁最后回滚了事务1。找到问题之后把两个事务的更新顺序改成一致的永远先更新订单再更新支付流水死锁就解决了。3、提前预警做好监控不要等故障了才发现不要等锁堵死了业务才去排查平时就要做好监控1、监控Innodb_row_lock_waits行锁等待次数、Innodb_row_lock_time_avg平均行锁等待时间、Innodb_deadlocks死锁次数这几个指标超过阈值立刻告警。2、监控执行时间超过5秒的长事务只要有事务执行超过5秒没提交就告警通知开发检查避免长事务持锁太久。3、不要在业务高峰期跑批量更新、DDL、数据订正这些任务这些任务都放到凌晨业务低峰期跑避免和线上业务抢锁。四、避免锁冲突的最佳实践都是线上踩坑踩出来的这些年我总结了一套写事务和SQL的规范我们组严格执行之后线上锁相关的故障直接降了90%死锁更是几个月都出不了一次你直接照着用就行。1、永远缩小事务范围事务越短越好不要给整个方法加Transactional只给需要事务的那几行数据库操作加事务所有RPC调用、HTTP请求、发消息、文件操作这种耗时操作全部移到事务外面执行事务里只做最必要的增删改尽量让事务在100毫秒以内提交越快释放锁越好。2、所有更新操作必须固定加锁顺序不管是更新多张表还是多行数据永远固定一个统一的顺序多表更新按照表的依赖关系固定顺序永远先更新主表再更新关联表多行更新永远按照主键升序的顺序更新所有业务代码都遵守这个顺序从根源上破坏死锁的循环等待条件。3、保证update/delete的where条件都走索引所有写操作上线前必须跑EXPLAIN确认走了索引type不能是ALL避免索引失效导致全表锁。尽量用主键或者唯一索引做更新条件锁的粒度最小只锁需要的那一行不要锁无关的行。4、避免大事务分批操作批量更新、批量删除的时候不要一个SQL跑几万几十万条数据每次操作100-1000条提交一次分多批执行避免一个事务锁太多行、持锁时间太长。比如你要删10万条历史数据每次删1000条sleep 100毫秒再删下一批既不会堵线上业务也不会产生大事务。5、合理选择隔离级别如果你的业务不需要严格的可重复读直接把数据库隔离级别改成RC读提交RC级别下没有间隙锁锁的粒度更小持锁时间更短还支持半一致性读能大幅减少死锁和锁冲突的概率。现在互联网公司90%的业务场景用RC都没问题我们所有核心业务库跑了五六年RC从来没出过数据一致性问题。6、尽量不要用先查再写的逻辑很多人喜欢写“先select查一下有没有没有再insert”这种逻辑高并发下不仅会有并发问题还容易触发间隙锁死锁。正确的做法是给表建唯一索引直接insert遇到唯一键冲突就捕获异常做更新操作也就是upsert不需要提前查既安全性能又好还不会有锁冲突。我一直觉得锁相关的故障本质上都不是数据库的问题都是开发写代码不注意细节埋的坑。很多人觉得锁是DBA要关心的事和业务开发没关系实际上90%的锁问题都是业务代码写的不规范导致的你只要写事务的时候多注意一下事务范围、加锁顺序、索引这几个细节就能避开绝大多数的坑。数据库的锁从来不是洪水猛兽你理解它的逻辑遵守它的规则它就会老老实实工作你不按规则来它早晚会给你个大事故。注意本文所介绍的软件及功能均基于公开信息整理仅供用户参考。在使用任何软件时请务必遵守相关法律法规及软件使用协议。同时本文不涉及任何商业推广或引流行为仅为用户提供一个了解和使用该工具的渠道。你在生活中时遇到了哪些问题你是如何解决的欢迎在评论区分享你的经验和心得希望这篇文章能够满足您的需求如果您有任何修改意见或需要进一步的帮助请随时告诉我感谢各位支持可以关注我的个人主页找到你所需要的宝贝。作者郑重声明本文内容为本人原创文章纯净无利益纠葛如有不妥之处请及时联系修改或删除。诚邀各位读者秉持理性态度交流共筑和谐讨论氛围