SpringBoot接入SSE:从轮询到服务端消息推送的实战手册

📅 发布时间:2026/9/11 2:25:40
SpringBoot接入SSE:从轮询到服务端消息推送的实战手册
我先讲个最近遇到的真实情况。线上有个工单监控系统前端每隔5秒就向后端请求一次最新状态高峰期超过300个用户同时打开页面服务器被这种纯查询请求打得差点喘不过气。我去后台翻了一遍日志发现真正状态有变化的消息连5%都不到其余全是重复的无效轮询。后来我把这套逻辑改造为SSE消息推送服务端压力直接降了一个数量级前端代码反而比原来更简单。这篇文章就把SpringBoot接入SSE的完整过程和踩过的坑一次性讲透从协议原理、基础接口到生产环境会遇到的各种问题按真实项目的落地顺序走一遍。如果你现在正在为“页面要实时刷新但轮询又太浪费”这类需求发愁这篇内容应该能帮你省掉不少弯路。它既适合刚接触SSE的后端开发也适合已经接入SSE但在线上碰到断连、超时、多实例广播问题的同学参考。1. 轮询为什么让人又爱又恨先看清旧方案的真实成本1.1 轮询的业务场景与表面优势早期的实时消息推送大多数团队的第一选择都是轮询。原因很简单后端只需要写一个普通查询接口前端在页面里加个定时器每隔几秒请求一次拿到最新数据就刷新视图。代码量少、逻辑直观、出了问题也好排查尤其在并发不高的管理后台里一个轮询接口跑个一两年都不见得会出事。像订单状态确认、任务执行进度、运维告警通知这类场景第一版大多都是这么做的。开发效率确实高产品验收时只要把轮询间隔调小一点演示效果看起来也很“实时”。团队规模小、用户量可控的时候轮询作为保底方案几乎是无痛的。但问题在于轮询这套架构天然存在一个资源利用率的矛盾你发出的每个请求即使业务数据毫无变化服务端也要完整走一遍请求解析、参数校验、权限检查、SQL查询、结果序列化、网络传输的链路。消息变更越稀疏、用户规模越大这个浪费就越明显。1.2 从一次线上事故看轮询的成本我之前维护过一个工单状态页前端轮询间隔设为5秒高峰期在线约300人。表面上看每秒60次请求的量级并不吓人但状态页背后关联了工单详情、操作记录、处理人信息等多张表每一次轮询都会触发一次多表关联查询。实际计算下来每秒至少有60次数据库查询打到连接池再叠加系统的其他业务请求连接池很快被打满接口超时率直线上升。更要命的是300个在线用户里可能280个人屏幕上显示的内容根本没有变化这批请求等于白白消耗了服务端资源。轮询间隔的设定也是一个两难问题调小间隔实时性上去了压力成倍增长调大间隔服务器轻松了但用户会明显感觉消息不够快。高并发时前端定时器一旦出现叠加轮询请求还会像“雪崩”一样集中打到后台。提示轮询本身不是错误它只是不适合“消息变更频率低、用户规模大”的推送场景。判断要不要换方案不能只看代码写起来是不是顺手要看单位时间里真正有数据传输的请求占比有多低。从那次事故之后我逐步把这类场景替换成了SSE推送实测下来服务端压力下降非常明显。这也是这篇文章想展开的核心同样实现“服务端到客户端的消息推送”SSE可以用更小的代价拿到更实时、更可控的效果。2. SSE到底是什么一个基于HTTP的单向推送协议2.1 协议原理一次握手持续输出SSE的完整名称是Server-Sent Events即服务端推送事件。它没有发明新的传输协议而是完全基于HTTP长连接。客户端发起一个普通GET请求服务端在响应头里把Content-Type设为text/event-stream随后连接保持打开服务端可以持续向客户端写入文本格式的事件数据。一个标准的SSE响应体看起来是这样的event: message data: 这是一条测试消息 event: connected data: 连接成功 : heartbeat每组事件之间用空行分隔event字段表示事件名称data字段是实际内容。浏览器端的EventSource对象会自动解析这个格式并按event名称触发对应的事件回调。如果连接意外断开浏览器会默认自动重新发起请求并向服务端携带Last-Event-ID字段方便服务端补发断线期间漏掉的消息。SSE协议本身非常轻量因为它就是纯文本格式的HTTP响应不需要额外的握手协议、帧解析、二进制编解码。对于“服务端单方面向客户端推送数据”这类需求SSE几乎是为这个场景量身定制的。2.2 SSE、WebSocket、轮询三者的对比很多初学者一听到“实时推送”就直接想到WebSocket但实际选型时三者的定位差异很大。我把它们放在一张表里对比维度轮询SSEWebSocket数据传输方向客户端请求后获得响应服务端单向推送全双工双向通信底层协议HTTPHTTP需要协议升级到WebSocket断线重连依赖前端代码实现浏览器内置自动重连需要自己实现心跳和重连后端实现复杂度低低较高消息格式任意HTTP响应纯文本事件流文本或二进制帧适用场景低频查询、兼容性要求极高通知、监控大屏、流式输出在线聊天、实时协作需要特别强调的是SSE只支持服务端到客户端的单向推送。如果业务要求客户端也能随时往服务端发消息那应该选择WebSocket。但反过来如果服务端只是需要主动通知前端“有变化了”用WebSocket其实是杀鸡用牛刀你会额外付出心跳包、断线重连、消息帧封装和连接状态管理的成本而SSE把这些绝大部分都交给了浏览器和HTTP协议本身。2.3 怎样判断你的场景该不该用SSE结合我自己的项目经验下面几类场景用SSE会非常顺手通知中心用户登录后需要接收站内信、公告、待办事项提醒。监控大屏大屏页面需要实时展示指标变化但指标频率并不高几秒更新一次足够。工单状态跟踪用户提交工单后页面等待后端流程推进状态变更不频繁但要求即时感知。流式输出应用比如AI对话生成、长任务日志输出服务端把已经生成的内容分块推给前端。消息广播系统向所有在线用户推送版本更新、运营活动、告警信息。如果需求是“在线聊天或多人在线协同编辑”双向通信的WebSocket会更合适。如果消息变更频率极低、用户量也非常小那保留原有轮询也不是不行。选型最重要的标准不是技术栈够不够新而是它和业务模型是否匹配。3. SpringBoot接入SSE从依赖到第一个推送接口3.1 核心类SseEmitter是什么在SpringBoot中使用SSE最核心的类就是SseEmitter。它从Spring MVC 4.2版本开始提供专门用于以异步方式向客户端返回text/event-stream数据。你可以把SseEmitter理解为一个“代表客户端连接的句柄”Controller方法把它作为返回值返回后请求线程就释放了后续任意后台线程都能通过这个句柄向客户端不断写入数据直到连接被主动关闭或超时。使用SseEmitter不需要额外引入第三方依赖SpringBoot Web项目里已经包含。实际开发中只需要关注几个关键点构造函数里的超市时间、发送消息时的事件名称和数据类型以及连接关闭后的资源清理。3.2 从零写一个最简推送接口下面是一个最基础的示例功能很简单客户端通过userId建立订阅连接另一个接口向指定用户推送一条消息。RestController RequestMapping(/api/notify) public class NotifyController { private final MapString, SseEmitter emitterMap new ConcurrentHashMap(); GetMapping(value /subscribe, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter subscribe(RequestParam String userId) { SseEmitter emitter new SseEmitter(60_000L); emitterMap.put(userId, emitter); emitter.onCompletion(() - emitterMap.remove(userId)); emitter.onTimeout(() - emitterMap.remove(userId)); emitter.onError(e - emitterMap.remove(userId)); try { emitter.send(SseEmitter.event() .name(connected) .data(连接成功)); } catch (IOException e) { emitter.completeWithError(e); } return emitter; } PostMapping(/send) public String send(RequestParam String userId, RequestBody String message) { SseEmitter emitter emitterMap.get(userId); if (emitter null) { return offline; } try { emitter.send(SseEmitter.event() .name(message) .data(message)); return ok; } catch (IOException e) { emitterMap.remove(userId); return send_error; } } }注意produces MediaType.TEXT_EVENT_STREAM_VALUE这个必须设置否则响应会被当成普通JSON前端EventSource无法识别为事件流。SseEmitter.event()返回一个事件构造器通过.name()设置事件名.data()设置事件内容最终由emitter.send()发送出去。前端接收的方式也很简单const eventSource new EventSource(/api/notify/subscribe?userId1001); eventSource.addEventListener(connected, (event) { console.log(连接建立:, event.data); }); eventSource.addEventListener(message, (event) { console.log(收到消息:, event.data); });浏览器会在EventSource内部自动维护连接状态收到不同类型的事件后触发对应的回调。当连接因网络异常断开时浏览器会默认自动重连不需要前端写任何重连逻辑。3.3 异步线程模型千万别在请求线程里阻塞SseEmitter返回后Spring会把请求切换到异步线程池来管理连接生命周期。这时有一个很容易踩的坑不能在Controller方法所在的主线程里同步调用耗时操作等待数据因为主线程一旦阻塞后续请求的处理能力会直线下降最终拖垮应用。正确的做法是把发送操作放到独立线程池、定时任务或者消息监听器里执行。高并发场景下建议为SSE模块单独配置一个带名称的线程池避免和业务线程池互相影响。压测下来比较稳妥的配置是核心线程数8~16最大线程数32队列容量根据连接数和消息频率来定。4. 面向生产环境的SSE超时、鉴权、断线重连与集群广播4.1 超时与IdleTimeout报错到底在说什么生产环境中SSE连接最常见的报错信息是before completion: idle timeout waiting for sse。这个提示来自Tomcat或其它Servlet容器意思是连接在一段时间内没有进行任何读写操作触发了空闲超时容器主动关闭了连接。SSE连接在消息不多的业务里长时间没有数据发送是很正常的。如果不做处理默认30秒后就会被容器断开。解决办法有两个层面第一将SseEmitter的超时时间设大一些。构造函数传入0L表示永不超时但我个人不建议这么设置万一程序里有泄漏连接就永远不会释放了。更稳妥的是设置一个合理值比如30分钟或60分钟配合下面的心跳机制来维持连接活跃状态。第二增加心跳发送。每隔一段时间向客户端发送一个SSE注释行比如SseEmitter emitter new SseEmitter(30 * 60 * 1000L); Scheduled(fixedRate 30_000) public void heartbeat() { emitterMap.forEach((userId, emitter) - { try { emitter.send(SseEmitter.event().comment(heartbeat)); } catch (Exception e) { emitterMap.remove(userId, emitter); } }); }SSE规范里以冒号开头的行是注释内容浏览器会自动忽略不会触发任何事件回调。但对于服务端路由器、负载均衡器和Servlet容器来说这是一个有效的数据包可以让它们认为连接仍然活跃从而避免触发空闲超时。心跳间隔我一般设置为30秒左右太频繁会造成无谓的网络开销太稀疏又可能在其他代理层超时之前来不及兜底。4.2 EventSource的鉴权与参数传递EventSource有一个先天限制它只能用GET请求建立连接而且没法像fetch那样自定义Header。实际项目中token通常放在请求头的Authorization字段里这就产生了一个矛盾SSE建立连接时带不上Header鉴权怎么做目前项目里比较通用的做法有三种把token作为URL的query参数传递例如/api/notify/subscribe?userId1001tokenxxx。这种方式最简单但token会暴露在网关日志里安全性要求高的系统不建议直接这么干。通过一个独立的登录或预授权接口换取一次性ticket然后用这个ticket建立SSE连接服务端校验通过后再把ticket标记为已使用。不走EventSource改用fetch加ReadableStream手动解析SSE格式。这种方式支持自定义Header也能拿到更精细的连接状态但你需要自己实现断线重连和事件流解析逻辑代码量会大不少。从我的实际经验看大多数内部系统直接用第一种或者第二种方案就够了。如果token本身是短期有效的JWT放在query参数里其实风险可控前提是确保HTTPS和严格的日志脱敏。4.3 多实例部署下的广播Redis发布订阅SSE连接有一个容易被忽略的特点连接是绑定在服务端单台节点上的。用户A连上了实例1实例2想要给用户A推送消息但实例2的本地emitterMap里根本找不到用户A。线上服务通常都是多实例部署这个问题不解决SSE上线就会遇到“消息发不出去”的诡异情况。常规解法是引入Redis发布订阅。所有实例订阅同一个频道当某个实例需要推送消息时先把消息发布到Redis频道各实例收到消息后再把自己本地保存的SseEmitter逐个推送。核心代码大致是这样的Configuration public class RedisPubSubConfig { Bean public RedisMessageListenerContainer container(RedisConnectionFactory factory, MessageListener listener) { RedisMessageListenerContainer container new RedisMessageListenerContainer(); container.setConnectionFactory(factory); container.addMessageListener(listener, new ChannelTopic(sse:notify)); return container; } Bean public MessageListener sseMessageListener(SseEmitterRegistry registry) { return (message, pattern) - { String payload new String(message.getBody(), StandardCharsets.UTF_8); registry.broadcast(payload); }; } }SseEmitterRegistry实际上就是对本地MapString, SseEmitter的封装广播时遍历Map发送消息。这里有两个细节需要注意一是遍历时捕获每个send的异常避免单个连接异常影响其他正常连接二是注册表里的连接要严格执行清理策略否则断开的连接会一直留在Map里时间长了必然内存泄漏。提示如果只是单实例部署不引入Redis也能正常跑。但系统一旦准备横向扩容SSE的注册表广播问题就必须提前设计好否则后续改造代价会很高。5. 线上排障实录这些坑我真金白银踩过5.1 连接总是被Nginx代理断开SSE上线后第一周前端反馈消息推送经常过几分钟就断了刷新页面后又恢复。排查到最后问题并不在SpringBoot而是Nginx默认对HTTP响应启用了缓冲。Nginx会等后端把响应数据攒到一定量再一次性转发给客户端对于普通接口没问题但对于SSE这种需要即时推送的长连接缓冲会把实时性彻底破坏甚至导致连接被误判为闲置而断开。解决方式是在Nginx的location里加上下面这些配置location /api/notify/ { proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_http_version 1.1; proxy_set_header Connection ; }proxy_buffering off是关键关闭缓冲后数据能实时转发到客户端。proxy_read_timeout要设置得比后端心跳间隔大否则长时间没有数据时Nginx会主动掐断连接。有些场景里后端不方便改Nginx配置也可以通过设置响应头X-Accel-Buffering: no来让Nginx对单个接口关闭缓冲这个方式在实测中同样有效。5.2 连接堆积导致线程和内存双双上升SSE服务运行几天后我遇到了另一个更隐蔽的问题线程池活跃线程数持续上升内存占用也在缓慢增长。排查后发现是在客户端直接关闭页面的情况下服务端并没有立刻感知连接断开。TCP连接只有在真正发送数据失败时才会抛异常如果一直不发送这条“僵尸连接”就会一直留在注册表里。要彻底解决这个问题必须建立一套清理机制在onCompletion、onTimeout、onError回调中统一执行注册表移除操作心跳定时发送时如果捕获到异常则立即移除对应连接定期检查emitterMap的大小如果连接数远超在线用户数说明清理逻辑有遗漏需要结合日志进一步排查。注册表推荐使用ConcurrentHashMap删除操作使用remove(key, value)方法避免并发情况下误删掉重连后新的连接对象。5.3 前端自动重连后引发消息风暴EventSource的自动重连机制本来是个好事但如果没有考虑重连后的消息补偿会引发另一个问题。客户端断线重连成功后服务端如果把断线期间积蓄的历史数据全部补发一遍前端可能会短时间收到大量重复消息页面可能出现抖动或多次弹窗。应对思路是在协议层面做好消息ID和增量的处理。服务端在发送每条业务消息时带上.id()字段客户端重连时浏览器会自动发送Last-Event-ID服务端根据这个ID找到断点位置只补发断线之后的新消息。业务侧对处理逻辑做好幂等即使极端情况下有重复消息也不会影响最终数据。5.4 跨域导致EventSource一直不触发前端页面和后端SSE接口如果部署在不同域名下就会遇到浏览器跨域拦截的问题。EventSource和普通XHR一样受同源策略限制服务端必须显式允许跨域。在SpringBoot里可以通过全局CORS配置来解决Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(https://your-frontend.com) .allowedMethods(GET, POST); } }配置完成后前端EventSource能正常建立连接否则控制台通常只会出现跨域错误后台也不会收到任何订阅请求。调试时建议优先打开浏览器Network面板确认SSE请求是否返回了200 text/event-stream再往下判断到底是网络层、代理层还是应用层的问题。最后聊点实际的过去这段时间我陆续在几个项目里接入了SSE最大的感受是它对后端开发来说非常友好不需要引入额外的中间件不需要处理复杂协议却能解决掉轮询带来的大部分资源浪费。和WebSocket相比SSE可能显得“不够高级”但技术选型里真正重要的永远是匹配需求而不是浮于表面的技术热度。如果你打算在下一个项目里尝试SSE我建议从一个小模块开始改造比如通知中心或状态跟踪页。前端用EventSource搭起来的速度非常快后端只需要一个SseEmitter接口把它和现有的消息队列、Redis发布订阅串起来整个过程基本没有想象中那么重的负担。项目稳定运行后再逐步扩大使用范围比如在AI对话场景里做流式输出你会发现这套模式还能延伸到更远的地方。