《互联网产品的客户端架构·内容、社交与实时互动》6.2 一条实时连接的生命周期

连接不是一个 connected 布尔值,而是 idle、connecting、open、reconnecting、closed、failed 之间的迁移。本节用可测试的状态机定义合法迁移、后退钩子、页面切后台与恢复、卸载清理,并复盘旧连接回调把新连接误标为 open 的事故。

本节只关心连接自身的状态

这一节不讨论握手细节、帧格式与关闭码,附录 A 已经覆盖;也不展开退避算法、抖动与连接风暴,第 7 章会专门处理。这里讨论的是端上那条连接从创建到销毁要经过哪些状态、哪些迁移合法、页面切后台时怎么处理、组件卸载时清理什么。产品能力如何随状态降级,留到 6.3。

很多客户端代码把连接写成两个布尔值:connected 与 connecting。短轮询时代这够用,因为每次请求都是独立的;长连接时代不够,因为「正在重连」和「首次连接」对用户、对写队列、对埋点的含义完全不同。连接不是事实,而是一台需要被观察和约束的状态机。

为什么布尔值不够

一个真实的长连接至少有六种可观察状态:还没开始、正在首次连接、已经打开、断开后等待重试、用户主动关闭、重试耗尽失败。用 connected: boolean 表示,会出现三个问题。

第一,UI 无法区分「首次连接」与「断线重连」。首次连接可以显示骨架屏,断线重连应该保留已有内容并显示一个轻提示。两者都写成 connected = false,产品只能统一显示「加载中」,用户会以为页面卡死。

第二,写操作无法决定该禁用还是排队。首次连接时用户还没开始输入,禁用是合理的;重连时用户可能已经写了一半,直接禁用会丢草稿。状态机把「为什么不可用」暴露出来,产品才有机会选择排队。

第三,非法事件无法被拒绝。例如连接还没开始就收到 opened,或者已经打开又收到 retry。布尔值只能被覆盖,状态机可以返回 false 并记录事件,让适配层的 bug 在测试里暴露,而不是在生产环境里变成「看起来在线、实际发不出消息」。

六个状态

状态含义进入方式用户可见提示
idle尚未开始,或组件刚创建初始状态无
connecting首次连接或页面恢复后的连接中start、retry、show连接中
open通道可用,可以收发opened无
reconnecting连接丢失,等待下一次重试lost 且未超过上限重连中
closed主动关闭、页面隐藏或已销毁close、hide、dispose离线或暂停
failed重试次数耗尽或遇到致命错误lost 超限、fail失败,可手动重试

closed 与 failed 的区别很重要。closed 是预期内的结束,例如用户退出页面、组件卸载、页面切后台;failed 是预期外的终止,需要产品给出「重试」入口。两者都不应该继续自动重连,但恢复方式不同:closed 可以由用户操作或页面可见性触发重新开始,failed 通常等待用户显式点击。

reconnecting 不是 connecting 的别名。它携带尝试次数与下一次重试时间,UI 可以据此显示「第 2 次重连」或延长提示;connecting 则可能是首次连接,也可能是重试真正发起后的短暂阶段。

合法迁移表

状态机的核心不是状态列表,而是「谁允许谁」。下表是本节实现接受的迁移:

当前状态事件目标状态说明
idlestartconnecting首次连接
connectingopenedopen握手成功
connectinglostreconnecting连接中失败,进入退避
connectingfailfailed鉴权失败等致命错误
connectingclose / hideclosed用户取消或页面隐藏
openlostreconnecting网络中断
openfailfailed服务端要求终止
openclose / hideclosed主动关闭或切后台
reconnectingretryconnecting到达重试时间
reconnectinglostfailed超过最大尝试次数
reconnectingclose / hideclosed用户取消或切后台
closedstartconnecting手动重新开始
closedshowconnecting仅当因页面隐藏而关闭
failedstartconnecting用户点击重试

open 不能直接 start,idle 不能 opened,closed 不能 retry。这些看起来是显而易见的约束,但适配层经常在错误时机调用:socket 的 onopen 可能在组件已经卸载后触发,visibility 监听可能在连接已经失败后再次调用 show。状态机返回 false,比静默接受更能帮助定位问题。

事件不是日志,是钩子

每次迁移都会产出一个事件:from、to、reason、at、attempt。它不是给日志系统看的字符串,而是 UI、埋点、适配层共同的钩子。监听器在状态更新之后同步调用,因此读取 machine.state 一定拿到新状态。

钩子有三个约束。第一,监听器不能抛异常影响状态机;实现上应该由适配层自己包 try/catch,或者把副作用放到微任务里。第二,监听器不能同步调用会再次触发迁移的方法,否则会造成重入;例如在 closed 事件里立刻 start(),状态机会在事件分发过程中再次迁移,事件顺序变得难以推理。第三,监听器要能取消订阅,否则组件卸载后闭包仍持有旧 UI。

reason 的价值在于区分同一条边的不同原因。open -> closed 可能是用户点击关闭、页面隐藏或组件销毁;三者对「是否允许恢复」的答案不同。只记录 from 与 to,恢复逻辑只能靠猜。

重试时间只记录,不自己定时

状态机在 lost 时计算 nextRetryAt,但不持有 setTimeout。真正的定时器由调度层管理,到点后调用 retry()。这样拆分有两个好处:状态机是纯进程内逻辑,测试用假时钟就能覆盖;退避策略可以独立演进,第 7 章会加入抖动、上限与网络类型判断,而不用改状态迁移。

实现里 attempt 在 opened 时清零,而不是在 retry 时清零。这样连续失败会持续放大退避;如果每次重试都清零,退避永远停在一倍,服务端故障时会变成固定频率的连接风暴。

maxAttempts 是状态机的边界,不是产品承诺。超过上限进入 failed,只是停止自动重试;产品仍可以通过 start() 让用户手动重试。把「自动重试」与「允许恢复」分开,可以避免状态机既管连接又管产品策略。

页面切后台与恢复

移动端切后台后,JavaScript 可能被挂起,socket 可能已经被系统回收,但 onclose 不一定触发。如果客户端继续认为自己是 open,回到前台后第一次发送才会发现连接已死;更糟的是,后台期间安排的 setTimeout 会被节流,回到前台时多个重试同时触发。

本节的策略是:页面隐藏时调用 hide(),把 open、connecting 或 reconnecting 统一迁到 closed,记录 suspended 标记,并清空 nextRetryAt。这样后台不会继续安排重连,也不会出现「后台重连成功又立刻被挂起」的抖动。

回到前台时调用 show(),只有 suspended 为真且状态为 closed 才迁到 connecting。重连成功后,适配层还要做一次补拉,因为断开期间的消息不会自动出现;补拉与空洞检测属于 7.3,这里只保证状态机给出明确的恢复入口。

hide() 与 show() 都必须是幂等的。visibility 事件会重复触发,组件也可能同时被多个监听器调用;第二次调用返回 false,不产生重复迁移,UI 才不会闪。

卸载时清理什么

dispose() 是终态:如果当前不是 closed,先迁到 closed 并发出 dispose 事件;然后清空所有监听器,后续 start()、lost()、retry() 一律返回 false。重复调用 dispose() 不产生第二个事件。

状态机不拥有 socket、定时器、visibility 监听与心跳。它只负责状态,资源由适配层在 closed 事件里释放:关闭 socket、清除心跳、取消重试定时器、移除页面可见性监听、中止正在进行的补拉请求。把这些清理写进 dispose 的注释或适配层清单,比依赖垃圾回收可靠。

一个常见遗漏是事件监听器的引用。组件卸载时只关闭了 socket,却忘记取消 machine.onTransition 返回的订阅函数;下次组件挂载创建新状态机,旧监听器仍可能被旧事件触发,更新已经卸载的 UI。订阅函数必须是清理清单的第一项。

一个可运行的状态机

下面的实现属于 L2:它没有真实网络,但通过注入时钟与显式 retry() 模拟了连接生命周期。测试用假时钟推进 nextRetryAt,不需要等待真实退避时间。

// file: src/realtime/connection.ts
// tier: L2
// verify: node --test --test-concurrency=1 test/realtime/connection.test.ts
export type ConnectionState =
  | "idle"
  | "connecting"
  | "open"
  | "reconnecting"
  | "closed"
  | "failed";

export type TransitionReason =
  | "start"
  | "opened"
  | "network-lost"
  | "retry"
  | "max-attempts"
  | "fatal"
  | "close"
  | "page-hidden"
  | "page-visible"
  | "dispose";

export interface TransitionEvent {
  from: ConnectionState;
  to: ConnectionState;
  reason: TransitionReason;
  at: number;
  attempt: number;
}

export interface ConnectionOptions {
  now?: () => number;
  maxAttempts?: number;
  baseDelayMs?: number;
  maxDelayMs?: number;
}

export type TransitionListener = (event: TransitionEvent) => void;

export function retryDelay(attempt: number, baseDelayMs = 500, maxDelayMs = 30_000): number {
  if (!Number.isInteger(attempt) || attempt < 1) throw new Error("attempt 必须是正整数");
  if (!Number.isFinite(baseDelayMs) || baseDelayMs <= 0) throw new Error("baseDelayMs 必须是正数");
  if (!Number.isFinite(maxDelayMs) || maxDelayMs < baseDelayMs) {
    throw new Error("maxDelayMs 不能小于 baseDelayMs");
  }
  return Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
}

export class ConnectionLifecycle {
  #state: ConnectionState = "idle";
  #attempt = 0;
  #nextRetryAt: number | null = null;
  #suspended = false;
  #disposed = false;
  #listeners = new Set<TransitionListener>();
  #now: () => number;
  #maxAttempts: number;
  #baseDelayMs: number;
  #maxDelayMs: number;

  constructor(options: ConnectionOptions = {}) {
    this.#now = options.now ?? (() => Date.now());
    this.#maxAttempts = options.maxAttempts ?? 5;
    this.#baseDelayMs = options.baseDelayMs ?? 500;
    this.#maxDelayMs = options.maxDelayMs ?? 30_000;
    if (!Number.isInteger(this.#maxAttempts) || this.#maxAttempts < 1) {
      throw new Error("maxAttempts 必须是正整数");
    }
    retryDelay(1, this.#baseDelayMs, this.#maxDelayMs);
  }

  get state(): ConnectionState {
    return this.#state;
  }

  get attempt(): number {
    return this.#attempt;
  }

  get nextRetryAt(): number | null {
    return this.#nextRetryAt;
  }

  get suspended(): boolean {
    return this.#suspended;
  }

  get disposed(): boolean {
    return this.#disposed;
  }

  onTransition(listener: TransitionListener): () => void {
    this.#listeners.add(listener);
    return () => {
      this.#listeners.delete(listener);
    };
  }

  start(): boolean {
    if (this.#disposed) return false;
    if (this.#state !== "idle" && this.#state !== "closed" && this.#state !== "failed") {
      return false;
    }
    this.#attempt = 0;
    this.#nextRetryAt = null;
    this.#suspended = false;
    this.#move("connecting", "start");
    return true;
  }

  opened(): boolean {
    if (this.#disposed) return false;
    if (this.#state !== "connecting") return false;
    this.#attempt = 0;
    this.#nextRetryAt = null;
    this.#move("open", "opened");
    return true;
  }

  lost(reason: TransitionReason = "network-lost"): boolean {
    if (this.#disposed) return false;
    if (this.#state !== "open" && this.#state !== "connecting") return false;
    this.#attempt += 1;
    if (this.#attempt > this.#maxAttempts) {
      this.#nextRetryAt = null;
      this.#move("failed", "max-attempts");
      return true;
    }
    this.#nextRetryAt = this.#now() + retryDelay(this.#attempt, this.#baseDelayMs, this.#maxDelayMs);
    this.#move("reconnecting", reason);
    return true;
  }

  retry(): boolean {
    if (this.#disposed) return false;
    if (this.#state !== "reconnecting") return false;
    if (this.#nextRetryAt !== null && this.#now() < this.#nextRetryAt) return false;
    this.#nextRetryAt = null;
    this.#move("connecting", "retry");
    return true;
  }

  fail(): boolean {
    if (this.#disposed) return false;
    if (this.#state === "idle" || this.#state === "closed" || this.#state === "failed") return false;
    this.#nextRetryAt = null;
    this.#move("failed", "fatal");
    return true;
  }

  close(): boolean {
    if (this.#disposed) return false;
    if (this.#state === "idle" || this.#state === "closed") return false;
    this.#nextRetryAt = null;
    this.#suspended = false;
    this.#move("closed", "close");
    return true;
  }

  hide(): boolean {
    if (this.#disposed || this.#suspended) return false;
    if (this.#state !== "open" && this.#state !== "connecting" && this.#state !== "reconnecting") {
      return false;
    }
    this.#suspended = true;
    this.#nextRetryAt = null;
    this.#move("closed", "page-hidden");
    return true;
  }

  show(): boolean {
    if (this.#disposed || !this.#suspended) return false;
    if (this.#state !== "closed") return false;
    this.#suspended = false;
    this.#attempt = 0;
    this.#move("connecting", "page-visible");
    return true;
  }

  dispose(): void {
    if (this.#disposed) return;
    if (this.#state !== "closed") {
      this.#nextRetryAt = null;
      this.#suspended = false;
      this.#move("closed", "dispose");
    }
    this.#disposed = true;
    this.#listeners.clear();
  }

  #move(to: ConnectionState, reason: TransitionReason): void {
    const from = this.#state;
    this.#state = to;
    const event: TransitionEvent = {
      from,
      to,
      reason,
      at: this.#now(),
      attempt: this.#attempt,
    };
    for (const listener of this.#listeners) listener(event);
  }
}

start() 只接受 idle、closed、failed,因此用户关闭后可以重新开始,失败后也可以手动重试。lost() 同时接受 open 与 connecting,因为握手阶段也可能断线。retry() 检查当前时间是否到达 nextRetryAt,未到时间返回 false,调度层可以安全地重复调用。

close() 与 hide() 都会进入 closed,区别在 suspended 标记。hide() 只在连接仍活跃时生效,避免页面隐藏事件把已经失败的连接「洗白」成可恢复状态。

测试与真实输出

本机运行结果如下:

$ node --test --test-concurrency=1 test/realtime/connection.test.ts
✔ idle 只能先进入 connecting,再进入 open (0.946ms)
✔ 非法迁移返回 false,不产生事件也不改变状态 (0.466791ms)
✔ 连接丢失进入 reconnecting,并按尝试次数安排下一次重试时间 (0.127291ms)
✔ 未到重试时间不能 retry,假时钟推进后可以 (0.07825ms)
✔ 连续丢失时退避翻倍,超过上限进入 failed,之后可以从 failed 重启 (0.276167ms)
✔ 页面切后台关闭连接并标记 suspended,回到前台重新 connecting (0.099167ms)
✔ dispose 关闭连接、清理监听,并且是终态和幂等的 (0.095ms)
✔ 取消订阅后不再收到迁移事件,但状态机继续工作 (0.071541ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 126.213708

八条用例覆盖了正常迁移、非法迁移、退避、超限、页面可见性、销毁与取消订阅。它们没有覆盖「旧 socket 的回调迟到」这一类问题,因为状态机没有连接身份,必须由适配层用代际令牌解决。

失败复盘:旧连接的回调把新连接标成 open

「回声」的会话页在切后台再回来后,偶发出现「显示在线但发不出消息」。日志里状态机确实是 open,socket 却已经关闭。复盘发现两条连接的生命周期交叠了。

页面隐藏时,适配层调用了 hide(),状态机进入 closed;但旧 socket 的关闭是异步的,onclose 在几秒后才到。回到前台,show() 让状态机进入 connecting,适配层创建了新 socket。此时旧 socket 的 onopen 迟到,适配层没有区分它属于哪一次连接,直接调用 machine.opened(),状态机从 connecting 迁到 open。新 socket 其实还在握手,于是 UI 显示在线,发送却全部失败。

修复不是改状态机,而是给每次连接分配一个递增的 generation:适配层在创建 socket 时记录当前代际,所有回调先比较代际,旧代际的事件直接丢弃。状态机仍然只负责「当前这条连接」的迁移,身份过滤属于适配层。这也说明状态机的 opened() 不应该接受任意来源的「打开」信号。

第二个问题是后台定时器。修复前,hide() 只改了状态,没有通知调度层取消已经安排的 setTimeout;回到前台后多个定时器同时调用 retry()。状态机只让第一个成功,后两个返回 false,所以没有产生重复迁移,但适配层在调用 retry() 前就创建了 socket,仍然多建了两条连接。最终把「取消定时器」放进 closed 事件的清理清单,问题才消失。

与 6.3 的边界

本节结束时,我们只知道连接处于哪个状态,还不知道每个状态对应什么产品能力。reconnecting 时评论框应该禁用还是排队?角标应该停止更新还是保留最后一次数字?这些不是状态机该回答的问题。下一节把状态映射成能力矩阵,并讨论实时不可用时页面如何避免白屏。

小结

连接生命周期要用状态机而不是布尔值表达。六个状态覆盖首次连接、已打开、等待重试、主动关闭与失败;合法迁移表拒绝迟到与重复事件;事件钩子携带原因与尝试次数;退避时间只记录、不自己定时;页面隐藏时挂起并清理重试,恢复时重新连接并补拉;销毁时进入终态并清空监听。状态机不拥有 socket,适配层要用代际令牌过滤旧连接的回调。

状态机解决了「连接现在怎么样」,但产品还需要回答「连接不好时用户能做什么」。下一节给出降级矩阵:哪些能力暂停、哪些改为手动、哪些允许排队,以及恢复后如何补拉。

阅读导航:上一节:6.1 轮询、SSE 与 WebSocket 的判定表 · 下一节:6.3 降级路径:实时不可用时产品还能做什么 。

常见错误与边界

  • 用 connected 布尔值表示连接,无法区分首次连接、重连与失败。
  • 连接还没开始就接受 opened,适配层 bug 被静默吞掉。
  • 每次 retry 都把尝试次数清零,退避永远停在一倍。
  • 状态机自己持有 setTimeout,测试必须等真实时间,清理也容易漏。
  • 页面隐藏后继续安排重连,回到前台时多个定时器同时触发。
  • hide() 与 show() 不幂等,visibility 事件重复时 UI 闪烁。
  • dispose() 只关 socket,不取消事件订阅,旧监听器更新已卸载的组件。
  • 不区分旧连接回调与新连接,迟到的 onopen 把新连接误标为 open。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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