SSE(Server-Sent Events):轻量级单向实时推送
SSE(Server-Sent Events):轻量级单向实时推送
一、SSE 是什么
SSE 是一种基于 HTTP 的服务器向客户端单向推送技术。客户端发一次普通 HTTP 请求,服务端保持连接不关闭,持续以流的形式下发数据。
它不是新协议,就是标准 HTTP + 一个响应头:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
二、为什么选 SSE 而不是 WebSocket
| 维度 | SSE | WebSocket |
|---|---|---|
| 方向 | 服务端 → 客户端(单向) | 双向 |
| 协议 | 纯 HTTP | 独立协议(ws/wss) |
| 断线重连 | 浏览器自带 | 需自己实现 |
| 穿透性 | 走普通 HTTP,无额外网关配置 | 部分代理需特殊配置 |
| 复杂度 | 极低 | 较高 |
结论:只需要服务端推、客户端收的场景,SSE 是更省事的选择——AI 流式输出、进度条、实时通知、日志推送。
三、数据格式
服务端返回的每条消息是纯文本,用空行(\n\n)分隔:
id: 1
event: message
data: {"content":"你好"}
data: 多行数据第一行
data: 第二行
: 这是注释,用于保活心跳
关键字段:
data:消息体,可多行,客户端会拼接event:事件名,客户端按名监听(默认message)id:用于Last-Event-ID断线续传retry:重连间隔(毫秒)
四、服务端实现(Spring Boot)
用 SseEmitter 就够了,无需额外依赖:
@RestController
public class SseController {
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
@GetMapping(path = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@RequestParam String userId) {
SseEmitter emitter = new SseEmitter(0L); // 0 = 不超时
emitters.put(userId, emitter);
emitter.onCompletion(() -> emitters.remove(userId));
emitter.onTimeout(() -> emitters.remove(userId));
emitter.onError(e -> emitters.remove(userId));
return emitter;
}
// 任意业务处主动推送
public void push(String userId, String chunk) {
SseEmitter emitter = emitters.get(userId);
if (emitter != null) {
try {
emitter.send(SseEmitter.event()
.name("message")
.data(chunk)
.id(String.valueOf(System.currentTimeMillis())));
} catch (IOException e) {
emitters.remove(userId);
}
}
}
}
先搞清 Flux 是什么
Flux 是 Project Reactor 的核心类型,表示一个异步的、包含 0~N 个元素的流。
| 对比 | 类型 | 含义 |
|---|---|---|
| 普通返回 | String |
一个值,方法返回时就全有了 |
| 单个/空的异步结果 | Mono<T> |
0 或 1 个元素 |
| 多个异步结果 | Flux<T> |
0~N 个元素,一个一个陆续到达 |
两个关键特性:
- 懒执行:定义
Flux时代码不会跑,只有被订阅(subscribe)后才开始执行。 - 背压(backpressure):消费者可以告诉生产者"处理不过来,慢点发"。
SseEmitter 和 Flux 怎么选
常见误解是"Spring MVC 用 SseEmitter,Spring AI 用 Flux"——这俩不在同一层,划分依据搞错了:
| 层次 | 问题 | 答案 |
|---|---|---|
| AI 层(Spring AI 的 API) | 流式对话返回什么? | 永远是 Flux<String>(Spring AI 基于 Reactor 设计) |
| Web 层(你的 Controller) | 怎么把流推给浏览器? | 取决于 Web 栈:MVC → SseEmitter;WebFlux → Flux |
即:Spring AI 产出 Flux,你在 Controller 里把它接到不同的输出方式上。
① Spring MVC + SseEmitter(最传统、最可控)
@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chat(String msg) {
SseEmitter emitter = new SseEmitter(0L);
chatClient.prompt(msg).stream().content()
.subscribe(
emitter::send, // 每个 token 推一条
emitter::completeWithError, // 出错
emitter::complete // 结束
);
return emitter;
}
② Spring MVC + 直接返回 Flux(代码更少)
@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chat(String msg) {
return chatClient.prompt(msg).stream().content();
}
Spring MVC 5+ 支持 Publisher 返回值,内部由 ReactiveTypeHandler 订阅并异步写出。缺点是无法精细控制超时/完成回调。
③ WebFlux + Flux(最原生)
@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> chat(String msg) {
return chatClient.prompt(msg).stream().content()
.map(c -> ServerSentEvent.<String>builder().data(c).build());
}
全程响应式,自带背压,无阻塞。
选型建议
- Servlet 栈(
spring-boot-starter-web)+ 需要精细控制连接生命周期 → ① - Servlet 栈,想少写代码 → ②
- 本来就用 WebFlux(
spring-boot-starter-webflux) → ③
注意:不要同时引入
spring-boot-starter-web和webflux,否则 Spring Boot 会默认退化为 MVC 栈,Flux 的响应式优势全没了,还容易踩坑。
六、客户端实现
const es = new EventSource('/stream?userId=1001');
es.addEventListener('message', (e) => {
console.log('收到:', e.data);
});
// 自定义事件名
es.addEventListener('done', () => es.close());
es.onerror = () => {
// 浏览器会自动重连,这里一般只做提示
};
注意:
EventSource只支持 GET,且不能自定义请求头(鉴权只能用 Cookie 或 URL 参数)。需要 POST / 自定义 Header 时,用fetch+ReadableStream手动解析。
完整浏览器页面(可直接保存为 html 打开)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>SSE 接收端</title>
<style>
body { font: 14px/1.6 system-ui; max-width: 720px; margin: 40px auto; }
#log { border: 1px solid #ddd; padding: 12px; height: 320px; overflow-y: auto; }
.status { color: #888; margin-bottom: 8px; }
</style>
</head>
<body>
<h2>SSE 实时接收</h2>
<div class="status" id="status">未连接</div>
<div id="log"></div>
<script>
const log = document.getElementById('log');
const status = document.getElementById('status');
const es = new EventSource('http://localhost:8080/stream?userId=1001');
es.onopen = () => status.textContent = '已连接';
// 默认事件
es.onmessage = (e) => {
const div = document.createElement('div');
div.textContent = e.data;
log.appendChild(div);
log.scrollTop = log.scrollHeight;
};
// 自定义事件
es.addEventListener('done', () => {
status.textContent = '推送结束';
es.close();
});
es.onerror = (e) => {
status.textContent = '连接异常,浏览器将自动重连...';
// readyState: 0=CONNECTING 1=OPEN 2=CLOSED
if (es.readyState === EventSource.CLOSED) {
status.textContent = '连接已关闭,不再重连';
}
};
</script>
</body>
</html>
用 fetch 手动解析(需要 POST / 自定义 Header 时)
const res = await fetch('/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ prompt: '讲讲 SSE' }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split('\n\n'); // SSE 以空行分隔消息
buffer = chunks.pop(); // 末尾可能是不完整的半条
for (const chunk of chunks) {
if (chunk.startsWith(':')) continue; // 心跳注释
const data = chunk
.split('\n')
.filter(line => line.startsWith('data:'))
.map(line => line.slice(5).trim())
.join('\n');
console.log('收到:', data);
}
}
七、生产环境踩坑点
-
Nginx 缓冲:必须关掉,否则数据会被攒着一起发
proxy_buffering off; proxy_read_timeout 3600s; -
心跳保活:空闲时定时发
: ping\n\n,防止中间层掐断连接。 -
连接数:HTTP/1.1 下浏览器对同源并发连接数有 6 个上限,多标签页场景要注意(HTTP/2 可缓解)。
-
超时:
SseEmitter默认 30s 超时,长任务必须显式传0L或更长时间。
八、一句话总结
SSE = 用最普通的 HTTP,做最简单高效的单向实时推送。如果你的场景是"服务端持续吐数据、客户端只管听",它比 WebSocket 轻得多,也稳得多。


浙公网安备 33010602011771号