《互联网产品的客户端架构·内容、社交与实时互动》10.1 未读数的来源与端上合并

点赞、评论、关注与系统通知四种来源的未读数,为什么不能用一个 total += 1 维护。本节用服务端水位 ackSeq 与事件 id 划清「已计数」和「端上待确认」,给出按类型分组、99+ 上限与幂等合并的 TypeScript 实现,并复盘一次旧快照把角标从 4 覆盖回 3 的线上问题。

未读数不是「一个数字」

通知中心在需求文档里通常只有一句话:「右上角显示未读红点,超过 99 显示 99+。」真正做起来,它是客户端最容易反复出错的模块之一:冷启动快照、实时推送、本地已读三条路径同时改同一个数字,任何一条路径的假设不成立,用户看到的就是「明明有 4 条,角标却是 3」。

这一节只讨论「多个来源的未读如何合并成一个角标」:来源的计数单位、端上增量与快照的边界、上限怎么表达。它不讨论推送通道是否可达、权限被拒后怎么补(10.3),也不讨论多端之间已读位置如何取最大值(10.2);我们假定通知已经到达端上,只关心数字怎么落下来。

四个来源,四种计数单位

「未读数」不是天然可加的。点赞、评论、关注、系统通知看起来都是「一条通知」,但用户点开时期待看到的东西不同,计数单位也就不同。

来源计数单位用户点开后的预期常见错误
点赞点赞事件数谁在什么时候赞了哪条内容按人去重,同一个人赞三次只算一次
评论未读评论条数哪条内容下多了几条评论与会话内未读重复计一次总数
关注新粉丝事件数最近哪些人关注了我取关再关注被当成两条
系统公告条数哪几条公告没看过与业务通知混进同一个总数

产品可以先定义「一个未读等于一次需要用户知晓的事件」,再决定列表怎么聚合。端上不应该替产品做语义判断:服务端给什么 kind,端上就按什么 kind 分组;服务端给什么事件 id,端上就用它去重。计数单位是协议的一部分,不是渲染层的临时决定。

端上为什么不能只维护一个总数

最直观的实现是这样:

// 说明性代码:不对应实验文件
let total = 0;

function onRealtimeEvent(): void {
  total += 1;
  renderBadge(total);
}

function onSnapshot(snapshot: { total: number }): void {
  total = snapshot.total;
  renderBadge(total);
}

这段代码在「快照与实时事件不重叠」的假设下是对的。但两条路径天然会重叠:

  1. 冷启动发出快照请求,服务端在 seq 100 处生成响应,总数 3。
  2. 实时通道推来 seq 101 的点赞,端上执行 total += 1,角标变成 4。
  3. 快照响应到达,total = 3,角标跳回 3。用户点开列表,里面确实有 4 条。

反过来也会错:快照已经包含 seq 101、总数 4,实时通道因为至少一次投递又把 seq 101 送来,total += 1 变成 5。同一份数据被数了两次,或者被覆盖了一次,根因都是端上不知道「服务端的这个总数算到了哪里」。

用 ackSeq 划清「已计数」与「待确认」

解决办法不是给快照加时间戳,而是让服务端在快照里带一个水位:ackSeq。协议约定:

  • counts 包含所有 seq <= ackSeq 的事件;
  • 端上收到、但 seq > ackSeq 的事件,是「服务端还没算进去的待确认增量」;
  • 显示值 = 快照基数 + 待确认事件数。

这样快照到达时不需要猜它有没有包含某条实时事件:水位以下的一律以快照为准,水位以上的继续挂在待确认集合里。快照确认后,把 seq <= ackSeq 的待确认事件删掉,数字自然从「基数 + 增量」过渡到「新基数」,中间不会跳。

水位必须由服务端分配,不能用客户端时间戳代替。客户端时钟可能偏快或偏慢,两台设备的时间戳也不可比;而 seq 是服务端单调递增的整数,同一条事件在所有设备上是同一个值。

三道具名门,各挡一种重复

只靠水位还不够,至少一次投递会重发同一条事件。合并器需要三道具名门:

  1. 事件 id:同一条事件重复到达,第二次直接判 duplicate,不进入待确认集合。
  2. 水位:事件 id 没见过的旧事件,如果 seq <= ackSeq,说明快照已经包含它,判 covered,不再累加。
  3. 快照水位:ackSeq 比当前小的快照属于迟到响应,整份丢弃并标记 stale,不能把已经前进的数字改小。

三者的顺序不能交换。先查 id 再查水位,才能保证「重复」和「已覆盖」被区分开:前者是投递问题,后者是快照与增量的边界问题,日志里应该分开统计。若先查水位,一条真正重复的事件会被记成 covered,排查投递质量时就失去了线索。

分组、上限与「99+」

角标通常只显示总数,但通知页要按类型分组,所以合并器内部必须保留 byKind。总数是各类型之和,不是另一个独立维护的变量;只要有一个地方单独维护 total,它迟早会和分组数据对不上。

上限只作用于显示,不作用于存储:

  • total = 125 时显示 99+,但 byKind.like 仍然是 80;
  • 用户读掉 30 条点赞后,总数变成 95,角标要能回到 95,而不是从截断后的 99 继续减;
  • 如果把 99+ 解析回 99 存进状态,用户永远看不到 95。

系统公告有时用「红点」而不是数字,这是产品选择;合并器仍然返回完整计数,由渲染层决定显示数字还是圆点。

一个可运行的合并器

下面的 UnreadMerger 属于 L1:没有网络、没有定时器,所有输入都是普通对象。它把快照基数、水位、待确认事件和去重集合放在同一个类里,保证四者不会各自漂移。

// file: src/notify/unread.ts
// tier: L1
// verify: node --test test/notify/unread.test.ts
export type NotifyKind = "like" | "comment" | "follow" | "system";

export const NOTIFY_KINDS: readonly NotifyKind[] = ["like", "comment", "follow", "system"];

export interface UnreadSnapshot {
  counts: Partial<Record<NotifyKind, number>>;
  ackSeq: number;
}

export interface UnreadEvent {
  id: string;
  kind: NotifyKind;
  seq: number;
}

export interface UnreadBadge {
  total: number;
  byKind: Record<NotifyKind, number>;
  overflow: boolean;
  text: string;
}

export type EventStatus = "applied" | "duplicate" | "covered";

export interface ApplyEventResult {
  status: EventStatus;
}

export interface SnapshotResult {
  applied: boolean;
  stale: boolean;
  dropped: number;
}

// 未读数 = 服务端快照 + 快照水位之后端上收到、尚未被服务端确认的事件。
// ackSeq 是「服务端计数已经包含到这里」的水位,不是最后收到的 seq。
export class UnreadMerger {
  #base: Record<NotifyKind, number>;
  #ackSeq = 0;
  #pending = new Map<string, UnreadEvent>();
  #seen = new Set<string>();
  #seenOrder: string[] = [];
  #seenLimit: number;
  #badgeLimit: number;

  constructor(options: { seenLimit?: number; badgeLimit?: number } = {}) {
    const seenLimit = options.seenLimit ?? 4096;
    const badgeLimit = options.badgeLimit ?? 99;
    if (!Number.isInteger(seenLimit) || seenLimit < 1) throw new Error("seenLimit 必须是正整数");
    if (!Number.isInteger(badgeLimit) || badgeLimit < 1) throw new Error("badgeLimit 必须是正整数");
    this.#seenLimit = seenLimit;
    this.#badgeLimit = badgeLimit;
    this.#base = emptyCounts();
  }

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

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

  // 只接受水位不低于当前快照的响应;迟到的旧快照不能把数字改小。
  applySnapshot(snapshot: UnreadSnapshot): SnapshotResult {
    const counts = normalizeCounts(snapshot.counts);
    if (!Number.isInteger(snapshot.ackSeq) || snapshot.ackSeq < 0) {
      throw new Error("ackSeq 必须是非负整数");
    }
    if (snapshot.ackSeq < this.#ackSeq) return { applied: false, stale: true, dropped: 0 };
    this.#base = counts;
    this.#ackSeq = snapshot.ackSeq;
    let dropped = 0;
    for (const [id, event] of this.#pending) {
      if (event.seq <= snapshot.ackSeq) {
        this.#pending.delete(id);
        dropped += 1;
      }
    }
    return { applied: true, stale: false, dropped };
  }

  applyEvent(event: UnreadEvent): ApplyEventResult {
    assertEvent(event);
    if (this.#seen.has(event.id)) return { status: "duplicate" };
    this.#remember(event.id);
    // 水位之内的事件已经被快照计数包含,再加一次就是双计。
    if (event.seq <= this.#ackSeq) return { status: "covered" };
    this.#pending.set(event.id, { id: event.id, kind: event.kind, seq: event.seq });
    return { status: "applied" };
  }

  applyEvents(events: UnreadEvent[]): { applied: number; duplicate: number; covered: number } {
    const result = { applied: 0, duplicate: 0, covered: 0 };
    for (const event of events) {
      const status = this.applyEvent(event).status;
      result[status] += 1;
    }
    return result;
  }

  badge(): UnreadBadge {
    const byKind = emptyCounts();
    for (const kind of NOTIFY_KINDS) byKind[kind] = this.#base[kind];
    for (const event of this.#pending.values()) byKind[event.kind] += 1;
    const total = NOTIFY_KINDS.reduce((sum, kind) => sum + byKind[kind], 0);
    const overflow = total > this.#badgeLimit;
    return {
      total,
      byKind,
      overflow,
      text: overflow ? `${this.#badgeLimit}+` : String(total),
    };
  }

  #remember(id: string): void {
    this.#seen.add(id);
    this.#seenOrder.push(id);
    // 仍在待确认集合里的事件不能丢掉去重标记,否则重发会被当成新事件。
    const cap = this.#seenOrder.length;
    let scanned = 0;
    while (this.#seenOrder.length > this.#seenLimit && scanned < cap) {
      scanned += 1;
      const oldest = this.#seenOrder.shift() as string;
      if (this.#pending.has(oldest)) {
        this.#seenOrder.push(oldest);
        continue;
      }
      this.#seen.delete(oldest);
    }
  }
}

function emptyCounts(): Record<NotifyKind, number> {
  return { like: 0, comment: 0, follow: 0, system: 0 };
}

function normalizeCounts(counts: Partial<Record<NotifyKind, number>>): Record<NotifyKind, number> {
  const result = emptyCounts();
  for (const kind of NOTIFY_KINDS) {
    const value = counts[kind] ?? 0;
    if (!Number.isInteger(value) || value < 0) throw new Error(`${kind} 计数必须是非负整数`);
    result[kind] = value;
  }
  return result;
}

function assertEvent(event: UnreadEvent): void {
  if (typeof event.id !== "string" || event.id.length === 0) throw new Error("事件 id 不能为空");
  if (!NOTIFY_KINDS.includes(event.kind)) throw new Error(`未知通知类型: ${event.kind}`);
  if (!Number.isInteger(event.seq) || event.seq < 1) throw new Error("seq 必须是正整数");
}

测试与真实输出

本机运行结果如下:

$ node --test test/notify/unread.test.ts
✔ 快照按类型分组,水位之后的事件逐个累加 (0.644125ms)
✔ 同一事件重复到达只计一次 (0.100917ms)
✔ 水位之内的事件已被快照包含,不再累加 (0.096959ms)
✔ 迟到的旧快照被标记 stale,不把数字改小 (0.963584ms)
✔ 快照确认后清理待确认事件,避免回滚时双计 (0.126833ms)
✔ 超过角标上限显示 99+,但底层计数不截断 (0.077542ms)
✔ 批量应用按 applied/duplicate/covered 分类计数 (0.14525ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 115.68625

七条用例分别覆盖:正常合并、id 重复、水位覆盖、旧快照、快照清理、99+ 显示和批量分类。它们都是确定性的纯函数测试,不需要等待真实推送;真正容易出错的竞态,被压缩成了「先调哪个方法」的顺序问题。

失败复盘:角标从 4 跳回 3

「回声」的第一版通知角标用的是 total += 1 加 total = snapshot.total。上线后收到一条很具体的反馈:打开应用时角标先显示 4,半秒后变成 3,点进通知页却能看到 4 条未读。

日志还原了过程:

  1. 冷启动请求在服务端 seq 100 处生成快照,total = 3,网络耗时 600ms。
  2. 实时通道先连上,推来 seq 101 的点赞,端上 total += 1,渲染 4。
  3. 快照响应到达,端上 total = 3,渲染 3。
  4. 用户点开列表,端上又按列表长度把角标改成 4,但下一次快照回来再跳一次。

修复分两步。第一步,快照带 ackSeq,端上只把 seq > ackSeq 的事件放进待确认集合;快照到达时先算「新基数 + 剩余待确认」,再渲染,数字不会先降后升。第二步,实时事件带事件 id,重复投递直接丢弃。修复后同一条反馈不再出现,日志里 duplicate 与 stale 也能分开统计:前者说明推送重复,后者说明请求乱序,是两类完全不同的运维问题。

第二个坑出现在上限上。早期版本把显示文本 "99+" 直接存进状态,用户读掉几条后,代码尝试从 "99+" 减一,得到 NaN,角标消失。后来把所有显示逻辑收进 badge(),状态里只保留整数计数,显示文本每次现算,这个问题才彻底消失。

未读数与消息数的边界

通知未读和会话未读是两套数字。一条评论通知可能对应会话里五条新消息;把通知数当成消息数,或者反过来用消息数覆盖通知角标,都会让用户觉得「数字不对」。正确的分工是:通知服务维护通知事件与通知未读,会话服务维护会话未读,端上分别渲染,只有在产品明确要求「合并成一个总红点」时才做加法,并且要说明加法的时间窗口。

服务端还可能在不涉及已读的情况下让数字变小:内容被删除、通知被撤回、风控清理。端上不能假设「未读只会增加」。只要新快照的 ackSeq 不低于当前水位,计数变小是合法的;只有 ackSeq 后退才说明响应过期。把这两件事分开,才能既接受合法回退,又挡住乱序覆盖。

小结

未读数的难点不在加法,而在边界:先定义每种来源的计数单位,再用服务端水位划清「快照已算过」与「端上待确认」,用事件 id 挡住重复投递,用快照水位挡住迟到响应。上限只影响显示,底层计数永远保留完整值。

数字稳定之后,下一个问题是:用户在手机上将一条通知标为已读,平板上的同一个角标凭什么跟着变?下一节把「已读」从一个布尔值改成一个位置。

阅读导航:上一节:9.3 拉黑与屏蔽:端上必须立即生效的部分 · 下一节:10.2 多端已读:一致性从哪里来 。

常见错误与边界

  • 用一个 total 同时承担快照与实时增量,两条路径互相覆盖。
  • 快照不带水位,端上只能靠到达顺序猜它有没有包含某条事件。
  • 只按事件 id 去重,不检查水位,快照已包含的事件仍被累加。
  • 接受 ackSeq 更小的迟到快照,角标被旧响应改小。
  • 把 99+ 存成状态再参与减法,出现 NaN 或永远回不到 95。
  • 按人去重点赞,同一个人多次点赞只显示一次,与列表条数对不上。
  • 把通知未读与会话未读混成一个总数,点进去发现数量不一致。
  • 认为未读只会增加,服务端撤回或删除内容后拒绝接受合法回退。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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