为什么需要这份速查
第 6 章给出了实时通道的选型方法,第 7 章讨论了重连与补拉。这一份附录把三种常见通道放在一起,做成可以随时翻查的对照表。
它不替代协议规范。SSE 的 text/event-stream 格式、WebSocket 的握手与帧结构,都有正式文档。附录关注的是架构层面:这个通道在断线时告诉我什么、不告诉我什么,端上需要补哪些逻辑。
先给结论:
- 只需要服务端推、不需要客户端频繁上行,优先 SSE。
- 需要双向、低延迟、高频,选 WebSocket。
- 更新频率低、实现成本敏感、基础设施受限,用轮询。
- 任何通道都不能假设「消息一定到达」,端上必须有补拉与去重。
三种通道的最小定义
轮询:客户端按固定间隔发请求,服务端返回「自上次以来」的变化。短轮询每次立即返回;长轮询把请求挂起,直到有数据或超时才返回。
SSE(Server-Sent Events):客户端发一个普通 HTTP 请求,服务端保持响应不关闭,持续写入 text/event-stream 格式的事件。它是单向的:服务端到客户端。
WebSocket:通过 HTTP Upgrade 握手切换到双向帧协议。握手完成后,双方可以随时发送文本或二进制帧。
三者的共同点是都建立在 TCP 之上,都受网络切换、代理超时、后台挂起的影响。区别在于「谁先说话」「连接如何复用」「断线如何被发现」。
能力对照表
| 维度 | 短轮询 | 长轮询 | SSE | WebSocket |
|---|---|---|---|---|
| 方向 | 单向拉 | 单向拉 | 服务端推 | 双向 |
| 连接复用 | 每次新建 | 每次新建 | 一条长连接 | 一条长连接 |
| 消息延迟 | 一个轮询周期 | 近实时 | 近实时 | 近实时 |
| 服务端成本 | 请求数高 | 请求数中等 | 连接数高 | 连接数高 |
| 断线检测 | 请求失败 | 请求失败 | onerror + 重连 | onclose + 心跳 |
| 自动重连 | 天然(下次轮询) | 天然 | 浏览器可自动重连 | 需自行实现 |
| 断点续传 | 需业务字段 | 需业务字段 | 原生 Last-Event-ID | 需业务字段 |
| 双向通信 | 不支持 | 不支持 | 不支持 | 支持 |
| 二进制 | 需编码 | 需编码 | 仅文本 | 支持 |
| 代理兼容性 | 最好 | 较好 | 好(HTTP/1.1 起) | 需支持 Upgrade |
| 鉴权 | 每次请求 | 每次请求 | 请求头/Cookie | 握手时或首帧 |
这张表里最容易被低估的是「自动重连」与「断点续传」两行。它们决定了端上要写多少代码。
轮询天然带断点:每次请求都带上「我已经看到哪里」,服务端按位置返回。SSE 的浏览器实现会自动重连,并在重连时带上 Last-Event-ID。WebSocket 两样都没有,必须自己实现。
短轮询与长轮询
短轮询
短轮询的实现最简单:
// 每 5 秒拉一次增量
setInterval(async () => {
const res = await fetch(`/api/messages?after=${lastSeq}`);
const page = await res.json();
for (const message of page.items) {
lastSeq = Math.max(lastSeq, message.seq);
}
}, 5_000);
它的问题不是「慢」,而是「无效请求多」。没有新消息时,每个客户端仍然每 5 秒发一次请求。一万在线用户就是每 5 秒一万次请求,服务端大部分时间在返回空结果。
短轮询适合:更新频率低、在线用户少、或者已经有成熟缓存层可以吸收空请求。
长轮询
长轮询把「立即返回空」改成「挂起等待」:
async function longPoll(lastSeq: number, signal: AbortSignal) {
while (!signal.aborted) {
const res = await fetch(`/api/messages?after=${lastSeq}&wait=25`, { signal });
const page = await res.json();
for (const message of page.items) {
lastSeq = Math.max(lastSeq, message.seq);
}
// 服务端超时返回空页时立即重发,形成连续监听
}
}
服务端在 25 秒内没有新消息就返回空页,客户端立刻重发。它把空请求的间隔从 5 秒拉长到 25 秒,同时保持近实时。
长轮询的代价是服务端要维持大量挂起请求。每个挂起请求占一个连接与一份内存,连接数上限成为容量瓶颈。它适合中等规模、且不想引入 WebSocket 基础设施的产品。
SSE
协议格式
SSE 的响应是一个永不结束的文本流,每个事件由若干 field: value 行组成,以空行分隔:
id: 1024
event: message
data: {"seq":1024,"text":"在吗"}
id: 1025
event: typing
data: {"userId":"u2"}
字段含义:
id:事件 id,浏览器会在重连时通过Last-Event-ID请求头带回。event:事件类型,对应EventSource的addEventListener。data:负载,多行data会以换行拼接。retry:建议的重连间隔,单位毫秒。
注释行以 : 开头,常用作心跳:
: keep-alive
浏览器 API
const source = new EventSource("/api/events", { withCredentials: true });
source.addEventListener("message", (event) => {
const payload = JSON.parse(event.data);
handleMessage(payload, event.lastEventId);
});
source.onerror = () => {
// 浏览器会自动重连;这里只更新 UI 状态
setConnectionState("connecting");
};
EventSource 的自动重连是它最大的优势,也是最大的坑:重连时浏览器会带 Last-Event-ID,但服务端必须正确实现从该 id 之后补发,否则断线期间的消息仍然会丢。
断线与 Last-Event-ID
一个可靠的 SSE 服务端需要做三件事:
- 每个事件带单调递增的
id。 - 收到
Last-Event-ID时,从该 id 之后开始发送。 - 客户端在
onopen时把状态切回open,但不要清空已有数据。
客户端还需要处理「服务端不支持补发」的情况:如果服务端忽略 Last-Event-ID,客户端无法从协议层发现,只能靠业务序列号检测空洞。这一点在第 7.3 节展开。
SSE 的限制也很明确:只能服务端推、只能文本、每个域名下的并发连接数受浏览器限制(HTTP/1.1 通常是 6 个)。需要双向通信时,SSE 必须配一个普通的 POST 上行通道。
WebSocket
握手
WebSocket 从一次 HTTP 请求开始:
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
服务端返回 101 Switching Protocols 后,连接切换为帧协议。握手阶段可以携带 Cookie 或自定义头,但浏览器的 WebSocket 构造函数不支持自定义头,因此常见做法是用 Cookie 鉴权,或先拿一个短期 ticket 再连。
帧与消息
WebSocket 帧有文本与二进制两种。应用层通常再包一层 JSON:
const socket = new WebSocket("wss://example.com/ws");
socket.onopen = () => {
socket.send(JSON.stringify({ type: "hello", lastSeq }));
};
socket.onmessage = (event) => {
const frame = JSON.parse(event.data);
if (frame.type === "message") {
handleMessage(frame.payload, frame.seq);
}
};
WebSocket 保证帧的顺序,但不保证消息一定到达。连接断开时,发送缓冲区里未发出的帧会丢失;已发出但未被应用层确认的消息,也无法从协议层判断是否到达。因此业务层仍需序列号与确认机制。
心跳与关闭码
中间设备(负载均衡、代理、NAT)会清理长时间空闲的连接。心跳的作用是保活与检测半开连接:
const timer = setInterval(() => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "ping", at: Date.now() }));
}
}, 25_000);
只发心跳不够,还要检测「发出去了但没回应」。常见做法是记录最后一次收到 pong 的时间,超过两个心跳周期就主动关闭并重连。
关闭码也值得记录:
1000正常关闭。1001端点离开(页面关闭、服务端重启)。1006异常关闭,通常没有任何关闭帧,说明连接被切断。1011服务端内部错误。
1006 是最常见的,也是信息量最少的:它只告诉你「断了」,不告诉你为什么。端上应该把它当作「需要重连并补拉」的信号。
选型判定表
按产品约束逐项回答:
| 问题 | 是 | 否 |
|---|---|---|
| 需要客户端高频上行吗? | WebSocket | 继续 |
| 消息延迟要求低于 1 秒吗? | SSE 或 WebSocket | 轮询可接受 |
| 能接受每个在线用户一条长连接吗? | SSE 或 WebSocket | 轮询 |
| 服务端已经有 HTTP 推送基础设施吗? | SSE | 评估成本 |
| 需要传输二进制吗? | WebSocket | SSE 可满足 |
| 团队没有长连接运维经验吗? | 先轮询,再演进 | 按能力选 |
判定表不能替代压测。连接数、消息频率、单条消息大小、断线率,都会改变结论。
常见故障模式
重复消息:重连后服务端从更早的位置补发,客户端没有去重。修复:按「会话 id + 序列号」去重。
消息丢失:断线期间的消息没有被补发,客户端也不知道缺了。修复:服务端支持从 Last-Event-ID 或业务序列号补发;客户端检测序列号空洞。
连接风暴:服务端重启后,所有客户端在同一秒重连。修复:指数退避加随机抖动。
假在线:TCP 连接还在,但对端已经不可达。修复:心跳 + pong 超时。
代理超时:长连接在 60 秒无数据时被代理切断。修复:心跳间隔小于代理超时,或在 SSE 中定期发送注释行。
鉴权过期:长连接建立后 token 过期,服务端无法中途要求重新鉴权。修复:服务端主动关闭并返回特定关闭码,客户端重连前刷新 token。
安全与鉴权
三种通道的鉴权方式不同:
- 轮询:每次请求都带凭证,天然支持短期 token。
- SSE:
EventSource支持withCredentials,用 Cookie;不支持自定义头。 - WebSocket:握手可带 Cookie;浏览器无法设置自定义头,常用短期 ticket。
跨域时,SSE 需要服务端返回正确的 CORS 头;WebSocket 不受同源策略限制,但服务端必须校验 Origin,否则任意站点都能建立连接。
还有一个常被忽略的点:长连接会绕过部分 HTTP 层的安全策略(如某些 WAF 规则)。上线前应与安全团队确认代理与网关对 Upgrade 与长响应的处理。
如何在 Node 中验证
本卷的 L3 用例用 Node 标准库起真实端口。SSE 可以用 http.createServer 手写:
import { createServer } from "node:http";
const server = createServer((req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
let seq = 1;
const timer = setInterval(() => {
res.write(`id: ${seq}\ndata: {"seq":${seq}}\n\n`);
seq += 1;
}, 50);
req.on("close", () => clearInterval(timer));
});
server.listen(0, "127.0.0.1");
WebSocket 在 Node 标准库里没有内置服务端实现。本卷的做法是:用 http 手写最小握手与帧解析,只验证「连接、心跳、关闭」三个行为,不实现完整协议。这样既不需要第三方依赖,也能覆盖架构层面关心的语义。
小结
选通道的本质是选「断线之后怎么办」。轮询天然带断点,SSE 有协议级重连与 Last-Event-ID,WebSocket 最灵活但什么都要自己做。
无论选哪种,端上都需要三样东西:连接状态机、序列号或游标、补拉与去重。这三样与通道无关,是实时架构的公共底座。
常见错误与边界
- 认为 SSE 自动重连就等于不丢消息。自动重连只负责重连,补发要靠服务端实现。
- 用 WebSocket 却不做心跳。半开连接会让 UI 显示在线,实际已经不可达。
- 重连没有退避与抖动。服务端重启会引发连接风暴。
- 把
1006当成可忽略的关闭。它通常意味着网络中断,必须触发补拉。 - 用长轮询承载高频消息。挂起请求的连接数会成为容量瓶颈。
- 在 SSE 中传二进制。协议只支持文本,必须 Base64 或其他编码,体积会膨胀。
- 忽略代理的超时设置。长连接在无数据时会被中间设备切断。
- 把「连接建立成功」当成「鉴权一定有效」。长连接的生命周期可能超过 token 的有效期。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。