SSE 是什么:HTTP 上的服务端单向推送(含最小可跑案例)
最近在写大模型 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/子协议 |
动手
- 把
setInterval改成随机的毫秒数,看客户端是否逐条到达; - 加一个
/chat路由模拟「有货才推」:平时不写,有消息才写一帧——体会长轮询与 SSE 的差异; - 把
n === 5的关闭去掉,中途Ctrl-C杀服务端,观察浏览器onerror后是否自动重连。
自测
- SSE 里“一帧结束”的标志是什么?
data:多行会怎样? - 浏览器
EventSource相对手写fetch读流,白送的两个能力是什么? - SSE 和 WebSocket 的本质差别?分别适合什么场景?
- 想断线续传,
id:在重连时如何被利用?
