超时之后,订单究竟有没有写入

客户端发送 POST,下游已经完成写入,却返回 503。调用端增加一次 retry,第二次得到 200,日志最终显示成功。若只统计最终响应,会漏掉两次真实请求和两次业务写入。HTTP 调用失败描述的是一次观察结果,不等于下游没有产生副作用。

另一个容易混淆的问题是“已经设了超时”。连接池等待、TCP 建连、TLS 握手、等待响应数据和整条业务链的时限分别属于不同阶段。一个响应超时不能自动覆盖 DNS、连接池等待和重试总时长;上层调用结束也不能单独证明连接已经释放。

Chapter37 在本机运行真实 Reactor Netty HTTP 服务,注入无响应、503、提前断开和写入后返回错误。客户端分别使用 RestClient、WebClient 与 HTTP Interface 代理。运行基线为 Spring Framework 6.2.11、Reactor Netty 1.2.10、Reactor Core 3.7.11 与 JDK 21,源码在下载工程独立的 reactive-lab 模块中。

HTTP 阶段、重试与幂等边界

三个入口仍然需要具体传输实现

RestClient 提供同步的调用接口,实际连接管理与超时行为取决于 ClientHttpRequestFactory。实验显式使用 JDK HttpClient 和 JdkClientHttpRequestFactory,防止 classpath 改变后无意切换底层实现。

WebClient 构造响应式请求链,实际网络行为由 ClientHttpConnector 执行。实验显式使用 ReactorClientHttpConnector,传入受控的 Reactor Netty HttpClient 和容量为一的 ConnectionProvider。构造请求链并没有完成发送,订阅才触发该冷请求的执行。

HTTP Interface 使用注解描述方法到 HTTP 请求的映射。HttpServiceProxyFactory 创建接口代理,再通过 RestClientAdapter 或 WebClientAdapter 委托给上述客户端。它减少重复调用代码,没有消除传输配置和错误语义。实验用同一个 @GetExchange("/ok") String ok() 接口分别建立两种代理,真实服务都返回 ok。

同步接口方法使用 WebClient 适配器时,调用端仍然需要等到结果可用。接口返回 String 并不会变成异步协议。因此该实验在主测试线程调用代理,不把这类阻塞返回值接口放进 Netty 事件循环。HTTP Interface 适配器与返回类型

按阶段设置超时

实验中的 Reactor Netty 客户端配置如下,完整 import 和生命周期处理均在 Chapter37.java:

1
2
3
4
5
6
7
8
var pool = ConnectionProvider.builder("spring37-client")
.maxConnections(1)
.pendingAcquireTimeout(Duration.ofSeconds(1))
.build();
var nativeClient = reactor.netty.http.client.HttpClient.create(pool)
.disableRetry(true)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000)
.responseTimeout(Duration.ofMillis(250));

连接池等待时限约束等待可用连接的阶段;connect timeout 约束 TCP 建连;response timeout 约束响应读取阶段的网络等待。Reactor 的 timeout 操作符则作用于其所在序列中的信号等待,位置不同,包含的范围也不同。不能因为这些参数单位都是毫秒,就把它们当作同一种预算。

JDK 路径使用 HttpClient.Builder.connectTimeout(1 秒),再为 Spring 的 JdkClientHttpRequestFactory 设置 250 毫秒 read timeout。固定版本的 JdkClientHttpRequest 包含围绕异步响应 Future 和响应流的超时处理;不能把它与 Netty 的读事件超时直接视为完全等价。JdkClientHttpRequest 固定源码

/slow 处理器已进入,但响应 Publisher 永不发出数据。RestClient 路径最终观察到根因 HttpTimeoutException,WebClient 路径观察到 ReadTimeoutException。断言校验具体异常类型,避免把连接拒绝、500 或程序错误误判成预期超时。250 毫秒是配置值,实验不要求墙钟耗时精确等于该数值。

慢连接必须说明卡在哪一层

本地没有可靠、无侵入的方式把一次 TCP SYN 精确丢弃到超时;选择随机不可达地址还可能得到立即拒绝或不同路由结果。因此实验用一个真实 ServerSocket 接收 TCP 连接后保持静默,让 TLS 客户端发出握手请求却收不到响应。

客户端单独设置 250 毫秒 TLS handshake timeout,最后断言根因为 SslHandshakeTimeoutException。服务端 accept 屏障确认 TCP 已经建立,因而这个场景覆盖的是 TLS 建立阶段停滞。它没有验证 TCP SYN 重传超时,也没有验证慢 DNS;这两个网络故障窗口明确不作为本次通过项。

测试 TLS 客户端为了连接本地故障服务器使用专用不校验证书的信任管理器。该配置只存在于没有 TLS 响应的握手故障分支,不应进入真实业务客户端。实验目的在于控制握手时序,不涉及可信证书部署。

当系统需要端到端预算,应先确定一次业务允许等待多久,再给取连接、握手、响应和重试分配上限。只在每次尝试设置相同超时,重试后总等待可能成倍增长;同步调用方、网关与下游的超时次序也可能导致业务已经完成但调用方先放弃。

错误响应与断开是不同失败

/error 完整返回 503。RestClient 的 retrieve() 抛出对应 HttpServerErrorException.ServiceUnavailable,WebClient 抛出对应 WebClientResponseException.ServiceUnavailable。这时已经拿到了有效 HTTP 状态,调用方可以按业务策略决定是否恢复。

/disconnect 在响应建立前主动关闭连接。WebClient 观察到根因 PrematureCloseException,没有可供业务解释的正常响应。实验显式关闭 Reactor Netty 传输层重试,并检查该端点实际只收到一次请求,防止底层自动重试掩盖应用层次数。

两种失败都可能出现在下游副作用之后。503 并不承诺服务端回滚;提前断开更不能说明服务端尚未收到请求。要判断订单是否存在,需要业务查询或幂等键,不能仅靠异常类名。

错误状态恢复也应消费或释放响应体。实验使用 retrieve().bodyToMono(String.class),让标准处理链处理完整响应或错误;没有使用 exchange() 后丢弃响应对象。若改成 exchangeToMono 或手工读取 DataBuffer,需要把响应体消费和释放路径纳入检查。

重试实验检查实际次数和业务效果

/unsafe 每次调用都增加一次写入计数。第一次写入后返回 503,第二次返回 200。WebClient 的 retry(1) 重新订阅请求链,最终文本为 write=2;断言同时确认请求次数为二、写入次数为二。单看最终 200 就会遗漏重复效果。

/safe 要求请求带 Idempotency-Key: order-7。服务端用并发集合的原子 add 判断是否第一次出现这个键,只有首次才增加业务计数。第一次仍然在写入后返回 503,重试沿用相同键,最终结果为 write=1,请求次数为二而业务效果为一。

这只是单进程故障注入服务的最小幂等模型。集合不持久化,也不保存请求摘要、响应快照或处理状态;服务重启会丢失记录。生产实现通常需要以数据库唯一约束或具有相同持久性承诺的机制保护幂等键,处理相同键不同请求体、并发处理中和保留期限。前一篇的 PostgreSQL 唯一键实验验证了数据库层的重复约束,两者不能混写成已经验证的完整支付协议。

重试策略还应限制错误类型、次数和总预算。实验只为了展示重复执行而使用一次无延迟重试;没有把所有 4xx、鉴权失败或参数错误当作可重试故障。多个调用层分别重试时,实际请求数可能相乘,因而服务端实际计数比客户端打印“重试一次”更可靠。

主动取消与连接池恢复

主动取消场景单独关闭 response timeout,避免超时先触发而使测试名不副实。服务端进入 /slow 后发出屏障信号,主线程才调用订阅的 dispose();服务端响应 Publisher 的 doOnCancel 再通知取消已经传播。断言确认本组只进入一次处理器。

连接池最大容量设为一。响应超时、提前断开和主动取消三组后,都通过同一池请求 /ok 并成功消费响应。如果旧租借永久占用唯一容量,后续请求就会因获取连接失败而不能通过。这个检查证明池恢复服务能力,不要求必须复用原来的 TCP 连接;错误连接被销毁后新建连接同样是正确恢复。

程序结束时等待客户端连接池、服务器和事件循环关闭,并检查 disposed 状态;JDK 客户端也退出资源作用域。这个结果不能推出服务端业务任务一定因客户端取消而停止。当前 /slow 只是一个可取消的 Mono.never,没有线程外部任务或已提交的数据库写入。

运行与证据

在第 00 篇下载工程的根目录,设置 JDK 21 的 JAVA_HOME 后执行:

1
2
3
./mvnw -f reactive-lab/pom.xml -q compile dependency:build-classpath \
-Dmdep.outputFile=target/classpath.txt
python3 reactive-lab/verify.py 37

该篇不需要数据库,不访问外部业务服务。HTTP 与故障 TLS 服务都绑定本机随机端口,等待都有截止时间,测试进程总超时为 90 秒。

实际运行通过 18 条断言,退出码为 0。原始日志在 evidence/37/local-20261002/run.txt,同目录保存命令、JDK、源码及 POM 哈希。预期的超时和断连会产生传输层警告堆栈,它们属于故障注入证据,不能只因日志中含 WARN 就判断测试失败。

1
2
3
4
5
6
7
8
RestClient 503           -> HttpServerErrorException.ServiceUnavailable
WebClient 503 -> WebClientResponseException.ServiceUnavailable
JDK 无响应 -> HttpTimeoutException
Netty 无响应 -> ReadTimeoutException
响应前断开 -> PrematureCloseException
TCP 已建立但 TLS 静默 -> SslHandshakeTimeoutException
POST 重试 -> requests=2, writes=2
同键 POST 重试 -> requests=2, writes=1

预测题与改动练习

把 /safe 的幂等键在每次重试时重新生成,最终业务写入次数应该是多少?如果确实每次订阅都生成不同键,服务端会视为两项独立业务。记录最终收到的键,避免误把请求链构造时只生成一次的行为当成每次重试生成。

把 response timeout 关闭,只在取响应体后增加 timeout,对比异常根因和取消传播。保留服务端进入、服务端取消、下一次取连接成功三个观察点,区分调用方结束、下游取消与池恢复。

将连接池容量为一的客户端同时发出两个永不响应的请求,明确第一个请求已占用连接后再发第二个。预测第二个失败于池等待还是响应读取,分别记录实际异常;不要仅以总耗时相近认为两个请求走了相同失败阶段。

参考资料

下载入口:深入 Spring(00):从手动组装到可验证的容器实验。