未读数不是「一个数字」
通知中心在需求文档里通常只有一句话:「右上角显示未读红点,超过 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);
}
这段代码在「快照与实时事件不重叠」的假设下是对的。但两条路径天然会重叠:
- 冷启动发出快照请求,服务端在 seq 100 处生成响应,总数 3。
- 实时通道推来 seq 101 的点赞,端上执行
total += 1,角标变成 4。 - 快照响应到达,
total = 3,角标跳回 3。用户点开列表,里面确实有 4 条。
反过来也会错:快照已经包含 seq 101、总数 4,实时通道因为至少一次投递又把 seq 101 送来,total += 1 变成 5。同一份数据被数了两次,或者被覆盖了一次,根因都是端上不知道「服务端的这个总数算到了哪里」。
用 ackSeq 划清「已计数」与「待确认」
解决办法不是给快照加时间戳,而是让服务端在快照里带一个水位:ackSeq。协议约定:
counts包含所有seq <= ackSeq的事件;- 端上收到、但
seq > ackSeq的事件,是「服务端还没算进去的待确认增量」; - 显示值 = 快照基数 + 待确认事件数。
这样快照到达时不需要猜它有没有包含某条实时事件:水位以下的一律以快照为准,水位以上的继续挂在待确认集合里。快照确认后,把 seq <= ackSeq 的待确认事件删掉,数字自然从「基数 + 增量」过渡到「新基数」,中间不会跳。
水位必须由服务端分配,不能用客户端时间戳代替。客户端时钟可能偏快或偏慢,两台设备的时间戳也不可比;而 seq 是服务端单调递增的整数,同一条事件在所有设备上是同一个值。
三道具名门,各挡一种重复
只靠水位还不够,至少一次投递会重发同一条事件。合并器需要三道具名门:
- 事件 id:同一条事件重复到达,第二次直接判
duplicate,不进入待确认集合。 - 水位:事件 id 没见过的旧事件,如果
seq <= ackSeq,说明快照已经包含它,判covered,不再累加。 - 快照水位:
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 条未读。
日志还原了过程:
- 冷启动请求在服务端 seq 100 处生成快照,
total = 3,网络耗时 600ms。 - 实时通道先连上,推来 seq 101 的点赞,端上
total += 1,渲染 4。 - 快照响应到达,端上
total = 3,渲染 3。 - 用户点开列表,端上又按列表长度把角标改成 4,但下一次快照回来再跳一次。
修复分两步。第一步,快照带 ackSeq,端上只把 seq > ackSeq 的事件放进待确认集合;快照到达时先算「新基数 + 剩余待确认」,再渲染,数字不会先降后升。第二步,实时事件带事件 id,重复投递直接丢弃。修复后同一条反馈不再出现,日志里 duplicate 与 stale 也能分开统计:前者说明推送重复,后者说明请求乱序,是两类完全不同的运维问题。
第二个坑出现在上限上。早期版本把显示文本 "99+" 直接存进状态,用户读掉几条后,代码尝试从 "99+" 减一,得到 NaN,角标消失。后来把所有显示逻辑收进 badge(),状态里只保留整数计数,显示文本每次现算,这个问题才彻底消失。
未读数与消息数的边界
通知未读和会话未读是两套数字。一条评论通知可能对应会话里五条新消息;把通知数当成消息数,或者反过来用消息数覆盖通知角标,都会让用户觉得「数字不对」。正确的分工是:通知服务维护通知事件与通知未读,会话服务维护会话未读,端上分别渲染,只有在产品明确要求「合并成一个总红点」时才做加法,并且要说明加法的时间窗口。
服务端还可能在不涉及已读的情况下让数字变小:内容被删除、通知被撤回、风控清理。端上不能假设「未读只会增加」。只要新快照的 ackSeq 不低于当前水位,计数变小是合法的;只有 ackSeq 后退才说明响应过期。把这两件事分开,才能既接受合法回退,又挡住乱序覆盖。
小结
未读数的难点不在加法,而在边界:先定义每种来源的计数单位,再用服务端水位划清「快照已算过」与「端上待确认」,用事件 id 挡住重复投递,用快照水位挡住迟到响应。上限只影响显示,底层计数永远保留完整值。
数字稳定之后,下一个问题是:用户在手机上将一条通知标为已读,平板上的同一个角标凭什么跟着变?下一节把「已读」从一个布尔值改成一个位置。
阅读导航:上一节:9.3 拉黑与屏蔽:端上必须立即生效的部分 · 下一节:10.2 多端已读:一致性从哪里来 。
常见错误与边界
- 用一个
total同时承担快照与实时增量,两条路径互相覆盖。 - 快照不带水位,端上只能靠到达顺序猜它有没有包含某条事件。
- 只按事件 id 去重,不检查水位,快照已包含的事件仍被累加。
- 接受
ackSeq更小的迟到快照,角标被旧响应改小。 - 把
99+存成状态再参与减法,出现NaN或永远回不到 95。 - 按人去重点赞,同一个人多次点赞只显示一次,与列表条数对不上。
- 把通知未读与会话未读混成一个总数,点进去发现数量不一致。
- 认为未读只会增加,服务端撤回或删除内容后拒绝接受合法回退。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。