KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
02 · SSE 协议本身:报文格式与浏览器的真实行为 — keel 龙骨
这一章回答:一个 SSE 响应到底长什么样,Last-Event-ID 是怎么工作的,以及浏览器替你做了什么、不替你做什么。
这一章回答:一个 SSE 响应到底长什么样,Last-Event-ID 是怎么工作的,以及浏览器替你做了什么、不替你做什么。
SSE 的全部规范只有几页,但细节决定了"能不能续"和"会不会错乱"。这一章把协议讲透,因为后面所有工程决策都建立在它之上。
一、响应头与报文格式
HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: no # 告诉 nginx 不要缓冲(第 03 章详解)
报文体是由空行分隔的事件块组成的文本流:
id: 1710000000123-0
event: delta
data: {"text":"你"}
id: 1710000000124-0
event: delta
data: {"text":"好"}
data: 这一块没有 id 和 event,默认事件类型是 message
: 这一行以冒号开头,是注释(心跳常这么写)
event: done
data: {"reason":"stop"}
规则要点:
| 要素 | 说明 |
|---|---|
| 分隔符 | 每个事件块以空行(\n\n)结束;行分隔符是 \n(也接受 \r\n) |
data: |
载荷;同一块里多行 data: 会被拼接,行间插入 \n(所以多行 JSON 要小心) |
event: |
事件类型,客户端用 addEventListener('delta', ...) 接收;不写则默认 message |
id: |
事件 ID,浏览器会记住,重连时通过 Last-Event-ID 请求头带回 |
retry: |
建议的重连间隔(毫秒),服务端可下发 |
:xxx |
注释行,不触发事件,常用作心跳 |
最容易写错的一点:data: 后面必须有一个空格(data: xxx),虽然多数解析器容错,但严格按规范写最安全。多行数据要写成多行 data: 或单行 JSON(推荐后者,避免拼接歧义)。
二、事件边界:为什么"半个事件"不会发生
TCP 是字节流,客户端可能一次收到半个事件块——但浏览器 EventSource 会缓冲到收到完整空行才派发事件。所以:
- 应用层不需要自己做粘包/拆包(这是 SSE 相比裸 TCP/WebSocket 省事的地方);
- 但如果数据本身不完整(例如一个 JSON 被分成多个
data:发送),浏览器会把它拼成一个字符串给你——所以每条data必须是自包含的完整 JSON。
⚠️ 反过来,如果代理把响应缓冲起来(第 07 章),客户端会长时间收不到任何字节,表现为"卡住"。这两者要区分:前者是解析问题,后者是传输问题。
三、断线重连:Last-Event-ID 的完整流程
这是 SSE 最有价值的机制,也是很多人没用上的部分:
t0 客户端连接 GET /stream
t1 服务端推送 id: 100 data: A
t2 服务端推送 id: 101 data: B ← 浏览器记住 lastId = 101
t3 网络中断
t4 浏览器自动重连,请求头带上:
GET /stream
Last-Event-ID: 101
t5 服务端从 101 之后继续推送(102、103…)
服务端必须做的两件事:
- 读取
Last-Event-ID请求头(不是 query 参数,虽然也可以兼容); - 从该 ID 之后继续,而不是从头重推(从头重推会导致客户端重复处理)。
@router.get("/stream")
async def stream(request: Request, last_event_id: str | None = Header(default=None, alias="Last-Event-ID")):
start = last_event_id or "0-0"
...
浏览器自动重连的行为:
- 连接断开后,等待
retry指定的时间(服务端未指定则默认约 3 秒)后重连; - 重连次数没有上限(这点与轮询/WS 不同,需要服务端自己防滥用);
- 如果服务端返回 204 或
Content-Type不是text/event-stream,浏览器会停止重连——这是主动终止流的正确方式。
四、EventSource 的三条限制(会约束你的设计)
限制 1:只能 GET,不能自定义请求头
// ❌ 做不到
new EventSource(url, { headers: { Authorization: 'Bearer xxx' } }) // 没有这个选项
→ 鉴权必须走 Cookie、query 参数或一次性 ticket(第 08 章详述三种方案)。
限制 2:HTTP/1.1 下同域最多 6 条并发连接
浏览器对每个域名的并发连接数有限制(HTTP/1.1 通常 6)。开满之后:
- 其它普通的 XHR/fetch 请求会被排队饿死;
- 表现为"页面其它功能全卡住"。
→ 对策:同一页面最多开 1 条 SSE;需要多路数据时用 event: 类型区分(这也是为什么事件类型设计很重要);或者上 HTTP/2(连接复用,限制解除)。
限制 3:无法主动发送"结束"
服务端关闭连接后,浏览器会重连。如果想表达"流真的结束了":
- 发一个
event: done(或自定义终止事件)后关闭,客户端收到后自己调用es.close(); - 或返回 204 让浏览器停止重连。
否则会出现"任务已完成,但客户端一直在重连"的幽灵流量。
五、三种客户端用法
① 浏览器原生 EventSource(最简单,自动重连)
const es = new EventSource('/api/stream?ticket=xxx');
es.addEventListener('delta', (e) => render(JSON.parse(e.data)));
es.addEventListener('done', (e) => es.close());
es.onerror = (e) => { /* readyState: 0=连接中 1=已打开 2=已关闭 */ };
readyState === 2(CLOSED)表示不会再重连(通常是服务端返回了非事件流响应)。
② fetch + ReadableStream(需要自定义头、POST 或手动控制时)
const res = await fetch('/api/stream', { headers: { Authorization: `Bearer ${token}` } });
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 blocks = buffer.split('\n\n');
buffer = blocks.pop(); // 最后一块可能不完整
for (const b of blocks) handleBlock(b); // 自己解析字段
}
代价:重连、事件 ID、retry 全部要自己实现。好处是能用任意请求头与方法。
③ 服务端/命令行
curl -N -H 'Accept: text/event-stream' http://localhost:8000/stream
# -N 关闭 curl 自己的缓冲,否则看不到实时输出(这是排查"是不是服务端没发"的第一步)
六、与其它流式协议的边界
| 协议 | 传输 | 事件边界 | 重连 | 适用 |
|---|---|---|---|---|
| SSE | HTTP | 协议内建(空行分隔) | 内建 | 服务端单向推 |
| HTTP chunked + 裸 JSON Lines | HTTP | 需自己定(通常 \n 分隔) |
自己实现 | 内部服务、简单场景 |
| WebSocket | WS | 消息帧 | 自己实现 | 双向 |
| gRPC streaming | HTTP/2 | protobuf 消息 | 自己实现 | 内部微服务 |
一个实用判断:如果你的客户端是浏览器且是单向流,SSE 几乎总是最省事的;如果是内部服务间通信,JSON Lines over chunked 也够用,但确认一下"外层是 HTTP 响应"这一点是否被中间层缓冲。
动手:可观察结果
| 产出 | 判断标准 |
|---|---|
| 一个手写 SSE 响应 | 用 curl -N 能看到逐条事件,字段完整(id/event/data 齐全) |
Last-Event-ID 验证 |
手动带上该请求头请求,服务端从断点继续而不是重头推 |
| 心跳验证 | 注释行心跳能被收到且不触发业务事件 |
| 6 连接限制复现 | HTTP/1.1 下开 7 条 SSE,观察第 7 条与其它请求被饿死 |
| 终止语义验证 | 返回 204 后浏览器不再重连(Network 面板确认无后续请求) |
完成标志:能手写(不用框架)一个完整的 SSE 响应,并说清 id / retry / 注释行三个字段各自解决什么问题。
故障注入
| 注入方式 | 观察 |
|---|---|
| 事件块末尾不发空行 | 事件是否一直不派发(验证边界依赖空行) |
data: 后不加空格 |
各客户端解析器是否容错 |
一个 JSON 拆成多行 data: 发送 |
客户端收到的是拼接串还是多个事件 |
| 服务端关闭连接但不发 done / 不返回 204 | 浏览器是否无限重连(幽灵流量) |
不下发 retry 时断网 |
重连间隔是多少(验证默认值) |
| 同时开 7 条 SSE(HTTP/1.1) | 页面其它请求是否被阻塞 |
自测题
- 一个 SSE 事件块由哪些字段组成?
data出现多行时会发生什么? Last-Event-ID是请求头还是 query 参数?服务端不处理它会有什么后果?- 如何让浏览器"停止重连"?为什么直接
close()连接做不到? EventSource不能自定义请求头,这对鉴权设计意味着什么?- HTTP/1.1 的 6 连接限制会造成什么现象?怎么规避?