Guava-Retrying 重试框架详解

目录

    • [一、什么是 guava-retrying?](#一、什么是 guava-retrying?)
      • [1.1 什么是重试机制?](#1.1 什么是重试机制?)
      • [1.2 guava-retrying 是什么?](#1.2 guava-retrying 是什么?)
      • [1.3 guava-retrying 适合哪些场景?](#1.3 guava-retrying 适合哪些场景?)
    • [二、Maven 依赖引入](#二、Maven 依赖引入)
      • [2.1 Maven 坐标](#2.1 Maven 坐标)
      • [2.2 引入依赖后的注意事项](#2.2 引入依赖后的注意事项)
    • [三、guava-retrying 的核心 API](#三、guava-retrying 的核心 API)
      • 核心概念
        • [核心 API 详解](#核心 API 详解)
        • [1. 重试条件(什么时候触发重试)](#1. 重试条件(什么时候触发重试))
        • [2. 停止策略(何时停止)](#2. 停止策略(何时停止))
        • [3. 等待策略(每次重试间隔)](#3. 等待策略(每次重试间隔))
        • [4. 重试监听器(记录日志/监控)](#4. 重试监听器(记录日志/监控))
      • [3.1 RetryerBuilder:重试执行器构建器](#3.1 RetryerBuilder:重试执行器构建器)
      • [3.2 Retryer:执行重试任务](#3.2 Retryer:执行重试任务)
      • [3.3 重试条件:retryIfResult 和 retryIfException](#3.3 重试条件:retryIfResult 和 retryIfException)
      • [3.4 StopStrategy:停止策略](#3.4 StopStrategy:停止策略)
        • [3.4.1 按次数停止](#3.4.1 按次数停止)
        • [3.4.2 按时间停止](#3.4.2 按时间停止)
        • [3.4.3 永不停止](#3.4.3 永不停止)
      • [3.5 WaitStrategy:等待策略](#3.5 WaitStrategy:等待策略)
        • [3.5.1 固定间隔等待](#3.5.1 固定间隔等待)
        • [3.5.2 指数退避](#3.5.2 指数退避)
        • [3.5.3 随机等待](#3.5.3 随机等待)
        • [3.5.4 递增等待](#3.5.4 递增等待)
        • [3.5.5 Fibonacci 退避](#3.5.5 Fibonacci 退避)
        • [3.5.6 根据异常决定等待时间](#3.5.6 根据异常决定等待时间)
      • [3.6 RetryListener:重试监听器](#3.6 RetryListener:重试监听器)
      • [3.7 Attempt:一次执行尝试的结果](#3.7 Attempt:一次执行尝试的结果)
    • 四、完整使用示例
      • [4.1 示例一:查询结果为 null 时重试](#4.1 示例一:查询结果为 null 时重试)
      • [4.2 示例二:异常时重试远程接口](#4.2 示例二:异常时重试远程接口)
      • [4.3 示例三:记录每次尝试的日志](#4.3 示例三:记录每次尝试的日志)
    • 五、结合实际业务场景进行分析
      • [5.1 场景一:ES 缓存查询重试](#5.1 场景一:ES 缓存查询重试)
      • [5.2 场景二:Redis → ES → DB 多级降级查询](#5.2 场景二:Redis → ES → DB 多级降级查询)
      • [5.3 场景三:滴滴改派单创建重试](#5.3 场景三:滴滴改派单创建重试)
    • 六、生产环境使用时的关键注意事项
      • [6.1 明确区分尝试次数与重试次数](#6.1 明确区分尝试次数与重试次数)
      • [6.2 计算总耗时,避免请求超时](#6.2 计算总耗时,避免请求超时)
      • [6.3 不要对所有异常无差别重试](#6.3 不要对所有异常无差别重试)
      • [6.4 注意线程池资源占用](#6.4 注意线程池资源占用)
      • [6.5 不要把重试当作熔断器](#6.5 不要把重试当作熔断器)
      • [6.6 注意中断与异常处理](#6.6 注意中断与异常处理)
      • [6.7 注意 Retryer 的复用](#6.7 注意 Retryer 的复用)
    • 七、常见问题
    • 八、总结

一、什么是 guava-retrying?

1.1 什么是重试机制?

在 Java 后端开发中,我们经常需要调用外部服务、查询缓存、访问数据库或执行远程 HTTP 请求。这些操作可能受到网络抖动、服务短暂不可用、缓存数据尚未写入等因素影响,导致一次调用失败。

例如,调用第三方打车平台的创建订单接口:

java 复制代码
CreateOrderResponse response = taxiClient.createOrder(request);

假设这次调用因为网络超时而失败,但第三方服务实际上可能仍在处理请求。如果我们直接返回错误,就可能导致业务中断。

重试机制(Retry) 的核心思想是:当一次操作满足特定的失败条件时,按照预先定义的策略重新执行该操作,直到成功或达到停止条件。

一个典型的重试流程如下:

重试机制并不意味着所有失败都应该重新执行。例如,网络连接偶尔失败可能适合重试,而参数校验失败、权限不足、业务规则不满足通常不适合重试。

1.2 guava-retrying 是什么?

guava-retrying 是一个 Java 重试工具库,提供了基于构建器模式的重试执行框架。开发者可以通过链式调用配置:

  • 重试条件:什么情况下需要再次执行。
  • 停止策略:最多执行多少次,或者在什么时间条件下停止。
  • 等待策略:每次重试之前等待多长时间。
  • 重试监听器:在执行尝试的过程中记录日志、收集指标等。

与手动编写 for 循环、try-catch 和 Thread.sleep() 相比,它可以将重试规则集中配置,让业务代码更加简洁。

例如:

java 复制代码
Retryer<String> retryer = RetryerBuilder.<String>newBuilder()
        .retryIfException()
        .withStopStrategy(
                StopStrategies.stopAfterAttempt(3))
        .withWaitStrategy(
                WaitStrategies.fixedWait(500, TimeUnit.MILLISECONDS))
        .build();

String result = retryer.call(() -> remoteService.query());

这段代码表达的意思是:如果执行过程中发生符合条件的异常,就按照配置进行重试,最多进行 3 次尝试,每次失败后等待 500 毫秒。

需要特别注意:stopAfterAttempt(3) 表示最多尝试 3 次,而不是首次调用失败后再额外重试 3 次。

1.3 guava-retrying 适合哪些场景?

使用场景 典型问题 适用的重试方式
缓存查询 缓存暂时未写入,返回 null 根据返回值重试
HTTP / Feign 调用 短暂网络错误、服务临时不可用 根据异常或错误码重试
第三方接口集成 外部平台短暂故障 限次重试、退避等待
异步结果轮询 任务尚未完成,结果为空 根据业务结果重试
多级缓存查询 Redis、ES 查询失败或未命中 每一级独立重试,再降级

需要区分的是:重试和降级不是同一个概念。

重试是在同一类操作上再次尝试;降级是在当前方案无法得到结果时,切换到其他方案。例如 Redis 查询失败后查询 ES,最后查询数据库,就是一种多级降级流程,其中每一级都可以单独使用重试机制。

二、Maven 依赖引入

2.1 Maven 坐标

guava-retrying 的 Maven 坐标如下:

配置项 值
groupId com.github.rholder
artifactId guava-retrying
version 2.0.0

该版本可以在 Maven Central 中查询。

在 pom.xml 中添加依赖:

xml 复制代码
<dependency>
    <groupId>com.github.rholder</groupId>
    <artifactId>guava-retrying</artifactId>
    <version>2.0.0</version>
</dependency>

如果项目使用 Spring Boot,并且通过父工程统一管理依赖版本,可以在父工程中定义版本号,再由子模块引入依赖。

xml 复制代码
<properties>
    <guava-retrying.version>2.0.0</guava-retrying.version>
</properties>
xml 复制代码
<dependency>
    <groupId>com.github.rholder</groupId>
    <artifactId>guava-retrying</artifactId>
    <version>${guava-retrying.version}</version>
</dependency>

2.2 引入依赖后的注意事项

guava-retrying 使用了 Guava 的部分 API,因此需要注意 Guava 相关依赖的版本兼容性。

如果项目已经引入 Guava,建议检查依赖树,确认是否存在版本冲突:

xml 复制代码
mvn dependency:tree

重点关注:

  • 是否存在多个版本的 guava。
  • 是否有其他依赖传递引入了不同版本的 Guava。
  • 项目当前 JDK 版本是否满足最终解析到的依赖版本要求。

不要仅因为引入了 guava-retrying,就直接升级整个项目的 Guava 版本。应先检查现有依赖树,再根据项目实际情况决定是否需要调整。

三、guava-retrying 的核心 API

使用该库时,最重要的是理解下面几个核心类之间的关系。

核心概念

组件 作用
Retryer 重试执行器,封装完整的重试逻辑,通过 retryer.call(callable) 执行任务
RetryerBuilder 构建器,链式配置重试条件、停止策略、等待策略、监听器等
StopStrategy 何时停止重试(按次数、按时间等)
WaitStrategy 每次重试之间的等待策略(固定等待、指数退避、随机等待等)
RetryListener 每次重试的回调监听器(记录日志、监控等)
Attempt 封装每次尝试的结果(第几次、是否有异常、返回值等)

核心 API 详解
1. 重试条件(什么时候触发重试)
java 复制代码
RetryerBuilder.<T>newBuilder()
    .retryIfResult(Objects::isNull)           // 返回值为 null 时重试
    .retryIfResult(StringUtils::isBlank)      // 返回值为空字符串时重试
    .retryIfException()                       // 抛出任何异常时重试
    .retryIfExceptionOfType(IOException.class) // 抛出特定类型异常时重试
    .retryIfRuntimeException()                // 抛出 RuntimeException 时重试
    .retryIfException(e -> e instanceof SQLException) // 自定义异常判断
2. 停止策略(何时停止)
java 复制代码
// 最多重试 N 次
.withStopStrategy(StopStrategies.stopAfterAttempt(3))

// 最多重试 N 秒
.withStopStrategy(StopStrategies.stopAfterDelay(10, TimeUnit.SECONDS))

// 永不停止(直到成功)
.withStopStrategy(StopStrategies.neverStop())
3. 等待策略(每次重试间隔)
java 复制代码
// 固定间隔等待
.withWaitStrategy(WaitStrategies.fixedWait(500, TimeUnit.MILLISECONDS))

// 指数退避等待(越来越长)
.withWaitStrategy(WaitStrategies.exponentialWait(100, 5, TimeUnit.MINUTES))

// 随机等待
.withWaitStrategy(WaitStrategies.randomWait(100, 500, TimeUnit.MILLISECONDS))

// 递增等待(每次增加固定值)
.withWaitStrategy(WaitStrategies.incrementingWait(100, TimeUnit.MILLISECONDS, 200, TimeUnit.MILLISECONDS))

// Fibonacci 退避
.withWaitStrategy(WaitStrategies.fibonacciWait(100, 5, TimeUnit.MINUTES))

// 组合等待策略(异常时等待更久)
.withWaitStrategy(WaitStrategies.exceptionWait(IOException.class, e -> 2000L))
4. 重试监听器(记录日志/监控)
java 复制代码
.withRetryListener(new RetryListener() {
    @Override
    public <V> void onRetry(Attempt<V> attempt) {
        // attempt.getAttemptNumber()  → 第几次尝试
        // attempt.hasException()      → 是否有异常
        // attempt.hasResult()         → 是否有返回值
        // attempt.getResult()         → 获取返回值
        // attempt.getExceptionCause() → 获取异常
        log.info("第 {} 次重试", attempt.getAttemptNumber());
    }
})

3.1 RetryerBuilder:重试执行器构建器

RetryerBuilder<T> 是配置重试行为的入口,采用 Builder 模式组织各项配置。

常见方法如下:

方法 作用
newBuilder() 创建构建器
retryIfResult(predicate) 根据返回值判断是否重试
retryIfException() 对异常进行重试判断
retryIfExceptionOfType(clazz) 针对指定异常类型
retryIfRuntimeException() 针对运行时异常
withStopStrategy(strategy) 配置停止策略
withWaitStrategy(strategy) 配置等待策略
withRetryListener(listener) 添加监听器
build() 创建 Retryer<T>

例如:

java 复制代码
Retryer<String> retryer = RetryerBuilder.<String>newBuilder()
        .retryIfResult(Objects::isNull)
        .retryIfExceptionOfType(IOException.class)
        .withStopStrategy(StopStrategies.stopAfterAttempt(3))
        .withWaitStrategy(
                WaitStrategies.fixedWait(500, TimeUnit.MILLISECONDS))
        .build();

这里的泛型 String 表示被执行的业务方法返回 String 类型。若业务方法返回 ResponseEntity<CreateOrderResponse>,就可以将泛型替换成相应的类型。

一个重要细节: 多次调用 retryIfResult()、retryIfException() 等方法,可以组合多种重试条件。只要其中一个条件判定本次尝试需要重试,就可能触发下一次执行。可以将其理解为多个重试条件之间的逻辑 OR。

3.2 Retryer:执行重试任务

Retryer<T> 是实际执行重试逻辑的对象。

最常用的方法是:

java 复制代码
T call(Callable<T> callable)
        throws ExecutionException, RetryException;

其中:

  • Callable<T>:真正要执行的业务逻辑。
  • 返回值 T:业务执行成功后得到的结果。
  • ExecutionException:某次操作抛出的异常未被重试条件判定为需要重试时,通常会以此异常向外抛出。
  • RetryException:重试策略决定停止时,仍未获得可接受结果,或者线程被中断时可能抛出。

例如:

java 复制代码
String orderNo = retryer.call(() -> {
    return orderService.queryOrderNo();
});

重试框架只负责执行和控制重试,不会自动决定失败后应该返回什么业务结果。最终异常如何转换、是否执行降级,需要由调用方处理。

官方 API 对 call() 的异常行为有明确说明。

3.3 重试条件:retryIfResult 和 retryIfException

这是整个框架最关键的部分。

方式一:根据返回值重试

当方法正常返回,但返回值不符合业务要求时,可以使用 retryIfResult()。

java 复制代码
Retryer<String> retryer = RetryerBuilder.<String>newBuilder()
        .retryIfResult(Objects::isNull)
        .build();

这表示当结果为 null 时,重试框架认为当前结果不可接受。

再比如:

java 复制代码
.retryIfResult(StringUtils::isBlank)

当返回值为空字符串、空白字符串或 null 时,触发重试判断。这里的 StringUtils 应使用项目实际引入的 Apache Commons Lang 等工具类。

需要注意,返回值重试并不意味着返回值一定是业务失败。框架只执行开发者定义的条件,具体哪些结果不合格,必须由业务自行确定。

方式二:根据异常重试

对网络调用、文件访问等可能抛出异常的操作,可以根据异常类型设置重试条件。

java 复制代码
.retryIfException()

对所有被框架捕获的异常进行重试判断。

java 复制代码
.retryIfExceptionOfType(IOException.class)

只对指定的 IOException 类型异常进行重试判断。

java 复制代码
.retryIfRuntimeException()

对 RuntimeException 类型的异常进行重试判断。

也可以使用自定义条件:

java 复制代码
.retryIfException(e -> e instanceof SQLException)

实际开发中,建议优先明确哪些异常可以恢复,避免直接对所有异常进行无差别重试。例如,参数错误、权限异常、业务校验失败,通常不会因为重复执行而恢复。

方式三:返回值和异常条件组合
java 复制代码
Retryer<String> retryer = RetryerBuilder.<String>newBuilder()
        .retryIfResult(Objects::isNull)
        .retryIfExceptionOfType(IOException.class)
        .withStopStrategy(StopStrategies.stopAfterAttempt(3))
        .withWaitStrategy(
                WaitStrategies.fixedWait(500, TimeUnit.MILLISECONDS))
        .build();

这段代码表示:

  1. 结果为 null 时,重试。
  2. 出现 IOException 时,重试。
  3. 其他异常不会因为上述两项条件自动被判定为可重试。
  4. 最多进行 3 次尝试。

3.4 StopStrategy:停止策略

停止策略决定重试框架何时停止继续尝试。

常见的策略由 StopStrategies 提供。

3.4.1 按次数停止
java 复制代码
.withStopStrategy(StopStrategies.stopAfterAttempt(3))

最多执行 3 次,包括首次执行。

如果每次执行都失败,则最多执行:

  • 第 1 次:首次执行。
  • 第 2 次:第 1 次重试。
  • 第 3 次:第 2 次重试。

总计 3 次调用,而不是 4 次。

3.4.2 按时间停止
java 复制代码
.withStopStrategy(
        StopStrategies.stopAfterDelay(10, TimeUnit.SECONDS))

该策略根据从首次尝试开始经过的时间来判断是否应该停止继续重试。

要特别注意:它不是严格的整个业务方法超时控制。一次正在执行的远程调用可能持续较长时间,因此仍应为 HTTP 客户端、数据库连接或单次操作设置合理的超时时间。

3.4.3 永不停止
java 复制代码
.withStopStrategy(StopStrategies.neverStop())

意味着没有基于该停止策略的重试次数或时长上限。

这种方式存在明显风险:如果操作一直失败,调用线程可能长期无法返回,持续占用线程池资源。因此,在普通的 Web 请求和同步接口调用中,应谨慎使用,通常优先采用有限重试。

3.5 WaitStrategy:等待策略

等待策略决定两次尝试之间如何等待。WaitStrategies 提供了多种实现。

3.5.1 固定间隔等待
java 复制代码
.withWaitStrategy(
        WaitStrategies.fixedWait(500, TimeUnit.MILLISECONDS))

每次重试前等待 500 毫秒。

适用于等待成本较低、重试次数较少、希望行为简单可控的场景。

3.5.2 指数退避
java 复制代码
.withWaitStrategy(
        WaitStrategies.exponentialWait(
                100, 5, TimeUnit.MINUTES))

指数退避的特点是随着尝试次数增加,等待时间逐渐变长,并受配置的上限约束。

假设等待时间按 100ms、200ms、400ms、800ms 的趋势增长,那么越靠后的重试间隔越长。实际等待时间还受到该策略的参数、内部计算和上限约束。

指数退避适合远程服务临时过载、网络波动等情况,可以减少连续快速重试给故障服务带来的额外压力。

3.5.3 随机等待
java 复制代码
.withWaitStrategy(
        WaitStrategies.randomWait(
                100, 500, TimeUnit.MILLISECONDS))

在指定范围内随机产生等待时间。

随机等待可以降低大量客户端在相同时间重试的同步性,减少请求同时涌向故障服务的风险。

3.5.4 递增等待
java 复制代码
.withWaitStrategy(
        WaitStrategies.incrementingWait(
                100,
                TimeUnit.MILLISECONDS,
                200,
                TimeUnit.MILLISECONDS))

适用于希望等待间隔按照固定增量逐渐增加的情况。

3.5.5 Fibonacci 退避
java 复制代码
.withWaitStrategy(
        WaitStrategies.fibonacciWait(
                100, 5, TimeUnit.MINUTES))

Fibonacci 退避按照斐波那契数列的增长趋势增加等待时间,并受到配置上限约束。

它和指数退避的区别在于等待时间增长规律不同,可根据业务延迟要求选择。

3.5.6 根据异常决定等待时间
java 复制代码
.withWaitStrategy(
        WaitStrategies.exceptionWait(
                IOException.class,
                e -> 2000L))

可以针对指定异常设置等待时间,例如 IOException 对应等待 2 秒。

这类策略适合不同错误类型需要不同等待时间的场景。需要注意的是,等待策略只决定等待时间,并不等同于重试条件。

3.6 RetryListener:重试监听器

RetryListener 用于观察执行尝试,可以在每次尝试时记录日志、统计次数或采集监控数据。

基本用法:

java 复制代码
.withRetryListener(new RetryListener() {
    @Override
    public <V> void onRetry(Attempt<V> attempt) {
        log.info("执行第 {} 次尝试",
                attempt.getAttemptNumber());
    }
})

监听器可获取的信息主要来自 Attempt<V>。

方法 含义
getAttemptNumber() 当前尝试的序号,从 1 开始
hasException() 当前尝试是否抛出异常
hasResult() 当前尝试是否产生了正常返回值
getResult() 获取正常返回的结果
getExceptionCause() 获取本次尝试的异常原因
getDelaySinceFirstAttempt() 从首次尝试开始经过的时间,单位为毫秒

这些方法可以帮助我们判断重试失败的原因,并记录执行过程。

注意:监听器在每次尝试时都会执行,不仅仅在发生重试时执行。由于第一次尝试也会触发监听器,日志文案最好使用"尝试"而不是一概写成"重试"。

此外,getResult() 和 getExceptionCause() 应根据 hasResult()、hasException() 的判断结果使用,否则可能因为读取了不适用的字段而抛出异常。

3.7 Attempt:一次执行尝试的结果

Attempt<V> 用于封装某次执行的状态,包括结果或异常、尝试次数以及耗时信息。

例如:

java 复制代码
if (attempt.hasException()) {
    log.warn("第 {} 次执行失败",
            attempt.getAttemptNumber(),
            attempt.getExceptionCause());
} else if (attempt.hasResult()) {
    log.info("第 {} 次执行成功",
            attempt.getAttemptNumber());
}

Attempt 主要用于观察和分析执行过程,本身并不负责发起重试。真正的重试决策仍然由构建器中配置的条件和策略控制。

四、完整使用示例

4.1 示例一:查询结果为 null 时重试

假设我们要查询一个刚刚写入的 ES 缓存。由于写入和查询之间可能存在短暂延迟,第一次查询返回 null,我们希望等待后再试。

下面给出一个可以独立理解的示例:

java 复制代码
import com.github.rholder.retry.RetryException;
import com.github.rholder.retry.Retryer;
import com.github.rholder.retry.RetryerBuilder;
import com.github.rholder.retry.StopStrategies;
import com.github.rholder.retry.WaitStrategies;

import java.util.Objects;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;

public class RetryDemo {

    public static void main(String[] args) {
        Retryer<String> retryer = RetryerBuilder.<String>newBuilder()
                // 查询结果为 null 时重试
                .retryIfResult(Objects::isNull)

                // 最多尝试 3 次
                .withStopStrategy(
                        StopStrategies.stopAfterAttempt(3))

                // 两次尝试之间等待 1 秒
                .withWaitStrategy(
                        WaitStrategies.fixedWait(
                                1, TimeUnit.SECONDS))
                .build();

        try {
            String result = retryer.call(() -> queryCache());

            System.out.println("查询成功:" + result);

        } catch (RetryException e) {
            // 已达到停止条件,仍然没有得到可接受的结果
            System.err.println("重试结束,仍未获取到缓存");

        } catch (ExecutionException e) {
            // 异常没有被重试条件判定为需要重试
            System.err.println("查询过程出现不可重试异常");
            e.printStackTrace();
        }
    }

    private static String queryCache() {
        // 模拟缓存尚未准备好
        return null;
    }
}

运行时,queryCache() 每次都返回 null,因此最多执行 3 次,每次失败后等待 1 秒。

由于每次返回的都是 null,最终达到停止条件后会抛出 RetryException。

这里有一个值得注意的地方:retryIfResult(Objects::isNull) 只处理返回值判定,不代表任意异常都会重试。如果 queryCache() 抛出未配置重试条件的异常,调用方仍然需要处理相应的异常。

4.2 示例二:异常时重试远程接口

假设调用第三方服务时,偶尔会抛出 IOException。我们希望只对该类异常重试,最多尝试 3 次,每次间隔 500 毫秒。

java 复制代码
import com.github.rholder.retry.RetryException;
import com.github.rholder.retry.Retryer;
import com.github.rholder.retry.RetryerBuilder;
import com.github.rholder.retry.StopStrategies;
import com.github.rholder.retry.WaitStrategies;

import java.io.IOException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;

public class RemoteServiceRetryDemo {

    public String invokeRemoteService()
            throws IOException, ExecutionException, RetryException {

        Retryer<String> retryer = RetryerBuilder.<String>newBuilder()
                // 仅对 IOException 重试
                .retryIfExceptionOfType(IOException.class)

                // 最多尝试 3 次
                .withStopStrategy(
                        StopStrategies.stopAfterAttempt(3))

                // 固定等待 500 毫秒
                .withWaitStrategy(
                        WaitStrategies.fixedWait(
                                500, TimeUnit.MILLISECONDS))
                .build();

        return retryer.call(() -> callRemoteService());
    }

    private String callRemoteService() throws IOException {
        // 替换成真实的 HTTP / RPC 调用
        return "success";
    }
}

这个示例展示了如何将重试策略与业务操作分开配置。实际开发中,callRemoteService() 可以替换为 Feign、RestTemplate 或其他 HTTP 客户端调用。

但有一个重要前提:远程请求必须具备合理的单次超时设置。如果单次请求一直阻塞,重试策略无法替代 HTTP 客户端的连接超时、读取超时等控制。

4.3 示例三:记录每次尝试的日志

在生产环境中,单纯重试还不够。我们通常需要知道操作执行了几次、失败的原因是什么,以及最后是否成功。

java 复制代码
Retryer<String> retryer = RetryerBuilder.<String>newBuilder()
        .retryIfExceptionOfType(IOException.class)
        .withStopStrategy(
                StopStrategies.stopAfterAttempt(3))
        .withWaitStrategy(
                WaitStrategies.fixedWait(
                        500, TimeUnit.MILLISECONDS))
        .withRetryListener(new RetryListener() {
            @Override
            public <V> void onRetry(Attempt<V> attempt) {

                if (attempt.hasException()) {
                    log.warn(
                            "远程调用第 {} 次尝试失败,原因:{}",
                            attempt.getAttemptNumber(),
                            attempt.getExceptionCause().toString());
                } else {
                    log.info(
                            "远程调用第 {} 次尝试返回结果",
                            attempt.getAttemptNumber());
                }
            }
        })
        .build();

这里需要根据实际业务情况调整日志级别。异常可能在第一次尝试后被恢复,因此可以使用 warn;对于最终失败,则应在调用方记录包含业务标识的错误日志。

生产环境日志中还应避免直接输出敏感请求参数、认证令牌或完整个人信息。

五、结合实际业务场景进行分析

前面介绍的是基础用法,下面结合三个真实业务场景,分析 guava-retrying 在实际项目中应该如何使用。

5.1 场景一:ES 缓存查询重试

假设业务需要根据缓存标识查询 ES 中的数据:

java 复制代码
Retryer<T> retryer = RetryerBuilder.<T>newBuilder()
        .retryIfResult(Objects::isNull)
        .withStopStrategy(
                StopStrategies.stopAfterAttempt(3))
        .withWaitStrategy(
                WaitStrategies.fixedWait(
                        1000, TimeUnit.MILLISECONDS))
        .withRetryListener(new RetryListener() {
            @Override
            public <V> void onRetry(Attempt<V> attempt) {
                log.info(
                        "EsCacheService.getCache.cache_id:{}, 执行{}次",
                        header.getId(),
                        attempt.getAttemptNumber());
            }
        })
        .build();

return retryer.call(() -> getCacheInner(header));

这段代码的业务逻辑可以拆解为:

  1. 调用 getCacheInner(header) 查询缓存。
  2. 如果返回值为 null,则等待 1 秒。
  3. 再次执行查询,最多尝试 3 次。
  4. 如果仍然没有查询到数据,抛出 RetryException。
  5. 由上层业务决定是否降级、返回空结果或抛出业务异常。

这个方案适合处理短暂的数据可见性延迟,但需要注意:如果数据长期不存在,重试并不能解决问题,只会增加查询开销。

5.2 场景二:Redis → ES → DB 多级降级查询

假设系统需要根据供应商订单号查询内部订单号,依次尝试 Redis、ES 和数据库。

业务流程如下:

示例代码:

java 复制代码
private static Retryer<String> getStringRetryer(
        int executionCount,
        long timeout,
        String supplierNo,
        String supplierOrderId) {

    return RetryerBuilder.<String>newBuilder()
            // 空字符串或 null 时重试
            .retryIfResult(StringUtils::isBlank)

            // 限制最大尝试次数
            .withStopStrategy(
                    StopStrategies.stopAfterAttempt(executionCount))

            // 固定等待间隔
            .withWaitStrategy(
                    WaitStrategies.fixedWait(
                            timeout, TimeUnit.MILLISECONDS))

            .withRetryListener(new RetryListener() {
                @Override
                public <V> void onRetry(Attempt<V> attempt) {
                    log.info(
                            "supplier:{}, supplierOrderId:{}, 执行{}次",
                            supplierNo,
                            supplierOrderId,
                            attempt.getAttemptNumber());
                }
            })
            .build();
}

调用方:

java 复制代码
// 第一层:Redis
Retryer<String> retryer =
        getStringRetryer(2, 500, supplierNo, supplierOrderId);

String orderNo = retryer.call(
        () -> cacheService.get(
                Header.init(BASE_ORDER_ID_CONFIG, key)));

// 第二层:ES
if (StringUtils.isBlank(orderNo)) {
    retryer = getStringRetryer(
            2, 1100, supplierNo, supplierOrderId);

    orderNo = retryer.call(
            () -> getOrderNoFromES(
                    supplierNo, supplierOrderId));
}

// 第三层:数据库
if (StringUtils.isBlank(orderNo)) {
    orderNo = orderBaseMapper.getOrderNoBySupplierNoAndSupplierOrderNo(
            supplierNo, supplierOrderId);
}

这个设计有两个层次:

  • 同一层内部重试:Redis 或 ES 暂时没有查到数据时,重复查询当前数据源。
  • 层级之间降级:当前数据源仍然没有结果时,切换到下一个数据源。

需要注意,上述代码是对原始业务逻辑的示意整理,实际项目应补充异常处理,并确认缓存未命中、缓存访问异常、ES 查询异常分别应该怎样处理。

尤其要考虑:Redis 不可用时,是应该重试 Redis,还是直接降级到 ES?这取决于系统的容错目标,而不是简单地对所有异常重试。

5.3 场景三:滴滴改派单创建重试

第三方订单接口的重试尤其需要谨慎,因为创建订单通常属于有副作用的操作。

原始业务的重试配置类似下面这样:

java 复制代码
private static Retryer<ResponseEntity<CreateOrderResponse>>
        getCreateDiDiOrderRetryer(
                int executionCount,
                long timeout,
                String supplierOrderId) {

    return RetryerBuilder
            .<ResponseEntity<CreateOrderResponse>>newBuilder()

            .retryIfException()

            .withStopStrategy(
                    StopStrategies.stopAfterAttempt(5))

            .withWaitStrategy(
                    WaitStrategies.fixedWait(
                            500, TimeUnit.MILLISECONDS))

            .withRetryListener(new RetryListener() {
                @Override
                public <V> void onRetry(Attempt<V> attempt) {
                    log.error(
                            "getCreateDiDiOrderRetryer,supplierOrderId:{}, 执行{}次",
                            supplierOrderId,
                            attempt.getAttemptNumber());
                }
            })
            .build();
}

调用方式:

java 复制代码
Retryer<ResponseEntity<CreateOrderResponse>> retryer =
        getCreateDiDiOrderRetryer(
                5, 500, request.getSupplierOrderNo());

ResponseEntity<CreateOrderResponse> response =
        retryer.call(
                () -> taxiClient.createDiDiOrder(token, request));

假设第一次请求已经到达第三方服务,订单也成功创建,但响应在网络传输过程中丢失。客户端观察到异常后再次调用,就有可能创建重复订单。

因此,不能简单地认为"异常就代表业务没有执行成功"。

在生产环境中,针对创建订单、支付、退款等操作,应重点考虑:

  • 幂等键:使用供应商订单号或专用请求 ID,确保重复请求不会重复创建业务数据。
  • 异常分类:只有网络暂时异常、服务临时不可用等适合恢复的情况才重试。
  • 结果核对:遇到请求超时等结果不确定的情况,优先根据业务单号查询第三方订单状态。
  • 有限重试:避免持续重试造成下游服务过载。

只有在重试成功且确认业务恢复后,才适合上报自愈事件。重试框架本身并不能保证远程业务的幂等性或最终一致性。

六、生产环境使用时的关键注意事项

6.1 明确区分尝试次数与重试次数

假设:

java 复制代码
StopStrategies.stopAfterAttempt(5)

含义是最多执行 5 次,即:

  • 1 次首次执行。
  • 最多 4 次额外重试。

如果每次都失败,则总共调用 5 次。

6.2 计算总耗时,避免请求超时

假设最多执行 3 次,每次等待 1 秒,那么仅重试间隔就可能累计 2 秒。

这里 n 是总尝试次数。

例如,最多尝试 3 次,每次远程调用耗时 800ms,两次间隔各等待 1 秒,则总耗时约为:

因此,重试次数、等待策略、单次调用超时和上游 HTTP 请求超时必须协同设计。

6.3 不要对所有异常无差别重试

例如:

异常或失败类型 通常是否适合重试
短暂网络连接异常 可以考虑
临时服务不可用 可以考虑
参数校验失败 通常不适合
权限不足 通常不适合
业务状态不允许操作 通常不适合
创建订单响应超时 先核对业务状态,再决定

以上只是一般原则,具体还要根据接口协议和业务语义判断。

6.4 注意线程池资源占用

guava-retrying 的常规等待策略会阻塞当前执行线程。对于同步接口调用,如果配置了较长的等待时间,线程就会一直被占用。

例如,一个线程池中有 50 个工作线程,如果大量请求同时进入重试等待阶段,就可能导致线程池吞吐量下降。

建议:

  • 控制最大尝试次数。
  • 配置合理的单次请求超时。
  • 根据业务需要选择固定间隔或退避等待。
  • 对高并发远程调用考虑限流、熔断和舱壁隔离。
  • 避免在没有上限的情况下无限重试。

6.5 不要把重试当作熔断器

重试用于应对短暂故障;熔断用于在故障持续时暂时停止请求,避免系统持续消耗资源。

如果下游服务已经长时间不可用,一味重试只会放大故障。此时应结合熔断、限流、降级等措施,控制故障传播。

6.6 注意中断与异常处理

在停止重试或等待过程中,如果线程被中断,框架会结束当前重试流程,并可能抛出 RetryException。框架也会恢复线程的中断状态。

因此,在业务代码中捕获异常时,不要随意吞掉中断信号。

如果业务逻辑需要自行处理中断,应检查线程的中断状态,并按应用的并发处理约定退出任务或向上层传递中断信息。

6.7 注意 Retryer 的复用

官方 API 文档指出,Retryer 在传入参数具备线程安全性的前提下可以被多个线程安全地使用。

但这并不意味着任意业务对象、监听器和回调都自动线程安全。

对于 Spring Boot 项目,可以将配置固定的 Retryer 封装为 Bean 复用;如果重试条件、监听器或业务参数与单次请求绑定,则应谨慎处理共享状态,避免不同请求之间相互影响。

七、常见问题

问题一:返回 null 会自动重试吗?

不会。只有配置了类似 .retryIfResult(Objects::isNull) 的条件,null 才会被判定为需要重试。

问题二:发生异常就一定会重试吗?

不会。只有异常满足已配置的重试条件时才会继续重试。未匹配的异常通常会通过 ExecutionException 向外传递。

问题三:重试失败后会返回 null 吗?

不一定。如果重试条件一直不满足,调用可能直接返回结果;如果停止策略触发但始终没有得到可接受的结果,则会抛出 RetryException。

问题四:stopAfterDelay() 可以限制整个接口的执行时间吗?

不能完全等同。它限制的是重试过程继续尝试的时间条件,不会自动取代单次 HTTP 请求超时或整个业务流程的超时控制。

问题五:RetryListener 只在重试时执行吗?

不是。它会在每次尝试时执行,包括首次尝试。因此,监听器可以用于记录整个重试调用过程。

八、总结

guava-retrying 是一个轻量、灵活的 Java 方法重试工具库,适合在现有业务代码中引入明确的重试条件、停止策略和等待策略。

掌握它的核心,需要重点理解以下几点:

  1. RetryerBuilder 负责构建重试规则。
  2. Retryer.call() 负责执行带重试能力的任务。
  3. retryIfResult() 和 retryIfException() 决定哪些失败需要重试。
  4. StopStrategy 决定什么时候停止。
  5. WaitStrategy 决定两次尝试之间等待多久。
  6. RetryListener 和 Attempt 帮助记录每次尝试的结果与异常。
  7. 重试需要与超时、幂等、降级、限流和熔断协同设计。

对于常见的 ES 缓存查询、Redis 与 ES 多级降级、第三方接口调用等场景,可以使用 guava-retrying 统一管理重试逻辑。但在订单创建、支付等有副作用的业务中,必须首先考虑幂等性和结果核对,不能单纯依靠重试机制保证业务正确性。


8.1 使用示例

场景一:ES 缓存查询重试(返回值为 null 时重试)
java 复制代码
Retryer<T> retryer = RetryerBuilder.<T>newBuilder()
        .retryIfResult(Objects::isNull)                                    // 结果为 null 就重试
        .withStopStrategy(StopStrategies.stopAfterAttempt(3))              // 最多 3 次
        .withWaitStrategy(WaitStrategies.fixedWait(1000, TimeUnit.MILLISECONDS)) // 每次等 1 秒
        .withRetryListener(new RetryListener() {
            @Override
            public <V> void onRetry(final Attempt<V> attempt) {
                log.info("EsCacheService.getCache.cache_id:{}, 执行{}次",
                        header.getId(), attempt.getAttemptNumber());
            }
        })
        .build();
return retryer.call(() -> getCacheInner(header));

业务含义:查 ES 缓存可能因为索引延迟返回 null,重试 3 次、每次间隔 1 秒,确保数据最终能查到。


场景二:订单号查询多级降级重试(返回值为空字符串时重试)
java 复制代码
private static Retryer<String> getStringRetryer(int executionCount, long timeout, 
        String supplierNo, String supplierOrderId) {
    return RetryerBuilder.<String>newBuilder()
            .retryIfResult(StringUtils::isBlank)                           // 结果为空就重试
            .withStopStrategy(StopStrategies.stopAfterAttempt(executionCount)) // 可配次数
            .withWaitStrategy(WaitStrategies.fixedWait(timeout, TimeUnit.MILLISECONDS))
            .withRetryListener(new RetryListener() {
                @Override
                public <V> void onRetry(final Attempt<V> attempt) {
                    log.info("getStringRetryer.supplier:{}, supplierOrderId:{}, 执行{}次",
                            supplierNo, supplierOrderId, attempt.getAttemptNumber());
                }
            })
            .build();
}

调用

java 复制代码
// 调用方:先重试 Redis(2次×500ms),再重试 ES(2次×1100ms),最后降级到 DB
Retryer<String> retryer = getStringRetryer(2, 500, supplierNo, supplierOrderId);
orderNo = retryer.call(() -> cacheService.get(Header.init(BASE_ORDER_ID_CONFIG, key)));

if (StringUtils.isBlank(orderNo)) {
    retryer = getStringRetryer(2, 1100, supplierNo, supplierOrderId);
    orderNo = retryer.call(() -> getOrderNoFromES(supplierNo, supplierOrderId));
}

if (StringUtils.isBlank(orderNo)) {
    // 最终降级到数据库查询
    orderNo = orderBaseMapper.getOrderNoBySupplierNoAndSupplierOrderNo(...);
}

业务含义 :查询订单号采用多级降级策略 --- Redis → ES → DB,每一级内部用 guava-retrying 做有限重试。


场景三:滴滴改派单创建重试(抛异常时重试)
java 复制代码
private static Retryer<ResponseEntity<CreateOrderResponse>> getCreateDiDiOrderRetryer(
        int executionCount, long timeout, String supplierOrderId) {
    return RetryerBuilder.<ResponseEntity<CreateOrderResponse>>newBuilder()
            .retryIfException()                                         // 抛任何异常就重试
            .withStopStrategy(StopStrategies.stopAfterAttempt(5))       // 最多 5 次
            .withWaitStrategy(WaitStrategies.fixedWait(500, TimeUnit.MILLISECONDS))
            .withRetryListener(new RetryListener() {
                @Override
                public <V> void onRetry(final Attempt<V> attempt) {
                    log.error("getCreateDiDiOrderRetryer,supplierOrderId:{}, 执行{}次",
                            supplierOrderId, attempt.getAttemptNumber());
                }
            })
            .build();
}

调用

java 复制代码
// 调用方:改派单时加入重试,重试成功后上报自愈事件
Retryer<ResponseEntity<CreateOrderResponse>> retryer =
        getCreateDiDiOrderRetryer(5, 500, request.getSupplierOrderNo());
res = retryer.call(() -> taxiClient.createDiDiOrder(token, request));

if (res != null && res.getBody() != null) {
    selfHealingEventProcess.reportSelfHealing(
        "createDidiReassignOrderException", abnormalTime, recoveryTime,
        "订单改派单创建异常通过重试创建成功并恢复业务");
}

业务含义:调用滴滴创建改派单接口可能因网络抖动失败,重试 5 次、每次间隔 500ms,重试成功后上报自愈事件用于监控。


总结

维度 说明
重试触发 按返回值判断(retryIfResult)或按异常判断(retryIfException)
停止策略 按次数(stopAfterAttempt)或按时间(stopAfterDelay)
等待策略 固定等待、指数退避、随机等待、Fibonacci 退避等
监听机制 RetryListener 回调,每次重试可记录日志或上报监控
你项目中的用途 ES 缓存查询重试、订单号多级降级查询、滴滴外部接口调用重试

官方资料

相关推荐
SunnyDays10111 小时前
使用 Java 高效管理 Excel 批注:添加、读取、修改和删除
java·开发语言·excel
const_qiu2 小时前
第 2.5 章 2.5.8:质量关 —— 安全审查、UI/UX 审查与踩坑复盘
java·人工智能·安全·ui·ux
chde2Wang2 小时前
hadoop部署错误-namenode address dfs.namenode.servicerpc-address not configured
java·大数据·hadoop
牛战士coding2 小时前
Java 对象写入 Redis 后类型变了?Jackson 多态序列化问题复盘
java·开发语言·redis
小白的码BUG之路2 小时前
Jenkins -- 配置jdk和maven
java·jenkins·maven
xiaoqiMikko2 小时前
Redis 实战(基础篇):把一个老 RedisUtil 搬到 Spring Boot 4,一路踩出来的坑(真机实测)
java·spring boot·redis
yuniko-n2 小时前
【JUC】读写锁
java·开发语言
yuzhiboyouye2 小时前
后端怎么评估工作量
java
java资料站2 小时前
十五、Spring AI Alibaba · 高级 ·Graph
java·人工智能·spring