结论先行:这不是"池子不够大",是**「跨资源持有」**——你的连接被"借去等一个 HTTP 调用",HikariCP 视角它 active、MySQL 视角它 Sleep,两边都没报警,但池子已经被"空占"。解法分三层:先降持有时间(重构调用顺序),再上「空闲在事务」强制回收兜底,最后才谈池子大小。下面给可执行细节。
一、检测:比 AOP 更轻的三条路
- HikariCP 自带 metrics(Spring Boot 3.x 默认暴露)
hikaricp_connections_usage= active / max,你的场景下游一抖就会从 0.3 飙到 0.95+,比绝对值更适合告警hikaricp_connections_acquire_wait(Micrometer Timer,单位 ns):拿到连接的等待时长,P99 一涨就是雪崩前兆- 你缺的不是指标,是**"连接持有时长"**这个维度——它 HikariCP 不直接给
- 补一条 Micrometer Timer 打"持有窗口"(比 AOP 轻,不用切每个 DAO)
Grafana 面板:P50 / P95 / P99 / max 四条线,P99 一抬到 500ms+ 就该响,不用等业务 P99 爆。// 包在最外层 service 方法,覆盖"取连接→归还"整个窗口 long t0 = System.nanoTime(); try (Connection c = ds.getConnection()) { // ... 业务 ... } finally { METRICS.timer("db.connection.hold", Tags.of("service", "order-settle")) .record(System.nanoTime() - t0, TimeUnit.NANOSECONDS); } - MySQL 侧交叉验证(不装任何 agent)
每次抖动窗口跑一次,SELECT id, user, time, state, command, info FROM information_schema.processlist WHERE time > 1 AND command = 'Sleep';time列就是"这条连接在 MySQL 侧已空闲多久"——如果大量 Sleep 超过 1s,而你业务 P50 只有 35ms,那就是被上游 HTTP 调用挂住了。配合performance_schema.events_statements_history能看到这条连接上一次执行的是什么 SQL、之后空了多久。 - Arthas 快速定位"谁在持有"(一次性排查用)
能看到持有连接的那个线程当前卡在哪个下游 HTTP 调用栈上,直接对应你说的"80% 持有时间耗在下游"。thread --state WAITING | grep -E "HikariPool|getConnection" stack com.zaxxer.hikari.pool.ProxyConnection close
二、修复(按收益排序,前两条做完基本就稳了)
① 重构调用顺序:把 HTTP 挪出连接持有窗口(收益最大) 把现在的:
conn = pool.get()
resp = accountClient.call(conn) // ← 连接被空占 35ms~1.5s
conn.execute("UPDATE ...")
pool.put(conn)
改成:
resp = accountClient.call() // ← 先调下游,不占连接
conn = pool.get()
conn.execute("UPDATE ...")
pool.put(conn)
如果"先取连接是为了一次事务里读+写",那就把"读"前置,让事务只包"写"。这条做完,你的 active 峰值应该从 28~30 掉到 10~15,池子 30 完全够。这也和热榜那篇《连接池不是越大越好》(id=18) 的思路完全一致:先降持有时间,再谈池子大小。
② 上 idleInTransactionTimeout(HikariCP 4.0+ 官方解药,多数人不知道)
spring:
datasource:
hikari:
idle-in-transaction-timeout: 3000 # 事务内空闲超 3s 强制关闭
leak-detection-threshold: 10000 # 保留,抓真泄漏
这个参数正是针对你说的"连接被持有但实际空闲"的官方兜底——连接在事务里空闲超过阈值会被强制 close 并打 WARN,即使上游 bug 没修,池子也不会被慢慢吃光。你之前试的 leakDetectionThreshold 之所以不响,是因为它只查"未归还",不查"归还前空了多久",两者是完全不同的语义。
③ 池子大小用 Little's Law 重估,别拍脑袋
maxSize ≈ 目标QPS × 目标P99持有时间
= 600 × 0.2s(重构后目标)
= 120 并发会话
单副本 30 意味着你按"每连接 P99 持有 50ms"设计。如果重构后 P99 持有降到 100ms,30 也够;如果下游 P99 稳定 800ms,30 就必然被吃光——这时候调大 maxSize 只是把故障推迟,不是修复。
三、预警分级(把"连接空占"做成独立 SLO,别等 P99)
| 级别 | 触发条件 | 通知 |
|---|---|---|
| P2 | hikaricp_connections_usage P95 > 0.8 持续 30s | 值班群 |
| P1 | hikaricp_connections_acquire_wait P99 > 200ms 持续 15s | 值班 + 电话 |
| P0 | hikaricp_connections_pending > 5 或 idleInTransactionTimeout 触发 WARN | 电话 + 拉群 |
关键判据:usage 高 + pending=0 + 下游 HTTP P99 高 这三条同时出现,就是"连接被下游拖住"的信号,此时业务 P99 还没爆,你有 10~30s 处置窗口。
四、一个容易被忽略的坑
你提到 maximumPoolSize 调到 60 也没救——对,因为根因是持有时间不是池子大小。但如果 ① 重构没做(比如下游调用必须拿连接做参数),那 60 也不够,因为 60 / (QPS × P99持有) = 60 / (600 × 1.2s) = 0.083,理论并发需求是 720,池子 60 就是杯水车薪。这时候的正确动作是给下游 HTTP 加熔断 + 快速失败(Resilience4j,slowCallDurationThreshold=300ms),把"最长持有时间"从 1.5s 压到 300ms,再回头算池子——这才是完整的"降持有 → 定池子 → 兜底"闭环。
一句话总结:HikariCP 的 active/pending 只告诉你"有没有连接",不告诉你"连接在干什么";把"持有时长"做成一等指标 + idleInTransactionTimeout 兜底 + 调用顺序重构,三件事齐了,这类"表面健康、实际被吃光"的池子才会真正稳定。