给 Spring REST API 设置请求超时

1、概览

本文将带你了解给 Spring REST API 设置请求超时的几种方法。

当资源耗时过长时,请求超时机制可以避免糟糕的用户体验。当然也可以使用断路器模式(Circuit Breaker pattern)来实现,本文不细说。

2、@Transactional 超时

在数据库调用中实现请求超时的一种方法是利用 Spring 的 @Transactional 注解。它有一个 timeout 属性可以设置。该属性的默认值是 -1,相当于没有任何超时。

例如,假设将超时设置为 30 秒。如果注解方法的执行时间超过这个秒数,就会抛出异常。这对于回滚长时间运行的数据库查询可能很有用。

编写一个非常简单的 JPA Repository,它代表一个外部服务,该服务需要太长时间才能完成并导致超时。

这个 Repository 中有一个耗时的方法:

public interface BookRepository extends JpaRepository<Book, String> {

    default int wasteTime() {
        Stopwatch watch = Stopwatch.createStarted();

        // 延迟 2 秒
        while (watch.elapsed(SECONDS) < 2) {
          int i = Integer.MIN_VALUE;
          while (i < Integer.MAX_VALUE) {
              i++;
          }
        }
    }
}

如果在超时时间为 1 秒的事务中调用 wasteTime() 方法,超时时间将在方法执行完毕之前结束:

@GetMapping("/author/transactional")
@Transactional(timeout = 1)
public String getWithTransactionTimeout(@RequestParam String title) {
    bookRepository.wasteTime();
    return bookRepository.findById(title)
      .map(Book::getAuthor)
      .orElse("No book found for this title.");
}

调用该端点会导致 500 HTTP 错误,可以将其转换为更有意义的响应。

不过,这种超时解决方案也有一些缺点。

首先,它依赖于具有 Spring 管理事务的数据库。其次,由于注解必须存在于每个需要它的方法或类中,因此它不能全局性地适用于一个项目。此外,它也无法实现亚秒级精度。最后,当超时时,它不会缩短请求时间,因此请求实体仍需等待全部时间。

3、Resilience4j TimeLimiter

Resilience4j 是一个主要管理远程通信容错的库。这里用到的是它的 TimeLimiter 模块。

首先,在项目中加入 resilience4j-timelimiter 依赖:

<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-timelimiter</artifactId>
    <version>2.1.0</version>
</dependency>

接下来,定义一个简单的 TimeLimiter,超时时间为 500 毫秒:

private TimeLimiter ourTimeLimiter = TimeLimiter.of(TimeLimiterConfig.custom()
  .timeoutDuration(Duration.ofMillis(500)).build());

这可以轻松地从外部进行配置。

使用 TimeLimiter 来封装与 @Transactional 示例相同的逻辑:

@GetMapping("/author/resilience4j")
public Callable<String> getWithResilience4jTimeLimiter(@RequestParam String title) {
    return TimeLimiter.decorateFutureSupplier(ourTimeLimiter, () ->
      CompletableFuture.supplyAsync(() -> {
        bookRepository.wasteTime();
        return bookRepository.findById(title)
          .map(Book::getAuthor)
          .orElse("No book found for this title.");
    }));
}

@Transactional 解决方案相比,TimeLimiter 有几个优点。它支持亚秒级精度和超时响应的即时通知。不过,这仍需在所有需要超时的端点中手动加入它。它还需要一些冗长的封装代码,而且产生的错误仍然是一般的 500 HTTP 错误。最后,它需要返回一个 Callable<String> 而不是原始的 String

TimeLimiter 仅包含 Resilience4j 功能的一个子集,可与断路器模式完美对接。

4、Spring MVC request-timeout

Spring 提供了一个名为 spring.mvc.async.request-timeout 的属性。该属性允许以毫秒为精度定义请求超时。

定义该属性,设置超时时间为 750 毫秒:

spring.mvc.async.request-timeout=750

该属性是全局属性,可从外部配置,但与 TimeLimiter 解决方案一样,它只适用于返回 Callable 的端点。

定义一个与 TimeLimiter 示例类似的端点,但无需将逻辑封装在 Future 中,也无需提供 TimeLimiter

@GetMapping("/author/mvc-request-timeout")
public Callable<String> getWithMvcRequestTimeout(@RequestParam String title) {
    return () -> {
        bookRepository.wasteTime();
        return bookRepository.findById(title)
          .map(Book::getAuthor)
          .orElse("No book found for this title.");
    };
}

你可以看到,代码不再那么冗长,而且 Spring 会在定义 application properties 时自动实现配置。一旦超时,响应将立即返回,甚至会返回一个描述性更强的 503 HTTP 错误,而不是通用的 500。项目中的每个端点都将自动继承这一超时配置。

5、WebClient 超时

与为整个端点设置超时不同,我们可能只想为单个外部调用设置超时。WebClient 是 Spring 的响应式 Web 客户端,允许配置响应超时。

也可以在 Spring 较早的 RestTemplate 对象上配置超时;不过,大多数开发人员现在更喜欢 WebClient 而不是 RestTemplate

要使用 WebClient,首先在项目中添加 Spring 的 WebFlux 依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
    <version>2.4.2</version>
</dependency>

定义一个响应超时为 250 毫秒的 WebClient,可以用它通过 baseURL 中的 localhost 调用自己:

@Bean
public WebClient webClient() {
    return WebClient.builder()
      .baseUrl("http://localhost:8080")
      .clientConnector(new ReactorClientHttpConnector(
        HttpClient.create().responseTimeout(Duration.ofMillis(250))
      ))
      .build();
}

显然,这可以很容易地从外部配置这个超时值以及 base URL 和其他的几个可选属性。

现在,将 WebClient 注入 Controller,并用它来调用自己的 /transactional 端点,该端点的超时时间仍为 1 秒。由于已经将 WebClient 的超时时间配置为 250 毫秒,因此它的失效时间应该比 1 秒快得多。

端点如下:

@GetMapping("/author/webclient")
public String getWithWebClient(@RequestParam String title) {
    return webClient.get()
      .uri(uriBuilder -> uriBuilder
        .path("/author/transactional")
        .queryParam("title", title)
        .build())
      .retrieve()
      .bodyToMono(String.class)
      .block();
}

调用该端点后,可以看到确实以 500 HTTP 错误响应的形式收到了 WebClient 的超时。还可以查看日志,查看下游 @Transactional 的超时,但如果调用的是外部服务而不是 localhost,它的超时信息会在远程服务中打印出来。

为不同的后端服务配置不同的请求超时可能是必要的,本解决方案也可以做到这一点。此外,WebClient 返回的 MonoFlux 响应包含大量错误处理方法,可用于处理通用超时错误响应。

6、总结

本文介绍了在 Spring REST API 中实现请求超时的几种不同解决方案。

如果想对数据库请求设置超时,可能需要使用 Spring 的 @Transactional 方法及其 timeout 属性。如果想与更广泛的断路器模式集成,则使用 Resilience4j 的 TimeLimiter 会更合理。使用 Spring MVC 的 request-timeout 属性最适合为所有请求设置全局超时,也可以使用 WebClient 以自请求的方式为每个资源轻松定义更细粒度的超时。


Ref:https://www.baeldung.com/spring-rest-timeout