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 环境或跨平台混合应用中,为了兼容浏览器沙箱并实现实时推送,业界已经演化出了一套成熟的技术栈。
面对“服务器向客户端实时推送数据”这一需求,目前主流的解决方案包括:
- WebSocket:基于 TCP 的全双工通信协议,是实时互动的标准解法,适用于聊天室、即时游戏等双向高频交互场景。
- Server-Sent Events (SSE):基于 HTTP 的轻量级单向推送技术,专门用于服务器向客户端发送更新。
- gRPC (Streaming):基于 HTTP/2 的高性能 RPC 框架,支持双向流、服务端流等模式,常用于微服务间通信(Web 端需配合 gRPC-Web 代理)。
- 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 那样处理复杂的握手、心跳包或二进制帧格式。
- 自动重连:浏览器原生的
EventSourceAPI 内置了断线重连机制。
SSE 是如何工作的?
1. 建立连接(握手)
SSE 的连接建立完全是一个标准的 HTTP 请求,没有任何“魔法”。
- 客户端 发起一个普通的 GET 请求。
- 服务端 返回响应,但通过特殊的响应头(Headers)告诉浏览器:“我还没说完,保持连接,别挂断”。
关键的 HTTP 响应头:
1 | Content-Type: text/event-stream |
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 | data: Hello World\n\n |
浏览器端的 EventSource API 会自动解析这些文本流,每当遇到 \n\n 时,它就会触发一个 JavaScript 事件。
值得注意的是,在传输阶段,一个数据包就不再包含 HTTP/1.1 200 OK、Content-Type 这种包含几十上百字节的 HTTP 头信息了。而是就是在上述字符串的基础上,包了一层极薄的包装。假设我们要发送的消息是:
1 | data: {"speed": 100}\n\n |
大多数 SSE 服务器(Node.js, Nginx 等)都会开启 Chunked Transfer Encoding。实际上发送的数据一般是这样的:
1 | 17\r\n <-- 1. 包装:表示接下来有 0x17 (即23) 个字节 |
没有 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 | // server.js |
客户端实现 (HTML/JS)
浏览器内置了 EventSource 类来处理 SSE。注意,原生的 EventSource 只支持 GET 请求,且无法自定义请求头(如 Authorization)。如果需要鉴权,通常通过 URL 参数传递 Token(例如 ?token=xxx)或使用第三方库(如 fetch-event-source)。
1 |
|
通过这个例子我们可以看到,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 编码。
- 带宽膨胀:Base64 编码会使数据体积增加约 33%。对于高频传输,这是巨大的带宽浪费。
- 算力损耗:服务端需要
Serialize -> Base64 Encode,浏览器端需要Base64 Decode -> Deserialize。在几百个传感器、毫秒级刷新的压力下,这会造成无谓的 CPU 飙升和电池消耗。
而 WebSocket 允许直接发送 ArrayBuffer 或 Blob。
- 零膨胀:服务端接收到的 UDP Protobuf 数据,可以原封不动地(Pass-through)透传给前端。
- 高性能:前端通过
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 则是必须关注的未来。
技术选型的本质,是对业务需求、开发成本与运维复杂度的三方平衡。我们不应盲目追求“最新”或“最全”的协议,而应深入理解每一种协议底层的设计哲学,从而在不同的业务场景下,精准地拿起最顺手的那把武器。