Java

锁等待、热点行竞争把写接口拖慢时,应该先查什么?

当写接口变慢、读接口也开始被拖慢、连接池等待跟着升高时,不要只盯数据库 CPU。把“执行慢还是等待慢 -> 谁持锁太久 -> 热点行还是长事务”这几个问题依次拆开,才能更快把锁等待问题和应用层超时、排队串起来。

  • MySQL
  • 锁等待
  • 热点行
  • 长事务
  • 性能排查
19 分钟阅读

当你看到写接口 RT 抬高、事务接口偶发超时、连接池等待时间也开始变长时,第一反应通常会先去看数据库 CPU、慢 SQL 数量和 explain。但这类现场里,最容易被漏掉的一条主线其实是:数据库没有全面打满,不代表数据库等待链没有先把应用拖慢。

热点行竞争、锁等待、长事务和批量写操作,常常会先把请求拖进“等待型瓶颈”,然后沿着下面这条链一路放大:

热点行 / 锁等待 / 事务竞争 -> SQL 在执行前后花大量时间等待 -> 事务持有连接和锁的时间一起变长 -> 连接池归还速度下降 -> 请求线程和业务线程开始排队 -> 接口 RT 抬高、超时增多、重试继续放大

这篇文章不重复讲锁的基础概念,而是聚焦更贴近线上排障的问题:当锁等待、热点行和事务竞争已经把写接口拖慢时,应该怎么先分清方向、再找真正的持锁源头。

如果你还没分清是单接口慢、整组服务慢,还是数据库等待链已经外溢到应用层,可以先回到 接口响应慢怎么排查?后端 API 变慢与超时的定位步骤,或者先看 数据库没打满,为什么 API 和连接池已经开始变慢?

锁等待把写接口拖慢时

下面几种现象同时出现时,这篇更适合你:

你现在看到的现象更像什么下一步
写接口、状态流转接口、扣减接口先变慢热点写路径、事务竞争、锁等待继续看本文
读接口也开始跟着慢,但数据库 CPU 不算高锁等待已经外溢到连接池和线程排队继续看本文,再联动连接池页
获取连接时间变长,活跃连接偏高前面的事务把连接拿得更久了看完本文后接 连接池等待时间变长时,如何判断是数据库慢还是应用拿着不放?
explain 看着还行,但高峰期 SQL 停留时间明显变长更像等待慢,不只是执行慢explain 看起来没问题,SQL 还是很慢,接下来该查什么?
某个事务时间一直很长,提交很晚长事务是上游放大器事务执行时间过长,真正拖慢系统的往往不只是数据库

一、为什么锁等待问题最容易被误判成“数据库没事”

锁等待类问题最迷惑人的地方,在于它经常不长成“数据库资源全面打满”的样子。

你可能会看到这些现象:

  • 数据库 CPU 中高,但没到 100%
  • 磁盘 IO 也没有特别夸张
  • explain 看起来还过得去
  • 慢日志里不是所有 SQL 都慢得离谱

但与此同时,应用侧已经在报:

  • 接口 RT 持续上升
  • 获取连接变慢
  • 写接口超时增多
  • 线程池队列上涨

原因很简单:锁等待本质上是“等”,不是“算”。

事务拿到了连接,甚至已经跑到数据库里了,但它可能把大量时间花在:

  • 等别的事务提交
  • 等热点行释放锁
  • 等某批批量更新结束
  • 等长事务把资源释放回来

所以锁等待类问题经常会呈现一种“数据库没打满,但业务越来越慢”的矛盾感。

二、先看影响面和时间窗口,别急着翻 SQL 文本

遇到这类问题,第一步不要马上去翻某条 update 语句,先把影响范围看清。

至少先回答下面几个问题:

  • 是只有写接口慢,还是读接口也被一起拖慢
  • 是只有某一类业务键值慢,还是整组服务一起慢
  • 是高峰期慢,还是某次发布后开始慢
  • 是个别实例更严重,还是所有实例都在抖

1. 只有写接口明显变慢

更要优先怀疑:

  • 热点状态流转
  • 同一行反复更新
  • 事务边界太大
  • 同表的批量任务和在线请求撞车

2. 读接口也开始一起变慢

通常说明问题已经不只是单条更新语句慢,而是:

  • 锁竞争把更多事务拖住
  • 连接池开始紧张
  • 线程排队把整组服务吞吐拉低

3. 只在高峰期明显变差

更像:

  • 某个热点业务键在并发高时集中冲突
  • 重试和补偿把竞争继续放大
  • 请求数量一上来,锁等待从局部问题变成系统问题

4. 某次发布或某个任务窗口后开始变差

更要优先核对:

  • 最近是否改了事务边界
  • 是否新增了批量更新 / 对账 / 修复脚本
  • 是否把下游调用放进了事务
  • 是否把某个幂等更新改成了更高频的写路径

三、先把“执行慢”还是“等待慢”分清

锁等待、热点行和事务竞争最值钱的一步判断,就是把“慢”拆成两类。

1. 执行慢

更像是:

  • SQL 本身扫描范围大
  • 排序重
  • 回表重
  • 结果集过大
  • 应用处理过重

2. 等待慢

更像是:

  • SQL 本身不算很差,但在等锁
  • 事务本身不算很重,但在等别的事务提交
  • 连接已经借到了,但一直归还不回来
  • 应用线程卡在数据库返回或事务提交上

如果本质是等待慢,你继续只盯 explain 或 CPU,很容易越查越偏。这个时候更应该把时间窗口内的锁等待、事务时长、连接持有时长和接口 RT 放到同一时间窗里看。

四、真正的慢链路,通常是这样一步步传导的

锁等待问题很少只停留在数据库内部。它更常见的传导链路是下面这样。

第 1 段:热点行开始被反复竞争

典型场景包括:

  • 同一订单、库存、券状态被高并发更新
  • 同一用户记录被短时间反复改写
  • 计数器、余额、状态机类表行天然容易成为热点
  • 多个请求都在 update ... where id = ? 同一批主键

第 2 段:事务持锁时间变长

这时哪怕单条 SQL 本身不算特别复杂,只要:

  • 前面的事务没及时提交
  • 事务里夹了远程调用
  • 批量处理没有拆小
  • 某条事务被别的等待继续拖长

锁的持有时间就会被放大。

第 3 段:后续事务开始排队

后面来的请求会逐渐表现为:

  • 同一条更新语句耗时飘得很厉害
  • 同一接口时快时慢
  • 高峰期大量请求卡在事务里

你这时看到的 SQL “慢”,很多已经不是执行慢,而是等待慢。

第 4 段:连接池和线程池一起被拖慢

事务等待时间一长,连接也会被拿得更久,于是会出现:

  • 活跃连接越来越高
  • 空闲连接越来越少
  • 获取连接耗时变长
  • 请求线程排队、业务线程池堆积

第 5 段:接口 RT 和重试继续放大问题

如果此时应用层还有:

  • 超时重试
  • MQ 重投
  • 异步补偿
  • 网关重放

原本局部的锁竞争,会继续被流量和重试放大成更大的拥塞。

五、排查时,先找等待者,再找持锁者

如果线上已经出现这类症状,我更建议按下面的顺序排。

第 1 步:先对齐接口 RT、错误率和连接池信号

先确认问题是不是已经从数据库内部传导到了应用层。

重点看:

  • 哪些接口 RT 抬升最明显
  • 是否集中在写路径或状态流转接口
  • 获取连接耗时是否一起升高
  • 连接池活跃连接和等待线程是否上涨
  • 线程池队列是否也在涨

这一步的意义是确认:你处理的已经不是一条孤立 SQL,而是一条真实慢链路。

第 2 步:再看锁等待、活跃事务和长事务

这一步要尽量把下面几类信息放在一起看:

  • 锁等待是否明显升高
  • 活跃事务数量是否异常
  • 是否出现长事务长期不提交
  • 哪些表、哪些主键区间最容易冲突

如果你发现:

  • 锁等待增长
  • 活跃事务变多
  • 某几个写接口同一时间窗口一起慢

那基本就要把排查重心放到事务竞争,而不是单纯慢 SQL 上了。

第 3 步:识别谁是“持锁者”,谁是“等待者”

这一步是很多团队最容易跳过的,但其实最关键。

你要尽量找清楚:

  • 哪个事务先拿到了锁
  • 它为什么拿了这么久
  • 后面哪些请求在等它
  • 这些等待是不是集中在某一类业务键值上

这里最常见的真实根因是:

  • 某个事务里混进了外部调用
  • 同一事务里做了循环更新或批量处理
  • 批处理脚本一次锁住了太多热点记录
  • 多个事务更新顺序不一致,彼此竞争加剧

第 4 步:回到代码边界,看事务里到底包了什么

锁等待问题最终还是要回到代码和业务语义里解释。

重点问这些问题:

  • 事务里有没有 HTTP / RPC / MQ 回执等待
  • 事务里有没有循环查库、循环更新
  • 是否把原本可拆分的步骤全放进一个事务
  • 是否把热点状态更新设计成所有请求都要争同一行
  • 是否最近把幂等逻辑、乐观锁、状态机写法改重了

很多锁等待不是 DBA 视角里才看得懂的问题,而是代码边界本身就设计得太容易竞争。

第 5 步:最后再看是不是还叠加了慢 SQL 或实例资源问题

锁等待不等于数据库别的问题一定不存在。

如果同一时间窗口里还伴随:

  • explain 正常但 SQL 仍然很慢
  • 某些查询本身 rows 很高
  • 报表或批量任务占用实例资源
  • 连接池获取连接也在变慢

那说明锁等待可能只是链路里的一段,仍然要和下面几篇一起串起来看:

六、这类问题背后的常见根因,不只是“有锁竞争”这么简单

1. 热点行业务设计天然集中

最典型的是:

  • 同一库存记录
  • 同一优惠券 / 余额 / 配额记录
  • 同一订单状态行
  • 同一用户当天统计行

这类数据在高并发下天生容易变成热点,不是换个索引就能彻底解决。

2. 事务边界过大,把等待时间也包进去了

这类问题特别常见:

  • 先改库,再调下游,再回库提交
  • 在事务里等待外部确认
  • 在事务里做大量对象计算或循环操作

最后锁拿住的时间比业务真正需要的时间长得多。

3. 批处理和在线写请求撞车

例如:

  • 对账任务
  • 修复脚本
  • 批量状态迁移
  • 夜间补偿任务

这些任务如果一次更新太大、持锁太久,很容易把线上请求一起拖慢。

4. 重试和补偿把竞争继续放大

热点写路径如果本来就紧张,再叠加:

  • 应用超时重试
  • 消息重复消费
  • 网关或客户端重放

原本只是局部热点,最后会演变成整段链路的排队问题。

七、最容易出现的几个误判

误判 1:数据库 CPU 没打满,就说明瓶颈不在数据库

这在锁等待场景里经常是错的。

因为数据库这里的瓶颈不一定是计算瓶颈,也可能是等待瓶颈。

误判 2:只看 explain,觉得 SQL 没问题就结束排查

explain 解释不了谁在等锁,也解释不了谁把事务拖长了。

误判 3:连接池满了,就直接调大连接池

如果真正的问题是锁等待和事务竞争,扩连接只会让更多请求更快涌进同一段堵点。

误判 4:把所有责任都推给 DBA 或数据库参数

很多热点行问题的起点,其实在业务模型、事务边界和请求模式设计上。

误判 5:只盯单条慢 SQL,不看时间窗口和业务键值

热点冲突类问题往往高度依赖时间窗口、热点 key 或某类状态流转,不把这些维度拉出来,很难真正看懂。

八、FAQ:这类问题里最常被问到的几个问题

1. 锁等待一定会让数据库 CPU 很高吗?

不一定。

很多锁等待问题最明显的现象反而是:应用越来越慢,但数据库资源图没有那种“全面打爆”的感觉。

2. 为什么读接口也会被热点行竞争拖慢?

因为热点写事务一旦把连接、事务、线程排队一起拖长,读接口虽然不一定直接等同一把锁,也可能被共享连接池、共享线程池和同库等待链一起拖慢。

3. 同一条 SQL 有时候几十毫秒,有时候几秒,说明什么?

这通常就是等待型信号。

执行路径可能没怎么变,但它在某些时间窗口里卡在了锁等待或事务竞争上。

4. 锁等待和长事务是什么关系?

长事务往往是锁等待被放大的上游原因之一。事务提交越慢,锁持有时间越长,后面的事务越容易排队。

5. 这类问题第一反应应该先扩连接池还是先拆事务?

默认不要急着扩连接池。先确认连接为什么归还慢,再判断是数据库等待、事务边界过大,还是应用把连接拿着不放。大多数情况下,收短事务边界、拆热点写路径,收益比直接扩池更高。

九、锁等待坐实以后,下一步别只盯着锁本身

后面往哪边继续收,要看你现在掌握的证据更像哪一段链路:

我想强调的不是“锁等待很复杂”,而是别把它误当成一条孤立的数据库指标。真正能收口的,还是要回到谁把锁拿太久、热点为什么形成、事务为什么拖长,以及它怎么一路放大到连接池、线程池和接口 RT。把这条因果链接完整,修复动作才不会停在表面。