Spring Boot实时推送方案对比:短轮询、长轮询、SSE与WebSocket实战
2026/9/8 0:11:00 网站建设 项目流程

做 Web 开发这几年,实时推送永远是一个绕不开的话题。用户在线聊天、后台任务进度、消息中心红点、股票行情刷新,背后都是同一件事:服务端有状态变化,得第一时间让客户端知道。但 HTTP 协议天生是“请求-响应”模式,客户端不发请求,服务端就没办法主动开口。Spring Boot 工程里,常见的实时推送方案我总结下来无非四种:短轮询、长轮询、SSE(Server-Sent Events)和 WebSocket。这篇文章不打算罗列概念,而是用三个我在项目里实际落地的场景,把技术选型、核心代码、常见坑一次讲清楚。如果你想系统了解 Spring Boot 实时推送的套路,或者正在纠结自己的场景该用哪种方案,这篇应该能帮你省不少时间。

1. 实时推送方案选型:先看场景再看技术

1.1 四种主流实时推送方案原理拆解

先讲原理,因为选型选错了,后面写再多代码都是返工。

短轮询是最朴素的方案。客户端开个定时器,每 3 秒或 5 秒发一次请求,服务端每次返回当前状态。真实场景里,如果任务执行需要 10 秒,客户端可能已经发了三次无意义的请求,前两次拿到的都是“还没好”。反过来,如果服务端在第 4 秒就完成了,客户端也得等到第 6 秒才能查出来。结果就是:实时性差,服务端有 2 秒以上的感知延迟,同时大量无数据变化的请求白白消耗了 Tomcat 线程和带宽。短轮询唯一的优势是没有任何技术门槛,所有 Web 开发同学拿到就能写,浏览器兼容性也最好。

长轮询比短轮询聪明一点。客户端发起请求后,服务端不立即返回,而是把这个请求挂起来,比如挂 30 秒,这期间业务数据一旦就绪,立刻把响应写回去;如果 30 秒内一直没数据,就返回一个超时标志,客户端收到后立刻重新发起下一次请求。用这种方式,数据到达和响应返回之间的延迟基本能控制在毫秒级,同时请求次数比短轮询少得多。但代价是:服务端需要维护大量挂起状态的请求,且对请求线程的释放时机有要求,搞不好就会占用连接资源。

SSE全称 Server-Sent Events,是 HTML5 规范里的东西。客户端通过 EventSource 发起一个 HTTP 请求,服务端接受请求后保持连接不关闭,然后持续把数据以文本流的方式推给客户端。它是单向的,也就是只能服务端推送、客户端接收,不需要客户端回消息。浏览器原生支持断线自动重连,服务端还可以通过事件 ID 帮客户端做消息续传,这一点在很多场景里非常实用。

WebSocket是真正意义上的全双工长连接。首次握手走 HTTP,然后升级为 WebSocket 协议,之后服务端和客户端可以随时互相发消息。它的实时性是四种方案里最高的,双向能力也是其他三种方案不具备的。缺点是实现和运维成本更高,连接保活、心跳、鉴权、跨域、集群消息路由都要自己处理,网关和代理服务器还得专门做协议升级的兼容配置。

1.2 如何快速判断该用哪种方案

我自己的项目经验里,选型基本按照下面这个思路判断:

  • 需要双向交互,比如聊天、协同编辑、多人白板,选 WebSocket,这是唯一真正支持双向的长连接方案。
  • 只需要服务端单方向推送数据,比如通知提醒、行情刷新、日志流,优先选 SSE。代码量和复杂度远低于 WebSocket,还自带重连和事件 ID 机制。
  • 项目环境很老,浏览器兼容性要求极高,或者设备厂商的 SDK 只支持普通 HTTP 请求,才有可能回退到长轮询。
  • 任务状态的查询频率本身不高,比如 5 到 10 秒轮询一次就能接受,直接用短轮询都行,没必要为了一次实时体验引入复杂框架。

这里给一个对比表格,方便你以后快速决策:

维度短轮询长轮询SSEWebSocket
通信方向客户端主动请求请求挂起后被动响应服务端单向推送双向实时通信
实时性取决于轮询间隔毫秒级毫秒级毫秒级
浏览器兼容性全部全部现代浏览器现代浏览器
服务端资源占用高,无效请求多中,挂起连接占资源
实现复杂度低到中中到高
典型场景非敏感状态查询低版本兼容环境通知、行情、日志聊天、协作

选型这块我踩过一个很深的坑:有一个内部项目要用实时推送做数据看板,我上来就上了 WebSocket,后来发现所有页面都只需要收数据,没有一次需要往服务端发消息。用 WebSocket 白白维护了一套连接管理、心跳、断线重连的代码,后来重构换成 SSE,代码少了将近一半,稳定性反而更高。所以选型的第一步永远是问自己:这个场景,数据流向到底是不是双向的。

2. 案例一:WebSocket 实现网页版聊天室

2.1 依赖与基础配置

先看一个最常见的场景:网页版聊天室。用户进入页面,能看到其他人上线、发送的消息,自己也能发消息,所有人即时看到。这类场景必须用 WebSocket,因为每一次“发送”和“接收”都是双向的数据流动,SSE 只支持单向,根本做不到。

Spring Boot 使用 WebSocket 非常方便,引入一个 starter 就带齐了服务端和客户端的支持。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>

然后实现一个配置类,把 WebSocket 的处理器注册到指定路径上。

@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new ChatWebSocketHandler(), "/chat") .setAllowedOrigins("*"); } }

这里有个细节:setAllowedOrigins("*")在前后端分离的项目里经常是必须的。如果不设置,跨域请求会被浏览器拦截,前端一直报连接失败但后端日志里又看不到明显报错。生产环境不建议直接放开*,最好配置成具体的线上域名列表,避免被恶意站点拉一条 WebSocket 连接做坏事。

Spring 的 WebSocket 支持两种写法。一种是上面这种面向 Handler 的风格,也是 Spring 官方主推、嵌入 WebSocket 原生 API 最少的方式。另一种是使用@ServerEndpoint注解,需要额外注入一个ServerEndpointExporter的 Bean,写起来更像传统 Java WebSocket 的写法。如果你的项目里没有历史包袱,我建议用第一种,因为 Spring 封装得更彻底,和 Spring Security、Spring MVC 的集成也更好做。

2.2 服务端消息处理器

聊天室的核心逻辑都在TextWebSocketHandler的子类里。我把完整实现贴出来,注释里写清楚了每个方法的作用。

@Slf4j public class ChatWebSocketHandler extends TextWebSocketHandler { // 使用 ConcurrentHashMap 保存所有在线会话 private static final Map<String, WebSocketSession> SESSIONS = new ConcurrentHashMap<>(); /** * 用户建立连接后,把他加到在线列表,并广播上线通知 */ @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { SESSIONS.put(session.getId(), session); log.info("用户 {} 加入聊天室,当前在线 {}", session.getId(), SESSIONS.size()); session.sendMessage(new TextMessage("欢迎加入聊天室,当前在线人数:" + SESSIONS.size())); broadcast("用户 " + session.getId() + " 加入了聊天室"); } /** * 收到用户消息后,做简单加工再广播给所有人 */ @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); log.info("收到来自 {} 的消息:{}", session.getId(), payload); broadcast("[" + session.getId() + "] " + payload); } /** * 连接关闭后,移除会话并广播离开通知 */ @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { SESSIONS.remove(session.getId()); log.info("用户 {} 离开聊天室,当前在线 {}", session.getId(), SESSIONS.size()); broadcast("用户 " + session.getId() + " 离开了聊天室"); } /** * 传输异常时,关闭连接并清理会话 */ @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { log.error("WebSocket 传输异常,sessionId={}", session.getId(), exception); SESSIONS.remove(session.getId()); if (session.isOpen()) { session.close(CloseStatus.SERVER_ERROR); } } private void broadcast(String content) { TextMessage message = new TextMessage(content); SESSIONS.values().forEach(session -> { try { if (session.isOpen()) { sendMessageSafely(session, message); } } catch (Exception e) { log.error("广播消息失败,sessionId={}", session.getId(), e); } }); } }

这里非常容易踩一个并发坑:多个消息同时要发给同一个WebSocketSession时,会抛出The remote endpoint was in state [TEXT_FULL_WRITING]。原因很简单,长连接上没有消息锁,两个线程同时往同一个连接写数据,底层 Channel 就乱套了。生产中不要直接调用session.sendMessage,我会给 session 包一层ConcurrentWebSocketSessionDecorator,或者像上面代码一样,在发送消息时加一个synchronized (session)。前者是 Spring 官方提供的线程安全会话包装器,建议优先用这个:

WebSocketSession safeSession = new ConcurrentWebSocketSessionDecorator(session, 10000, 64 * 1024);

第二个参数是发送时阻塞的超时时间,10 秒;第三个参数是缓冲区上限,64KB。超过限制时连接会被强制关闭,避免内存被慢消费者拖垮。

2.3 前端实现与连接管理

浏览器端的 WebSocket 客户端代码不复杂,但断线重连的逻辑必须写。项目上线后最常见的投诉就是“页面放着不动,过一会儿消息就不来了”,八成是连接被服务端或中间网络设备悄悄断掉,而前端没有重连机制。

<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <title>聊天室</title> </head> <body> <h3>Spring Boot WebSocket 聊天室</h3> <div id="chatBox" style="height: 300px; overflow-y: auto; border: 1px solid #ccc; padding: 10px;"></div> <input id="msgInput" type="text" style="width: 300px;" placeholder="输入消息后回车发送"> <button onclick="sendMessage()">发送</button> <script> let ws; function connect() { ws = new WebSocket('ws://' + location.host + '/chat'); ws.onopen = function () { console.log('WebSocket 连接已建立'); }; ws.onmessage = function (event) { appendLine(event.data); }; ws.onclose = function () { console.log('连接已关闭,3 秒后重连'); setTimeout(connect, 3000); }; ws.onerror = function (e) { console.error('WebSocket 错误', e); // onerror 之后一般会触发 onclose,所以这里不用重复重连 }; } function appendLine(text) { const div = document.createElement('div'); div.textContent = text; document.getElementById('chatBox').appendChild(div); } function sendMessage() { const input = document.getElementById('msgInput'); if (input.value && ws && ws.readyState === WebSocket.OPEN) { ws.send(input.value); input.value = ''; } } connect(); </script> </body> </html>

实际项目中,断线重连建议加上指数退避策略:第一次重连等 1 秒,失败后等 2 秒、4 秒、8 秒,再往上封顶到 30 秒或 60 秒。否则服务端发布重启时,几百个客户端同时疯狂重连,会直接把服务端打挂。另外,如果连接经过了 Nginx,记得服务端和客户端都要有心跳机制保活,这部分我在第 5 节里详细展开。

3. 案例二:SSE 实现服务端主动通知

3.1 为什么这里选 SSE 而不是 WebSocket

第二个案例场景是消息中心。用户登录系统后,如果被分配了新任务、收到新的审批单,页面顶部的红点要立刻亮起来。这类场景的特点是:数据只从服务端流向客户端,客户端不需要给服务端回任何实时消息,而且连接的并发量可能很大。

这个场景如果上 WebSocket,等于杀鸡用牛刀。SSE 的优势非常明显:

  • 协议就是普通 HTTP,服务端实现简单,前端一个EventSource对象就能订阅。
  • 自带断线重连功能。EventSource 连接断开后,浏览器会自动重连,你不用像 WebSocket 那样自己写重连逻辑。
  • 支持自定义事件名,也支持使用事件 ID 做断点续传。复杂消息场景下很好用。
  • 服务端资源占用比 WebSocket 管理一堆 session 更轻。

当然,SSE 的局限也必须说清楚。它是单向的,客户端不能通过同一个连接往服务端发消息;EventSource 只支持 GET 请求,也不能自定义请求头,所以鉴权信息一般只能放在 URL 参数或者 Cookie 里。另外,HTTP/1.1 下浏览器对同一域名的连接数有限制,一般最多开 6 个 SSE 连接,如果一个页面同时订阅多路事件流,要注意控制数量。

3.2 SseEmitter 核心用法

Spring MVC 中 SSE 的核心类是SseEmitter。控制器收到订阅请求后,创建一个 SseEmitter 返回给 Spring 框架,这个连接就会被保持住,后续任意线程都可以调用emitter.send()把数据写回客户端。

先看订阅端口的实现:

@Slf4j @RestController @RequestMapping("/api/notify") public class NotifyController { // userId -> SseEmitter,维护每个用户当前的订阅连接 private final Map<String, SseEmitter> emitterMap = new ConcurrentHashMap<>(); /** * 用户订阅消息通知流 */ @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter subscribe(@RequestParam String userId) { // 这里设置 60 秒超时只是为了演示,真实场景可设为 0L 表示不超时 SseEmitter emitter = new SseEmitter(60_000L); emitterMap.put(userId, emitter); emitter.onCompletion(() -> { log.info("SSE 连接正常结束,移除 userId={}", userId); emitterMap.remove(userId); }); emitter.onTimeout(() -> { log.info("SSE 连接超时,移除 userId={}", userId); emitter.complete(); emitterMap.remove(userId); }); emitter.onError(e -> { log.error("SSE 连接异常,移除 userId={}", userId, e); emitterMap.remove(userId); }); return emitter; } /** * 给指定用户推送一条消息,模拟业务触发 */ @PostMapping("/send") public void send(@RequestParam String userId, @RequestBody String message) { SseEmitter emitter = emitterMap.get(userId); if (emitter == null) { log.warn("用户 {} 未建立 SSE 连接", userId); return; } try { emitter.send(SseEmitter.event() .id(UUID.randomUUID().toString()) .name("message") .data(message)); } catch (IOException e) { log.error("向用户 {} 推送消息失败", userId, e); emitterMap.remove(userId); } } }

需要注意几个核心点。

SseEmitter的构造参数是超时时间,单位毫秒。传0L表示永不超时,但生产环境不建议一刀切设成 0。如果客户端异常断开而服务端没有及时发现,emitter 会一直留在emitterMap里,造成连接和内存泄漏。我更推荐的做法是设置一个合理超时时间,然后配合心跳机制探测死连接。比如设定 60 秒,业务数据超过 60 秒没有推送时,就发送一条注释行: heartbeat来维持连接,同时也可以验证客户端是否还活着。

SseEmitter.event()返回一个SseEventBuilder,可以连缀设置事件 ID、事件名、数据内容。.id()是可选但推荐使用的,浏览器重连时会自动把 Last-Event-ID 带到服务器,服务端接收后可以决定从哪个消息开始续推。.name()指定事件名,前端用addEventListener('message')监听的就是这个名字;如果没设置 name,默认走onmessage

最后,emitter.send()之后不需要也不能手动关闭连接。连接的生命周期由服务端控制,如果想主动结束推送,调用emitter.complete(),客户端会收到正常的流结束信号。

在真实项目里,emitterMap不建议像我示例里这么简单粗暴地使用。多端登录同一用户会产生多条连接,更合理的维护结构应该是Map<String, List<SseEmitter>>,推送时遍历这用户的全部分组设备。如果你有 Redis Pub/Sub 或者 Kafka 在系统里,推送接口也经常是消费消息队列里的消息再调用emitter.send(),这样集群中的任意节点都能把消息推给连接到其他节点的客户端。

3.3 客户端 EventSource 接入

前端用 EventSource 非常省心,浏览器内置,不需要额外引入库。

const source = new EventSource('/api/notify/subscribe?userId=10001'); source.addEventListener('open', function () { console.log('SSE 连接已打开'); }); source.addEventListener('message', function (event) { console.log('收到消息:', event.data); // 更新页面上的红点或通知列表 }); source.addEventListener('error', function (e) { if (e.eventPhase === EventSource.CLOSED) { console.log('SSE 连接关闭,浏览器会自动重连'); } });

EventSource 的自动重连是浏览器帮我们做好的,重连间隔默认几秒,可以通过服务器返回的retry:字段调整。如果需要带 Cookie 或者 URL 参数做鉴权,直接在拼接 URL 时加参数就行,比如上面的userId=10001

用 curl 调试 SSE 接口很方便,能直接看到服务端输出的流式内容:

curl -N http://localhost:8080/api/notify/subscribe?userId=10001

-N参数表示禁用缓冲,让 curl 一条条实时打印服务端返回的数据。这种调试方式在排查“到底服务端有没有推送”的问题时特别高效。

4. 案例三:长轮询实现任务状态查询

4.1 长轮询的工作流程

第三个案例回到日常开发里最烦人的问题:后台任务执行状态查询。用户提交一个 Excel 导出请求,或者触发一个数据清洗任务,服务端执行可能要花十几秒甚至更久。这种场景如果用短轮询,每 3 秒问一次“好了吗”,服务端接口压力不小;用 WebSocket 又显得大材小用,尤其是不需要跟客户端有任何后续交互。长轮询是这里的折中方案:客户端发起请求,服务端把请求挂起,任务完成或超时后再返回结果,客户端拿到结果处理后立刻发起下一次请求。

长轮询的核心思想是“挂了等消息”,而不是“定时问一次”。这样做的好处是,任务完成和客户端感知到结果之间的延迟被压缩到极小,请求次数也降到了最低。

4.2 DeferredResult 挂起请求

Spring MVC 的异步功能里,DeferredResult就是为这种场景准备的。它的工作方式很巧妙:控制器直接返回一个DeferredResult,Servlet 容器立刻释放当前请求线程回去处理其他请求,等业务线程在后台把结果算好,调用setResult()时,容器再把结果写回客户端。整个挂起过程不占用 Tomcat 工作线程,这是它和Thread.sleep阻塞线程最大的区别。

@Slf4j @RestController @RequestMapping("/api/task") public class TaskController { private final Map<String, DeferredResult<String>> requestMap = new ConcurrentHashMap<>(); private final Map<String, String> taskStatusMap = new ConcurrentHashMap<>(); /** * 客户端长轮询任务状态 */ @GetMapping("/v1/status/{taskId}") public DeferredResult<String> getStatus(@PathVariable String taskId) { // 先查一次内存状态,如果已经完成了,直接返回结果 String currentStatus = taskStatusMap.get(taskId); if (currentStatus != null && !"RUNNING".equals(currentStatus)) { return immediateResult("{\"status\":\"" + currentStatus + "\"}"); } // 挂起 30 秒,超时返回 RUNNING,客户端收到后会立刻发起下一次请求 DeferredResult<String> deferredResult = new DeferredResult<>(30_000L, "{\"status\":\"RUNNING\"}"); requestMap.put(taskId, deferredResult); deferredResult.onCompletion(() -> { log.info("taskId={} 的请求处理完成", taskId); requestMap.remove(taskId); }); deferredResult.onTimeout(() -> { log.warn("taskId={} 的请求等待超时,返回 RUNNING", taskId); requestMap.remove(taskId); }); return deferredResult; } /** * 业务代码在后台任务执行完毕后调用,唤醒等待的请求 */ public void completeTask(String taskId, String finalStatus) { taskStatusMap.put(taskId, finalStatus); DeferredResult<String> deferredResult = requestMap.get(taskId); if (deferredResult != null) { deferredResult.setResult("{\"status\":\"" + finalStatus + "\"}"); } } private DeferredResult<String> immediateResult(String json) { DeferredResult<String> result = new DeferredResult<>(); result.setResult(json); return result; } }

这里有几个实现细节要特别留意。

超时时间的设置。我一般选 30 秒,原因有两个:一是大多数后端任务在 30 秒内都应该有中间状态或最终状态可返回;二是即使请求超时返回,客户端立刻重新发起,体验上几乎无感。如果超时时间设到 60 秒以上,客户端网络层可能先超时断开,反而容易出问题。

DeferredResult的构造器第二参数是超时后的默认返回值。当请求挂起达到 30 秒时,Spring 会把默认值写回客户端,同时触发onTimeout回调。注意,超时后任务如果才完成,setResult()会抛出IllegalStateException,业务代码里要捕获这个异常,否则会把异常顶到调用链上。

直接返回"RUNNING"的设计是长轮询的精髓。客户端拿到 RUNNING 就知道任务还没完成,马上发起下一轮请求;服务端拿到同一任务的新请求时,会再次挂起等待。这样任务完成和客户端感知之间的时间差,最多就是一次网络 round-trip 的时间,远比短轮询的固定间隔实时。

4.3 前端不断重新请求

前端的长轮询代码比服务端看着更简单,核心思想就是一个递归链式的 fetch:

async function pollTaskStatus(taskId) { try { const resp = await fetch(`/api/task/v1/status/${taskId}`); const data = await resp.json(); if (data.status === 'SUCCESS') { console.log('任务已完成'); renderResult(data); return; } // 拿到 RUNNING,立刻发起下一轮请求 pollTaskStatus(taskId); } catch (e) { // 网络异常或接口超时,延迟 1 秒后重新发起 setTimeout(() => pollTaskStatus(taskId), 1000); } }

这里注意,前端请求的默认超时时间可能跟服务端的 30 秒对不上。比如浏览器、axios 默认超时只有 10 秒的话,服务端还挂着呢,客户端这边早就报错了。所以使用长轮询时,要么把客户端请求超时时间设置得比服务端DeferredResult超时时间长一点,要么在 catch 分支里做重试。建议两种都做:显式设置客户端 timeout 为 35 秒,catch 分支继续重试,双保险。

5. 实战踩坑记录与排查技巧

5.1 WebSocket 连接断开与心跳机制

WebSocket 最典型的坑就是连接“悄悄断掉”。客户端网络切换、服务器重启、中间防火墙清理空闲连接,都会导致连接断开。但断开这件事不一定能被双方感知到,有时候会一直保持一个半开连接状态,服务端往这个连接上写数据才发现已经写不通了。

解决方法是加心跳。服务端每隔 30 秒发一次 ping 帧或业务层心跳消息,客户端收到后回一个 pong 帧,或者客户端自己定期发一个{"type":"heartbeat"}的空消息。如果连续几次心跳没有收到对方的回应,就主动断开重连。Spring 里没有开箱即用的心跳定时器,你可以自己注入一个TaskScheduler,在afterConnectionEstablished时给每个 session 安排一个定时任务,向连接写入心跳消息,同时在消息处理器里过滤掉心跳类型的数据,不要把它当业务消息广播。

还有注意 Nginx 对 WebSocket 的空闲超时。默认配置下,Nginx 大约 60 秒没有数据传输就会断开连接。所以不要只靠应用层心跳,Nginx 的proxy_read_timeout也要同步调大,比如设成 75 秒,比心跳间隔大就行。

5.2 Nginx 代理与集群部署的坑

WebSocket 走 Nginx 代理时,第一件事就是配置升级头和读写超时。完整配置如下:

location /chat { proxy_pass http://backend_servers; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 75s; proxy_send_timeout 75s; }

重点是UpgradeConnection "upgrade"两行,没有它们后端拿到的还是普通 HTTP 请求,WebSocket 握手会一直失败。

SSE 走 Nginx 时坑又不一样。Nginx 默认开了缓冲,会把后端推送的数据攒一批才转发给客户端,结果前端一看,数据半天才冒出来一次,实时性全没了。解决方法是关闭该路径的缓冲:

location /api/notify { proxy_pass http://backend_servers; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; }

或者在后端响应头里加X-Accel-Buffering: no,这是 Nginx 专门用来控制是否缓冲响应体的头,Spring 可以放在拦截器或过滤器里统一添加。

如果服务端是集群部署,WebSocket 和 SSE 都要考虑连接粘滞。用户第一次连到 A 节点,下一次请求被负载均衡到 B 节点,B 节点没有他的连接,推送就丢了。解决思路有两个:一是用 Redis Pub/Sub 或消息中间件广播推送事件,所有节点都消费,再查询本地连接是否存在;二是用 Nginxip_hash做简单的会话保持,但从运维角度,长期维护还是前者更稳。

5.3 SSE 与长轮询的隐藏坑

SSE 使用中我踩过最深的一次坑是连接数打满。线上有个页面同时打开了六个 SSE 订阅,用户多开几个标签页,浏览器直接没法访问该域名的其他资源了。这是因为 HTTP/1.1 对同一域名的连接数限制是 6 个,SSE 连接本身一直不释放,把连接池占满了。要彻底解决,要么上 HTTP/2(多路复用),要么减少 SSE 连接数,把一批推送合并到一个事件流里,用事件名区分消息类型。

SSE 的连接泄漏也容易发生:客户端页面直接关闭,浏览器不会通知服务端,服务端那条 SSE 连接还一直开着,直到下一次 send 时才可能抛 IOException。这就是为什么生产环境一定要配合心跳写探测消息。我习惯每 30 秒用 SseEmitter 发送一行注释": heartbeat",一旦发现抛出 IOException,立刻调用onError里的清理逻辑移除连接。

长轮询的坑主要在并发和状态一致性。如果同一任务的多个请求同时挂起,任务完成时setResult只能触发一次,其他请求会一直挂到超时才返回 RUNNING,客户端就会多做几轮空轮询。因此服务端最好维护“一个人只挂一个请求”的策略,比如用putIfAbsent代替put,或者把旧请求的setResult提前触发。另一个坑是任务执行完毕后,如果服务端把taskStatusMap当成临时缓存而不清理,时间长了内存会一直涨。我给这个 Map 设置了定时清理任务,启动一个延迟线程,定期扫描超过一天没有更新的键值对。

调试方面再分享几个小技巧:SSE 用curl -N能流式看数据;WebSocket 直接浏览器控制台new WebSocket('ws://localhost:8080/chat')就能手动测试交互;长轮询用curl -v能看到请求一直挂住、直到服务端返回响应的时间线,一次就能判断服务端的挂起时间是否按预期工作。

最后再分享一点我的个人体会。实时推送方案没有绝对的好坏,最重要的是匹配场景。如果需求只是“服务端有数据变了,页面要及时刷新”,SSE 往往是最舒服的;如果要求双向交互,只能 WebSocket;如果被老旧浏览器和网络环境卡住了,长轮询也能体面地兜底。动手写代码之前,先把数据流方向、并发量、浏览器环境、中间代理这几件事想清楚,方案自然就浮出来了。我在实际项目里见过太多一上来就上 WebSocket,结果连需求都说不清楚的情况,做技术还是要克制一点,能用简单方案解决的,就不要为了技术而技术。

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

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

立即咨询