基础重试
Reactor 的 retryWhen 是响应式流的标准重试机制:
import reactor.util.retry.Retry;
import java.time.Duration;
public Mono<String> fetchWithRetry(String url) {
return client.get()
.uri(url)
.responseSingle((res, bytes) -> bytes.asString())
.retryWhen(Retry.max(3)); // 简单重试 3 次
}
Retry.max(3) 表示最多额外尝试 3 次(即总共 4 次)。如果 4 次都失败,最后的异常会向下游传播。
指数退避
简单重试在下游故障时会造成「惊群效应」——所有客户端同时重试,瞬间压垮恢复中的服务。指数退避让每次重试间隔翻倍:
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.maxBackoff(Duration.ofSeconds(2))
.jitter(0.5)); // 加 50% 随机抖动
| 参数 | 行为 |
|---|---|
3 | 最多重试 3 次 |
Duration.ofMillis(100) | 初始间隔 100ms |
maxBackoff | 最大间隔上限 2s |
jitter(0.5) | 每次间隔 ±50% 随机扰动 |
时间线示例:
第 1 次失败 → 等待 80ms (100 * 0.8)
第 2 次失败 → 等待 210ms (200 * 1.05)
第 3 次失败 → 等待 450ms (400 * 1.12)
超过 3 次 → 抛 Exceptions.RetryExhaustedException
只对特定错误重试
不是所有错误都值得重试。5xx 服务端错误通常可以重试,4xx 客户端错误重试无意义。Reactor Netty 的 HttpClient 不会因为 4xx/5xx 自动抛异常——它只把状态码交给你,需要自己决定是否转成错误:
public Mono<String> fetchRetryable(String url) {
return client.get()
.uri(url)
.responseSingle((res, bytes) -> {
int code = res.status().code();
if (code >= 500) {
return bytes.asString().then(Mono.error(
new RetryableException("server error: " + code)));
}
if (code >= 400) {
return bytes.asString().then(Mono.error(
new NonRetryableException("client error: " + code)));
}
return bytes.asString();
})
// 只有 RetryableException 触发重试,其余错误原样向下游传播
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(RetryableException.class::isInstance)
.onRetryExhaustedThrow((spec, signal) ->
new RuntimeException("Retries exhausted", signal.failure())));
}
如果改用 Spring 的 WebClient.retrieve(),4xx/5xx 会自动转成 WebClientResponseException,过滤条件可以写成:
.filter(err -> !(err instanceof WebClientResponseException e)
|| e.getStatusCode().is5xxServerError())
Timeout:防止无限等待
响应式流没有默认超时。如果下游服务挂住不返回,Mono 可能永远 pending:
public Mono<String> fetchWithTimeout(String url) {
return client.get()
.uri(url)
.responseSingle((res, bytes) -> bytes.asString())
.timeout(Duration.ofSeconds(5)); // 总耗时超过 5s 则抛 TimeoutException
}
注意 timeout() 的计时起点:它从 subscription(订阅)开始计时,不是从 HTTP 请求发出开始。如果连接池排队消耗了 3 秒,实际留给 HTTP 传输的时间只剩 2 秒。
如果希望分别控制「连接等待」和「响应读取」,不要只靠一个 timeout:连接池排队由 ConnectionProvider.pendingAcquireTimeout 控制,Reactor Netty 的 HttpClient.responseTimeout(Duration) 限制的是两次网络读之间的最大间隔,底层对应 Netty 的 ReadTimeoutHandler。服务端只要持续缓慢地吐字节,每次读都会重置计时,总耗时可以远超设定值;它不是「请求发出到响应完成」的总 deadline。总 deadline 仍要用上面的 .timeout(Duration),两者可以组合。
降级:onErrorResume
重试耗尽后,与其直接抛异常拖垮整条链路,不如返回一个降级值:
public Mono<String> fetchOrDefault(String url) {
return client.get()
.uri(url)
.responseSingle((res, bytes) -> bytes.asString())
.retryWhen(Retry.backoff(2, Duration.ofMillis(100)))
.timeout(Duration.ofSeconds(5))
.onErrorResume(err -> {
System.err.println("Failed to fetch " + url + ", using default: " + err.getMessage());
return Mono.just("{\"status\":\"degraded\",\"source\":\"default\"}");
});
}
onErrorResume 捕获所有上游错误(包括重试耗尽和超时),返回一个替代 Mono。这在读场景很有效,但写场景需要谨慎——你不能对订单创建请求简单地降级忽略。
组合顺序
重试、超时、降级的组合顺序决定了行为:
client.get().uri(url)
.responseSingle((res, bytes) -> bytes.asString())
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))) // A
.timeout(Duration.ofSeconds(5)) // B
.onErrorResume(err -> Mono.just("default")); // C
执行顺序:
- 发起请求 → 失败 → A 重试
- A 重试 3 次后仍失败 → 失败信号继续向下游的 B 传播
- B 的计时从整条链被订阅时开始,重试间隔也计入;总时间超过 5s → 触发
TimeoutException TimeoutException被 C 捕获 → 返回 “default”
这里的关键是算子位置:
retryWhen在timeout上游(本文写法):timeout只订阅一次,覆盖「首次请求 + 所有重试 + 所有退避等待」,是总时限。timeout在retryWhen上游(即先timeout再retryWhen):每次重试都会重新订阅上游,timeout在每个尝试周期重新计时,变成单次尝试的时限。
两种语义都对,取决于你要「整次调用最多 5 秒」还是「每次尝试最多 5 秒」。要防止重试把总耗时拖长,用本文的顺序(retry → timeout);要让单次尝试快速失败、多次重试仍可继续,把 timeout 放到 retryWhen 前面。
Resilience4j 集成
对于更复杂的弹性模式(熔断、限流、舱壁隔离),Resilience4j 提供了 Reactor 适配器:
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.reactor.retry.RetryOperator;
Retry retry = Retry.ofDefaults("http-retry");
return client.get()
.uri(url)
.responseSingle((res, bytes) -> bytes.asString())
.transformDeferred(RetryOperator.of(retry));
Resilience4j 的 RetryOperator 与 Reactor 的 retryWhen 类似,但提供了更丰富的配置(如自定义等待函数、重试事件监听)。熔断器和限流器在第 11 章详细展开。
服务器端视角
客户端重试策略必须与服务器端的幂等性配合。如果请求已经到达服务端并执行了副作用(如扣款),客户端的超时重试会导致重复执行。
幂等性解决方案:
- 幂等键(Idempotency-Key):客户端每次请求生成唯一键,服务端拒绝重复键
- 查询后写:先查询状态,确认未执行后再提交
- 乐观锁:请求携带版本号,服务端拒绝低版本操作
eventd v2 的内部写调用使用幂等键:客户端生成唯一键,服务端按 key 去重并返回首次执行结果:
import io.netty.buffer.ByteBufAllocator;
import reactor.netty.ByteBufMono;
public Mono<String> idempotentPost(String path, String body) {
String key = UUID.randomUUID().toString();
return client.post()
.uri(path)
// 单次请求头只能在 send 的 BiFunction 里设置,req 是 HttpClientRequest;
// RequestSender 上没有 headers(),不能写成 .uri(path).headers(...)
.send((req, out) -> {
req.header("Idempotency-Key", key);
return out.send(ByteBufMono.fromString(
Mono.just(body), StandardCharsets.UTF_8, ByteBufAllocator.DEFAULT));
})
.responseSingle((res, bytes) -> bytes.asString())
.timeout(Duration.ofSeconds(5))
// 只有超时(服务端可能已执行)才重试;同一个 key 保证重复请求被服务端去重
.retryWhen(Retry.backoff(2, Duration.ofMillis(100))
.filter(err -> err instanceof TimeoutException));
}
参数速查
| 场景 | 重试次数 | 初始间隔 | 最大间隔 | 总超时 | 降级策略 |
|---|---|---|---|---|---|
| 内部服务调用 | 2–3 | 100ms | 1s | 5s | 返回缓存 / 默认值 |
| 外部第三方 API | 1–2 | 500ms | 5s | 10s | 返回降级页 / 失败 |
| 写操作(关键) | 0–1 | — | — | 10s | 不降级,直接抛异常 |
| 读操作(非关键) | 3 | 100ms | 2s | 3s | 返回空值 |
小结
| 能力 | API | 注意 |
|---|---|---|
| 简单重试 | retryWhen(Retry.max(n)) | 总次数 = n + 1 |
| 指数退避 | Retry.backoff(n, initialDelay).maxBackoff(...).jitter(...) | 加 jitter 防惊群 |
| 超时 | .timeout(Duration) | 从订阅开始计时 |
| 降级 | onErrorResume(err -> Mono.just(...)) | 读场景安全,写场景慎用 |
| 组合顺序 | retryWhen → timeout → onErrorResume | timeout 在上游则每次尝试重置计时 |
| 幂等性 | Idempotency-Key 头 | 重试写操作的前提 |
第 2 章结束。第 3 章进入服务端高级特性:3.1 WebSocket 服务端 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。