跳到主要内容

SSE 是什么:HTTP 上的服务端单向推送(含最小可跑案例)

· 阅读需 7 分钟

最近在写大模型 API 的流式(SSE),发现不少人分不清 SSE、WebSocket、长轮询的区别。这篇把 SSE(Server-Sent Events,服务端推送事件)单独拎出来讲透:它是什么、长什么样、什么时候用它,最后给一个原生 Node、零依赖的最小案例,跑起来给你看真实数据流。

1. SSE 是什么

一句话:SSE 是「在一条普通的 HTTP 响应里,服务端可以持续不断地给客户端推送文本」的机制。

  • 客户端照常发一个 HTTP GET(浏览器里就是 new EventSource(url));
  • 服务端收到后不马上结束响应,而是把头 Content-Type: text/event-stream,然后在这条长连接里按一定格式一段一段写
  • 客户端边收边触发回调,直到连接被关闭。

它本质是 HTTP 长连接上的单向推送:数据只能从服务端 → 客户端。想从客户端反推?那是 WebSocket 或普通 POST 的活。

对比一下常见的四种“实时”方案:

方案方向额外成本断线重连典型场景
轮询客户端不停问请求量大客户端自己管数据低频、懒得升级
长轮询服务端挂住请求直到有货实现绕自己管老接口兼容
SSE服务端单向推就是 HTTP,零升级浏览器自动通知、日志流、LLM 逐字输出
WebSocket双向需要握手升级、自己处理心跳自己写聊天、协同、游戏

SSE 的两张王牌:① 不需要任何协议升级/新端口,跑在普通 HTTP 上② 浏览器原生 EventSource 自带断线自动重连——这两点就够它拿下“服务端单向推”这个场景。

2. 一帧到底长什么样

SSE 是纯文本协议,消息叫「事件帧」:若干字段行 + 一个空行(空行表示这一帧结束,服务端才把这条 data 交给客户端):

event: tick # 事件名(可选;没有时浏览器走 onmessage)
id: 3 # 事件 id(可选;重连时浏览器自动带上 Last-Event-ID)
data: 第 3 条消息 # 数据(可以有多行 data:,会被拼成一条)
# ← 空行:帧结束

关键规则就几条:

  • 每一帧以空行结束,没有空行就不算一帧;
  • data: 可以出现多次,多行会按 \n 拼成一条消息;以 data: 开头的内容里不要自带多余空白;
  • 命名事件用 event:,客户端用 addEventListener('tick', …) 听;没写 event: 的走默认的 onmessage
  • id: 配合自动重连做续传:断了重连时浏览器自动带上 Last-Event-ID,服务端可以从断点继续推(本案例推了 id 但没有用服务端读它,为了演示帧格式)。

服务端响应头必须有的:

Content-Type: text/event-stream # 认出这是 SSE
Cache-Control: no-cache # 别让代理/浏览器缓存这条长响应
Connection: keep-alive # 明确保持连接(HTTP/1.1)

3. 最小案例:一个能真跑的服务器

原生 Node,零依赖。启动后每秒推一条 tick,推满 5 条发一个 bye 再关闭连接:

// server.mjs —— 最小 SSE 服务端:原生 node:http,零依赖
import { createServer } from 'node:http';

const PORT = 39876;

createServer((req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*'); // 让浏览器端 EventSource 能跨源连
if (req.url === '/events') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
// 首条:普通 data + 空行 = 一个完整帧
res.write('data: 连接成功,开始推送\n\n');
let n = 0;
const timer = setInterval(() => {
n++;
// 命名事件 tick + 事件 id(断线重连可传 Last-Event-ID 续传)
res.write(`event: tick\nid: ${n}\ndata: 第 ${n} 条消息 @ ${new Date().toLocaleTimeString('zh-CN', { hour12: false })}\n\n`);
if (n === 5) {
clearInterval(timer);
res.write('event: bye\ndata: 推送结束,服务端关闭连接\n\n');
res.end();
}
}, 500);
req.on('close', () => clearInterval(timer)); // 客户端断开 → 停掉定时器
} else {
res.writeHead(404); res.end('not found');
}
}).listen(PORT, () => {
console.log(`SSE 服务已启动: http://localhost:${PORT}/events`);
});

跑起来,然后用 curl -N 抓原始流(-N 关掉缓冲,边到边显示):

node server.mjs &
curl -N http://localhost:39876/events

真实输出(本机实测):

data: 连接成功,开始推送

event: tick
id: 1
data: 第 1 条消息 @ 23:44:55

event: tick
id: 2
data: 第 2 条消息 @ 23:44:56

event: tick
id: 3
data: 第 3 条消息 @ 23:44:56

event: tick
id: 4
data: 第 4 条消息 @ 23:44:57

event: tick
id: 5
data: 第 5 条消息 @ 23:44:57

event: bye
data: 推送结束,服务端关闭连接

看响应头(curl -N -D - -o /dev/null 抓的),确认它就是个普通 HTTP/1.1 响应、只是迟迟不结束:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Transfer-Encoding: chunked

4. 浏览器端消费:EventSource

浏览器里不用手写解析,EventSource 直接消费同一份流(同一份 server.mjs,开个静态页面指向它):

<!doctype html>
<html>
<body>
<ul id="log"></ul>
<script>
const log = document.getElementById('log');
const es = new EventSource('http://localhost:39876/events'); // 同源可省略域名

es.onopen = () => append('连接已建立');
es.onerror = () => append('连接异常(浏览器会自动重连)');
es.onmessage = (e) => append(`[默认消息] ${e.data}`); // 没写 event: 的消息
es.addEventListener('tick', (e) => append(`[tick] ${e.data}`)); // 命名的 event: tick
es.addEventListener('bye', (e) => { append('bye: ' + e.data); es.close(); });

function append(t) {
const li = document.createElement('li');
li.textContent = t; // 用 textContent,别用 innerHTML
log.appendChild(li);
}
</script>
</body>
</html>

体验点:

  • 页面收一条、渲染一条——不用等全部到齐;
  • onerror 触发时 EventSource 自动重连,你什么都不用写;
  • bye 事件里我们主动 es.close(),否则服务端关连接后浏览器又会重连。

5. 什么时候用它

用 SSE 的典型场景:服务端单向实时推文本——新通知、运行日志 tail、实时指标、进度条,以及最近很火的 LLM 逐字生成(大模型就是「边算边把 token 推给你」,SSE 是它最顺手的载体,本站在 llm-format 流式篇 里就是拿它做的打字机)。

别硬用 SSE 的场景:需要客户端实时往服务端说话(聊天、协作白板)→ 用 WebSocket;要推的是二进制大块 → WebSocket 更合适;低频小数据 → 普通轮询可能更省事。

6. 三个容易翻车的点

#解法
1每次 res.write 忘记 \n\n,客户端收不到任何帧帧 = 字段行 + 空行;多行 data 记得要 \n\n 结尾
2经 Nginx 后数据卡住不实时Nginx 默认缓冲响应——加响应头 X-Accel-Buffering: no,或用 proxy_buffering off
3跨源页面连不上服务端给 Access-Control-Allow-Origin(本例已加);浏览器 EventSource 不支持自定义 header,鉴权要么靠 cookie,要么走 query/子协议

动手

  1. setInterval 改成随机的毫秒数,看客户端是否逐条到达;
  2. 加一个 /chat 路由模拟「有货才推」:平时不写,有消息才写一帧——体会长轮询与 SSE 的差异;
  3. n === 5 的关闭去掉,中途 Ctrl-C 杀服务端,观察浏览器 onerror 后是否自动重连。

自测

  1. SSE 里“一帧结束”的标志是什么?data: 多行会怎样?
  2. 浏览器 EventSource 相对手写 fetch 读流,白送的两个能力是什么?
  3. SSE 和 WebSocket 的本质差别?分别适合什么场景?
  4. 想断线续传,id: 在重连时如何被利用?