Server-Sent Events:现代 Web 流式传输

Server-Sent Events:现代 Web 流式传输

引言:对“实时”的极致渴求

在现代软件开发中,流传输(Streaming) 已不再是边缘需求,而是构建高质量用户体验的核心能力。无论是在工业物联网(IIoT)中监控成百上千个传感器实时回传的压力与温度,还是在金融交易终端上毫秒级刷新股价与 K 线,亦或是运维大屏上动态跳动的 QPS 曲线,其背后的诉求是一致的:前端界面必须以近乎“零延迟”的感知,将数据的变化可视化地呈现给用户。

架构演进中的数据获取难题

在早期的单体架构(Monolithic Architecture) 时代,实现这种“实时性”相对单纯。

当数据采集、业务逻辑处理与 UI 渲染都运行在同一个进程(Process)甚至同一个线程中时,数据传输的本质仅仅是内存变量的读取与更新。以早期的 WinForms 或 WPF 桌面应用为例,开发者往往只需通过事件委托(Delegate)或观察者模式监听内存中数据模型的变更,即可直接刷新界面。这里不存在网络延迟,没有序列化/反序列化的开销,更无需考虑协议的转换。

然而,随着系统复杂度的指数级增长,架构必然向分布式演进。应用被拆分为经典的 C/S(Client/Server)B/S(Browser/Server) 模式。在这种模式下,数据不再共享内存,网络成为了新的边界。

  • 服务端(Server):负责数据的聚合、处理与持久化,对外暴露 API。
  • 客户端(Client):无论是原生 App 还是 Web 页面,都需要通过网络协议(HTTP, TCP, WebSocket 等)跨越物理隔离来获取数据。

原生与 Web:不同的权限,不同的策略

虽然从宏观架构上看,B/S 可以被视为 C/S 的一种特例,但在流式数据传输的技术选型上,两者的底层约束天差地别。

原生客户端(Native Client)

原生应用(如使用 C++ Qt、C# WPF 编写的程序)在用户授权后,拥有操作系统级别的权限。它们是一等公民:

  • 网络自由:可以自由创建原始的 TCP/UDP 套接字(Socket)。
  • 协议直连:在极端追求性能的场景下,桌面监控端甚至可以直接通过 TCP 协议连接 Kafka 集群或 Redis 实例,订阅 Topic 并实时消费数据。尽管从分层架构角度看,客户端直连中间件存在耦合风险,但这在技术实现上完全畅通无阻。

浏览器客户端(Browser Client)

相比之下,现代浏览器是一个高度受限的沙箱环境(Sandbox)。出于安全考虑(防止恶意脚本扫描内网、读写本地文件),浏览器对底层资源的访问有着严格限制:

  • 网络受限:网页代码无法建立任意的原始 TCP 连接。
  • 协议隔离:浏览器无法直接通过 Kafka 或 Redis 的原生协议进行通信。它必须依赖标准的应用层协议(如 HTTP、WebSocket)作为载体。因此,Web 应用必须引入一个中间层(Web Server 或 Gateway),负责将后端的多样化数据“翻译”并转发给浏览器。

边界的模糊:混合架构的兴起

近年来,"Write Once, Run Anywhere" 的理念催生了混合架构,C/S 与 B/S 的界限逐渐模糊。现代应用的主流形态已演变为:“统一云端服务 + 多形态终端”

最典型的案例莫过于 VS Code: * 桌面版(Electron):本质上是 Chromium 浏览器内核 + Node.js 运行时。虽然 UI 由 HTML/CSS/JS 构建,但得益于 Node.js 的集成,它突破了沙箱限制,能够调用 Shell 指令、访问本地文件系统、甚至开启子进程建立任意 Socket 连接。 * Web 版(vscode.dev):纯粹运行在标准浏览器中。即便拥有相同的 UI 代码,它却失去了 Node.js 的能力。受限于浏览器沙箱,它无法直接读写本地磁盘(需依赖 File System Access API 或远程文件系统),也无法直接连接本地的 TCP 服务。

技术选型:构建 Web 通信的工具箱

上述讨论引出了一个关键结论:在设计数据流(Streaming)方案时,运行环境(Native vs Web)决定了传输策略的上限。特别是在 Web 环境或跨平台混合应用中,为了兼容浏览器沙箱并实现实时推送,业界已经演化出了一套成熟的技术栈。

面对“服务器向客户端实时推送数据”这一需求,目前主流的解决方案包括:

  1. WebSocket:基于 TCP 的全双工通信协议,是实时互动的标准解法,适用于聊天室、即时游戏等双向高频交互场景。
  2. Server-Sent Events (SSE):基于 HTTP 的轻量级单向推送技术,专门用于服务器向客户端发送更新。
  3. gRPC (Streaming):基于 HTTP/2 的高性能 RPC 框架,支持双向流、服务端流等模式,常用于微服务间通信(Web 端需配合 gRPC-Web 代理)。
  4. WebTransport:基于 QUIC/HTTP3 的下一代传输协议,旨在提供低延迟、非可靠传输能力,面向未来的云游戏与实时媒体传输。

在这个博客中,我们将首先深入探讨 SSE。作为一种轻量级方案,它在监控大屏、AI 生成式对话(如 ChatGPT 的打字机效果)等场景中展现出了惊人的优势。而在后续的文章中,我们也将逐步学习整理 gRPC 和 WebTransport。

SSE:基于 HTTP 的流式传输

概述

我们之前介绍过 WebSocket。作为一种全双工通信协议,WebSocket 允许客户端和服务器之间进行自由的双向对话。它的建立过程虽然依赖一次 HTTP 握手(Upgrade),但随后便脱离了 HTTP 的语义,直接在 TCP 上封装了一套独立的二进制帧协议。

和 WebSocket 不同,SSE 并非一种全新的网络协议,而是基于 HTTP 协议的一种标准机制(Standardized under HTML5)。它允许服务器在建立连接后,不立即关闭连接,而是保持连接打开(Open Connection),并通过在这个单一的 HTTP 连接中持续发送文本流的方式,将数据推送到客户端。所以后面我们也可以看到,WebSocket 因为是直接基于 TCP 的,因此需要单独开一个端口。而 SSE 是基于 HTTP 的,因此新增一个 Path 端点即可。

SSE 的核心特征是:

  • 单向通信:数据只能从 Server 流向 Client。
  • 基于文本:专门传输 UTF-8 文本数据(通常是 JSON)。
  • 轻量级:无需像 WebSocket 那样处理复杂的握手、心跳包或二进制帧格式。
  • 自动重连:浏览器原生的 EventSource API 内置了断线重连机制。

SSE 是如何工作的?

1. 建立连接(握手)

SSE 的连接建立完全是一个标准的 HTTP 请求,没有任何“魔法”。

  1. 客户端 发起一个普通的 GET 请求。
  2. 服务端 返回响应,但通过特殊的响应头(Headers)告诉浏览器:“我还没说完,保持连接,别挂断”。

关键的 HTTP 响应头:

1
2
3
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
  • Content-Type: text/event-stream: 这是 SSE 的身份证,告诉客户端接下来的 Body 是事件流,不是普通的 HTML 或 JSON。
  • Cache-Control: no-cache: 必须禁用缓存,否则实时消息可能会被中间的代理服务器或浏览器缓存拦截。
  • Connection: keep-alive: 明确告知 TCP 连接需要保持打开。

2. 传输数据(帧格式)

连接建立后,服务端通过这个长连接发送的数据必须遵循特定的文本格式。可以把它理解为 SSE 的“帧”。

SSE 的数据流由一系列字段(Field)组成,每个字段由 \n(换行符)结尾,而一条完整的消息\n\n(双换行)结尾。

常见的字段包括:

  • data: 消息的数据载体(最常用)。
  • event: 自定义事件名称(允许客户端监听特定类型的消息)。
  • id: 消息 ID(用于断线重连时,客户端告知服务器处理到哪条消息了)。
  • retry: 建议浏览器的重连间隔时间(毫秒)。

一个典型的数据流示例:

1
2
3
4
5
6
7
8
9
data: Hello World\n\n

data: {"count": 1}\n\n

event: price_update\n
data: {"symbol": "AAPL", "price": 150}\n\n

id: 101\n
data: message with id\n\n

浏览器端的 EventSource API 会自动解析这些文本流,每当遇到 \n\n 时,它就会触发一个 JavaScript 事件。

值得注意的是,在传输阶段,一个数据包就不再包含 HTTP/1.1 200 OKContent-Type 这种包含几十上百字节的 HTTP 头信息了。而是就是在上述字符串的基础上,包了一层极薄的包装。假设我们要发送的消息是:

1
data: {"speed": 100}\n\n

大多数 SSE 服务器(Node.js, Nginx 等)都会开启 Chunked Transfer Encoding。实际上发送的数据一般是这样的:

1
2
3
17\r\n                       <-- 1. 包装:表示接下来有 0x17 (即23) 个字节
data: {"speed": 100}\n\n <-- 2. 你的核心货品 (23字节)
\r\n <-- 3. 包装:一个回车换行结束符

没有 HTTP 头部,额外开销仅仅是一个十六进制的长度数字(17)和几个换行符(),加起来也就几字节。

HTTP 对 SSE 的支持

什么是长连接(Keep-Alive)?

在 Web 的早期(HTTP/1.0),HTTP 是真正的“无连接”协议。浏览器每次请求一个资源(如图片、CSS),都要经历 建立 TCP 连接 -> 传输数据 -> 关闭 TCP 连接 的过程。这在性能上非常浪费。

到了 HTTP/1.1持久连接(Persistent Connection) 也就是俗称的 Keep-Alive 成为了默认标准。它的含义是:“请求处理完了,TCP 连接先别断,后续的 HTTP 请求还可以复用这个通道。”

SSE 正是利用了 HTTP/1.1 的这一特性。但 SSE 做得更极致:它不仅复用连接,而且在这个请求没结束之前,一直占用着这个连接不放,通过它源源不断地写数据。

HTTP/1.1 vs HTTP/2:SSE 的性能分水岭

HTTP 的不同版本对 SSE 的支持有巨大差异:

  • HTTP/1.0:基本不支持 SSE。因为缺乏持久连接的标准机制。
  • HTTP/1.1支持,但有瓶颈。
    • 浏览器对同一个域名的 TCP 连接数是有上限的(Chrome 等现代浏览器通常限制为 6 个)。
    • 这意味着,如果你在一个页面中打开了 6 个 SSE 连接(或者 6 个标签页各连一个 SSE),浏览器的连接池就被占满了。第 7 个请求(甚至是加载一张图片)会被阻塞,处于 pending 状态。这是 SSE 在 HTTP/1.1 下最大的痛点。
  • HTTP/2完美支持。
    • HTTP/2 引入了 多路复用(Multiplexing)
    • 所有请求共享同一个 TCP 连接。SSE 流只是这个单一 TCP 连接中的一个虚拟“流(Stream)”。
    • 因此,在 HTTP/2 下,SSE 不再占用宝贵的浏览器连接数名额,可以轻松建立几十上百个 SSE 流而不会导致浏览器阻塞。

结论:在现代 Web 开发中,尽量在 HTTP/2 环境下使用 SSE,以获得最佳性能。

简单的 SSE 实现

SSE 的美妙之处在于其 API 的简洁性。对于后端,它只是一个特殊的 HTTP 响应;对于前端,它是一个标准的 EventSource 对象。

服务端实现 (Node.js)

我们通过一个原生 Node.js 示例来演示。注意看响应头的设置,这是 SSE 生效的关键。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// server.js
const http = require('http');

const server = http.createServer((req, res) => {
// 允许跨域(生产环境请限制域名)
res.setHeader('Access-Control-Allow-Origin', '*');

if (req.url === '/events') {
// 1. 设置核心响应头
res.writeHead(200, {
'Content-Type': 'text/event-stream', // 声明这是 SSE 流
'Cache-Control': 'no-cache', // 禁止缓存
'Connection': 'keep-alive' // 保持长连接
});

// 2. 发送初始数据(可选)
// 注意:SSE 实际上是流式传输,所以我们使用 res.write 而不是 res.end
res.write('data: Connected to SSE server\n\n');

// 3. 模拟周期性推送
let id = 0;
const interval = setInterval(() => {
id++;
const data = JSON.stringify({
time: new Date().toISOString(),
count: id
});

// 构造符合 SSE 规范的字符串格式
// 格式:field: value\n
// 结束:\n
res.write(`id: ${id}\n`);
res.write(`data: ${data}\n\n`); // \n\n 表示一条消息结束
}, 1000);

// 4. 清理工作:当客户端断开连接(关闭页面)时触发
req.on('close', () => {
console.log('Client disconnected');
clearInterval(interval); // 务必清除定时器,防止内存泄漏
res.end(); // 结束响应
});
} else {
// 普通 HTTP 接口
res.writeHead(404);
res.end();
}
});

server.listen(3000, () => {
console.log('SSE Server running on http://localhost:3000/events');
});

客户端实现 (HTML/JS)

浏览器内置了 EventSource 类来处理 SSE。注意,原生的 EventSource 只支持 GET 请求,且无法自定义请求头(如 Authorization)。如果需要鉴权,通常通过 URL 参数传递 Token(例如 ?token=xxx)或使用第三方库(如 fetch-event-source)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>SSE Demo</title>
</head>
<body>
<h1>Server-Sent Events 实时流</h1>
<div id="status" style="color: gray;">正在连接...</div>
<div id="output"></div>

<script>
// 1. 建立连接
const evtSource = new EventSource('http://localhost:3000/events');

// 2. 监听连接打开
evtSource.onopen = function() {
document.getElementById('status').innerText = "已连接 (Connected)";
document.getElementById('status').style.color = "green";
};

// 3. 监听消息(默认事件)
evtSource.onmessage = function(event) {
// event.data 是服务器发来的文本数据
const data = JSON.parse(event.data);
const el = document.getElementById('output');
el.innerHTML = `
<p>
<strong>ID:</strong> ${event.lastEventId} <br/>
<strong>Time:</strong> ${data.time} <br/>
<strong>Count:</strong> ${data.count}
</p>
`;
};

// 4. 监听错误
evtSource.onerror = function(err) {
console.error("EventSource failed:", err);
document.getElementById('status').innerText = "连接中断";
document.getElementById('status').style.color = "red";
// EventSource 会默认尝试重连,不需要手动编写重连逻辑
};
</script>
</body>
</html>

通过这个例子我们可以看到,SSE 的实现几乎没有引入新的复杂度,完全复用了现有的 HTTP 基础设施。这使得它在运维监控、日志推送、简单通知等场景下,比 WebSocket 更加轻便。

SSE vs WebSocket

了解了 SSE 的原理与实现后,我们不可避免地要面临一个问题:在 SSE 和 WebSocket 之间,我该如何抉择?

很多开发者存在一个误区,认为 WebSocket 是“全能型”选手,而 SSE 只是“低配版”。事实上,这两种技术在设计初衷上就代表了不同的哲学:SSE 追求的是“HTTP 协议内的极致简洁”,而 WebSocket 追求的是“跨越 HTTP 的独立通道”

为了做出最正确的架构决策,我们需要从以下几个维度进行深度解剖,特别是针对工业监控这类对性能敏感的场景。

协议开销与数据封装 (Overhead)

你可能会问:SSE 后续传输时,真的连 HTTP 头都没有了吗?

答案是肯定的。SSE 的连接建立依赖一次标准的 HTTP 握手(包含完整的 Request/Response Headers),一旦连接建立(Status 200 OK),后续的数据传输就变成了“在同一个 Response Body 内的持续追加写入”

  • SSE 的流式封装

    SSE 的每一次推送不需要重复发送 Host, Cookie, User-Agent 等 HTTP 头。它只有极简的文本前缀(如 data:)。

    • 开销:极低(仅几个字节的文本标记)。
    • 场景:非常适合纯文本数据推送。
  • WebSocket 的帧封装

    WebSocket 在握手完成后,切换到了独立的 TCP 二进制协议。

    • 开销:每个数据包都有一个 2~14 字节 的帧头(Frame Header),用于标记 Fin 位、操作码(Opcode)、掩码(Mask)和负载长度。
    • 场景:虽然帧头很小,但在极高频(如 100Hz+)发送极小包的场景下,这依然是一笔开销。但在处理二进制数据时,这点开销往往被其优势抵消(详见下文)。

连接复用与并发 (HTTP/2 Multiplexing)

这是 SSE 常常被忽视的“杀手锏”。

  • SSE (HTTP/2 下的王者): 在 HTTP/2 环境下,SSE 能够完美利用 多路复用(Multiplexing) 特性。这意味着,无论你在一个页面中开启了多少个 SSE 订阅,或者同时还在加载图片和 CSS,它们都可以共享同一个 TCP 连接
    • 优势:不仅节省了服务器的文件描述符(File Descriptor),还分摊了 TCP/TLS 握手的耗时。
  • WebSocket (独占的代价): 虽然 RFC 8441 提出了 HTTP/2 over WebSocket,但目前的主流实现中,建立一个 WebSocket 连接通常仍意味着要独占一个 TCP 连接
    • 劣势:如果开发的是一个多窗口的看板应用,每个窗口都建立独立的 WS 连接,会迅速消耗服务端的资源,且容易触碰浏览器的并发限制。

致命分界线:数据类型与编码效率

如果说前面的对比是“优劣之分”,那么这一点就是“生死之判”

WebSocket 天生支持二进制(Binary),而 SSE 仅支持 UTF-8 文本

让我们回到一个具体场景:工业监控。假设数据源是 UDP 协议,数据格式采用了高效的 Protobuf (Protocol Buffers),且频率极高(如 50Hz)。由于 SSE 只能传输文本,要发送 Protobuf 的二进制数据,服务端必须先将其进行 Base64 编码

  1. 带宽膨胀:Base64 编码会使数据体积增加约 33%。对于高频传输,这是巨大的带宽浪费。
  2. 算力损耗:服务端需要 Serialize -> Base64 Encode,浏览器端需要 Base64 Decode -> Deserialize。在几百个传感器、毫秒级刷新的压力下,这会造成无谓的 CPU 飙升和电池消耗。

而 WebSocket 允许直接发送 ArrayBufferBlob

  1. 零膨胀:服务端接收到的 UDP Protobuf 数据,可以原封不动地(Pass-through)透传给前端。
  2. 高性能:前端通过 binaryType = 'arraybuffer' 接收原始字节流,直接喂给 Protobuf 解码器,链路损耗最低。

网络穿透与运维复杂性

  • SSE:因为它本质上就是普通的 HTTP 请求,所以对企业防火墙、Nginx 负载均衡、API 网关极其友好。除了需要调整 Nginx 的 proxy_read_timeout 避免超时外,几乎是“零配置”。
  • WebSocket:作为一种“有状态”的协议,它容易被严格的企业防火墙(Firewall)拦截。在配置反向代理时,需要专门设置 Upgrade 头。此外,WebSocket 的心跳保活(Ping/Pong)断线重连通常需要开发者在应用层手动实现,复杂度略高。

决策矩阵

在面对“实时数据流”需求时,可以参考以下决策路径:

选择 SSE 的场景

  • 数据形态纯文本(JSON, XML, String)。
  • 业务类型:ChatGPT 式的流式回复、股票行情(文本数值)、新闻推送、系统日志监控、站内信通知。
  • 核心诉求:开发简单、兼容 HTTP 现有设施、利用 HTTP/2 复用连接、单向推送。

选择 WebSocket 的场景

  • 数据形态二进制(Protobuf, 音视频流, 图片)。
  • 业务类型:工业大屏(高频传感器)、在线游戏(低延迟同步)、即时通讯(IM)、协作编辑。
  • 核心诉求:双向通信能力、对带宽极其敏感、需要传输二进制载荷。

面向未来的选择:WebTransport

如果在工业场景中,不仅面临 Protobuf 效率问题,还深受 TCP 队头阻塞(Head-of-Line Blocking) 的困扰(即网络抖动导致实时数据卡顿积压),那么 WebTransport 是终极方案。 * 它是基于 HTTP/3 (QUIC/UDP) 的下一代标准。 * 它支持无序、不可靠的 Datagram 传输(类似 UDP)。 * 它像 WebSocket 一样支持二进制,但没有 TCP 的“重传卡顿”问题,是实时波形图和云游戏的未来。

总而言之:

  • 如果是推 JSON 文本,选 SSE 省心又省力;
  • 如果是推 Protobuf 二进制,选 WebSocket 别犹豫;
  • 如果是 极低延迟的 UDP 直通,请关注 WebTransport

总结:回归场景,理性选型

回顾 Web 实时通信技术的发展历程,我们发现这并不是一个“后浪推前浪,前浪死在沙滩上”的淘汰过程,而是一个工具箱不断丰富的过程。

SSE 曾因 WebSocket 的耀眼光芒而被许多开发者遗忘,但在微服务架构普及和 HTTP/2、HTTP/3 逐步落地的今天,它再次证明了自己的独特价值。它不需要复杂的握手协议,不需要庞大的 SDK,仅凭一个 Content-Type: text/event-stream 就能以最轻量级的方式打通服务端到客户端的“最后一公里”。对于 ChatGPT 式的流式对话、即时通知、日志监控 等以文本为主的单向场景,SSE 无疑是性价比最高的选择。

然而,正如我们在对比章节中所强调的,软件工程没有银弹。 * 如果业务涉及高频二进制数据(如 Protobuf 格式的工业传感器读数),或者需要低延迟的双向交互(如在线游戏),那么 WebSocket 依然是合适的选择。 * 如果追求的是极致的传输性能,试图在不可靠的网络环境中实现类似 UDP 的数据直达,那么基于 HTTP/3 的 WebTransport 则是必须关注的未来。

技术选型的本质,是对业务需求、开发成本与运维复杂度的三方平衡。我们不应盲目追求“最新”或“最全”的协议,而应深入理解每一种协议底层的设计哲学,从而在不同的业务场景下,精准地拿起最顺手的那把武器。