返回

Spring Boot 接口 405 错误:Thread.sleep 阻塞与网关超时详解

java

Spring Boot 接口请求 405 错误:探究 Thread.sleep 引发的问题

最近在开发中遇到一个奇怪的问题:在 Spring Boot RestController 的方法中使用 Thread.sleep(5000) 模拟耗时操作,通过 API 网关访问接口时,立刻返回 405 错误,但五秒后断点又能进入 Controller 方法。 去掉 Thread.sleep,一切又恢复正常。这让我很困惑,为什么简单的睡眠操作会导致 405 错误?

一、 问题现象

简单来说,问题表现如下:

  1. 使用了 Thread.sleep
  2. 通过 API 网关访问接口,立刻得到 405 错误。
  3. 五秒后,代码执行到 Controller 的断点处。
  4. 直接访问服务接口 (不通过网关),或去掉 Thread.sleep,问题消失。

二、 原因分析

问题的根源在于 API 网关的超时设置与 Thread.sleep 阻塞的共同作用 ,外加 Spring 对 HTTP 方法处理的机制

  1. 阻塞与超时: Thread.sleep 会阻塞当前线程。在这个例子中,它阻塞了处理请求的 Tomcat worker 线程。这意味着在 5 秒内,该线程无法处理任何其他事情,包括响应网关。

  2. 网关的超时: 大多数 API 网关(如此处的 Spring Cloud Gateway)都有自己的超时设置。它们会等待后端服务的响应,如果在指定时间内没有收到响应,就会认为请求失败,返回相应的错误码(比如 504 Gateway Timeout,或其他自定义错误)。在这个问题中,很可能网关等不及 5 秒,提前返回错误。

  3. HTTP 方法不匹配 (405 Method Not Allowed): 405 错误表示服务器理解请求的资源,但请求的方法不被允许。结合上述的超时分析, 推测可能是网关因为某些机制 (比如重试,或者 fallback 处理),改变了原来的请求方法 (很可能变成了其他非GET 方法)。导致与Controller中的@RequestMapping(value = "/users", method = RequestMethod.GET) 不匹配.

三、 解决方案

既然知道了原因,解决起来就容易多了。下面提供几种方案:

1. 调整网关超时时间

这是最直接的方法。既然问题是因为网关等不及,那就让它多等等。

  • 原理: 延长网关等待后端服务响应的时间,使其大于等于 Thread.sleep 的时间。

  • 操作: 修改 Spring Cloud Gateway 的配置文件(application.ymlapplication.properties)。
    在YAML文件中, 主要是spring.cloud.gateway.httpclient.response-timeout控制响应时间。在本案例中,已经设置为 7 秒。所以排除这一项。 进一步修改可以增加如下配置项,修改全局或单个route 的超时配置.

    spring:
      cloud:
        gateway:
          httpclient:
            connect-timeout: 10000  # 连接超时,单位毫秒, 确保大于后端服务的最大处理时间.
            response-timeout: 10s   # 响应超时,单位秒或毫秒(如 10s 或 10000ms)
          routes:
            - id: USER-SERVICE
              uri: lb://USER-SERVICE
              predicates:
                - Path=/user/*
              # 针对特定路由的超时配置(可选)
              metadata:
                   response-timeout: 10s
                   connect-timeout: 10000
    

    或, 采用全局配置

      spring:
        cloud:
            gateway:
               routes:
                  - id: USER-SERVICE
                    # ...其他配置...
               # 全局超时配置.
               global-timeout:
                  connectTimeoutMillis: 10000
                  readTimeoutMillis: 10000
    
    

    需注意的是, 不同版本的 Spring Cloud Gateway 或不同的网关产品,配置项可能略有不同,要查阅相应的官方文档。
    此方法有一定弊端, 简单粗暴增加超时时间,可能会掩盖真正的问题,而且过长的超时时间可能会导致资源浪费.

2. 使用异步处理

与其让 worker 线程傻傻地等待,不如把它解放出来,让它去处理其他请求。

  • 原理: 使用 Spring 的异步处理机制,将耗时操作放到单独的线程中执行,不阻塞主线程。

  • 代码示例:

    import org.springframework.web.bind.annotation.*;
    import java.util.List;
    import java.util.concurrent.CompletableFuture;
    
    @RestController
    @RequestMapping("/user")
    public class UserController {
    
        @Autowired
        UserService userService;
    
        @GetMapping("/users")
        public CompletableFuture<List<User>> getAllUsers() {
            return CompletableFuture.supplyAsync(() -> {
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt(); // 恢复中断状态
                    // 适当的错误处理
                }
                System.out.println("In getAllUsers");
                return userService.findAllUsers();
            });
        }
    }
    
  • 说明:

    • 将方法的返回类型改为 CompletableFuture<List<User>>
    • 使用 CompletableFuture.supplyAsync() 将耗时操作包装起来,使其在另一个线程中执行。
    • Thread.currentThread().interrupt(); :在catch代码块中重新设置线程的中断状态是一个好习惯。
      采用异步处理方式能够提高系统的吞吐量和响应速度。
  • 进阶使用技巧 : 可以结合@Async注解 和 Spring的线程池配置,更加精细地控制异步任务的执行.
    例如:

  @Async("myThreadPoolTaskExecutor") //自定义的线程池
  public CompletableFuture<List<User>> getResultAsync(){
    // ....
  }

其中myThreadPoolTaskExecutor是用户配置的TaskExecutor Bean.

3. 使用 WebClient (响应式编程)

如果你的项目使用了 Spring WebFlux (响应式编程框架), 则推荐使用 WebClient。

  • 原理: WebClient 是 Spring WebFlux 提供的非阻塞 HTTP 客户端,可以更优雅地处理异步请求和响应。

  • 代码示例: (需要 Spring WebFlux 依赖)

        @RestController
        @RequestMapping("/user")
        public class UserController {
    
            @Autowired
            private WebClient webClient;
    
            @Autowired
            UserService userService;
    
          @GetMapping("/users")
            public Mono<List<User>> getAllUsers() {
                   return Mono.delay(Duration.ofSeconds(5))  // 模拟延时,非阻塞
                            .flatMap( aLong-> {
                              System.out.println("getAllUsers 执行完毕");
                              return Mono.just(userService.findAllUsers());
    
                        });
    
            }
    
            //构建 WebClient (一般作为 Bean 注入,这里做个示例)
           @Bean
            public WebClient getWebClient() {
    
                HttpClient httpClient = HttpClient.create()
                        .responseTimeout(Duration.ofSeconds(10));  //设置超时时间.
    
              return WebClient.builder()
                        .clientConnector(new ReactorClientHttpConnector(httpClient))
                        .baseUrl("http://localhost:8080")  //可以设置 baseurl.
                         .build();
              }
    
    
    
        }
    

    在这个例子中, 我们用Mono.delay(Duration.ofSeconds(5))来代替Thread.sleep()。前者是非阻塞的。
    使用 WebClient 和 响应式编程模型能够充分发挥非阻塞 I/O 的优势,提升系统性能。

4. 前端轮询 (或长连接)

如果业务场景允许,也可以考虑前端轮询或使用 WebSocket 等长连接技术。

  • 原理:

    • 轮询: 前端定期向后端发送请求,询问任务是否完成。
    • 长连接: 建立持久连接,后端在任务完成后主动通知前端。
  • 适用场景:

    • 任务执行时间较长,且不需要实时返回结果。
    • 需要获取任务的进度信息。

这里不展开具体代码,因为这涉及到前端和后端的配合。

5. 检查网关的请求方式 (针对 405)

根据上面的分析, 很有可能在超时后网关对请求方法进行了修改。因此:

  1. 查看网关日志: 仔细检查 API 网关的日志,看是否有重试或其他修改原始请求的记录,尤其是请求的 HTTP Method。

  2. 调试: 可以在 Controller 中打印出请求的 HttpServletRequest 对象,看看到达 Controller 的请求的实际方法是什么。

      @GetMapping("/users")
      public List<User> getAllUsers(HttpServletRequest request) {
    
          System.out.println("Request method: " + request.getMethod());
          // ... 其他代码 ...
      }
    

如果发现到达服务时的确请求方法变成了POST等,那么可以进一步排查,调整网关或服务的配置.

四、安全建议

  • 避免在生产环境中使用 Thread.sleep 尽量使用异步处理或响应式编程来处理耗时操作。
  • 合理设置超时: 根据业务需求和性能测试结果,设置合理的超时时间。过短的超时时间容易导致请求失败,过长的超时时间可能导致资源浪费。
  • 异常处理: 在异步任务中,要正确处理异常,避免异常被吞掉。可以给CompletableFuture 添加exceptionally 处理异常情况。
  • 中断处理 : 对InterruptedException,除了throw new RuntimeException(e),最好调用Thread.currentThread().interrupt()

这个问题虽然看似简单,却牵涉到多个方面,希望上面这些分析和建议能够帮助遇到类似问题的朋友。记住,具体选择哪种解决方案要结合自己的业务情况,做适当调整和优化。