如何使用SpringBoot的重试功能模块? - Gavin


重试功能是 Spring Batch 模块的一部分。从 2.2.0 开始,此功能从 Spring Batch 中提取出来并作为一个单独的模块进行维护。要在 Spring 应用程序中启用此功能,请将此依赖项包含到您的 maven pom.xml 中。

<dependency> 
  <groupId>org.springframework.retry</groupId> 
  <artifactId>spring-retry</artifactId> 
  <version>1.3.1.RELEASE</version> 
</dependency>

该库不带有自动配置,因此@EnableRetry应将注解添加到 SpringBoot App 或带有@Configuration注解的类中,以启用重试功能。
 
声明式方法——构建重试逻辑的快速简便方法
下面的示例代码指定了以下设置:

  • 最多 3 次重试
  • 每次重试与乘法器之间的范围为 0.5 秒 - 3 秒的随机间隔
  • 仅针对 RuntimeException 触发重试,这意味着系统会立即针对其他异常(例如客户端错误或验证拒绝)抛出异常。

@Service
public class RetryableCustomerSrvClient {
    @Autowired
    private CustomerSrvClient customerSrvClient;

    @Retryable(value = RuntimeException.class, maxAttempts = 4, backoff = @Backoff(delay = 500L, maxDelay = 3000L, multiplier = 2, random = true))
    public Optional<Customer> getCustomer(Long id) {
        return customerSrvClient.getCustomer(id);
    }
}

注释无需编码即可神奇地工作。
起先,系统逻辑调用 Customer API 客户端上的一个方法来检索客户资料,而无需重试注释。
应用注解
@Retryable
后,Spring 框架在运行时引入了一个代理,该代理通过重试逻辑处理客户 API 客户端上的方法调用。由于代理是在系统启动时创建的,它对产品报价逻辑是完全透明的,因此不需要更改代码。
参数也可以由系统属性指定。但是,如果您想要更动态的东西,这种方法可能不适合您。例如,注解不支持基于产品类型的不同重试设置,除非为每个产品类型指定了单独的方法调用。
在这种情况下,使用命令式风格可以通过动态重试设置来实现这样的系统需求。

 
命令的方法——支持动态重试策略
Spring 框架为命令式方法提供了一个实用程序类RetryTemplate。这是一种“侵入性”方法,涉及更改程序代码,以便系统逻辑使用RetryTemplate来检索客户资料。下图显示它类似于声明式方法中的代理,但是它不是在运行时创建的。
下面的示例代码根据产品类型应用不同的重试策略。使用RetryTemplate显然是一个优势,因为它允许灵活地自定义重试策略作为系统逻辑的一部分。
此示例代码使用声明式方法实现了与上一个类似的重试逻辑。它展示了根据产品代码确定最大尝试次数的逻辑的灵活性。

private Optional<Product> retrieveProduct(String productCode) {

    int maxAttempts = productCode.startsWith(TRAVEL_INSURANCE_PREFIX)? 5 : 2;

    RetryTemplate retryTemplate = RetryTemplate.builder()
            .maxAttempts(maxAttempts)
            .retryOn(RuntimeException.class)
            .exponentialBackoff(300L, 2, 5000L, true)
            .build();

    return retryTemplate.execute(arg -> productSrvClient.getProduct(productCode));
}

 
重试数据插入/更新
重试不仅适用于数据查询。它可以应用于其他过程,例如数据插入/更新的 I/O 操作。想象一下,一个消耗资源并涉及多个步骤的系统进程,您绝对不希望该进程仅仅因为它在进程结束时未能将结果存储到数据库中而崩溃。系统偶尔会在 I/O 操作上遇到错误并不少见,例如,由于并发访问可能导致记录锁定。当系统再次重试时,I/O 操作将成功完成。
请记住,该操作应该是幂等的。换句话说,多次执行操作时,结果应该是不变的。例如,如果多次执行该操作,则只会在数据库中插入一条新记录,而不是重复记录。
如果记录的主键是由 MySQL 根据自增代理 id 生成的,那么每次应用程序逻辑保存一个引用记录时都会创建一个新记录。因此,将通过重试创建重复的记录。
因此,应用程序代码应该准备主键,而不是依赖 MySQL 中的序列号,以实现幂等的数据插入。报价的样本数据模型表明报价代码为记录ID。

@Data
@Builder
@Entity
@Table(name = "quotation")
public class Quotation {
    @Id
    private String quotationCode;

    private Double amount;

    @JsonFormat (shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
    private LocalDateTime expiryTime;

    private String productCode;

    private Long customerId;
}

并且应用逻辑为引用代码分配一个唯一的 UUID
Quotation generateAndSaveQuotation(QuotationReq request, Customer customer, Product product) {

    // generate quotation price
    Double quotationAmount = generateQuotation(request, customer, product);

    // Construct quotation
    Quotation quotation = Quotation.builder()
            .quotationCode(UUID.randomUUID().toString())
            .customerId(customer.getId())
            .expiryTime(now.plusMinutes(quotationExpiryTime))
            .productCode(request.getProductCode())
            .amount(quotationAmount)
            .build();

    // Save into data store
    return saveQuotation(quotation);
}

Quotation saveQuotation(Quotation quotation) {
    RetryTemplate retryTemplate = RetryTemplate.builder()
            .maxAttempts(3)
            .fixedBackoff(1000L)
            .build();
    return retryTemplate.execute(arg -> quotationRepo.save(quotation));
}

 
为重试逻辑构建自动化测试
对重试逻辑的验证并不容易。大多数情况下,很难模拟外部服务和数据库中的错误。另一种方法是使用 Mockito 模拟错误情况。这是用于验证save()引用存储库中方法的示例单元测试代码。
  • 场景 1 - 所有尝试都失败

模拟重试失败的场景很简单,你可以将模拟 bean 配置为在涉及目标方法时始终抛出异常。
@Test
void givenAllRetryOnQuotationSaveExhausted_whenRequestForQuotation_thenThrowException() throws IOException {
    
  // GIVEN 
  when(quotationRepo.save(any(Quotation.class)))
    .thenThrow(new RuntimeException("Mock Exception"));
    
  // other mock setup
  // ...
  
  // WHEN
  Quotation quotation = quotationService.generateQuotation(req);

  // THEN
  // verify exception
  RuntimeException exception = assertThrows(RuntimeException.class, () -> quotationService.generateQuotation(req));
}

  • 场景 2 — 前 2 次尝试失败,第三次尝试成功

更复杂的情况是前 2 次尝试失败,然后第 3 次成功。此示例代码save()在前 2 次调用引用存储库方法时模拟异常错误,并在第 3 次尝试时返回引用对象。Mockito 是一个方便的工具来模拟连续的函数调用。您可以简单地链接模拟设置方法 -thenThrow()并按thenAnswer()顺序链接。
@Test
void givenRetryOnQuotationSaveSuccess_whenRequestForQuotation_thenSuccess() throws IOException, RecordNotFoundException, QuotationCriteriaNotFulfilledException {
    
  // GIVEN 
  when(quotationRepo.save(any(Quotation.class)))
    .thenThrow(new RuntimeException("Mock Exception 1"))
    .thenThrow(new RuntimeException("Mock Exception 2"))
    .thenAnswer(invocation -> (Quotation) invocation.getArgument(0));
    
  // other mock setup
  // ...
  
  // WHEN
  Quotation quotation = quotationService.generateQuotation(req);

  // THEN
  // verify quotation
  // ....
}

请参阅此 GitHub 存储库以获取包含重试逻辑和自动化测试用例的完整应用程序代码