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 会缓冲到收到完整空行才派发事件。所以:

⚠️ 反过来,如果代理把响应缓冲起来(第 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…)

服务端必须做的两件事:

  1. 读取 Last-Event-ID 请求头(不是 query 参数,虽然也可以兼容);
  2. 从该 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"
    ...

浏览器自动重连的行为:

四、EventSource 的三条限制(会约束你的设计)

限制 1:只能 GET,不能自定义请求头

// ❌ 做不到
new EventSource(url, { headers: { Authorization: 'Bearer xxx' } })   // 没有这个选项

→ 鉴权必须走 Cookie、query 参数或一次性 ticket(第 08 章详述三种方案)。

限制 2:HTTP/1.1 下同域最多 6 条并发连接

浏览器对每个域名的并发连接数有限制(HTTP/1.1 通常 6)。开满之后:

→ 对策:同一页面最多开 1 条 SSE;需要多路数据时用 event: 类型区分(这也是为什么事件类型设计很重要);或者上 HTTP/2(连接复用,限制解除)。

限制 3:无法主动发送"结束"

服务端关闭连接后,浏览器会重连。如果想表达"流真的结束了":

否则会出现"任务已完成,但客户端一直在重连"的幽灵流量。

五、三种客户端用法

① 浏览器原生 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) 页面其它请求是否被阻塞

自测题

  1. 一个 SSE 事件块由哪些字段组成?data 出现多行时会发生什么?
  2. Last-Event-ID 是请求头还是 query 参数?服务端不处理它会有什么后果?
  3. 如何让浏览器"停止重连"?为什么直接 close() 连接做不到?
  4. EventSource 不能自定义请求头,这对鉴权设计意味着什么?
  5. HTTP/1.1 的 6 连接限制会造成什么现象?怎么规避?

进入 keel 阅读