SSE实时推送实战:Spring Boot实现与Nginx避坑全指南

发布时间:2026/10/13 7:38:48
SSE实时推送实战:Spring Boot实现与Nginx避坑全指南 SSE这技术在圈子里其实有点微妙——你说它新鲜吧它早在HTML5时代就被定下来了到现在得有十多年你说它老套吧这两年大模型服务端流式输出火了之后又有不少人重新翻它的牌子。在Spring Boot里做实时数据推送很多人第一反应是上WebSocket但真到落地的时候Server-Sent EventsSSE这种基于普通HTTP的单向推送方案反而更省事尤其在服务器主动往客户端推数据、客户端基本不做上行交互的场景里优势非常明显。这篇教程就把SSE从原理到实战讲透为什么有轮询和WebSocket还要选它、Spring Boot里几种实现方式怎么选、一个完整的实时推送Demo怎么写最后再把生产环境里Nginx缓冲、连接数限制、断线重连这些坑挨个排一遍。适合已经会用Spring Boot写接口、但没怎么碰过服务端推送的开发者看完可以直接抄代码落地。1. 先搞明白SSE到底解决什么问题1.1 从一次实际需求聊起前阵子某平台要做一个活动实时数据大屏需求很明确后端每隔1秒推一次在线人数、参与量、订单金额大屏页面只能被动展示不需要对服务器发指令。团队里有人提议上WebSocket理由很直接实时通信就用WebSocket啊现在不都这么干吗但仔细想一下WebSocket在这个场景里其实有点杀鸡用牛刀。它需要额外参与握手、协议升级前后端都有一套独立的生命周期要维护光是连接状态管理和断线重连逻辑就得写不少代码。而且这个场景是典型的单向推送——数据从服务端流向客户端客户端根本没有持续的上行数据流。这时候SSE的匹配度就非常高了。SSE的全称是Server-Sent Events翻译过来就是服务端发送事件。它标准化了服务器向客户端推送数据的通道客户端只需要创建一个EventSource对象就能持续收到服务器发来的消息。关键是它跑在普通HTTP上不需要专门的服务器支持Spring Boot、Nginx、各种云厂商的负载均衡器都能兼容。1.2 三种常见方案的真实对比这里把轮询、WebSocket、SSE三个方案摆在一起看选型逻辑会清晰很多。维度短轮询WebSocketSSE通信方向客户端请求、服务器响应全双工双向单向只能服务器推协议基础HTTP独立协议基于TCP普通HTTP实时性取决于轮询间隔最高高依赖HTTP长连接断线重连每次请求天然重试需要自己实现浏览器内置自动重连服务器复杂度低高需要处理会话和心跳低一个端点就够浏览器兼容全部现代浏览器现代浏览器基本都支持短轮询最大的问题不是实时性差而是大部分请求都是白跑的。如果数据3秒才变一次客户端每秒轮询一次那2/3的请求连数据都拿不到纯属浪费服务器资源和带宽。WebSocket的复杂度主要藏在细节里连接异常后要自己检测心跳、自己处理重连、自己维护会话状态。我在实际项目里见过不少WebSocket连接池泄漏的事故大多数都是因为连接关闭的事件没处理好。SSE最大的亮点就是这些脏活浏览器都替你干了——断线自动重连重连时自动携带上次接收的最后事件ID服务端拿到这个ID就能接着推不用额外写状态同步逻辑。不过这方案也不是没短板。SSE是单向的客户端想给服务器发消息只能另走普通HTTP接口另外浏览器对单个域名的并发连接数有限制HTTP/1.1时代一个域名最多同时开6个连接多个页面同时挂SSE就可能互相挤占。这些限制后面我都会给出实际的破解办法。2. 动手前需要理解的几个关键概念2.1 事件流格式不只是返回一段文本SSE传输的数据不是JSON也不是纯文本它有一套专门的格式叫事件流event stream。服务器响应的Content-Type必须是text/event-stream消息体用字段: 值的形式组织每条消息之间用空行分隔。最简单的消息大概是这样的data: 这是一条普通消息如果一条数据太长可以拆成多行data字段data: {code: BTC, data: price: 42000}这里有个细节多个data行会被浏览器合并成一个完整的事件数据中间自动加换行符。所以实际传业务数据时我建议直接塞JSON字符串别拆行省得解析时还要处理换行。更完整的SSE消息还可以带上event字段事件类型和id字段事件IDevent: price id: 1713265892000 data: {symbol:BTC,price:42000.12}event的作用是让前端可以用addEventListener按名字监听不同类型的事件而不是只在默认的onmessage里处理。id的作用更关键它参与了断线重连的断点恢复机制后面单独讲。还有一种特殊的消息——注释。以冒号开头的行不会被当成数据只用于心跳: ping浏览器收到注释消息时不会触发任何事件但连接上的数据流动会让代理服务器和浏览器都认为连接还活着这就是最轻量级的保活手段。2.2 内置断线重连机制这是SSE被严重低估的一个特性。我用WebSocket时断线重连的代码得自己写监听close事件、设置重试次数、用指数退避控制间隔、重连后还要自己做数据补发。SSE这一整套全是浏览器内置的。EventSource对象内部有状态机CONNECTING、OPEN、CLOSED。网络断开时它会自动回到CONNECTING状态并尝试重连默认重连间隔大约是3秒。服务器可以通过发送retry:字段来告诉浏览器你等多久再来retry: 5000这个数字的单位是毫秒。重连成功后如果之前收到过带id的消息浏览器会自动在请求头上带一个Last-Event-ID值就是最后一条消息的id。后端拿到这个头就能判断客户端已经收到哪条数据了然后从下一条开始推。这个机制在弱网环境里尤其有用客户端不需要记住任何状态所有断点续传的信息都跟着HTTP请求自然流转。2.3 HTTP连接的边界条件SSE虽然叫Server-Sent Events但底层就是一个普通的HTTP响应只是响应体被拉成了永远不结束的流。这意味着它不如WebSocket那样在协议层面就为长连接做了优化需要靠一些基础设施配合。第一个边界条件是代理服务器的缓冲。Nginx默认会缓冲上游的响应如果没关掉缓冲SSE数据会被攒在Nginx的内存里客户端半天等不到一条。我见过最夸张的情况是服务器每秒推一条消息客户端一分钟才收到一起看起来就像卡死了一样。第二个边界条件是连接超时。Nginx的proxy_read_timeout默认60秒如果发完数据后紧接着60秒内没有新数据流向代理代理就会主动断掉连接。SSE场景里客户端断线重连虽然能自动恢复但如果你的服务端没有做任何心跳注释每次超时都会造成一次无谓的重连开销。第三个边界条件是浏览器连接数限制。Chrome、Firefox对同一个域名的HTTP/1.1长连接默认限制为6个如果你在多个浏览器标签页里都打开了EventSource第7个标签页就会一直处于挂起状态。这个问题的正解是用HTTP/2它支持多路复用共享一条TCP连接就不存在连接数限制问题了。3. Spring Boot里的三种SSE实现方式3.1 最经典SseEmitterSpring MVCSpring MVC从4.2版本开始内置了SseEmitter这是实现SSE最直接的方式。它本质上是一个响应体的异步封装你把SseEmitter对象返回给Spring然后让它在后台线程里随时往里写数据。基本用法是这样的RestController RequestMapping(/api/stream) public class StreamController { private final ExecutorService sseExecutor Executors.newFixedThreadPool(10); GetMapping(value /events, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter events() { SseEmitter emitter new SseEmitter(60_000L); sseExecutor.execute(() - { try { for (int i 0; i 10; i) { MapString, Object payload new HashMap(); payload.put(seq, i); payload.put(time, System.currentTimeMillis()); emitter.send(SseEmitter.event() .name(tick) .id(String.valueOf(System.nanoTime())) .data(payload, MediaType.APPLICATION_JSON)); Thread.sleep(1000); } emitter.complete(); } catch (Exception ex) { emitter.completeWithError(ex); } }); return emitter; } }重点解释一下其中的设计思路。SseEmitter()构造函数传入的是超时时间单位毫秒。如果在这个时间内没有数据发送或者连接超过了Servlet容器的默认超时限制Tomcat默认是30秒连接就会被回收。不想让连接自动断掉有两种处理思路一是把超时设成一个很大的值二是定期发送心跳消息让连接保持活跃。实际生产中推荐后者因为永不超时的长连接在容器层面更容易引发资源问题。SseEmitter.event()能构造一个完整的SSE事件.name()对应事件流里的event:字段.id()对应id:字段.data()就是实际负载。数据对象返回时会被转成JSON前提是项目里已经配置了Jackson。线程这一块要特别上心。上面示例用的是newFixedThreadPool(10)如果在真实业务里每个请求都往里提交一个死循环任务线程池很快就会被占满。我在项目中更推荐用一个有名字前缀的ThreadPoolTaskExecutor并制定一套推送任务失败就掐断连接、不要再重试的策略避免后台线程永远被无效任务占用。3.2 响应式风格WebFlux Flux如果你用的是Spring WebFluxSSE的写法会变得更宣告式——不再手动管理线程而是返回一个反应式数据流让框架自己去背压、去调度。首先需要引入响应式依赖。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency然后控制器可以这样写RestController RequestMapping(/api/stream) public class ReactiveStreamController { GetMapping(value /events, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventMapString, Object events() { return Flux.interval(Duration.ofSeconds(1)) .map(i - { MapString, Object payload new HashMap(); payload.put(seq, i); payload.put(time, System.currentTimeMillis()); return payload; }) .map(payload - ServerSentEvent.MapString, Objectbuilder() .event(tick) .id(String.valueOf(System.nanoTime())) .data(payload) .build()) .take(50); } }Flux.interval会每秒发射一个递增数字然后我们把它映射成ServerSentEvent对象。WebFlux会自动序列化为标准SSE格式并设置好Content-Type: text/event-stream。这里有个比较现实的问题如果你的项目已经基于Spring MVC搭好了只是想用SSE那不太建议为了它的SSE功能单独引入WebFlux因为MVC和WebFlux在Spring Boot里共存会牵涉到大量配置优先级问题容易引发看不懂的冲突。WebFlux版本适合的是新项目直接走响应式路线的场景或者你已经在用WebClient、Reactor做异步数据流了。3.3 底层方案ResponseBodyEmitterResponseBodyEmitter是SseEmitter的父辈也可以实现异步响应但它不会自动设置text/event-stream内容类型也不会帮你格式化SSE消息结构。用它做SSE需要自己写流格式GetMapping(value /raw, produces MediaType.TEXT_EVENT_STREAM_VALUE) public ResponseBodyEmitter raw() { ResponseBodyEmitter emitter new ResponseBodyEmitter(); executor.execute(() - { try { for (int i 0; i 5; i) { emitter.send(data: raw- i \n\n); Thread.sleep(1000); } emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }用ResponseBodyEmitter意味着你完全掌控了消息格式但也意味着你要自己处理换行、转义、心跳这些细节。说实话日常开发没必要自找麻烦除非你在做某些特殊的协议定制。知道有这个东西就行实战中主推SseEmitter。4. 完整实操实现一个实时股票行情推送4.1 后端完整代码场景设为模拟股票行情推送每次推送一个股票代码、最新价格和涨跌幅。为了让代码可以直接跑起来我写一个定时模拟数据源。先定义实体类public record StockPrice( String symbol, double price, double changePercent, long timestamp ) {}再写一个模拟价格的服务类Service public class StockPriceService { private static final MapString, Double BASE_PRICES new HashMap(); static { BASE_PRICES.put(DEMO_AI, 88.50); BASE_PRICES.put(DEMO_NET, 132.00); BASE_PRICES.put(DEMO_CHIP, 55.20); } public ListStockPrice snapshot() { return BASE_PRICES.entrySet().stream() .map(entry - { double price entry.getValue() (Math.random() - 0.5) * 2; double changePercent (price - entry.getValue()) / entry.getValue() * 100; return new StockPrice( entry.getKey(), Math.round(price * 100.0) / 100.0, Math.round(changePercent * 100.0) / 100.0, System.currentTimeMillis() ); }) .toList(); } }接下来是真正的SSE端点。这里我刻意加上Last-Event-ID的断点续传逻辑RestController RequestMapping(/api/stock) public class StockSseController { private final StockPriceService stockPriceService; private final ThreadPoolTaskExecutor sseExecutor; public StockSseController(StockPriceService stockPriceService) { this.stockPriceService stockPriceService; this.sseExecutor new ThreadPoolTaskExecutor(); this.sseExecutor.setCorePoolSize(4); this.sseExecutor.setMaxPoolSize(10); this.sseExecutor.setQueueCapacity(500); this.sseExecutor.setThreadNamePrefix(stock-sse-); this.sseExecutor.initialize(); } GetMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream( RequestHeader(value Last-Event-ID, required false) String lastEventId) { SseEmitter emitter new SseEmitter(0L); if (StringUtils.hasText(lastEventId)) { // 忽略最后一条ID模拟从断点继续 // 真实项目中这里会从lastEventId还原业务游标 } sseExecutor.execute(() - { try { while (true) { ListStockPrice prices stockPriceService.snapshot(); for (StockPrice price : prices) { emitter.send(SseEmitter.event() .name(price) .id(String.valueOf(price.timestamp())) .data(price, MediaType.APPLICATION_JSON)); } // 加一条注释行作为心跳 emitter.send(SseEmitter.event().comment(heartbeat)); Thread.sleep(1000); } } catch (Exception ex) { emitter.completeWithError(ex); } }); emitter.onCompletion(() - log.info(SSE connection completed for lastEventId{}, lastEventId) ); emitter.onTimeout(emitter::complete); return emitter; } }几个设计点值得展开。超时时间设成0L表示让容器按照配置的默认值来管理超时。因为我每秒钟都在发心跳注释连接不会因为空闲被回收所以超时时间反而不需要设得很大。这样即使某个连接出了问题容器也能在默认超时周期把它清理掉避免僵尸连接堆积。SseEmitter.onCompletion和onTimeout回调是排查问题时很重要的一环。生产环境里如果发现连接数只增不减多半就是这些回调里没有做资源清理。比如后台线程还在while循环里跑但客户端已经断开了推送任务就会一直空转。实际上SseEmitter的send方法在连接断开后会抛出IOException线程最终会退出但回调和日志能让你第一时间发现问题而不是等内存预警了才去查。4.2 前端EventSource接入前端这部分得益于浏览器原生的EventSource代码量非常少。建一个最简单的HTML页面模拟大屏的滚动展示!DOCTYPE html html langzh-CN head meta charsetUTF-8 title实时行情看板/title /head body h1实时股票行情/h1 table border1 thead trth代码/thth最新价/thth涨跌幅/thth时间/th/tr /thead tbody idstockBody/tbody /table script const eventSource new EventSource(/api/stock/stream); eventSource.addEventListener(price, function (event) { const priceData JSON.parse(event.data); const row document.getElementById(stockBody).insertRow(0); row.insertCell(0).innerText priceData.symbol; row.insertCell(1).innerText priceData.price.toFixed(2); row.insertCell(2).innerText (priceData.changePercent 0 ? : ) priceData.changePercent.toFixed(2) %; const time new Date(priceData.timestamp); row.insertCell(3).innerText time.toLocaleTimeString(); }); eventSource.onopen function () { console.log(SSE连接已建立); }; eventSource.onerror function (event) { if (eventSource.readyState EventSource.CLOSED) { console.error(SSE连接已关闭不再重连); } else { console.warn(SSE连接异常浏览器正在自动重连...); } }; /script /body /html这里我用addEventListener(price, ...)而不是onmessage。原因很简单如果后面后端要推不同类型的消息比如notice、trade、kline前端可以分别挂监听器互不干扰。onmessage只处理没有指定event字段的消息能力上弱了很多。onerror里面我特意检查了EventSource.CLOSED状态目的是区分主动关闭后不再重连和意外断开后自动重连。这个区分在排障时很关键很多前端新手一进onerror就以为连接炸了其实大多数情况浏览器正在静默重连UI上根本不需要任何处理。4.3 断点续传优化断点续传的完整形态需要用Last-Event-ID做数据补偿。浏览器收到的每条SSE消息如果带了id字段EventSource会自动记住最后一个id。连接断开后重连请求的HTTP头里会自动带上Last-Event-ID。后端在方法签名里用RequestHeader(Last-Event-ID)就能取到。真实业务中这个ID可以是数据库自增ID、业务时间戳、或者消息队列里的偏移量。拿到之后从消息源跳过对应位置继续推送就行。有个坑值得提醒Last-Event-ID头只有在上一次完整发送了带id的消息时才会被带上。如果后端用了SseEmitter.event().data()但忘了.id()前端就无从知道自己的消费进度断点续传自然失效。所以设计事件时id不能省最好用业务侧单调递增的字段而不是随机数。5. 生产环境避坑手册5.1 Nginx反代的缓冲问题SSE上了生产环境第一道坎往往不是Java代码而是Nginx。Nginx默认会对后端响应做缓冲也就是先把后端返回的数据攒到一定大小再发给客户端。对普通接口来说这是优化能减少I/O次数但对SSE这种细水流长的响应缓冲会把推送彻底毁掉——数据在后端明明每秒都在发客户端却半天看不到动静。要在Nginx配置里关掉这个行为location /api/stock/stream { proxy_pass http://backend_service; proxy_http_version 1.1; proxy_set_header Connection ; proxy_set_header Host $host; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; }proxy_buffering off是关键它告诉Nginx拿到后端数据后立刻转发不要攒着。proxy_read_timeout设成3600秒表示代理和后端之间只要1小时内有数据流动就不会断开再加上后端每隔1秒都发心跳注释这个连接基本是稳固的。配置有个小坑proxy_buffering off不是所有Nginx版本都默认有效而且如果你用了第三方模块或者CDN它们也有各自的缓冲逻辑。上线后最简单的验证方法是用curl直接请求SSE地址看数据是否每1秒都到如果curl正常但浏览器不正常基本就是中间链路缓冲的问题。5.2 线程与连接资源管理后台线程推数据这类代码写起来爽但坑最深的也是它。之前我见过一个案例项目的SSE端点用new ThreadPoolExecutor()创建了一个无界线程池每次客户端连接就往池里丢一个无限循环任务。结果某天大屏页面被人开了一堆标签页线程数直接冲到几百最后把服务器CPU打满。正确的做法既要限制线程池大小还要给任务设一个最大推送时长。比如股票行情这种场景一个客户端连接我只让它连续推15分钟到期就emitter.complete()优雅关闭。客户端EventSource会感知到连接关闭并自动重连重连后拿到新的推送Session业务数据照样是连续的。这个限制单连接寿命的思路比让连接永远挂着要稳得多。另外线程池的拒绝策略要选好。默认的AbortPolicy会直接抛异常导致请求线程黑屏我一般用CallerRunsPolicy让多余任务在调用线程里执行或者直接放弃发不出去的数据。实时推送场景偶尔丢几条行情数据比系统雪崩强得多。5.3 浏览器连接数限制与HTTP/2前端偶尔会出现其他页面正常唯独SSE页面连不上的诡异问题。排查到最后基本都会落在浏览器连接数限制上。以Chrome为例同一域名下最多同时维持6个HTTP/1.1长连接。如果用户在多个标签页打开了大屏或者页面上还挂着一堆轮询接口长连接额度很快会被占满。EventSource连接一直处于CONNECTING状态服务端日志里完全看不到请求进来。破解办法有两个方向。服务端方向是全面启用HTTP/2它允许多路复用长连接不再按条数限制而是共享同一个TCP连接里的多个流。前端方向则是减少页面上其他长连接需求比如把原本用WebSocket的模块改成EventSource让连接数规划更合理。我实测过HTTP/1.1环境下浏览器开第7个大屏标签页时SSE确实会挂起切到HTTP/2后同时开十几个标签页都没问题。5.4 业务侧的心跳保活心跳注释前面代码里出现过这里把它的作用说透。SSE连接中断的常见原因有三个Nginx代理超时、浏览器空闲超时、移动端网络切换。防止这些超时的核心思路就是不要让连接出现‘沉默期。每隔30秒到1分钟服务端发一条以冒号开头的注释消息emitter.send(SseEmitter.event().comment(keep-alive));这样一个不到20字节的消息既能让代理服务器更新最后的活跃时间也能让浏览器确认连接还活着。如果你的事件频率本来就很高比如每秒都在推心跳可以省略一旦推送频率降到每分钟一次以下就很有必要加心跳了。6. 常见问题排查实录现象可能原因排查手段解决方案客户端长时间收不到数据Nginx缓冲未关闭用curl请求SSE端地址观察返回间隔关闭proxy_buffering连接每分钟断开一次代理read_timeout太小在后端日志记录连接关闭时间调大read_timeout或加心跳多个页面一起打开后互相挤掉浏览器HTTP/1.1连接数限制开多个标签页复现上HTTP/2服务器线程数缓慢上涨后台推送线程没有退出打印线程池活跃数设置最大推送时长重连后数据从最早开始重复推送事件没带id字段抓包看响应头给事件加上有序idEventSource一直CONNECTING连接数占满或CORS开发者工具看网络面板检查跨域配置减少其他长连接Java端send抛IOException客户端已经断开在catch块里打日志调用completeWithError释放资源排查SSE问题我个人的固定动作是先用curl -N去看原始事件流curl -N http://localhost:8080/api/stock/stream-N参数禁用了curl的缓冲收到的数据会一行一行打出来。如果curl能稳定输出而页面不行基本可以断定前端代码或代理配置有问题如果curl也卡住那就直接到后端看日志重点查SseEmitter.onTimeout和onCompletion的回调是否异常执行了。有一种很容易被忽略的情况是项目里同时引入了Spring Security。Spring Security的默认配置会拦截一切请求SSE端点必须在过滤器链里放行否则EventSource连握手都完成不了。配置时要额外注意Configuration public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth - auth .requestMatchers(/api/stock/stream).permitAll() .anyRequest().authenticated()); return http.build(); } }如果要走认证建议用请求头中的Token而不是Cookie因为EventSource API没法自定义请求头只能用Cookie或查询参数带凭证而Cookie在跨域场景下问题更多。一个关于用线程还是用反应式的实操体会最后说点个人经验。我在项目里两种SSE写法都写过说实话SseEmitter版本的好处是贴近传统Spring MVC开发团队里的同事上手几乎没有成本。但坏处也明显它把推送逻辑在哪里执行的问题抛给了开发者线程池规划、任务边界、异常兜底全要靠自己扛。而WebFlux版本虽然从底层改成了反应式声明式的Flux定义让代码看起来干净很多但前提是团队已经习惯Reactor的思维方式否则光是搞清楚背压和调度就足够劝退一批人了。如果让我给建议单体应用、团队以传统MVC为主直接用SseEmitter。项目本身就是WebFlux技术栈或者数据源天然就是响应式流用Flux版本。至于选型时的业务判断标准记一句话就够只要数据方向是服务端往客户端单向流动SSE就是比WebSocket更省心的方案一旦出现频繁的双向交互再老老实实回到WebSocket的赛道上去。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询