人人都会AI编程

22.3 SSE 服务端推送:适用场景与实现

更新时间:2026-07-10

在前两节中,我们分别讨论了 WebSocket 的全双工实时通信能力,以及 Socket.IO 在此基础上提供的生产级抽象。然而,并非所有的“服务端主动推送”场景都需要双向消息通道。当服务器只需向客户端单向发送数据流时,Server-Sent Events(SSE) 是一种更轻量、更简单的选择。

22.3.1 什么是 SSE

SSE(Server-Sent Events)是 HTML5 规范的一部分,它允许服务器通过普通的 HTTP 连接向客户端推送文本数据流。客户端使用浏览器内置的 EventSource API 来连接服务器端点,并自动处理重连、事件 ID 追踪和流解析。

与 WebSocket 不同,SSE 是严格单向的:数据只能从服务器流向客户端。客户端无法通过 SSE 连接主动向服务器发送消息,但它可以继续使用普通的 HTTP 请求(或另外的 WebSocket 连接)来进行上行通信。SSE 的消息格式非常简单,基于纯文本,遵循以下约定:

  • 每个字段以 field: value 的形式发送,多条消息之间用空行分隔。
  • 支持 data(数据)、event(事件类型)、id(消息ID)、retry(重连时间)等字段。
  • 默认事件类型为 message,可以通过 event 字段自定义。

例如,一段 SSE 响应体可能是这样:

data: 这是一条消息

data: {"user": "Alice", "msg": "hello"}
event: custom_event
id: 42

SSE 最显著的优势在于基于 HTTP 协议,这意味着它可以利用现有的 HTTP/2 多路复用、代理、缓存和认证基础设施,而无需像 WebSocket 那样单独处理协议升级和防火墙问题。

22.3.2 适用场景

SSE 特别适合需要服务器持续推送数据,而客户端不需要频繁发送数据的场合:

| 场景 | 为什么 SSE 合适 |
|------|----------------|
| 实时日志查看 | 服务器持续输出日志流,客户端只读不写。 |
| 股票行情、加密货币价格更新 | 单向数据流,数据更新频率高,但客户端只接收。 |
| 服务器处理进度通知 | 如文件上传后的转码进度、数据导出进度。 |
| 社交动态更新 | 服务器推送新微博、新评论等,客户端响应。 |
| AI 模型流式输出 | 如 ChatGPT 的逐字流式回复,一个长响应分段推送给前端。 |
| 数据库变更通知(变更数据捕获 CDC) | 后端监听数据库变更,通过 SSE 推送给前端界面,保持 UI 实时同步。 |

在这些场景中,SSE 比 WebSocket 更轻量,开发成本更低,且天然支持 HTTP/2,能够在同一 TCP 连接上高效复用。客户端重连机制也是内建的,不需要额外实现心跳或断线恢复逻辑。

需要注意的是,SSE 不适合双向高频交互(如在线游戏、聊天室),也不适合需要向服务器发送大量用户指令的实时协作应用。对于这些场景,WebSocket 仍然是更合适的选择。

22.3.3 客户端实现

客户端使用 EventSource 对象连接 SSE 端点,使用非常简单:

const evtSource = new EventSource('/events');

// 监听默认 message 事件
evtSource.onmessage = (event) => {
  console.log('收到数据:', event.data);
  // event.lastEventId 可以拿到服务端设置的 id
};

// 监听自定义事件类型
evtSource.addEventListener('custom_event', (event) => {
  console.log('自定义事件:', event.data);
});

// 连接打开处理
evtSource.onopen = () => {
  console.log('SSE 连接已建立');
};

// 错误处理(自动重连已内建,这里可以处理致命异常)
evtSource.onerror = (event) => {
  if (event.readyState === EventSource.CLOSED) {
    console.error('连接已关闭,无法自动重连');
  }
};

// 手动关闭连接
// evtSource.close();

EventSource 会自动处理重连:连接断开后它会等待一小段时间再重试,重试间隔可由服务器通过 retry 字段指定。服务器也可以通过 id 字段标记消息序列号,当连接中断后重连时,客户端会在请求头中发送 Last-Event-ID,让服务器知道从哪里继续推送,避免数据丢失。

22.3.4 Node.js 服务端实现

在 Node.js 中实现 SSE 端点的核心是设置正确的 HTTP 响应头,并使用 res.write() 持续输出数据。以下是一个使用原生 HTTP 模块和 Express 的示例。

原生 HTTP 实现

const http = require('http');

const server = http.createServer((req, res) => {
  if (req.url === '/events') {
    // 设置 SSE 响应头
    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
      'Access-Control-Allow-Origin': '*', // 允许跨域
    });

    // 发送初始注释行,部分代理会忽略纯空白流
    res.write(':ok\n\n');

    // 定期推送消息
    let id = 0;
    const interval = setInterval(() => {
      id++;
      const data = JSON.stringify({
        time: new Date().toISOString(),
        message: `消息 #${id}`,
      });

      res.write(`id: ${id}\n`);
      res.write(`data: ${data}\n\n`);
    }, 2000);

    // 连接关闭时清除定时器
    req.on('close', () => {
      clearInterval(interval);
      res.end();
    });
  } else {
    res.writeHead(404);
    res.end();
  }
});

server.listen(3000, () => {
  console.log('SSE 服务运行在 http://localhost:3000');
});

几个要点:

  • Content-Type: text/event-stream 是 SSE 的必需头部。
  • Cache-Control: no-cache 确保代理不缓存数据流。
  • Connection: keep-alive 保持长连接。
  • 每条消息最后必须有两个换行符 \n\n
  • 通过 req.on('close') 检测客户端断开连接,清理资源(如定时器、数据库监听)。

Express 实现

const express = require('express');
const app = express();

app.get('/events', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  });

  res.write(':ok\n\n');

  // 模拟数据库或业务事件
  const pushEvent = (data) => {
    res.write(`data: ${JSON.stringify(data)}\n\n`);
  };

  // 模拟一个数据源,比如 Redis 订阅
  const listener = (msg) => pushEvent(msg);
  // 订阅某个事件源...
  // subscriber.on('message', listener);

  req.on('close', () => {
    // 清理订阅
    // subscriber.off('message', listener);
  });
});

app.listen(3000);

如果需要在 Node.js 中管理多个 SSE 连接、广播消息或集成到更复杂的数据源,可以使用一些轻量级库如 sse-channelbetter-sse,但核心实现逻辑始终是维持连接并写入符合格式的文本。

22.3.5 生产环境注意事项

1. 连接数限制与并发

在 Node.js 中,SSE 会占用一个持久的 HTTP 连接。尽管 Node.js 擅长处理大量并发连接,但每个连接都占用一个 TCP socket 和部分内存。如果客户端数量巨大(十万级以上),需要考虑:

  • 使用 HTTP/2 让多个 SSE 流复用一个 TCP 连接(对客户端浏览器的支持有一定要求)。
  • 通过反向代理(如 Nginx)来分担负载,并配置合适的 proxy_buffering off; 以避免代理缓存导致推送延迟。
  • 监控进程的文件描述符限制和内存使用。

2. 连接中断与数据连续性

虽然 EventSource 会自动重连,但服务端若不做任何处理,重连后客户端可能会丢失断开期间的消息。要保证数据连续性,可以在服务端为每条消息设置递增的 id,并在重连时根据客户端发来的 Last-Event-ID 头,从断点处重放数据。通常的做法是在服务端记录每个用户已推送的最大消息 ID(或者用 Redis 保存当前偏移),以便从断点继续推送。

3. 心跳与僵尸连接检测

长连接可能因为网络中间设备(如路由器、代理)的空闲超时而断开。SSE 支持通过注释行(冒号开头的行)发送心跳,以保持连接活跃:

// 每隔 30 秒发送心跳
const heartbeatInterval = setInterval(() => {
  res.write(':heartbeat\n\n');
}, 30000);

这样即使没有业务数据,连接也不会被中间设备关闭。同时,服务端应在 req.on('close') 中清理定时器资源,避免内存泄漏。

4. 安全考虑

  • 对 SSE 端点实施认证和授权,如通过 Cookie 或 URL 查询参数传递 token。
  • 设置合理的 CORS 头,避免未授权站点发起 SSE 连接。
  • 注意不要将敏感数据意外暴露,因为 SSE 流量是明文的(除非使用 HTTPS)。

22.3.6 SSE 与 WebSocket 的选择

在决策 SSE 还是 WebSocket 时,可以参考以下对比:

| 特性 | SSE | WebSocket |
|------|-----|-----------|
| 数据方向 | 服务器→客户端单向 | 双向全双工 |
| 底层协议 | HTTP/1.1 或 HTTP/2 | 独立的 WebSocket 协议 (ws/wss) |
| 重连机制 | 浏览器内置自动重连 | 需手动实现 |
| 消息格式 | 文本(默认 UTF-8) | 文本或二进制 |
| 代理/防火墙兼容性 | 好,基于标准 HTTP | 可能被某些代理阻挠升级 |
| 实现复杂度 | 低(原生支持) | 中(需库或原生 WebSocket) |
| 适用场景 | 数据流、通知、进度条 | 聊天、游戏、协同编辑 |

在实践中,两者也可以组合使用:例如,使用 SSE 服务器单向推送通知,同时客户端通过普通 HTTP POST 发送用户操作。或者,在一个应用内用 WebSocket 处理双向交互,用 SSE 分担日志流、状态更新等单向数据流。

22.3.7 小结

SSE 是一种简单而强大的服务端推送技术,它利用标准 HTTP 协议提供可靠的单向数据流。在 Node.js 中实现 SSE 只需要正确设置响应头并持续写入数据,开发成本极低。它特别适用于实时日志、进度通知、流式输出等场景,尤其在现代 AI 接口(如 OpenAI 的流式对话)中被广泛采用。

理解了 SSE 的工作原理和实现细节之后,我们可以根据业务需求在实时通信的三件套——SSE、WebSocket、Socket.IO——中做出最合适的选择,既不“大材小用”,也能保证系统的可靠性和可维护性。