跳到主要内容

SSE 消息粘连与截断:为什么流式响应不能按一次 data 事件就当一条完整消息?

面试速答(30 秒版 TL;DR)

  • SSE(Server-Sent Events)是服务端到客户端的单向流式推送协议,浏览器原生可用 EventSource 消费。
  • SSE 运行在 HTTP 连接之上,底层传输是 连续字节流,不是“天然按消息边界一包一包送达”。
  • 所谓“消息粘连与截断”,本质上不是 SSE 协议坏了,而是你在错误地用传输层/分块层的边界,当成了应用层消息边界。
  • 正确做法是:按 SSE 协议分隔规则解析,通常以空行作为一条事件结束标记,再逐行处理 data:id:event: 等字段。
  • 如果直接拿任意一次 chunkJSON.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"}

客户端底层读取时,可能出现这些情况:

  1. 一次读到一整条消息
  2. 一次读到两条消息连在一起
  3. 一次只读到半条消息

所以“粘连”和“截断”不是异常,而是流式协议的常态。


三、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 可能只有半段 JSON
  • text 也可能包含两条甚至多条 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 在这个问题上的差异

对比项SSEWebSocket
通信方向服务端单向推送双向
应用层消息边界文本协议靠空行分隔帧协议本身有更明确边界
浏览器原生消费EventSourceWebSocket
常见踩坑自定义流解析时半包/粘包业务层自己再包协议时仍可能有拆包问题

不要把这张表理解成“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 通常已帮你做解析;手写流解析时最容易踩坑。