retryWhen 只接受 Retry 对象
retry(n) 只能做固定次数、无延迟的重试。需要退避、抖动、按异常类型过滤时,用 retryWhen。
Reactor 3.8 的签名已经收窄:
// Flux<T> 上只有这一个重载
public final Flux<T> retryWhen(Retry retry); // reactor.util.retry.Retry
旧的 retryWhen(Function<Flux<Throwable>, Publisher<?>>) 已经不在 API 里。要沿用「拿到错误流、自己决定何时重试」的写法,得包一层 Retry.withThrowable(...),它接收的才是 Flux<Throwable>。
理解 retryWhen 的关键是 companion(伴随流)的四种信号:
| companion 收到的/发出的信号 | 结果 |
|---|---|
上游 onError | 错误进入 companion,等待重试信号 |
companion onNext | 重新订阅上游 |
companion onError | 立即终止下游,用 companion 的错误 |
companion 正常 onComplete | 下游收到 onComplete,原始错误被丢弃 |
最后一行是最容易踩的坑。下面的代码看起来「最多重试 2 次,然后把错误抛给下游」:
AtomicInteger subscribes = new AtomicInteger();
Flux<Integer> alwaysFails = Flux.defer(() -> {
int n = subscribes.incrementAndGet();
System.out.println("subscribe#" + n);
return Flux.error(new RuntimeException("boom" + n));
});
alwaysFails
.retryWhen(Retry.withThrowable(errors ->
errors.take(2).delayElements(Duration.ofMillis(20))))
.subscribe(
v -> {},
e -> System.out.println("ONERROR: " + e.getMessage()),
() -> System.out.println("ONCOMPLETE"));
实测输出(reactor-core 3.8.8):
subscribe#1
subscribe#2
subscribe#3
ONCOMPLETE
take(2) 让 companion 在转发两个错误后完成。第三次订阅失败时,companion 已经完成,retryWhen 把「完成」当成终止信号发给下游——下游看到的是 ONCOMPLETE,而不是第 3 个 boom3。重试耗尽的错误被静默吞掉了,调用方会误以为请求成功。
Retry.from(...) 也一样:它接收 Flux<RetrySignal>,但 companion 正常完成时同样是向下游发 onComplete。实测 Retry.from(errors -> errors.zipWith(Flux.range(1, 3), ...).concatMap(...)) 的结局也是 3 次重试后 ONCOMPLETE。
手写退避:在耗尽时把原错误发回去
要保留原始异常,companion 必须在重试耗尽时 onError 而不是 onComplete。做法是把计数放在 companion 内部,第 maxRetries + 1 次失败直接返回 Mono.error(error):
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
/** 工具类中的静态方法:每个订阅独立计数;第 maxRetries+1 次失败把原错误交给下游 */
static <T> Flux<T> retryWithBackoff(Flux<T> source, int maxRetries, long baseMillis) {
return source.retryWhen(Retry.withThrowable(errors -> {
AtomicInteger failures = new AtomicInteger();
return errors.concatMap(error -> {
int attempt = failures.incrementAndGet();
if (attempt > maxRetries) {
// 关键:companion 以 onError 结束,原始异常才会到达下游
return Mono.<Long>error(error);
}
// 指数退避:base, 2*base, 4*base ...
long delay = baseMillis * (1L << (attempt - 1));
System.out.println("第 " + attempt + " 次重试,等待 " + delay + "ms");
// companion 的 onNext 触发重订阅
return Mono.delay(Duration.ofMillis(delay))
.thenReturn((long) attempt);
});
}));
}
用 3 次重试、base=100ms 跑一个永远失败的源,实测时间戳如下(起点做了归一化,间隔才是重点):
subscribe#1 at +0ms
subscribe#2 at +105ms // 100ms 退避
subscribe#3 at +308ms // 200ms 退避
subscribe#4 at +712ms // 400ms 退避
ONERROR: boom4 // 第 4 次失败,原样交给下游
换成 baseMillis * attempt 就是线性序列,同样的运行会看到 100ms、200ms、300ms 的间隔。注意 Mono.delay 默认跑在 Schedulers.parallel() 上,退避期间不占用订阅线程;concatMap 保证一次只处理一个失败信号,计数不会并发错乱。
固定间隔与线性退避
三个策略的差别只在延迟函数:
| 策略 | 延迟函数(attempt 从 1 开始) | base=100ms 的序列 |
|---|---|---|
| 固定间隔 | base | 100, 100, 100 |
| 线性退避 | base * attempt | 100, 200, 300 |
| 指数退避 | base * 2^(attempt-1) | 100, 200, 400 |
固定间隔直接改用内置的 Retry.fixedDelay 更省事;线性退避 Reactor 没有内置工厂,上面的手写版本是可用的;指数退避优先用 Retry.backoff。
内置 Retry:不用自己维护状态机
Retry 的工厂方法覆盖了大多数场景:
| 工厂 | 语义 | 3 次重试后的结果 |
|---|---|---|
Retry.max(3) | 立即重试,最多 3 次 | RetryExhaustedException,cause 是最后一次失败 |
Retry.maxInARow(3) | 连续失败最多 3 次;中途有 onNext 则计数清零 | 同上 |
Retry.fixedDelay(3, Duration.ofMillis(200)) | 每次固定等 200ms | RetryExhaustedException |
Retry.backoff(3, Duration.ofMillis(200)) | 200ms 起,按 multiplier(默认 2)递增 | RetryExhaustedException |
Retry.indefinitely() | 无限重试,无延迟 | 不会耗尽,必须配合 take/超时 |
Retry.from(Function<Flux<RetrySignal>, Publisher<?>>) | 自定义,信号里带 totalRetries()、failure() | 取决于 companion |
Retry.withThrowable(Function<Flux<Throwable>, Publisher<?>>) | 旧式入口,只暴露 Throwable | 取决于 companion |
先把次数口径钉死。Retry.max(3)、Retry.backoff(3, ...)、Flux.retry(3) 里的 3 都是最多重试 3 次,也就是最多 4 次订阅。实测 Retry.backoff(3, 100ms).jitter(0) 对永远失败的源:
subscribe#1 at +0ms
subscribe#2 at +107ms
subscribe#3 at +310ms
subscribe#4 at +715ms
ONERROR at +715ms: Retries exhausted: 3/3
退避间隔约 100ms、200ms、400ms,然后第 4 次订阅失败时抛出 RetryExhaustedException。参数名虽然叫 maxAttempts,但它数的是重试次数,不是总订阅次数。
常用配置
Mono<Response> call = client.get()
.retryWhen(Retry.backoff(3, Duration.ofMillis(200))
.maxBackoff(Duration.ofSeconds(5)) // 单次退避上限
.multiplier(2.0) // 200, 400, 800
.jitter(0.5) // 随机抖动,打散重试时刻
.filter(this::isTransient) // 只重试瞬态错误
.doBeforeRetry(s -> log.warn("第 {} 次重试,原因:{}",
s.totalRetries() + 1, s.failure().toString()))
.onRetryExhaustedThrow((spec, signal) ->
new ServiceUnavailableException(
"已重试 " + signal.totalRetries() + " 次", signal.failure())));
几个容易忽略的细节:
RetrySignal.totalRetries()是 0 起始的。第一次失败时是 0,所以日志里写+ 1。filter不匹配时,重试不会发生,原错误立即向下游传播,订阅次数保持 1。实测对RuntimeException使用.filter(e -> e instanceof IOException),只打印了一次subscribe#1和原始异常。jitter(0.5)的抖动结果会被夹在[minBackoff, maxBackoff]内。第一次退避不会低于minBackoff(base=100ms、jitter=0.5 时是 100–150ms),第二次起才可能出现低于基准值的情况(200ms 对应 100–300ms)。transientErrors(true)让maxAttempts按「连续失败次数」计算:中途只要成功发出过onNext,计数就清零。适合长连接上的间歇性故障。- 默认的
RetryExhaustedException保留了最后一次失败的 cause。它是reactor.core.Exceptions的包级内部类,不能直接写catch (RetryExhaustedException e),要用Exceptions.isRetryExhausted(t)判定。onRetryExhaustedThrow返回 null 会立刻抛 NPE,不要写return null。
Retry.from 与 Retry.withThrowable 的区别
两者都允许自定义 companion,差别在信号类型:
Retry.from的入参是Flux<RetrySignal>,能读到累计重试次数和失败原因,适合「重试几次后换一种策略」。Retry.withThrowable的入参是Flux<Throwable>,只适合老代码迁移。
两者共享同一个终止规则:companion 正常完成 = 下游 onComplete,原错误丢失。自定义时务必像上一节那样,在耗尽分支返回 Mono.error(...)。
重试与熔断的边界
重试解决「这次可能刚好失败」,熔断解决「对面已经不可用,别再打了」。两者互补:
| 策略 | 触发条件 | 行为 | 恢复方式 |
|---|---|---|---|
| 重试 | 单次请求失败 | 等待后重新订阅 | 某次成功即恢复 |
| 熔断 | 时间窗口内失败率/慢调用比例超阈值 | 直接失败,不再发起请求 | 半开状态放探测请求 |
决策顺序:
请求失败?
├── 4xx / 参数错误 / 业务校验失败 → 不重试,直接返回
├── 瞬态错误(连接重置、5xx、超时)→ 有限次退避重试
└── 连续多次瞬态失败 → 熔断,走降级
用 Resilience4j 的 Reactor 适配器时,操作符的位置决定熔断器「看到几次失败」:
import io.github.resilience4j.reactor.circuitbreaker.operator.CircuitBreakerOperator;
// 写法 A:熔断器在重试外层,每次逻辑调用只记一次结果
client.get()
.retryWhen(Retry.backoff(3, Duration.ofMillis(200)).filter(this::isTransient))
.transformDeferred(CircuitBreakerOperator.of(circuitBreaker))
.onErrorResume(this::fallback);
// 写法 B:熔断器在重试内层,每一次重试尝试都会被记录
client.get()
.transformDeferred(CircuitBreakerOperator.of(circuitBreaker))
.retryWhen(Retry.backoff(3, Duration.ofMillis(200)).filter(this::isTransient))
.onErrorResume(this::fallback);
- A 把「一组重试」当成一次调用,熔断阈值反映的是逻辑请求的失败率。
- B 把每次尝试都计入,熔断器会更早打开,能更快保护下游,但失败率会被重试放大。
没有统一答案,取决于你想保护的是「用户请求的成功率」还是「下游服务的瞬时压力」。选一种并在团队里写明。
陷阱
无限重试
// 危险:companion 永远不完成,失败一次就重试一次
flux.retryWhen(Retry.withThrowable(errors ->
errors.flatMap(error -> Mono.delay(Duration.ofSeconds(1)))));
errors 只在源失败时发射,永远不会完成,所以这个流会一直重试。Retry.indefinitely() 和 flux.retry() 同理。无限重试不是「高可用」,它会把故障期间的所有请求持续压向已经过载的下游,同时占住连接和队列。
如果确实需要「一直重试直到成功」,至少加一个总时限:
// 1 秒一次的无限重试,由 timeout 兜住总时长,避免无延迟忙等
flux.retryWhen(Retry.fixedDelay(Long.MAX_VALUE, Duration.ofSeconds(1))
.filter(this::isTransient))
.timeout(Duration.ofSeconds(30))
.onErrorResume(e -> fallback());
重试非幂等操作
重试 = 重新订阅 = 再执行一次。支付、下单、发消息这类操作,如果第一次其实已经成功、只是响应超时,重试会造成重复扣款或重复消息。要么只重试幂等接口,要么带上幂等键(Idempotency-Key),由服务端去重。
重试放大
三层服务各重试 3 次,最坏情况下一个用户请求会变成 4 × 4 × 4 = 64 次下游调用。退避和抖动只能错开时间,不能消除总量。常见做法是只在最外层重试,内层快速失败;或者给重试加预算(例如整个请求链路最多 3 次尝试)。
把 doBeforeRetry 当成功回调
doBeforeRetry 在每次重新订阅之前执行,不是失败最终处理。要记录最终失败,用 onRetryExhaustedThrow 或下游的 onErrorResume。
完整配置模板
public final class RetryPolicies {
private RetryPolicies() {
}
public static Retry transientBackoff() {
return Retry.backoff(3, Duration.ofMillis(200))
.maxBackoff(Duration.ofSeconds(5))
.multiplier(2.0)
.jitter(0.5)
.filter(RetryPolicies::isTransient)
.doBeforeRetry(signal -> log.warn(
"retry {}/3 after {}: {}",
signal.totalRetries() + 1,
signal.failure().getClass().getSimpleName(),
signal.failure().getMessage()))
.onRetryExhaustedThrow((spec, signal) ->
new ServiceUnavailableException(
"retry exhausted after " + signal.totalRetries() + " retries",
signal.failure()));
}
private static boolean isTransient(Throwable t) {
return t instanceof java.io.IOException
|| t instanceof java.util.concurrent.TimeoutException
|| t instanceof TransientServiceException; // 应用自定义,例如 429 / 5xx 包装
}
}
其中 ServiceUnavailableException、TransientServiceException 是应用自己的异常类型,log 用项目现有日志门面。使用:
Mono<Response> call = client.get()
.retrieve()
.bodyToMono(Response.class)
.retryWhen(RetryPolicies.transientBackoff())
.onErrorResume(ServiceUnavailableException.class, e -> fallback());
模板里的 isTransient 只是示例:4xx(除 429)不应该重试,认证失败、参数错误重试多少次都不会成功。
退避序列速查
以 base=100ms、3 次重试为例:
| 重试次数 | 固定间隔 | 线性 | 指数(multiplier=2) | 指数 + jitter(0.5) |
|---|---|---|---|---|
| 第 1 次 | 100ms | 100ms | 100ms | 100–150ms |
| 第 2 次 | 100ms | 200ms | 200ms | 100–300ms |
| 第 3 次 | 100ms | 300ms | 400ms | 200–600ms |
| 累计等待 | 300ms | 600ms | 700ms | 400–1050ms |
带抖动的范围来自 Retry.backoff 的实现:抖动偏移被限制在 [minBackoff, maxBackoff] 区间内,所以第一次退避只会向上浮动。
小结
| 需求 | 选择 |
|---|---|
| 固定次数、无延迟 | retry(n) 或 Retry.max(n) |
| 固定间隔 | Retry.fixedDelay(n, duration) |
| 指数退避 + 抖动 | Retry.backoff(n, minBackoff).jitter(...) |
| 线性退避 | 手写 companion,耗尽时 Mono.error(lastError) |
| 只重试特定异常 | .filter(predicate);不匹配会立即传播原错误 |
| 长时间运行的流按「连续失败」计数 | .transientErrors(true) |
两个必须记住的点:
maxAttempts = 3是重试 3 次、最多订阅 4 次,不是总共尝试 3 次。- 手写 companion 正常完成会让下游收到
onComplete并丢失原始异常;耗尽时要用onError把原错误发回去。
下一节,我们讲超时与取消——如何防止慢操作永远挂起。
阅读导航
上一篇:5.1 错误处理:onErrorReturn、onErrorResume、retry
下一篇:5.3 超时与取消传播
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。