线上死锁到支付超时?这份锁调优指南帮你救场

 线上死锁到支付超时?这份锁调优指南帮你救场

前年618大促晚8点峰值的时候,我们的支付接口突然大面积报错,超时率直接飙到40%,监控显示数据库CPU不高,但连接数瞬间打满,一堆请求卡着不动。我当时紧急登上去排查,发现光等待行锁的事务就堆了300多个,还有好几个死锁被回滚,最后查了半天,根源就是两个支付相关的事务更新表的顺序写反了,高峰期流量大了之后直接触发死锁,后面的请求全堵在锁等待上,整个故障持续了12分钟,影响了几十万笔支付。很多开发写事务的时候只关心能不能正确回滚,根本不关心锁的问题,直到线上出了事故才追悔莫及。我做了这么多年后端,处理过的锁相关事故不下三十次,今天把事务和锁调优的所有实战经验分享给你,从锁原理、排查方法到避坑指南全给你讲透,看完你也能从容应对线上的锁问题。

事务锁调优与线上冲突排查实战‌

一、先搞懂:InnoDB的锁到底是怎么加的

很多人写了好几年代码,对锁的认知还停留在“写会加锁,读不会加锁”的层面,连锁到底加在哪、什么情况下会加什么锁都搞不清楚,写出导致锁冲突的代码是必然的。

1、行锁不是锁记录本身,是锁索引

InnoDB的行锁是加在索引上的,不是加在数据行本身的,这个点90%的新手都不知道。如果你更新数据的时候where条件没有走索引,InnoDB会进行全表扫描,扫描到每一行的时候都会加上行锁,最后等于把整个表都锁了,这就是很多人疑惑“我明明加的是行锁,为什么把整个表锁死了”的根本原因。

举个最简单的例子,你有张100万行的用户表,phone字段建了普通索引,你执行update user_info set status=0 where phone='13800138000',这个语句走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_id+coupon_id)的间隙加上共享间隙锁,然后两个事务都要执行insert,都需要拿这个间隙的插入意向锁,但是插入意向锁和对方持有的共享间隙锁冲突,于是两个事务就互相等对方释放锁,直接死锁。这种场景我在线上见过不下十次,每次高并发发券的时候必出,后来我们把隔离级别改成读提交(RC),RC下没有间隙锁,直接就解决了这个问题。

为了方便你理解,我把InnoDB最常见的几种锁类型整理成了表格:

表格

锁类型 锁范围 触发场景 冲突情况

记录锁 单条索引记录 等值查询,精确匹配到唯一索引/主键的记录 和其他事务的X锁、S锁冲突

间隙锁 两个索引记录之间的间隙 RR隔离级别下的范围查询、等值查询未匹配到记录时 和插入意向锁冲突,间隙锁之间不冲突

临键锁 记录+前面的间隙 RR隔离级别下的普通索引范围查询,默认的行锁算法 和其他事务的插入、写锁冲突

表锁 整张表 没走索引的全表更新/删除、DDL操作 阻塞所有其他写操作

插入意向锁 插入位置的间隙 insert执行前加的锁 和间隙锁、临键锁冲突

二、线上90%的锁问题,逃不出这几个场景

我梳理了这些年处理过的锁故障,发现90%的锁冲突、死锁问题,本质上都是几个固定的写法错误导致的,你写代码的时候避开这些坑,基本不会遇到大的锁问题。

1、最常见的死锁:多事务加锁顺序不一致

死锁的四个必要条件大家都背过:互斥、持有并等待、不可剥夺、循环等待,线上90%的死锁都是因为不同的业务逻辑,对多张表或者多行记录的加锁顺序不一致,最后形成循环等待。

比如我们之前的支付故障,就是两个事务的加锁顺序反了:支付回调的逻辑是先更新支付流水表,再更新订单表,再扣减库存;而用户取消订单的逻辑是先更新订单表,再更新库存表,再回滚支付流水。高峰期的时候刚好有个事务在更新支付流水,等订单表的锁,另一个事务更新了订单表,等支付流水的锁,两个事务就互相卡住,形成死锁。MySQL检测到死锁之后会回滚代价小的那个事务,但是如果请求量特别大,就会不断有事务进入等待队列,最后把连接池打满。

还有多行更新的顺序问题,比如你一个事务要更新id=1、id=2、id=3三个订单的状态,另一个事务要更新id=3、id=2、id=1三个订单的状态,刚好交叉执行,也会死锁。所以我们组里有硬性规定:同一个业务里,更新多张表必须固定顺序,永远按照 订单表→支付表→库存表→用户表的顺序更新,多行更新的时候永远按照主键升序的顺序更新,从根源上避免循环等待。

2、最隐蔽的坑:大事务长时间持锁

这个问题出现的频率特别高,而且特别隐蔽,很多人写代码的时候图方便,把一堆无关的逻辑都包在一个@Transactional注解里,什么查数据库、调第三方接口、发MQ消息、查缓存,全扔在事务里,导致事务执行时间特别长,持有的锁很久都不释放,后面所有要更新这些行的请求全堵在锁等待上,最后雪崩。

我刚工作的时候就踩过这个坑,当时写一个批量给用户发优惠券的功能,直接给整个方法加了事务,里面循环10万次给用户发券,还在循环里调了短信接口给用户发短信,那个短信接口高峰期超时要3秒,结果整个事务跑了5分钟,锁了优惠券表的10万行数据,导致用户端领券的接口全超时,最后我还没跑完,数据库连接池就打满了,整个服务雪崩,被领导骂了一晚上。从那之后我写事务,永远先看事务里有没有耗时操作,所有RPC调用、发消息、调外部接口,全挪到事务提交之后再执行,事务里只留最必要的数据库增删改操作,尽量让事务在几毫秒内提交,尽快释放锁。

我给你举个反面例子和正确例子的对比:

java

// 反面写法:事务里包了RPC调用,大事务持锁时间长

@Transactional

public 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);

}

}

@Transactional

public 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

-- 直接查询锁等待链路,拿来就能用

SELECT

r.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_time

FROM information_schema.innodb_lock_waits w

INNER JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id

INNER JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id

ORDER BY wait_seconds DESC;

如果看到堵了很久的事务,而且是那种异常的大事务或者写错的SQL,直接执行kill 加上blocking_thread的线程id,把阻塞的事务杀掉,锁立刻就释放了,业务几秒钟就能恢复,然后再慢慢查根因。

这里要注意,kill线程的时候,要先看一下blocking_trx_start_time,确认那个事务真的是异常的,不要把正常跑的长事务杀掉了,比如后台统计的任务。

2、死锁排查:看懂死锁日志

如果是死锁问题,你直接执行show engine innodb status,找到LATEST DETECTED DEADLOCK这一段,就是最近一次死锁的详细日志,里面会记录两个事务分别持有什么锁、等待什么锁、执行的什么SQL、最后回滚了哪个事务。

我给你截一段典型的死锁日志片段,教你怎么看:

text

LATEST DETECTED DEADLOCK

------------------------

2025-06-18 20:00:12 7f8a1c3d7700

*** (1) TRANSACTION: // 第一个事务

TRANSACTION 2345678, ACTIVE 2 sec starting index read

mysql tables in use 1, locked 1

LOCK 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 updating

UPDATE order_info SET status = 2 WHERE id = 1001 // 事务1在执行更新id=1001的订单

*** (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 waiting

Record lock, heap no 4 PHYSICAL RECORD: n_fields 5; ... // 事务1在等主键1001的行锁

*** (2) TRANSACTION: // 第二个事务

TRANSACTION 2345679, ACTIVE 3 sec starting index read

mysql tables in use 1, locked 1

3 lock struct(s), heap size 360, 2 row lock(s)

MySQL thread id 1235, OS thread handle 0x7f8a1c457700, query id 98766 localhost root updating

UPDATE 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 gap

Record 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 waiting

Record 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%的锁问题都是业务代码写的不规范导致的,你只要写事务的时候多注意一下事务范围、加锁顺序、索引这几个细节,就能避开绝大多数的坑。数据库的锁从来不是洪水猛兽,你理解它的逻辑,遵守它的规则,它就会老老实实工作,你不按规则来,它早晚会给你个大事故。

💡注意:本文所介绍的软件及功能均基于公开信息整理,仅供用户参考。在使用任何软件时,请务必遵守相关法律法规及软件使用协议。同时,本文不涉及任何商业推广或引流行为,仅为用户提供一个了解和使用该工具的渠道。

你在生活中时遇到了哪些问题?你是如何解决的?欢迎在评论区分享你的经验和心得!

希望这篇文章能够满足您的需求,如果您有任何修改意见或需要进一步的帮助,请随时告诉我!

感谢各位支持,可以关注我的个人主页,找到你所需要的宝贝。

 作者郑重声明,本文内容为本人原创文章,纯净无利益纠葛,如有不妥之处,请及时联系修改或删除。诚邀各位读者秉持理性态度交流,共筑和谐讨论氛围~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

山峰哥

你的鼓励将是我创作的最大动力!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值