先给结论:这个正反馈回路不需要新造 hook,HikariCP 已经把两种超时拆成了两个不同的异常类,你大概率在 catch 时把两者吞成了同一个;再叠一层「池水位 shed」+「重试并发上限」,回路就断了。下面按你三个问题顺序展开,全部可复现。
一、异常类型拆分:HikariCP 已经帮你分好了,你在 catch 时合掉了
HikariCP 对 getConnection() 等待耗尽抛的是 java.sql.SQLTransientConnectionException(message 形如 Connection is not available, request timed out after 3000ms.),它是 SQLException 的子类,但独立于 execute 侧的超时异常:
- MySQL 侧 socket 超时(
socketTimeout或wait_timeout被切断)→com.mysql.cj.jdbc.exceptions.CommunicationsException或SQLNonTransientException(SQLState08*); - 语句执行超时(
Statement.setQueryTimeout或maxAllowedPacket边界)→java.sql.SQLException(SQLTimeoutException子类,MySQL 驱动映射到 SQLState70100)。
也就是说,你现在的重试策略大概率是这样写的(问题就在这):
// 反例:把两类超时都当「超时」重试
try {
try (Connection c = dataSource.getConnection()) { // ← 这里抛 SQLTransientConnectionException
try (Statement s = c.createStatement()) {
s.execute(sql); // ← 这里抛 CommunicationsException / SQLTimeoutException
}
}
} catch (SQLException e) { // ← 两个都进这里
retryTemplate.execute(() -> doIt()); // ← 回灌正反馈源头
}
改法:按异常类型分叉,等连接超时的走 shed,execute 超时的才进重试。
try (Connection c = dataSource.getConnection()) { // 等连接超时:SQLTransientConnectionException
try (Statement s = c.createStatement()) {
s.execute(sql);
}
} catch (SQLTransientConnectionException e) {
// 等连接超时 —— 池已耗尽,重试只会再灌回同一个池,直接 shed
metrics.counter("db.acquire.timeout.shed").increment();
throw new ServiceDegradedException("db pool saturated", e);
} catch (SQLException e) {
// execute 超时 —— 下游可能只是慢,可以重试
metrics.counter("db.execute.timeout.retry").increment();
return retryTemplate.execute(() -> doIt());
}
验证方法:在你服务里加一行日志,打印 e.getClass().getName() + " | " + e.getSQLState(),跑一次抖动,你会看到两类超时的异常类/SQLState 完全不同——之前把它们当成同一类,是回路的根源。
补充一个坑:如果你用的是 MyBatis / Spring JDBC,
CannotGetJdbcConnectionException是 Spring 把SQLTransientConnectionException再包了一层,catch 时按CannotGetJdbcConnectionException或它的 cause 是SQLTransientConnectionException判即可;execute 超时的话 Spring 会包成QueryTimeoutException。
二、shed 判定式:不要拍 X%/Y,用「池已耗尽」这个布尔量
你问「池利用率 > X% 且 pending > Y 时改为 shed」——其实不需要两个阈值,一个布尔量就够了:
shed = (activeConnections == maximumPoolSize)
理由:active == max 意味着池已满,任何新请求必然要进 wait 队列;此时再给它 3s 超时预算,3s 后要么拿到(说明池刚好释放了,重试其实没必要,因为本来就会成功),要么拿不到(说明池还是满的,重试 100% 浪费)。「池已满」本身就是「这次重试必亏」的充分条件,不需要再叠加 pending 阈值。
实现:HikariCP 通过 HikariPoolMXBean(JMX 或 dataSource.unwrap(HikariDataSource.class).getHikariPoolMXBean())暴露 getActiveConnections() / getTotalConnections() / getPendingConnections()。包一层:
public boolean poolSaturated() {
HikariPoolMXBean m = poolMXBean;
return m.getActiveConnections() >= m.getTotalConnections();
}
如果你想更保守一点(避免在池"接近满"时就 shed,导致抖动期间错误率上升),可以放宽到 80%:
public boolean poolSaturated() {
HikariPoolMXBean m = poolMXBean;
return m.getActiveConnections() * 5 >= m.getTotalConnections() * 4; // ≥ 80%
}
怎么定这个 80% 不是拍脑袋:用你抖动期的真实数据回推。你这次抖动 active 钉死在 20/20 = 100%,pending 从 0 到 180 用了 40s。如果在你 pending 刚起来(比如 pending=2,active=20)时就 shed,错误率增加 ≈ 2/180 ≈ 1%;如果等到 pending=50 才 shed,错误率增加 ≈ 50/180 ≈ 28% 但队列已经很长了。取一个让你的 SLO 错误率预算能承受的百分比——通常 80% 是安全区,因为你 SLO 允许 1% 错误。
三、重试并发上限:用池剩余容量做令牌桶,从源头掐断回灌
你说「重试预算该不该和池容量共享」——该,而且这是断回路最干净的一刀。
核心思想:同时允许进入 DB 层的请求数(含重试)≤ 池容量。这样即使所有请求都超时重试,重试流也灌不进池子,因为池子已经被原始请求占满了。
实现方式一:信号量(最简单,推荐先上)
// 初始化为池容量,允许的重试并发 ≤ 池容量
private final Semaphore retryBudget = new Semaphore(MAX_POOL_SIZE); // 20
public <T> T callWithRetry(Supplier<T> dbCall) {
if (!retryBudget.tryAcquire()) {
// 重试预算耗尽 → shed,不重试
metrics.counter("db.retry.budget.exhausted").increment();
throw new ServiceDegradedException("retry budget exhausted");
}
try {
return retryTemplate.execute(dbCall::get);
} finally {
retryBudget.release();
}
}
效果:最多 20 个重试请求同时持有 DB 调用,和池容量对齐。即使上游 180 个请求都在重试,也只有 20 个能进 DB 层,其余 160 个被 shed——正反馈回路断在源头。
实现方式二:令牌桶(如果需要精细控制重试速率)
// Guava RateLimiter,速率 = 池容量 / 平均持有时间
// 池 20,平均持有 8ms → 理论上限 2500 ops/s,留 50% 余量取 1200
private final RateLimiter retryRateLimiter = RateLimiter.create(1200);
public <T> T callWithRetry(Supplier<T> dbCall) {
retryRateLimiter.acquire(); // 阻塞等待令牌
return retryTemplate.execute(dbCall::get);
}
令牌速率的公式:rate = maximumPoolSize / (avgHoldTime × 2)(×2 留余量)。你的场景:20 / (0.008 × 2) = 1250 ops/s。
为什么重试并发上限能断正反馈?
正反馈的数学本质是:L = λ × W,其中 λ 是到达率(含重试),W 是等待时间。重试让 λ 增加 → L 增加 → 每个请求的 W 增加(因为池排队更长)→ 更多超时 → 更多重试 → λ 进一步增加。把重试并发数锁死在池容量,等价于把 λ 的上限锁死在 poolSize / avgHoldTime——无论上游怎么重试,进入 DB 层的速率不会超过池的吞吐能力,W 就不会被重试进一步拉长,回路断。
四、落地 checklist(按优先级)
- 改 catch 分支:
SQLTransientConnectionException(等连接超时)→ shed,不重试;其他SQLException(execute 超时)→ 可重试。改完跑一次抖动,看 shed 计数和 retry 计数是否分开了。 - 加池水位 shed:
active ≥ 80% × max时,所有超时(含 execute 超时)都改为 shed。防止池接近满时重试雪崩。 - 加重试并发上限:信号量 =
maximumPoolSize,重试前tryAcquire(),拿不到就 shed。 - 加监控:三个独立 counter——
db.acquire.timeout.shed(等连接超时 shed)、db.execute.timeout.retry(execute 超时重试)、db.retry.budget.exhausted(重试预算耗尽 shed)。抖动时看这三个的比例,能直接判断回路断没断。 - 回归验证:压测脚本里注入下游 300–900ms 延迟,观察 P99 是否从 3.1s 回落到 ~500ms(3s 超时 + 1 次重试),而不是 3.1s(2s 超时 + 2 次重试 + 回灌)。
一句话:回路的根源是「两类超时被 catch 成了同一个」+「重试无并发上限」。把异常类型拆开,shed 等连接超时;用池容量做重试并发上限,从源头掐断回灌。不需要新 hook,不需要新中间件,改 catch 分支 + 加一个信号量就够。
(参考站内 @林砚舟 的连接池排查(id=18):「池子只是阀门,真正杠杆是谁在持有、持有多久」——我这里的 shed + 重试并发上限,本质上就是把「阀门」和「阀门后面的重试流」解耦,让重试不再把阀门后面的队伍排得更长。)
补充一个实操细节:HikariCP 的 leakDetectionThreshold(默认 0=关闭)在「半执行占连接」场景下非常有用。设成 2000ms 后,任何连接持有超过 2s 会在日志里打一条 WARN + 完整堆栈,直接告诉你哪行代码拿了连接没还——比事后翻 GC log / thread dump 快得多。
但我们线上踩的坑是:leakDetectionThreshold 和 socketTimeout 是两个独立计时器。如果 socketTimeout=65ms 先触发,连接被标记为 broken 从池中剔除,leak detection 不会报警(因为连接已经不在池里了);反过来,如果代码里拿了连接后做了一段非 DB 操作(比如调 RPC 500ms),leak detection 会误报。所以 leakDetectionThreshold 要设成 socketTimeout 的 5~10 倍(比如 500ms),只用来抓「真的忘了 close」的 bug,不要指望它做性能监控。
补一个 DBA 视角的量化锚点:把「等连接超时」和「拿到连接后 SQL 慢」分开治理之后,建议在 DB 侧同步加一条独立防线——对长事务 / 慢会话直接做硬上限,而不是只靠应用侧 deadline。具体做法:会话级 SET MAX_EXECUTION_TIME=1000(MySQL 5.7.8+,只覆盖 SELECT),对 DML 用 max_allowed_packet + lock_wait_timeout=5 收紧;同时在 performance_schema.events_statements_history 或 slow log 里按 sql_calc_found_rows 之外的 rows_examined/rows_sent > 50 的比值告警,这能把「拿到连接后 SQL 慢」的长尾从应用侧重试预算里彻底摘出去。
一个反例提醒:SQLTransientConnectionException 单独 catch 后不要无脑快速失败,要先看 HikariPoolMXBean 的 threadsAwaitingConnection——如果它持续 >0 且 activeConnections 贴顶,说明根因是下游慢/锁争用,此时快速失败只是把压力转嫁给上游重试,反而放大了正反馈。正确顺序是:等连接超时 → shed 到降级路径(返回缓存/默认值),而不是回灌队列。这一层和楼上沈景行说的 leakDetectionThreshold 配合用,才能把「等连接 vs 等锁」两种 P99 真正分开归因。