1. 实时数据推送的技术选型:为什么这次我押注SSE
先说结论:在Spring Boot项目里做实时数据推送,很多人第一时间会想到WebSocket,但真正落到业务里,SSE(Server-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载体:SseEmitter
Spring 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 Map<String, 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做响应式编程,也可以用Flux<ServerSentEvent<T>>返回响应,效果一样但写法完全不同。如果你当前项目是传统的Spring MVC + Tomcat,就用SseEmitter;如果是WebFlux项目,才考虑Flux<ServerSentEvent>。不要混用,不然依赖冲突和线程模型差异会给你带来一堆莫名其妙的麻烦。
3. 完整可落地的操作流程:从零搭建一个SSE推送接口
3.1 构建工程与依赖准备
这部分我按实际环境来写,你会发现SSE真正需要用到的依赖比你想象中少得多——核心只是Spring MVC自带的SseEmitter,不需要额外引入任何SSE专用库。
创建一个Maven项目后,在pom.xml中确保有下面这个依赖,版本跟着你的Spring Boot版本走:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-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 Map<String, 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 Map<String, 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 Boot | server.tomcat.max-swallow-size | 接收大响应时避免被吞 |
| Spring Boot | spring.mvc.async.request-timeout | 异步请求超时,默认是容器级别,比emitter内部超时更早生效的话要注意调大 |
| Nginx | proxy_buffering off | 必须关闭代理缓冲,否则数据会被攒批 |
| Nginx | proxy_read_timeout 300s | 没有心跳时可以适当调大,但推荐用心跳替代 |
| Nginx | proxy_http_version 1.1 | 支持长连接,HTTP/1.1默认keep-alive行为 |
| Nginx | X-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 + WebFlux,Flux<ServerSentEvent>给Web前端,WebSocket给移动端,两种通道可以并行维护,这个组合在AI问答类应用中很常见。
6. 写在最后的一点实战体会
这次分享从原理讲到了实操,我再掏几句压箱底的体会。我做SSE相关功能一段时间后回头复盘,最深的感受是:好东西的标准之一是不引人注目,SSE就是这种特性,它不要求你改架构、不要求你引入新集群、不要求前端学习新API,但能实实在在把推送体验从一个档次抬到另一个档次。
如果你跟我一样是在现有Spring Boot项目里接入SSE,我的建议是从最小的场景切起:选一个“任务进度推送”的接口,跑通第一个SSE连接,再扩展到告警、AI流式输出等更多实时功能。过程中要特别注意心跳、超时和代理层这三个高频坑,把这三个问题处理好了,SSE基本能稳定跑很久。
最后再分享一个小技巧:上线后可以在前端做一个简单的SSE连接自愈机制,监听onerror后等待EventSource自动重连,如果连续重连超过5次还失败,就提示用户检查网络并手动刷新。这个逻辑虽然简单,但它能把体验兜底得很好,尤其是做数据大屏或者后台告警时,一次长时间断网重连对用户的感知影响极大,自愈机制能减少很多无谓的支持工单。