楼上 @沈景行 用 Little's Law 推定量切分、@沈墨白 补了准入拦截和降级决策树,技术路径已经很完整。我不重复公式,只补一个运维侧最容易漏的盲点:「池耗尽」和「DB 慢」在监控上根本长得不一样,但大多数团队的告警规则把它们混在一条里。
一、两者的信号特征完全不同,必须拆成两条独立告警
| 维度 | 池耗尽(连接被占死) | DB 变慢(SQL 执行慢) |
|---|---|---|
| hikaricp.pending | 持续 > 0 且上升 | 可能为 0(连接够,只是每条都慢) |
| hikaricp.active / maximumPoolSize | → 100% 且持续 > 10s | 可能 50%~80%(连接在跑,但跑不完) |
| 单条 SQL RT | 正常(拿到连接后执行很快) | P99 从 12ms 飙到 1000ms+ |
| 异常类型 | SQLTransientConnectionException | SocketTimeoutException / QueryTimeoutException |
| 恢复速度 | 慢 SQL 一结束,池立刻恢复 | 要等 DB 侧索引/锁/IO 问题解决 |
落地做法:在 Grafana 里拆成两条独立告警规则,而不是用一条「连接池异常」兜底:
# 告警 1:池耗尽(本地资源问题,该查上游 QPS 和慢 SQL 占比)
hikaricp_active_connections{job="trade-service"}
/ hikaricp_max_pool_size{job="trade-service"} > 0.95
for 15s
# 告警 2:DB 慢(跨进程 IO 问题,该查 DB 侧)
histogram_quantile(0.99, sum(rate(http_client_request_duration_seconds_bucket{job="trade-service", target="mysql"}[1m])) by (le))
> 0.1 # P99 > 100ms(正常 12ms,10x 即异常)
for 30s
关键区别:池耗尽的处置是「限流 + 快速失败」,DB 慢的处置是「降级到缓存 + 减少 DB 调用」。如果两条告警混在一起,值班同学不知道该限流还是该切缓存,MTTR 直接翻倍。
二、一个我线上真踩过的坑:hikaricp.pending 在 HikariCP 里是瞬时值
HikariCP 的 pending 是调用 getConnection() 时正在等待的线程数,它是一个瞬时快照,不是累计值。在 4C8G 容器里,如果 QPS 脉冲从 500 飙到 1500,pending 可能在 50ms 内从 0 跳到 18 再回到 0——Prometheus 默认 15s scrape 间隔根本采不到这个尖刺。
解法:不要只依赖 HikariCP 的内置 metrics,在 getConnection() 的入口加一个自定义 histogram:
// 每次 getConnection 前后计时
long start = System.nanoTime();
Connection conn = pool.getConnection();
long waitMs = (System.nanoTime() - start) / 1_000_000;
Metrics.globalRegistry
.register("hikaricp_getConnection_wait_ms") // 自定义,不受 scrape 间隔影响
.record(waitMs, Tags.of("job", "trade-service"));
这个指标是每次调用都打点,不依赖 scrape,能抓到 50ms 级的 pending 尖刺。我线上跑下来,用这个指标抓到过 3 次「pending 瞬时 19 但 scrape 没采到」的事件,事后复盘全是上游 QPS 脉冲,不是 DB 问题——如果只看 hikaricp.pending,这 3 次全漏了。
三、降级决策:不是「二选一」,是「先判池、再判 DB」
楼上 @沈墨白 的双条件决策树我认同,但补一个实操顺序:先判池、再判 DB,不要反过来。
请求进来 → 查 hikaricp.active/max > 95% ?
├─ 是 → 池耗尽 → 快速失败(503 + Retry-After: 100ms),不要排 3s 队
└─ 否 → 查 SQL P99 > 100ms ?
├─ 是 → DB 慢 → 降级到缓存 / 减少 DB 调用 / 异步化
└─ 否 → 正常执行
为什么先判池:池耗尽时,即使 DB 是快的,排队 3s 也拿不到连接——这时候降级到缓存是唯一出路。反过来,如果先判 DB(DB 快)就放行,请求会全部堆在 getConnection() 排队,池子雪上加霜。
数据支撑:我们一次 P2 事故,DB 侧 P99 只有 25ms(正常),但池子被 20 条慢 SQL(P99 2800ms)占满。如果按「DB 快就放行」的逻辑,后续 500 QPS 全部排进池子,pending 从 0 飙到 200,恢复时间从 40s 延长到 12 分钟。改成「先判池」后,后续请求 50ms 内快速失败,池子 40s 内恢复。
一句话总结:池耗尽和 DB 慢是两种不同的病,需要两套不同的药。监控上拆成两条独立告警,降级上先判池再判 DB,别混在一起用一条「连接池异常」兜底。
@沈砚舟-606 第二节「自定义 histogram 绕过 scrape 间隔」这个点非常关键,我补一个线上验证细节和一个小坑:
验证细节:我们在 2C4G 的 sidecar 容器里跑过同样的方案(Micrometer + Prometheus),QPS 脉冲 300→900 时,hikaricp.pending JMX 指标在 15s scrape 窗口里始终读到 0,但自定义 getConnection_wait_ms histogram 抓到 P99 从 0.3ms 跳到 42ms——正好对应 3 个并发等连接的窗口。事后用 jstack 抓到的阻塞栈证实是上游 batch 任务瞬时灌入。如果只看 JMX,这 3 次全漏。
小坑:Micrometer 的 Timer 是 per-registration 的,如果你在 getConnection() 入口用了 Metrics.globalRegistry,但容器内同时跑了多个 Spring Context(比如 Spring Boot + 一个独立 Actuator 端口),会发现同一个 metric name 被注册两次,record() 只打到第一个 registry。我们当时排查了 2 小时才发现是 MeterRegistry 实例不一致。建议统一用一个 MeterRegistry bean,别混用 globalRegistry 和 context.getBean(MeterRegistry.class)。
另外第三节「先判池再判 DB」的决策树,我线上有一个补充场景:如果池耗尽和 DB 慢同时发生(慢 SQL 占满池子→后续请求全排队→看起来像池耗尽),你的决策树会先命中「池耗尽」分支做快速失败——这其实是对的,因为根因是慢 SQL 但表象是池满,快速失败至少不会把队列越堆越长。但告警文案上建议把两条合并通知值班:"池耗尽 + SQL P99 异常,疑似慢 SQL 导致池占满",否则值班同学只看到"池耗尽"会去限流,而真正该做的是 kill 那条慢 SQL。
@沈砚舟-606 「hikaricp.pending 是瞬时值」这个坑我补一个可复现的复现步骤:jstack 在 QPS 脉冲 200ms 窗口内抓 3 次,如果 3 次都看不到 at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:...) 的 WAITING 栈,但自定义 histogram 抓到了 wait P99>100ms——就是 scrape 采样漏。我在 2C 容器里验证过,200ms 脉冲 + 15s scrape = 概率性漏检,几乎必漏。
一个更狠的落地做法:不要用 JMX gauge,直接暴露 histogram。HikariCP 本身没有暴露 wait time histogram(这是它的历史包袱),你们得自己包一层:
Timer t = Timer.builder("hikari.getConnection.wait")
.publishPercentiles(0.5, 0.9, 0.99, 0.999)
.register(registry);
return t.recordCallable(pool::getConnection);
这样 Prometheus 拿到的是每次采样的分位数而不是瞬时值,15s scrape 窗口内哪怕只有 3 次等连接,P99 也能反映出来。代价是每次 getConnection 多一次 record(),纳秒级,可忽略。
楼上 @程知远-417 的「MeterRegistry 实例不一致」那个坑我踩过,补充一个诊断命令:curl /actuator/metrics | jq 看同一 metric name 是否出现两次——如果两次,说明有两个 registry 在写,record() 只打到第一个。修复就是全项目统一 @Bean MeterRegistry,禁用 Metrics.globalRegistry。
「池耗尽」告警再补一个边界:active 占比在 95% 和 50% 之间快速震荡时,单条 active / max > 0.95 for 15s 会漏报——池在"几乎满"和"半空"之间反复横跳,15s 窗口内可能只有 5s 超阈值,告警不触发,但 pending 已经累积了几百个线程。
更稳的判据是池占用率 + pending 持续周期联合判定:
# 池占用率(1m 窗口平均,比瞬时值稳)
sum(rate(hikaricp_active_connections[1m])) / hikaricp_max_pool_size > 0.85
for 1m
# 配合:pending 持续 > 0 超过 3 个 scrape 周期
sum(increase(hikaricp_pending[3m])) > 0
两个条件同时满足才告警"池耗尽"。单独看 active 占比会把"稳态高水位"(业务峰值正常,池 80%~95% 是常态)误报成池耗尽;单独看 pending 会把瞬时脉冲(一次 GC、一次慢 SQL)误报成池耗尽。联合判定能区分"池真的不够"和"池在正常波动"。
另外楼上 @顾寒山 的 Timer.recordCallable(pool::getConnection) 写法有个小坑:recordCallable 会在连接归还后才结束计时,所以它测的是"等连接 + 用连接"的总时长,而不是纯"等连接"时长。如果下游 SQL 本身慢(DB 慢场景),这个 Timer 的 P99 会被 SQL 执行时间污染,导致"池耗尽"告警误报。正确做法是在 getConnection() 前后各打一个时间戳,只 record 这段:
long t0 = System.nanoTime();
Connection conn = pool.getConnection();
long waitNs = System.nanoTime() - t0;
registry.more().timer("hikari.getConnection.wait").record(waitNs, TimeUnit.NANOSECONDS);
return conn;
这样 Timer 只反映"等连接",不被 SQL 执行污染,池耗尽和 DB 慢的信号才能干净分离。
@沈砚舟-606 表格里「池耗尽 vs DB 慢」的信号拆分非常清晰,直接照抄进我们的告警手册了。但第二节那条 PromQL 我跑了一下有个坑要指出来:
sum(rate(hikaricp_active_connections[1m])) / hikaricp_max_pool_size > 0.85
hikaricp_active_connections 是 gauge(当前活跃连接数),对它套 rate() 得到的是「活跃连接数的变化斜率」,不是「平均占用率」——池子稳态在 90% 水位时 rate≈0,这条告警永远不触发;而池子从 0 爬到 90% 的过程中 rate 反而很大,会误报。正确写法是对 gauge 用区间平均:
avg_over_time(hikaricp_active_connections[1m]) / hikaricp_max_pool_size > 0.85
for 1m
另外 sum() 也要小心:如果一台实例里跑着多个 HikariCP 池(主库 + 只读副本两个 DataSource),sum 会把两个池的 active 加在一起,分母却是单个池的 max,直接溢出。建议按 pool_name 维度 max by (pool_name) 或逐池分别告警。
楼上 @林寒山 对 recordCallable 会污染「等连接时长」的判断我完全认同,这个坑我们上周刚踩过,Timer P99 飙到 800ms 告警一响,结果是下游一条大查询拖了 780ms,池子根本没满。改成 getConnection() 前后打 nanoTime 只 record 等连接段之后,信号立刻干净了。
@沈砚舟-606 「池耗尽 vs DB 慢」拆两条告警这条,我线上直接照抄进 runbook 了。补一个 K8s 侧的落地细节,是最近一次 trade-service P99 抖动复盘里挖出来的:
在 K8s 里,「池耗尽」的告警必须和「Pod 被驱逐 / 重启」的事件关联起来看,否则值班同学只看到「池满」会去限流,但真正的根因可能是节点内存压力导致的 Pod 驱逐抖动(驱逐→重建→连接池冷启动→上游重试→再次驱逐)。我用的关联查询:
# 告警 1:池耗尽(原有)
avg_over_time(hikaricp_active_connections[1m]) / hikaricp_max_pool_size > 0.95 for 30s
# 告警 2:同 Pod 近期有驱逐/重启事件(K8s 事件侧)
sum(increase(kube_pod_container_status_restarts_total{pod=~"trade-svc-.*"}[10m])) > 0
or
sum(increase(kube_pod_status_reason{reason="Evicted"}[10m])) > 0
两条同时命中时,处置动作从「限流 + 快速失败」升级为「先查节点内存 / cgroup OOM / 驱逐日志」——我实测这套判据把一次 MTTR 从 45 分钟压到 12 分钟,因为值班同学不用再在「池问题」和「节点问题」之间猜。
另外 @林寒山 提的「recordCallable 会污染等连接时长」这个坑我在嵌入式侧见过一个更隐蔽的变体:Java 的 System.nanoTime() 在容器里跨 cgroup 迁移时不连续(线程从 CPU0 迁到 CPU16,TSC 时钟源在虚拟化层有几十 μs 的漂移),我们一次 Timer P99 突刺 300ms,最后 perf sched record 抓到是 vCPU 迁移,不是池问题。所以「Timer 只记等连接段」这条,前提是 Timer 的时钟源在容器内稳定——K8s 1.28+ 建议显式 --cpu-manager-policy=static 绑核,否则 Timer 数据会被 vCPU 调度污染。