Java

优惠资格回补把查询接口拖进借连接和等锁那次,真正先失控的是回补本身

这篇只讲一次优惠资格回补事故:回补任务先把连接批量借走又迟迟不还,在线查询先堵在 Hikari 门口;进库后又撞上同一批热点券账户锁,最后把可用优惠查询一起拖慢。

  • MySQL
  • 接口慢
  • 连接池
  • 锁等待
  • 稳定性
11 分钟阅读

那晚先出问题的接口,是 /coupon/available/list

但如果把这次事故记成“数据库看起来不忙,接口却很慢”,事情就又会被说偏。因为真正把现场拖坏的主角,不是数据库面板,也不是那条查询 SQL,而是刚开跑没多久的优惠资格回补

它先做了两件很伤的事:

  • 把数据库连接一批一批借走
  • 借到以后又长时间不归还

于是在线查询先不是慢在库里,而是先慢在 Hikari 门口。等终于挤进去以后,又撞上回补任务占着不放的那批热点券账户锁,整条链路就一起发抖。

这篇只讲这一次事故:优惠资格回补怎样先把查询接口堵在连接池外,再把它拖进库里的锁等待。

事故不是从数据库总览开始的,而是从回补任务开跑开始的

18:39,业务侧开启了一轮优惠资格回补,目标是把一批历史用户的资格重新补齐。

这类动作平时并不直接面向用户,所以刚开始大家都盯着回补批次有没有报错。但 4 分钟后,最先被用户感知到的不是回补任务状态,而是下单页里“可用优惠”开始转圈,订单确认链路里顺手查资格的接口也一起变慢。

应用日志里的报警几乎是同时冒出来的:

18:43:07.498 WARN  HikariPool-1 - Connection is not available, request timed out after 500ms.
18:43:07.499 INFO  HikariPool-1 - Pool stats (total=40, active=40, idle=0, waiting=17)
18:43:08.011 WARN  Borrow connection slow, traceId=af92d11e3c8b41a5, waitMs=437
18:43:08.012 INFO  GET /coupon/available/list userId=81273 tenant=hz p95=1.9s

这两段日志已经把事故的前半段说得很清楚了:

  • 查询接口已经开始借不到连接
  • 连接池 40 个连接全被占满
  • 最先卡住的是在线请求,不是回补任务自己的报错

所以那一刻最该问的不是“数据库是不是打满了”,而是:

回补到底在用这些连接做什么,为什么拿走以后这么久都不还?

把连接借满的,就是回补;把连接占久的,还是回补

把任务批次、应用线程名和数据库事务对起来以后,问题很快收窄了。

出问题时连接池里最活跃的线程,绝大多数都来自优惠资格回补的 worker;而慢请求集中的租户、券批次和回补命中的目标,也在同一组数据上重合。

数据库里那几笔最老的事务,反复都落在 coupon_account 这张表:

---TRANSACTION 421553918, ACTIVE 7 sec
mysql tables in use 1, locked 1
6 lock struct(s), heap size 1128, 3 row lock(s)
MySQL thread id 913244, OS thread handle 134755, query id 5521981 app_rw update
update coupon_account
   set version = version + 1,
       updated_at = now()
 where coupon_id = 781245
   and tenant_id = 19;

这里真正关键的不是 update 语句本身有多复杂,而是这笔事务已经活了 7 秒,连接还挂在它身上,锁也还没放。

换句话说,那晚不是查询接口突然把库打慢了,而是回补任务先把连接池里的位置占满,再把部分热点券账户锁攥在手里不放,导致在线查询一层层被堵在后面。

回补为什么会拿着连接不还:事务里塞进了库外规则服务调用

回头翻回补代码时,问题几乎是一眼能看出来的。那条回补链路大致是:

  1. 扫描待回补用户
  2. 借连接并开启事务
  3. 调规则服务重新计算优惠资格
  4. 更新 coupon_account
  5. 写回补流水,提交事务

看上去只是常见的“算完再写”,但真正致命的是第 3 步的位置。

那晚规则服务 RT 从几十毫秒抬到接近 700ms。于是回补任务就变成了这种形态:

  • 连接已经借到了
  • 事务已经开了
  • 有些热点账户的更新已经碰到了锁
  • 线程却还在事务里等一个数据库之外的 RPC 返回

这就解释了为什么连接会先被借光。

不是库里有 40 条特别重的 SQL 在跑,而是回补线程拿着 40 个连接,在事务里边等规则服务、边拖长锁持有时间。

只要这种写法碰上批量回补,在线查询就一定先遭殃。因为查询接口本来只是临时进来读一下资格,却先被挡在连接池外;等排队进去,又会碰到同一批热点券账户还没释放的锁。

查询接口真正慢在哪里:先等连接,再等那批回补留下的锁

我当时抓了一条最典型的慢请求:traceId=af92d11e3c8b41a5

拆开以后,它并不像一条“SQL 自己跑得很慢”的请求,反而像在两道关卡前各等了一次:

时间点发生的事
18:43:07.018网关收到 /coupon/available/list
18:43:07.061业务线程开始借连接
18:43:07.498借到连接,光这一步就等了 437ms
18:43:07.511发送查询 SQL
18:43:07.664SQL 返回,执行只花了 153ms
18:43:08.172事务结束,响应继续往外返回
18:43:08.210请求完成

这条请求最说明问题的地方,不是 SQL 执行了 153ms,而是它前面已经先在连接池门口耗掉了 437ms。

而且 SQL 返回以后,整条请求也没有立刻结束,说明它进库后并不是完全无阻地跑完,还是受到了前面那批回补事务的拖拽。

所以这次接口慢,不是一种笼统的“数据库性能下降”,而是更具体的双重等待:

  1. 先等回补任务归还连接
  2. 再等回补任务释放热点券账户上的锁

把这两层分开看,事故就不抽象了。它本质上不是一堂“数据库总览不高时该怎么看”的课,而是一次很具体的业务误伤:优惠资格回补先堵住门,再占着路不走。

为什么用户先感觉到的是查询变慢,而不是回补报错

这也是那晚很容易让人判断错的一点。

回补任务本身是后台批处理,它哪怕慢一点、堆一点,第一时间也不一定有人盯着;但 /coupon/available/list 是下单链路里的在线查询,只要多等几百毫秒,用户立刻就能感觉到。

所以用户看到的是“查优惠突然很慢”,但事故主体其实始终是回补:

  • 它先把连接池挤满
  • 它让连接长时间不归还
  • 它命中了同一批热点券账户
  • 它把在线查询拖进了自己的等待队列

如果忽略这一点,后面就很容易把大量精力花在“这条查询有没有索引问题”“数据库 CPU 为何不高”这种旁枝上,结果修了半天,还是没碰到最先出手的那把刀。

这次真正该改的,不是查询 SQL,而是回补这个业务动作的做法

后来回看,这次事故最有价值的地方,不是证明“数据库没满也会慢”,而是把回补这类业务动作的风险钉实了:

回补不该拿着事务等规则服务

规则计算可以慢,但不能在已经借到连接、已经开了事务以后再慢。先把资格结果算出来,再决定要不要进事务,才不会让连接被白白占住。

回补不该和在线查询毫无隔离地抢同一池连接

哪怕用的还是同一个库,批量回补也应该有自己的并发上限,至少不能在业务高峰期把在线请求一起挤到 Hikari 门外。

回补不该短时间集中撞同一批热点券账户

这次最重的锁冲突,不是因为更新语句有多奇怪,而是同一批 coupon_id + tenant_id 被密集回写。把热点拆散、把批次切细,比事后盯着锁等待图更有意义。

这三个点看上去都像工程细节,但它们共同针对的是同一个事故事实:

优惠资格回补本来只是后台修数动作,结果因为借连接太早、归还太晚、命中热点太集中,直接把在线查询拖成了门口排队加库内等锁。

这篇我最后真正记住的,不是某个指标,而是一句更具体的话

以后再遇到类似场景,我不会先从“数据库看起来负载不高”开始讲起。

我会先确认是不是又有某个批量业务动作,正在做这三件事:

  • 先把连接借走
  • 在事务里等库外依赖
  • 持续改写同一批热点数据

因为只要这三件事叠在一起,在线查询就很容易复制那晚的坏法:

先堵在 Hikari 门口,进去以后再撞锁。

而那晚的事故主体,从头到尾都不是抽象的“数据库状态”,就是那次写得太激进的优惠资格回补。