Java

连接池真的打满那次,我怎么往前倒回去找到根因,而不是先改 maxPoolSize

一次支付落单事故里,Hikari 已经开始报 `connection timeout`,但真正先坏掉的不是连接数配置,而是连接回不来的那一段。把 pool exhausted 往前倒推到事务、锁等待和回退动作,根因会比盯参数更快收住。

  • 数据库连接池
  • HikariCP
  • MySQL
  • 性能排查
  • 长事务
13 分钟阅读

21:07,支付落单接口开始大面积报错:

HikariPool-1 - Connection is not available, request timed out after 30000ms.

值班现场一旦看到这种日志,几乎都会有人先说两句话:

  • 连接池太小了吧
  • 要不要先把 maxPoolSize 调大

这反应太常见了,但我后来几乎不把它当第一步。因为大多数时候,连接池打满不是起点,而是前面那段“连接回不来”的结果。

那晚真正的排查顺序,不是先改参数,而是把 pool exhausted 往前倒回去,看是谁先把连接拖死了。

先确认它真的是“借不到连接”

那次事故窗口很短,但证据很集中。21:05 到 21:09 的连接池指标是这样:

指标21:0421:08
Hikari active22 / 3030 / 30
Hikari idle80
Hikari pending037
获取连接 p9514ms2.8s
获取连接 timeout 次数091 / 分钟

至少先可以确认一件事:这不是“数据库相关超时看起来像连接池问题”,而是真的已经到了 连接借不出来 这一步。

再分清:这是瞬时顶满,还是持续顶满

第二步我会先看它是短时尖峰,还是连接一直回不来。

那晚不是瞬时冲高,而是持续顶满。因为 21:06 之后连续几分钟都长这样:

  • active 一直贴着 30 / 30
  • idle 一直是 0
  • pending 不回落
  • timeout 一直在刷

这很重要。瞬时打满,很多时候要先查流量尖峰、缓存回源、批任务同发;但持续顶满,通常更像 连接没有按预期速度归还

pool exhausted 倒回前面的时间线

我当时把几条关键指标摊在同一时间窗里,顺序就出来了。

时间发生的事
21:05:20submitPayment p95 开始抬头
21:05:50longest transaction age 抬到 9.4s
21:06:10lock wait sessions 从 0 涨到 16
21:06:30Hikari pending 开始出现
21:07:00connection timeout
21:07:20上游网关 504 和用户失败量一起抬高

这条时间线至少说明两件事:

  1. pool exhausted 不是第一现场,前面先坏的是长事务和锁等待。
  2. 连接池只是把前面的等待继续放大到应用层。

traceId 也能看出来,连接不是平白无故不见了

最典型的一条 trace 是:traceId=e3a41b9dcf6d4e37

时间点事件
21:06:12.044请求进 payment-service
21:06:12.061借连接
21:06:14.882拿到连接
21:06:14.913执行 update payment_order ...
21:06:15.417SQL 返回
21:06:15.982事务提交
21:06:16.030响应返回

这条 trace 最扎眼的不是 SQL,而是 借连接就等了 2.8 秒。而在更早进来的那批请求里,连接又被事务持有得很久,归还不下来,于是后来的请求只能在池前面排。

换句话说,连接池“打满”只是最后的表现,前面的堵点还在事务和锁等待里。

把根因钉死的,是一批长事务和同一张热点表

进一步翻数据库侧指标,最值钱的是下面这几组:

指标21:0421:08
active sessions1871
lock wait sessions016
oldest transaction age260ms9.4s
MySQL CPU26%34%
平均 DB RT31ms44ms

真正反复出现的 SQL 在支付状态表上:

update payment_order
set status = ?,
    updated_at = now()
where biz_order_id = ?;

21:03 刚上线的“支付补单对账”任务也在批量更新同一批 biz_order_id,而且写法很糟:

  1. 先查待补单记录
  2. 开事务
  3. 调第三方支付网关补拉状态
  4. 拿到结果后更新 payment_order
  5. 再写审计表

第三方网关 RT 一抬,这批事务就都变长。在线支付回调和补单任务撞在同一张表上,锁等待开始出现,active sessions 越堆越多,连接归还速度越来越慢,最后就把 Hikari 顶死了。

所以那晚真正的因果链不是:

连接池太小 -> 借不到连接

而是:

补单任务把事务拉长 -> payment_order 锁等待增加 -> 连接归还变慢 -> pending 上升 -> pool exhausted

为什么我没有在这个阶段先扩连接池

不是说永远不能扩,而是那晚先扩几乎没意义。

因为数据库当时并不是“连接配少了但处理很快”,而是前面的事务已经拖长、锁已经排起来了。你这时候把 maxPoolSize 从 30 改到 50,更可能发生的是:

  • 更多连接一起去排锁
  • active sessions 更高
  • 数据库侧等待更难看
  • 用户侧恢复却不明显

我后来越来越把“先扩连接池”当成一种止血动作,而不是第一判断。前面这条等待链没拆清楚,参数通常只是把症状往后拖。

真正有效的动作,是切在连接回不来的前一段

21:10,我们先暂停“支付补单对账”任务;21:12,再把第三方补拉状态从事务里挪出去,只保留必要更新。

后面几分钟里的回落顺序非常明确:

时间先回落的指标后回落的指标
21:11oldest transaction age、lock wait sessions-
21:12active sessions、Hikari pending-
21:13获取连接 p95、connection timeoutsubmitPayment p95
21:14网关 504、支付失败量基本恢复

这个顺序也反过来证明,根因不是池子参数本身。最先掉下来的,是事务年龄和锁等待;再后面才是连接池的症状。

所以我现在遇到 pool exhausted 会先做什么

如果你已经看到了 connection timeoutpool exhausted,我现在通常先做四件事:

  1. 先确认是不是持续顶满,而不是瞬时尖峰。
  2. 再把 pending、获取连接耗时、transaction age、lock wait 放进同一时间窗。
  3. 再抓一条 traceId,看请求是卡在借连接前,还是拿到连接后又待了太久。
  4. 最后再决定要不要动参数;在这之前,先找连接为什么回不来。

那晚真正把事故收住的,不是有人果断调大了 maxPoolSize,而是终于承认:连接池是结果层,不是起点层。

一旦你愿意把 pool exhausted 往前倒一层,真正的堵点通常比参数表更早冒出来。