DeferredResult 使用指南

2024-02-19
4 min read

DeferredResult 是 Spring MVC 提供的异步 HTTP 响应机制。Controller 方法可以先返回一个「占位结果」,在稍后的任意线程中再调用 setResult() 完成真正的 HTTP 响应。

适用于「请求发起快、结果产出慢」的场景:等待第三方回调、消息队列响应、长轮询、跨服务异步通知等。HTTP 请求线程只负责「注册等待 + 触发业务」,真正的响应在异步事件到达后再写出。

为什么需要 DeferredResult

  1. 问题背景

    以「提交任务并等待外部系统回调结果」为例,典型时序:

    1. 客户端调用 API 提交请求
    2. 服务端向外部系统发起调用(RPC、消息、Webhook 等)
    3. 外部系统异步处理
    4. 外部系统通过回调或事件通知服务端「处理完成」
    5. 服务端将结果返回给客户端

    第 2~4 步往往需要数秒甚至更久。如果 Controller 在 Servlet 线程里同步阻塞等待,Tomcat 工作线程会被大量占用,高并发时容易线程池耗尽。

  2. DeferredResult 做了什么

    DeferredResult 让 Spring MVC 在 Controller 返回后立即释放 Servlet 线程,但保持 HTTP 连接不关闭。后续任意线程调用 setResult(data) 时,Spring 会发起一次 ASYNC dispatch,将响应写回客户端。

    不需要在 Controller 里自己创建线程池
    完成响应的线程可以来自消息监听、@Async 任务、定时任务、Netty 事件循环等已有异步来源。

  3. 请求生命周期

    同步请求:
      request → Filter → Servlet → Controller → 响应 → 结束
    
    DeferredResult:
      第一次 dispatch:
        request → Filter → Controller → return DeferredResult → 释放 Servlet 线程
        (HTTP 连接仍保持打开)
    
      等待异步结果 ...
    
      第二次 dispatch(ASYNC):
        用已 set 的结果完成响应 → 结束
    

标准用法

  1. 基本模式

    @GetMapping("/order/{orderId}")
    public DeferredResult<ResponseEntity<OrderResult>> getOrderResult(
            @PathVariable String orderId) {
    
        DeferredResult<ResponseEntity<OrderResult>> result =
            new DeferredResult<>(10_000L);  // 10 秒超时
    
        result.onTimeout(() ->
            result.setResult(ResponseEntity.status(504)
                .body(new OrderResult(orderId, "TIMEOUT", null))));
    
        result.onError(throwable ->
            result.setResult(ResponseEntity.internalServerError().build()));
    
        orderService.submitAndWait(orderId, (code, data) -> {
            if (code == 0) {
                result.setResult(ResponseEntity.ok(data));
            } else {
                result.setResult(ResponseEntity.badRequest().body(data));
            }
        });
    
        return result;  // 立即返回,释放 Servlet 线程
    }
    
  2. 唤醒链路(通用)

    HTTP 请求线程
      → 创建 DeferredResult,注册到 Holder
      → 触发异步业务(发消息、调外部 API 等)
      → return result(释放 Tomcat 线程)
    
    外部事件线程(MQ 消费者 / @Async / 回调接口 / 定时任务)
      → 业务处理完成
      → holder.completeOne() 或 completeAll()
      → deferredResult.setResult()
      → HTTP 响应返回客户端
    

    唤醒 setResult() 的线程,通常不是当初处理 HTTP 请求的那个线程 ,而是承载异步事件的那条线程。

API 说明

方法 说明
new DeferredResult<>(timeoutMs) 创建实例,超时后触发 onTimeout
setResult(value) 设置成功结果,完成 HTTP 响应(仅可调用一次)
setErrorResult(value) 设置错误结果
onTimeout(Runnable) 超时回调,应在此做资源清理
onCompletion(Runnable) 无论成功、失败或超时,最终都会执行
onError(Consumer<Throwable>) 设置结果过程中抛出异常时触发

常见误区

误区 事实
用了 DeferredResult 就必须在 Controller 里开线程池 不需要;任何线程调用 setResult() 即可
返回 DeferredResult 等于业务完全异步 仅「等待结果」异步;前置校验、DB 查询等仍占用 Servlet 线程
没有新建线程池就等于没用 异步完成可来自 MQ、回调、@Async、定时任务等
超时会自动取消后台任务 onTimeout 只影响 HTTP 层;业务侧需自行实现取消逻辑
setResult 可以多次调用 只能调用一次,重复调用会抛 IllegalStateException

线程池规划

一个后端服务tomcat有一个线程池,自己的某个业务(IO密集型)又存在一个线程池,另外的多个业务(CPU密集型)又需要很多个线程池。那服务启动的线程池岂不是很多?
全局来看,应该如何合理的规划一个服务的线程池数量呢?如何平衡性能?

核心原则:线程池按“阻塞类型”分组,而不是按“业务模块”分组。
IO阻塞时间越长,线程池可以(且应该)设置得越大。 这两者是严格的正相关关系。

  • IO密集型业务(查DB、调RPC、读文件)→ 共用 1个大的IO线程池
  • CPU密集型业务(加密、压缩、序列化)→ 共用 1个小的CPU线程池
  • 特殊隔离业务(如关键支付链路、批量导出)→ 最多再开 1个独立池用于隔离

参考