一个服务调下游用 WebClient,配了 responseTimeout(Duration.ofSeconds(3))。生产上仍然出现"30 秒才失败"的请求,堆栈指向"连接建立"。原因是:responseTimeout 只管"响应",而"连接建立"由另一处配置(连接池/HttpClient)决定。
WebClient 的超时不是一个参数,而是四个:连接、响应、读(流式)、以及整体。而它们"归属不同的层"——这个"层"的差别在于"谁在等":连接超时是"等 socket",响应超时是"等数据"——而这两件事的"最坏耗时"可以相差一个数量级。
设错层,超时就不生效。
1. 四种超时的归属
| 超时 | 归属 | 管什么 |
|---|---|---|
| 连接超时 | HttpClient(连接池) |
TCP 握手 / 池内等待 |
| 响应超时 | WebClient(Reactor Netty) |
从发请求到收到响应头 |
| 读超时(流式) | 传输层 | 两个数据块之间的间隔 |
| 整体超时 | 调用方(Mono.timeout) |
端到端 |
第二项"看起来很全",但它不含"等连接"的时间——于是"池被占满"时,你的 3 秒变成了"排队 30 秒"。这个"排队"的机制很具体:请求先在池外"等一个空闲连接",而这段等待"不属于任何超时"——于是"3 秒的配置"在"池满"时完全失效。
2. "设了不生效"的三种原因
| 原因 | 表现 |
|---|---|
| 设错层(只设了响应) | 连接慢时超时不生效 |
| 被"重试"放大 | 3 秒 × 3 次 + 退避 ≈ 10 秒 |
| 被"整体超时"覆盖 | 外层 timeout 更长 |
第三项的"连锁"是"日志失真":你只看到"30 秒超时",而真正的"3 秒失败"被吞掉——于是"排查方向"被引向"下游太慢",而实际是"配置层级压错了"。
第三项很常见:外层 Mono.timeout(30s) 包着内层 responseTimeout(3s)——于是"内层先超时、外层还在等",而"日志上只有一个 30 秒"。
"分层超时"可以"先量再定",而不是"凭直觉给数":
// 三步定四层超时(同一调用)
1 量下游 P99:假设 800ms → 响应超时 = 2–3× P99 ≈ 2s
2 量建连 P99:内网 20ms、跨公网 200ms → 连接超时 = 500ms–1s
3 用户能等多久:6s → 整体 = 6s,重试方案要"塞得进 6s"
// 判据:四层"由内向外递增",且"总时长 ≤ 用户预算"
3. "四层都设"的正确写法
// 一处配置,四层都有明确边界
var http = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000) // 连接
.responseTimeout(Duration.ofSeconds(3)) // 响应
.doOnConnected(c -> c.addHandlerLast(new ReadTimeoutHandler(5))); // 读(流式)
var client = WebClient.builder().clientConnector(new ReactorClientHttpConnector(http)).build();
client.get().uri(url).retrieve().bodyToMono(String.class)
.timeout(Duration.ofSeconds(6)) // 整体(含重试,故 > 单次)
.retryWhen(Retry.backoff(2, Duration.ofMillis(200)));
4. "连接超时"为什么容易被漏
| 原因 | 说明 |
|---|---|
| 默认值偏大 | 有的客户端默认 30s |
| 池大小不足 | 等池 → 表现为"连接超时" |
| DNS 慢 | 解析时间算在"连接"里 |
第二项最容易误判:"池只有 20,而并发 100" → 80 个请求在"等池",而它们"看起来像连接慢"。
"池等待"与"建连"的区别,用两个指标就能分开:
// 两个数(Micrometer / Reactor Netty)
reactor.netty.connection.provider.pending.connections > 0 → 在排队
reactor.netty.http.client.connect.time P99 ↑ → 真在建连
// 判据:pending 高、connect.time 正常 → 加池/降并发;反之才是网络问题
5. "重试"与"超时"的乘法
| 配置 | 实际最坏耗时 |
|---|---|
| 3s,无重试 | 3s |
| 3s,重试 2 次 | 9s + 退避 |
| 3s,重试 2 次,退避 1s/2s | 12s |
这个乘法的"含义"是"超时要按"用户愿意等多久"倒推":用户能等 6 秒,就"1.5 秒 × 2 次重试 + 0.5 秒退避"——而不是"3 秒 + 3 次重试"。
这个乘法解释了"为什么用户等了 12 秒":单次 3 秒的配置,被"重试 + 退避"放大四倍——而"整体超时"必须大于这个数,否则"重试永远跑不完"。
"整体超时"要"包住重试",而它的写法常被写反:
// 反例:超时套在"单次"里面,重试在外 → 每次重试都重置计时
client.get()...timeout(Duration.ofSeconds(3)).retryWhen(Retry.backoff(2, Duration.ofMillis(200)))
// 正解:把"重试 + 总时长"当成一个整体
Mono.defer(() -> client.get()...retrieve().bodyToMono(String.class))
.retryWhen(Retry.backoff(2, Duration.ofMillis(200)))
.timeout(Duration.ofSeconds(6)); // 整体在重试之外
6. 排错:五种"超时不生效"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 等 30 秒才失败 | 只设响应超时 | 加连接超时 |
| 偶发长时间挂住 | 读超时未设(流式) | 加 ReadTimeoutHandler |
| 重试完才失败 | 重试放大 | 整体超时要 > 总耗时 |
| 日志只有一个超时 | 内外层覆盖 | 分层记录 |
| 池等待被误认为连接慢 | 池不足 | 加池或降并发 |
"重试"要不要"只对幂等操作",而这一条决定了"能不能重试":
// 三类操作与重试
查询/读(幂等):可重试,但要"限次数 + 退避"
写(非幂等):不重试,改"幂等键 + 重试"(否则重复下单)
支付/扣款:不重试,改"对账 + 人工"
// 判据:重试前先问"重复执行会不会多扣一次钱"
7. 边界:什么情况"一个超时就够"
| 场景 | 为什么 | 做法 |
|---|---|---|
| 单次短请求(无流式) | 无"块间隔"问题 | 响应超时即可 |
| 内网、低并发 | 池不紧张 | 响应超时即可 |
| 无重试 | 无乘法 | 响应超时即可 |
| 已有外层网关超时兜底 | 由网关保证 | 内层可粗 |
判断口径一句话:"这次调用会不会"排队等连接"或"长时间读不到数据"。最后一条与"可观测"有关的边界:"超时"要"分层打点"(连接/响应/重试/整体各一个计数器),否则"哪层先放弃"永远靠猜——而它决定了"该调哪层的参数"。
会(高并发、流式、跨公网),四个超时都要设;不会(内网单发),一个响应超时足够。
还有一个"框架差异"值得记:RestTemplate 用 SimpleClientHttpRequestFactory 时,"读超时"与"连接超时"是两个 setter(setReadTimeout/setConnectTimeout),而默认"读超时 0 表示无限"——从 RestTemplate 迁到 WebClient 的人,最容易"以为设了、其实没设"。迁移时该做的第一件事,是"把旧配置的四个数抄下来,逐条对应到新 API"。
再补一条"排查"技巧:用"固定延迟的下游"去验证超时——给下游加 10 秒 sleep,看你的调用"在第几秒失败"。若它"在 30 秒失败",说明"你设的那层没生效";若"在 6 秒失败",说明"整体超时在起作用"。这个实验比读配置快,也比猜可靠。
而"超时设置"这件事,本质上是在回答"我们愿意为一个失败等多久"——它是个产品问题,不是技术参数。所以它该和"用户等待预算"一起定,而不是"在代码里随手写个数"。
把这句话记住,比记住任何 API 都管用。
8. 自测
- 四种超时分别归属哪一层?为什么
responseTimeout"看起来很全"却不含"等连接"? - "设错层"与"被外层覆盖"两种原因,表现有什么不同?
- 为什么"连接超时"容易被误判?"池不足"为什么表现为"连接慢"?
- "重试 × 超时"的乘法怎么算?为什么"整体超时"必须大于总耗时?
- 为什么"日志上只有一个超时"?分层记录能解决什么?
- 判断"一个超时够不够"的那个问题是什么?