Skip to content
not500
Go back

SSE 技术详解:从长 HTTP 响应到重连、心跳与消息恢复

On this page

SSE(Server-Sent Events)经常被描述成“服务器主动推送消息”。这个说法没有错,但还不够完整:SSE 并不是一种新的传输层协议,而是浏览器基于 HTTP 长响应解析出来的一种文本事件流。

理解这一点很重要。SSE 的实时性来自响应保持打开,消息边界来自文本格式,断线恢复则需要应用层自己保存事件和处理消费位置。只有把这几层拆开,才能解释为什么 res.write() 已经执行,浏览器却可能迟迟收不到数据,也才能正确设计重连和资源清理。

一、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 请求的大致流程是:

  1. 浏览器通过 EventSource 发起一个 GET 请求。
  2. 服务器返回 Content-Type: text/event-stream
  3. 服务器不调用 res.end(),而是继续向响应体写入文本。
  4. 浏览器按 SSE 格式解析文本,遇到完整事件后触发回调。
  5. 连接直到客户端、服务器或中间链路关闭才结束。

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,浏览器却每隔几十秒一次收到多条事件。

排查时应沿链路检查:

Node.js 中可以使用 res.flushHeaders() 尽快发送响应头;部署在 Nginx 等代理后时,可以通过 X-Accel-Buffering: no 提示不要缓冲,代理侧也需要关闭相应的缓冲和缓存配置。flush 的含义是尽快把当前可发送的数据推进下一层,它不保证数据瞬间抵达浏览器,也不能替代心跳和超时设置。

五、自动重连不等于可靠消息

浏览器原生 EventSource 通常会在连接异常后尝试重新连接。服务器可以通过 retry 提供重连等待时间:

retry: 5000
id: 100
data: message-100

但自动重连只解决“连接重新建立”,并不解决“断线期间漏掉了哪些业务消息”。要实现恢复路径,通常需要三件事:

  1. 每条事件带有唯一且可比较的 id
  2. 服务端保存一段时间内可重放的事件。
  3. 客户端重连时把最后收到的事件位置带回服务端。

当客户端最后收到 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);

这段代码中,每个部分承担不同职责:

最后一点尤其容易被忽略。页面关闭或网络断开后,如果服务端仍然运行原来的定时器,就可能继续生成数据、保留订阅、持有连接引用,最终把长连接变成资源泄漏。生产实现通常还需要清理消息订阅、连接映射、数据库游标和其他与该请求绑定的资源。

八、SSE 的能力边界

把 SSE 放进正确的层次后,它的适用范围就很清晰:

问题SSE 能做什么仍需要应用层解决什么
服务端主动通知通过长 HTTP 响应持续发送文本事件业务消息模型
消息分帧用空行标记事件结束载荷的 JSON 或其他编码
连接重试EventSource 自动尝试重连,retry 调整节奏失败退避和错误分类
断线恢复通过 idLast-Event-ID 提供恢复位置事件持久化、重放、过期和去重
空闲连接保活用注释心跳保持链路活跃代理超时与连接容量规划
双向通信不负责客户端到服务端的持续推送另发 HTTP 请求或改用 WebSocket

总结:把“实时”拆成四个问题

SSE 并不神秘,它可以拆成四个相互关联但不能混为一谈的问题:

  1. 传输:使用一个不立即结束的 HTTP 响应。
  2. 分帧:使用 data 等字段和空行表示一条事件。
  3. 到达:依赖应用、服务器、代理、TCP 和浏览器之间的缓冲与刷新链路。
  4. 可靠性:用 idLast-Event-ID、持久化和去重策略实现业务恢复。

看完这篇文章,至少应该能够回答:

真正可靠的 SSE 系统,不只是“把响应挂住然后不断写字符串”,而是同时处理了事件边界、链路缓冲、空闲超时、断线恢复和连接生命周期。


Share this post:

Previous Post
消息队列,RabbitMQ视角。
Next Post
Java 21 虚拟线程