《互联网产品的客户端架构·内容、社交与实时互动》6.3 降级路径:实时不可用时产品还能做什么

实时通道会断,产品不能跟着白屏。本节把连接状态映射成角标、自动插入、输入中、评论框与在线状态五种能力,给出暂停、手动、排队、禁用四类降级模式与恢复顺序,让页面在弱网下仍能读缓存、写草稿、手动刷新;队列只接受可幂等重放的写操作,并复盘一次整页空白事故。

这一节讨论「连接不好时产品怎么办」

上一节把连接拆成六个状态,但没有回答每个状态对应什么产品行为。这一节不重复通道选型(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

连接恢复后,很多实现只做一件事:把提示条隐藏。这不够。恢复至少包含三个动作,而且有顺序:

  1. 补拉:拉取断线期间的消息,按序列号检测空洞,去重后合并。先让本地视图追上服务端。
  2. 冲刷队列:按原顺序发送排队的写入,携带幂等键,逐条确认。服务端状态已经追平,才能判断冲突。
  3. 重置提示:隐藏断线提示,恢复角标与在线状态。

顺序不能反。先冲刷队列再补拉,可能把基于旧视图的评论发到一个已经变化的话题里;先补拉再冲刷,至少能让用户看到上下文再决定是否重发。对于不可自动重放的写入,恢复后应该提示用户「有 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,同一状态在不同页面表现不一致。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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