☰
Spring Boot SSE实时推送实战:SseEmitter与WebSocket选型指南
2026/10/17 14:31:15 网站建设 项目流程

1. 先搞清楚SSE能做什么,和WebSocket到底怎么选

很多人一听到“实时推送”就想到WebSocket,然后陷入前后端联调的深坑里。实际上有一类很常见的业务场景——服务端往客户端单向推数据,用SSE(Server-Sent Events,服务器发送事件)反而更省事。SSE是HTML5标准里自带的能力,基于HTTP协议实现,客户端通过EventSource接口订阅,服务端通过普通的HTTP连接持续输出数据流。它解决的核心问题很简单:服务端有了新数据,主动把数据推给浏览器或客户端,而客户端不需要轮询。

举个例子,一台服务器上有N个任务在跑,前端要实时看到每个任务的进度百分比、日志输出、状态变更。用WebSocket当然能做,但你需要处理握手协议、消息帧格式、双向通信;而SSE完全不需要这些,它就是一次普通的HTTP请求,服务端不关连接,源源不断地把数据写出去就行。客户端拿到的是text/event-stream格式的数据流,用浏览器的EventSource对象一两行代码就能接住。

这里先说适合用SSE的场景:服务端状态变化通知、实时日志流、任务进度上报、消息中心弹窗、股票行情或监控指标的大屏展示。共同特点是方向单一,基本不需要客户端往服务端推消息。如果有强双向交互需求——比如聊天、多人协作编辑器、实时对战,那再去考虑WebSocket。还有一个非常现实的考量:SSE走的就是普通HTTP/HTTPS协议,不需要额外的协议解析和端口,对防火墙、反向代理、负载均衡这些基础设施非常友好,而WebSocket在复杂网络环境里经常要配额外的超时时间、代理缓冲策略。很多项目里,SSE就是那个让你省掉一大堆事的方案。

2. 动手前先搞懂SSE的核心协议细节

2.1 事件流到底长什么样

SSE的数据格式看起来平淡无奇,但它定义了一套自己的文本协议,MIME类型是text/event-stream,字符集UTF-8。服务端推送的每一条消息可以包含这些字段:

  • data:数据内容,可以多行,客户端收到的是一整块文本
  • event:事件类型,不写就默认是message
  • id:消息ID,客户端会自动记录,断线重连时通过Last-Event-ID头传给服务端
  • retry:重连时间(毫秒),告诉客户端如果断开了,隔多久再来连

消息之间用空行分隔。一个最简单的推送片段是这样:

data: 第一条消息 data: 第二条消息 id: 2 event: status data: {"progress": 78}

客户端在浏览器里对应的事件监听是:默认的message事件、直接用addEventListener('status')监听自定义事件。这个格式设计的好处是通过标准化的文本就能实现事件路由、断点续传和自动重连,不需要任何第三方库。

2.2 为什么SSE能“一条连接推到底”

本质上这就是一个长驻的HTTP响应。服务端收到GET请求后,把响应头设置好,然后体内容以分块传输(chunked)的方式持续往客户端写文本。客户端不关心你是一次性写完还是慢慢写,只要连接不断,数据就一直流过来。

关键响应头有这么几个,每一个都有讲究:

响应头值作用
Content-Typetext/event-stream;charset=UTF-8告诉客户端这是SSE流
Cache-Controlno-cache禁止缓存,否则数据被代理或浏览器缓存起来就收不到实时推送了
Connectionkeep-alive保持长连接
X-Accel-Bufferingno如果前面挂了Nginx,要关掉缓冲,否则数据会攒在Nginx里不往下发

第三项在Spring Boot里一般不手动设置,容器会自动处理。第四项看起来不起眼,但很多生产事故就出在这里——Nginx默认开启了proxy buffering,SSE数据会卡在缓冲区里,攒够4KB或一段时间才flush一次,前端看到的效果就是“数据一跳一跳地出现”。针对这类问题,我后面会在常见问题章节里专门展开讲。

3. Spring Boot接入SSE的完整实操

3.1 用Spring MVC的SseEmitter打通第一版

从Spring Framework 4.2开始,就原生支持了SseEmitter这个异步控制器返回值。接口的核心足够简单,几分钟就能跑通。下面是一个基础示例:

@RestController @RequestMapping("/api/sse") public class SseController { /** * 建立SSE连接,客户端连接后,服务端每2秒推送一次当前时间 */ @GetMapping(value = "/clock", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter clock() { // 1小时不操作自动断开 SseEmitter emitter = new SseEmitter(3600_000L); Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> { try { emitter.send(SseEmitter.event() .name("clock") .data("当前时间: " + LocalDateTime.now())); } catch (IOException e) { emitter.completeWithError(e); } }, 0, 2, TimeUnit.SECONDS); return emitter; } }

关键就三件事:设定超时时间、拿到SseEmitter返回给Spring MVC、在有数据的时候调用emitter.send()。注意send()方法里如果抛了IOException,说明客户端已经断开,这时候要completeWithError或者直接complete(),把线程释放掉。

3.2 线程池别乱用,这是第一道坑

SseEmitter本质上有两个线程参与工作:一个是Tomcat容器的工作线程,负责把HTTP请求挂起;另一个是你自己线程池里的线程,负责定时或业务触发去调send()。如果图省事直接在控制器里new线程池,每来一个连接就创建一个线程,客户端一多,线程数和句柄数会直接被打满。

我的做法是准备一个全局的调度线程池,专门负责往各个emitter发数据:

@Configuration public class SseThreadPoolConfig { @Bean("sseScheduler") public ScheduledExecutorService sseScheduler() { return Executors.newScheduledThreadPool(8, r -> { Thread t = new Thread(r, "sse-push-thread"); t.setDaemon(true); return t; }); } }

使用的时候注入这个@Bean,不要在每次请求的时候Executors.newSingleThreadScheduledExecutor()。8个线程够用吗?这里有个很实际的计算思路:如果你的每条推送只发送一小段文本,一个线程一秒钟能做几千次send(),瓶颈通常不在线程数,而在容器的IO和网络带宽。8个线程支持几百个并发连接没大问题。要再省事,也可以直接用TaskScheduler。

提示:SseEmitter超时设置为0的话,表示永不超时。但我仍然建议设置一个合理的上限,比如1小时,避免异常情况下连接永远挂着不释放。超时后Spring容器会自动清理,节省资源。

3.3 把连接池管理起来,实现主动推送

上一节同步推送的写法适合固定频率推送,但真实业务多半是“某个事件发生了,我要立刻推给相关客户端”。这就得把所有活跃的SseEmitter统一管起来。我习惯用一个简单的事件总线管理器:

@Component public class SsePushManager { private final ConcurrentHashMap<String, SseEmitter> emitterMap = new ConcurrentHashMap<>(); /** * 加入连接池 */ public SseEmitter connect(String clientId) { SseEmitter emitter = new SseEmitter(3600_000L); // 注册回调:完成、超时、报错都要从map里移除 emitter.onCompletion(() -> emitterMap.remove(clientId)); emitter.onTimeout(() -> emitterMap.remove(clientId)); emitter.onError(e -> emitterMap.remove(clientId)); emitterMap.put(clientId, emitter); return emitter; } /** * 往指定客户端推送 */ public void sendToClient(String clientId, Object data) { SseEmitter emitter = emitterMap.get(clientId); if (emitter != null) { try { emitter.send(SseEmitter.event().data(data)); } catch (IOException e) { emitterMap.remove(clientId); } } } /** * 广播给所有客户端 */ public void broadcast(Object data) { emitterMap.forEach((id, emitter) -> { try { emitter.send(SseEmitter.event().data(data)); } catch (IOException e) { emitterMap.remove(id); } }); } }

控制层就薄薄一层:

@RestController @RequestMapping("/api/sse") public class SubscribeController { @Resource private SsePushManager pushManager; @GetMapping("/subscribe/{clientId}") public SseEmitter subscribe(@PathVariable String clientId) { return pushManager.connect(clientId); } @PostMapping("/publish") public void publish(@RequestBody Map<String, Object> payload) { pushManager.broadcast(payload); } }

这样前端只要先subscribe拿到一个长连接,后端任何一个入口——接口、定时任务、MQ消费者——调到pushManager.broadcast()就能把消息实时推出去。这个模式基本覆盖了我遇到过的九成需求。

4. 一个完整的实例:任务进度实时上报

4.1 整体需求与前端设计

上面的框架如果能跑通,我们来做一个更接近生产环境的Demo:前端发起一个耗时任务,后端异步执行,执行过程中把每一步的进度推给前端。假设场景是批量处理1000条数据,前端要看每个阶段的处理条数和耗时。我直接用EventSource接数据,用fetch去触发任务启动。

前端页面核心代码(HTML + 原生JS):

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>SSE批量任务进度</title> </head> <body> <h2>批量任务进度</h2> <button id="startBtn">启动任务</button> <ul id="progressList"></ul> <script> let eventSource = null; document.getElementById('startBtn').addEventListener('click', async () => { // 先触发后端任务 const resp = await fetch('/api/task/start', { method: 'POST' }); const body = await resp.json(); if (body.taskId) { connectSse(body.taskId); } }); function connectSse(taskId) { if (eventSource) { eventSource.close(); } // 带上taskId建立连接 eventSource = new EventSource('/api/task/progress/' + taskId); // 默认事件,这里后端没写event名,所以走message eventSource.onmessage = function(event) { const data = JSON.parse(event.data); const li = document.createElement('li'); li.textContent = '进度: ' + data.percent + '%, 当前阶段: ' + data.stage + ', 已处理: ' + data.processed + ' / ' + data.total; document.getElementById('progressList').prepend(li); }; // 监听特殊结束事件(也可以使用message事件+数据判断,这里演示自定义事件) eventSource.addEventListener('finish', function(event) { const data = JSON.parse(event.data); alert('任务完成,总耗时: ' + data.cost + 'ms'); eventSource.close(); }); eventSource.onerror = function() { // EventSource自带重连,不用手动处理 console.log('连接异常,等待自动重连...'); }; } </script> </body> </html>

EventSource有两个值得记住的特性:断线会自动重连;可以在构造函数里带URL参数。我们的taskId直接拼在URL里就行,不需要额外设置Header,这一点比WebSocket省心。

4.2 后端任务执行 + SSE推送落地

后端流程拆分清楚:任务启动接口返回taskId;任务执行接口拿着taskId,注册好SseEmitter,然后在一个异步线程里模拟执行步骤,每次处理一批数据就推一条进度消息。

@RestController @RequestMapping("/api/task") public class TaskController { @Resource private SsePushManager pushManager; @Resource private ScheduledExecutorService sseScheduler; private final AtomicInteger taskIdGen = new AtomicInteger(0); private final Map<String, SseEmitter> activeTasks = new ConcurrentHashMap<>(); @PostMapping("/start") public Map<String, String> startTask() { String taskId = "task-" + taskIdGen.incrementAndGet(); // 立即注册SSE连接,但前端还没连上,没关系,EventSource连接很快 SseEmitter emitter = pushManager.connect(taskId); activeTasks.put(taskId, emitter); // 异步执行任务 sseScheduler.execute(() -> runTask(taskId)); return Map.of("taskId", taskId); } /** * 前端订阅 */ @GetMapping(value = "/progress/{taskId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter subscribe(@PathVariable String taskId) { SseEmitter emitter = activeTasks.get(taskId); if (emitter == null) { SseEmitter e = new SseEmitter(); e.complete(); return e; } return emitter; } private void runTask(String taskId) { int total = 100; int batchSize = 10; long start = System.currentTimeMillis(); try { for (int i = 0; ; i += batchSize) { // 模拟耗时处理 Thread.sleep(500); int processed = Math.min(i + batchSize, total); Map<String, Object> data = new HashMap<>(); data.put("percent", processed * 100 / total); data.put("stage", processStage(processed, total)); data.put("processed", processed); data.put("total", total); pushManager.sendToClient(taskId, data); if (processed >= total) { break; } } // 发送完成事件 Map<String, Object> finish = new HashMap<>(); finish.put("cost", System.currentTimeMillis() - start); pushManager.sendEvent(taskId, "finish", finish); } catch (IOException | InterruptedException e) { // 客户端断开,任务照常执行,只是推送失败 Thread.currentThread().interrupt(); } finally { activeTasks.remove(taskId); } } private String processStage(int processed, int total) { if (processed < total * 0.3) return "开始阶段"; if (processed < total * 0.7) return "中间阶段"; return "收尾阶段"; } }

这个Demo里有几个细节值得展开:

  • 任务启动时pushManager.connect(taskId)和前端订阅是分开的两个请求。connect()创建了emitter放进map里,前端再new EventSource()连过来时,Spring MVC会找到这个SseEmitter对象作为Response返回。所以即使前端晚了一秒连上也没事,消息不会丢——因为还没开始跑任务呢。
  • 发送完成时用了一个sendEvent方法,这个方法我建议封装成支持event字段。上一条sendToClient发默认消息,完成通知用自定义事件名,方便前端区分。
  • Thread.sleep要放在异步线程里,绝对不能在Tomcat工作线程里做,否则会占用连接线程,导致其他请求等待。

我在实战中还习惯把activeTasks和pushManager里的map合并成一套,直接用SsePushManager来完成订阅和发送,避免两个map数据不一致。上面代码为了解释清楚,分开了两个容器。真正落地时,建议把subscribe接口直接委托给pushManager.connect,保证同一个clientId对应同一个emitter。

5. 不可忽视的几个进阶处理

5.1 心跳保活,防止连接“假死”

SSE连接虽然挂着,但网络链路中很多设备(路由器、负载均衡、云平台的网关)会对长时间空闲的连接做超时清理。比如某云负载均衡默认空闲超时60秒,你的连接60秒没数据就被咔嚓断了。心跳就是每隔一段时间推一个注释行或空数据,告诉链路“我还活着”。由于只是占位信息,客户端完全感知不到。

用Spring Boot实现心跳非常简单,就在调度线程里定时往emitter里写event: ping或直接发注释行:

sseScheduler.scheduleAtFixedRate(() -> { activeEmitters.forEach((id, emitter) -> { try { emitter.send(SseEmitter.event().name("ping").comment("heartbeat")); } catch (IOException e) { // 连接已断开,清理 } }); }, 30, 30, TimeUnit.SECONDS);

实测下来30秒一次比较稳妥,对流量消耗可以忽略不计。

5.2 断线重连和消息去重要一起考虑

浏览器EventSource内置了自动重连,断线后默认重连间隔大概是1~3秒(可以服务端通过retry:字段控制)。重连后如果服务端还有未消费的数据,完全可以通过上一节讲过的id和Last-Event-ID头实现增量续传。实现思路:客户端每次收到带id的消息都记录下来,重连时请求头里自动携带Last-Event-ID,服务端判断这个值之后的数据继续推。

在Spring Boot里读取这个请求头:

@GetMapping("/progress/{taskId}") public SseEmitter subscribe(@PathVariable String taskId, @RequestHeader(value = "Last-Event-ID", required = false) String lastEventId) { if (lastEventId != null) { // 从lastEventId之后继续推送,实现断线续传 } // ... }

这套“消息序列号 + 客户端ack”的模式做起来容易,但绝大多数业务用不上。日常开发更常见的是短时重连——业务数据不追求完全无丢失,重启一下重新拉全量就行。我的建议是:实时性要求高且允许少量重建缓存、重新拉数据的场景,直接让前端close()后再建立新连接;必须保证不丢数据的金融级场景,再去考虑队列、序列号和窗口确认。

5.3 鉴权怎么处理

SSE的接口说到底是个GET请求,鉴权方式比WebSocket简单得多。常用的三种:

  • Cookie鉴权:浏览器自动带上,和普通页面一致
  • Token拼在URL上:new EventSource('/api/sse?token=xxx'),注意Token会出现在访问日志里,适合临时连接
  • Header鉴权:通过EventSource无法自定义Header,但可以在服务端进行拦截校验。如果必须用Header,一个变通方案是先请求一个鉴权接口,拿到一次性连接凭证再建SSE连接

个人建议用Cookie或一次性短期Token,省心可靠。对于内网或B端场景,Token拼URL是最常见的,因为浏览器的EventSource天生不支持自定义请求头。

6. 常见问题排查与避坑实录

6.1 前端一直是重连状态,但收不到任何数据

九成原因是反向代理缓冲。在Nginx配置里,对SSE接口的location加上:

location /api/sse/ { proxy_pass http://backend; proxy_set_header Connection ''; proxy_http_version 1.1; proxy_buffering off; proxy_cache off; chunked_transfer_encoding on; proxy_read_timeout 3600s; }

核心就是proxy_buffering off,让数据来一条转一条。如果后端是Tomcat,Connection: keep-alive会有些兼容性问题,把proxy_set_header Connection ''配置上,然后让Nginx自己管理和后端的长连接,Nginx默认会复用上游连接。proxy_read_timeout这个很关键,它表示后端在多久没发新数据时Nginx认为连接超时。之前一个项目就是因为这个参数默认60秒,SSE推送间隔一长就被Nginx切断。

6.2 SseEmitter发送时报IOException,但前端看起来还连着

这通常是网络设备断开的“滞后感知”。TCP连接一端断掉,另一端往往要等下次写数据时才感知到。所以send()抛异常时别犹豫,直接emitter.completeWithError()并从map中移除。心跳可以加速这种断线发现。

6.3 开启SSE接口后,系统报“不能处理异步请求”

Spring Boot默认是支持异步请求的。如果自己写了一层Filter,要确保Filter里做完了逻辑就chain.doFilter(),不要吞掉请求;更不要用request.getInputStream()去读SSE的请求体,因为SSE是GET请求,正常没体可以读。曾经遇到一个同事在权限Filter里尝试读取request body做统一鉴权,GET请求读到EOF以后把流关了,Tomcat就认为请求不完整,后续SSE写数据直接失败。排查半天才发现是这个原因。

6.4 连接数暴涨,疑似“连接泄漏”

SseEmitter的三个回调千万别漏:

emitter.onCompletion(() -> activeTasks.remove(taskId)); emitter.onTimeout(() -> activeTasks.remove(taskId)); emitter.onError((e) -> activeTasks.remove(taskId));

前端如果直接关掉页面,服务端短时间内可能感知不到,超时回调和错误回调就是兜底回收机制。我建议再做一层冗余清理:用调度任务扫描map里上一次心跳时间超过N分钟的emitter,直接complete()。

6.5 WebSocket和SSE混用时的选择恐惧

再补充一个决策工具:消息频率极高(每秒几十条以上)且需要对客户端发消息的场景,用WebSocket;频率一般、单向推送为主,选SSE。SSE还自带HTTP压缩,文本数据用gzip压下去带宽可以减少70%以上。WebSocket要做压缩就得自己定制扩展。

7. 生产环境级别的SSE封装建议

如果项目里SSE用得多,我推荐把最外层封装成一个消息推送服务,统一屏蔽底层细节。参考设计如下:

public interface RealtimePusher { SseEmitter subscribe(String topic, String userId); void publish(String topic, Object message); }

内部用ConcurrentHashMap<String, CopyOnWriteArrayList<SseEmitter>>维护topic和emitter的关系。为什么用CopyOnWriteArrayList?因为广播场景是读多写少(遍历比改动频繁得多),写锁开销小,读的时候直接拿快照遍历,并发安全。

发布消息时,可以对消息做简单的toString序列化,或者直接用Jackson统一序列化为JSON字符串。注意不要序列化Java对象后直接emitter.send(),因为SseEmitter.send()对非String对象也是走JSON序列化,但有时Date、LocalDateTime的格式会不一致,建议手动统一。

最后再分享一个运营层面的注意点:SSE连接在服务端是有状态的,应用多实例部署时必须考虑连接路由问题。比如A实例维护了一堆连接,请求打到B实例就找不到对应emitter。解决办法:引入消息中间件做广播,每个实例订阅同一个topic,收到消息后推给本地维护的连接;或者让客户端在订阅时带上实例标识,再用负载均衡器做一致性哈希路由。如果你只是单机应用或Demo,上面这些都可以忽略,但如果是生产环境多实例,一定要在架构设计时想清楚。

实际操作里比较稳的一条路:先不做复杂路由,用Redis Pub/Sub或者消息中间件做广播,每个实例把自己本地的emitter都注册到一个全局连接管理上,消费者收到消息后再走本地推送。等连接数真正大到需要分级时,再做更精细的定向路由。

这个方案我已经在几个项目里用了一年多,稳定度不错,而且SSE的维护成本是真的低。当年调WebSocket心跳、重连、断线补包的记忆还历历在目,换到SSE之后,大部分问题都被浏览器和HTTP规范消解掉了。

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

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

立即咨询