5.2 指数退避重试与熔断信号

剖析 retryWhen(Retry) 的 companion 协议:手写 companion 正常完成会向下游发 onComplete 并吞掉原始异常(实测),正确做法是在重试耗尽时显式发出原始错误。对比 Retry.fixedDelay、Retry.backoff、Retry.max 的订阅次数、退避序列与异常类型,给出线性/指数退避的可运行实现、重试与熔断的边界,以及避免无限重试和惊群的配置模板。示例基于 reactor-core 3.8.8。

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 的序列
固定间隔base100, 100, 100
线性退避base * attempt100, 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))每次固定等 200msRetryExhaustedException
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 次100ms100ms100ms100–150ms
第 2 次100ms200ms200ms100–300ms
第 3 次100ms300ms400ms200–600ms
累计等待300ms600ms700ms400–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)

两个必须记住的点:

  1. maxAttempts = 3 是重试 3 次、最多订阅 4 次,不是总共尝试 3 次。
  2. 手写 companion 正常完成会让下游收到 onComplete 并丢失原始异常;耗尽时要用 onError 把原错误发回去。

下一节,我们讲超时与取消——如何防止慢操作永远挂起。

阅读导航

上一篇:5.1 错误处理:onErrorReturn、onErrorResume、retry

下一篇:5.3 超时与取消传播

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 15.3 全书总结与学习路径回顾
  2. 15.2 响应式流处理的演进趋势
  3. 15.1 引擎选型指南