《互联网产品的客户端架构·内容、社交与实时互动》7.3 断线期间的消息补拉与空洞检测

重连后只拉最新一条,会让中间的缺口永远补不上。本节用已收序列号维护连续高水位,按批生成补拉区间,把补拉结果与实时推送合并去重,并复盘一次「重连只同步最新消息」导致的历史消息永久丢失,说明补拉报告如何表达未完成状态,以及服务端历史过期后端上必须承认的边界。

本节不重复协议层的补发

附录 A 讲过 Last-Event-ID、WebSocket 的业务确认与长轮询的游标,这些是通道能力,解决「服务端能不能从某个位置继续发」。本节解决端上的另一半:服务端愿意补,客户端怎么知道自己该从哪补、补回来的消息怎么与实时推送合并、补不全时怎么不假装完整。 7.2 的顺序门负责「到达之后怎么排队」,本节负责「没到达的怎么发现并取回来」;顺序门看到的是一个缺口状态,补拉逻辑要把这个状态变成一组区间请求。

重连成功后的第一个错误

最常见的补拉实现只有一行:重连成功后请求「最新一条消息」,把它显示出来,然后继续监听。这个做法在聊天里看起来完全正常:用户回到应用,看到最后一条消息,界面底部是最新内容,没有报错。问题是中间。假设断线前用户看到序列号 100,断线期间产生了 101 到 140,重连后只拉 140;界面上 140 出现了,101 到 139 不在。如果产品只展示最新消息、不回溯,用户可能暂时发现不了;一旦往上滑,或者 141 到达后触发一次「加载更多」,缺口就暴露成一段空白。

更糟的是,这个错误会自我固化。如果端上把「已收最大序列号」当成下一次补拉的起点,那么拉过 140 之后,下次就从 141 开始。101 到 139 永远不会再被请求。服务端的历史保留期一过,它们就真的丢了。「只拉最新一条」不是少拉了几条,而是把缺口从「待补」变成了「已越过」。

高水位不是已收最大值

补拉的正确起点不是「收到过的最大序列号」,而是「连续应用到的最大序列号」。这两个值在乱序时不同:

已收序列号已收最大值连续高水位真实缺口
1, 2, 333无
1, 2, 5523–4
1, 4, 5, 6612–3

第三行是关键:最大值是 6,但 2 和 3 从未到达。如果拿 6 当起点,2 和 3 会被永久跳过;拿高水位 1 当起点,补拉区间是 2–6,其中 4、5、6 已经在缓冲区里,补回来会被去重,2、3 则被真正补上。高水位的定义必须写进代码注释:它是「从起点开始,每一条都连续到达」的最大序列号,不是「见过的最大的那个」。 这两个概念混淆一次,补拉逻辑就会在第一次乱序时失效,而且失效得很安静。

空洞是怎么被看见的

端上需要维护三样东西:highWater 是连续应用到的最大序列号;buffered 保存序列号大于 highWater + 1 的已到达消息,按序列号索引;seenIds 记录已经处理过的消息 id,用于补拉与实时推送之间的去重。有了这三样,空洞可以随时计算:从 highWater + 1 开始向上扫描,跳过 buffered 里已有的序列号,剩下的连续段就是缺失区间。这个过程不需要知道服务端总共有多少消息,只需要一个上界——通常是服务端返回的当前高水位。

空洞检测与顺序门有一个重要区别:顺序门只需要判断「这条消息现在能不能应用」,补拉逻辑需要判断「哪些区间值得发请求」。前者是单条判定,后者是区间生成。把两者混在一个方法里,通常会导致「每来一条消息就发一次补拉请求」,把恢复过程变成请求风暴。

生成补拉区间

生成区间需要三个输入:highWater、buffered 的键集合、服务端高水位。算法很直接:从 highWater + 1 扫描到服务端高水位;遇到 buffered 里已有的序列号,结束当前缺口段;其余连续序列号合并成 { from, to },再按批大小切分,避免一次请求跨几千条消息。

批大小的取舍与上传分片类似:太大,单次响应慢、超时后重试成本高;太小,请求数多、恢复时间长。50–200 是一个常见起点,具体值应该由消息平均大小与服务端单次返回上限决定,而不是拍脑袋。

区间必须按序列号升序请求。补拉的目的是尽快把 highWater 推到服务端高水位;如果先拉后面的区间,前面的缺口仍然卡着,highWater 不会前进,缓冲区会越来越大。升序请求还有一个好处:一旦前面的区间补上,后面已缓冲的消息会立刻被连续放行,后续请求可能直接变成空操作。

补拉与实时推送并发

补拉不是「暂停实时通道,等历史拉完再恢复」。用户重连后,新消息仍在产生;如果暂停推送,补拉结束时又会缺一段。正确做法是让两条路径进入同一个状态容器:

  • 实时推送到达时,直接调用 ingest,与补拉响应走同一条去重与排序路径。
  • 补拉响应到达时,也调用 ingest;已经在实时路径处理过的消息按 id 去重。
  • 补拉期间实时消息推进了 highWater,后续补拉区间即使包含这些序列号,也会被 late 或 duplicate 丢弃,不会重复应用。

这要求补拉请求发出前计算的区间允许「过期」。区间是一个提示,不是合同:服务端返回的消息可能已经被实时路径应用,端上必须能安全地重复处理。补拉逻辑的正确性不能依赖「请求发出后状态不变」。 另一个约束是单飞:同一时刻只允许一个补拉流程。用户可能连续触发重连、回到前台、手动刷新,如果每次都启动一个补拉,多个响应会交错进入容器,虽然去重能兜底,但请求量与日志会失控;用一个 running 标志拒绝并发补拉,让后来的调用等待或直接返回「已在进行中」。

补拉不是一次请求

服务端可能只返回一部分:单次响应有条数上限、历史保留期截断了更早的消息、或者请求在传输中被截断。补拉流程必须能表达「还没补完」:

  • 响应返回后重新计算剩余空洞,而不是假设「请求过的区间就完整了」。
  • 有剩余空洞时,报告 remainingGaps,由上层决定立即再拉、退避后重试,还是提示用户。
  • 重试仍然使用 7.1 的退避与上限,不能因为「这是补拉」就无限重试。
  • 「补拉完成」的判据不是「请求成功」,而是「没有剩余空洞,或服务端明确告知某段已不可用」;前者是正常完成,后者是部分完成,两者都必须能传到 UI。

一个可运行的补拉模块

下面的模块属于 L2:它没有真实端口,但包含异步请求、可控响应与假时钟,能验证「补拉期间实时消息到达」这种时序问题。GapTracker 负责状态,CatchUpSession 负责编排。

// file: src/reliability/gap.ts
// tier: L2
// verify: node --test --test-concurrency=1 test/reliability/gap.test.ts

export interface SequencedMessage {
  id: string;
  seq: number;
}

export interface SeqRange {
  from: number;
  to: number;
}

export type GapIngestStatus = "applied" | "buffered" | "duplicate" | "late";

export interface GapIngestResult {
  status: GapIngestStatus;
  applied: SequencedMessage[];
  gap: SeqRange | null;
}

export interface GapTrackerOptions {
  startSeq?: number;
  seenLimit?: number;
  historyLimit?: number;
}

export class GapTracker {
  #highWater: number;
  #seenLimit: number;
  #historyLimit: number;
  #seen = new Set<string>();
  #seenOrder: string[] = [];
  #buffered = new Map<number, SequencedMessage>();
  #appliedOrder: number[] = [];

  constructor(options: GapTrackerOptions = {}) {
    const startSeq = options.startSeq ?? 0;
    const seenLimit = options.seenLimit ?? 4096;
    const historyLimit = options.historyLimit ?? 1024;
    if (!Number.isInteger(startSeq) || startSeq < 0) throw new Error("startSeq 必须是非负整数");
    if (!Number.isInteger(seenLimit) || seenLimit < 1) throw new Error("seenLimit 必须是正整数");
    if (!Number.isInteger(historyLimit) || historyLimit < 1) throw new Error("historyLimit 必须是正整数");
    this.#highWater = startSeq;
    this.#seenLimit = seenLimit;
    this.#historyLimit = historyLimit;
  }

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

  get pendingCount(): number {
    return this.#buffered.size;
  }

  appliedSeqs(): number[] {
    return [...this.#appliedOrder];
  }

  ingest(message: SequencedMessage): GapIngestResult {
    assertMessage(message);
    if (this.#seen.has(message.id)) return { status: "duplicate", applied: [], gap: null };
    if (message.seq <= this.#highWater) {
      this.#remember(message.id);
      return { status: "late", applied: [], gap: null };
    }
    if (message.seq > this.#highWater + 1) {
      const existing = this.#buffered.get(message.seq);
      if (existing) {
        // 同一个序列号被两条不同 id 占用,按重复处理并保留先到者。
        return { status: "duplicate", applied: [], gap: this.#gapBefore(message.seq) };
      }
      this.#buffered.set(message.seq, message);
      this.#remember(message.id);
      return { status: "buffered", applied: [], gap: this.#gapBefore(message.seq) };
    }
    this.#remember(message.id);
    const applied: SequencedMessage[] = [message];
    this.#highWater = message.seq;
    this.#appliedOrder.push(message.seq);
    while (this.#buffered.has(this.#highWater + 1)) {
      const next = this.#buffered.get(this.#highWater + 1) as SequencedMessage;
      this.#buffered.delete(next.seq);
      applied.push(next);
      this.#highWater = next.seq;
      this.#appliedOrder.push(next.seq);
    }
    this.#trimHistory();
    return { status: "applied", applied, gap: null };
  }

  // 只报告已缓存消息之间能确认的空洞;没有上界时无法推断尾部缺失。
  gaps(): SeqRange[] {
    return this.#missing(this.#bufferedHigh());
  }

  // 以服务端高水位为上界,生成补拉区间,并按 batchSize 切分。
  planPull(serverHighWater: number, batchSize = 50): SeqRange[] {
    if (!Number.isInteger(serverHighWater) || serverHighWater < 0) {
      throw new Error("serverHighWater 必须是非负整数");
    }
    if (!Number.isInteger(batchSize) || batchSize < 1) throw new Error("batchSize 必须是正整数");
    const upTo = Math.max(serverHighWater, this.#bufferedHigh());
    return this.#missing(upTo).flatMap((range) => splitRange(range, batchSize));
  }

  #bufferedHigh(): number {
    let high = this.#highWater;
    for (const seq of this.#buffered.keys()) {
      if (seq > high) high = seq;
    }
    return high;
  }

  #gapBefore(seq: number): SeqRange | null {
    const ranges = this.#missing(seq - 1);
    return ranges.length > 0 ? ranges[0] : null;
  }

  #missing(upTo: number): SeqRange[] {
    const ranges: SeqRange[] = [];
    let start = -1;
    for (let seq = this.#highWater + 1; seq <= upTo; seq += 1) {
      if (this.#buffered.has(seq)) {
        if (start !== -1) {
          ranges.push({ from: start, to: seq - 1 });
          start = -1;
        }
        continue;
      }
      if (start === -1) start = seq;
    }
    if (start !== -1) ranges.push({ from: start, to: upTo });
    return ranges;
  }

  #remember(id: string): void {
    if (this.#seen.has(id)) return;
    this.#seen.add(id);
    this.#seenOrder.push(id);
    while (this.#seenOrder.length > this.#seenLimit) {
      const oldest = this.#seenOrder.shift() as string;
      this.#seen.delete(oldest);
    }
  }

  #trimHistory(): void {
    if (this.#appliedOrder.length > this.#historyLimit) this.#appliedOrder.shift();
  }
}

export interface PullSource {
  serverHighWater(): number;
  fetchRange(range: SeqRange): Promise<SequencedMessage[]>;
}

export interface CatchUpReport {
  requests: number;
  received: number;
  applied: number;
  duplicates: number;
  remainingGaps: SeqRange[];
  highWater: number;
  finishedAt: number;
}

export type Clock = () => number;

export class CatchUpSession {
  #tracker: GapTracker;
  #source: PullSource;
  #now: Clock;
  #running = false;

  constructor(tracker: GapTracker, source: PullSource, now: Clock = () => Date.now()) {
    this.#tracker = tracker;
    this.#source = source;
    this.#now = now;
  }

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

  // 补拉期间实时通道仍可能推送,直接进入同一个 tracker,由 id 去重合并。
  ingestLive(message: SequencedMessage): GapIngestResult {
    return this.#tracker.ingest(message);
  }

  async catchUp(): Promise<CatchUpReport> {
    if (this.#running) throw new Error("补拉已在进行中");
    this.#running = true;
    try {
      const serverHigh = this.#source.serverHighWater();
      const ranges = this.#tracker.planPull(serverHigh);
      let requests = 0;
      let received = 0;
      let applied = 0;
      let duplicates = 0;
      for (const range of ranges) {
        const page = await this.#source.fetchRange(range);
        requests += 1;
        received += page.length;
        for (const message of page) {
          const result = this.#tracker.ingest(message);
          applied += result.applied.length;
          if (result.status === "duplicate") duplicates += 1;
        }
      }
      return {
        requests,
        received,
        applied,
        duplicates,
        remainingGaps: this.#tracker.planPull(Math.max(serverHigh, this.#tracker.highWater)),
        highWater: this.#tracker.highWater,
        finishedAt: this.#now(),
      };
    } finally {
      this.#running = false;
    }
  }
}

function splitRange(range: SeqRange, batchSize: number): SeqRange[] {
  const chunks: SeqRange[] = [];
  for (let from = range.from; from <= range.to; from += batchSize) {
    chunks.push({ from, to: Math.min(range.to, from + batchSize - 1) });
  }
  return chunks;
}

function assertMessage(message: SequencedMessage): void {
  if (typeof message.id !== "string" || message.id.length === 0) throw new Error("id 不能为空");
  if (!Number.isInteger(message.seq) || message.seq < 1) throw new Error("seq 必须是正整数");
}

实现里的三个决定

  • 用高水位,不用最大值。 planPull 从 #highWater + 1 开始扫描,而不是从缓冲区里的最大值开始。用最大值当起点,看似能少发几次请求,实际会把「中间的空洞」永久跳过;用高水位当起点,补回来的已缓冲消息会被去重,代价只是少量重复传输。
  • 缓冲区既参与生成区间,也参与去重。 #buffered 的键在 #missing 里被跳过,避免请求已经在本地等待的消息;同时 ingest 会按 id 去重,防止补拉响应与实时推送重复应用同一条。
  • 单飞与「未完成」是一等状态。 CatchUpSession 用 #running 拒绝并发补拉,CatchUpReport 用 remainingGaps 表达「还没补完」。调用方不能只检查 Promise 是否 resolve,还要检查剩余空洞。

测试与真实输出

本机运行结果如下:

$ node --test --test-concurrency=1 test/reliability/gap.test.ts
✔ 按已收序列识别缺口 (0.996042ms)
✔ 补拉区间按批大小切分 (0.260917ms)
✔ 补拉结果与已缓存的实时消息合并 (0.267209ms)
✔ 补拉期间到达的实时消息不重复应用 (0.186583ms)
✔ 只补最新一条会留下永远补不上的空洞 (0.10275ms)
✔ 已经连续时补拉不发请求 (0.0895ms)
✔ 服务端返回不完整时保留剩余空洞 (0.092709ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 175.77425

七条用例覆盖了缺口识别、分批、与实时消息合并、并发去重、只拉最新一条的失败、空操作与不完整响应。它们没有覆盖真实网络与服务端历史保留策略,因为这一层验证的是「给定已收序列号与服务端高水位,端上生成什么区间、合并后处于什么状态」。

失败复盘:只拉最新一条

「回声」的一个群聊频道上线后,客服收到一种零散反馈:用户从电梯出来后,往上滑能看到一段空白,但新消息正常。复现路径很稳定:进电梯断网两分钟,出来重连,群里在这两分钟里发了三十条消息。用户看到最新一条,往上滑却只有断网前的最后一条,中间三十条不在。日志还原了完整链路(示例值):

  1. 断线前 highWater = 100。
  2. 断线期间服务端产生 101–130。
  3. 重连后客户端请求「最新一条」,拿到 130,界面显示,同时把本地游标更新为 130。
  4. 此后实时消息从 131 正常应用,highWater 变成 131、132……;101–129 从未被请求,三天后服务端历史过期,它们永久缺失。

这个 bug 之所以难被发现,是因为它不产生错误、不产生重复、不影响新消息。用户只会在「往上滑」这个动作里看到异常,而大多数用户不会在断线后立刻回溯。直到有人要找一条断网期间的消息,才报上来。修复分三步:

  1. 端上不再用「最新一条」的序列号更新游标,而是维护连续高水位;重连后计算 highWater + 1 到服务端高水位的区间。
  2. 补拉响应与实时推送进入同一个 tracker,按消息 id 去重,避免修复引入重复。
  3. 增加一条回归用例:已收 1,直接收到 5,断言 gaps() 为 [3,4]、planPull(5) 仍包含这段区间。这条用例直接对应事故,不依赖真实网络。

修复上线后,同类反馈消失。更重要的是,补拉报告里开始出现 remainingGaps:当服务端历史保留期截断了更早的消息时,端上不再假装完整,而是能把这个状态交给产品决定怎么提示。

补拉完成不等于消息完整

服务端可能已经删除了请求区间内的消息:历史保留期到期、频道被清理、消息被合规删除。这时补拉请求成功,返回的条数却少于区间长度;端上不能把「请求成功」当成「区间完整」,否则会制造一个看不见的洞。正确的处理是让服务端明确返回「这段已不可用」或「返回被截断」,端上把剩余空洞标记为 unavailable,而不是继续重试。产品可以选择显示「部分历史消息已过期」,也可以选择不展示这段,但不能假装它不存在。用户看到一段空白,至少知道发生了什么;看到消息序号跳变却没有任何提示,才会怀疑应用坏了。

补拉也不能修复端上从未知道的序列号。如果服务端高水位是 130,端上只知道 100,它能补 101–130;如果服务端自己也不知道 101–129 去哪了,端上没有任何办法。端上的职责是「把可观测的缺口补上并报告不可补的部分」,不是「保证服务端数据永远完整」。

小结

补拉的核心不是「再请求一次」,而是「知道从哪里继续」。高水位是连续应用的最大序列号,不是收到过的最大值;补拉区间从高水位向上扫描,跳过已缓冲的序列号,按批请求;补拉与实时推送进入同一个容器,用消息 id 去重;补不全时要报告剩余空洞,而不是用成功响应掩盖。

到这里,实时可靠性的三块拼图齐了:重连负责把管道接回来,顺序门负责去重与排队,补拉负责把缺口填上。下一节离开消息层,讨论 presence:为什么端上看到的「在线」永远只是一个近似值。

阅读导航:上一节:7.2 消息去重与有序:端上能保证什么 · 下一节:8.1 presence 的近似语义:没有真正的「在线」 。

常见错误与边界

  • 重连后只拉最新一条。中间缺口被游标越过,历史过期后永久丢失。
  • 用已收最大序列号当补拉起点。乱序时会把中间空洞当成已覆盖。
  • 补拉区间不跳过已缓冲的序列号。为本地已有的消息发无用请求。
  • 补拉与实时推送使用两个容器。同一消息可能被应用两次,或顺序互相覆盖。
  • 并发触发多次补拉。响应交错,日志与请求量失控,去重只能兜底不能防错。
  • 把「请求成功」当成「补拉完成」。服务端截断或历史过期时,端上仍显示完整。
  • 剩余空洞只写日志不传 UI。用户看到空白,产品却不知道发生过什么。
  • 补拉失败后无限重试。补拉也是请求,必须遵守退避与次数上限。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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