
1. 为什么接口会超时问题往往不是出在网络先讲一个挺典型的场景你在做B端系统给第三方对接一个数据导出接口对方代码里设置的是30秒超时而你这边有个统计逻辑全量算下来得跑60秒。第一次联调对方直接抛Read timed out你去查监控发现接口实际执行是成功的只是在返回那一瞬间连接已经断了。这种问题我见过太多次它既不是你们服务挂了也不是对方服务有问题纯粹是HTTP协议同步请求的天然限制调用方发一个请求就死死等一个完整响应响应体没出来之前连接就必须一直挂着。一旦接口耗时超过调用方超时时间连接就会被掐断。常见的解决办法无非几种加长调用方超时、改成异步任务轮询状态、或者用MQ把结果推回去。加长超时治标不治本不可能因为一个接口慢就让所有人都去改配置异步任务轮询比较通用但体验很割裂还得建状态表、写回调逻辑复杂度上来了MQ推送更是重方案很多内部接口犯不上。其实还有一个被低估的思路就是Spring里已经支持得很好的异步流式接口。它的核心思想是让请求线程立刻释放把响应体交给另一个异步执行单元去边算边写调用方在响应没完全结束前就能拿到部分数据只要保证数据一直在流动连接就不会被掐断。这个思路可以直接干掉大多数接口超时的烦恼而且不需要改调用方的任何配置。我在生产环境里用三种不同的实现方案处理过这类问题分别覆盖了大结果集导出、实时事件推送、以及高并发流式数据三个方向。这期就把这3种方式完整拆一遍包括核心代码、线程模型、适用场景还有我踩过的几个实实在在的坑。2. 异步流式接口的统一底层逻辑2.1 从Servlet 3.1异步化说起要理解Spring的异步流式接口得先看看Servlet底层的异步支持。在没有异步化之前Servlet线程从请求进入开始到完整响应写回全程都要占着。如果业务逻辑执行30秒这个Tomcat工作线程就空等技术时间30秒——要知道Tomcat默认线程池上限一般是200个高并发下几十个慢接口就能把线程池拖垮后面所有请求全部排队这就是典型的线程饥饿。Servlet 3.1引入了异步处理能力。Spring MVC封装了这套机制可以让Controller直接返回一个异步类型比如Callable、WebAsyncTask、DeferredResult。核心变化是请求线程收到返回的异步对象后立刻结束自己的任务回到线程池继续处理其他请求真正的业务逻辑在另一个线程池里执行执行完成后通过AsyncContext把响应写回客户端。流式接口在此基础上再进一步不仅异步执行而且执行过程中可以分多次向输出流中写数据写完一部分就flush一部分客户端眼睁睁看到数据一段一段流过来。连接断没断取决于两次写入的间隔和客户端/网关的超时配置。2.2 三种方案各自站在哪个位置接下来要讲的三种方式在Spring里处于不同的技术栈层级StreamingResponseBody属于Spring MVC的响应式输出接口走的是Servlet异步化机制用额外线程池做异步写。SseEmitter同样在Spring MVC中但它是基于Server-Sent Events协议的专门用于服务端向客户端推送事件流前端用EventSource就能接。WebFlux的FluxObject这是Spring 5引入的响应式编程栈整个请求处理链路都是非阻塞的从底层换了一套路子不依赖Servlet线程池。有些文章还会把DeferredResult也列入异步方案但它是异步返回单个完整结果没有流式效果不能持续输出所以这次不重点讲。真正的流式口子就这么三个够用了。3. 方案一StreamingResponseBody把大文件/大结果边写边发3.1 最小可用示例StreamingResponseBody的使用很直观Controller返回一个ResponseEntityStreamingResponseBody在这个Body里写一个匿名函数函数体内做业务逻辑并把结果写进输出流。来看最基础的示例RestController RequestMapping(/api/async) public class StreamController { GetMapping(/streaming-body) public ResponseEntityStreamingResponseBody streamingBody() { StreamingResponseBody body outputStream - { // 模拟长时间计算比如统计报表数据 for (int i 1; i 10; i) { Thread.sleep(500); byte[] data (第 i 批数据\n).getBytes(StandardCharsets.UTF_8); outputStream.write(data); outputStream.flush(); } }; return ResponseEntity.ok() .contentType(MediaType.TEXT_PLAIN) .body(body); } }这段代码的逻辑很简单调用方请求进来后立刻拿到了HTTP 200响应但是响应体不是一次性返回而是每500毫秒往里面追加一条数据一共流10次才结束。对于调用方来说它发起一次请求但能看到流式到达的数据块不是干等到底。在实际开发中StreamingResponseBody最常被用在两个场景大数据量的文件导出。比如查询100万行数据并转成CSV/Excel如果全部查完再一次性写出来内存直接爆炸接口也必然超时改成边查边写边flush内存占用稳定接口耗时虽然长但数据一直在流动连接始终活跃。爬虫或采集服务向客户端报告进度。每抓取一批数据就往流里写一条JSON调用方可以实时掌握处理进度。3.2 实战细节与注意事项使用StreamingResponseBody有几个关键点我挨个说下。第一必须理解它不会占用请求线程但它需要一个真正干活的任务线程。Spring MVC底层用WebMvcConfigurer里的TaskExecutor来跑这个流式任务默认是SimpleAsyncTaskExecutor这个默认执行器每次都会新建线程不共享、不池化高并发下线程数会失控。所以生产环境一定要自配一个线程池Configuration public class AsyncStreamConfig { Bean(name streamExecutor) public Executor streamExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setThreadNamePrefix(stream-worker-); executor.setWaitForTasksToCompleteOnShutdown(true); executor.initialize(); return executor; } }至于怎么让Spring MVC用上这个自定义线程池可以简单粗暴地在application.yml里指定spring: task: execution: pool: core-size: 8 max-size: 32 queue-capacity: 200 thread-name-prefix: stream-worker-第二写入的数据要想清楚格式。如果客户端期望一个完整的JSON数组你不能直接一个对象一个对象地瞎写必须手动拼成合法的JSON结构常见格式是这样StreamingResponseBody body out - { ObjectMapper mapper new ObjectMapper(); out.write([.getBytes(StandardCharsets.UTF_8)); for (int i 0; i 100000; i) { MapString, Object item Map.of(id, i, name, user i); byte[] bytes mapper.writeValueAsBytes(item); out.write(bytes); if (i 99999) { out.write(,.getBytes(StandardCharsets.UTF_8)); } out.flush(); } out.write(].getBytes(StandardCharsets.UTF_8)); };客户端那边用支持增量解析的JSON库比如Jackson的JsonParser配合readTree逐段解析或者直接按行读。第三也是我后期遇到最多的坑StreamingResponseBody内部如果执行了很耗时的数据库查询这个查询连接会长时间占着数据库连接池小一点就容易被耗尽。所以流式导出这类场景尽量用分页查询每查一批、写一批、释放一批连接别一次性全查出来。我试过一个报表接口开始时用一条大SQL把所有数据查出来再流式写跑了半小时数据库连接池直接满了改成每500条查一次之后连接占用从峰值48个降到3个。第四写出的过程中如果有异常调用方那边可能已经收到了部分数据因为它拿到的响应是200开头的只能靠业务层自己做标记。比如在流的最后一行写一个特殊符号或者约定好数据量客户端发现数量不对就判定失败否则没办法做到完美的事务回滚——流式本身就是牺牲事务性换取时效性的这个取舍要心里有数。4. 方案二SseEmitter把接口变成一条持续推送的通道4.1 从轮询到服务端推流SseEmitter是Spring MVC里专门做Server-Sent Events用的。它和StreamingResponseBody最大的区别在于后者本质是流式写响应体数据格式自己定义前者则是遵循SSE协议每个数据块都是一个事件有固定的格式而且它是专门为服务端向浏览器/客户端推送设计的。用SSE协议前端不需要引入额外的库浏览器原生支持的EventSource对象就能直接消费。每次事件的数据格式是event: message data: {content:hello,index:1}空行表示一个事件结束。Spring的SseEmitter把这些协议细节都封装好了你只需要调用emitter.send()。4.2 最小可用示例看代码RestController RequestMapping(/api/async) public class SseController { GetMapping(path /sse/chat, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chat() { // 超时时间设成5分钟 SseEmitter emitter new SseEmitter(300_000L); // 业务线程池不能占用请求线程 NamedThreadFactory factory new NamedThreadFactory(sse-push-); ExecutorService executor Executors.newFixedThreadPool(4, factory); executor.execute(() - { try { for (int i 0; i 20; i) { // 模拟AI大模型对话结果逐字返回 emitter.send(SseEmitter.event() .name(message) .data(这是第 i 段响应文本)); Thread.sleep(200); } emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); // 连接断开时的清理动作防止线程池泄漏 emitter.onCompletion(() - executor.shutdown()); emitter.onTimeout(() - executor.shutdown()); return emitter; } }调用方如果是浏览器一行代码就能接const source new EventSource(/api/async/sse/chat); source.addEventListener(message, event { console.log(event.data); });SseEmitter现在的应用场景特别多我重点提几个AI对话流式输出、消息通知中心、实时监控面板、数据同步进度展示。这两年做AI应用的团队尤其喜欢用它——前端等待大模型生成的回复交互上就是要一个字一个字蹦出来才有效果用轮询完全做不出这种体验。4.3 实战细节与注意事项使用SseEmitter最容易出问题的就是超时和心跳。先讲超时。SseEmitter构造函数的第一个参数是超时毫秒数如果超过这个时间没有向客户端发送任何事件Spring会自动关闭连接。大模型对话场景一个回复可能要几分钟如果你只设了30秒那用户话还没问完连接就断了。一般建议设成0表示不超时但生产环境的网关和负载均衡器往往有更狠的阈值Nginx默认proxy_read_timeout是60秒这意味着就算你服务端一直发数据Nginx那边超过60秒没从上游读到数据也会断心跳就是用来解决这个的。心跳的意思很简单数据没得发但也不能让连接闲着每隔20~30秒发一个注释事件过去。注释在SSE协议里是:开头的行Spring发送它也很容易emitter.send(SseEmitter.event().comment(heartbeat));我见过有同事用emitter.send({})发一个空JSON当心跳这样前端会收到大量无效事件还得自己过滤不如发comment事件干净EventSource默认会忽略注释事件又不触发onmessage回调。再讲线程。SseEmitter本身没有自动维护业务执行的能力业务逻辑要跑在线程池里所以创建SseEmitter后要尽快把任务丢给线程池不能让请求线程自己阻塞着模拟业务。线程池大小也要掂量好毕竟一个连接就会占一个线程。一般推送场景并发不会特别高但AI对话的活跃连接可能有上千个用newFixedThreadPool(4)显然扛不住。我现在的做法是根据场景分开处理AI对话这种IO密集型的用一个弹性线程池核心线程数取CPU核数的两倍简单的通知推送直接用虚拟线程或者MQ异步消费。最后有一个细节容易被忽略SseEmitter在连接断开时不会主动通知你只有在下一次试图发送事件时才抛异常。如果你没有注册onCompletion回调线程池里的任务可能会一直傻乎乎往下执行白白浪费资源。所以要养成习惯emitter.onCompletion()和emitter.onTimeout()里一定要做线程回收处理。emitter.onCompletion(() - { // 清理当前连接相关的资源 log.info(SSE连接已关闭); });5. 方案三WebFlux Flux换一套非阻塞模型5.1 响应式编程不是噱头前两种方式不管是StreamingResponseBody还是SseEmitter本质上都还是在Servlet栈上做文章请求线程释放了但真正的业务逻辑还要靠额外线程去跑本质是异步线程池流式写入。而Spring WebFlux是自底向上的另一套模型从接收到返回整个链路都是基于Reactor的非阻塞事件驱动线程不会被一段等待阻塞住。开始之前先提醒一下WebFlux和Spring MVC不能直接混用。Spring Boot里如果同时引入了spring-boot-starter-web和spring-boot-starter-webflux默认优先使用MVC要让WebFlux生效得去掉MVC依赖或者通过配置强制指定。如果你现有的业务系统是MVC架构不建议为了一个流式接口就整体切换到WebFlux迁造成本很高。WebFlux更适合新项目、网关层、或者对高并发流式数据有硬需求的服务。5.2 最小可用示例用WebFlux写流式接口代码比Servlet栈抽象一个层级但表达力更强RestController RequestMapping(/api/async) public class FluxController { GetMapping(path /flux/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString stream() { // interval每秒发射一次take(10)表示最多取10个 return Flux.interval(Duration.ofSeconds(1)) .map(i - 实时数据- i) .take(10); } }客户端发起请求后每秒钟收到一条数据连收10条连接才结束。看起来和SSE效果差不多但内部机制完全不一样这个接口不需要你自己创建线程池不需要手动调用flush不需要管超时和心跳Reactor框架本身就带背压机制——如果客户端消费速度跟不上生产速度上游会被迫放慢发送节奏而不是一股脑把数据全塞给客户端这在StreamingResponseBody和SseEmitter里是做不到的。再举个例子返回一组自定义对象WebFlux会自动帮你序列化成JSON流GetMapping(path /flux/users, produces MediaType.APPLICATION_NDJSON_VALUE) public FluxUser userStream() { return Flux.range(1, 1000) .map(i - new User(i, user- i)) .subscribeOn(Schedulers.boundedElastic()); }APPLICATION_NDJSON_VALUE表示服务端返回的是换行分隔的JSON流也是流式接口常用的媒体类型。5.3 背压到底是什么很多人一听背压觉得玄乎我用个生活化类比解释你有一个水管向水桶里放水传统的同步模型不管水桶满没满水一直哗哗往里流满了就溢出来WebFlux的背压相当于在水桶和阀门之间连了一根感应线水桶快满了阀门自动关小一点等水被用掉一部分再开大。在流式接口场景上游生成100万条数据客户端一秒只处理100条背压机制会让上游每秒最多推100条避免客户端被冲垮。WebFlux不要求你懂背压才能用但理解背压能帮你更好地设计数据流边界。如果数据源是数据库通常用Flux.generate配合Schedulers.boundedElastic做分页查询如果是消息队列直接从Flux里订阅消费即可。真正的难点在于SQL查询本身是阻塞的不能让WebFlux的主事件循环去等数据库所以一定要用subscribeOn切到对应的调度器上。5.4 一个容易被忽略的坑用WebFlux写流式接口时很多人会忘了produce的类型。如果不指定TEXT_EVENT_STREAM_VALUESpring会按照普通JSON去序列化整个Flux客户端拿到的可能是一个完整JSON数组而不是流式的。这就失去了流式的意义。设置成text/event-stream后每个元素都被包装成SSE事件格式推出去。这点和SseEmitter客户端写法是兼容的EventSource依然能直接用。另外WebFlux服务端默认的响应缓冲区大小有限如果单个事件的数据量特别大可能触发DataBufferLimitException。可以在配置里调大限制spring: codec: max-in-memory-size: 10MB6. 三种方案怎么选一张表给你说清楚网上聊这种话题最后都会让你自己掂量我直接给一个可以照着抄的选型表。维度StreamingResponseBodySseEmitterWebFlux Flux技术栈依赖Spring MVCSpring MVCSpring WebFlux核心机制Servlet异步化流式写响应体SSE协议封装响应式非阻塞流线程模型需要自配线程池执行业务需要自配线程池执行业务无需额外线程池背压控制数据格式完全自定义SSE事件格式SSE / NDJSON / 纯流是否支持断线重连不支持断了就断了支持EventSource自动重连取决于客户端实现典型场景大文件导出、报表下载AI对话、消息推送、监控面板高并发数据管道、微服务间流式通信上手成本低低高需要理解响应式编程服务端资源占用每个连接一个线程每个连接一个线程线程数固定事件驱动事务性弱无法保证完整回滚弱弱个人经验供参考如果你的项目已经用Spring MVC且场景是大结果集下载、文件导出优先选StreamingResponseBody改动最小调用方拿到的是普通HTTP响应不需要特殊协议知识。如果场景是AI对话、消息通知这类服务端主动推送且前端是浏览器优先选SseEmitter。因为EventSource天然支持断线重连省掉一大堆前端重连逻辑。相比WebSocket也不用考虑握手复杂度和消息格式SSE足够轻量。如果是一个新起的服务且对QPS有比较高的追求数据流是生产-消费模型那直接用WebFlux。它的非阻塞模型在面对大量长连接时资源消耗远低于前面两种每连接一线程的模式。微服务之间互相调流式接口也建议用WebFlux因为可以配合WebClient做端到端的响应式流式调用上游下游都不用额外开线程池。7. 线上常用的排查路径与避坑指南这一节我把自己在生产环境踩过的、帮别人排查过的典型问题整理成本按出现频率排了序。坑一明明数据一直在推客户端还是超时这个最常见。服务端每200毫秒发一次数据但Nginx或网关层的proxy_read_timeout设的是60秒当两次事件间隔超过这个值连接就会在中间层被断开。现象是客户端在长时间无事件后突然报错。解决办法是发心跳或者调大中间件超时时间。注意不是调自己服务端的超时是中间层。坑二SseEmitter连接泄漏导致线程暴涨没注册onCompletion和onTimeout回调或者回调里只写了日志没做资源清理。客户端断开后线程池里的任务还在继续跑每断一个连接就多执行一个僵尸任务日积月累线程池满。我排查过一个AI对话服务线程数从32涨到2000多就是这个问题。解决办法没别的所有SseEmitter都要注册清理回调任务里也要检查emitter是否已关闭比如捕获IOException后主动退出。坑三流式输出和事务同时用引发的数据错乱有人在流式导出接口上开了事务查一批写一批。这个做法本身没问题但如果你在事务里又去更新数据库事务隔离级别配置不当会让客户端看到不一致的数据。我建议流式接口优先用只读事务或者干脆不开事务分页读、分页写。实在要保证一致性就得提前算好数据的快照版本。坑四WebFlux接口被下游误当普通JSON解析下游用RestTemplate调你的WebFlux流式接口你会发现它拿到的要么是完整的Flux序列化结果要么直接报解析错误。解决办法是下游也换成支持流式的请求方式比如Spring的WebClient设置retrieve().bodyToFlux()或者原生的Java 11HttpClient配合BodySubscribers处理流。协议是对齐的但如果两边编程模型不一样会非常痛苦。坑五自定义媒体类型和内容协商冲突StreamingResponseBody返回时如果不显式设置Content-TypeSpring有时会根据请求头的Accept做内容协商可能导致返回的不是预期格式。我建议所有流式接口都显式指定produces或ResponseEntity里的contentType不要依赖默认值。SSE一定用text/event-streamNDJSON一定用application/x-ndjson纯文本用text/plain这样客户端解析才有保障。坑六把SseEmitter和StreamingResponseBody在同一个接口里混着用有人想既推事件又没有固定事件格式在SseEmitter里发普通字符串或者反过来。其实两者解决的问题边界不同不要混合。SseEmitter内部就是SSE协议你能发的数据必须是协议允许的格式StreamingResponseBody则完全自由但也没有断线重连。选定了就按对应协议的规则来混用只会让客户端难处理。坑七WebFlux的错误处理没有自定义用Flux的时候业务逻辑抛了异常默认情况下连接会被直接关闭客户端拿不到任何错误信息。正确的做法是在响应流里加上onErrorResume把错误转换成一条业务消息推给客户端。比如Flux.interval(Duration.ofSeconds(1)) .map(i - { if (i 3) { throw new RuntimeException(模拟异常); } return data- i; }) .onErrorResume(e - Flux.just(error: e.getMessage())) .take(10);这样客户端至少能看到一个明确结束标记不至于一脸懵。8. 关于异步流式接口我最后的实操心得做了这么多年接口我越来越觉得超时问题与其死磕调用方配置不如从自己这边把响应模型设计好。异步流式接口本质上是在完整性和时效性之间做取舍你不能既要流式边推边用还要求接口返回的结果是原子性的、出错能全部回滚这不可能。但只要场景符合流式特征比如大文件导出、对话生成、实时数据推送这三种方式就是最趁手的工具。我的倾向是已有MVC项目用前两种新项目直接上WebFlux。StreamingResponseBody负责大块数据搬运SseEmitter负责实时交互推送WebFlux负责高并发下的流式吞吐。先把这三个方案里的一个吃透再往另两个迁移就不会觉得异步流式有多玄。写的时候多想想连接生命周期、线程归属、中间层超时这三个点坑基本都能避开。