1. 项目概述为什么我抛弃 WebSocket 选了 SSE做后端开发这些年实时推送的需求几乎躲不开。消息通知、订单状态变更、工单进度、大屏数据刷新、AI 对话流式输出……只要是“服务器主动找客户端说话”的场景都会面临一个技术选型问题。早些年我习惯上了 WebSocket后来在 Spring Boot 项目里越用越觉得别扭要处理握手协议、子协议协商、心跳保活、断线重试、消息二进制分帧还要关注连接数的内存占用。有一次做内部运营后台的“进度条推送”需求其实很简单——服务器每隔几秒把任务执行百分比发给浏览器前端用一个小脚本渲染进度。结果我为了个进度条接入了全套 WebSocket 支持包维护成本比业务代码还高。后来我在一个模拟项目X里改用了 Spring Boot 的 Server-Sent Events也叫 SSE。这玩意儿本质上就是 HTTP 长连接服务端把响应头里Content-Type设成text/event-stream然后像流水一样往连接里写数据浏览器端只需要一个EventSource对象就能自动接收。它不需要额外的协议解析不需要升级连接不需要握手过程中那个绕人的 101 状态码前端代码量直接砍掉一半。如果你已经用了 Spring BootSSE 的支持几乎是开箱即用的——SseEmitter类专门干这件事。这篇文章我打算从一个真实需求出发给后台管理系统的“任务中心”做一个实时进度推送。任务在服务端异步执行前端页面要实时看到正在处理(2/10)这样的状态并且当任务完成时推送一条最终结果。我会把完整的实现代码、配置参数、踩坑记录都写出来。适合的读者是已经在用 Spring Boot 做业务开发想找一个轻量级实时推送方案的人或者对 WebSocket 的重模式有点烦想看看 HTTP 长连接能不能替代的人。通过这篇文章你可以直接在自己的项目里搭出一套可运行的 SSE 推送接口并知道它在什么情况下该用、什么情况下别用。很多人会问SSE 和 WebSocket 到底选哪个我先把结论放在这儿如果数据是单向的服务器到客户端且客户端主要就是浏览器或支持EventSource的终端SSE 是性价比最高的选择。它不需要额外的端口、额外的协议处理、额外的鉴权流程甚至连负载均衡都能直接用现成的 HTTP 层策略。如果你需要双向通信比如聊天室、在线协同编辑那就老老实实用 WebSocketSSE 天生不适合别硬拧。2. 核心设计思路把 SSE 拆开看它到底是怎么工作的2.1 SSE 的连接模型和消息格式先说人话SSE 就是一个“挂在服务器上的一个响应流”。客户端发一个普通 GET 请求服务器接收后不立即返回完整响应而是把响应头保持在“打开”状态然后每隔一段时间写一次数据每次写的部分叫一条 SSE 事件。客户端通过EventSource浏览器原生 API监听这些事件拿到数据后更新界面。SSE 的消息格式有约定纯文本遵循以下规则一行以data:开头后面跟的就是具体消息内容。一条事件Event以空行\n\n结束表示这条数据已经发送完毕。可以使用id:给事件编号客户端断线重连时会带着Last-Event-ID头回来服务器可以根据这个 ID 补发缺失的消息。可以使用event:自定义事件类型这样前端可以监听不同名称的事件。retry:字段可以告诉浏览器重连的间隔时间单位毫秒。所以服务端往响应里写的原始内容大概长这样id: 1 event: progress data: {percent:20} id: 2 event: progress data: {percent:40} id: 3 event: done data: {taskId:1001,result:success}这些字段你不需要手动拼——Spring Boot 的SseEmitter已经封装好了你只要调send()方法传入一个SseEventBuilder或者对象框架会负责格式化输出。但理解它的数据格式对排错很重要很多诡异问题其实都是因为你没按格式写数据前端解析不到。2.2 Spring Boot 中的 SseEmitter 到底是什么SseEmitter在 Spring MVC 里是一个特殊的返回值类型。当 Controller 方法返回它时Spring 会启动一个异步处理流程方法的返回值被当作一个事件发射器控制器线程立即释放供下一个请求使用。后续你可以在任意线程里调用emitter.send()往连接写数据在完成时调用emitter.complete()关闭连接。这意味着两件事SSE 请求不能走传统的同步请求-响应模型它必须在“异步”上下文中运行。Spring Boot 默认支持异步不用额外配置但要注意过滤器和拦截器对异步请求的处理。SseEmitter实例的创建和send()方法的调用不一定要在同一个线程里。你可以把 emitter 存进一个内存 Map然后从另一个业务线程触发推送。这也是实现“多客户端广播”的关键。SseEmitter的构造器可以指定超时时间单位毫秒默认是 30 秒就是说如果 30 秒内没有往连接写数据连接会被自动关闭。所以做实时推送必须处理好心跳这也是大家踩坑最多的地方。2.3 和 WebSocket 相比SSE 的优势和界限我在项目里整理过一个对比写在这里供你参考维度SSEWebSocket连接方式普通 GET 请求HTTP 长连接独立协议需要握手升级通信方向服务器到客户端单向双向全双工数据格式文本基于事件文本或二进制前端APIEventSource原生支持WebSocket原生支持断线重连内置自动带最后事件ID需自己实现传输细节编码简单易调试需要处理帧、掩码等细节适用场景通知、进度、推流、监控聊天、游戏、协作编辑注意有两条红色边界SSE 只能用 GET 方法。Spring Boot 的SseEmitter只支持 GET 映射你要是硬写 POST 接口返回SseEmitter请求会报HttpRequestMethodNotSupportedException。想建立一次 SSE 连接前端必须用EventSource并指向一个 GET URL。SSE 是单向的客户端想给服务器发消息比如确认消息、提交参数必须额外再用普通 HTTP 请求。好在实际业务中客户端传给服务器的数据通常量很小用fetch或表单提交就能解决不需要因此升级到 WebSocket。2.4 什么时候不应该用 SSE不是所有实时功能都适合 SSE。如果你的推送频率特别高比如每秒几百条SSE 的 HTTP 头开销和文本格式反而成为负担这时候 TCP 直连或 WebSocket 更合适。如果需要在多个服务实例之间广播推送集群环境SSE 连接是长在某个实例上的你需要用 Redis 的 Pub/Sub 或消息中间件把推送消息分发到各实例再由对应实例把数据推到它自己建立的 SSE 连接上。这个改动比单机模式复杂不少如果一开始没规划好后面重构会比较疼。所以当你做技术选型时先想清楚服务端是否真的需要主动、单向、持续地向客户端吐数据如果是SSE 的面条就摆在面前了如果还需要客户端频繁反馈输入那就别跟 SSE 死磕。3. 动手实现Spring Boot 集成 SSE 从零搭一套任务进度推送3.1 项目基础准备我用的是 Spring Boot 2.7.183.x 也适用核心 API 没变项目结构就是一个标准的 Maven Web 项目核心依赖就一个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency不需要额外的 SSE 专用依赖这是它比 WebSocket 省心的地方。你把上文中的 Maven 配置进了pom.xml启动项目框架的异步支持自动生效。如果你项目里用了 Spring Security记住 CSRF 相关配置要注意因为 SSE 连接是一个持续存在的 GET 请求某些安全配置可能会在异步请求期间把认证信息丢掉这个后面专门聊。为了方便演示我会建一个TaskController用来处理创建任务和订阅任务进度两个接口。再建一个TaskService负责模拟执行任务并推送进度。3.2 核心代码任务中心的 SSE 推送实现我把完整可运行的代码分四块写出来并在代码注释里说明关键点。第一块创建任务接口。这个接口的前端只要正常 POST 就可以了它会返回一个任务 ID。实际业务中这里一般会落库、发 MQ我这里简化成直接在内存中生成一个任务并为它创建一个 Timer 线程模拟进度。RestController RequestMapping(/api/tasks) public class TaskController { private final TaskService taskService; public TaskController(TaskService taskService) { this.taskService taskService; } PostMapping(/create) public MapString, Object createTask() { String taskId taskService.createTask(); MapString, Object result new HashMap(); result.put(taskId, taskId); return result; } }第二块订阅任务进度这是核心中的核心。注意返回类型是SseEmitter这里我必须显式指定它。RestController RequestMapping(/api/tasks) public class TaskController { GetMapping(value /subscribe/{taskId}, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter subscribe(PathVariable String taskId) { SseEmitter emitter taskService.registerTask(taskId); return emitter; } }produces MediaType.TEXT_EVENT_STREAM_VALUE这行不能漏。它告诉 HTTP 客户端这个接口返回的 Content-Type 是text/event-stream浏览器接到这个类型才会按流式方式处理。不要省掉它有些版本的 Spring 是会自动推断但显式写出来更稳妥尤其是在 controller 里同时存在多个返回类型时。第三块TaskService的核心注册 emitter 和创建任务模拟推送。Service public class TaskService { private final MapString, SseEmitter emitterMap new ConcurrentHashMap(); public String createTask() { String taskId task- UUID.randomUUID().toString().substring(0, 8); // 模拟一个异步任务在一个线程里逐步执行 CompletableFuture.runAsync(() - simulateTask(taskId)); return taskId; } public SseEmitter registerTask(String taskId) { // 超时时间设置为 60 秒心跳频率 30 秒一次 SseEmitter emitter new SseEmitter(60_000L); // 把当前连接保存下来后面推送要用 emitterMap.put(taskId, emitter); // 完成、超时、异常时都要清理避免内存泄漏 emitter.onCompletion(() - emitterMap.remove(taskId)); emitter.onTimeout(() - emitterMap.remove(taskId)); emitter.onError(e - emitterMap.remove(taskId)); return emitter; } private void simulateTask(String taskId) { int total 10; for (int i 1; i total; i) { try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } int percent i * total; // 10, 20, 30... // 实时推送进度事件 pushProgress(taskId, percent); } // 任务完成推送结果并关闭连接 sendDone(taskId, 任务处理完成); } private void pushProgress(String taskId, int percent) { SseEmitter emitter emitterMap.get(taskId); if (emitter null) { return; } try { MapString, Object data new HashMap(); data.put(taskId, taskId); data.put(percent, percent); data.put(status, RUNNING); emitter.send(data); } catch (IOException | IllegalStateException e) { emitterMap.remove(taskId); emitter.completeWithError(e); } } private void sendDone(String taskId, String result) { SseEmitter emitter emitterMap.get(taskId); if (emitter null) { return; } try { MapString, Object data new HashMap(); data.put(taskId, taskId); data.put(status, DONE); data.put(result, result); emitter.send(data); emitter.complete(); } catch (IOException | IllegalStateException e) { emitter.completeWithError(e); } } }仔细看几个细节ConcurrentHashMap用来存taskId - SseEmitter并发场景下不会有丢失。实际项目中可能还要存 userId 或者 sessionId总之要保证 key 能定位到连接。我在registerTask里设置了 60 秒超时并在注释里提了一下心跳频率是 30 秒但上面的模拟代码里实际没做心跳。如果你把 simulateTask 的耗时改成 2 分钟那连接会在 60 秒后被 Spring 自动关掉。这里我先保留一个坑后面章节专门解决。emitter.send(data)这个方法有个值得一提的细节Spring 会把传入的 Map 自动转成 JSON 字符串并作为data:字段发送。如果你传入的是字符串它原样发送。这给开发带来了很大的便利前端拿到的就是data: {percent:30}这类格式。completeWithError会向客户端发送一个错误事件并关闭连接前端EventSource的onerror会被触发。第四块前端页面用EventSource订阅接口。这是最少的可运行代码了复制到static/index.html就行。!DOCTYPE html html langzh-CN body h3任务进度/h3 div idprogress/div script let taskId ; // 先创建任务 fetch(/api/tasks/create, { method: POST }) .then(res res.json()) .then(data { taskId data.taskId; // 用 EventSource 订阅任务进度 const source new EventSource(/api/tasks/subscribe/${taskId}); // 监听默认的 message 事件 source.onmessage function(event) { const msg JSON.parse(event.data); if (msg.status DONE) { document.getElementById(progress).innerText 完成 msg.result; source.close(); } else { document.getElementById(progress).innerText 进度 msg.percent %; } }; source.onerror function(event) { console.log(SSE连接异常或断开event:, event); // 如果连接被关闭EventSource 会自动重连暂不手动处理 }; }); /script /body /htmlEventSource默认监听名为message的事件。我们在服务端用send(data)推送的没有指定事件类型所以浏览器会触发onmessage。如果你想发一个自定义事件服务端要用SseEventBuilder指定事件名前端要写成source.addEventListener(customEvent, ...)的形式。后面我会专门讲这个。3.3 如何优雅地实现心跳和超时处理前面提到SseEmitter默认 30 秒超时我设了 60 秒但任务还是要跑 10 秒的对吧问题不大。但如果你的业务任务可能超过一分钟或者你干脆想一直保持连接等待服务器随时推送就得让连接“活着”的超时时间内有数据流动。最通用的方案是定时发送注释行或心跳事件。在 Spring Boot 里我一般写一个SseHeartbeatScheduler每隔 20 秒检查一次所有存活的 emitter发一条“ping”类型的事件Component public class SseHeartbeatScheduler { private final TaskService taskService; public SseHeartbeatScheduler(TaskService taskService) { this.taskService taskService; } Scheduled(fixedRate 20000) public void heartbeat() { taskService.sendHeartbeat(); } }在TaskService中增加sendHeartbeat方法public void sendHeartbeat() { long now System.currentTimeMillis(); emitterMap.forEach((taskId, emitter) - { try { // 注释行以冒号开头的行会被浏览器忽略但能维持连接 emitter.send(SseEmitter.event().comment(keep-alive)); } catch (IOException | IllegalStateException e) { emitterMap.remove(taskId); emitter.completeWithError(e); } }); }为什么用注释行而不用心跳数据这是我从实际使用中总结的一个小技巧。注释行的格式是以冒号开头的一行比如: keep-alive。浏览器会认为这是无意义的注释不会触发了onmessage但 TCP 连接上确实有字节流过这样代理服务器和 Spring 都能感知到连接是活跃的不会因为超时把连接掐断。如果你发送一个真正的 JSON 数据前端还得判断是不是心跳冗余逻辑更多。同时在registerTask里把超时时间设置得比心跳时间大两倍左右比如我设 60 秒心跳 20 秒一次就有足够的余量。如果你希望连接无限长还可以传0L给SseEmitter但那就会彻底关闭超时控制风险比较大不建议在生产上用。3.4 给连接加上鉴权与参数传递实际业务中subscribe接口不能让人随便连总要带个 token 或者用户信息。由于 SSE 连接是通过 GET 建立的最常见的两种做法是在唯一的连接 URL 里带一个防伪的长随机数token比如/api/tasks/subscribe/task-xxx?tokenabcdefg。这种方式对EventSource来说最简单因为EventSource不能自定义请求头但可以携带查询参数。如果你项目已经接了 Spring Security可以直接用标准的安全配置来拦截这个接口在方法里通过SecurityContextHolder获取当前用户再去业务里判断权限。这里必须注意一个 Spring Security 坑很多人在集成 SSE 时遇到“连上了但第一次发送就被前端断开”的情况原因往往是SecurityContext默认不会自动分发到异步线程。SseEmitter的send()通常在一个新线程里执行这时SecurityContextHolder如果用的是ThreadLocal取不到认证信息业务校验就会失败。解决方法是启用SecurityContext的异步分发或者干脆把需要校验的信息比如 userId在注册 emitter 时读取出来放进业务对象一起保存不要等推送时再取。java // 在 registerTask 时传入用户名而不是推送时再取 public SseEmitter registerTask(String taskId, String userId) { SseEmitter emitter new SseEmitter(60_000L); String emitterKey buildKey(taskId, userId); // 或者直接用 taskId 关联 userId // 后续 pushProgress 时直接从业务对象拿 userId }用这种土办法反而比在异步线程里反复处理安全上下文更省心也便于推广到集群。 ## 4. 多客户端广播、事件类型和项目集成细节 ### 4.1 实现广播让多个订阅者同时收到同一条推送 上面的代码有个前提一个任务只有一个订阅者。但有些场景是多个客户端同时查看同一个任务进度比如领导办公室的大屏和员工电脑上的工单页要看同一个任务。这时候如果只是把 emitterMap 换成 MapString, SseEmitter新进来的客户端会把旧连接顶掉这肯定不对。 正确做法是把连接存成列表或者用一个 key 对应多个 emitter java public class TaskBroadcaster { private final ConcurrentHashMapString, CopyOnWriteArrayListSseEmitter emitterGroups new ConcurrentHashMap(); public void register(String groupKey, SseEmitter emitter) { CopyOnWriteArrayListSseEmitter list emitterGroups.computeIfAbsent(groupKey, k - new CopyOnWriteArrayList()); list.add(emitter); emitter.onCompletion(() - list.remove(emitter)); emitter.onTimeout(() - list.remove(emitter)); emitter.onError(e - list.remove(emitter)); } public void broadcast(String groupKey, Object data) { ListSseEmitter emitters emitterGroups.get(groupKey); if (emitters null || emitters.isEmpty()) { return; } for (SseEmitter emitter : emitters) { try { emitter.send(data); } catch (IOException e) { emitter.completeWithError(e); emitters.remove(emitter); } } } }这里用了CopyOnWriteArrayList因为 SSE 连接的内存存活时间可能很长遍历一次不希望在中间被并发修改干扰。广播时要考虑一个问题如果一个客户端断网了服务端调用send()会抛 I/O 异常我们要及时把这个连接移除避免下次广播空连异常。上面代码已经做了。4.2 自定义事件让前端优雅区分不同类型的推送使用EventSource时它默认把消息当作message事件。如果你在一个 SSE 连接里既想推进度又想推聊天消息还想推告警全让前端解析 JSON 里的一个type字段也能工作但不优雅。SSE 协议本身就带event:字段Spring 封装了SseEventBuilder可以方便地指定事件名emitter.send(SseEmitter.event() .name(progress) .data(Map.of(percent, 40, stage, download))); emitter.send(SseEmitter.event() .name(notify) .data(Map.of(level, INFO, msg, 文件下载完成))); emitter.send(SseEmitter.event() .name(heartbeat) .comment(ping));前端就对应着const source new EventSource(/api/tasks/subscribe/task-xxx); source.addEventListener(progress, function(event) { const data JSON.parse(event.data); // 更新进度条 }); source.addEventListener(notify, function(event) { const data JSON.parse(event.data); // 展示通知 });我个人比较推荐这种写法前端代码的语义会清晰很多。如果一开始就用默认message事件硬扛后面推送类型越来越多前端代码会写出一堆 if-else。既然协议支持就不要埋这颗雷。4.3 SSE 连接和 Spring MVC 的拦截器、过滤器在 Spring Boot 里无论你用 Filter 还是拦截器处理 SSE 请求时都要小心“异步分派”的问题。因为 SSE 请求的主处理逻辑在业务线程返回后并没有结束连接还挂在容器上。如果你加了像OncePerRequestFilter这样的东西它默认只对REQUEST分派执行一次如果 request 走了ASYNC分派你可能要设置AsyncSupported。我遇到过的问题是在 Filter 中对所有请求做参数校验、往 Header 里补值结果 SSE 连接建立后第二次推送时Filter 里的逻辑又执行了一遍把一个本来应该只执行一次的埋点统计重复触发。解决方法是自定义一个 Filter在实现上判断如果request.getDispatcherType()是DispatcherType.ASYNC直接放行不要重复处理。如果业务确实需要读取request头里的信息要在主请求阶段REQUEST分派取完并保存到自己的上下文不要延迟到异步线程里去取。Spring MVC 的拦截器同样要注意preHandle和afterCompletion的执行次数。另外CrossOrigin在异步情况下有时会失效如果你前端和后端是分开的域名请确认 GLOBAL 或方法级别的 CORS 配置正确并且在event-stream的响应里会带上必要的 CORS 头。如果遇到不了可以考虑在WebMvcConfigurer中设置asyncRequestTimeout并且给SseEmitter设置足够大的超时时间。4.4 结合消息中间件做集群推送如果你只是在单机部署上面的代码已经能完成任务。但真实生产环境Spring Boot 服务通常开了多个实例Nginx 做了负载均衡。问题来了第一个请求创建任务打到了实例 A第二个订阅请求被负载均衡分发到了实例 B那么taskId绑定的SseEmitter在实例 B 上根本不存在前端就连不上或者连上了也收不到进度推送。解决思路是让“创建任务”和“订阅任务”这两个请求绑定到同一个实例上。这个在 Nginx 配置里叫“会话保持”。你可以给前端发一个特殊 Cookie比如routeserver-aNginx 根据 Cookie 路由。但这套方案在服务端做水平扩容的时候很脆弱新加的实例没法平滑接管。工业级方案是解耦“连接管理”和“推送触发”。也就是说每个实例的本地emitterMap仍然保存本机连接但推送消息统一从 Redis 订阅或 MQ 接收。任何一个实例收到“更新 task-123 进度”的广播消息先判断本地有没有 task-123 的 emitter有就推没有就忽略。这样实例之间天然互补。我拿 Redis Pub/Sub 写一个简单的例子Service public class RedisMessageReceiver { Autowired private StringRedisTemplate redisTemplate; // 模拟业务线程中广播消息 public void publishProgress(String taskId, String payload) { redisTemplate.convertAndSend(task:progress: taskId, payload); } // 在配置类里监听 topic }在订阅端每个 Spring Boot 实例你都要把本机的emitterMap注册到 Redis 的消息监听器。Redis 消息到达时调用本机emitterMap查找对应 emitter 并推送。这样无论请求打到哪个实例最终推送都由持有真实连接的实例完成。这个方案会引入 Redis 或 MQ 的额外依赖但它让 SSE 真正具备了横向扩展能力。如果你预感到项目未来要集群化建议一开始就留好这个口子——在TaskService里抽象出一个MessagePublisher接口单机模式下直接用本地调用集群模式下用 Redis 实现切换成本很低。5. 实际运行中的问题排查与避坑经验5.1 连接建立成功但收不到数据这是最常见的现象。前端EventSource一连接就立刻触发onopen但服务端的数据过来了浏览器没有反应。我排查过几次归纳起来通常有这几个原因服务端推送的数据格式不是标准 SSE。比如直接用emitter.send(String)但字符串里带有换行符且没有按data:前缀格式发送。用 Spring 的SseEmitter一般不会犯这种错但如果你用了原生响应流就很容易出错。中间代理或 Nginx 的缓冲没关。Nginx 默认会缓冲响应体直到响应关闭才把数据发给客户端。对于 SSE必须关掉缓冲配置proxy_buffering off; proxy_cache off; proxy_set_header X-Accel-Buffering no;前端EventSource解析 JSON 出错。如果服务端发的不是合法 JSONJSON.parse抛异常但onmessage就不会执行任何逻辑看起来像“没收到”。建议在onmessage里包一个 try-catch打印原始数据。5.2 连接总是超时断开前端一直在重连SSE 默认超时是 30 秒这个很多人会踩。如果你不设置超时且不发任何数据浏览器端大约 30 秒就会触发onerror然后 EventSource 自动重连。重连后如果前端带着Last-Event-ID服务端会恢复上下文但如果后端没实现基于上次 ID 的补发逻辑就会造成漏消息。我的建议是统一设置心跳注释行每 1520 秒一次前端不感知超时断线概率大幅降低。如果业务允许把SseEmitter超时时间设置成业务任务的最大预估时长加 30 秒不要设成0L无限除非有特别理由。如果确实要使用无限超时请注意容器线程资源。每个 SSE 连接一直占用一个容器线程的话几百个连接就能把 Tomcat 默认线程池打满。Spring Boot 2.x 默认最大 200 线程每个长连接持有一个线程这个坑会非常隐蔽。所以除非你明确知道自己在做什么否则不要轻易开无限超时。补充一个我自己的配置习惯在application.yml里增加这样一段让异步请求的超时时间与心跳对齐spring: mvc: async: request-timeout: 45000注意这里的单位是毫秒。它起到一个“兜底”作用哪怕你忘记设置 SseEmitter 超时容器层面也会在那个时间后强制结束请求。5.3 同一个用户重复订阅导致连接数量爆炸如果用户刷新了页面旧事件源被取消但服务端可能没立刻感知到旧连接还挂在 Map 里。几天之后内存里堆积大量僵尸连接推送时会向死连接写数据不断抛异常。解决办法有两个层面注册时对同一个“逻辑身份”userId 或 taskId做幂等处理如果存在旧 emitter先把它complete()再创建新的。为每个SseEmitter设置一个较短的超时时间让它自动清理。很多僵尸连接其实已经断了但不自知定时器会帮你兜底。我在项目里把registerTask改造为“先关旧再建新”public SseEmitter registerTask(String taskId) { SseEmitter oldEmitter emitterMap.remove(taskId); if (oldEmitter ! null) { oldEmitter.complete(); } SseEmitter emitter new SseEmitter(60_000L); // 后续注册监听... }这样能避免重复订阅时前端只收到后一个连接的数据也避免 Map 里积攒无效对象。5.4 容器线程池耗尽为什么连接一多页面就卡之前说过 SSE 长连接会占用 Tomcat 容器线程。Tomcat 默认用户线程请求处理模型下每个活动请求占用一个线程直到响应结束。SSE 请求长时间不结束因此线程一直占用。如果系统同时有大量其他普通接口请求线程池很快耗尽新请求排队页面必然卡。了解这个机制后我有两个方案可选使用 Spring WebFlux响应式栈来实现 SSE它不依赖线程池而是基于事件循环。代码写法会有变化如果你整个项目已经是同步 Spring MVC 栈迁移成本不低。保持 Spring MVC但控制 SSE 连接的数量。比如用配置中心动态调整最大连接数或者在接入层用 Nginx 限制单个 IP 的连接数。业务层面上不要让每个用户默认就建立一条 SSE而是等确实需要时再建。保守建议如果 SSE 连接数预计会长期超过 500就要认真考虑 WebFlux 或直接换 WebSocket 了。Spring MVC 容器线程模型对 SSE 是不太友好的这是架构上的硬伤靠调超时只能缓解不能根治。5.5 不同浏览器和网络环境的兼容性现代浏览器都支持EventSource但有些网络代理和移动端 WebView 会对 HTTP 长连接做过短的超时痛击。前端可以利用EventSource自带的重连机制把retry:设置为合理的值。如果你希望前端更稳定可以换fetch配合ReadableStream自己解析 SSE 格式但那等于重新实现一遍协议代价不小。在安卓旧版 WebView 里EventSource可能不受支持。我遇到过一个项目在 PC 上一切正常放安卓端就收不到后来查到一个别称“SSE 在部分海康WebView下不可用”的问题最终用fetchgetReader()流式读取接口解决。如果你想覆盖这些老环境实现成本会高一些需要自维护断线重连和事件解析。但绝大多数 Web 场景EventSource是足够稳的。5.6 一个完整的问题排查速查表症状可能原因排查步骤前端一直报错连接失败接口路径错误、鉴权未过、CORS用 curl 带 head 请求看响应码确认返回 Content-Type 是否为 event-stream连接建立后立刻断开SseEmitter 超时过短、容器超时检查 application.yml 的 async request-timeout检查 Nginx proxy_read_timeout连接 30 秒左右自动断开服务端没发心跳超时默认 30 秒设置 SseEmitter 超时大于 30 秒并增加心跳收不到数据或只收到第一条Nginx 缓冲未关闭添加 proxy_buffering off某些用户重复连接导致串数据旧连接未清理register 时先 complete 旧 emitter推送大面积超时容器线程池被 SSE 打满监控线程池限制连接数评估 WebFlux 迁移集群环境下一个任务消息只有部分用户能收到订阅落在不同实例推送没做多播引入 Redis Pub/Sub 或 MQ 广播浏览器 console 显示 “Unexpected token in JSON”网关或负载均衡返回了 HTML直接抓接口原始报文确认返回不是页面错误6. 写在最后我调试 SSE 时的一些个人心得其实把 SSE 用熟之后它的开发体验比 WebSocket 舒服很多。Spring Boot 的SseEmitter已经帮我们把协议层磨平了我只需要专注业务什么时候发射数据、发射什么数据、连接什么时候关闭。这种“简单到透明”的感觉是非常重要的。项目里如果只是希望服务器给浏览器递个话没有必要请出一个独立的协议模块。我个人在多次实操中形成了一条工作习惯凡是实时推送接口首先考虑 SSE只有出现双向通信、超低延迟、大规模并发连接时才把 WebSocket 拿出来对比。在这个前提下我还总会写一个SseClient测试类用RestTemplate或WebClient拉一次流式响应先确认接口在没有任何浏览器参与的情况下能正常输出data:行再去调前端。这能帮你节约至少一半的排查时间。如果你所在的项目已经有 Spring Boot 的基础从零接入 SSE按我这篇文章的代码走差不多半天就能把核心链路跑通。后面再考虑心跳、鉴权、集群分发逐项加固。最后补一句测试 SSE 的时候别用那个所谓的“浏览器预览”容易误导。直接用curl -N看原始输出curl -N -H Accept: text/event-stream http://localhost:8080/api/tasks/subscribe/task-xxx你会清晰地看到一行行data:不断打印出来。等你能看到这些输出剩下的就是逻辑层的事了。