先把边界划清
这一节讨论「给定产品约束,应该选哪种实时通道」,不讨论协议本身。SSE 的 text/event-stream 格式、WebSocket 的握手与帧结构、Last-Event-ID 的补发语义,附录 A 已经逐项拆过;连接建立之后的状态迁移放在 6.2,实时不可用时的产品降级放在 6.3,断线重连与补拉放在第 7 章。这里只回答一个问题:在写第一行连接代码之前,怎样把需求变成可解释的选择。
很多团队的选型会议会在一小时内结束,结论通常是「竞品用了 WebSocket,我们也用」。这不是选型,是抄作业。竞品的在线规模、运维团队、消息类型、发布节奏都和你不同;同一张能力表,在两套约束下会推出相反答案。
选型不是选协议,是选约束
把「实时」当成一个二值需求,是大多数选型错误的起点。产品说「消息要实时到达」,可能指 200 毫秒内看到对方正在输入,也可能指 30 秒内角标数字更新。前者需要长连接,后者用轮询就够,成本差一个数量级。
同样,「我们有多少用户」也不等于「同时有多少条连接」。日活一百万、峰值在线五万,和日活五万、峰值在线四万,对连接容量的要求完全不同。选型输入必须来自可观测的峰值,而不是注册数。
还有一个经常被漏掉的输入:团队现在能运维什么。长连接不是连上就结束,它需要心跳、连接迁移、容量预案、代理超时排查、发布时的连接排空。一个没有长连接值班经验的团队,即使产品需要双向低延迟,也应该先评估招聘、托管服务或分阶段演进的成本,而不是直接假设「WebSocket 很简单」。
因此这一节的方法不是给一张「什么场景用什么」的死表,而是把需求写成结构化的七个字段,让一个纯函数给出推荐、分数差距与警告。函数不能替代压测,但它能把争论从「我觉得」变成「哪个输入还不确定」。
七个输入字段
| 字段 | 要回答的问题 | 为什么影响选型 |
|---|---|---|
direction | 只有服务端推,还是客户端也要高频上行 | 双向需求会直接排除纯 SSE |
latencyBudgetMs | 从事件发生到用户看见,最多能等多久 | 分钟级延迟不值得维持长连接 |
onlineUsers | 峰值同时在线连接数是多少 | 轮询请求量随在线数线性增长 |
longConnectionOps | 团队能否维护心跳、重连与连接容量 | 运维能力是硬约束,不是软偏好 |
payload | 消息是文本还是二进制 | SSE 只传文本,二进制要额外编码 |
clientSendPerMinute | 每个用户每分钟主动发多少条 | 高频上行需要真正的双向通道 |
updatesPerMinute | 每个用户每分钟收到多少条更新 | 高频推送会让轮询退化成持续请求 |
这七个字段刻意不包含「竞品用什么」「框架默认支持什么」。它们是产品与运维可以共同确认的事实。字段值不确定时,选型结论就应该标记为低置信度,而不是用默认值假装确定。
latencyBudgetMs 要写端到端预算,不是网络往返。用户点击发送到对方看到消息,中间还有服务端排队、推送、端上渲染。如果产品承诺「一秒内」,留给通道的预算通常只有几百毫秒。
onlineUsers 要取峰值而不是均值。凌晨两点的在线数不能用来证明轮询扛得住晚八点的流量。没有峰值数据时,先按「当前在线数 × 3」做保守估算,并把它标记为假设值。
打分而不是硬编码判定树
最常见的实现是一棵判定树:如果双向就 WebSocket,否则如果延迟低于一秒就 SSE,否则轮询。它好写,但有几个问题。
第一,一条规则可以否决全部。某个产品只需要服务端推,但团队完全没有长连接运维能力;判定树看到「延迟低于一秒」就直接选 SSE,把运维风险藏了起来。
第二,无法表达「两项都还行」。轮询和 SSE 可能只差一分,硬规则却必须给出唯一答案,评审时无法看出结论有多脆弱。
第三,不可解释。规则命中就返回,没人知道其他方案差在哪里,也无法回答「如果在线数涨十倍,结论会不会变」。
打分模型把每条约束拆成独立的加减分,并记录命中的理由与警告。最终推荐按总分排序,分差决定置信度:分差大,说明多数约束指向同一方向;分差小,说明还需要补充数据或做压测。低置信度不是模型的失败,而是它诚实地说「这份需求还不足以做决定」。
权重本身是团队共识,不是自然常数。把「运维能力不足」扣四分、把「二进制」加五分,都是可以被质疑和调整的。关键是权重写在代码里、有测试覆盖、改动能被评审,而不是散落在三次会议的口头争论中。
一个可运行的选择器
下面的选择器是纯函数:输入需求,输出三种通道的分数、推荐、置信度与警告。它不建立连接、不读环境变量,也不依赖时间,因此测试不需要任何模拟器。
// file: src/realtime/choice.ts
// tier: L1
// verify: node --test test/realtime/choice.test.ts
export type Transport = "polling" | "sse" | "websocket";
export interface Requirement {
direction: "server-push" | "bidirectional";
latencyBudgetMs: number;
onlineUsers: number;
longConnectionOps: "none" | "basic" | "strong";
payload: "text" | "binary";
clientSendPerMinute: number;
updatesPerMinute: number;
}
export interface TransportScore {
transport: Transport;
score: number;
reasons: string[];
warnings: string[];
}
export interface Choice {
recommended: Transport;
confidence: "high" | "medium" | "low";
scores: TransportScore[];
rationale: string;
}
const ORDER: Transport[] = ["polling", "sse", "websocket"];
const NAMES: Record<Transport, string> = {
polling: "轮询",
sse: "SSE",
websocket: "WebSocket",
};
function validate(requirement: Requirement): void {
if (!Number.isFinite(requirement.latencyBudgetMs) || requirement.latencyBudgetMs <= 0) {
throw new Error("latencyBudgetMs 必须是正数");
}
if (!Number.isInteger(requirement.onlineUsers) || requirement.onlineUsers < 0) {
throw new Error("onlineUsers 必须是非负整数");
}
if (!Number.isFinite(requirement.clientSendPerMinute) || requirement.clientSendPerMinute < 0) {
throw new Error("clientSendPerMinute 必须是非负数");
}
if (!Number.isFinite(requirement.updatesPerMinute) || requirement.updatesPerMinute < 0) {
throw new Error("updatesPerMinute 必须是非负数");
}
}
export function scoreTransport(requirement: Requirement, transport: Transport): TransportScore {
validate(requirement);
if (!ORDER.includes(transport)) throw new Error(`未知通道:${String(transport)}`);
const reasons: string[] = [];
const warnings: string[] = [];
let score = 0;
const add = (points: number, reason: string): void => {
score += points;
if (points > 0) reasons.push(reason);
};
if (requirement.direction === "bidirectional") {
if (transport === "websocket") add(5, "需要客户端持续上行,双向帧最自然");
if (transport === "sse") add(-4, "SSE 只有服务端到客户端,上行要另开 POST 通道");
if (transport === "polling") add(-2, "轮询只能把上行搭在请求里,交互延迟受限");
} else {
if (transport === "sse") add(2, "单向服务端推送与 SSE 的方向完全一致");
if (transport === "websocket") add(1, "WebSocket 也能推送,但双向能力闲置");
}
if (requirement.latencyBudgetMs <= 1000) {
if (transport === "websocket") add(3, "双向帧没有轮询周期,适合亚秒级交互");
if (transport === "sse") add(3, "服务端事件可即时写出,延迟接近网络往返");
if (transport === "polling") add(-4, "轮询周期本身就会吃掉延迟预算");
} else if (requirement.latencyBudgetMs <= 5000) {
if (transport === "sse") add(2, "秒级延迟在 SSE 的舒适区");
if (transport === "websocket") add(2, "WebSocket 能稳定满足秒级延迟");
if (transport === "polling") add(-1, "短轮询要缩短周期才能满足,空请求会变多");
} else {
if (transport === "polling") add(3, "分钟级延迟用轮询最省事");
if (transport === "websocket") add(-1, "为低频更新维持长连接不划算");
}
if (requirement.onlineUsers >= 10000) {
if (transport === "polling") add(-3, "请求量随在线用户数线性增长");
if (transport === "sse") add(1, "一条连接承载一路推送,请求数不再随刷新增长");
if (transport === "websocket") add(1, "一条连接承载双向流量,适合大规模在线");
} else if (requirement.onlineUsers <= 100) {
if (transport === "polling") add(1, "小规模下轮询的实现与排障成本最低");
}
if (requirement.longConnectionOps === "none") {
if (transport === "polling") add(4, "不需要维护长连接、心跳与连接容量");
if (transport === "sse") add(1, "SSE 的运维面比 WebSocket 窄,但仍需长连接");
if (transport === "websocket") add(-4, "心跳、重连、连接迁移都需要专人维护");
} else if (requirement.longConnectionOps === "basic") {
if (transport === "sse") add(2, "团队能维护长连接,SSE 的协议面更小");
if (transport === "polling") add(1, "现有运维能力可以兜底轮询");
} else {
if (transport === "websocket") add(3, "团队有成熟的长连接容量与故障预案");
if (transport === "sse") add(1, "长连接运维能力可以复用到 SSE");
}
if (requirement.payload === "binary") {
if (transport === "websocket") add(5, "帧协议原生支持二进制");
if (transport === "sse") add(-5, "SSE 只能传文本,二进制要 Base64");
if (transport === "polling") add(-4, "轮询要自行编码二进制并处理分片");
}
if (requirement.clientSendPerMinute >= 10) {
if (transport === "websocket") add(3, "高频上行不需要额外通道");
if (transport === "sse") add(-3, "上行要另开请求通道,两套连接难对齐");
if (transport === "polling") add(1, "上行可以直接搭在轮询请求里");
} else if (requirement.clientSendPerMinute === 0 && transport === "sse") {
add(1, "纯推送场景不需要常驻上行通道");
}
if (requirement.updatesPerMinute >= 60) {
if (transport === "sse") add(2, "高频推送不需要为每条消息新建请求");
if (transport === "websocket") add(2, "双向高频消息共用一条连接");
if (transport === "polling") add(-3, "高频更新会让轮询退化成持续请求");
} else if (requirement.updatesPerMinute <= 6) {
if (transport === "polling") add(3, "低频更新不值得维持长连接");
}
if (requirement.payload === "binary" && transport !== "websocket") {
warnings.push(
transport === "sse"
? "SSE 需要 Base64 编码二进制,体积约增加三分之一"
: "轮询需要自行编码二进制,并处理消息分片",
);
}
if (requirement.onlineUsers >= 10000 && transport === "polling") {
warnings.push("高峰请求量约等于在线用户数除以轮询周期,上线前必须压测");
}
if (requirement.onlineUsers >= 10000 && transport !== "polling" && requirement.longConnectionOps === "none") {
warnings.push("长连接需要心跳、重连与容量预案,当前运维能力不足");
}
if (requirement.direction === "bidirectional" && transport !== "websocket") {
warnings.push("双向需求需要额外的上行通道,状态要跨两套连接对齐");
}
if (requirement.latencyBudgetMs <= 1000 && transport === "polling") {
warnings.push("轮询周期无法稳定满足一秒以内的延迟预算");
}
return { transport, score, reasons, warnings };
}
export function chooseTransport(requirement: Requirement): Choice {
validate(requirement);
const scores = ORDER.map((transport) => scoreTransport(requirement, transport)).sort((a, b) => {
if (a.score !== b.score) return b.score - a.score;
return ORDER.indexOf(a.transport) - ORDER.indexOf(b.transport);
});
const top = scores[0];
const runnerUp = scores[1];
const gap = top.score - runnerUp.score;
const confidence = gap >= 5 ? "high" : gap >= 2 ? "medium" : "low";
const rationale = `推荐 ${NAMES[top.transport]}:${top.reasons.slice(0, 3).join(";")}。` +
(top.warnings.length > 0 ? `注意:${top.warnings.join(";")}。` : "");
return {
recommended: top.transport,
confidence,
scores,
rationale,
};
}
scoreTransport 每次都会重新校验输入,避免调用方绕过 chooseTransport 直接传坏数据。chooseTransport 在分数相同时按「轮询、SSE、WebSocket」的顺序稳定排序,因为同等效果下应优先选择运维复杂度更低的方案。这个排序不是价值判断,只是让结果可复现。
警告与扣分是两件事。扣分影响推荐,警告只提醒风险。例如「大在线量 + 轮询」会被扣分并给出压测警告;「大在线量 + 长连接 + 运维能力不足」可能仍然推荐 SSE,但会明确写出「需要心跳、重连与容量预案」。架构评审时,后者比一个干净的唯一答案更有用。
四组需求跑一遍
用四组典型需求调用同一个函数,结果如下(示例值,来自本机测试用例):
| 场景 | 关键输入 | 推荐 | 置信度 | 分数(轮询 / SSE / WebSocket) |
|---|---|---|---|---|
| 低频通知 | 单向、60 秒预算、200 在线、无长连接运维、每分钟 2 条 | 轮询 | high | 10 / 4 / -4 |
| 双向聊天 | 双向、300 毫秒、5000 在线、基础运维、每分钟上行 30 条 | WebSocket | high | -7 / 0 / 13 |
| 内容站角标 | 单向、2 秒、5 万在线、基础运维、每分钟 20 条 | SSE | medium | -3 / 8 / 4 |
| 语音片段 | 单向、2 秒、100 在线、无长连接运维、二进制 | WebSocket | medium | 0 / 1 / 4 |
第一组和第四组值得对照。低频通知的结论很稳:轮询领先六分,任何单项权重的小调整都不会翻盘。语音片段则是低置信度:WebSocket 只领先三分,SSE 的文本限制被扣了五分,但它的方向与延迟得分仍然把总分拉到一分。如果产品能把语音改成「先上传、再推一个文本消息」,SSE 会重新变得有竞争力。 选型结论依赖于产品怎么定义消息,而不只是协议能力。
第二组说明双向需求的权重有多大:聊天场景里 WebSocket 领先十三分,但这不是「聊天必须 WebSocket」的证明,而是「双向 + 高频上行 + 亚秒延迟」三个输入共同作用的结果。如果聊天产品只要求「消息最终送达」,延迟预算放到三十秒,轮询的分数会明显上升。
测试与真实输出
本机运行结果如下:
$ node --test test/realtime/choice.test.ts
✔ 低频通知且运维能力有限时选轮询,并给出高置信度 (0.655875ms)
✔ 双向聊天在亚秒延迟下选 WebSocket (0.465542ms)
✔ 大在线量的单向推送选 SSE,运维能力进入推荐理由 (0.108708ms)
✔ 二进制负载把 SSE 与轮询排到后面,并提示编码成本 (0.107542ms)
✔ 两项得分接近时置信度降为 low,而不是假装有唯一答案 (0.07525ms)
✔ 非法需求输入直接拒绝 (0.279542ms)
✔ 三种通道的分数按降序排列,且理由不为空 (0.417208ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 159.482166
七条用例覆盖了四类需求、低置信度、非法输入与排序稳定性。它们没有覆盖「权重是否合理」,因为权重是产品与运维的共识,不是代码能证明的东西。测试能保证的是:给定同一份输入,结论可复现;改了权重,哪些用例的结论会翻转,一目了然。
失败复盘:把「实时」当成一个词
「回声」早期做消息中心时,产品需求只有一句话:「新消息要实时提醒」。团队据此选了 WebSocket,理由是「实时就用 WebSocket」。上线后问题集中在三处:移动端切后台后连接被系统回收,回到前台要等重连;代理在六十秒无数据时切断连接,需要心跳保活;服务端发布时所有客户端同时重连,形成连接风暴。
这些问题本身都能解,但团队的运维能力当时只够处理 HTTP 请求。真正让项目回滚的,是需求侧的一次澄清:所谓「实时提醒」,产品可接受的最长延迟是三十秒,且只需要更新角标数字,不需要在页面内插入消息。也就是说,七个输入里 latencyBudgetMs 是 30000、clientSendPerMinute 是 0、updatesPerMinute 是 2,longConnectionOps 是 none。
把这三个值代入选择器,轮询以十分领先,SSE 四分,WebSocket 负四分。团队不是「选错了协议」,而是在需求还没变成约束之前就选了实现。回滚到轮询后,请求量确实上升,但服务端用增量接口和缓存吸收空请求,成本远低于维护一套长连接。
第二个事故更隐蔽:另一个团队用 SSE 传语音片段,把二进制 Base64 后塞进 data 字段。体积膨胀三分之一,长消息还触发代理缓冲,延迟反而比轮询更差。选择器会对这个组合给出五分扣分和明确的 Base64 警告;如果评审时看过警告,至少会先做一次体积与延迟的实测。
什么时候重新评估
选型不是一次性决定。下面任何一项变化,都应该重新跑一遍输入:
- 峰值在线数跨过数量级,例如从五千涨到五万。
- 产品把延迟承诺从三十秒改成两秒。
- 消息从纯文本变成图片、语音或自定义二进制。
- 团队引入或失去了长连接运维能力。
- 服务端从自建机房迁到对长连接不友好的网关后面。
把每次决策的输入、分数与警告写进架构决策记录,比只记录「我们用了 WebSocket」有用得多。半年后有人问「为什么不用 SSE」,记录能直接回答:当时的 clientSendPerMinute 是 40,SSE 需要另开上行通道,分数差了九分。如果输入已经变了,也应该允许结论变化,而不是把旧决定当成身份认同。
小结
实时选型的第一步不是比较协议,而是把产品语言翻译成七个可确认的约束:方向、延迟预算、峰值在线、运维能力、负载类型、上行频率、更新频率。用打分而不是判定树,可以保留权衡、暴露风险、给出置信度;低置信度提醒你补充数据或做压测,而不是假装有唯一答案。
选型回答「用哪条通道」,接下来要回答「这条通道从建立到关闭经历什么」。下一节把连接拆成状态机,讨论合法迁移、页面切后台、恢复与卸载清理。
阅读导航:上一节:5.3 回滚的边界:哪些操作不能乐观 · 下一节:6.2 一条实时连接的生命周期 。
常见错误与边界
- 把「实时」当成一个二值需求,不写延迟预算就选 WebSocket。
- 用日活或注册数估算连接容量,而不是峰值同时在线数。
- 把团队的长连接运维能力当成软偏好,直到发布时才发现没有值班预案。
- 用判定树一次命中就返回,隐藏其他方案的风险与分差。
- 看到「聊天」就选 WebSocket,忽略产品其实只要求最终送达。
- 用 SSE 传二进制,Base64 膨胀后仍宣称「和 WebSocket 差不多」。
- 把打分权重当成自然常数,从不评审、也不写测试。
- 选型结论不记录输入,半年后无法解释为什么当时不用另一种通道。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。