WebSocket 与 SSE:长连接通信的常见方案

做后端的人迟早会接到这样一个需求:服务端的数据变了,要"立刻"推给浏览器——可能是一条新消息、一次订单状态变化、一行实时日志、一个监控指标的跳动。可传统 HTTP 是"一问一答"的:客户端不开口,服务端就没机会说话。于是很多人第一反应是写个定时器,每隔一秒发一次请求问"有没有新东西"。这能跑,但你心里清楚它不优雅,也扛不住量。

WebSocket 和 SSE 就是为这个场景准备的两套方案。但大多数文章一上来就比较"谁更强",却很少有人讲清楚:这两条长连接到底是怎么在 TCP 之上建立起来的?升级握手发了什么报文?数据帧长什么样?断了之后浏览器是怎么自动重连的? 结论悬在空中,选型自然也站不稳。

所以这一篇我们换个顺序:先把两套机制的运转过程拆开看清楚——WebSocket 怎么从一个普通 HTTP 请求"升级"成全双工连接、SSE 的事件流是怎样一行行推下来的——看懂了机制,再回过头谈该怎么选、长连接会带来哪些工程问题。

一、先认识舞台:它们都建在 TCP 长连接之上

在拆机制之前,先把一个共同前提摆正:无论 WebSocket 还是 SSE,底层都是一条不主动关闭的 TCP 连接

普通 HTTP 请求的生命周期是:建立连接 → 发请求 → 收响应 → (可能)关连接。即使有 Keep-Alive,那也只是"连接复用"——一次请求收完响应,连接闲置等下一次请求,服务端在闲置期间没法主动往里塞数据

长连接技术的核心诉求只有一个:让这条 TCP 连接在"收完一次响应"之后不进入闲置等待,而是持续保持可写,从而给服务端一个主动发送的通道。

WebSocket 和 SSE 是用两种完全不同的手法达成这个诉求的。SSE 走的是"把一次 HTTP 响应无限拉长"的路子;WebSocket 走的是"先借 HTTP 握手,再彻底换掉协议"的路子。下面分别拆开。

二、SSE 的机制:一次永不结束的 HTTP 响应

SSE(Server-Sent Events)的聪明之处在于,它根本没发明新协议,而是复用了 HTTP 的分块传输能力

客户端发起的,就是一个普通的 GET 请求,只是请求头里声明它想要事件流:

1
2
3
GET /stream HTTP/1.1
Host: example.com
Accept: text/event-stream

服务端的响应头是这套机制的关键:

1
2
3
4
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

注意 Content-Type: text/event-stream,以及——响应没有 Content-Length。普通响应要靠 Content-Length 或分块编码告诉客户端"内容到此为止";SSE 故意不给结束信号,于是这条响应在客户端看来就是一个永远没读完的 body。服务端此后想推送时,就往这个 body 里追加文本:

1
2
3
4
5
data: 第一条消息\n\n

data: {"price": 100}\n\n

event: ping\ndata: keepalive\n\n

这就是 SSE 的全部线上格式。它是纯文本、按行解析的:

  • data: 行携带数据,可以有多行,浏览器会拼接;
  • 连续两个换行 \n\n 表示一个事件结束,触发前端一次 message 回调;
  • event: 行可以给事件命名,前端用 addEventListener('ping', ...) 分类监听;
  • id: 行给事件编号,这个编号是断线重连的关键,后面会讲;
  • retry: 行可以告诉浏览器重连前等多久。

前端的使用极简:

1
2
3
const es = new EventSource('/stream');
es.onmessage = (e) => console.log(e.data);
es.addEventListener('ping', (e) => console.log('心跳', e.data));

SSE 的本质是"把一次 HTTP 响应的 body 当成无限流,服务端按一种约定好的文本格式不断往里写"。它没有跳出 HTTP,所以天然兼容 HTTP 的一切基础设施。

三、SSE 最被低估的机制:自动断线重连

SSE 真正省心的地方,是浏览器内建了重连逻辑,而这套逻辑是和 id: 字段配合工作的。

机制是这样的:每当服务端发的事件带了 id:,浏览器就会把这个 id 记下来作为"最后收到的事件 ID"。一旦连接断了(网络抖动、代理超时),EventSource 会自动发起重连,并且在新请求的头里带上:

1
Last-Event-ID: 42

服务端只要读这个头,就知道"客户端已经收到第 42 条了",从第 43 条继续推即可,不重不漏。这一整套断点续传,前端一行代码都不用写——这是 WebSocket 不具备的、需要你自己实现的能力。

四、WebSocket 的机制(上):升级握手怎么发生

WebSocket 的路子完全不同。它要的是一条全双工通道,双方都能随时主动发,所以它不能停留在 HTTP 的请求-响应语义里,必须"换协议"。但它又想复用 HTTP 的 80/443 端口和已有链路,于是设计了一次协议升级握手

先用一张时序图看整体,再拆字段:

sequenceDiagram participant C as 浏览器 participant S as 服务端 Note over C,S: 已建立 TCP 连接 C->>S: HTTP GET + Upgrade: websocket<br/>Sec-WebSocket-Key: dGhlIHNhbXBsZQ== Note over S: 校验 Key,计算 Accept S->>C: HTTP 101 Switching Protocols<br/>Sec-WebSocket-Accept: s3pPLMBi... Note over C,S: 协议切换完成,同一条 TCP<br/>之后跑 WebSocket 帧,全双工 C->>S: WS 帧(文本/二进制) S->>C: WS 帧(可随时主动推)

第一步——客户端发升级请求。 它表面是个 HTTP GET,但带了几个特殊头:

1
2
3
4
5
6
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

Upgrade: websocketConnection: Upgrade 是在告诉服务端(以及沿途的代理):“我想把这条连接的协议从 HTTP 换成 WebSocket。” Sec-WebSocket-Key 是客户端随机生成的一段 base64,用于握手校验。

第二步——服务端回 101。 如果服务端同意,它不返回常见的 200,而是返回一个特殊状态码:

1
2
3
4
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

101 Switching Protocols 这个状态码字面意思就是"协议切换中"。Sec-WebSocket-Accept 是服务端把客户端的 Sec-WebSocket-Key 拼上一个固定的魔法字符串(258EAFA5-E914-47DA-95CA-C5AB0DC85B11),做一次 SHA-1 再 base64 算出来的。

这个校验值不是为了加密,而是为了防止误升级——证明服务端确实"听懂"了 WebSocket 握手,而不是某个缓存或中间设备碰巧回了个 101。客户端拿到后会用同样算法验证一遍,对得上才认为升级成功。

第三步——协议正式切换。 101 之后,这条 TCP 连接上不再跑 HTTP,双方改用 WebSocket 的帧格式通信。从此它就是一条全双工管道,谁都能主动发。

五、WebSocket 的机制(下):帧结构与掩码

升级完成后,数据不再是 HTTP 报文,而是 WebSocket 帧(frame)。理解帧结构能帮你看懂很多线上现象。一个帧的头部大致包含:

  • FIN 位:标记这是不是一条消息的最后一帧(一条大消息可以拆成多帧);
  • opcode:帧类型,常见的有 0x1(文本)、0x2(二进制)、0x8(关闭)、0x9(ping)、0xA(pong);
  • MASK 位 + 掩码:所有客户端发往服务端的帧都必须用一个随机掩码做异或处理
  • payload length:负载长度,用变长编码,小消息 1 字节,大消息扩展到 2 或 8 字节;
  • 负载数据本身。

这里有个常被问到的设计点:为什么客户端发的帧必须加掩码? 这不是为了安全(掩码键是明文发的,毫无保密性),而是为了防止缓存投毒攻击。早期有中间代理会把 WebSocket 流量误当成 HTTP 来缓存,攻击者可以构造看起来像 HTTP 请求的负载去污染代理缓存。强制掩码后,负载在每一帧都被随机异或打乱,代理无法把它误解析成可缓存的 HTTP 内容。

ping/pong 这两个控制帧(0x9/0xA)就是 WebSocket 内建的心跳机制。服务端定期发 ping,客户端自动回 pong,用来探测连接是否还活着、并防止中间设备因空闲而切断连接。

六、把两套机制并排看:差异从机制里自然长出来

看懂了两边的运转方式,差异就不用死记了,它是机制决定的:

维度 SSE WebSocket
底层 一条拉长的 HTTP 响应 HTTP 升级后换成 WS 帧
方向 单向(服务端→客户端) 全双工
数据格式 纯文本 text/event-stream 文本 / 二进制帧
断线重连 浏览器内建 + Last-Event-ID 需自己实现
走 HTTP 基础设施 完全兼容 需代理支持 Upgrade

SSE 是单向的,因为它本质就是一次响应流——客户端想发数据,还得另开普通 HTTP 请求。WebSocket 是全双工的,因为它已经脱离了请求-响应语义。这不是谁"功能更全",而是两条机制路径的必然结果。

七、回到设计:到底该怎么选

现在再谈选型,就有根据了。判断标准不是"谁先进",而是你的通信模型是单向还是双向

  • 服务端单向推送为主——日志流、进度条、行情广播、通知中心、AI 流式输出(ChatGPT 那种逐字蹦字,用的就是 SSE)。选 SSE:实现轻、自动重连、纯 HTTP 兼容好。
  • 双向频繁交互——聊天、协同编辑、多人游戏、实时信令。选 WebSocket:全双工,客户端也能随时主动发。

很多选型争论之所以没结论,是因为没先把"通信模型是单向还是双向"这条边界说清。先定模型,再定技术,争论自然就消失了。

一个常被忽略的现实因素:SSE 跑在 HTTP/1.1 上时,浏览器对同一域名的并发连接数有限制(通常 6 个),多个 SSE 流会很快占满;换到 HTTP/2 多路复用之后这个问题才缓解。WebSocket 则每条都是独立 TCP 连接,不受这个限制,但也意味着连接数本身就是要管理的资源。

八、长连接真正的难点:不在"连上",在"长期稳定地连着"

无论选哪个,一旦连接数上量,真正的工程难题才开始浮现。这些问题在一问一答的 HTTP 里几乎不存在,但在长连接里全冒出来了:

  • 连接数与内存:每条长连接在服务端都占着 fd、缓冲区和一点内存。几万条连接对单机内存和 fd 上限是实打实的压力,这也是为什么高并发长连接服务普遍用 epoll 这类 I/O 多路复用,而不是一连接一线程。
  • 心跳保活:连接可能"假死"——TCP 没断,但实际已经收不到数据了。靠 WebSocket 的 ping/pong 或 SSE 的注释行心跳(: keepalive\n\n)定期探活,及时清掉死连接。
  • 代理与超时:Nginx 这类反向代理默认对空闲连接有超时(如 proxy_read_timeout 60s),长连接不发数据就会被切。WebSocket 还要求代理显式配置转发 Upgrade/Connection 头,否则 101 握手根本过不去。
  • 负载均衡粘性:WebSocket 是有状态的,得保证同一条连接始终落到同一台后端(会话粘性)。SSE 重连后落到哪台都行——只要每台都能根据 Last-Event-ID 续上。
  • 背压(backpressure):服务端推得比客户端收得快时,数据会在发送缓冲堆积。不做背压控制,内存就会被慢消费者拖爆。

实时通信真正难的部分,从来不是"能不能连上",而是"几万条连接长期存在时,系统怎么顶得住"。WebSocket 和 SSE 绝不只是一个前端 API。

九、和网络基础的联系:很多"玄学掉线"都有解释

它们是应用层方案,但脚下踩的全是前面学过的网络基础,所以排障时要往下看:

  • 代理没配 Upgrade 头 → WebSocket 握手卡在 101 之前,连都连不上;
  • 空闲连接被中间设备清掉 → 没有心跳的长连接莫名其妙断开,这是 NAT 超时或代理超时干的;
  • 移动网络切换(WiFi 切 4G)→ 四元组变了,TCP 连接其实已废,但应用层可能还没察觉,需要靠心跳尽快发现并重连;
  • TLS 终止位置(wss:// 在网关层还是源站终止)→ 影响你抓包时能不能看到明文帧。

理解了 TCP、TLS、NAT、代理这些基础,回头看这些"玄学掉线",大多能定位到具体的某一层。

学习这一部分最容易踩的坑

1. 以为 WebSocket 是"另一个协议端口"

WebSocket 复用 80/443,靠的是 HTTP 升级握手,不是另起炉灶。ws:// 对应 80、wss:// 对应 443。理解"它是从 HTTP 借道、再换协议"这条机制,才不会把它和端口配置搞混。

2. 把 SSE 当成"也能双向"

SSE 本质是一条响应流,只能服务端往客户端推。客户端要发数据,得另发普通 HTTP 请求。需要客户端高频主动发消息的场景,硬用 SSE 会很别扭。

3. 长连接不做心跳

不发心跳的长连接,会被 NAT、代理、负载均衡的空闲超时悄悄切断,而应用层往往要等到下次写失败才发现。WebSocket 用 ping/pong、SSE 用注释行心跳,是长连接的标配而非可选项。

4. 忽视 WebSocket 的负载均衡粘性

WebSocket 连接是有状态的,握手落在哪台机器,整条连接的生命周期都得在那台。负载均衡若按请求轮询,会把同一连接的后续帧发到不认识它的机器上。要么做会话粘性,要么把连接状态外置(如共享内存/消息队列)。

总结

这一篇我们坚持先讲机制、再谈选型,核心要带走的是两条长连接各自是怎么搭起来的:

  • 两者底层都是"不关闭的 TCP 连接",目标都是给服务端一个主动推送的通道;
  • SSE 是"一次永不结束的 HTTP 响应",纯文本按行解析,靠 id: + Last-Event-ID 内建断线重连,单向、轻量、HTTP 兼容好;
  • WebSocket 靠 Upgrade + 101 Switching Protocols 完成协议升级,之后用带掩码的帧全双工通信,能力更强但需代理支持、需自己处理重连;
  • 选型看通信模型:单向推送选 SSE,双向频繁交互选 WebSocket,先定模型再定技术;
  • 长连接真正的难点在心跳、代理超时、连接数、负载均衡粘性和背压,这些都建立在 TCP/TLS/NAT/代理等网络基础之上。

当你能默画出 WebSocket 那张"GET → 101 → 帧通信"的升级时序,并说清 SSE 为什么不带 Content-Length,这部分就真正属于你了。

参考资源