先给结论:connectionTimeout 和 socketTimeout 的切分不是「各分一半」,而是由两端的物理量决定——socketTimeout 由下游 P99 RT 的 N 倍决定(IO 等待),connectionTimeout 由池排空时间决定(排队等待),两者的和必须 ≤ 上游 deadline 减去本地开销。 下面按你的四个问题逐一给可落地方案。
一、定量切分:从 Little's Law 推,不是拍脑袋
1.1 先确定 socketTimeout(执行侧)
socketTimeout 约束的是「拿到连接后,SQL 在 DB 里跑多久算超时」。
公式:
socketTimeout = k × P99_RT + network_rtt
其中:
P99_RT是你正常态下 SQL 的 P99(你给的 12ms)network_rtt是同机房 RTT(一般 <1ms,取 1ms)k是「容忍慢几倍」的系数,取决于你的降级策略:- 如果超时后直接降级(不重试)→ k=5 足够(覆盖正常抖动 + 一次 GC)
- 如果超时后还要重试一次 → k=8(给重试留窗口)
- 如果下游有锁竞争、长事务 → k=10~15
你的场景:socketTimeout = 5 × 12ms + 1ms = 61ms,取整 65ms。
踩坑点:很多人把 socketTimeout 设成 2000ms,然后发现慢 SQL 占住连接 2 秒不释放,池子瞬间耗尽。2000ms 不是「安全值」,是「让问题更严重的值」——它允许一条 2800ms 的慢 SQL 在池里多待 2 秒,期间 20 个连接全被占死。socketTimeout 应该比你的 SLO deadline 小得多,不是差不多大。
1.2 再确定 connectionTimeout(等连接侧)
connectionTimeout 约束的是「在池里排队等多久算放弃」。
用 Little's Law 推:
W = L / λ
W= 平均等待时间(这就是 connectionTimeout 应该设的值)L= 平均排队长度(pending 连接的稳态值)λ= 池的吞吐率 = N / avg_service_time
你的参数:
- N = 20
- avg_service_time = P50 RT = 8ms(注意用 P50 不是 P99,因为稳态下大部分请求在 P50 附近)
- λ = 20 / 0.008 = 2500 req/s
峰值 QPS = 1500,ρ = 1500/2500 = 0.6(未饱和)。
M/M/c 排队模型下(近似),平均等待时间:
W_q ≈ (ρ^c / (c × (1-ρ))) / λ (M/M/1 近似)
更简单的工程估算:
- ρ=0.6 时,P99 排队等待 ≈ 3~5ms(池还没打满,大部分请求秒拿到连接)
- 但你要留慢 SQL 突发的余量:一条 2800ms 的慢 SQL 会占一个连接 2.8 秒,期间等效于 N 从 20 降到 19,ρ 从 0.6 升到 0.63——影响很小
- 真正的风险是多条慢 SQL 同时出现(比如锁竞争波及 5 条),等效 N 降到 15,ρ 升到 0.83,W_q 开始指数增长
工程取值:
connectionTimeout = max(3 × P99_W_q, 100ms)
- 正常态 P99_W_q ≈ 5ms → 3×5 = 15ms
- 但 15ms 太紧了(JVM GC、线程调度抖动),下限取 100ms
- 你的场景:connectionTimeout = 100~150ms
为什么不是 3000ms:3000ms 意味着一个请求可以在池里排 3 秒队。但你的上游 deadline 只有 500ms——排 3 秒队的时候,上游早就 504 了,用户已经看到错误了,你的连接还被占着。connectionTimeout 超过 (deadline - socketTimeout) 就是纯浪费,只会拖慢池恢复。
1.3 验证:两段之和 ≤ deadline
connectionTimeout + socketTimeout + local_overhead ≤ D
100ms + 65ms + 20ms(序列化+网络) = 185ms ≤ 500ms ✓
剩余 315ms 是你的安全余量,给上游 cancel 传播、JVM 调度、网络抖动留空间。如果你的 SQL 复杂度高(多表 JOIN),把余量给 socketTimeout(比如 socketTimeout=150ms),如果 QPS 脉冲大,把余量给 connectionTimeout(比如 connectionTimeout=200ms)。
1.4 配置落地
spring:
datasource:
hikari:
maximum-pool-size: 20
connection-timeout: 150 # 等连接:150ms
minimum-idle: 20
# 注意:HikariCP 没有 socketTimeout 配置项
# socketTimeout 通过 JDBC URL 设置:
jdbc-url: jdbc:mysql://host:3306/db?socketTimeout=65000&connectTimeout=1000
# connectTimeout=1000ms 是 TCP 建连超时(首次连接/连接池补充),不是等池连接
// 如果用了 MyBatis / Spring JDBC,还要设 Statement 级别的超时:
@Bean
public JdbcTemplate jdbcTemplate(DataSource ds) {
JdbcTemplate t = new JdbcTemplate(ds);
t.setQueryTimeout(1); // 1 秒,比 socketTimeout=65ms 宽松,
// 因为 queryTimeout 是「总执行时间」,
// socketTimeout 是「单次 socket read 超时」
return t;
}
踩坑点:
socketTimeout和queryTimeout是两回事。socketTimeout是「两次 socket read 之间的最大间隔」,queryTimeout是「整条 SQL 从发到收到结果的最大时间」。一条 SQL 如果中间有 200ms 没有数据返回(比如 DB 在做全表扫描),socketTimeout=65ms会先触发。如果你的 SQL 有「长时间无返回」的阶段(大表扫描),socketTimeout 要设得比 queryTimeout 小不了太多,或者干脆只设 queryTimeout。
二、差异化降级:等连接超时 ≠ 执行超时
2.1 等连接超时(池耗尽)→ 快速失败 + 降级,不要重试
判断依据:activeConnections == maximumPoolSize(池已满)。
为什么不能重试:重试 = 再排一次队。池已满时,排队时间只会更长(Little's Law:L 增长 → W 增长)。重试一个「等连接」的请求,等于往一个已经溢出的桶里再倒水。
降级策略:
try (Connection c = dataSource.getConnection()) {
// 正常执行
} catch (SQLTransientConnectionException e) {
// 等连接超时 —— 池耗尽
// 不要重试,直接降级
metrics.counter("db.pool.exhausted").increment();
log.warn("DB pool exhausted, shedding request", e);
return fallbackResponse(); // 缓存 / 默认值 / 503
}
降级缓存怎么建(交易服务的典型做法):
// 每次成功查询后,写一份本地缓存(Caffeine,TTL 30s)
String cacheKey = "trade:" + orderId;
cache.put(cacheKey, result, Duration.ofSeconds(30));
// 降级时读缓存
public TradeResult getTradeResult(String orderId) {
try {
return jdbcTemplate.queryForObject(sql, TradeResult.class);
} catch (SQLTransientConnectionException e) {
TradeResult cached = cache.getIfPresent(orderId);
if (cached != null) {
metrics.counter("db.fallback.cache_hit").increment();
return cached; // 带 "stale" 标记,让前端知道是缓存
}
throw new ServiceDegradedException("DB unavailable", e);
}
}
2.2 执行超时(DB 慢)→ 可以 cancel + 重试(幂等 SQL 才重试)
判断依据:SQLTransientException(SQLState 08*)或 CommunicationsException。
能不能重试:看 SQL 是否幂等。
SELECT→ 幂等,可以重试INSERT(有唯一键)→ 幂等,可以重试(唯一键兜底)UPDATE ... WHERE id=?→ 幂等,可以重试INSERT ... SELECT/ 批量写 → 不幂等,不要重试
cancel 后连接是否可靠归还:
try (Connection c = dataSource.getConnection()) {
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setQueryTimeout(1); // 1 秒
ps.execute();
}
// 正常返回,连接归还池
} catch (SQLException e) {
// 超时后,HikariCP 的 close() 会:
// 1. 调 Statement.cancel()(MySQL 驱动支持)
// 2. 如果 cancel 失败,等 socketTimeout 自然断开
// 3. 把连接标记为「不可用」,从池中剔除,新建一个
// 所以:连接会被归还,但可能不是原来那个
// 这是安全的——HikariCP 的 leak-detection 会兜底
metrics.counter("db.execute.timeout").increment();
if (isIdempotent(sql)) {
return retryTemplate.execute(() -> doIt());
}
throw e;
}
踩坑点:
Statement.cancel()对 MySQL 驱动不是 100% 可靠。MySQL 的 cancel 机制是往 socket 发一个 KILL QUERY 命令,但如果 DB 正在做全表扫描且网络抖动,cancel 命令本身可能超时。此时连接会一直被占着,直到socketTimeout触发 socket 断开。所以socketTimeout是真正的兜底,queryTimeout只是「尽力而为」。 这也是为什么你不能只设queryTimeout不设socketTimeout——万一 cancel 失败,连接就卡死了。
三、「半执行」问题:准入检查,别等执行完才发现 deadline 已过
3.1 问题本质
deadline 剩 80ms 时拿到连接、开始执行,执行了 200ms 后被上游 cancel——这 200ms 白白占了连接,拖慢池恢复。
3.2 解法:执行前检查剩余预算
public TradeResult executeWithDeadline(String sql, Object[] params, long deadlineEpochMs) {
long remaining = deadlineEpochMs - System.currentTimeMillis();
// 准入检查:剩余预算 < socketTimeout × 2 → 直接拒绝执行
// 理由:如果剩余预算不够「执行 + 返回」,执行了也是白执行(会被 cancel)
if (remaining < socketTimeoutMs * 2) {
metrics.counter("db.admission.rejected").increment();
log.debug("Rejected: remaining={}ms < 2×socketTimeout={}ms", remaining, socketTimeoutMs * 2);
return fallbackResponse(); // 降级,别浪费连接
}
// 正常执行
try (Connection c = dataSource.getConnection()) {
try (PreparedStatement ps = c.prepareStatement(sql)) {
// 用剩余预算设 queryTimeout(比全局 socketTimeout 更紧)
ps.setQueryTimeout((int)(remaining / 1000));
ps.execute();
// ...
}
}
}
阈值怎么定:remaining < 2 × socketTimeout 意味着「即使 SQL 正常执行完(P99 时间),也不够把结果传回上游」。2 倍是经验值——1 倍太紧(P99 本身就是统计值,偶尔会超),3 倍太松(浪费了太多「半执行」窗口)。
踩坑点:这个准入检查不能太激进。如果你的 P99 RT 是 12ms,
2 × 65ms = 130ms,意味着 deadline 剩 130ms 时就拒绝执行。在 QPS=1500、deadline=500ms 的场景下,大部分请求在 50ms 内就拿到连接并开始执行了(connectionTimeout=150ms 的 P99 排队 ≈ 5ms),所以准入检查只会拦截「deadline 快到了才拿到连接」的那一小撮请求——这些请求即使执行了也会被 cancel,拦截是对的。
3.3 如果不想做准入检查
那就接受损耗,但加一条监控:
// 记录「拿到连接时剩余预算 < 100ms」的次数
if (remaining < 100) {
metrics.counter("db.late_admission").increment();
}
如果这个计数器在抖动期间持续上涨(> 5% 请求),说明你的 connectionTimeout 设得太长,需要缩短。
四、可观测性:区分「等连接耗时」和「执行耗时」
4.1 HikariCP 自带指标(够用)
// JMX 或 Micrometer 自动暴露
hikaricp_connections_active // 当前正在执行的连接数
hikaricp_connections_idle // 空闲连接数
hikaricp_connections_pending // 等待拿连接的线程数
hikaricp_connections_timeout_total // 累计等连接超时次数
hikaricp_connections_acquire_seconds // 等连接耗时直方图(P50/P99)
hikaricp_connections_usage_seconds // 连接使用时长直方图(含执行时间)
这两个直方图就是你要的「等连接 vs 执行」拆分:
acquire_seconds= 等连接耗时(connectionTimeout 约束的)usage_seconds= 拿到连接后的总耗时(含执行 + 归还)
4.2 额外埋点(推荐)
// 在 getConnection 前后打时间戳
long t0 = System.nanoTime();
Connection c = dataSource.getConnection();
long acquireNs = System.nanoTime() - t0;
// 暴露为 Micrometer Timer
Timer.builder("db.acquire")
.description("Time to acquire a connection from pool")
.register(meterRegistry)
.record(acquireNs, TimeUnit.NANOSECONDS);
// 执行耗时
long t1 = System.nanoTime();
try (c) { /* execute */ }
long execNs = System.nanoTime() - t1;
Timer.builder("db.execute")
.description("Time to execute SQL (after acquiring connection)")
.register(meterRegistry)
.record(execNs, TimeUnit.NANOSECONDS);
4.3 告警规则(Prometheus)
# 等连接 P99 > 50ms → 池快满了
- alert: DbAcquireSlow
expr: histogram_quantile(0.99, rate(db_acquire_seconds_bucket[5m])) > 0.05
for: 1m
labels:
severity: warning
# 执行 P99 > 100ms → 下游在变慢
- alert: DbExecuteSlow
expr: histogram_quantile(0.99, rate(db_execute_seconds_bucket[5m])) > 0.1
for: 2m
labels:
severity: warning
# 等连接超时次数 > 0 → 池已耗尽
- alert: DbPoolExhausted
expr: increase(hikaricp_connections_timeout_total[1m]) > 0
for: 30s
labels:
severity: critical
踩坑点:
hikaricp_connections_pending是个瞬时值(当前排队线程数),不是速率。如果你在 Grafana 里画pending > 5的告警,它会在抖动期间持续触发、恢复后消失——但如果你用rate()包它,会得到 0(因为 rate 只对 counter 有效,pending 是 gauge)。pending 要用avg_over_time()或直接看当前值,不要用rate()。
五、总结:一张表
| 配置项 | 你现在的值 | 建议值 | 推导依据 |
|---|---|---|---|
| connectionTimeout | 3000ms | 100~150ms | Little's Law:ρ=0.6 时 P99 排队 ≈ 5ms,×3 余量 + 100ms 下限 |
| socketTimeout(JDBC URL) | 2000ms | 65~100ms | 5×P99_RT(12ms) + 1ms RTT,取整 |
| queryTimeout(Statement) | 未设 | 1s(兜底) | 比 socketTimeout 宽松,防止 cancel 失败时连接卡死 |
| maximumPoolSize | 20 | 20(不动) | 扩池只推迟问题,不解决根因 |
| 准入检查阈值 | 无 | remaining < 2×socketTimeout → 拒绝执行 | 避免「半执行」浪费连接 |
| 等连接超时降级 | 统一 catch | SQLTransientConnectionException → 快速失败 + 缓存 | 重试只会回灌正反馈 |
| 执行超时降级 | 统一 catch | CommunicationsException → 幂等 SQL 重试,非幂等直接降级 | 区分失败类型 |
一句话:超时切分的核心不是「两段各分一半」,而是每段由各自的物理量决定——socketTimeout 由下游 RT 决定(IO 等不了那么久),connectionTimeout 由池吞吐率决定(排队等不了那么久),两者的和必须小于上游 deadline,超出 deadline 的等待是纯浪费,只会拖慢池恢复、加剧正反馈。