2001 字
约 6 分钟
19
S07-A24 · `WebClient` 的超时:为什么"设了 3 秒"还是等了 30 秒

一个服务调下游用 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. 自测

  1. 四种超时分别归属哪一层?为什么 responseTimeout "看起来很全"却不含"等连接"?
  2. "设错层"与"被外层覆盖"两种原因,表现有什么不同?
  3. 为什么"连接超时"容易被误判?"池不足"为什么表现为"连接慢"?
  4. "重试 × 超时"的乘法怎么算?为什么"整体超时"必须大于总耗时?
  5. 为什么"日志上只有一个超时"?分层记录能解决什么?
  6. 判断"一个超时够不够"的那个问题是什么?
S07-A24 · `WebClient` 的超时:为什么"设了 3 秒"还是等了 30 秒
https://www.clxhxhhr.top/posts/6662/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。