《互联网产品的客户端架构·内容、社交与实时互动》附录 A 实时通道协议速查

把短轮询、长轮询、SSE 与 WebSocket 放在同一张表里逐项对照:通信方向、成本、断线语义、鉴权方式与典型故障。附录给出每种通道的最小实现、Last-Event-ID 与心跳的处理方式,以及一张按产品约束与项目阶段选择通道的判定表。

为什么需要这份速查

第 6 章给出了实时通道的选型方法,第 7 章讨论了重连与补拉。这一份附录把三种常见通道放在一起,做成可以随时翻查的对照表。

它不替代协议规范。SSE 的 text/event-stream 格式、WebSocket 的握手与帧结构,都有正式文档。附录关注的是架构层面:这个通道在断线时告诉我什么、不告诉我什么,端上需要补哪些逻辑。

先给结论:

  • 只需要服务端推、不需要客户端频繁上行,优先 SSE。
  • 需要双向、低延迟、高频,选 WebSocket。
  • 更新频率低、实现成本敏感、基础设施受限,用轮询。
  • 任何通道都不能假设「消息一定到达」,端上必须有补拉与去重。

三种通道的最小定义

轮询:客户端按固定间隔发请求,服务端返回「自上次以来」的变化。短轮询每次立即返回;长轮询把请求挂起,直到有数据或超时才返回。

SSE(Server-Sent Events):客户端发一个普通 HTTP 请求,服务端保持响应不关闭,持续写入 text/event-stream 格式的事件。它是单向的:服务端到客户端。

WebSocket:通过 HTTP Upgrade 握手切换到双向帧协议。握手完成后,双方可以随时发送文本或二进制帧。

三者的共同点是都建立在 TCP 之上,都受网络切换、代理超时、后台挂起的影响。区别在于「谁先说话」「连接如何复用」「断线如何被发现」。

能力对照表

维度短轮询长轮询SSEWebSocket
方向单向拉单向拉服务端推双向
连接复用每次新建每次新建一条长连接一条长连接
消息延迟一个轮询周期近实时近实时近实时
服务端成本请求数高请求数中等连接数高连接数高
断线检测请求失败请求失败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 服务端需要做三件事:

  1. 每个事件带单调递增的 id。
  2. 收到 Last-Event-ID 时,从该 id 之后开始发送。
  3. 客户端在 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评估成本
需要传输二进制吗?WebSocketSSE 可满足
团队没有长连接运维经验吗?先轮询,再演进按能力选

判定表不能替代压测。连接数、消息频率、单条消息大小、断线率,都会改变结论。

常见故障模式

重复消息:重连后服务端从更早的位置补发,客户端没有去重。修复:按「会话 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 的有效期。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查