连接池真的打满那次,我怎么往前倒回去找到根因,而不是先改 maxPoolSize
一次支付落单事故里,Hikari 已经开始报 `connection timeout`,但真正先坏掉的不是连接数配置,而是连接回不来的那一段。把 pool exhausted 往前倒推到事务、锁等待和回退动作,根因会比盯参数更快收住。
21:07,支付落单接口开始大面积报错:
HikariPool-1 - Connection is not available, request timed out after 30000ms.
值班现场一旦看到这种日志,几乎都会有人先说两句话:
- 连接池太小了吧
- 要不要先把
maxPoolSize调大
这反应太常见了,但我后来几乎不把它当第一步。因为大多数时候,连接池打满不是起点,而是前面那段“连接回不来”的结果。
那晚真正的排查顺序,不是先改参数,而是把 pool exhausted 往前倒回去,看是谁先把连接拖死了。
先确认它真的是“借不到连接”
那次事故窗口很短,但证据很集中。21:05 到 21:09 的连接池指标是这样:
| 指标 | 21:04 | 21:08 |
|---|---|---|
| Hikari active | 22 / 30 | 30 / 30 |
| Hikari idle | 8 | 0 |
| Hikari pending | 0 | 37 |
| 获取连接 p95 | 14ms | 2.8s |
| 获取连接 timeout 次数 | 0 | 91 / 分钟 |
至少先可以确认一件事:这不是“数据库相关超时看起来像连接池问题”,而是真的已经到了 连接借不出来 这一步。
再分清:这是瞬时顶满,还是持续顶满
第二步我会先看它是短时尖峰,还是连接一直回不来。
那晚不是瞬时冲高,而是持续顶满。因为 21:06 之后连续几分钟都长这样:
- active 一直贴着 30 / 30
- idle 一直是 0
- pending 不回落
- timeout 一直在刷
这很重要。瞬时打满,很多时候要先查流量尖峰、缓存回源、批任务同发;但持续顶满,通常更像 连接没有按预期速度归还。
把 pool exhausted 倒回前面的时间线
我当时把几条关键指标摊在同一时间窗里,顺序就出来了。
| 时间 | 发生的事 |
|---|---|
| 21:05:20 | submitPayment p95 开始抬头 |
| 21:05:50 | longest transaction age 抬到 9.4s |
| 21:06:10 | lock wait sessions 从 0 涨到 16 |
| 21:06:30 | Hikari pending 开始出现 |
| 21:07:00 | 报 connection timeout |
| 21:07:20 | 上游网关 504 和用户失败量一起抬高 |
这条时间线至少说明两件事:
pool exhausted不是第一现场,前面先坏的是长事务和锁等待。- 连接池只是把前面的等待继续放大到应用层。
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.417 | SQL 返回 |
| 21:06:15.982 | 事务提交 |
| 21:06:16.030 | 响应返回 |
这条 trace 最扎眼的不是 SQL,而是 借连接就等了 2.8 秒。而在更早进来的那批请求里,连接又被事务持有得很久,归还不下来,于是后来的请求只能在池前面排。
换句话说,连接池“打满”只是最后的表现,前面的堵点还在事务和锁等待里。
把根因钉死的,是一批长事务和同一张热点表
进一步翻数据库侧指标,最值钱的是下面这几组:
| 指标 | 21:04 | 21:08 |
|---|---|---|
| active sessions | 18 | 71 |
| lock wait sessions | 0 | 16 |
| oldest transaction age | 260ms | 9.4s |
| MySQL CPU | 26% | 34% |
| 平均 DB RT | 31ms | 44ms |
真正反复出现的 SQL 在支付状态表上:
update payment_order
set status = ?,
updated_at = now()
where biz_order_id = ?;
21:03 刚上线的“支付补单对账”任务也在批量更新同一批 biz_order_id,而且写法很糟:
- 先查待补单记录
- 开事务
- 调第三方支付网关补拉状态
- 拿到结果后更新
payment_order - 再写审计表
第三方网关 RT 一抬,这批事务就都变长。在线支付回调和补单任务撞在同一张表上,锁等待开始出现,active sessions 越堆越多,连接归还速度越来越慢,最后就把 Hikari 顶死了。
所以那晚真正的因果链不是:
连接池太小 -> 借不到连接
而是:
补单任务把事务拉长 ->
payment_order锁等待增加 -> 连接归还变慢 -> pending 上升 -> pool exhausted
为什么我没有在这个阶段先扩连接池
不是说永远不能扩,而是那晚先扩几乎没意义。
因为数据库当时并不是“连接配少了但处理很快”,而是前面的事务已经拖长、锁已经排起来了。你这时候把 maxPoolSize 从 30 改到 50,更可能发生的是:
- 更多连接一起去排锁
- active sessions 更高
- 数据库侧等待更难看
- 用户侧恢复却不明显
我后来越来越把“先扩连接池”当成一种止血动作,而不是第一判断。前面这条等待链没拆清楚,参数通常只是把症状往后拖。
真正有效的动作,是切在连接回不来的前一段
21:10,我们先暂停“支付补单对账”任务;21:12,再把第三方补拉状态从事务里挪出去,只保留必要更新。
后面几分钟里的回落顺序非常明确:
| 时间 | 先回落的指标 | 后回落的指标 |
|---|---|---|
| 21:11 | oldest transaction age、lock wait sessions | - |
| 21:12 | active sessions、Hikari pending | - |
| 21:13 | 获取连接 p95、connection timeout | submitPayment p95 |
| 21:14 | 网关 504、支付失败量 | 基本恢复 |
这个顺序也反过来证明,根因不是池子参数本身。最先掉下来的,是事务年龄和锁等待;再后面才是连接池的症状。
所以我现在遇到 pool exhausted 会先做什么
如果你已经看到了 connection timeout、pool exhausted,我现在通常先做四件事:
- 先确认是不是持续顶满,而不是瞬时尖峰。
- 再把
pending、获取连接耗时、transaction age、lock wait 放进同一时间窗。 - 再抓一条 traceId,看请求是卡在借连接前,还是拿到连接后又待了太久。
- 最后再决定要不要动参数;在这之前,先找连接为什么回不来。
那晚真正把事故收住的,不是有人果断调大了 maxPoolSize,而是终于承认:连接池是结果层,不是起点层。
一旦你愿意把 pool exhausted 往前倒一层,真正的堵点通常比参数表更早冒出来。