Java

读多写少的接口为什么也会被写事务拖慢?

读接口慢,不一定是查询自己有问题。很多读多写少的接口,真正慢在写事务把锁、连接、副本延迟和数据库等待链一起拖长了。更有用的做法,是先判断读接口到底慢在执行还是等待,再沿写事务外溢到读链路的共享等待链逐层收敛。

  • MySQL
  • 长事务
  • 锁等待
  • 读写冲突
  • 性能排查
18 分钟阅读

很多团队第一次遇到这类问题时,都会有点不服气。

明明这个接口:

  • 读多写少
  • SQL 主要是 select
  • 平时也没什么复杂更新
  • explain 看着也还可以

可一到写流量高峰、批量任务窗口或者某类状态更新变多的时候,偏偏就是这些“读接口”先开始变慢。

于是现场很容易冒出几种判断:

  • 是不是读 SQL 自己退化了
  • 是不是数据库实例资源不够了
  • 是不是缓存命中率掉了
  • 读不是 MVCC 吗,为什么还会被写事务拖慢

这些方向都可能成立,但更接近真实现场的情况通常是:读接口并不是直接被某一条写 SQL 打慢,而是被写事务拉长了整条共享等待链。

常见传导路径往往是这样的:

写事务变长 -> 锁释放变慢、连接归还变慢、副本延迟变大 -> 读请求开始等连接、等热点对象、等副本追平,或者被迫回到主库 -> 读接口 RT 抬高,缓存回填和上游请求继续放大

所以这里不展开“读写锁原理”,只回答一个更接近值班现场的问题:

当一个读多写少的接口,明显是在写事务高峰时变慢的,怎么判断它慢在锁、慢在连接、慢在副本,还是慢在缓存回源后的数据库等待链?

如果你已经发现读接口在写高峰、批任务窗口或状态更新窗口里变慢,这篇就能直接接上。

如果读多写少的接口在写高峰变慢

你现在看到的现象更适合先看哪条线为什么
读接口主要在写高峰、批任务窗口、状态更新窗口变慢就按本文这条外溢链往下拆这正是写事务外溢到读链路的典型场景
读 SQL 平峰也慢,和写流量变化关系不大更偏读 SQL 执行成本不要把纯查询问题硬塞到这条线
读接口拿连接越来越慢,连接池和写接口一起恶化先沿本文拆共享等待,再接连接池页共享连接池往往是第一放大器
从库延迟升高,部分读请求被切回主库先把本文里的副本链路看完读写分离路径已经开始失效
读接口里有锁定读、状态校验后更新、强一致读主库先把读路径里的这些成分拆清楚这说明它并不是纯读路径
缓存 miss 变多,但写事务高峰同时也在拉长数据库等待先把本文里的回源与写事务叠加段核清楚这往往是缓存回源和写事务叠加的后半段场景

先把这条外溢链的边界划清

这篇不打算替你回答“读 SQL 为什么慢”,也不是从缓存那条线起步的第一篇。

我更想先拆开一个现场里最容易混掉的问题:为什么写事务问题会继续外溢到读接口。

和已有文章的分工可以这样理解:

所以如果你现在真正卡的是读 SQL 自己慢、排序重或缓存命中率下降,就别把所有现象都收进“写事务拖慢读链路”这一条里。

第一轮先判断慢点落在哪一段

如果你现在怀疑读接口被写事务拖慢,我建议第一轮先把这几类信号放进同一个时间窗:读接口 RT、写接口 RT、连接池 pending、活跃事务、从库延迟、缓存 miss / 回源量。为什么先抓这些?因为“读接口变慢”很容易先让人去看 explain,但这类场景真正要先证明的,是写事务到底把读链路拖到了锁、连接、副本,还是缓存回源这一段。

遇到读接口在写高峰里一起变慢时,先不要直接看 explain,先把下面这几步切一遍:

  1. 先对齐读接口 RT、写接口 RT、活跃事务、连接池 pending、从库延迟是不是同时间窗一起抬高。
  2. 再确认这个“读接口”到底是不是纯读路径,还是混了锁定读、查后改、强一致回主库等逻辑。
  3. 再判断它更像慢在拿连接、等锁 / 等副本,还是慢在缓存 miss 后的数据库回填。
  4. 最后再决定下一步更该回哪条线:更像长事务源头,就回长事务根因页;更像连接先被拖紧,就看连接池那篇;更像缓存 miss 在前面先放大,就转去缓存回源链。

这样做的目的,是先证明“读接口到底被写事务拖到了哪一段”,而不是把所有现象都误收敛成读 SQL 自己退化。

先纠正一个误区:读请求不会天然免疫写事务

很多人看到“读接口”这三个字,脑子里默认的是一种理想情况:

  • 纯快照读
  • 只走普通 select
  • 不加锁
  • 不依赖实时写后结果
  • 不和写路径共享紧张资源

如果现实真的满足这五个条件,读接口当然不容易被写事务直接拖慢。

但真实项目里,很多所谓“读接口”其实并没有这么干净。

1. 读接口可能并不是纯快照读

常见场景包括:

  • 读详情时顺手做状态校验,带了 for update 或锁定读
  • 先查再改的流程被封装成“查询接口”
  • 读取的是刚写完就必须可见的状态,强制走主库
  • 查询里混进了会等待元数据锁、行锁或事务提交的路径

这时它虽然名字叫读接口,本质上已经不只是“普通 select”。

2. 读接口和写接口共享数据库连接池

这类更常见。

只要写事务把连接拿得更久:

  • 活跃连接数会上升
  • 空闲连接数会减少
  • 获取连接耗时会变长

读接口即使自己的 SQL 很轻,也可能在“真正开始查库之前”就已经慢了。

3. 读接口依赖副本,但写事务会把副本延迟拉高

当写事务多、提交慢、binlog 应用慢时,经常会出现:

  • 从库延迟上升
  • 读流量因一致性要求被切回主库
  • 主库读写混跑,整体等待继续变长

这时候读接口慢,不是因为读 SQL 本身变坏了,而是因为它失去了原来那条较轻的读路径。

4. 写事务会把数据库等待从“点问题”放大成“链问题”

长事务、高并发更新、热点状态流转一上来,数据库里的等待会顺着下面这条线传导:

写事务持锁更久 -> 后续事务和部分读请求等待更久 -> 连接归还更慢 -> 读接口拿连接、查主库、回填缓存都一起变慢

所以读接口被写事务拖慢,很多时候不是例外,而是共享资源和等待链的自然结果。

读多写少接口最常见的 4 条被拖慢路径

1. 写事务把连接拿太久,读接口先慢在“拿连接”

这是最常见的一类。

现场通常会看到:

  • 写接口 RT 先抬高
  • 数据库连接池 active 持续高位
  • idle 接近 0
  • 读接口没有明显慢 SQL,但获取连接耗时上升

这种场景里,读接口真正慢的往往不是 SQL 执行,而是:

  • 写事务没有及时提交
  • 事务里夹了远程调用或循环处理
  • 连接迟迟不归还
  • 读请求排队等连接

如果你只去看读 SQL,很容易一直查不到关键点。

2. 写事务制造热点锁,读接口慢在“读路径并不纯”

这类问题更隐蔽。

有些接口虽然大部分时间是读,但在关键分支里会:

  • 查状态后立刻更新
  • 读取库存、额度、订单时做锁定读
  • select ... for update 保证并发一致性

这时一旦写事务在同一批热点对象上变长:

  • 锁释放变慢
  • 读接口里那一步锁定读也会被拖住
  • 整体 RT 表现成“这个读接口也开始慢”

现场最容易错的地方,是把它当成纯查询退化。

3. 写事务把从库延迟拉高,读接口被迫回主库

很多“读多写少接口突然慢”的现场,第一因其实不在主库查询本身,而在读写分离路径。

常见表现是:

  • 平时读流量主要走从库
  • 写高峰一来,从库延迟上升
  • 某些接口因为读后要看到最新结果,被路由回主库
  • 主库上读写混跑,查询 RT 和连接池等待同步变差

这类问题特别容易误判成:

  • 主库突然变慢
  • 某条读 SQL 退化

实际上更该先问的是:

这批慢读,是不是已经不再走原来的副本路径了?

4. 写事务把缓存回源窗口拉长,读接口慢在缓存故障链后半段

还有一类现场很容易绕远路。

表面上你看到的是:

  • 某个读接口缓存 miss 变多
  • 回源数据库以后 RT 变高
  • 上游觉得是读接口慢了

但继续往下看会发现:

  • 写事务让数据库等待变长
  • miss 之后回填缓存变慢
  • 更多并发请求在回填完成前继续 miss
  • 读接口被持续吸进数据库等待链

这时读接口看起来像是缓存问题,实际上背后已经叠了写事务的放大效应。

别一上来就看 explain:先判断读接口到底慢在“执行”还是“等待”

遇到这类问题,我更建议先把“读接口慢”拆开,而不是马上盯 SQL 文本。

更像执行慢的信号

通常会看到:

  • 某条读 SQL 平峰也慢
  • explain、rows、排序、回表都能解释耗时
  • 影响面相对稳定,不强依赖写高峰
  • 连接池和锁等待不一定异常

这种才更像读 SQL 自己的访问路径问题。

更像等待慢的信号

通常会看到:

  • 读接口主要在写高峰、批任务窗口、状态更新窗口变慢
  • 同一条查询有时几十毫秒,有时几秒
  • 获取连接时间、锁等待、从库延迟或缓存回填耗时一起抬头
  • 数据库 CPU 没一定打满,但接口和线程已经很难受

如果更像第二类,就别把第一轮时间都花在 explain 上。

现场我通常先沿写事务的影响面往外推

第 1 步:先确认慢读是不是和写高峰同时间窗

先对齐这些时间线:

  • 写接口 RT
  • 活跃事务数、长事务数
  • 连接池 active / pending
  • 读接口 RT
  • 从库延迟或缓存 miss

如果这些曲线高度重合,基本就不要再把它当孤立读问题看了。

第 2 步:再看读接口是否真的走了纯读路径

重点确认:

  • 有没有 for update、锁定读、状态校验后更新
  • 有没有读后立即写、查后改的封装逻辑
  • 有没有必须读到最新结果、因此强制走主库
  • 有没有事务注解把一整段逻辑都包住了

这一步常常比 explain 更值钱,因为很多问题根子在代码边界,而不是 SQL 写法本身。

第 3 步:再看连接池是不是先出问题

重点看:

  • 获取连接耗时
  • 活跃连接数和空闲连接数
  • 等待连接线程数
  • 读接口慢的时候,是否刚好连接池 pending 上升

如果这里已经明显异常,说明读接口至少有一部分时间,是在等写事务把连接还回来。

第 4 步:再看锁和长事务

重点查:

  • 哪些事务长时间不提交
  • 是否存在热点行、热点状态、批量更新
  • 读接口依赖的表,是否正处在高冲突写窗口
  • 元数据锁、行锁等待是否上升

这一步特别适合和 锁等待、热点行、事务竞争把接口拖慢时,应该先查什么? 一起串起来看。

第 5 步:再看副本延迟和路由切换

如果服务有读写分离,这一步不能漏。

重点问:

  • 慢读时是否从库延迟明显上升
  • 慢读请求是否因为一致性要求被切到主库
  • 某些实例、某些机房是否比别的更慢
  • 路由策略是否在高峰期发生了回退

很多读接口慢,不是读请求本身变复杂,而是原来的轻路径已经失效。

第 6 步:证据都对上以后,再回头看读 SQL 本身

这时再看:

  • 索引是否退化
  • 回表、排序、扫描范围是否扩大
  • 数据量变化是否让原有读查询开始脆弱

这样顺序不会错,但也不会把真正更高频的等待链漏掉。

最容易被忽略的两个根因:共享资源和错误抽象

1. 共享连接池、共享实例、共享副本策略

只要读写共享:

  • 连接池
  • 主库实例
  • 副本路由
  • 缓存回填后的数据库访问层

那么写事务就很难只影响写接口。

2. 接口名字叫“查询”,不代表它实现上真的是读链路

很多线上慢读问题,最后回头看都输在这个地方:

  • 接口名看着像读
  • 但实现上混了锁定读、事务、状态校验、写后读一致性要求
  • 团队却一直按“普通查询慢”去查

这类误判会白白浪费很多排障时间。

最容易误判的几个地方

误判 1:读接口慢,就默认先查读 SQL

不一定。

如果它明显只在写高峰变慢,更该先查写事务、连接池、锁等待和副本延迟。

误判 2:MVCC 下读不加锁,所以读不会被写影响

这句话只在“纯快照读、无一致性强约束、无共享资源紧张”的理想前提下成立。真实业务里,经常并不满足。

误判 3:数据库 CPU 不高,就说明写事务不是主因

等待型故障很多时候发生在 CPU 还没满的时候。连接等待、锁等待、复制延迟都可能先把读接口拖慢。

误判 4:读接口慢,只看主库不看从库

如果系统有读写分离,不看副本延迟和路由回退,现场很容易永远解释不通。

误判 5:缓存 miss 变多,就把锅全丢给缓存

很多 miss 只是第一层现象。真正让读接口越来越慢的,可能是写事务把数据库回填链也拖长了。

FAQ

1. 普通 select 也会被写事务拖慢吗?

会,但方式不一定是直接等行锁。更常见的是共享连接池导致拿连接变慢,主库资源和副本延迟变化导致读路径变重,或者缓存 miss 后回源窗口被写事务拖长。

2. 这种问题优先查锁还是优先查连接池?

如果读接口慢和写高峰高度重合,我会先一起看连接池和长事务,再判断是否存在热点锁。因为很多现场先暴露的是“连接回不来”,再往下才定位到锁和事务边界。

3. 从库延迟为什么会让读接口变慢?

因为一旦副本追不上,读请求可能被切回主库,主库读写混跑会放大等待,某些接口为了读到最新值,也只能接受更重的读路径。

4. 这种问题最后一般是优化读 SQL,还是优化写事务?

如果根因在等待链,大多数时候更值钱的是先缩短写事务边界、降低热点冲突、改善连接归还速度,而不是先优化那条原本并不差的读 SQL。

5. 这篇文章和缓存那条线怎么分?

如果你最先看到的是“缓存命中率下降 / 回源放大”,就先沿缓存那条线查;这篇只处理“写事务已经把读链路也拖慢”的数据库等待链边界。如果问题主线先落在缓存失效本身,就别从这篇起步。

证据继续往下走时,我一般这样接着查

如果你已经基本确认读接口是被写事务拖慢的,后面通常就按最硬的证据继续收:

这篇文章最重要的价值,不是重新讲一遍“读写分离”或“MVCC”,而是把“写事务外溢到读链路”这件事拆成一条可验证的共享等待链。

最后一句结论

读多写少的接口被写事务拖慢时,不要先问“读 SQL 为什么慢”,而要先问:写事务把哪一段共享等待链拉长了,是连接、锁、副本延迟,还是缓存回源后的数据库回填窗口。

只有先把这一步分清,读 SQL 优化、缓存治理和数据库等待链下钻,才不会互相抢同一个问题意图。