SSE 消息粘连与截断:为什么流式响应不能按一次 data 事件就当一条完整消息?
面试速答(30 秒版 TL;DR)
- SSE(Server-Sent Events)是服务端到客户端的单向流式推送协议,浏览器原生可用
EventSource消费。 - SSE 运行在 HTTP 连接之上,底层传输是 连续字节流,不是“天然按消息边界一包一包送达”。
- 所谓“消息粘连与截断”,本质上不是 SSE 协议坏了,而是你在错误地用传输层/分块层的边界,当成了应用层消息边界。
- 正确做法是:按 SSE 协议分隔规则解析,通常以空行作为一条事件结束标记,再逐行处理
data:、id:、event:等字段。 - 如果直接拿任意一次
chunk就JSON.parse,非常容易在流式场景下出现半包、粘包、乱码或解析失败。
一、SSE 到底是什么
SSE 适合这类场景:
- 日志流
- AI 文本流式输出
- 实时通知
- 进度推送
浏览器原生 API:
const es = new EventSource("/events");
es.onmessage = (event) => {
console.log(event.data);
};
服务端典型响应头:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
二、为什么会出现“粘连”和“截断”
因为网络传输看到的是 字节流,不是“逻辑消息对象”。
你在服务端写两条消息:
data: {"token":"he"}
data: {"token":"llo"}
客户端底层读取时,可能出现这些情况:
- 一次读到一整条消息
- 一次读到两条消息连在一起
- 一次只读到半条消息
所以“粘连”和“截断”不是异常,而是流式协议的常态。
三、SSE 的消息边界是什么
SSE 不是靠 TCP 包边界,也不是靠 HTTP chunk 边界来分消息。
它靠的是 协议文本格式。
一条事件通常由多行字段组成,例如:
id: 42
event: message
data: {"text":"hello"}
重点规则:
- 每行以
field: value形式存在 - 可以有多行
data: - 空行表示一条事件结束
所以客户端必须自己累积内容,直到遇到空行,才能确认一条完整事件组装完成。
四、浏览器原生 EventSource 为什么一般不需要你自己处理粘包
因为浏览器已经帮你做了 SSE 协议解析。
只要服务端输出是合法的 text/event-stream 格式,onmessage 收到的通常就是组装好的事件。
真正容易踩坑的场景,通常是:
- 你不用
EventSource - 而是用
fetch+ReadableStream - 或 Node 端、移动端、自定义客户端自己解析流
这时如果你按“每个 chunk 就是一条消息”来写,就会出问题。
五、错误示例:直接把 chunk 当完整 JSON
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = new TextDecoder().decode(value);
const obj = JSON.parse(text); // 这里很危险
console.log(obj);
}
为什么危险:
text可能只有半段 JSONtext也可能包含两条甚至多条 SSE 事件- UTF-8 多字节字符还可能刚好被切开
六、正确思路:缓冲区 + 协议分隔符解析
1. 先把字节按流式方式解码
要保留跨 chunk 的多字节字符上下文。
2. 把内容累积到 buffer
每次新读到文本后拼到旧 buffer 末尾。
3. 按 SSE 事件结束符切分
一般以空行分隔,即连续两个换行代表一条事件结束。
4. 对每条事件再逐行解析字段
尤其是多行 data: 需要合并。
示意代码:
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let index;
while ((index = buffer.indexOf("\n\n")) !== -1) {
const rawEvent = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
const dataLines = rawEvent
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trimStart());
const data = dataLines.join("\n");
console.log(data);
}
}
注意:
- 真正严谨实现还要兼容
\r\n - 还要处理
event:、id:、注释行等
七、为什么 AI 流式输出场景更容易踩这个坑
因为很多 AI 接口会返回:
- SSE 格式的流
- 或“长得像 SSE,但又包了一层 JSON”的流
如果你边读边 JSON.parse,就会出现:
- 内容被截断
- 两条消息粘一起
[DONE]混在 JSON 后面
前端要记住:
- 流式 UI 更新的最小单位,不等于传输 chunk 的最小单位。
八、SSE 和 WebSocket 在这个问题上的差异
| 对比项 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 服务端单向推送 | 双向 |
| 应用层消息边界 | 文本协议靠空行分隔 | 帧协议本身有更明确边界 |
| 浏览器原生消费 | EventSource | WebSocket |
| 常见踩坑 | 自定义流解析时半包/粘包 | 业务层自己再包协议时仍可能有拆包问题 |
不要把这张表理解成“WebSocket 永远没有边界问题”,而是:
- WebSocket 协议自己有帧;
- 但如果你在一条消息里继续复用文本协议,也仍然可能需要业务层解析。
高频题标准答法
1. SSE 为什么会出现消息粘连?
因为底层看到的是连续字节流,一次读取可能拿到多条应用层消息拼在一起;消息边界要按 SSE 协议自己识别。
2. SSE 截断是服务端没发完吗?
不一定。
很多时候只是客户端这次读取拿到了部分字节,完整事件还没到齐。
3. 为什么浏览器 EventSource 平时不容易遇到这个问题?
因为浏览器已经内建了 SSE 协议解析;问题主要出在你自己用流式 API 手动处理时。
4. 解析 SSE 时最关键的点是什么?
不要按 chunk 解析,要按 SSE 事件分隔规则 解析;同时流式解码时要处理好多字节字符边界。
易错点 / 坑
- 把 HTTP chunk 边界当成 SSE 消息边界。
- 读取一段文本就立即
JSON.parse。 - 忽略
TextDecoder.decode(value, { stream: true })的流式解码能力。 - 没处理
\r\n与多行data:。
速记要点(可背诵)
- SSE 是 HTTP 上的单向流式协议。
- 粘连/截断本质是 字节流和应用层消息边界不一致。
- 正确做法:buffer 累积 + 按空行切事件 + 按字段解析。
EventSource通常已帮你做解析;手写流解析时最容易踩坑。