Fork me on GitHub

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):消费者可以告诉生产者"处理不过来,慢点发"。

SseEmitterFlux 怎么选

常见误解是"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-webwebflux,否则 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);
  }
}

七、生产环境踩坑点

  1. Nginx 缓冲:必须关掉,否则数据会被攒着一起发

    proxy_buffering off;
    proxy_read_timeout 3600s;
    
  2. 心跳保活:空闲时定时发 : ping\n\n,防止中间层掐断连接。

  3. 连接数:HTTP/1.1 下浏览器对同源并发连接数有 6 个上限,多标签页场景要注意(HTTP/2 可缓解)。

  4. 超时SseEmitter 默认 30s 超时,长任务必须显式传 0L 或更长时间。

八、一句话总结

SSE = 用最普通的 HTTP,做最简单高效的单向实时推送。如果你的场景是"服务端持续吐数据、客户端只管听",它比 WebSocket 轻得多,也稳得多。

posted @ 2026-09-23 21:26  秋夜雨巷  阅读(7)  评论(0)    收藏  举报