SSE 是"服务器→客户端"的单向广播;WebSocket 是"客户端⇄服务器"的双向对讲机
SSE(Server-Sent Events,服务端推送事件),顾名思义,是服务器单向往客户端"塞"数据的一种机制。它底层还是 HTTP,只是把连接保持住,服务器想什么时候推就什么时候推。浏览器原生给了个 EventSource API,拿来即用。
WebSocket,则是另一种协议。它在 HTTP 握个手之后,把这条 TCP 连接"升级"成全双工通道——客户端能发、服务器能发,两边随时互怼,谁也不用等谁。
看起来都是"实时",但一个只出不进,一个能进能出。这个本质差别,决定了后面所有的使用姿势。
二者的主要差异如下:
| 维度 | SSE | WebSocket |
|---|---|---|
| 通信模型 | 半双工、单向(仅服务器 → 客户端) | 全双工、双向(客户端 ⇄ 服务器) |
| 所处协议层 | 应用层,承载于 HTTP(HTTP/1.1 或 HTTP/2) | 独立应用层协议;握手借 HTTP,之后脱离 HTTP 语义 |
| 建连方式 | 标准 HTTP 请求,Accept: text/event-stream,响应后保持连接不关闭 | HTTP 请求带 Upgrade: websocket,服务器回 101 Switching Protocols 完成协议切换 |
| 传输单位 | 文本事件流(UTF-8 必选),空行分隔消息 | 二进制帧(frame):含 FIN / opcode / 掩码位,文本与二进制皆支持 |
| 客户端发消息 | 协议层不支持;需另起 fetch / XHR | 原生支持 |
| 数据类型 | 仅文本(UTF-8) | 文本 + 二进制(Blob / ArrayBuffer) |
| 断线重连 | 浏览器 EventSource 原生重连,并带 Last-Event-ID 续传 | 无内置,需业务层自实现心跳 + 退避重连 |
| 消息序号 / 续传 | 有:id: 字段 + Last-Event-ID 机制 | 无内置,需业务层自定义序号 / ACK |
| 并发连接 | 受 HTTP/1.1 每域约 6 条连接上限约束,多路 SSE 易占满连接池 | 单条"升级连接"承载双向,不占该限额 |
| 代理 / 防火墙 | 走标准 HTTP 长连接;但中间代理可能缓冲响应(需关缓冲) | 需代理支持 Upgrade;部分老旧代理 / 安全设备会拦截 |
| 浏览器原生 API | EventSource(含自动重连) | WebSocket(不含重连) |
| 同源策略 | 遵循标准同源 / CORS | 握手阶段走 CORS;之后不受同源限制,且无法自定义请求头(鉴权走 Cookie 或 query 参数) |
SSE 严格说不算新协议——它只是把一次普通的 HTTP 响应"不关掉"。客户端发 GET 请求,带 Accept: text/event-stream;服务器回 Content-Type: text/event-stream 且不结束响应,之后想推就推。它跑在 HTTP/1.1 上是长连接,跑在 HTTP/2 上则复用单条连接做多路复用。中间的 Nginx、CDN、负载均衡全当普通 HTTP 处理,几乎零适配成本——但有个坑在第 4 节说。
WebSocket 是在 HTTP 握手阶段"叛变"。客户端请求带:
text
Sec-WebSocket-Key 是 16 字节随机数经 Base64 得到。服务器必须把它和固定魔数 GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 拼接,做 SHA-1 再 Base64,作为 Sec-WebSocket-Accept 返回(RFC 6455 给出的标准示例值正是 s3pPLMBiTxaQ9kYGzzhZRbK+xOo=)。客户端校验通过、收到 101 Switching Protocols 后,这条 TCP 连接才正式切换为 WebSocket 协议,彻底脱离 HTTP 语义。
正因如此,老旧的代理、WAF、企业防火墙如果不理解 Upgrade,往往会直接断开这条连接——这是 WebSocket 穿透性差、常被拦的根源,不是玄学。
SSE 是纯文本事件流,且必须是 UTF-8。一条事件由若干行组成,字段前缀是硬约定:
sh
规则很硬:
data: 可多行,客户端自动拼接;事件以空行收尾。id: 给事件编号;断线重连时浏览器会把最后收到的 id 作为 Last-Event-ID 请求头带回(见第 3 节)。event: 自定义事件名,客户端用 addEventListener('price', ...) 监听,不指定则走默认 onmessage。retry: 指定重连间隔(毫秒)。curl 就能看流,调试极其友好。WebSocket 是基于消息、用帧封装。一条消息可能被拆成多个 frame,每个 frame 带:FIN 位(是否为消息最后一帧)、opcode(0x1 文本 / 0x2 二进制 / 0x8 关闭 / 0x9 ping / 0xA pong)、payload 长度,以及掩码位(规范强制:客户端 → 服务器 的帧必须掩码,服务器 → 客户端 不掩码)。文本与二进制通吃(收到的是 string 或 Blob / ArrayBuffer)。代价是:对象得自己 JSON.stringify / parse,分包、心跳格式全得业务层定。
这是 SSE 最容易被忽略、却最香的一点。EventSource 原生断线重连:连接一掉,浏览器按内部退避(约 3 秒起,实现相关)自动重连;如果服务器下发的事件带过 id:,重连请求会带上 Last-Event-ID 头,服务器据此从断点续推——这是协议级的续传保证。
WebSocket 则什么都没给:掉线就触发 onclose,没有自动重连、没有消息序号、没有续传。你要保活就得自己垒:应用层心跳、超时检测、onclose 里指数退避重连、必要时自己维护消息序号 / ACK 来做续传。喜闻乐见的"我又得自己造轮子"环节。
补充一个常被误解的点:WebSocket 协议确实有 ping / pong 控制帧(opcode
0x9/0xA),属于协议层保活。但浏览器实现的WebSocketAPI 不向你暴露发送协议级 ping 的能力——ping / pong 由浏览器在底层自动处理(收到服务器 ping 自动回 pong)。所以应用层想判断"连接是不是还活着",往往还是得自己发业务心跳消息。
并发连接数(SSE 的暗坑)。浏览器对同一域名的 HTTP/1.1 持久连接通常有约 6 条上限。SSE 每条流占一条长连接——如果你在前端同时开好几个 SSE(比如每个看板一个流),连接池很快被占满,连带普通接口请求都被阻塞、排不上队。WebSocket 是单条"升级连接"双向复用,不占这条限额。缓解办法:优先上 HTTP/2(多路复用,一条 TCP 承载多流),或把多个业务合并成一个 SSE 多路流(用 event: 区分)。
代理缓冲(SSE 的另一暗坑)。SSE 经过 Nginx、CDN 时,默认会缓冲响应——消息攒一批才一次性下发,表现就是"明明每秒在推,前端却一两秒才收到一次"。解决办法是在响应头声明不要缓冲:
text
并确保分块传输。WebSocket 一般不受缓冲影响,但反过来要求代理正确转发 Upgrade:
text
否则握手过不去。
一句话收这节:SSE 的短板不在"能力",而在"两条长连接相关的坑"(连接数、缓冲);WebSocket 的短板不在"能力",而在"什么都不给你,全得自己造"。
一句话:只要你的需求是"服务器单方面持续告诉客户端",SSE 基本都能优雅胜任,还白送自动重连。
一句话:只要"客户端也要持续往服务器发",或者要传二进制,上 WebSocket。