这一节讨论「连接不好时产品怎么办」
上一节把连接拆成六个状态,但没有回答每个状态对应什么产品行为。这一节不重复通道选型(6.1),也不讨论状态迁移本身(6.2),更不展开重连退避与补拉算法(第 7 章)。它只做一件事:把连接状态翻译成能力矩阵,让页面在实时不可用时仍然可用。
很多团队把「实时」当成页面的一部分:连接打开后渲染内容,连接断开后显示加载或错误。这个顺序在演示环境里没问题,在移动网络里会直接变成白屏。用户在地铁里打开应用,通道还没连上,缓存里的内容明明可以显示,页面却因为一个 if (state !== "open") return null 什么都不渲染。
实时不可用是常态
先接受三个事实。第一,移动端切后台、锁屏、网络切换都会让长连接失效,而且客户端不一定立刻知道。第二,服务端发布、扩容、代理重启会主动切断连接,断线率与发布频率正相关。第三,鉴权 token 会过期,长连接的生命周期可能比 token 更长。
这三个事实意味着:连接不可用不是异常分支,而是产品必须设计的一等状态。区别只在于持续多久、用户此刻想做什么。一个成熟的产品在断线时仍然能读缓存、写草稿、看最后一次计数;不成熟的产品会把整个页面交给一个转圈图标。
五种能力,四种模式
不是所有功能都应该用同一种方式降级。本节的模型把实时相关能力拆成五个:
| 能力 | 正常时 | 断线时可选模式 | 说明 |
|---|---|---|---|
badge | 实时角标 | paused / manual | 保留最后一次数字,或回到页面时拉一次 |
autoInsert | 新内容自动插入 | paused / manual | 不插入可能过期的推送,改为下拉刷新 |
typing | 对方正在输入 | disabled | 无法可靠判断,宁可不显示 |
composer | 直接发送 | queued / disabled | 允许排队,或明确禁用并保留草稿 |
presence | 在线状态 | unknown / offline | 区分「不知道」与「确定离线」 |
模式的含义必须写清楚,否则前端会各自解释:
live:实时更新,用户无需操作。paused:停止更新,保留最后一次已知值,不假装是最新。manual:停止自动更新,提供显式刷新入口。queued:接受用户输入,先进入本地队列,恢复后发送。disabled:不接受输入,但必须保留已经写下的草稿。unknown:无法判断,UI 不显示「在线」也不显示「离线」。offline:可以确定不可用,UI 明确告知。
paused 与 manual 的区别经常被忽略。paused 是「先别动,等连接回来」;manual 是「自动更新已经不可信,但用户可以主动拉一次」。前者适合 connecting,后者适合 closed 或 failed。
状态到能力的矩阵
把 6.2 的六个状态代入,得到本节实现的默认矩阵:
| 状态 | 提示条 | 角标 | 自动插入 | 输入中 | 评论框 | 在线状态 |
|---|---|---|---|---|---|---|
idle | 无 | 暂停 | 暂停 | 禁用 | 禁用 | 未知 |
connecting | 连接中 | 暂停 | 暂停 | 禁用 | 禁用 | 未知 |
open | 无 | 实时 | 实时 | 实时 | 实时 | 实时 |
reconnecting | 重连中 | 暂停 | 暂停 | 禁用 | 排队 | 未知 |
closed | 离线 | 手动 | 手动 | 禁用 | 排队 | 离线 |
failed | 失败 | 手动 | 手动 | 禁用 | 禁用 | 离线 |
这张表是产品决策,不是技术推导。另一个产品完全可以让 reconnecting 时也禁用评论框,或者让 closed 时把角标设为 paused。关键是每个格子的选择都有理由,并且所有页面共用同一张表,而不是每个页面在自己组件里写一套 if。
reconnecting 允许排队、failed 不允许,是一个刻意的不对称。重连中通常是临时网络问题,排队的写入大概率能成功;failed 往往意味着鉴权失败、协议错误或重试耗尽,继续接受写入只会制造一批注定失败的队列项。
渲染不能以连接为前提
降级矩阵里最重要的不是五个能力,而是这一条:内容渲染与连接状态解耦。 缓存里有内容就渲染内容,没有内容就渲染空状态;连接状态只决定提示条、角标和输入能力。
实现上用三个字段表达:canRenderContent 永远为 true,source 取 live、cache 或 empty,hint 告诉用户当前看到的是什么。canRenderContent 永远为真不是自欺欺人,而是把「有没有内容」与「连接是否可用」拆成两个问题。没有缓存时展示空状态和刷新入口,仍然比白屏好。
白屏事故通常不是一次错误,而是三个假设叠加:第一,页面只在 open 时渲染;第二,缓存只在连接成功后写入;第三,错误处理只覆盖请求失败,不覆盖「连接一直没打开」。三个假设单独看都合理,叠加后就是「第一次进入且网络不好时永远空白」。
排队不是万能
允许排队听起来友好,但不是所有写入都适合。判断标准有三条:操作是否幂等、失败是否可解释、延迟发送是否会改变语义。
适合排队:发表评论、发送普通消息、点赞与收藏。它们可以带客户端生成的幂等键,恢复后重复发送不会产生两条;即使排队几分钟,语义仍然是「用户当时想表达这个」。
不适合排队:支付、删除、权限变更、带时效的投票。延迟发送可能在一个已经变化的世界里执行,用户以为取消了,恢复后却真的删除了。failed 状态禁用评论框,就是为了避免把这类操作悄悄塞进队列。
排队还需要四个工程约束:队列要有归属(哪个用户、哪个会话)、要有上限与过期时间、要保持同一会话内的顺序、要在发送前检查幂等键。只写一个 pending.push(message),恢复后并发 Promise.all 发出去,顺序和重复都会出问题。这些细节属于第 7 章,但降级矩阵必须先把「能不能排队」这个产品决定做出来。
恢复不是把状态改回 open
连接恢复后,很多实现只做一件事:把提示条隐藏。这不够。恢复至少包含三个动作,而且有顺序:
- 补拉:拉取断线期间的消息,按序列号检测空洞,去重后合并。先让本地视图追上服务端。
- 冲刷队列:按原顺序发送排队的写入,携带幂等键,逐条确认。服务端状态已经追平,才能判断冲突。
- 重置提示:隐藏断线提示,恢复角标与在线状态。
顺序不能反。先冲刷队列再补拉,可能把基于旧视图的评论发到一个已经变化的话题里;先补拉再冲刷,至少能让用户看到上下文再决定是否重发。对于不可自动重放的写入,恢复后应该提示用户「有 2 条草稿未发送」,而不是静默发送。
planRecovery 用 refresh、flushQueue、resetBanner 三个布尔值表达这件事,并区分 connected、degraded、steady 三种原因。它不执行动作,只给调度层一个明确的清单,避免每个页面自己决定恢复顺序。
一个可运行的降级矩阵
下面的实现是纯函数:输入连接状态与两个产品开关,输出能力矩阵、提示条、动作、排队标记与渲染来源。
// file: src/realtime/degrade.ts
// tier: L1
// verify: node --test test/realtime/degrade.test.ts
import type { ConnectionState } from "./connection.ts";
export const CAPABILITIES = ["badge", "autoInsert", "typing", "composer", "presence"] as const;
export type Capability = (typeof CAPABILITIES)[number];
export type CapabilityMode =
| "live"
| "manual"
| "paused"
| "queued"
| "disabled"
| "unknown"
| "offline";
export type Banner = "none" | "connecting" | "reconnecting" | "offline" | "failed";
export type DegradeAction = "refresh" | "retry" | "cancel";
export interface DegradeOptions {
canQueueWrites?: boolean;
hasCachedContent?: boolean;
}
export interface DegradePlan {
state: ConnectionState;
banner: Banner;
capabilities: Record<Capability, CapabilityMode>;
actions: DegradeAction[];
queuedWrites: boolean;
render: {
canRenderContent: boolean;
source: "live" | "cache" | "empty";
hint: string;
};
}
export interface RecoveryPlan {
refresh: boolean;
flushQueue: boolean;
resetBanner: boolean;
reason: "connected" | "degraded" | "steady";
}
interface BasePlan {
banner: Banner;
modes: Record<Capability, CapabilityMode>;
actions: DegradeAction[];
}
const STATES: ConnectionState[] = ["idle", "connecting", "open", "reconnecting", "closed", "failed"];
const BASE: Record<ConnectionState, BasePlan> = {
idle: {
banner: "none",
modes: {
badge: "paused",
autoInsert: "paused",
typing: "disabled",
composer: "disabled",
presence: "unknown",
},
actions: [],
},
connecting: {
banner: "connecting",
modes: {
badge: "paused",
autoInsert: "paused",
typing: "disabled",
composer: "disabled",
presence: "unknown",
},
actions: ["cancel"],
},
open: {
banner: "none",
modes: {
badge: "live",
autoInsert: "live",
typing: "live",
composer: "live",
presence: "live",
},
actions: [],
},
reconnecting: {
banner: "reconnecting",
modes: {
badge: "paused",
autoInsert: "paused",
typing: "disabled",
composer: "queued",
presence: "unknown",
},
actions: ["refresh"],
},
closed: {
banner: "offline",
modes: {
badge: "manual",
autoInsert: "manual",
typing: "disabled",
composer: "queued",
presence: "offline",
},
actions: ["refresh"],
},
failed: {
banner: "failed",
modes: {
badge: "manual",
autoInsert: "manual",
typing: "disabled",
composer: "disabled",
presence: "offline",
},
actions: ["refresh", "retry"],
},
};
export function planDegrade(state: ConnectionState, options: DegradeOptions = {}): DegradePlan {
if (!STATES.includes(state)) throw new Error(`未知的连接状态:${String(state)}`);
const base = BASE[state];
const canQueueWrites = options.canQueueWrites ?? true;
const hasCachedContent = options.hasCachedContent ?? true;
const capabilities = { ...base.modes };
if (capabilities.composer === "queued" && !canQueueWrites) {
capabilities.composer = "disabled";
}
const source = state === "open" ? "live" : hasCachedContent ? "cache" : "empty";
const hint =
state === "open"
? "实时更新中,新内容会自动出现"
: hasCachedContent
? "当前显示缓存内容,可手动刷新;输入不会丢失已写草稿"
: "暂无缓存内容,显示空状态与刷新入口,不阻塞页面渲染";
return {
state,
banner: base.banner,
capabilities,
actions: [...base.actions],
queuedWrites: capabilities.composer === "queued",
render: {
canRenderContent: true,
source,
hint,
},
};
}
export function acceptsWrites(plan: DegradePlan): boolean {
return plan.capabilities.composer === "live" || plan.capabilities.composer === "queued";
}
export function planRecovery(
previous: ConnectionState,
next: ConnectionState,
options: { queuedWrites?: boolean } = {},
): RecoveryPlan {
if (!STATES.includes(previous) || !STATES.includes(next)) {
throw new Error("planRecovery 收到未知的连接状态");
}
if (previous === next) {
return { refresh: false, flushQueue: false, resetBanner: false, reason: "steady" };
}
if (next === "open") {
return {
refresh: previous !== "idle",
flushQueue: options.queuedWrites === true,
resetBanner: true,
reason: "connected",
};
}
if (previous === "open") {
return { refresh: false, flushQueue: false, resetBanner: false, reason: "degraded" };
}
return { refresh: false, flushQueue: false, resetBanner: false, reason: "steady" };
}
canQueueWrites 是产品级开关,默认打开;当某个页面承载支付或删除时,可以传 false 把评论框降为禁用。hasCachedContent 只影响 render.source 与提示文案,不影响 canRenderContent,这是「不白屏」约定的代码化。
acceptsWrites 把「实时」与「排队」都视为可接受输入,调用方不需要自己判断两种模式。planRecovery 只在目标状态是 open 时建议补拉与冲刷,并且把 flushQueue 交给调用方传入的 queuedWrites 决定,避免在没排队时做无意义的重放。
测试与真实输出
本机运行结果如下:
$ node --test test/realtime/degrade.test.ts
✔ open 状态下所有能力实时可用,页面不显示提示条 (0.847584ms)
✔ connecting 时暂停实时插入与角标,但缓存内容仍然渲染 (0.0835ms)
✔ reconnecting 默认允许输入排队,恢复后统一发送 (0.068292ms)
✔ 关闭排队策略后,reconnecting 的输入改为禁用 (0.421041ms)
✔ closed 时角标改为手动刷新,presence 明确标为离线 (0.0995ms)
✔ failed 时保留刷新与重试入口,但不接受新的写入 (0.068417ms)
✔ 没有缓存时显示空状态,仍然不把渲染挂到连接状态上 (0.258625ms)
✔ 恢复计划区分补拉、冲刷队列与稳态 (0.111542ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 170.012417
八条用例覆盖了六个状态的默认矩阵、排队开关、无缓存渲染与恢复计划。它们没有覆盖真实 UI 的提示文案是否清楚,也没有覆盖队列本身的顺序与幂等,那些需要端到端测试与第 7 章的协议设计。
失败复盘:把连接当渲染前提
「回声」的详情页最初这样写:组件挂载时创建连接,state === "open" 才渲染正文,否则显示全屏加载。这个写法在办公室 Wi-Fi 下运行了两个月,直到一次地铁通勤场景的投诉:用户打开收藏的文章,页面一直转圈,返回再进仍然空白。
排查发现三个问题叠加。第一,详情页没有读本地缓存,正文只在连接打开后从接口拉取;第二,连接在弱网下进入 reconnecting,组件把「非 open」统一当成加载中;第三,全屏加载遮住了返回按钮之外的整个页面,用户甚至看不到「离线」提示。
修复分三步。第一步,详情页先从缓存渲染正文,缓存为空时渲染空状态与刷新按钮;第二步,连接状态只控制顶部提示条和评论框,不再控制正文;第三步,把降级矩阵抽成共享函数,所有页面用同一套状态到能力的映射,避免详情页修好了、消息页又犯同样的问题。
上线后指标变化很直接:详情页的「首屏空白时长」从依赖连接建立变成依赖缓存读取,弱网下的白屏率显著下降。更重要的是,用户能看见内容,即使评论框暂时不可用,也知道页面没有坏。
第二个事故来自排队。消息页把所有写操作都塞进同一个队列,包括「撤回消息」。恢复后队列按顺序重放,一条已经过期的撤回把对方后来发送的新消息撤回了。修复不是加更多重试,而是把队列拆成「可重放」与「需确认」两类:评论、点赞可重放;撤回、删除恢复后必须让用户确认。降级矩阵里的 canQueueWrites 因此从全局开关变成了按页面配置。
降级检查清单
上线前至少确认这些点:
- 页面在
connecting、reconnecting、closed、failed下都能渲染已有内容。 - 缓存为空时有空状态与刷新入口,而不是白屏或无限加载。
- 每个实时能力都能在矩阵里找到对应模式,没有页面私有的
if。 - 评论框禁用时保留草稿,恢复后不丢用户已经输入的文字。
- 队列有上限、过期时间、顺序保证与幂等键。
- 恢复顺序是「补拉 → 冲刷 → 重置提示」,不是只隐藏提示条。
- 不可重放的写操作在恢复后要求用户确认。
- 提示条文案区分「连接中」「重连中」「离线」「失败」,不统一写「加载中」。
小结
实时不可用是常态,产品应该把连接状态映射成能力,而不是把连接当成渲染前提。五个能力、四种主要降级模式、一张所有页面共用的矩阵,加上「内容永远可渲染」的约定,可以让断线时的页面仍然可读、可写草稿、可手动刷新。恢复时要先补拉、再冲刷队列、最后重置提示;排队只适用于可幂等重放的操作。
到这里,第 6 章回答了「选哪条通道」「连接怎么走完一生」「连接不好时产品怎么办」。下一章进入可靠性:重连如何退避、端上能保证什么程度的去重与有序、断线期间的消息如何补拉与检测空洞。
阅读导航:上一节:6.2 一条实时连接的生命周期 · 下一节:7.1 重连与退避:把断线当成常态 。
常见错误与边界
- 只在
open状态渲染正文,断线时整页白屏。 - 把「连接中」「重连中」「离线」「失败」统一显示成「加载中」。
- 缓存为空时无限转圈,没有空状态与刷新入口。
- 评论框禁用时清空输入框,用户草稿丢失。
- 所有写操作都排队,包括撤回、删除、支付等不可重放操作。
- 队列没有上限与过期时间,恢复后一次性重放几百条。
- 恢复时只隐藏提示条,不补拉断线期间的消息。
- 每个页面自己写一套降级
if,同一状态在不同页面表现不一致。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。