SSE这技术在圈子里其实有点微妙——你说它新鲜吧,它早在HTML5时代就被定下来了,到现在得有十多年;你说它老套吧,这两年大模型服务端流式输出火了之后,又有不少人重新翻它的牌子。在Spring Boot里做实时数据推送,很多人第一反应是上WebSocket,但真到落地的时候,Server-Sent Events(SSE)这种基于普通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三个方案摆在一起看,选型逻辑会清晰很多。
| 维度 | 短轮询 | WebSocket | SSE |
|---|---|---|---|
| 通信方向 | 客户端请求、服务器响应 | 全双工,双向 | 单向,只能服务器推 |
| 协议基础 | 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字段(事件ID):
event: 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 最经典:SseEmitter(Spring MVC)
Spring 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++) { Map<String, 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 WebFlux,SSE的写法会变得更"宣告式"——不再手动管理线程,而是返回一个反应式数据流,让框架自己去背压、去调度。
首先需要引入响应式依赖。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency>然后控制器可以这样写:
@RestController @RequestMapping("/api/stream") public class ReactiveStreamController { @GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<Map<String, Object>>> events() { return Flux.interval(Duration.ofSeconds(1)) .map(i -> { Map<String, Object> payload = new HashMap<>(); payload.put("seq", i); payload.put("time", System.currentTimeMillis()); return payload; }) .map(payload -> ServerSentEvent.<Map<String, Object>>builder() .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 底层方案:ResponseBodyEmitter
ResponseBodyEmitter是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 Map<String, 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 List<StockPrice> 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) { List<StockPrice> 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 lang="zh-CN"> <head> <meta charset="UTF-8"> <title>实时行情看板</title> </head> <body> <h1>实时股票行情</h1> <table border="1"> <thead> <tr><th>代码</th><th>最新价</th><th>涨跌幅</th><th>时间</th></tr> </thead> <tbody id="stockBody"></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字段 | 抓包看响应头 | 给事件加上有序id |
| EventSource一直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的赛道上去。