SSE(Server-Sent Events)经常被描述成“服务器主动推送消息”。这个说法没有错,但还不够完整:SSE 并不是一种新的传输层协议,而是浏览器基于 HTTP 长响应解析出来的一种文本事件流。
理解这一点很重要。SSE 的实时性来自响应保持打开,消息边界来自文本格式,断线恢复则需要应用层自己保存事件和处理消费位置。只有把这几层拆开,才能解释为什么 res.write() 已经执行,浏览器却可能迟迟收不到数据,也才能正确设计重连和资源清理。
一、SSE 解决的是“什么时候有数据”的问题
普通 HTTP 请求通常遵循“一次请求对应一次响应”的模式。客户端发起请求,服务器返回结果,响应结束。这个模式适合查询已经存在的数据,但不适合服务器产生数据的时间不确定的场景。
例如,客户端想知道任务什么时候完成,有三种常见方案:
- 每隔一段时间轮询一次接口。
- 建立 WebSocket,保持双向连接。
- 使用 SSE,让服务器沿着一个长期 HTTP 响应持续发送事件。
轮询像每隔一分钟打电话问一次“有新消息吗”。大多数电话得到的都是“还没有”,而真正有消息时还要等到下一次轮询。SSE 则像先打通电话并约定“有消息时直接告诉我”:客户端只建立一次连接,服务器在有数据时写入响应体。
sequenceDiagram
participant B as 浏览器
participant S as 服务器
B->>S: GET /api/events
S-->>B: 200 + text/event-stream
loop 连接保持
S-->>B: data: message-1\n\n
S-->>B: data: message-2\n\n
S-->>B: data: message-3\n\n
end
一个 SSE 请求的大致流程是:
- 浏览器通过
EventSource发起一个GET请求。 - 服务器返回
Content-Type: text/event-stream。 - 服务器不调用
res.end(),而是继续向响应体写入文本。 - 浏览器按 SSE 格式解析文本,遇到完整事件后触发回调。
- 连接直到客户端、服务器或中间链路关闭才结束。
SSE 适合 AI 流式输出、任务进度、通知、日志和状态变化等“服务器到客户端”的推送场景。它不是双向通信协议:客户端仍然需要通过普通 HTTP 请求提交数据;如果应用需要双方持续发送消息,则 WebSocket 往往更合适。
二、事件流格式:空行就是消息边界
SSE 事件是 UTF-8 文本字段组成的记录。最小的事件可以写成:
data: hello
这里看起来只有一行 data,实际上末尾有两个换行符。第一个换行结束字段,第二个换行形成空行,告诉浏览器“这一条事件已经结束”。如果只有 data: hello\n,浏览器可能仍然认为事件没有完整提交。
浏览器端可以这样建立连接:
const source = new EventSource("/api/events");
source.onmessage = event => {
console.log("收到默认消息:", event.data);
};
source.onerror = error => {
console.error("SSE 连接异常:", error);
};
服务器需要返回类似的响应头:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
常见字段的职责如下:
| 字段 | 作用 | 浏览器或应用的处理方式 |
|---|---|---|
data | 事件载荷 | 交给事件回调;JSON 需要应用自行解析 |
event | 自定义事件名 | 使用 addEventListener 按名称分发 |
id | 事件编号 | 为后续断线恢复提供消费位置 |
retry | 重连等待时间建议 | 影响浏览器后续重连节奏 |
: | 注释行 | 常用于发送不含业务数据的心跳 |
带有 event 字段的事件不会走默认的 onmessage,而是按自定义名称监听:
event: order-status
data: {"status":"paid"}
source.addEventListener("order-status", event => {
const payload = JSON.parse(event.data);
console.log(payload.status);
});
多行 data 属于同一条事件,浏览器会把它们按换行拼接:
data: first line
data: second line
这表示一条包含两行文本的数据,而不是两条消息。JSON 也只是 data 字段中的一种应用层编码,SSE 本身并不理解 JSON 的结构。
从解析器的角度看,事件流可以抽象成下面的流程:
flowchart LR
A[持续到达的文本字节] --> B[按行读取]
B --> C{遇到空行?}
C -- 否 --> B
C -- 是 --> D[收集字段]
D --> E{是否有 event 字段?}
E -- 有 --> F[触发同名事件]
E -- 无 --> G[触发 message 事件]
三、SSE 的底层仍然是 HTTP 长响应
SSE 的协议栈可以表示为:
TCP
→ HTTP/1.1 或 HTTP/2
→ 长时间不结束的 HTTP Response
→ text/event-stream 文本格式
→ EventSource 事件回调
因此,SSE 的“持续”来自 HTTP 响应没有结束,而“事件”来自响应体中的文本约定。
在 HTTP/1.1 中,一个 SSE 请求通常长期占用一个 TCP 连接,服务器可能使用 chunked 传输。应用层通常只需要持续调用:
res.write("data: hello\n\n");
不应该自己拼接 HTTP chunk 的长度;那是 Web 服务器或运行时负责的传输细节。如果同一域名下建立很多 SSE 连接,HTTP/1.1 的并发连接限制就可能成为约束。
HTTP/2 可以在一个 TCP 连接中复用多个 HTTP stream,因此 SSE 不一定独占 TCP 连接,但仍会持续占用 stream、连接流量控制和服务端资源。HTTP/2 改善了连接复用,并不意味着代理配置、超时和缓冲问题自动消失。
四、为什么 res.write 执行了,浏览器却没有立即收到
“服务器写入”与“浏览器收到”之间隔着一条链路:
flowchart TD
A[应用 res.write] --> B[Web 框架缓冲]
B --> C[Web 服务器]
C --> D[反向代理或 CDN]
D --> E[Socket 与 TCP]
E --> F[浏览器 SSE 解析器]
F --> G[触发事件回调]
B -.缓冲未释放.-> H[延迟或批量到达]
D -.代理缓冲或超时.-> H
任何一层都可能暂存数据。于是可能出现这样的现象:服务端日志显示每秒执行一次 res.write,浏览器却每隔几十秒一次收到多条事件。
排查时应沿链路检查:
- 应用是否错误地调用了
res.end()。 - Web 框架或运行时是否启用了响应缓冲。
- 反向代理是否启用了
proxy_buffering或缓存。 - 压缩是否把多个小块合并后才输出。
- 代理读超时是否短于预期的空闲时间。
- 响应头是否已经尽快发送。
Node.js 中可以使用 res.flushHeaders() 尽快发送响应头;部署在 Nginx 等代理后时,可以通过 X-Accel-Buffering: no 提示不要缓冲,代理侧也需要关闭相应的缓冲和缓存配置。flush 的含义是尽快把当前可发送的数据推进下一层,它不保证数据瞬间抵达浏览器,也不能替代心跳和超时设置。
五、自动重连不等于可靠消息
浏览器原生 EventSource 通常会在连接异常后尝试重新连接。服务器可以通过 retry 提供重连等待时间:
retry: 5000
id: 100
data: message-100
但自动重连只解决“连接重新建立”,并不解决“断线期间漏掉了哪些业务消息”。要实现恢复路径,通常需要三件事:
- 每条事件带有唯一且可比较的
id。 - 服务端保存一段时间内可重放的事件。
- 客户端重连时把最后收到的事件位置带回服务端。
当客户端最后收到 id: 100 后断线,浏览器重连时可能携带:
Last-Event-ID: 100
服务端读取这个值,查询并补发 id > 100 的历史事件,然后再切换到实时事件流:
sequenceDiagram
participant C as 客户端
participant S as 服务端
C->>S: 初次连接
S-->>C: id: 100 / data
Note over C,S: 连接断开
C->>S: 重连 + Last-Event-ID: 100
S->>S: 查询并重放 101 之后的事件
S-->>C: 101、102、实时新事件
如果历史事件已经过期、ID 不存在,服务端必须定义明确策略,例如发送当前快照后继续、返回需要重新同步的错误,或者要求客户端重新建立完整状态。SSE 不会自动提供持久化、确认、去重、严格有序或“恰好一次”处理,这些都属于应用层可靠性设计。
一个实用的判断是:Last-Event-ID 表示“客户端声称自己最后看到的位置”,并不表示对应业务操作已经成功处理。对于有副作用的业务,仍然需要幂等键、确认机制和去重策略。
六、心跳:保持链路活跃,也暴露断开
长连接在暂时没有业务消息时可能看起来像“空闲连接”。代理、网关或负载均衡器往往会对长时间没有数据的连接主动关闭。SSE 通常使用注释行发送心跳:
: heartbeat
以冒号开头的内容不会触发业务事件,但它能让链路持续有数据,也能帮助服务端更快发现连接是否已经断开。心跳间隔应小于链路中最短的空闲超时。例如,如果某个网关 60 秒无数据就断开,可以考虑 15~30 秒发送一次,但最终仍要结合实际部署配置和连接规模。
心跳不能证明客户端已经处理完上一条业务消息,它只说明字节仍在链路中流动。因此,心跳解决的是连接存活和空闲超时问题,不是业务确认问题。
七、一个可运行的 Node.js 服务端骨架
下面的代码展示一个最小但完整的生命周期:设置响应头、立即发送头部、编码业务事件、定时发送数据和心跳,并在连接关闭时清理定时器。
import http from "node:http";
const server = http.createServer((req, res) => {
if (req.url !== "/events") {
res.writeHead(404).end();
return;
}
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
let id = 0;
const sendEvent = (event, data, eventId) => {
if (eventId !== undefined) res.write(`id: ${eventId}\n`);
if (event) res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
const timer = setInterval(() => {
id += 1;
sendEvent("progress", { progress: Math.min(id * 10, 100) }, id);
}, 1000);
const heartbeat = setInterval(() => {
res.write(": heartbeat\n\n");
}, 15000);
req.on("close", () => {
clearInterval(timer);
clearInterval(heartbeat);
});
});
server.listen(3000);
这段代码中,每个部分承担不同职责:
Content-Type告诉浏览器按 SSE 文本流解析。Cache-Control避免缓存和不恰当的响应转换。Connection表达连接需要保持,但实际行为仍受 HTTP 版本和代理影响。X-Accel-Buffering为 Nginx 提供关闭缓冲的提示。flushHeaders()让响应头尽快离开应用层。sendEvent()统一处理id、event、data和事件末尾的空行。- 业务定时器产生带 ID 的事件,心跳定时器产生注释事件。
req.on("close")是资源生命周期的边界。
最后一点尤其容易被忽略。页面关闭或网络断开后,如果服务端仍然运行原来的定时器,就可能继续生成数据、保留订阅、持有连接引用,最终把长连接变成资源泄漏。生产实现通常还需要清理消息订阅、连接映射、数据库游标和其他与该请求绑定的资源。
八、SSE 的能力边界
把 SSE 放进正确的层次后,它的适用范围就很清晰:
| 问题 | SSE 能做什么 | 仍需要应用层解决什么 |
|---|---|---|
| 服务端主动通知 | 通过长 HTTP 响应持续发送文本事件 | 业务消息模型 |
| 消息分帧 | 用空行标记事件结束 | 载荷的 JSON 或其他编码 |
| 连接重试 | EventSource 自动尝试重连,retry 调整节奏 | 失败退避和错误分类 |
| 断线恢复 | 通过 id 和 Last-Event-ID 提供恢复位置 | 事件持久化、重放、过期和去重 |
| 空闲连接保活 | 用注释心跳保持链路活跃 | 代理超时与连接容量规划 |
| 双向通信 | 不负责客户端到服务端的持续推送 | 另发 HTTP 请求或改用 WebSocket |
总结:把“实时”拆成四个问题
SSE 并不神秘,它可以拆成四个相互关联但不能混为一谈的问题:
- 传输:使用一个不立即结束的 HTTP 响应。
- 分帧:使用
data等字段和空行表示一条事件。 - 到达:依赖应用、服务器、代理、TCP 和浏览器之间的缓冲与刷新链路。
- 可靠性:用
id、Last-Event-ID、持久化和去重策略实现业务恢复。
看完这篇文章,至少应该能够回答:
- 为什么 SSE 能减少轮询,却不能自动替代 WebSocket?
text/event-stream、EventSource和 HTTP 长响应分别处在哪一层?- 为什么
data: hello\n与data: hello\n\n的效果不同? - 为什么服务端每秒
res.write(),浏览器仍可能批量收到消息? retry、id、Last-Event-ID和心跳分别解决什么问题?- 为什么每个 SSE 连接都必须在
close时清理定时器和订阅?
真正可靠的 SSE 系统,不只是“把响应挂住然后不断写字符串”,而是同时处理了事件边界、链路缓冲、空闲超时、断线恢复和连接生命周期。