44.23 未读数系统

一句话

未读数看着像计数,本质是**「一份共享数据上的偏移量」**:不要给每个用户记一个数,而是记「他读到哪儿了」,读的时候再算差值——把 O(用户数) 的写换成 O(1) 的写,这是它和 44.22 计数系统 最根本的区别。

三类未读数,三种解法

先把 “未读数” 这个词拆开。它至少是三个不同的需求,混着做必然有一个撑不住:

场景 数据关系 解法 写成本
@ / 评论 / 赞(一对一) 每人一份、互不共享 通用计数,见 44.22 计数系统,进页面置 0 O(1)/事件
系统通知、全量红点 所有人共享一份列表 记 “看过最后一条的 ID / 时间戳” O(1)/事件(不是 O(用户数))
信息流未读(关注关系) 每人的列表都不同 记 “关注对象的发布数快照” O(1)/事件(读时求和)

🔑 共同点只有一句:把 “给 N 个人各加 1” 换成 “记一个全局进度 + 一次比较”。谁在写入路径上遍历用户,谁就会死在写路径上。

反面教材:为什么不能遍历全量用户

假设一亿用户,给一个人加未读数 1ms(一次远程写):

100,000,000 × 1ms = 100,000 s ≈ 27.8 小时

即使起 100 个线程并发,也要十几分钟——用户发一条系统通知,半小时后才收到提醒,不可接受。

还有第二层浪费:活跃用户只是极少数。为从不打开通知页的用户维护一个数,既费内存又费写。设计上要能 “不记账也能算出 0”。

解法一:系统通知 / 红点 —— 记住 “读到哪儿”

系统通知是所有人共享的同一个列表,差别只在 “每个人看到第几条”。所以只需存一个偏移量:

规则 做法
进页面 把 last_seen_id 设为当前最新通知 ID,未读数自然变 0
从未看过 last_seen_id 为空 → 新用户,未读数 = 0
长期不活跃 30 天未登录,清空 last_seen_id,省内存
计算 未读数 = 最新 ID 之后有多少条

红点(全量打点)是同一个结构:给每个用户存 “上次点击红点的时间戳”,全局存 “最近一次打点的时间戳”,比较两者大小即可决定是否展示。全量打点从 “写一亿次” 变成 “写一次全局 + 每人一次比较”。

解法二:信息流未读 —— 记住 “关注对象的发布数快照”

信息流每个人的列表不同,没法共享偏移量。用快照差:

未读数 = Σ(关注对象当前发布数 − 我上次查看时的快照值)

算例:关注了 B、C、D,当前发布数 10 / 8 / 14,上次查看时快照 6 / 7 / 12:

(10−6) + (8−7) + (14−12) = 4 + 1 + 2 = 7

写入路径只要给发布者的发布数 +1(一次操作,与粉丝数无关),读的时候按关注列表求和。这就是它能扛住大 V 的原因:谢娜发一条微博,写侧只 +1,而不是给 1.2 亿粉丝各写一次。

实测:求和这一步到底多贵

单次请求要遍历 “关注列表”,关注上限决定了单请求的成本。本机实测(Python 3.14 / macOS arm64):

import time

follows, rounds = 2000, 50000
cur  = [i * 3 for i in range(follows)]     # 各关注对象当前发布数
snap = [i * 2 for i in range(follows)]     # 上次查看时的快照

t0 = time.perf_counter()
sum_ = 0
for _ in range(rounds):
    s = 0
    for i in range(follows):
        s += cur[i] - snap[i]
    sum_ += s
el = time.perf_counter() - t0
ops = rounds * follows
print(f"总读次数 {ops/1e6:.0f} M, 耗时 {el:.2f}s")
print(f"单核吞吐 {ops/el/1e6:.2f} M 次读/秒, 单请求 CPU {el/rounds*1e6:.0f} µs")
print(f"50 万 QPS 需要 {500000*follows/(ops/el):.0f} 个核(纯 CPU)")
总读次数 100 M, 耗时 9.62s
单核吞吐 10.40 M 次读/秒, 单请求 CPU 192 µs
50 万 QPS 需要 96 个核(纯 CPU)

解读要小心:Python 解释器比编译型语言慢约一个数量级,同样的求和用 Go/Rust 大约 10–20 个核就能扛 50 万 QPS。而公开案例里这类系统用了 16 台机器扛近 50 万 QPS——说明真实瓶颈不在 CPU 求和,而在内存带宽、缓存 RTT 与跨机房流量。

🔑 所以 “关注上限 2000” 不只是产品规则,也是容量规则:没有上限,单请求成本随关注数线性上涨,再多的机器也追不上头部用户。

缺陷:它为什么允许不准

缺陷 现象 为什么能接受
关注/取关不及时 刚关注的人的消息没算进未读 用户看不出 9 还是 10
快照被剔除 缓存满、key 被 LRU 清掉 → 未读变 0 只在极端内存压力下发生
删博再发 总数不变 → 未读不变 与 “未读 10 还是 11” 同一量级的误差
冷用户不记账 长期未登录用户返回 0 本来就不看通知

这些 “不准” 是设计的一部分,不是 bug。但前提是:业务明确接受 “未读数可近似”。如果产品要求 “一条都不能少”,就得换成事件级去重(25.4 幂等设计与去重),成本高一个量级。

生产落地:指名 → 可抄 → 可验 → 可退

指名:偏移量类用 Redis Hash(一个用户一个 Hash,多个未读场景做 field);快照类用 Redis 或 Memcached(纯缓存,可丢);事件级严格未读用 MySQL 去重表 + 唯一键。大厂同类实现:微博未读服务、Twitter 的 timeline 服务。

可抄(Redis 侧,偏移量方案):

# 一个用户一个 Hash:field 是场景,value 是 "已读到的位置"
redis-cli HSET unread:u1001 notice 1024 at 88 sysmsg 7
redis-cli HGET unread:u1001 notice          # 读:查看读到哪儿了
redis-cli HSET unread:u1001 notice 1030     # 进页面:置为当前最新 ID

⚠️ 以上命令未在本机验证(本机没有 Redis)。注意 “未读数” 本身不要在每次事件里 HINCRBY——那又退回成 “给每个人加 1”;存的是位置,未读数是算出来的。

可验:

检查 命令 / 指标 期望
单次请求成本 服务端 P99 + 关注数分布 P99 与关注数强相关 = 没做缓存前置
缓存是否够用 INFO stats 的 keyspace_hits/misses 命中率 < 90% 说明快照频繁被剔除
不准的来源 抽样比对 “算出的未读” 与 “事件回放” 误差应集中在低频用户

可退:未读数是纯展示数据,退路天然存在——读失败返回 0 或上一个快照值,不要为了一个角标把接口拖垮。前端也要能处理 “有角标但点进去没有新内容”。

反模式

反模式 后果
发通知时遍历全量用户 +1 1 亿用户最坏要 27 小时,且大部分写是浪费
未读数用 INCR 逐事件累加 又退回 O(用户数) 写,且删帖后无法回退
不设关注上限 头部用户单请求成本爆炸,尾延迟无法收敛
快照存内存却不设淘汰策略 内存打满被 OOM 杀掉,未读全变 0
用未读数做业务判断(如 “有未读则必推”) 计数可近似,用它驱动强逻辑会出错
前端轮询不降频 未读接口变成全站最大 QPS 来源,挤占核心链路
冷用户也全量记账 内存随注册用户数线性增长,与实际活跃度无关

三条铁律

🔑 写路径上不许遍历用户:要么记全局偏移量,要么记 “发布者计数”,让写成本与粉丝数无关。

🔑 未读数是派生数据:允许近似、允许丢、必须能重建;它不该出现在任何强一致判断里。

🔑 关注数就是容量:单请求成本随关注数线性上涨,产品规则和容量规划必须一起定。

自检

Q1 系统通知的未读数,为什么不能用 “给每个用户 INCR” 实现?

▸ 答案:因为它是 O(用户数) 的写。一亿用户 × 1ms = 10 万秒 ≈ 27 小时,即使 100 线程也要十几分钟;而且绝大多数用户不看通知,为他们维护计数纯属浪费。正解是存 “每人读到的最新通知 ID” 或 “上次点击时间戳”,未读数在读取时算。

Q2 信息流未读为什么能扛住 “1.2 亿粉丝的大 V”?

▸ 答案:因为写路径只对发布者的发布数 +1,与粉丝数无关;读的时候才按 “我关注的人” 求和。对比推模式(给每个粉丝的收件箱插一条),写放大了 1.2 亿倍。代价是读要遍历关注列表,所以必须有关注上限与缓存。

Q3 “未读数变 0” 通常是什么原因?

▸ 答案:快照类方案里,快照是纯缓存,key 被 LRU 淘汰或实例重启后快照丢失,按公式算出来就是 0。这是设计上接受的降级(用户看不出 9 还是 10),但如果频繁发生,说明缓存容量不足或没做持久化兜底。

Q4 为什么 “未读数可近似” 是设计前提,而不是实现缺陷?

▸ 答案:因为强一致的未读需要事件级去重:每条消息一个 ID、每个用户一份已读集合、每次读都要去重比对,存储与写放大高一个数量级。产品上 “未读 10 还是 9” 无感,所以业界普遍选择近似方案,把成本花在更值钱的地方。

Q5 单请求 CPU 192µs(本机 Python),为什么线上还能扛 50 万 QPS?

▸ 答案:三个原因叠加:① 编译型语言比 Python 快约一个数量级;② 实际实现是 “一次批量取回关注对象的计数 + 内存求和”,把 2000 次远程读压成 1 次批量读;③ 前面有缓存挡着,绝大多数请求不落到存储。所以瓶颈从 CPU 转移到网络与内存带宽。

Q6 红点(全量打点)为什么可以只用两个时间戳实现?

▸ 答案:因为红点是 “是否有新东西” 的布尔判断,不是精确计数。全局存 “最近一次打点时间”,用户存 “上次点击时间”,比较大小即可。全量打点从 “写一亿次” 变成 “写一次 + 每人一次比较”,这是典型的 “把写扩散改成读时判断”。

Q7 关注上限为什么既是产品规则又是容量规则?

▸ 答案:单次未读计算的成本 ≈ 关注数 × 单次读取成本,关注数无上限时,头部用户的单请求成本可以比普通用户高几个数量级,尾延迟无法收敛。设上限(如 2000)把单请求成本钉死在一个可预算的范围内,容量规划才有意义。

关联