☰
接口超时怎么办?Spring Boot三种异步流式方案实战解析
2026/10/5 3:10:48 网站建设 项目流程

做后端这几年,我印象里最难看的一类工单就是接口超时。不是偶发慢,而是业务本身要跑很久:报表导出、批量任务、AI 对话、大文件下载,一算就是好几秒甚至几十秒。同步接口把请求生命周期焊死在一次 HTTP 往返里,客户端等不住断开,网关先掐住连接,服务端还在埋头算——谁都没做错,就是模型不对。Spring 解决接口超时最有效的一招,是把同步阻塞改成异步流式接口,让响应先活着,数据再一点一点往外送。今天我把三个方案逐一展开——SseEmitter、StreamingResponseBody、WebFlux 的 Flux,直接给可抄的代码和我在真实项目里踩过的坑,适合正在被慢接口折磨的 Spring Boot 开发者。

1. 超时问题的根源:同步阻塞链路下的连锁反应

1.1 一个典型的超时事故现场

之前在电商售后系统里,运营后台有个"导出全量售后工单"的需求。工单表一千多万行,按店铺+状态拉出来之后还要做 Excel 分 sheet 聚合,POI 写文件动辄七八秒。前端 Axios 默认 timeout 设了 10 秒,压测环境倒还好,一到线上数据量大一点的店铺,整个请求就要 12 秒往上,于是工单一条接一条涌进来:"导出接口超时""Excel 下载失败"。

排查之后会发现,后端代码并没有报错,日志里 SQL 执行很正常,最后的聚合循环也没问题,纯粹就是慢。当慢到超过客户端等待上限,客户端主动断开,连接释放,前端拿着一个半截响应不知所措。这类问题最坑的地方在于:它不是 bug,而是同步模型的天花板。你再怎么优化 SQL、加索引、改写法,总有一个业务场景会超过你的超时预算。

1.2 超时到底卡在哪:客户端、网关、容器三层

接口超时从来不是单点问题,一条完整链路至少有三层在同时计时。第一层是客户端超时,浏览器、App、乃至服务间调用的 HTTP 客户端都有自己的 readTimeout;第二层是网关超时,Nginx 默认的 proxy_read_timeout 是 60 秒,但很多团队会手动调成 10 秒或 5 秒来"防止慢接口拖垮网关";第三层是服务端容器超时,Tomcat 的连接超时、Spring MVC 异步请求超时,都各自有一笔账。

三层里只要有一层先扛不住,客户端拿到的就是超时。更微妙的是,即便你只把网关超时调大、客户端也愿意等,服务端线程池也会出问题。Tomcat 默认 200 个线程,如果 50 个请求都是需要 10 秒才返回的慢接口,线程池就被占满了,其他只需要 50 毫秒的快接口全部排队,系统整体瘫痪。同步接口慢,伤害的不仅是自己,是整个应用的吞吐。

1.3 异步流式为什么能破局

想通这一点之后,思路就清晰了:不要把一个长任务和一次 HTTP 请求绑在一起。异步流式接口的核心理念,是把"请求-响应"从一次性的封闭交易,变成一条可持续写入的通道。服务端收到请求后立刻把响应的"头部"交出去,让连接进入已连接状态,然后一边计算一边往通道里写数据,写完再结束。

这样做的好处有两个。第一,客户端在连接建立的那一刻就拿到了响应开始,超时倒计时不再和业务耗时强相关;第二,容器线程可以立刻返回线程池继续服务其他请求,真正干活的是另外的执行器或响应式调度器。前者解决了"等不到结果",后者解决了"线程被占死"。这两个问题恰好就是接口超时工单里最核心的两类根因。

2. 方案一:SseEmitter 做服务端推送,AI 对话和通知推送的首选

2.1 SseEmitter 的工作原理:长连接 + 事件帧

SseEmitter 是 Spring MVC 从 4.2 开始提供的服务端推送组件,底层基于 Servlet 3.1 的异步处理,跑的是 SSE(Server-Sent Events,服务端发送事件)协议。SSE 是一种轻量级的 HTTP 长连接方案,客户端发起一个普通 GET 请求,服务端不关闭连接,而是以text/event-stream的格式持续往响应里写事件帧。每个事件帧由若干行文本组成,常见的行有id:、event:、data:,事件之间用一个空行隔开。

它和 WebSocket 最大的区别是单向的:只能服务端往客户端推,客户端想说话得另发一个请求。但正因为单向,它省掉了 WebSocket 那一套握手升级、心跳保活、双向消息处理的复杂度。如果你要做的场景就是"服务端有数据就往外吐",SSE 是成本最低的实施方案,不需要引入额外的协议和前端框架支持,浏览器原生 EventSource 就能消费。

2.2 最小可运行实现

直接上代码。下面是一个典型的 SseEmitter 接口,功能是模拟服务端每 500 毫秒推送一条工单状态消息,总计 30 条后结束:

@RestController @RequestMapping("/api/stream") public class SseStreamController { private final ExecutorService pushExecutor = Executors.newFixedThreadPool(16); @GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter pushWorkOrderStatus() { SseEmitter emitter = new SseEmitter(60_000L); pushExecutor.execute(() -> { try { for (int i = 1; i <= 30; i++) { Map<String, Object> eventData = new HashMap<>(); eventData.put("index", i); eventData.put("status", "处理中"); eventData.put("message", "第 " + i + " 条状态推送"); emitter.send(SseEmitter.event() .id(String.valueOf(i)) .name("order-status") .data(eventData, MediaType.APPLICATION_JSON)); Thread.sleep(500); } emitter.complete(); } catch (Exception ex) { emitter.completeWithError(ex); } }); return emitter; } }

核心就三步。第一步,新建 SseEmitter,构造参数是超时毫秒数;第二步,用一个独立线程池执行耗时逻辑,每次产出数据就调用emitter.send();第三步,发完调用emitter.complete()正常结束,中途异常就completeWithError。这里要特别提醒:千万别把耗时逻辑直接写在接口方法里同步执行,SseEmitter 的意义就是让 Controller 方法立刻返回,把长任务扔给别的线程。

2.3 必配参数与客户端断连处理

SseEmitter 的默认超时行为和 Spring MVC 异步请求的超时配置相关。在 Spring Boot 里可以直接配一项全局的参数:

spring.mvc.async.request-timeout=60000

这表示异步请求最长 60 秒。如果你在构造器里传了超时时间,以构造器为准;没传就用全局配置。实际项目里,我习惯两个都设:全局给一个兜底值,具体接口按业务预估再在构造器单独覆盖。

客户端断连是个容易被忽视的问题。用户中途关闭了页面或切走,此时连接已经断了,但你的推送线程不知道,还在继续send(),就会抛 IOException。处理方式是在创建 emitter 之后立刻注册三个回调:

emitter.onCompletion(() -> log.info("连接正常结束: {}", Thread.currentThread().getName())); emitter.onTimeout(() -> log.warn("连接超时,释放资源")); emitter.onError(throwable -> log.error("连接异常: {}", throwable.getMessage()));

onTimeout和onError回调里要做的第一件事是清理线程资源。我的习惯是在回调里用一个 AtomicBoolean 标记连接已失效,推送线程每次发送前检查标记,避免无意义的循环空转。另外,SseEmitter 没有内置心跳机制,如果推送间隔超过 15 秒,中间最好定时发一个注释帧保持连接,否则 Nginx 这类中间层可能因为空闲时间过长把连接掐掉。

2.4 实测效果:curl 直连观察流式效果

写完之后,本地直接起服务,用 curl 的-N参数关闭缓冲区直连看效果:

curl -N http://localhost:8080/api/stream/sse

输出长这样:

id: 1 event: order-status data: {"index":1,"status":"处理中","message":"第 1 条状态推送"} id: 2 event: order-status data: {"index":2,"status":"处理中","message":"第 2 条状态推送"}

每两行事件之间有个空行,这就是 SSE 协议的事件分隔符。最近两年 AI 大模型接口火起来之后,很多对话式应用的前端就是用 EventSource 消费这种格式的 token 流,Spring AI 的流式输出底层也大量依赖这类推送模型。它的落地价值是实打实的:接口从"等待全部结果"变成"边出边看",体验完全不同。

3. 方案二:StreamingResponseBody 把大导出变成水管,边生成边下载

3.1 适用场景:报表导出、文件下载、数据搬移

如果说 SseEmitter 解决的是"事件型"的推送,StreamingResponseBody 解决的就是"字节型"的流式输出。最典型的场景是大文件下载:几 GB 的压缩包、上百万行的 CSV、超大 Excel 报表。这类请求在同步模型下有两个痛点,一是内存被整个文件撑爆,二是客户端要等全部数据写完才开始下载。

StreamingResponseBody 的思路是:Controller 方法不再返回一个完整的 byte[] 或 File 对象,而是返回一个回调,Spring MVC 会把响应输出流交给你,你拿到 OutputStream 之后想怎么写就怎么写。响应头先返回到客户端,浏览器立刻进入下载状态,后续内容边生成边写。

3.2 核心实现:返回 Lambda 形式的流式写入

代码非常简洁,直接返回一个 StreamingResponseBody 的 Lambda:

@GetMapping("/export/gigantic-csv") public ResponseEntity<StreamingResponseBody> exportCsv() { StreamingResponseBody body = outputStream -> { try (BufferedWriter writer = new BufferedWriter( new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) { writer.write("工单号,店铺,状态,创建时间\n"); workOrderService.queryLargeDataAndConsume(row -> { try { String line = String.join(",", row.getId(), row.getShopName(), row.getStatus(), row.getCreateTime().toString()); writer.write(line); writer.write("\n"); if (writer.toString().length() > 0) { // 按行写出,及时交给操作系统发送 } } catch (IOException e) { throw new RuntimeException(e); } }); } }; return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"work-orders.csv\"") .contentType(MediaType.TEXT_PLAIN) .body(body); }

这个写法把整个导出动作从 Controller 线程剥离了。Spring MVC 收到返回值之后,会设置好响应头、进入异步模式,然后把 Lambda 交给异步任务执行器运行。你在 Lambda 里做数据库分页游标查询也好、调用远程服务也好、逐行写 CSV 也好,都跟 Tomcat 的请求线程没有关系。

3.3 线程池配置:别让 SimpleAsyncTaskExecutor 拖垮服务

StreamingResponseBody 执行用的线程池,默认是 SimpleAsyncTaskExecutor。注意,这个类名里的"Simple"不是憨厚的意思,它是真的就是个每次 new 线程的简单实现,并发一大就会创建出大量无界线程,直接把内存堆满。生产环境必须换成有界线程池,方法是在 WebMvcConfigurer 里配 AsyncSupportConfigurer:

@Configuration public class AsyncStreamConfig implements WebMvcConfigurer { @Override public void configureAsyncSupport(AsyncSupportConfigurer configurer) { configurer.setDefaultTimeout(120_000); configurer.setTaskExecutor(asyncTaskExecutor()); } @Bean(name = "asyncStreamExecutor") public ThreadPoolTaskExecutor asyncTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(32); executor.setQueueCapacity(100); executor.setThreadNamePrefix("stream-export-"); executor.setWaitForTasksToCompleteOnShutdown(true); executor.initialize(); return executor; } }

线程数怎么定?核心线程数参考同时在线导出的人数,一般 8 到 16 足够;队列容量要小于网关超时内能完成的任务数,避免任务堆积导致客户端等到超时。这里有个很容易踩的配置冲突:如果你同时配有spring.mvc.async.request-timeout,它会覆盖这里 setDefaultTimeout 的默认值,所以统一用一处配置就好。

3.4 容易踩的坑:flush、Content-Length 与中途异常

第一坑:忘调 flush。数据写到 OutputStream 之后,不代表立刻发送到客户端。TCP 层有自己的缓冲,Servlet 容器也有缓冲,如果你写一小块不 flush,前端可能直到最后才一次性收到全部数据,流式效果直接变成假的。写日志、写 CSV、下载文件,写一批就 flush 一次,这是保体验的最低要求。

第二坑:Content-Length 与 chunked。StreamingResponseBody 场景下你没法提前知道总字节数,所以响应会走 chunked 传输编码。本身的下载不受影响,但如果你的网关或者服务端配置要求所有响应都要有 Content-Length,就得注意代理层的兼容。我在生产里就见过 Nginx 配置了proxy_buffering on,把流式响应又攒了一遍,下载进度条半天不动。

第三坑:中途异常没人知道。同步接口里异常可以直接抛给容器,但流式场景下响应可能已经写了一半,此时你再抛异常也只能终止输出流,HTTP 状态码已经回不去了。稳妥做法是在 Lambda 里自己 catch,记录日志,并把部分完成的数据尽量补齐再结束,前端侧通过文件大小或尾部标记来判断数据是否完整。

4. 方案三:WebFlux 的 Flux 流式接口,响应式思维从源头解决超时

4.1 WebFlux 不是"更快的 MVC",而是另一套模型

前两个方案都是基于 Servlet 容器,属于"异步地执行同步代码"。WebFlux 则是另一条路线:它基于 Reactor 的响应式流,所有操作都是非阻塞的,底层用 Netty 或 Servlet 3.1 结合少量事件循环线程支撑大量并发连接。你要理解的是,它不是 Spring MVC 的升级版,而是"换了一套编程模型"。

WebFlux 里返回 Flux 的接口天然支持流式。Flux 代表一个可能包含 0 到 N 个元素的异步序列,接口方法声明成返回 Flux 并指定produces为流式媒体类型,Spring 就会把元素逐个序列化后写回响应。高并发下,事件循环线程可以同时管理成千上万个连接,没有传统线程池打满的烦恼。

4.2 Flux 流式接口的两种输出格式

最常见的流式输出格式还是 SSE。实现代码如下:

@RestController @RequestMapping("/api/reactive") public class ReactiveStreamController { @GetMapping(value = "/order-status", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> streamOrderStatus() { return Flux.interval(Duration.ofMillis(500)) .take(30) .map(i -> ServerSentEvent.<String>builder() .id(String.valueOf(i)) .event("order-status") .data("第 " + i + " 条响应式消息") .build()); } }

Flux.interval 是响应式世界里最常用的定时数据源,每 500 毫秒产生一个递增数字,take(30) 限制只取前 30 个,最终 Flux 结束,响应自然关闭。整个过程没有任何一个线程在 sleep,事件循环就是靠注册的定时回调把数据推出来。

如果不想用 SSE,还有另一种格式application/x-ndjson,每行一个 JSON 对象。它特别适合大批量数据导出场景,前端用readline逐行解析,比 SSE 更接近"数据流"的直觉:

@GetMapping(value = "/order-list", produces = "application/x-ndjson") public Flux<WorkOrderDTO> streamOrderList() { return workOrderReactiveService.streamAll(); }

4.3 背压机制:消费多快,生产多快

响应式流里有一个同步模型没有的概念叫背压,通俗讲就是"你写慢点,我跟不上了"。Flux 生产数据的速度可以非常快,比如每秒产生一万个事件,但下游消费速度可能只有每秒一百个。背压机制会把需求信号从下游传到上游,让上游按需生产,而不是无限堆在内存里爆掉。

对比一下就能看出差距:SseEmitter 模式下,推送线程从数据库取到一条数据就往外写,如果客户端连接断了或者网络慢了,线程只能傻等;Flux 模式下,发布者会根据订阅者的请求量来调度数据生产,限流是协议自带的能力。单机几百上千个并发流式连接,响应式模型的稳定性明显更好。当然代价是,所有下游操作都得是响应式 API,如果你准备调用的服务还是同步阻塞的,背压优势会被那些阻塞调用吃掉大半。

4.4 老项目渐进接入的实操建议

最容易被坑的一点:Spring Boot 项目里同时引入 spring-boot-starter-web 和 spring-boot-starter-webflux,默认启用的是 MVC,WebFlux 的 @RestController 不会生效。两个 starter 共用同一个自动配置空间,谁先谁后、谁主谁次很容易把团队绕晕。我在实战里试过几次,老实说,把 WebFlux 硬塞进现有 MVC 项目是得不偿失的。

建议的做法是:新起的流式接口模块独立建一个服务,内部用 spring-boot-starter-webflux,对外暴露流式接口,走单独的网关路由;老的 MVC 应用不动。这样既能吃到响应式流的红利,又不会把现有 Controller 的语义搞乱。如果你想在同一个代码库里渐进尝试,也可以只引入 WebFlux 依赖、把需要流式处理的接口集中在少数类里,但要做好排查半天请求 404 的心理准备。

5. 三种方案怎么选:对比、边界与组合使用

5.1 一张表看清三个方案的分工

用一张表直接摊开对比,选型的时候照着看就行:

维度SseEmitterStreamingResponseBodyWebFlux Flux
底层协议SSE(text/event-stream)原始字节流(chunked)SSE / NDJSON / 任意流式媒体类型
所属模型Spring MVC 异步Spring MVC 异步Spring WebFlux 响应式
单向/双向单向(服务端推)单向(服务端写)单向流(WebSocket 才双向)
数据消费方式前端 EventSource下载流 / 逐行读取响应式订阅,天然背压
接入门槛低,MVC 里即插即用低,MVC 里即插即用高,需要了解 Reactor 模型
并发模型独立线程池发送异步执行器写流事件循环 + 少量线程
典型场景AI 对话、通知推送、实时状态大文件下载、CSV 导出高并发数据流、事件驱动

一句话总结:SseEmitter 适合"有事件要广播",StreamingResponseBody 适合"有大块字节要塞",WebFlux 适合"高并发下还要保持优雅"。

5.2 按业务场景选型的决策路径

我在新接一个流式需求时,会按这样一条决策路径走:先问"数据中间过程的消费方式是什么"。如果是前端页面上的实时状态、AI token、告警推送,选 SseEmitter,因为它和浏览器 EventSource 的配合最好,协议格式就是为事件设计的。如果是要落盘、要下载、要导出的字节流,选 StreamingResponseBody,因为前端不需要解析协议,只需要接一个二进制流写文件。

再问"并发规模到了什么级别"。如果单个接口的 QPS 预计超过几百,且后端数据链路本身可以做成响应式,就考虑 WebFlux;如果只是内部运营系统、几十个人用,SseEmitter 足够。最后问"研发团队对响应式模型的掌控力"。WebFlux 一旦出问题,排查链路和思维方式和 MVC 完全不同,团队没有把握的话,宁可先用前两个方案稳住业务。

5.3 组合拳:异步流式之外的超时治理手段

流式接口能解决"长耗时任务"的超时,但它不是银弹。如果接口慢是因为一条 SQL 要跑 5 秒,流式只能让输出不卡死,成本还是摆在那里。我的超时治理清单里,异步流式只是其中一家,其他几个配合着用效果更好。

数据库层面,分页或游标读取避免一次加载全量数据,给热点查询加二级缓存;服务调用层面,给第三方依赖配好连接池和 readTimeout,必要时做熔断,别让慢依赖把自己的线程池拖垮;网关层面,为流式接口单独配 proxy_read_timeout,不要和普通接口共用一套宽松配置;监控层面,给异步接口加上活跃连接数、推送速率、平均完成时长的指标,超时问题从"靠用户报"变成"靠指标看"。

这几个手段加在一起,接口超时的工单会明显少很多。

最后再分享一个我自己的习惯:每次上线流式接口,我一定用 curl -N 人工确认一次首包时间,从终端看到第一批数据能在一两秒内出来,心里才踏实。接口从"硬等结果"变成"边跑边聊",这套改造的收益,比单纯调大 timeout 踏实太多了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询