news 2026/10/3 4:11:15

Spring异步流式接口实战:DeferredResult、SseEmitter与WebFlux三方案详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring异步流式接口实战:DeferredResult、SseEmitter与WebFlux三方案详解

干接口开发的,几乎都遇到过这种场景:一个查询接口,后端要聚合多个下游服务的数据,平时 200 毫秒就能跑完,一旦某个下游抖动,响应时间直接飙到 30 秒,前端的超时告警一个接一个。尤其是现在的大屏、报表、AI 对话这类功能,对接口实时性要求越来越高,传统的同步阻塞模型已经越来越力不从心。

我最近在 Spring 项目里陆续落地了 3 种异步流式接口方案,把几个耗时大户从同步接口改造成异步流式之后,接口超时告警基本清零,整体吞吐量也提上来了。这篇文章不玩虚的,直接讲透三种方案的原理、代码和取舍逻辑,顺便把一些常规文档里不会写的坑一并列出来。如果你正在为接口超时头疼,或者想了解流式接口在 Spring 里到底能怎么玩,这篇文章应该能给你一些实实在在的参考。

1. 为什么需要异步流式接口

1.1 接口超时到底卡在哪里

很多老项目的接口都是典型的同步阻塞模型。一个 Controller 方法里调三个下游服务:查用户信息 200 毫秒、查订单列表 200 毫秒、查风控结果 200 毫秒,串行加起来就是 600 毫秒,再加框架序列化开销,整体响应时间在 700 到 800 毫秒左右。单看这个数字好像还能接受,可架不住下游偶尔抖动。一旦某个下游从 200 毫秒变成 3 秒,前端就只能干等,浏览器直接报 504。

这里的问题不在于某一个下游慢,而在于同步模型把请求线程给钉死了。Tomcat 默认线程池一般在 200 个线程左右,当 200 个线程全部阻塞在等待下游响应上时,新请求就只能排队,整个服务表现为大面积超时。我见过有团队把 Tomcat 线程池上限调到 1000,以为能解决问题,结果线程一多,CPU 上下文切换开销变大,系统反而更不稳定,属于治标不治本。

1.2 同步阻塞与异步流式的本质区别

同步阻塞模型就是一个线程一口气处理完请求,中间等待下游响应的阶段,这个线程什么都干不了,只能挂起。异步流式接口的核心思路刚好相反,目标是让连接线程尽快脱离等待状态,请求进来后先释放线程,后台任务执行完了再通过回调、异步上下文或者响应流把结果推回客户端。

用一个生活化的类比来说:同步处理就像去银行柜台办业务,柜员一直为你服务,直到你办完离开,下一位客户才能坐下;异步就像取号后在等候区休息,柜员先给下一位客户办业务,等你的号到了再广播叫你回来。对于耗时长的业务,异步模型明显能提升整体吞吐率。

流式接口在异步基础上又叠加了一层能力:数据不是一次性返回,而是像水龙头一样,生成一段就推一段,客户端边收边渲染。对 AI 对话、报表刷新这类场景,这个能力特别关键,用户不用等全部算完才看到结果,体验上的差别非常大。

1.3 什么样的接口适合改成异步流式

不是所有接口都适合异步流式改造。我一般用下面几个条件来判断:

  • 单个请求耗时长,且大部分时间都花在等待外部服务响应上;
  • 业务逻辑允许分段返回,或者可以在执行过程中推送进度;
  • 客户端能力支持长连接或流式接收,比如浏览器 EventSource、HTTP 客户端流式读取;
  • 业务对实时性有明确要求,比如聊天消息、任务进度、AI 生成结果、日志实时采集等。

反过来看,如果接口本身几十毫秒就能返回,改成异步流式反而会引入额外的连接管理成本,属于给自己找麻烦。异步流式解决的是“长时间占用连接但计算压力不一定高”的问题,它并不是所有接口的银弹。

2. 方案一:DeferredResult,聚合查询接口的救星

2.1 DeferredResult 的工作原理

先讲我最常用的一种:DeferredResult。这是 Spring MVC 从 3.2 版本开始提供的异步处理核心类,定位非常精准:接口逻辑本身很重、需要异步执行,但最终还是要给客户端返回一个完整结果,而不是边算边推。比如聚合多个下游数据后返回 JSON 对象,这种场景用 DeferredResult 最合适。

它释放线程的原理其实不复杂。客户端发起请求后,Controller 方法立即返回一个 DeferredResult 实例,Tomcat 连接线程在这个时刻就可以释放回线程池接待新请求了。真正耗时的业务逻辑被提交到独立的业务线程池执行。业务线程算完后,调用 deferredResult.setResult() 方法,Spring 异步上下文会唤回该连接的写回逻辑,把结果返回给客户端。

核心收益在于:大多数情况下,业务线程处理耗时任务时,Tomcat 连接线程完全不用为这个请求干等,服务整体的并发处理能力自然就上去了。

2.2 完整代码与关键细节

@RestController @RequestMapping("/api") public class DeferredResultController { private final ThreadPoolTaskExecutor asyncExecutor; public DeferredResultController(@Qualifier("asyncExecutor") ThreadPoolTaskExecutor asyncExecutor) { this.asyncExecutor = asyncExecutor; } @GetMapping("/order/aggregation") public DeferredResult<Map<String, Object>> getOrderAggregation() { // 设置超时时间为10秒 DeferredResult<Map<String, Object>> deferredResult = new DeferredResult<>(10_000L); asyncExecutor.submit(() -> { try { Map<String, Object> result = new HashMap<>(); result.put("userInfo", remoteUserService.getUserInfo()); result.put("orderList", remoteOrderService.getOrderList()); deferredResult.setResult(result); } catch (Exception e) { deferredResult.setErrorResult(e); } }); deferredResult.onTimeout(() -> { Map<String, Object> timeoutResult = new HashMap<>(); timeoutResult.put("code", 504); timeoutResult.put("message", "backend timeout"); deferredResult.setErrorResult(timeoutResult); }); return deferredResult; } }

代码看起来不长,但有几个细节直接决定线上稳定性:

  • 超时时间必须显式设置。如果不设置超时时间,请求可能一直挂着,直到被网关或前端断开,底层连接得不到及时释放,长时间积累会变成连接泄漏。
  • onTimeout 回调里一定要调用 setErrorResult 或者 setResult。Spring 只有在结果被设置后才知道请求已结束,否则连接依然悬挂。
  • 业务线程池和 Tomcat 线程池必须要隔离。如果业务线程池的核心线程数设置过大,等于把连接线程的压力转移到了业务线程池,照样会产生排队。一般建议异步线程池的核心线程数按机器核数和任务耗时比例来调,4 核机器可以先从 8 到 16 之间起步,压测后慢慢修正。

2.3 使用 DeferredResult 必须注意的坑

真实项目中我踩过一个很隐蔽的坑:DeferredResult 的写回是依赖 Servlet 容器异步上下文的。如果你在过滤器链路里做了某些认证或跨域逻辑,并且没有处理异步分发的场景,请求返回时响应体可能为空。Spring Boot 2.6 之后过滤器链配置语法有调整,多过滤器链注入时容易出现类似问题。排查这种问题,要重点检查过滤器里是否提前调用了 chain.doFilter(),以及 OncePerRequestFilter 是否处理了 ASYNC 分发类型。这类问题不是每次必现,压测时才会冒出来,定位起来比较费时间。

DeferredResult 适合“整体异步但一次性返回”的诉求。如果你想要的不是最终结果,而是真正把数据一段一段推送出去,那就得看下面的方案。

3. 方案二:SseEmitter,实时推送场景的主力

3.1 SseEmitter 与 SSE 协议怎么配合

SseEmitter 是 Spring MVC 对 Server-Sent Events(SSE)协议的封装。SSE 是一种基于 HTTP 的轻量级服务端推送协议,允许服务器在同一个连接上持续向客户端推送文本数据。和 WebSocket 比起来,SSE 最大的优势是它不需要额外建立独立通信协议,直接用 HTTP 就能跑,对防火墙、代理服务器都非常友好。前端用浏览器原生 EventSource 对象就能接收,后端只要把 Content-Type 设为 text/event-stream。

我在实际项目中优先选 SseEmitter,很大原因是它对现有代码结构的侵入性很低。它不需要引入响应式编程模型,可以在传统 Spring MVC Controller 里直接使用,Spring Security、拦截器、参数校验这些原有能力都能继续复用。对于一个已经跑了三四年的老项目来说,这个优势非常明显。

3.2 完整代码与心跳设计

@RestController @RequestMapping("/api") public class SseController { private final ThreadPoolTaskExecutor asyncExecutor; public SseController(@Qualifier("asyncExecutor") ThreadPoolTaskExecutor asyncExecutor) { this.asyncExecutor = asyncExecutor; } @GetMapping(value = "/ai/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chatStream() { // 连接最长存活时间60秒 SseEmitter emitter = new SseEmitter(60_000L); asyncExecutor.submit(() -> { try { for (int i = 0; i < 100; i++) { // 模拟AI生成结果分块返回 emitter.send(SseEmitter.event() .name("message") .data("chunk-" + i)); Thread.sleep(200L); // 如果业务中途出错,要向客户端发送明确错误 if (i == 50) { throw new RuntimeException("simulate error"); } } emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); emitter.onTimeout(() -> emitter.complete()); emitter.onCompletion(() -> log.info("sse connection completed")); return emitter; } }

这段代码里有一些细节值得展开说:

  • 构造参数是连接最大存活时间。超过这个时间没结束,会自动触发 onTimeout 回调。
  • 发送时可以指定事件名称。前端通过 addEventListener("message", handler) 就能按事件类型分类处理,不同业务模块可以共用同一个连接通道。
  • completeWithError 会向客户端发送错误事件后关闭连接,前端可以从 error 回调里捕获异常并提示用户,而不是莫名其妙地断掉。

关于心跳,这里的坑我印象很深。一开始我把超时时间设得很短,结果大模型生成内容稍微慢了一点,前端就断连了。后来改成超时时间按业务预估时长动态设置,同时后端每隔几秒发一条“heartbeat”事件。心跳操作本质上就是一个无实际数据的事件,前端 EventSource 会自动忽略,但连接保持活跃,Nginx 或负载均衡器也不会因为空闲超时把连接掐掉。

3.3 数据格式与断连恢复的细节

SSE 自带格式要求,event、data、id 这些字段各自占一行,字段之间用空行分隔。这里最容易踩的坑是数据里带换行符。如果推送给客户端的内容是普通字符串,里面有 \n 或 \r,EventSource 的解析会变得混乱。我见过有同事直接把一段多行日志串推出去,前端一端收到数据就开始各种错乱,排查了半天才发现是换行符的问题。正确做法是把数据序列化成 JSON 再推,或者对特殊字符做转义。

另外,SSE 是单向通道,服务端只能推,客户端不能通过同一个连接反向发数据。如果业务要求双向实时交互,那还是得考虑 WebSocket。选择方案时一定要把通信模型想清楚。

关于断连恢复,浏览器 EventSource 收到服务器 error 事件后会自动尝试重连,这是内置行为。如果业务上不希望自动重连,需要在返回的 error 事件上明确关闭连接,或者通过 HTTP 状态码来阻断。移动端网络环境对长连接不太友好,SSE 经常会出现断连重连,服务端要做幂等处理,避免同一笔业务被重复执行。

4. 方案三:WebFlux,高并发流式接口的正确姿势

4.1 WebFlux 与 MVC 的选型取舍

WebFlux 是 Spring 家族里的响应式 Web 框架,建立在 Project Reactor 之上,使用 Flux 和 Mono 表达异步数据流。和前两种方案比,它不是一个层面上的东西。DeferredResult 和 SseEmitter 本质上还是在 Spring MVC 框架内部做异步处理,而 WebFlux 从 Web 层开始就是全异步非阻塞模型。

如果你在存量 Spring MVC 项目里做局部优化,不太建议为了一个接口把整个项目切到 WebFlux,改造成本非常大。但如果是新建项目,某个模块明确需要超高并发,WebFlux 就是更好的选择。它的吞吐能力很突出:传统 Servlet 模型是“一个请求占一个线程”,线程数量是硬上限;WebFlux 基于 Netty 或 Servlet 3.1+ 异步 IO,用少量线程就能处理大量并发请求,线程数不需要随连接数增长而线性增长。

4.2 流式接口代码示例

@RestController @RequestMapping("/api") public class FluxController { private static final Logger log = LoggerFactory.getLogger(FluxController.class); @GetMapping(value = "/report/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> reportStream() { return Flux.interval(Duration.ofMillis(500)) .map(index -> ServerSentEvent.builder() .event("report") .data("section-" + index) .build()) .take(20); } }

实际业务里当然不会只是 Flux.interval,而是要接真正的业务链路。我提供一个更贴近生产场景的写法:

@GetMapping(value = "/report/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<ReportChunk>> reportStream() { return fluxService.generateReportChunks() .map(chunk -> ServerSentEvent.builder() .event("report") .data(chunk) .build()) .onErrorResume(e -> { log.error("generate report failed", e); return Flux.just(ServerSentEvent.builder() .event("error") .data(new ReportChunk("error", e.getMessage())) .build()); }); }

这一段代码里有几个关键点:

  • WebFlux 的背压机制。消费者处理慢时,框架会自动控制上游生产速度,不会像传统模型那样无脑消费导致内存爆掉。这一点在高并发场景下非常有用。
  • onErrorResume 处理异常。即使业务链路中途挂了,客户端也能收到明确的错误事件,而不是连接被直接掐断。这里我踩过坑,不写错误兜底时,Flux 内部异常直接导致连接关闭,前端只能看到一个网络错误,排查成本很高。
  • Flux.interval 生成的是无限数据流,必须要配合 take(N) 或者 takeUntil(Duration) 做截断,否则接口永远不会结束。
  • 调试响应式链路建议用 doOnNext 打日志,一段一段观察数据是否流过。单元测试可以用 StepVerifier,对纯 Flux 链路特别好用。

4.3 响应式编程里的阻塞陷阱

WebFlux 里最忌讳的就是在响应式流中调用阻塞 IO。用同步的 JDBC 事务、同步 HTTP 客户端,会让非阻塞模型瞬间失效。之前我在某个模块里用 WebFlux 对接第三方报表服务,本地测试正常,压测时发现吞吐量还不如原来的 MVC 接口。排查了半天,最后发现一个公共工具类内部用了同步 RestTemplate 调用第三方,那个阻塞调用把整个非阻塞链路拖垮了。把 RestTemplate 换成 WebClient 之后立刻恢复正常。

这个教训说明一个事:用 WebFlux 不只是改改 Controller 层,从 HTTP 客户端到数据库访问都要尽量换成非阻塞实现。如果现有项目里有大量同步阻塞代码,WebFlux 的改造成本会成倍上升,这时候使用 SseEmitter 往往更划算。技术选型要落在团队能力和项目现状之上,不要为了技术上的新鲜感去硬切。

5. 三种方案横向对比与选型逻辑

5.1 一张表看懂三种方案差异

对比维度DeferredResultSseEmitterWebFlux
数据返回形态一次性完整返回服务端单向推流,分段返回响应式流式,可分段返回
对现有工程改造量较小,几乎无侵入小,可在 MVC 中直接使用大,需要响应式技术栈
高并发能力依赖业务线程池配置依赖长连接数量,注意连接上限非阻塞模型,吞吐量最高
典型应用场景聚合接口、异步计算实时推送、AI 流式输出、进度通知新项目高并发流式接口
客户端要求无特殊要求EventSource 或 HTTP 流式读取同 SSE,需要流式解析
运维复杂度较低中等,需要关注连接生命周期较高,需要链路治理能力

5.2 不同场景下的选择建议

我的选择逻辑一般是这样的:老项目里某个接口超时,优先考虑 DeferredResult,除非业务确实需要边算边返回;实时推送场景优先考虑 SseEmitter,因为侵入性低,对 HTTP 环境友好;团队已经熟练掌握响应式编程,或者干脆就是新项目,就直接上 WebFlux,把异步流式能力一次做够。

这三种方案并不是彼此孤立的,也可以在同一个项目里混用。比如统计报表接口用 DeferredResult 做聚合查询,聊天窗口用 SseEmitter 推流,订单状态通知用 WebFlux 做高并发推送。架构选型重要的是匹配具体场景,而不是追逐所谓的新技术。

6. 实战中的常见问题与排查技巧

6.1 超时了但连接没释放

这是 DeferredResult 和 SseEmitter 最容易碰到的问题。超时回调里只打了日志,没有调用 complete 或 setResult,连接一直挂到中间层超时才会被清理。排查方法主要有几条:看应用访问日志里的响应时间是不是普遍接近前端超时时间;用 jstack 抓线程快照,看 Tomcat 工作线程是否大量处于 WAITING 状态;再看连接数指标是不是随请求量线性上升。真实环境里遇到这类问题,很多时候不只是回调没触发,还可能是 onTimeout 和 onCompletion 上下文里抛了异常,导致连接没有被正常关闭。

6.2 网关层断连和心跳配置

现在的系统基本都有网关或负载均衡层,比如 Nginx、Kong、API Gateway,这些中间层有默认的连接超时和读超时参数,默认值往往小于业务接口执行时间。我经历过本地跑得好好的 SSE 接口,一上生产环境,推流过程中总是被 Nginx 主动断开,后端日志里看到 connection reset。解决思路有两个方向:调整网关参数,把 proxy_read_timeout 调大,对大流接口关闭缓冲;同时在连接空闲期间发心跳包,保持连接活跃。后者对代码层面的要求更可控,也是我在生产环境里比较推荐的方案。

6.3 异步线程池参数与任务丢失

异步线程池容量是有限度的。当队列满了、线程数也到了最大值,新提交的任务会触发拒绝策略。Spring 默认的 AbortPolicy 会直接抛 TaskRejectedException,如果没有捕获处理,整个请求就失败了,对用户来说表现得和断连差不多。我建议自定义拒绝策略,至少打印告警日志并返回降级结果。任务丢失在异步场景里非常难排查,日志里看不到明显报错,但业务结果就是不出来。提前做好线程池监控,比如队列深度、活跃线程数、拒绝次数,这些指标能帮你尽早发现问题。

6.4 traceId 与日志链路串联

异步流式接口的日志天然是分段的,同一个请求的日志可能从多个线程打印出来,排查问题比传统同步接口麻烦得多。建议在请求入口生成一个 traceId,全程传递给异步执行的上下文,并在所有日志里打上 traceId。这里有个容易忽略的技术点:Java 的 MDC 在跨线程传递时,普通线程池不会自动继承父线程的上下文,所以你得在提交任务时手动把 MDC 的 map 传进去。

public class MdcRunnable implements Runnable { private final Runnable original; private final Map<String, String> contextMap; public MdcRunnable(Runnable original) { this.original = original; this.contextMap = MDC.getCopyOfContextMap(); } @Override public void run() { MDC.setContextMap(contextMap); try { original.run(); } finally { MDC.clear(); } } }

把业务任务包装成 MdcRunnable 再提交,子线程就能拿到父线程的 traceId 了。别看只是一个小包装,在排查线上问题的时候,能帮你省下大量的时间。异步流式接口的稳定性,很大程度上不是靠某个框架的高级特性,而是靠这些细枝末节上的功夫。

还有一点值得说,流式接口里数据顺序可能错乱。SseEmitter 靠线程执行顺序保证推送顺序,但如果中间有多线程并发参与,输出顺序和新一轮数据的产生顺序可能不一致;WebFlux 里用 concatMap 能保证上游元素的顺序传递。涉及强顺序关系的业务,要在设计阶段就把顺序问题考虑进去,等到数据乱序了再排查会非常痛苦。

上面这些内容,是我在 Spring 项目里落地三种异步流式接口的真实经验。回头来看,一开始是为了解决一个具体的接口超时问题才开始接触 DeferredResult,后来接触的场景多了,才意识到“异步”和“流式”其实是两个层面的能力,可以组合使用,也可以独立使用。选型时先想清楚业务到底属于哪一类:是拿结果慢,还是需要边出结果边展示。想清楚了,代码写起来也就顺了。最后再多说一句:任何流式方案都要把兜底逻辑写好,超时要兜底、线程池满要兜底、下游异常更要兜底,这些兜底才是接口稳定的关键。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 4:10:09

Comsol中BIC远场偏振与本征模式偏振态计算:算法选型与实操技巧

写这篇东西其实是这几天帮师弟擦屁股擦出来的经验。他做超表面里的BIC仿真&#xff0c;用Comsol算出来的远场偏振图总是不对&#xff0c;本征模式偏振态也说不清楚&#xff0c;拿着结果追着我问。我以为是他模型建错了&#xff0c;结果一查&#xff0c;问题出在算法选择上——他…

作者头像 李华
网站建设 2026/10/3 4:10:08

Arduino结合RS485与软串口实现多台电机稳定通信的完整指南

做电机控制&#xff0c;最让我头疼的不是算法而是通信。Arduino 主控和电机驱动器之间就隔几十厘米&#xff0c;TTL 串口却能乱码到怀疑人生&#xff1b;后来把 TTL 转 RS485 模块加上去&#xff0c;用软串口做总线转发&#xff0c;问题才彻底解决。下面就把完整接线、通信代码…

作者头像 李华
网站建设 2026/10/3 4:09:18

正点原子开发板lwIP+FreeRTOS移植实战与网络应用开发指南

手里有一块正点原子的探索者或者战舰开发板&#xff0c;又想在板子上把 lwIP 协议栈跑起来&#xff0c;配合 FreeRTOS 做一个真正的网络应用&#xff0c;那这应该是你绕不开的一份功课。正点原子官方例程里其实已经给了不少现成代码&#xff0c;但真正到自己做项目时&#xff0…

作者头像 李华
网站建设 2026/10/3 4:08:55

Vue 1.26实战复盘:从环境搭建到路由、流媒体与数据接入全攻略

看到“Vue 1.26”这个标题&#xff0c;先别误会&#xff0c;这真不是 Vue 官方出了 1.26 版本&#xff0c;官方现在还在 3.x 的路线里往前走。这串编号其实是我手里一个真实业务项目的迭代代号&#xff0c;第1轮开发打到第26个里程碑的时候&#xff0c;刚好把 Vue 相关的核心链…

作者头像 李华
网站建设 2026/10/3 4:08:18

char、String、StringBuilder三者的底层原理与性能实战

在Java里&#xff0c;char、String、StringBuilder这三个名字&#xff0c;几乎天天出现在代码里。但你真把它们拿出来对比着用&#xff0c;会发现藏着不少门道。char是基本类型&#xff0c;String是开发中最常用的引用类型&#xff0c;StringBuilder则是处理字符串拼接的首选工…

作者头像 李华