《互联网产品的客户端架构·内容、社交与实时互动》7.2 消息去重与有序:端上能保证什么

同一条消息可能因重连补发、重试或多路径到达两次,晚到的消息也可能插到已经读过的位置。本节区分消息 id 与序列号,给出端上的去重集合、乱序缓冲窗口与冲突判定,并用可运行代码证明端上只能保证最终有序,不能保证实时有序,还会说明窗口边界与冲突上报为什么决定了故障是否可观测。

本节只谈「到达之后」

上一节解决了「什么时候再连」。连接回来以后,端上会面对一批到达顺序不确定的消息:有的重复,有的迟到,有的跳过了几个序列号。这一节只处理这三件事:怎么判断见过、怎么判断该不该现在应用、怎么在缺口填上之后保持顺序。

它不讨论退避与重连调度(7.1),也不讨论缺口该向服务端补哪一段(7.3)。协议层的消息 id、事件 id 与断点续传字段在附录 A,这里不重复。我们假定消息已经到达端上,只关心它该不该进入业务状态、以什么顺序进入。

三个编号,各管一件事

实时系统里经常同时存在三个编号,把它们混用是去重与排序失效的起点:

编号生命周期作用不能用来做什么
连接 id一次连接区分「这条消息从哪条连接来」跨重连去重,重连后 id 会变
消息 id消息本身去重、幂等、跨路径识别同一条排序,id 不保证单调
序列号一个会话或频道排序、检测缺口、补拉定位去重,服务端重启后可能重复

消息 id 必须由服务端生成,并且在实时推送、历史拉取、重试响应里保持一致。序列号必须是单调递增的整数,作用域要明确:是全局、单聊会话、群聊还是某个频道。跨会话比较序列号没有意义,把两个群的 seq 放进同一个去重集合,会把正常消息误判为重复。

连接 id 最容易被误用。一个常见的写法是「连接建立时清空已见集合」,理由是「新连接是新会话」。这正好把重连后的补发全部放进来:服务端从断点重发最近十条,端上因为集合被清空,把十条全部当成新消息。连接 id 可以用来打日志、区分重连批次,但不能作为去重集合的生命周期边界。

去重与有序是两件事

去重回答「这条消息我处理过吗」,有序回答「这条消息现在能应用吗」。两者使用不同的键,也会产生不同的结果:

  • 一条消息的 id 见过,但序列号更大:重复,直接丢弃。
  • 一条消息的 id 没见过,但序列号小于等于已应用的最大值:迟到,不能插回历史位置。
  • 一条消息的 id 没见过,序列号比期望值大:既不是重复,也不能立刻应用,应该缓冲。
  • 一条消息的序列号与缓冲区里的另一条相同、id 不同:冲突,说明服务端编号或端上状态出了问题。

把「迟到」当成「重复」会丢失可观测性:你不知道服务端重发得太多,还是网络把旧消息拖了太久。把「重复」当成「迟到」则可能重复应用:同一条消息以更大的序列号重放时,如果只看序列号,它会被当成一条新消息。

端上能保证的是最终有序

「实时有序」要求消息一到达就按全局顺序展示。这个保证在客户端做不到,原因不在代码,而在路径:

  1. 实时推送与补拉响应是两条独立路径,到达顺序不受端上控制。
  2. 服务端可能为了低延迟先推小消息、后推大消息,或者按分片并行发送。
  3. 客户端可能同时有多个连接、多个标签页、多个设备,各自看到不同的到达顺序。

端上能保证的是一个更弱、但可定义的性质:最终有序。 给定一组序列号连续的消息,无论它们以什么顺序到达,只要缺口最终被填上,应用顺序一定等于序列号顺序。中间状态允许乱:消息 3 可以先到并停在缓冲区,消息 2 到达后再一起应用。

这个性质对产品的影响必须提前说清楚。聊天里,用户可能先看到「对方正在输入」的提示,隔几百毫秒才看到上一条文字;信息流里,新内容可能晚一个刷新周期出现。这些不是 bug,而是「最终有序」的代价。真正不能接受的是:消息 3 已经显示,消息 2 到达后又把 3 挤到下面,用户正在读的内容跳走。

因此端上的展示策略通常是:已渲染的消息不再移动。 新消息按序列号插入到它该在的位置;如果它应该插在用户正在看的内容上方,用「有新消息」提示,而不是直接推挤视口。数据结构保证顺序,渲染层决定何时把顺序呈现给用户。

乱序缓冲窗口

缓冲区不能无限大。一个迟到的消息可能带来任意大的序列号跳跃,如果端上把所有「未来消息」都缓存下来,内存会被一条异常消息拖垮。

窗口定义的是「允许多大的提前量」:

窗口行为风险
太小(如 8)稍微乱序就判空洞,频繁触发补拉补拉请求多,延迟高
适中(如 64)吸收常见网络乱序需要与补拉逻辑配合
太大(如 4096)几乎不判空洞内存占用高,异常序列号更难暴露

窗口的边界要写死并测试:序列号等于「期望值 + 窗口 - 1」时缓冲,再大一条就判空洞。边界模糊的实现会在压测时表现正常,在真实乱序时把本可缓冲的消息推给补拉,放大服务端压力。

超过窗口的消息不应该被静默丢弃。它应该返回一个明确的 gap 状态,让上层知道「这里需要补拉」,而不是让调用方从「没有 applied」去猜。静默丢弃是最坏的选择:消息既没进业务状态,也没进补拉队列,用户永远看不到它。

五种判定

把到达消息的处置收敛成五种状态,调用方就不需要理解内部集合:

状态条件端上动作
applied序列号正好是期望值,或补齐后连续按序写入业务状态
buffered序列号在窗口内但大于期望值暂存,等缺口补齐
duplicate消息 id 已见过,或同序列号同 id丢弃,计数
late序列号已越过,但 id 没见过丢弃,计数,不插回历史
conflict同序列号被不同 id 占用丢弃后到者,上报异常
gap序列号超出窗口上界不缓存,交给补拉

applied 返回的是一个数组,而不是单条消息。因为一条迟到的缺口消息到达时,可能同时把缓冲区里连续的多条消息一起放行。调用方拿到数组后按顺序写入,不需要再排序。

一个可运行的顺序门

下面的 OrderingGate 属于 L1:纯内存、无定时器、无网络。它把「去重」「缓冲」「应用」收在一个方法里,调用方只需要把每条到达的消息交给它。

// file: src/reliability/ordering.ts
// tier: L1
// verify: node --test test/reliability/ordering.test.ts

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

export type IngestStatus = "applied" | "buffered" | "duplicate" | "late" | "conflict" | "gap";

export interface IngestResult {
  status: IngestStatus;
  applied: SequencedMessage[];
  buffered: number;
  expected: number;
}

export interface OrderingOptions {
  initialSeq?: number;
  window?: number;
  seenLimit?: number;
}

export class OrderingGate {
  #lastSeq: number;
  #window: number;
  #seenLimit: number;
  #seen = new Set<string>();
  #seenOrder: string[] = [];
  #pending = new Map<number, SequencedMessage>();
  #appliedOrder: number[] = [];

  constructor(options: OrderingOptions = {}) {
    const initialSeq = options.initialSeq ?? 0;
    const window = options.window ?? 64;
    const seenLimit = options.seenLimit ?? 4096;
    if (!Number.isInteger(initialSeq) || initialSeq < 0) throw new Error("initialSeq 必须是非负整数");
    if (!Number.isInteger(window) || window < 1) throw new Error("window 必须是正整数");
    if (!Number.isInteger(seenLimit) || seenLimit < 1) throw new Error("seenLimit 必须是正整数");
    this.#lastSeq = initialSeq;
    this.#window = window;
    this.#seenLimit = seenLimit;
  }

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

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

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

  ingest(message: SequencedMessage): IngestResult {
    assertMessage(message);
    const expected = this.#lastSeq + 1;
    if (this.#seen.has(message.id)) {
      return this.#result("duplicate", [], expected);
    }
    if (message.seq <= this.#lastSeq) {
      // 序列号已经越过它:即使 id 没见过,也不能插回已应用的历史位置。
      this.#remember(message.id);
      return this.#result("late", [], expected);
    }
    const maxAhead = this.#lastSeq + this.#window;
    if (message.seq > maxAhead) {
      // 超出窗口说明中间缺得太多,交给补拉逻辑,不在本地无限缓存。
      return this.#result("gap", [], expected);
    }
    if (message.seq > expected) {
      const existing = this.#pending.get(message.seq);
      if (existing) {
        return this.#result(existing.id === message.id ? "duplicate" : "conflict", [], expected);
      }
      this.#pending.set(message.seq, message);
      this.#remember(message.id);
      return this.#result("buffered", [], expected);
    }
    this.#remember(message.id);
    const applied: SequencedMessage[] = [message];
    this.#lastSeq = message.seq;
    this.#appliedOrder.push(message.seq);
    this.#trimHistory();
    while (this.#pending.has(this.#lastSeq + 1)) {
      const next = this.#pending.get(this.#lastSeq + 1) as SequencedMessage;
      this.#pending.delete(next.seq);
      applied.push(next);
      this.#lastSeq = next.seq;
      this.#appliedOrder.push(next.seq);
      this.#trimHistory();
    }
    return this.#result("applied", applied, expected);
  }

  #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 > 1024) this.#appliedOrder.shift();
  }

  #result(status: IngestStatus, applied: SequencedMessage[], expected: number): IngestResult {
    return { status, applied, buffered: this.#pending.size, expected };
  }
}

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 必须是正整数");
}

实现里的三个决定

去重用 id,顺序用 seq

#seen 只存消息 id,#pending 只按序列号索引。这样一条消息的 id 重复但序列号不同,仍然会被去重;一条消息的序列号迟到但 id 陌生,会被标记为 late 而不是 duplicate。两种失败的原因不同,日志与告警也应该不同。

缓冲区按序列号,不按到达顺序

#pending 是 Map<number, SequencedMessage>,键是序列号。补齐 expected 后,用一个 while 循环连续放行 lastSeq + 1,直到缓冲区里没有下一个连续项。这个循环最多执行窗口大小次,不会因为一次补齐而扫描整个历史。

冲突不能静默覆盖

同一个序列号被两条不同 id 占用,说明编号出了问题。OrderingGate 保留先到者,返回 conflict,让上层上报。如果不做这个判断,直接以序列号为键覆盖,后到的消息会替换先到的,用户可能看到内容突然变化,而日志里没有任何异常。

测试与真实输出

本机运行结果如下:

$ node --test test/reliability/ordering.test.ts
✔ 顺序到达的消息立即应用 (0.943167ms)
✔ 重复 id 不推进序列号 (0.081792ms)
✔ 窗口内乱序先缓冲,缺口补齐后按序应用 (0.107167ms)
✔ 序列号已越过的陌生消息标记为迟到 (0.071791ms)
✔ 超出缓冲窗口的消息标记为空洞 (0.552375ms)
✔ 窗口边界内缓冲,超出一条即判空洞 (0.12575ms)
✔ 同一序列号被不同 id 占用时判定冲突 (0.095084ms)
✔ 去重集合有上限,淘汰后的旧消息仍被判迟到 (0.072417ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 157.427125

八条用例覆盖了顺序、重复、乱序补齐、迟到、窗口边界、冲突与去重集合淘汰。它们证明的是「给定一组序列号,应用顺序最终等于序列号顺序」,不证明「消息不会丢」。丢失是 7.3 的问题,顺序门只能把「缺失」暴露成一个 gap 或 buffered 状态。

失败复盘:把连接当成去重边界

「回声」的第一版实时消息层把已见集合放在连接对象里。连接断开时对象销毁,重连时新建一个空集合。这个设计在正常网络下完全看不出问题,直到一次地铁场景的灰度测试:

  1. 用户进隧道,连接断开。
  2. 出隧道后重连,服务端从断点重发最近 20 条消息。
  3. 端上已见集合是空的,20 条全部被当成新消息。
  4. 聊天窗口里每句话出现两次,用户以为对方重复发送。

根因不是服务端重发,而是去重集合的生命周期被绑在了连接上。修复方式是把 OrderingGate 提升到会话层:连接可以重建,门不重建;消息 id 在服务端是稳定的,因此跨连接去重成立。重连时只重置退避计数与连接状态,不重置已见集合与 lastSeq。

第二个事故更隐蔽。服务端一次发布后,某个频道的序列号从 1 重新开始,端上看到「seq 1 的消息 id 与历史不同」。旧实现只按序列号去重,直接把新消息当成重复丢弃,用户发现频道停更。新实现同时检查 id 与序列号,返回 conflict 并上报;运维据此发现服务端把序列号作用域配错了。静默丢弃会把服务端 bug 伪装成「没有新消息」。

端上保证不了什么

顺序门能保证最终有序,但不能保证不丢。它看到序列号 5 时,只知道 1 到 4 里缺了什么,不知道服务端是否还有 6 以后的消息。补拉是另一个动作,需要服务端高水位与区间请求。

它也不能保证实时有序。网络路径、服务端推送策略、多端并发都会让到达顺序与序列号顺序不同。产品如果要求「一到达就按顺序显示」,只能接受更大的延迟,或者接受消息在显示后移动。

它更不能修复服务端的编号错误。序列号回绕、作用域混用、同一序列号复用,端上只能检测并上报,不能猜出正确顺序。端上的职责是「在给定编号下保持一致性」,不是「替服务端发明编号」。

小结

去重与有序使用两把不同的尺子:消息 id 判断「见过没有」,序列号判断「现在能不能应用」。端上能保证的是最终有序——缺口填上后,应用顺序等于序列号顺序;不能保证实时有序,也不能保证消息不丢。窗口要有明确边界,冲突要显式上报,去重集合要跨连接存活但有上限。

顺序门把「重复」和「乱序」收敛成了可判定的状态,但缺口本身还没有被补上。下一节处理断线期间的消息:怎么从已收序列号算出补拉区间,怎么与实时推送合并,以及为什么「重连后只拉最新一条」会造成永久丢消息。

阅读导航:上一节:7.1 重连与退避:把断线当成常态 · 下一节:7.3 断线期间的消息补拉与空洞检测 。

常见错误与边界

  • 用连接 id 作为去重集合的生命周期。每次重连清空集合,补发消息全部重复。
  • 只按序列号去重。服务端序列号回绕或作用域错误时,新消息被静默丢弃。
  • 只按消息 id 排序。id 不保证单调,顺序会随生成策略漂移。
  • 把迟到的陌生消息当成重复。丢失了「服务端重发过多」与「网络延迟过大」的区分。
  • 缓冲区无上限。一条异常大的序列号就能让端上缓存大量永远不会应用的消息。
  • 窗口边界写成 > 或 >= 但没测试。压测正常,真实乱序时频繁误判空洞。
  • 补齐缺口后对整个列表重新排序。已经渲染的消息会跳动,用户正在读的内容被推走。
  • 认为顺序门能保证不丢消息。它只能把缺失暴露成状态,补拉需要服务端配合。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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