《互联网产品的客户端架构·内容、社交与实时互动》10.2 多端已读:一致性从哪里来

同一个「已读」布尔值在两台设备之间一定会打架:甲读完 3 条,乙把整个会话标成已读,新消息到达后两边都显示已读。本节把已读建模为按会话单调前进的位置,用最大位置合并多端状态,处理回执失败重试与服务端总数覆盖,并复盘一次无位置回执把新消息误标已读的事故。

布尔已读在多端一定打架

上一节把通知未读合并成了一个稳定的角标,但角标什么时候减、减到多少,取决于「已读」怎么表达。很多客户端把已读做成一个布尔值:进入会话,发一条 read: true;服务端把整个会话标成已读。单设备、单会话时它工作得很好,一旦用户有两台设备、或者回执会重试,这个模型立刻露出破绽。

这一节只讨论「已读位置」本身:为什么位置比布尔可靠、两端同时已读时以谁为准、回执失败后怎么重试、服务端总数回来之后如何覆盖本地。它不重复通知来源的合并(10.1),也不讨论推送通道不可达时怎么补偿(10.3);我们假定用户已经在某个界面上完成了「读到这里」这个动作。

为什么「已读 = true」是错误模型

设想一个十条消息的会话。用户在手机上点进去,只看了前三条就退出;平板随后收到一条推送摘要,客户端为了「消红点」把会话标成已读。布尔值一旦写入,服务端无法区分「读到第 3 条」和「读完了全部 10 条」,剩下 7 条永远不会再提醒。

布尔值还丢掉了时间维度。用户 10:00 把会话标为已读,10:05 来了新消息;如果服务端存的是 read = true,它必须额外维护「这个 true 是什么时候写的」才能判断新消息算不算未读。而一旦引入时间戳,又要面对客户端时钟不可信的问题。

布尔值是位置的投影,投影之后就回不去了。 正确做法是直接存位置:这个会话我读到了哪一条。未读数由「总消息位置」减去「已读位置」得到,而不是由一个开关决定。

位置应该是什么

位置通常取会话内最后一条已读消息的服务端序列号。它有三个要求:

  1. 单调:同一个会话里,位置只能前进,不能后退。
  2. 服务端分配:seq 由服务端生成,所有设备看到同一个值,不依赖本地时钟。
  3. 与会话绑定:不同会话的位置互不相干,不能用一个全局游标。

不要用数组下标:列表会插入、删除、重新排序,下标漂移后「读到第 5 条」可能指向另一条消息。也不要用客户端时间戳:两台设备的时钟偏差几分钟很常见,同一毫秒内的多条消息也无法区分。

位置模型默认「读到位置 X」意味着 X 之前的消息都算读过。如果产品要求「跳着读」也精确记录,单一位置不够,需要区间集合或逐条已读表;那是另一个复杂度层级,大多数社交产品不需要,也不应该在没有明确需求时引入。

两端同时已读:取最大位置

手机读到 120,平板读到 110,两个回执几乎同时发出。服务端应该存哪个?

  • 后写赢:谁最后到达用谁。平板的重试可能晚到,把 120 覆盖成 110,用户已经读过的 10 条重新变成未读。
  • 取最大:max(120, 110) = 120。无论到达顺序如何,结果一致。

取最大还有一个被低估的好处:它满足幂等、交换、结合。同一条回执重发多少次,结果不变;两个回执以任何顺序到达,结果不变;三台设备合并,可以先两两合并再与第三台合并。后写赢不满足交换律,一旦网络乱序,就必须靠时间戳补救,而时间戳又不可信。

客户端侧的合并同理:mergeCursor 只保留位置较大的那个。它不关心哪个设备、什么时候发的,只关心「读得更远的那次操作不能丢」。

回执只带位置,重试才安全

回执的载荷应该是 { conversationId, position },服务端执行 stored = max(stored, position)。这样重试天然安全:旧的 110 晚到,不会把 120 改小;同一条 120 发三次,结果还是 120。

如果回执带的是 read: true,服务端只能执行「把当前所有消息标为已读」。重试发生在 30 秒后,这 30 秒里新到的消息会被一起吞掉。回执必须表达「读到哪里」,而不是「执行已读这个动作」。

客户端还要处理一个更细的竞态:回执 110 正在发送,用户又读到了 120。110 发送成功后,不能把待发送队列里的 120 一起清掉,否则 120 永远不会到达服务端。正确做法是发送前记下目标位置,成功后只在「当前待发送位置不大于目标位置」时清理;如果用户已经前进,就保留新位置,等下一轮再发。

失败之后:保留最新位置,累计尝试次数

回执失败不是异常,是常态。网络切换、应用退到后台、服务端短暂不可用都会让一次发送失败。端上要做的不是立刻重试到成功,而是:

  • 每个会话只保留一个待发送位置,新的已读覆盖旧的;
  • 失败时累加 attempts,保留位置,交给退避策略决定下一次;
  • 成功时清理已被覆盖的条目,不清理更新过的条目;
  • 应用被杀掉后,待发送队列要能持久化,否则重启后这次已读就丢了。

退避的具体间隔属于重连与请求策略,本节不展开;这里只保证「失败不会丢位置,重试不会写回旧值」。

服务端总数回来之后

已读位置解决「读到哪里」,未读总数仍然由服务端算。原因很实际:客户端只加载了列表的一页,拿不到全量消息,不能用「列表长度减已读位置」自己算总数。

服务端同步接口通常返回三个值:会话 id、未读总数 total、服务端已确认的已读位置 readPosition。端上要处理两种情况:

  • 服务端位置更靠前:说明别的设备已经读得更多,本地位置直接前进到服务端位置。
  • 本地位置更靠前:说明本地刚读了、回执还没到,服务端总数里仍然包含这部分。显示值要减去 localPosition - readPosition,再夹到 0。

公式是 max(0, total - max(0, localPosition - readPosition))。它表达的是:服务端总数是权威的,但本地乐观已读要先扣掉;扣成负数说明服务端总数已经落后于本地读操作,显示 0,等下一次同步修正。

服务端同步回来时,如果 readPosition 已经不小于待发送位置,说明之前的回执其实已经被服务端接受(只是响应丢了),这时要把待发送条目清掉,避免永远重试一条已经生效的回执。

一个可运行的已读状态机

下面的 ReadStateStore 属于 L1:回执发送函数由调用方注入,测试用立即 resolve/reject 的 Promise 模拟成功与失败,不涉及真实网络与定时器。

// file: src/notify/read-state.ts
// tier: L1
// verify: node --test test/notify/read-state.test.ts
export interface ReadCursor {
  conversationId: string;
  position: number;
}

export interface ServerReadState {
  conversationId: string;
  total: number;
  readPosition: number;
}

export interface Receipt {
  conversationId: string;
  position: number;
  attempts: number;
}

export interface FlushReport {
  sent: number;
  failed: number;
  remaining: number;
}

export type ReceiptSender = (cursor: ReadCursor) => Promise<void>;

// 已读的事实是「读到了哪个位置」,不是「是否已读」。
export function mergeCursor(a: ReadCursor, b: ReadCursor): ReadCursor {
  assertCursor(a);
  assertCursor(b);
  if (a.conversationId !== b.conversationId) throw new Error("不能合并不同会话的已读位置");
  return a.position >= b.position ? { ...a } : { ...b };
}

// 服务端总数是权威值,但本地已经乐观读掉的部分要先扣掉,最后夹到 0。
export function effectiveUnread(state: ServerReadState, localPosition: number): number {
  assertServerState(state);
  if (!Number.isInteger(localPosition) || localPosition < 0) {
    throw new Error("localPosition 必须是非负整数");
  }
  const ahead = Math.max(0, localPosition - state.readPosition);
  return Math.max(0, state.total - ahead);
}

export class ReadStateStore {
  #positions = new Map<string, number>();
  #pending = new Map<string, { position: number; attempts: number }>();
  #flushing = false;

  positionOf(conversationId: string): number {
    return this.#positions.get(conversationId) ?? 0;
  }

  // 本地已读只前进不后退;返回 false 表示这次操作没有改变任何东西。
  markRead(cursor: ReadCursor): boolean {
    assertCursor(cursor);
    const current = this.positionOf(cursor.conversationId);
    if (cursor.position <= current) return false;
    this.#positions.set(cursor.conversationId, cursor.position);
    const pending = this.#pending.get(cursor.conversationId);
    if (!pending || cursor.position > pending.position) {
      this.#pending.set(cursor.conversationId, {
        position: cursor.position,
        attempts: pending?.attempts ?? 0,
      });
    }
    return true;
  }

  // 另一台设备的已读位置只用来前进本地位置,不需要再作为回执发回服务端。
  mergeRemote(cursor: ReadCursor): boolean {
    assertCursor(cursor);
    const current = this.positionOf(cursor.conversationId);
    if (cursor.position <= current) return false;
    this.#positions.set(cursor.conversationId, cursor.position);
    return true;
  }

  pendingReceipts(): Receipt[] {
    return [...this.#pending].map(([conversationId, entry]) => ({
      conversationId,
      position: entry.position,
      attempts: entry.attempts,
    }));
  }

  // 每个会话只保留最新位置。发送失败累加 attempts,成功则清掉已被覆盖的条目。
  async flush(send: ReceiptSender): Promise<FlushReport> {
    if (this.#flushing) throw new Error("已有回执发送在进行中");
    this.#flushing = true;
    let sent = 0;
    let failed = 0;
    try {
      for (const [conversationId, entry] of [...this.#pending]) {
        const target = entry.position;
        try {
          await send({ conversationId, position: target });
          sent += 1;
          const current = this.#pending.get(conversationId);
          // 发送期间用户可能又往前读了;只有没被超越时才清掉。
          if (current && current.position <= target) this.#pending.delete(conversationId);
        } catch {
          failed += 1;
          const current = this.#pending.get(conversationId);
          if (current) {
            this.#pending.set(conversationId, {
              position: current.position,
              attempts: current.attempts + 1,
            });
          }
        }
      }
      return { sent, failed, remaining: this.#pending.size };
    } finally {
      this.#flushing = false;
    }
  }

  // 服务端同步回来的已读位置与总数:位置取最大,回执被确认后从队列移除。
  reconcile(state: ServerReadState): { unread: number; readPosition: number } {
    assertServerState(state);
    const current = this.positionOf(state.conversationId);
    const position = Math.max(current, state.readPosition);
    if (position > current) this.#positions.set(state.conversationId, position);
    const pending = this.#pending.get(state.conversationId);
    if (pending && pending.position <= state.readPosition) {
      this.#pending.delete(state.conversationId);
    }
    return { unread: effectiveUnread(state, position), readPosition: position };
  }
}

function assertCursor(cursor: ReadCursor): void {
  if (typeof cursor.conversationId !== "string" || cursor.conversationId.length === 0) {
    throw new Error("conversationId 不能为空");
  }
  if (!Number.isInteger(cursor.position) || cursor.position < 0) {
    throw new Error("position 必须是非负整数");
  }
}

function assertServerState(state: ServerReadState): void {
  if (typeof state.conversationId !== "string" || state.conversationId.length === 0) {
    throw new Error("conversationId 不能为空");
  }
  if (!Number.isInteger(state.total) || state.total < 0) throw new Error("total 必须是非负整数");
  if (!Number.isInteger(state.readPosition) || state.readPosition < 0) {
    throw new Error("readPosition 必须是非负整数");
  }
}

测试与真实输出

本机运行结果如下:

$ node --test test/notify/read-state.test.ts
✔ 本地已读位置只前进,旧位置不产生新回执 (0.933416ms)
✔ 两端同时已读取最大位置 (0.472833ms)
✔ 同一会话只保留最新回执,尝试次数不丢 (0.237083ms)
✔ 回执失败后保留,重试成功后清理 (0.135417ms)
✔ 发送期间前进的位置不会被旧回执清掉 (0.136834ms)
✔ 服务端总数扣掉本地乐观已读并夹到 0 (0.127416ms)
✔ 服务端已确认的回执从队列移除,同步位置不回退 (0.075834ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 118.070458

七条用例覆盖了单调前进、最大位置合并、最新回执、失败重试、发送期间前进、总数夹取和服务端确认。最容易漏掉的是「发送期间前进」:它要求实现不能把「发送成功」简单等同于「队列清空」,而要以发送时的目标位置为准做条件清理。

失败复盘:一条「已读」回执吞掉五条新消息

「回声」最早的已读实现是布尔值。用户打开会话时,客户端发 { conversationId, read: true };发送失败就放进重试队列,30 秒后再试。

事故过程很典型:

  1. 10:00:00,用户打开会话,读到 seq 100,回执进入队列。
  2. 网络恰好切换,回执发送失败。
  3. 10:00:00 到 10:05:00 之间,会话里来了 5 条新消息,seq 101–105。
  4. 10:05:00,重试成功。服务端执行「把会话标为已读」,101–105 一起被标记。
  5. 用户的角标归零,但那 5 条消息从未展示过。

修复方式是把回执载荷改成 { conversationId, position: 100 },服务端执行 max。重试仍然会晚到,但它只声明「读到了 100」,101–105 不在声明范围内,未读保留。回归测试直接复现了这条时间线:先 markRead 到 5,发送失败,再 markRead 到 9,重试成功后断言待发送位置是 9,而不是被 5 覆盖。

第二个事故来自两端。手机读到 120,平板离线时读到 110;平板恢复后发了一条「已读」回执,服务端按后写赢把它存成 110,手机已经读过的 111–120 重新变成未读。用户看到角标从 0 跳回 10。改成最大位置合并后,110 不会覆盖 120,问题消失。这两个事故的共同教训是:已读操作携带的信息越少,重试和乱序造成的破坏越大。

已读位置的边界

位置模型不是万能的。内容被删除或撤回时,位置仍然可以前进,但「读过的消息」列表里不会再有那一条;产品要决定是否显示「消息已撤回」占位。用户从会话顶部直接跳到最新一条时,位置模型默认中间都算已读,如果产品要求「跳读不算读」,单一位置就不够用。

多端场景还要区分「会话已读」与「通知已读」。通知已读针对的是一条通知事件,会话已读针对的是消息位置;两者可以联动,但不能互相替代。通知被读掉不代表会话读到最新,会话读到最新也不一定意味着通知中心的那条摘要被点开过。

小结

已读的事实是位置,不是布尔。位置由服务端分配、按会话单调前进;多端同时已读取最大值,而不是后写赢;回执只带位置,服务端用 max 落库,重试才安全;发送成功要按目标位置条件清理,不能吞掉发送期间前进的已读;服务端总数是权威值,但要扣掉本地尚未确认的乐观已读。

读得准之后,还剩一个更现实的问题:用户根本不开推送,或者权限被系统关掉,端上靠什么知道自己有未读?下一节讨论推送链路的三种失效与打开应用时的补偿同步。

阅读导航:上一节:10.1 未读数的来源与端上合并 · 下一节:10.3 推送降级:没有推送权限时怎么补 。

常见错误与边界

  • 用 read: true 表示已读,重试时把期间到达的新消息一起吞掉。
  • 多端已读按后写赢落库,晚到的旧位置让已读回退。
  • 用客户端时间戳比较哪次已读更新,时钟偏差导致误判。
  • 用数组下标当已读位置,列表插入删除后指向另一条消息。
  • 发送成功后无条件清空待发送队列,吞掉发送期间前进的已读。
  • 回执失败后丢弃位置,重启应用后这次已读永久丢失。
  • 用「列表长度减已读位置」自己算总数,分页只加载一页时数量偏小。
  • 服务端总数直接覆盖本地显示,不扣掉尚未确认的乐观已读。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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