SSE 实战:在 SpringBoot + Vue 项目中零依赖实现轻量级实时消息推送
项目背景:一个基于 SpringBoot 2.3.5 + Vue 2 + Element UI 的智慧旅游出行推荐与分享系统,需要实现"管理员发布旅游资讯 → 所有在线用户实时收到弹窗通知"。
技术选型:不引入 WebSocket、不引入 MQ,用 HTML5 原生的 SSE 完成,后端零新依赖 。
本文从原理、选型、落地到踩坑,完整记录这次接入过程。全文以"旅游资讯发布"为例,实现系统通知/公告的实时推送,为表述统一,下文除特指外统称"资讯"。
一、前言:一个"资讯要实时弹出来"的需求
系统上线后,运营提了一个很朴素的需求:
管理员在后台发布一条旅游资讯,正在浏览网站的用户应该立刻在右上角看到通知,而不是刷新页面才看到。
这种"服务器主动告诉浏览器"的需求,常见方案有三种:
- 定时轮询:前端每 5 秒请求一次接口。实现最简单,但请求量大、实时性差、大部分请求是空结果;
- WebSocket :真正的双向实时通信,但要引入
spring-boot-starter-websocket,处理握手、心跳、鉴权、断线重连,成本高; - SSE(Server-Sent Events) :基于普通 HTTP 的单向推送,浏览器原生
EventSource支持、自动重连,后端 Spring MVC 内置SseEmitter,零新依赖。
我们的场景是典型的"服务器单向广播"------资讯发布、点赞通知、系统公告,客户端不需要通过这条通道发数据。用 SSE,是最轻量的选择。
下面把整个接入过程完整梳理一遍。
二、SSE 是什么
2.1 一句话解释
SSE(Server-Sent Events,服务器发送事件)是 HTML5 规范的一部分,是一种基于 HTTP 长连接 、服务器向浏览器单向推送文本数据的技术。
2.2 工作原理
浏览器 服务器
| ------ GET /sse/subscribe -----> |
| <-- Content-Type: text/event-stream
| |
| <====== data: 资讯1 ============ | (连接保持不断开)
| <====== data: 资讯2 ============ |
| <====== data: 资讯3 ============ |
| |
| (连接断开时)浏览器自动重连 -----> |
几个关键点:
- 浏览器发起的是一个普通 HTTP GET 请求;
- 服务器响应头设置
Content-Type: text/event-stream,并且不关闭连接; - 之后服务器可以随时往这个连接里"追加"写入数据,浏览器实时收到;
- 连接断了浏览器会自动重连 (默认 3 秒,可由服务端
retry字段控制)。
注意:它本质上还是 HTTP/1.1 的"一个请求一个响应",只不过响应体是流式、不结束的。所以它能穿过大多数防火墙和代理,不需要像 WebSocket 那样升级协议。
2.3 协议数据格式
服务器推送的每条消息是纯文本,格式如下:
data: 这是一条消息\n
\n
完整格式支持 4 个字段(字段名后跟 : ,两个换行表示一条消息结束):
| 字段 | 作用 |
|---|---|
data |
消息体(多行 data 会拼接,每行一个 data:) |
event |
自定义事件名,前端可按事件名监听 |
id |
消息 ID,断线重连时浏览器会带上 Last-Event-ID 请求头 |
retry |
告诉浏览器断线后多少毫秒重连 |
示例:
id: 1001
event: notice
retry: 5000
data: {"title":"系统公告","content":"今晚 24:00 系统维护"}
还有一个"隐藏用法":以 : 开头的行是注释,浏览器会忽略内容,但可以用来保活:
: heartbeat
这一点在心跳设计里很有用,后文会讲。
2.4 与轮询、WebSocket 对比
| 对比项 | 定时轮询 | WebSocket | SSE |
|---|---|---|---|
| 通信方向 | 客户端拉取 | 双向 | 服务器 → 客户端单向 |
| 协议 | HTTP | ws:// 新协议 | 普通 HTTP |
| 实时性 | 差(有间隔) | 实时 | 实时 |
| 自动重连 | 需自己实现 | 需自己实现 | 浏览器原生支持 |
| 实现成本 | 低(但浪费请求) | 高(服务端需额外配置) | 低(就是普通 HTTP) |
| 二进制 | 支持 | 支持 | 不支持(只能文本) |
| 适用场景 | 低频、不要求实时 | 聊天室、协同编辑 | 通知、公告、进度推送 |
结论:资讯推送、点赞/评论通知、系统公告这类服务器单向广播场景,SSE 是最轻量的选择。
2.5 SSE 的局限(选型前必须知道)
- 只能服务器 → 客户端单向推送。客户端要发数据,仍然走普通 HTTP 请求;
- HTTP/1.1 下浏览器对同一域名的连接数有限制(Chrome 约 6 个)。开多个 SSE 页面会占满,甚至影响同域名其他请求;
- 不支持二进制,二进制内容需要 Base64;
- EventSource 不能自定义请求头,token 只能放 URL query 或 cookie;
- 单机内存存连接,集群部署需要额外做跨节点广播(Redis Pub/Sub 或 MQ)。
三、SpringBoot 侧:SseEmitter 原理与用法
3.1 最小示例
Spring MVC 4.2+ 内置了 SseEmitter,无需引入任何新依赖 (spring-boot-starter-web 自带)。
java
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@GetMapping("/sse/subscribe")
public SseEmitter subscribe(String userId) {
// 超时时间:30 分钟;0 表示永不超时(不推荐,连接泄漏难排查)
SseEmitter emitter = new SseEmitter(30 * 60 * 1000L);
try {
emitter.send(SseEmitter.event()
.id("1001") // 消息 ID
.name("notice") // 自定义事件名
.data("{\"msg\":\"hello\"}") // 消息体
.reconnectTime(5000)); // 断线 5 秒后重连
} catch (IOException e) {
emitter.completeWithError(e);
}
return emitter; // 返回给 Spring 后,连接保持,异步线程可继续往里写
}
要点:
- 接口返回
SseEmitter对象(不是 JSON),Spring 会自动设置响应头并保持连接; - 需要把 emitter 存起来(如
ConcurrentHashMap),业务发生时再调用emitter.send()推送; - 必须处理
onCompletion/onTimeout/onError回调,从 map 中移除,防止内存泄漏; - 必须加心跳,否则中间代理/浏览器可能因长时间无数据断开空闲连接。
3.2 原理:SseEmitter 的线程模型与超时(容易踩坑)
理解这一点,很多问题就通了:
- 异步 Servlet :
SseEmitter基于 Servlet 3.0 的异步请求。Controller 返回后,请求进入异步模式,Tomcat 工作线程被释放 回线程池,连接本身由 NIO 的Poller管理。所以几百上千个 SSE 连接不会 占满maxThreads(默认 200),但会占maxConnections(默认 8192)和内存。 send是同步且线程安全的 :ResponseBodyEmitter.send()内部加了synchronized,并且直接往响应流写。这意味着:- 多个线程同时给同一个 emitter 发消息不会串,但会互相等待;
- 如果客户端网络慢、不读数据,
send会阻塞,进而拖慢调用它的业务线程。这是后文"发送阻塞"坑的根源。
- 超时 :
new SseEmitter(timeout)不传时,使用容器的默认异步超时(Tomcat 默认 30 秒),到点会触发onTimeout并结束请求。所以一定要显式指定一个合理的超时时间 ,否则连接 30 秒就断了,前端虽然会自动重连,但体验很差、日志也难看。
也可以用全局配置:spring.mvc.async.request-timeout: 1800000(单位毫秒),但只对未指定超时的 emitter 生效。 complete()与completeWithError():前者正常结束(浏览器视为连接关闭,不会自动重连),后者带错误结束(浏览器会尝试重连)。服务端主动踢人用前者,异常用后者。
3.3 三个回调:防内存泄漏的"三件套"
java
emitter.onCompletion(() -> emitterMap.remove(userId));
emitter.onTimeout(() -> {
emitter.complete();
emitterMap.remove(userId);
});
emitter.onError(e -> emitterMap.remove(userId));
这三个回调在连接完成、超时、出错 时触发,是清理 map 的唯一可靠时机。少写一个,长跑之后 map 里就会积累大量已失效的 emitter,最终 OOM。
3.4 心跳
空闲连接会被中间代理(Nginx 默认 proxy_read_timeout 60s)、运营商网关、甚至浏览器自己断开。所以需要一个定时任务定期发点东西。
两种写法:
java
// 写法一:发一个自定义 ping 事件(前端需注册监听,否则事件被丢弃)
emitter.send(SseEmitter.event().name("ping").data(String.valueOf(System.currentTimeMillis())));
// 写法二(更推荐):发注释行,不触发任何前端事件,最省
emitter.send(SseEmitter.event().comment("heartbeat"));
注意:
SseEmitter.event().comment()在 Spring 4.2+ 就可用。心跳频率一般 15~30 秒一次,太频繁浪费带宽,太稀疏保不住连接。
四、Vue 侧:EventSource 封装
4.1 原生 API
javascript
// 建立连接(就是一个 GET 请求)
const source = new EventSource('http://localhost:8080/sse/subscribe?userId=1')
// 监听默认消息(服务端没指定 event 字段时走这里)
source.onmessage = (event) => {
console.log('收到消息:', event.data) // 字符串
console.log('消息ID:', event.lastEventId)
}
// 监听自定义事件(服务端指定了 event: notice 时走这里)
source.addEventListener('notice', (event) => {
const data = JSON.parse(event.data)
console.log('收到公告:', data)
})
// 连接打开
source.onopen = () => console.log('SSE 连接已建立')
// 出错(断线后浏览器会自动重连,无需手动处理)
source.onerror = (err) => {
console.error('SSE 异常', err)
// source.readyState: 0=CONNECTING 1=OPEN 2=CLOSED
}
// 主动关闭(页面销毁时必须调,否则连接一直占用)
source.close()
两个必须记住的点:
EventSource不能自定义请求头 (不能加token: xxx),只能把参数拼在 URL query 上,或依赖 cookie;- 手动
close()后浏览器不会自动重连 ,只有连接"意外"断开才会。所以退出登录/组件销毁要主动close(),避免连接泄漏。
4.2 封装
javascript
// util/sse.js
export function createSse(url, handlers) {
const source = new EventSource(url)
source.onmessage = (e) => handlers.onMessage && handlers.onMessage(e)
source.onerror = (e) => handlers.onError && handlers.onError(e)
if (handlers.events) {
Object.keys(handlers.events).forEach(name => {
source.addEventListener(name, handlers.events[name])
})
}
return source
}
javascript
// 某 Vue 组件中
export default {
data() {
return { sseSource: null }
},
created() {
this.sseSource = createSse('http://localhost:8080/sse/subscribe?userId=' + this.userId, {
onMessage: (e) => {
this.$notify({ title: '新消息', message: e.data, type: 'success' })
}
})
},
beforeDestroy() {
if (this.sseSource) this.sseSource.close()
}
}
如果是
Vue 2.7+或Vue 3,生命周期换成beforeUnmount/onBeforeUnmount即可。
五、完整落地:智慧旅游系统接入方案
5.1 现状分析
| 项目 | 现状 | 对 SSE 的影响 |
|---|---|---|
| 后端 | SpringBoot 2.3.5,spring-boot-starter-web |
SseEmitter 开箱即用,零新依赖 |
| 跨域 | WebConfigurer.java 已对 /** 配置 CORS |
SSE 接口自动被覆盖,无需改动 |
| 前端 | Vue 2,devServer 端口 8081,未配置代理 | EventSource 直接连 http://localhost:8080(即 VUE_APP_BASE_API) |
| 鉴权 | 前端把 token 放 header,但后端没有拦截器校验 | EventSource 不能设 header 也不受影响,用 userId query 参数标识用户即可 |
| 返回体 | 统一 ResponseBean / ResultUtil |
SSE 接口不走统一返回体,直接返回 SseEmitter |
5.2 推荐落地场景
结合旅游系统业务,选**"旅游资讯/系统通知实时推送"**作为切入点,最直观、最好演示:
- 管理员在后台发布旅游资讯(
/news/add)→ 实时推送给所有在线用户,前端右上角弹出通知; - 用户攻略被点赞/评论 → 实时推送给该用户;
- 系统公告广播。
5.3 后端改造(3 个新文件,不改任何现有代码)
文件 1:连接管理器 com/travel/common/sse/SseEmitterManager.java
统一管理所有在线连接,避免散落在各 Controller。下面这版已结合生产实践做了并发遍历安全 与应用关闭清理两项加固:
java
package com.travel.common.sse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.annotation.PreDestroy;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* SSE 连接管理器:保存所有在线用户的 SseEmitter
*/
@Slf4j
@Component
public class SseEmitterManager {
/** key = 用户ID(字符串),value = 该用户的连接 */
private final Map<String, SseEmitter> emitterMap = new ConcurrentHashMap<>();
/** 超时时间:30 分钟 */
private static final long TIMEOUT = 30 * 60 * 1000L;
/**
* 用户建立连接
*/
public SseEmitter connect(String userId) {
// 如果该用户已有旧连接(比如开了两个标签页/刷新页面),先关掉旧的
SseEmitter old = emitterMap.get(userId);
if (old != null) {
old.complete();
}
SseEmitter emitter = new SseEmitter(TIMEOUT);
// 三个回调都必须移除 map,否则连接泄漏
emitter.onCompletion(() -> emitterMap.remove(userId));
emitter.onTimeout(() -> {
emitter.complete();
emitterMap.remove(userId);
});
emitter.onError(e -> emitterMap.remove(userId));
emitterMap.put(userId, emitter);
log.info("SSE 用户[{}]已连接,当前在线连接数:{}", userId, emitterMap.size());
// 先推一条连接成功的消息,防止代理因无数据提前断连
sendToUser(userId, "connect", "SSE 连接成功");
return emitter;
}
/**
* 推送给指定用户
* @param event 事件名(前端 addEventListener 监听)
* @param data 消息体(建议 JSON 字符串)
*/
public void sendToUser(String userId, String event, String data) {
SseEmitter emitter = emitterMap.get(userId);
if (emitter == null) return;
try {
emitter.send(SseEmitter.event().name(event).data(data));
} catch (IOException | IllegalStateException e) {
// 客户端已断开
emitterMap.remove(userId);
log.warn("SSE 推送给用户[{}]失败,连接已移除:{}", userId, e.getMessage());
}
}
/**
* 广播给所有在线用户
* 说明:ConcurrentHashMap.forEach 提供弱一致性遍历,遍历期间 map 结构的增删
* 不会抛 ConcurrentModificationException,也不会阻塞写入;
* 这里用 forEach + entrySet 直接拿到 userId,推送失败时才能精准 remove。
*/
public void sendToAll(String event, String data) {
emitterMap.forEach((userId, emitter) -> {
try {
emitter.send(SseEmitter.event().name(event).data(data));
} catch (IOException | IllegalStateException e) {
emitterMap.remove(userId);
log.warn("SSE 广播给用户[{}]失败,连接已移除:{}", userId, e.getMessage());
}
});
}
/**
* 心跳:防止空闲连接被中间件断开
* 同样使用 forEach 遍历 entrySet,保持与 sendToAll 一致的安全语义
*/
public void heartbeat() {
emitterMap.forEach((userId, emitter) -> {
try {
// 发注释行,前端无感知,最省资源
emitter.send(SseEmitter.event().comment("heartbeat"));
} catch (IOException | IllegalStateException e) {
emitterMap.remove(userId);
log.warn("SSE 心跳发送给用户[{}]失败,连接已移除", userId);
}
});
}
/**
* 应用关闭时清理所有连接,避免 shutdown 过程中定时任务继续往已关闭的流写数据
*/
@PreDestroy
public void destroy() {
log.info("应用关闭,清理所有 SSE 连接,共 {} 个...", emitterMap.size());
emitterMap.values().forEach(emitter -> {
try {
emitter.complete();
} catch (Exception ignored) {
// 关闭阶段的异常无需处理
}
});
emitterMap.clear();
}
}
这里两处加固值得注意:
- 并发遍历 :
ConcurrentHashMap本身不会抛ConcurrentModificationException,但keySet()遍历后再去get属于"两步操作",高并发下可能出现 key 还在、value 已经被remove的情况。改用forEach((userId, emitter) -> ...)直接在遍历回调里拿到 entry 的快照,语义更严谨;推送失败时也能精准remove(userId),避免无效连接残留。- 优雅关闭 :加
@PreDestroy,在 Spring 容器关闭前把连接全部complete()并清空 map。否则应用 shutdown 瞬间,定时任务可能仍在往已经关闭的响应流写数据,抛出一堆IllegalStateException,日志很脏,排查问题时也容易误判。
文件 2:订阅接口 com/travel/controller/SseController.java
java
package com.travel.controller;
import com.travel.common.sse.SseEmitterManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/**
* SSE 订阅入口
*/
@RestController
@RequestMapping("/sse")
public class SseController {
@Autowired
private SseEmitterManager sseEmitterManager;
/**
* 前端建立 SSE 连接
* 注意:EventSource 无法自定义请求头,用户标识通过 query 参数传递
* 示例:GET /sse/subscribe?userId=1
*/
@GetMapping("/subscribe")
public SseEmitter subscribe(String userId) {
return sseEmitterManager.connect(userId);
}
}
文件 3:心跳定时任务 com/travel/common/sse/SseHeartbeatTask.java
java
package com.travel.common.sse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* SSE 心跳:每 30 秒给所有连接发一次,防止连接被判定空闲而断开
*/
@Component
public class SseHeartbeatTask {
@Autowired
private SseEmitterManager sseEmitterManager;
@Scheduled(fixedRate = 30 * 1000)
public void ping() {
sseEmitterManager.heartbeat();
}
}
并在启动类 TravelApplication.java 上加注解开启定时任务:
java
@EnableScheduling // 新增这一行
@SpringBootApplication
public class TravelApplication { ... }
在业务中触发推送(以发布资讯为例)
只改 TravelNewsController.java 的 add 方法,加两行:
java
@Autowired
SseEmitterManager sseEmitterManager; // 新增注入
@PostMapping("/add")
public ResponseBean add(TravelNews news) {
if (news == null) {
return ResultUtil.error();
}
newsService.save(news);
// 新增:实时广播给所有在线用户
sseEmitterManager.sendToAll("news",
"{\"title\":\"" + news.getTitle() + "\",\"id\":" + news.getId() + "}");
return ResultUtil.successData(news);
}
更优雅的做法是用 Jackson 序列化:
objectMapper.writeValueAsString(news),避免手拼 JSON 在标题含引号时出问题。关于"推送逻辑和业务逻辑解耦"的进阶写法,见后文 6.2。
同理,点赞攻略、评论等任何业务发生处,注入 SseEmitterManager 调一行 sendToUser / sendToAll 即可。
5.4 前端改造(1 个新文件 + 1 处引用)
新文件:vuetravel/src/util/sse.js
与现有 util/http.js 同级,风格保持一致:
javascript
/**
* 封装 SSE(EventSource)
* 用法见下方注释,组件销毁时必须调 close()
*/
import Vue from 'vue'
const BASE_API = process.env.VUE_APP_BASE_API // http://localhost:8080/
let source = null
/**
* 建立 SSE 连接(全局只保留一条)
* @param {string|number} userId 当前登录用户ID,未登录可传 'guest'
*/
export function connectSse(userId) {
// 防止重复连接
if (source) {
source.close()
}
source = new EventSource(BASE_API + 'sse/subscribe?userId=' + userId)
// 心跳事件,忽略即可(仅用于保活)
source.addEventListener('ping', () => {})
// 连接成功回执
source.addEventListener('connect', (e) => {
console.log('SSE:', e.data)
})
// 新资讯推送
source.addEventListener('news', (e) => {
const data = JSON.parse(e.data)
Vue.prototype.$notify({
title: '新资讯发布',
message: data.title,
type: 'success',
duration: 5000
})
})
source.onerror = () => {
// 断线浏览器会自动重连,这里只打日志
console.warn('SSE 连接异常,等待自动重连...')
}
return source
}
/** 关闭连接(退出登录/页面卸载时调用) */
export function closeSse() {
if (source) {
source.close()
source = null
}
}
引用:登录成功后建立连接
在 App.vue(或登录成功回调、前台 Index.vue 的 created)中:
javascript
import { connectSse, closeSse } from '@/util/sse'
export default {
created() {
// 从 cookie 或 vuex 取当前登录用户ID;未登录用 guest 接收广播
const userId = this.$cookies.get('userId') || 'guest'
connectSse(userId)
},
beforeDestroy() {
closeSse()
}
}
注意:项目 devServer 没有配代理,所以 EventSource 和 axios 一样,直接通过
VUE_APP_BASE_API(http://localhost:8080/)连后端,后端 CORS 已放行/**,无需额外配置。
5.5 联调测试
- 启动后端
travel(端口 8080),启动前端vuetravel(npm run dev,端口 8081); - 浏览器打开前台首页,F12 → Network 面板应看到
sse/subscribe请求一直处于 pending 状态,EventStream 标签页可见每 30 秒一条心跳; - 用 Postman 或 swagger 调
POST /news/add发布一条资讯; - 前端页面右上角应立即弹出 Element UI 通知。
也可以直接在浏览器控制台快速验证后端(不依赖前端代码):
javascript
const s = new EventSource('http://localhost:8080/sse/subscribe?userId=test')
s.onmessage = e => console.log(e.data)
六、踩坑与最佳实践(重点)
这部分是上线后真实踩出来的,按坑的"杀伤力"排序。
6.1 坑一:不指定超时,连接 30 秒就断
new SseEmitter() 不传超时,Spring MVC 会使用容器默认的异步超时(Tomcat 默认 30 秒),到点触发 onTimeout,连接结束。前端 EventSource 虽然会自动重连,但每 30 秒断一次,日志刷屏、体验极差。
解决:显式指定超时(如 30 分钟),并用心跳保活。全局配置也可以:
yaml
spring:
mvc:
async:
request-timeout: 1800000 # 30 分钟,仅对未显式指定 timeout 的 emitter 生效
6.2 坑二:send 阻塞业务线程(附进阶解耦方案)
ResponseBodyEmitter.send() 是同步写响应流 的。如果某个客户端网络慢、TCP 缓冲区满,send 会阻塞,而这个调用发生在业务线程里(比如 /news/add 的 Controller 线程),会导致发布资讯接口变慢。
方案 A(基础):独立线程池隔离
把推送放到独立线程池,业务线程只负责"投递任务":
java
private final ExecutorService pushPool = Executors.newFixedThreadPool(4);
public void sendToAllAsync(String event, String data) {
pushPool.submit(() -> sendToAll(event, data));
}
连接数不多时问题不明显,但这是一个"量上来就炸"的隐患,建议一开始就隔离。
方案 B(进阶):Spring 事件 + @Async,业务与推送彻底解耦
更符合 Spring 风格的做法,是利用应用事件(ApplicationEvent)+ @Async 监听器,让业务代码完全不需要感知 SSE 的存在:
java
// 1. 定义事件
public class NewsAddedEvent {
private final Long newsId;
private final String title;
// 构造器 / getter 略
}
// 2. 业务里只发布事件,不直接调推送
@Service
public class TravelNewsService {
@Autowired
private ApplicationEventPublisher publisher;
public void add(TravelNews news) {
// ... 保存逻辑
publisher.publishEvent(new NewsAddedEvent(news.getId(), news.getTitle()));
}
}
// 3. 异步监听器里做推送
@Component
public class NewsSseListener {
@Autowired
private SseEmitterManager sseEmitterManager;
@Async
@EventListener
public void onNewsAdded(NewsAddedEvent event) {
sseEmitterManager.sendToAll("news",
"{\"id\":" + event.getNewsId() + ",\"title\":\"" + event.getTitle() + "\"}");
}
}
并在启动类或配置类加 @EnableAsync。
这样改的好处:
- 解耦:业务层只关心"发布了一条资讯",不关心要不要推送、推给谁;
- 异步 :
@Async让推送在独立线程执行,天然规避 6.2 的阻塞问题; - 可扩展 :将来新增"发短信""写日志""推送到 MQ"等下游动作,只需再加一个
@EventListener,业务代码零改动。
注意:
@Async默认使用SimpleAsyncTaskExecutor,每次都会新建线程,生产环境建议显式配置一个ThreadPoolTaskExecutor作为默认异步执行器。
6.3 坑三:心跳到底发什么
心跳有几种写法,效果不同:
| 写法 | 前端表现 | 推荐度 |
|---|---|---|
event().name("ping").data(...) |
触发前端 ping 监听(需注册,否则丢弃) |
一般 |
event().comment("heartbeat") |
前端完全无感知,不触发任何事件 | 推荐 |
直接往流里写 :\n\n |
同上,但绕过了 SseEmitter 的封装 | 不推荐 |
心跳频率 15~30 秒即可。另外要注意:heartbeat() 里循环 send,如果连接很多且某个连接阻塞,会拖慢整个心跳循环。连接量大时同样建议用线程池并发发送。
6.4 坑四:CORS 与 withCredentials
EventSource 默认 不带 cookie (withCredentials = false)。如果你的鉴权依赖 cookie,需要:
javascript
new EventSource(url, { withCredentials: true })
同时服务端 CORS 必须满足:
Access-Control-Allow-Credentials: trueAccess-Control-Allow-Origin不能是*,必须是具体 origin
而很多项目(包括我们最初)的 CORS 配置是 allowedOrigins("*"),一旦开启 credentials 就会报错。这是 Spring 5.3 / Spring Boot 2.4 之后的强制规定 ,会直接抛 IllegalArgumentException。
如果确实需要"允许所有域名 + 携带 Cookie" ,Spring Boot 提供了 allowedOriginPatterns 作为替代:
java
registry.addMapping("/**")
.allowedOriginPatterns("*") // 代替 allowedOrigins("*")
.allowCredentials(true)
.allowedMethods("*")
.allowedHeaders("*");
allowedOriginPatterns 允许使用通配符,并能与 allowCredentials(true) 共存,是 Spring 官方推荐的写法。
我们的做法是用 query 参数传 userId,不依赖 cookie,绕开这个坑。但生产环境更安全的做法是 query 传 token 并在服务端校验(见 7.2)。
6.5 坑五:浏览器 6 连接限制(含 HTTP/2 的真相)
HTTP/1.1 下浏览器对同一域名最多约 6 个并发连接。如果用户开了多个标签页,每个页面一条 SSE,很快就占满,导致同域名其他请求排队。
很多文章会说"升级到 HTTP/2 就好了",但这里有一个常被误解的关键前提:
升级到 HTTP/2 后,原生
EventSource并不一定自动享受多路复用红利。 浏览器底层是否为每个EventSource实例建立独立的 HTTP/2 流、是否合并连接,取决于浏览器实现。在部分浏览器版本中,EventSource仍然会独占一条 TCP 连接。要真正稳定地利用 HTTP/2 多路复用,通常需要改用基于fetch的 SSE 实现 (如@microsoft/fetch-event-source),由前端库主动复用连接。
所以,缓解手段分两种思路:
- 不升级协议 :用
BroadcastChannel/SharedWorker让多个标签页共享一条连接(进阶方案,适合强需求场景); - 升级协议 :升级到 HTTP/2 后,优先验证浏览器对
EventSource的连接合并行为,必要时切换到fetch版 SSE 库; - 务实方案:接受现状------普通用户很少开 6 个以上标签页,6 个连接的上限在多数 C 端场景下够用。
6.6 坑六:Nginx 缓冲导致"消息攒一批才到"
Nginx 默认开启 proxy_buffering,会把上游响应缓冲起来再发,SSE 的实时性就没了。
解决(二选一或都加):
nginx
location /sse/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s; # 大于心跳间隔
}
或者在后端响应头加 X-Accel-Buffering: no,让 Nginx 对该响应关闭缓冲。
6.7 坑七:内存泄漏
emitterMap 只增不减是 SSE 最经典的问题。必须保证 onCompletion / onTimeout / onError 三个回调都执行 remove。另外:
- 用户重复连接时,旧 emitter 要
complete(); - 前端组件销毁、退出登录时必须
close(); - 应用关闭时用
@PreDestroy统一清理(见 5.3 文件 1); - 建议加一个监控日志,定期打印
emitterMap.size(),发现只增不减就要查。
6.8 坑八:多标签页只有一个收到消息
我们的方案是 key = userId,同一个用户开两个标签页,后连接的会顶掉前一个(代码里 old.complete())。这是有意为之,避免同一用户重复弹窗。
如果业务要求多标签页都收,可以把 key 改成 userId + 随机串,sendToUser 改成遍历该用户的所有连接。代价是连接数变多,需要配合 6.5 的共享连接方案。
6.9 坑九:后端重启后前端"不推了"
后端重启,内存里的 emitter 全部失效。前端 EventSource 会检测到连接断开并自动重连 ,重新走 /sse/subscribe,一般几秒内恢复。这是正常现象,不需要特殊处理。
但要注意:如果后端重启期间前端处于"手动 close()"状态,就不会重连。所以只在退出登录/页面销毁时 close(),不要在其他地方随意关闭。
6.10 坑十:想传 token 到 header
EventSource 不支持自定义请求头。三种方案:
- query 参数传 token(最简单,但 token 会出现在 URL/日志里,注意脱敏);
- cookie 鉴权 +
withCredentials(受 6.4 的 CORS 限制,注意allowedOriginPatterns); - 引入
@microsoft/fetch-event-source,它用fetch实现 SSE,支持自定义 header,但需要额外安装,且要自己处理重连逻辑。
我们当前项目后端没有拦截器校验 token,所以直接用 userId query 参数,后续接入鉴权时建议升级到方案 1 + 服务端校验。
七、进阶:集群、安全、压测
7.1 集群部署:Redis Pub/Sub 广播
单机版 emitterMap 存在 JVM 内存里,集群部署时用户 A 连在节点 1,业务在节点 2 触发推送,A 就收不到。
整体架构:
┌─────────────────────────────┐
│ Redis Pub/Sub │
│ channel: sse:broadcast │
└──────────────▲──────────────┘
│
┌──────────────────────┼──────────────────────┐
│ publish │ subscribe │ subscribe
▼ │ ▼
┌─────────────────┐ │ ┌─────────────────┐
│ 节点 2 │ │ │ 节点 3 │
│ 触发推送 │ │ │ 用户 C 连接 │
│ /news/add │ │ └─────────────────┘
└─────────────────┘ │
│
┌─────────────────┐
│ 节点 1 │
│ 用户 A、B 连接 │
└─────────────────┘
流程:节点 2 收到发布资讯请求
→ publish 到 Redis 频道 sse:broadcast
→ 所有节点(1、2、3)都收到该消息
→ 各节点查本机 emitterMap,有连接的用户就推送
→ 用户 A、B、C 同时收到通知
思路 :每个节点订阅同一个 Redis 频道,业务触发时把消息 publish 到频道,所有节点收到后各自推给本机连接的用户。
java
// 业务侧:把广播消息发到 Redis,而不是直接遍历本地 map
public void sendToAll(String event, String data) {
String payload = JSON.toJSONString(Map.of("event", event, "data", data));
redisTemplate.convertAndSend("sse:broadcast", payload);
}
// 每个节点启动时订阅,收到后推送给本机连接
@Component
public class SseRedisSubscriber implements MessageListener {
@Autowired
private SseEmitterManager sseEmitterManager;
@Override
public void onMessage(Message message, byte[] pattern) {
Map<String, String> msg = JSON.parseObject(
new String(message.getBody()), Map.class);
sseEmitterManager.sendToAll(msg.get("event"), msg.get("data"));
}
}
注意:
sendToAll在这里是"推给本机 的所有连接",不是全局广播。真正的全局广播由 Redis 频道完成。所以SseEmitterManager里的sendToAll方法语义不用改,仍然只操作本机 map,集群广播是上层职责。定向推送给某个用户时,可以把消息发到
sse:user:{userId}频道,但更简单的是所有节点都收,各自查本机 map,有就推、没有就忽略------对在线用户量不大的系统,这种"广播式定向"成本完全可接受。
7.2 安全
- 鉴权 :
userId通过 query 传,可伪造。生产环境应在subscribe接口校验 token(query 或 cookie),从 token 解析出真实 userId,而不是信任前端传的; - 限流:限制单 IP / 单用户的 SSE 连接数,防止恶意占满连接;
- guest 连接 :未登录用户用
guest作为 key 会互相顶掉,只保留一条。如果要做匿名广播,建议匿名用户不建立 SSE 连接,或者给每个匿名会话生成唯一 ID; - 敏感信息:推送内容不要包含敏感数据,SSE 是明文文本(HTTPS 下加密传输,但服务端日志可能记录)。
7.3 压测与容量
- 线程 :异步 Servlet 释放了工作线程,
maxThreads(默认 200)不是瓶颈; - 连接数 :受 Tomcat
maxConnections(默认 8192)限制,可调大; - 内存:每个连接占一个 socket + emitter 对象 + 缓冲区,单连接约几十 KB,1 万连接约几百 MB,需实测;
- 文件句柄 :每个连接占一个 fd,注意
ulimit -n; - 压测工具 :可以用
curl -N http://.../sse/subscribe?userId=x批量起连接,或用 JMeter 的 SSE 插件。
单机支撑几千到上万连接是合理的,再往上就要考虑多节点 + 网关层连接复用。
八、常见问题速查表
| 问题 | 原因 | 解决 |
|---|---|---|
| 消息攒一批才到、不实时 | 中间有 Nginx/代理开了响应缓冲 | Nginx 加 proxy_buffering off;,或响应头加 X-Accel-Buffering: no |
| 长时间无消息后连接断了 | 连接空闲被断开 | 心跳(30 秒一次 comment / ping) |
| 连接 30 秒就断 | 未指定超时,用了容器默认 | new SseEmitter(30 * 60 * 1000L) 或配置 spring.mvc.async.request-timeout |
| 发布接口变慢 | send 同步阻塞业务线程 |
推送放独立线程池,或 ApplicationEvent + @Async 解耦 |
| 重启后端后前端不推了 | 内存 emitter 失效 | 正常现象,浏览器自动重连重新 subscribe |
| 多标签页只有一个收到消息 | 同 userId 后连接顶掉旧连接 | 本方案按用户存一条;如需多标签页都收,key 改成 userId + 随机串 |
| 想传 token 到 header | EventSource 不支持自定义 header | query 传 token;或引入 @microsoft/fetch-event-source |
| 集群部署只有部分用户收到 | emitter 存在单机内存 | 引入 Redis Pub/Sub 或 MQ 做跨节点广播 |
| 开了 SSE 后其他请求变慢 | 浏览器同域名 6 连接限制被占满 | 多标签页共享连接(BroadcastChannel/SharedWorker),或升级 HTTP/2(注意 EventSource 的合并行为) |
| 应用关闭日志大量异常 | shutdown 时定时任务仍在写已关闭的流 | 加 @PreDestroy 统一 complete() 并清空 map |
allowCredentials(true) 报错 |
Spring 5.3+ 禁止 allowedOrigins("*") + credentials |
改用 allowedOriginPatterns("*") |
九、总结
- SSE = 基于 HTTP 的服务器单向推送 ,浏览器原生
EventSource支持、自动重连,比 WebSocket 轻得多; - SpringBoot 用内置
SseEmitter即可,本工程零新依赖; - 接入成本:后端新增 3 个文件 + 启动类加
@EnableScheduling+ 业务处调一行推送;前端新增util/sse.js+ 入口建立连接; - 真正需要花心思的是踩坑部分:超时、阻塞、心跳、CORS、Nginx 缓冲、内存泄漏、集群广播;
- 两个值得一开始就做对的细节:
ConcurrentHashMap用forEach((k, v) -> ...)遍历而不是先keySet再get、用@PreDestroy在应用关闭时清理连接; - 适合场景:资讯发布广播、点赞/评论通知、系统公告、进度推送;不适合:聊天、协同编辑(这类用 WebSocket)。
SSE 的定位很清晰:"我不需要客户端跟我说话,我只需要它安静地听"。如果你的需求符合这句话,SSE 就是比 WebSocket 更轻、更省心的答案。
十、参考资料
- MDN:Server-sent events --- https://developer.mozilla.org/zh-CN/docs/Web/API/Server-sent_events
- HTML Living Standard:Server-sent events --- https://html.spec.whatwg.org/multipage/server-sent-events.html
- Spring Framework:SseEmitter / ResponseBodyEmitter --- https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.html
- Spring Framework:CORS 配置(
allowedOriginPatterns)--- https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-cors @microsoft/fetch-event-source--- https://github.com/Azure/fetch-event-source