Spring Boot SSE实时推送实战:原理、选型与避坑指南
发布时间:2026/9/28 5:45:06来源:尧图网络
1. 实时数据推送的技术选型为什么这次我押注SSE先说结论在Spring Boot项目里做实时数据推送很多人第一时间会想到WebSocket但真正落到业务里SSEServer-Sent Events服务端发送事件经常是更省心、更务实的那一个选项。前阵子我需要在一个企业级后台里做实时告警推送同时还有一个面向C端用户的AI问答功能要求在服务端生成回答时边生成边往浏览器推流参考了团队之前的技术栈封装经验最后统一用了SSE。这个决定不只是图省事无聊时我专门对比过所有方案结论比较清晰SSE在HTTP协议上实现了单向的实时推送场景覆盖告警通知、进度条、大模型流式输出开发成本和运维难度都要低一截。选型对比不要拍脑袋要看底层机制。传统的前端轮询是定时发HTTP请求这个方案最简单但我们都知道它浪费资源大量请求在没有数据变化时白白占着带宽和线程。长轮询Long Polling是轮询的改良版服务端挂起请求直到有新数据再返回消息及时性好了一些但每次请求都要重新建立HTTP连接频繁重连反而在弱网环境下更不稳定。WebSocket是真正的全双工双向通信实时性最强、消息可以双向发送但它需要单独的协议握手、专门的服务器配置、连接保持和心跳逻辑都要自己处理而且“双向”在很多场景下其实是过剩能力。这里就能看出SSE的特殊地位了连接是普通的HTTP请求协议是原生支持的text/event-stream服务端往客户端单向推送消息客户端用一句new EventSource(url)就能接住。对它就是专为“服务端往浏览器发消息”这个需求设计的。日常做后台管理系统、数据大屏、AI流式输出几乎不需要客户端往服务端持续发消息所以WebSocket那种重型双向通道反而是浪费。我的适配经验是这样的后台告警推送、任务进度通知、服务状态推送这类“服务端主动”场景直接上SSE不需要额外依赖不需要单独协议层。需要聊天、多人协作编辑这类“客户端也要高频发消息”的场景才需要WebSocket。AI大模型流式输出本质上就是服务端把token逐段推给前端SSE天然匹配这个过程配合abort控制请求生命周期体验很顺。说得直白一点SSE就是“服务端单向推送”这个细分需求的最优解它不试图覆盖所有实时场景但把“推送”这件事做到了极致。如果你要做的功能恰好是后端生成数据、前端负责展示SSE会是性价比最高的选择。我后面在Spring Boot里实现SSE时踩到过不少坑下面从原理到代码一步步给你拆开讲。2. SSE协议细节与Spring Boot落地方案2.1 先弄清EventSource和协议响应头SSE在浏览器端的API只有一个核心对象EventSource。它负责发起连接、监听事件、自动重连不需要像WebSocket那样管理连接状态和心跳。使用方式很简洁几行代码就能接住一个流const source new EventSource(/api/sse/alert); source.onmessage function (event) { console.log(收到推送, event.data); };服务端只要在HTTP响应里设置好关键响应头浏览器就会把这个连接当作事件流处理。响应头是SSE的基石一个典型的响应会是这样的HTTP/1.1 200 OK Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive X-Accel-Buffering: no这里有几个细节要特别注意Content-Type必须是text/event-stream浏览器靠它识别响应类型识别不了就当普通下载处理这是最常见的“明明接口通了但前端收不到消息”的根因之一。Cache-Control: no-cache保证数据实时性避免浏览器或中间代理缓存推送内容。X-Accel-Buffering: no是给Nginx用的。Nginx默认会缓冲响应不关闭这个缓冲SSE数据会积压在Nginx层前端收到的是“攒了很久的一条大消息”实时性被缓冲机制破坏。部署在Nginx后面的同学这个头一定记得加。Connection: keep-alive保证长连接不被提前断开。消息格式也有约定一组推送内容以两个换行符\n\n结尾每条消息可以包含data、event、id、retry这些字段。最基础的消息长这样data: 这是一条推送消息\n\nEventSource会自动解析这段内容把data:后面的文本传给onmessage。这个格式是SSE的协议层约定服务端必须按这个格式发消息客户端才能正确解开。2.2 Spring Boot里的SSE载体SseEmitterSpring Boot对SSE的支持核心是SseEmitter它是Spring MVC 4.2版本引入的异步推送机制专门用于流式返回数据。它的思路和DeferredResult类似请求进入Controller后立即返回但HTTP连接保持打开由另一个线程往连接里写数据。理解SseEmitter需要先理解“异步请求”这个概念。平时写的Controller方法返回一个对象Spring会等这个方法执行完把返回值序列化后塞进响应里关掉连接。但SseEmitter不是返回值它是你“挂起请求”的凭证Controller方法返回SseEmitter对象Spring拿到后就把这个请求挂起连接的存活周期移交给你自己控制。只要你在另一个线程里调用emitter.send()数据就会通过之前那个HTTP长连接实时推给浏览器。我把实现步骤拆解一下这是最容易看明白的部分。Controller层定义接口RestController RequestMapping(/api/sse) public class AlertSseController { private final MapString, SseEmitter emitterMap new ConcurrentHashMap(); GetMapping(value /alert, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamAlert() { SseEmitter emitter new SseEmitter(0L); String clientId UUID.randomUUID().toString(); emitter.onCompletion(() - emitterMap.remove(clientId)); emitter.onTimeout(() - emitterMap.remove(clientId)); emitterMap.put(clientId, emitter); return emitter; } public void pushToAll(String message) { emitterMap.forEach((id, emitter) - { try { emitter.send(SseEmitter.event() .name(message) .data(message)); } catch (IOException e) { emitter.completeWithError(e); emitterMap.remove(id); } }); } }SseEmitter构造参数是超时时间毫秒0表示不超时。实际生产里一般不设无穷大建议设成30分钟或60分钟然后用心跳机制维持连接。SseEmitter.event().name(message)设置事件名前端用addEventListener(message, callback)监听对应事件不设置name时走onmessage。Service层推送数据我习惯用Async线程池来做避免阻塞Tomcat的工作线程Service public class AlertPushService { private final AlertSseController sseController; Async(sseTaskExecutor) public void pushAlerts() { for (int i 0; i 10; i) { sseController.pushToAll(告警消息 # i); try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } } }在生产环境里要注意的是pushToAll这类方法内部遍历ConcurrentHashMap时SseEmitter.send()会抛IOException原因通常是客户端断开了连接。这个异常不能吞要捕获后调completeWithError把资源释放掉不然连接会一直挂在服务端时间久了就是连接泄漏。我在多次压测里见过这类问题客户端刷新页面或关闭浏览器后服务端没有感知到断开emitterMap里积累了大量僵尸连接。2.3 AI大模型场景如何用SSE流式输出最近用Java封装AI交互逻辑时SSE的价值体现得更充分。大模型接口OpenAI、通义千问、文心等都支持本身就是流式返回token的服务端拿到的是一段一段的增量数据传统做法是等全部生成完再一次性返回用户体验就是“问一个问题要转圈等很久”而SSE的做法是“生成一个字推一个字”前端渲染几乎是同步的。我在Spring Boot里的实践是先用一个HTTP客户端请求大模型流式接口拿到增量数据后立即通过SseEmitter推给前端。整个过程是“大模型流 - SSE流”的管道式传递延迟只有一次网络转发的损耗。public void streamChat(String prompt, SseEmitter emitter) { // 请求大模型流式接口 aiClient.streamChat(prompt).subscribe( chunk - { try { // 每收到一个增量块就立刻推给前端 emitter.send(SseEmitter.event() .name(delta) .data(chunk.getContent())); } catch (IOException e) { emitter.completeWithError(e); } }, error - emitter.completeWithError(error), () - { // 完成后发送结束标记 try { emitter.send(SseEmitter.event() .name(done) .data([DONE])); emitter.complete(); } catch (IOException e) { emitter.completeWithError(e); } } ); }前端回调里还涉及一个很重要的abort问题用户正在等大模型回答中途不想等了点了停止按钮。这个动作在浏览器端只需要source.close()即可断开连接服务端会收到连接断开异常然后需要把这个emitter从注册表里移除避免后续还在往已断开的连接里写数据。我一开始没做这一步结果就是用户点了一次停止后服务端还在继续请求大模型白白浪费token调用量还可能在已断开的emitter身上反复抛异常日志被刷得乱七八糟。这里我还想顺带说明一个容易绕晕的地方Spring Boot 3.x里官方推荐用SseEmitter做SSE没错但如果你用WebFlux做响应式编程也可以用FluxServerSentEventT返回响应效果一样但写法完全不同。如果你当前项目是传统的Spring MVC Tomcat就用SseEmitter如果是WebFlux项目才考虑FluxServerSentEvent。不要混用不然依赖冲突和线程模型差异会给你带来一堆莫名其妙的麻烦。3. 完整可落地的操作流程从零搭建一个SSE推送接口3.1 构建工程与依赖准备这部分我按实际环境来写你会发现SSE真正需要用到的依赖比你想象中少得多——核心只是Spring MVC自带的SseEmitter不需要额外引入任何SSE专用库。创建一个Maven项目后在pom.xml中确保有下面这个依赖版本跟着你的Spring Boot版本走dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency注意不需要引入spring-boot-starter-websocket也不需要在application.yml里配任何WebSocket连接参数。我一直觉得SSE对Spring Boot开发者友好恰恰是因为它不引入额外的协议层和配置项。很多搜索“spring boot sse配置”的朋友会误以为需要类似WebSocket那样的yml配置其实它只要一个spring-boot-starter-web就够了。连线程池也不是必须的但我建议加一个原因是异步推送如果直接用Tomcat的线程来执行耗时的循环逻辑会占用Web容器的工作线程一旦推送任务多了其他普通HTTP接口会跟着变慢甚至排队超时。我的方案是单独定义一个小型线程池给SSE推送用Configuration public class SseThreadPoolConfig { Bean(name sseTaskExecutor) public Executor sseTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(100); executor.setThreadNamePrefix(sse-push-); executor.initialize(); return executor; } }3.2 服务端核心代码完整实现一个能用起来的SSE接口分成两块注册连接、推送消息。先定义一个客户端连接的管理器用ConcurrentHashMap维护每个客户端的连接。我之所以用ConcurrentHashMap而不是HashMap是因为SSE接口通常是高并发的入口多个浏览器同时注册、同时断开普通HashMap在扩容或put时可能因为并发写入产生不可预知的问题ConcurrentHashMap的分段锁机制更适合这个场景。Component public class SseConnectionManager { private final MapString, SseEmitter connections new ConcurrentHashMap(); public SseEmitter register() { SseEmitter emitter new SseEmitter(30 * 60 * 1000L); String clientId UUID.randomUUID().toString(); emitter.onCompletion(() - connections.remove(clientId)); emitter.onTimeout(() - connections.remove(clientId)); emitter.onError((e) - connections.remove(clientId)); connections.put(clientId, emitter); return emitter; } public void send(String clientId, String eventName, Object data) throws IOException { SseEmitter emitter connections.get(clientId); if (emitter ! null) { emitter.send(SseEmitter.event() .name(eventName) .data(data)); } } public void broadcast(String eventName, Object data) { connections.forEach((clientId, emitter) - { try { emitter.send(SseEmitter.event() .name(eventName) .data(data)); } catch (IOException e) { connections.remove(clientId); emitter.completeWithError(e); } }); } public int count() { return connections.size(); } }Controller里只做最轻薄的一件事把SseEmitter注册进管理器后立即返回。复杂逻辑不能写在Controller里否则Tomcat线程会被占住等逻辑跑完。RestController RequestMapping(/api/sse) public class SseController { private final SseConnectionManager connectionManager; public SseController(SseConnectionManager connectionManager) { this.connectionManager connectionManager; } GetMapping(path /connect, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter connect() { return connectionManager.register(); } GetMapping(/stats) public MapString, Integer stats() { return Map.of(connectedClients, connectionManager.count()); } }推送动作可以由任何业务逻辑触发比如一个定时任务或报警事件监听器Component public class AlertScheduler { private final SseConnectionManager connectionManager; public AlertScheduler(SseConnectionManager connectionManager) { this.connectionManager connectionManager; } Scheduled(fixedDelay 5000) public void pushHeartbeat() { try { connectionManager.broadcast(heartbeat, server-time: System.currentTimeMillis()); } catch (Exception e) { // 推送异常不要影响定时任务执行 } } }3.3 前端接入与数据渲染服务端准备好了前端需要正确消费这个流。之前有朋友和我反馈说SSE接口在浏览器里直接打开能看到数据但用fetch接收不到这是因为fetch不解析text/event-stream格式需要手动读取ReadableStream。如果使用fetch处理方式会比较繁琐我建议直接用浏览器原生EventSource它天然解析SSE协议格式代码最少。一个完整的连接代码const source new EventSource(/api/sse/connect); // 监听默认消息 source.onmessage (event) { console.log(default message:, event.data); }; // 监听命名事件 source.addEventListener(heartbeat, (event) { updateHeartbeatUI(event.data); }); // 监听连接错误 source.onerror (error) { console.error(SSE连接异常EventSource将自动重连, error); }; // 手动断开连接 // source.close();有一个细节值得留意EventSource默认自带重连机制连接断开后浏览器会自动重新发起请求不需要你写任何重连逻辑。但很多后端同学不知道这点服务端一旦正常结束连接比如emitter.complete()浏览器会立刻重新连接导致出现“消息推送完了但连接数一直没下降”的现象。这是EventSource的设计机制不是Bug。如果你有“推送完就关闭连接”的需求需要前端在收到结束事件后主动调source.close()source.addEventListener(done, () { console.log(推送全部完成手动关闭连接); source.close(); });3.4 容器与代理层配置很多人栽在这里SSE接口在本地开发跑得好好的一部署到测试环境就收不到消息问题绝大多数出在代理层和容器配置上。我把自己踩过的配置坑整理成一张速查表配置位置关键项备注Spring Bootserver.tomcat.max-swallow-size接收大响应时避免被吞Spring Bootspring.mvc.async.request-timeout异步请求超时默认是容器级别比emitter内部超时更早生效的话要注意调大Nginxproxy_buffering off必须关闭代理缓冲否则数据会被攒批Nginxproxy_read_timeout 300s没有心跳时可以适当调大但推荐用心跳替代Nginxproxy_http_version 1.1支持长连接HTTP/1.1默认keep-alive行为NginxX-Accel-Buffering: no在响应头里设置比Nginx配置更精确Nginx相关配置示例在location块内配置location /api/sse/ { proxy_pass http://backend-server; proxy_http_version 1.1; proxy_set_header Connection ; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; add_header X-Accel-Buffering no; }Tomcat方面要留意在Spring Boot内置Tomcat的默认异步请求超时通常是30秒而SseEmitter不会自动刷新超时时间如果连接超过30秒没有任何数据Tomcat会主动把连接断开。解决思路有两个方向一是把spring.mvc.async.request-timeout调大到毫秒值比如300000二是服务端加心跳——我倾向前者与心跳并用既要保证连接不因空闲被回收也要防止异常连接长期挂着占用资源。我实际的生产环境配置是spring: mvc: async: request-timeout: 3000004. 常见问题与排查技巧实录4.1 SSE连接不实时像是攒一段时间才推送这种情况十有八九是代理缓冲导致的。服务端明明1秒推一条浏览器端却是10条攒一起一次性显示。排查方法打开浏览器开发者工具看这个请求的Content-Type是否为text/event-stream再看响应头里有没有X-Accel-Buffering: no如果走的是Nginx确认proxy_buffering是否已关闭。另外SSE消息的data:后面一定要跟\n\n如果服务端忘了加换行符解析会出错前端也可能表现为数据迟迟不到。SseEmitter的send方法会自动处理消息格式如果是自己写底层Socket是很容易漏掉这个细节的。4.2 连接容易被断开断断续续这个问题根源大多数是超时设置和心跳缺失。我用过一句话总结这个现象SSE断开的根本原因通常是没有任何数据活动超时机制就生效了。当连接空闲超过Nginx的proxy_read_timeout或Tomcat的异步请求超时网关层或容器层会主动关闭连接。解决方案是自己实现心跳机制每20~30秒发送一条空白注释消息data: ping告诉所有中间层“连接还活着”。我在心跳回调里是这么处理的public void heartbeat() { try { connectionManager.broadcast(heartbeat, ); } catch (Exception e) { log.warn(心跳推送异常, e); } }每隔25秒用一个Scheduled注解的定时任务去广播心跳实测下来最稳定。注意心跳内容不能太长也不要有业务含义它就是一条保活消息前端可以忽略它或简单记录一下最近活动时间。4.3 客户端数量一多推送越来越慢甚至报错SSE是HTTP长连接每个连接在Tomcat里占用一个请求线程而Tomcat的默认线程池有上限。大量SSE连接会占满线程池普通接口跟着遭殃。我压测时连了2000个SSE客户端Tomcat的http-nio线程全部被占用其他请求排队超时现象非常明显。我的经验是把SSE服务独立部署或者单独拆成一个进程它不和其他业务接口混在一起。方案有很多种比如用Spring Cloud Gateway做路由分流把这个接口独立拆成一个小服务部署到单独的实例上。如果非要混部署一定要把Tomcat线程池调大并严格控制连接数上限比如在连接管理器里加一个maxClients判断超过阈值就拒绝新连接private static final int MAX_CLIENTS 1000; public SseEmitter register() { if (connections.size() MAX_CLIENTS) { throw new IllegalStateException(SSE连接数已达上限); } // ... }4.4 AI流式场景stream disconnected before completion调用大模型的流式接口时偶发“stream disconnected before completion: idle timeout waiting for sse”这类错误。核心原因是大模型接口在生成回答时有较长的思考间隙比如Reading阶段期间没有数据流过来客户端侧的SSE超时机制触发断连。如果你的SSE超时时间设置得太短比如默认30秒就很容易在思考间隙被掐断。我的解法是把大模型客户端的读取超时调大比如180秒同时服务端侧在等待大模型第一段响应前先发一条data: wait的占位消息这样能重置中间所有层的空闲计时器从根上解决问题。这也解释了为什么我在心跳设计中坚持用占位消息而不是真正的业务数据——占位消息的唯一作用就是维持连接活性。4.5 实测验证接口是否正常的小技巧写完接口想在本地快速验证不一定非要写前端页面。用Linux/Mac自带的curl命令就能看到原始推送流curl -N http://localhost:8080/api/sse/connect-N参数代表禁用缓冲数据一到就立刻打印。你会看到类似这样的原始输出event: heartbeat data: server-time: 1712400000000这个技巧在排查接口问题时非常高效能直接看到服务端发出来的原始消息进而判断到底哪一层出了问题。5. 运行时架构与扩展思考5.1 SSE服务如何做水平扩展一个单机SSE服务能支撑的并发连接数是有限的业务量上来后需要考虑水平扩展。但SSE有一个天然特性连接是绑定到具体服务实例的客户端在实例A上建立了连接下次连接不一定能命中实例A。这就产生了“在线客户端”分布在不同机器上的问题。我在做告警推送时采用的方案是Redis Pub/Sub做消息广播业务服务把推送消息发布到Redis频道所有SSE推送服务订阅这个频道收到消息后只看自己维护的客户端连接表把消息推给属于自己实例的客户端。这个方案不需要引入消息队列Redis本身就够用而且实现清晰不会过度设计。如果团队本来就有RabbitMQ或Kafka用它们的Topic广播机制原理也一样。5.2 和WebSocket的取舍再往前踩半步前面已经提过选型但这里我想补充一点更具实操性的判断标准。判断一个实时方案是否合适不要只看实时性还要看消息模式。SSE是单向推送所以“服务端主动、客户端被动接收”的业务几乎都能用像企业办公系统里的审批待办数变化、订单状态流转通知、数据大屏的指标刷新这些都是典型场景。WebSocket的强项是“双向奋发”比如在线聊天、多人协同编辑这类业务客户端发送频率高、消息类型复杂SSE直接做会很别扭。还有一个我认为很关键的因素是运维心智。SSE的底层是HTTP监控方式跟普通接口完全一样日志、链路追踪、网络排查都能用现有工具。WebSocket的协议层更复杂线上排障时还要分析帧数据。团队如果对实时通信没有很强的基建积累从SSE起步的容错空间大得多。5.3 从SSE到跨端小程序和App怎么办如果你发现浏览器端用SSE很顺手但跨端方案小程序、App客户端不支持EventSource这时候有两个方向一是网关层做协议转换把SSE流转成WebSocket流给移动端使用二是后端提供两套接口Web端走SSE移动端走WebSocket由请求来源判断返回对应类型的接口。实际项目中我觉得方案二更实用因为两种接入方的业务逻辑差异往往不只传输协议还包括消息频率、展示方式拆开后反而清晰。当然如果你的技术栈是Spring Boot 3.x WebFluxFluxServerSentEvent给Web前端WebSocket给移动端两种通道可以并行维护这个组合在AI问答类应用中很常见。6. 写在最后的一点实战体会这次分享从原理讲到了实操我再掏几句压箱底的体会。我做SSE相关功能一段时间后回头复盘最深的感受是好东西的标准之一是不引人注目SSE就是这种特性它不要求你改架构、不要求你引入新集群、不要求前端学习新API但能实实在在把推送体验从一个档次抬到另一个档次。如果你跟我一样是在现有Spring Boot项目里接入SSE我的建议是从最小的场景切起选一个“任务进度推送”的接口跑通第一个SSE连接再扩展到告警、AI流式输出等更多实时功能。过程中要特别注意心跳、超时和代理层这三个高频坑把这三个问题处理好了SSE基本能稳定跑很久。最后再分享一个小技巧上线后可以在前端做一个简单的SSE连接自愈机制监听onerror后等待EventSource自动重连如果连续重连超过5次还失败就提示用户检查网络并手动刷新。这个逻辑虽然简单但它能把体验兜底得很好尤其是做数据大屏或者后台告警时一次长时间断网重连对用户的感知影响极大自愈机制能减少很多无谓的支持工单。
网站建设高端定制企业官网