给一个 1 亿 DAU 的关注式 timeline(Twitter/X 的 Home、微博关注流、Instagram Feed)设计后端:你关注了几百个账号,下拉刷新要在 p99 < 200ms 内拿到这些账号最新的、排好序的帖子。难点不是「存帖子」,而是读写放大的不对称——一次发帖要让几百万关注者「看到」,一次刷新要从几百个关注对象里聚合排序。先想清楚:这份合并工作放在写时做,还是读时做?
graph TD
POST["发帖 Write API"] --> TW[("Tweet Store
权威帖子库")]
POST --> FO["Fanout 服务
查社交图"]
FO -->|普通用户:推| TLC[("Home Timeline Cache
Redis · 每人一条有界 list")]
FO -.名人:跳过 fanout.-> CEL[("名人帖子
读时拉取")]
READ["刷新 Read API"] --> MIX["Timeline 混合服务"]
MIX -->|① 读预物化| TLC
MIX -->|② 拉名人最新| CEL
MIX --> RANK["排序 + 多样性"]
RANK --> HYDRATE["Hydrate
用 id 取帖子正文"]
HYDRATE --> TW
HYDRATE --> OUT["返回 20 条 feed"]
classDef w fill:#1a2530,stroke:#64c8ff,color:#e8eef5
classDef cache fill:#1a1a30,stroke:#ffb450,color:#e8eef5
classDef store fill:#2a1530,stroke:#ff7ab6,color:#e8eef5
class POST,READ,FO,MIX,RANK,HYDRATE,OUT w
class TLC,CEL cache
class TW store
写路径预物化普通用户 timeline;读路径合并「预物化」+「名人实时拉取」再排序
组件职责:Tweet Store 是帖子的 source of truth(按 tweet_id 分片的 KV)。Fanout 服务在发帖时查社交图,把 tweet_id 推进每个关注者的 Home Timeline Cache——这是「写时多干活」。名人例外:扇出代价太大,其帖子不预物化,留待读时拉。混合服务是关键:读时把预物化的 timeline 与名人最新帖 merge,排序后只对 top-N hydrate(timeline 里只存 id,正文回 Tweet Store 取,省内存)。
一句话 trade-off:用「发帖时的写放大」换「刷新时的读放大」——两者只能选一头省,名人逼你两头都得管。
原理:Push(fanout-on-write)发帖时把帖子 id 写进每个关注者的 timeline,读时一次性取出——读 O(1)、写 O(关注者数)。Pull(fanout-on-read)什么都不预算,刷新时现查你关注的所有人最近帖子再归并排序——写 O(1)、读 O(关注数×每人查询)。选择取决于读写比和扇出分布:读多写少(100:1)且大多数人关注者不多 → push 把成本摊到稀疏的写上,划算;但遇到千万粉丝的名人,一条帖子触发千万次写,push 直接崩。所以工业界几乎全是 Hybrid:普通用户 push、名人 pull,读时合并。
| Push(写扇出) | Pull(读扇出) | Hybrid | |
|---|---|---|---|
| 发帖成本 | 高(O 关注者) | 低(一次写) | 普通高 / 名人低 |
| 刷新成本 | 低(读一条 list) | 高(归并 N 路) | 读 1 条 + 拉几个名人 |
| 新帖延迟 | 扇出完才可见 | 实时 | 普通有延迟 / 名人实时 |
| 死穴 | 名人写爆 | 活跃用户读爆 | 合并逻辑复杂 |
一句话 trade-off:push 把读成本转嫁成写成本,而写成本随关注者数线性爆炸,到名人量级就不可持续。
原理:一条帖子的 fanout 写次数 = 作者的关注者数。普通人几百次缓存写,缓存层轻松吸收;但 5000 万粉丝的账号发一条,就是 5000 万次 Redis 写——即便每次 100µs,串行也要小时级,还会瞬间打满缓存集群带宽。更糟的是惊群叠加:名人同时在线粉丝多,扇出风暴和读风暴撞一起。解法是给账号设扇出阈值:关注者超过阈值的进「名人名单」,其帖子跳过 fanout,改在读时拉取并入每个读者的 timeline。代价是读路径多一次「拉名人最新帖 + 合并」,但避免了写侧的雪崩。
# Hybrid fanout:发帖时按阈值分流(pseudo-code)
FANOUT_THRESHOLD = 100_000 # 关注者超此数 → 走读时 pull
def on_post(author_id, tweet_id, ts):
tweet_store.put(tweet_id, ...) # 永远先落权威库
n = social_graph.follower_count(author_id)
if n > FANOUT_THRESHOLD:
celeb_recent.zadd(author_id, ts, tweet_id) # 名人最新帖,读时拉
return # ⚠️ 不 fanout
# 普通用户:异步扇出,分批 pipeline,避免阻塞发帖
for batch in chunks(social_graph.followers(author_id), 1000):
pipe = redis.pipeline()
for fid in batch:
pipe.zadd(f"tl:{fid}", ts, tweet_id)
pipe.zremrangebyrank(f"tl:{fid}", 0, -801) # 有界:只留最新 800
pipe.execute()
一句话 trade-off:用「读时多一次 hydrate 取正文」换「timeline 缓存内存缩小一两个数量级」。
原理:Home timeline 用 Redis 的有序结构(按时间打分的 sorted set / list)实现,每人一条,只存 tweet_id 不存正文。理由:① 正文几 KB,1 亿用户 × 800 条全存正文是 TB 级内存灾难;存 8 字节 id 则缩小百倍;② 帖子可能被编辑/删除,正文若冗余进每个 timeline,更新要再扇出一遍。读时拿到 top-N 个 id,再批量回 Tweet Store hydrate 正文(一次 MGET),并过滤掉已删除/已拉黑的。timeline 还必须有界——只保留最新几百条,老的截断,否则活跃大账号的关注者 timeline 无限增长。冷数据(翻很旧的帖)回落到 pull。
# 读路径:合并 + hydrate(pseudo-code)
def get_home_timeline(uid, limit=20):
ids = redis.zrevrange(f"tl:{uid}", 0, 400) # ① 预物化部分
for cid in following_celebs(uid): # ② 名人实时拉
ids += celeb_recent.zrevrange(cid, 0, 50)
ids = dedup(ids)
ranked = rank(uid, ids)[:limit] # ③ 排序后只取 top-N
tweets = tweet_store.mget(ranked) # ④ 仅对 top-N hydrate
return [t for t in tweets if visible(uid, t)] # ⑤ 过滤删除/拉黑
一句话 trade-off:用「用户参与度提升」换「实时性、可解释性与算力预算」——排序模型只能跑在聚合后的小候选集上。
原理:早期 feed 是纯时间倒序(reverse-chron),简单、实时、可预测。但信息过载后转向 ML 排序:聚合出候选(push 物化 + pull 名人,几百条)后,对每条用模型预测多个参与信号(点赞/评论/转发/停留概率),加权成分排序。这呼应 Day 13 的多阶段漏斗——区别是 feed 候选来自关注图而非全库召回,候选量小得多(几百 vs 十亿),所以可以直接上较重的排序模型。排序后还要重排注入多样性(同作者打散、避免连刷一个话题)和业务规则(广告插入、时效性提权)。
高频追问:① 千万粉名人发帖,怎么不打爆系统?② push/pull 阈值怎么定,依据什么指标?③ 新关注一个人,ta 的历史帖怎么进我的 timeline(backfill)?④ 发帖到关注者可见的端到端延迟预算怎么拆?⑤ 时间序 vs 排序流,各自牺牲什么、什么产品该选哪个?
建模:对单个账号,push 划算的条件大致是「该账号被读的次数 ≫ 发帖触发的扇出次数」——临界点本质是扇出写的边际收益:当一次 fanout 写在读侧省下的期望读成本 < 这次写本身的成本时,就该 pull。
直觉化:普通人发帖少、被反复刷 → 读远多于写 → push(写一次省无数次读)。名人粉丝数既抬高写成本,其帖又因被排序埋掉而人均曝光低、降低写收益——两头夹击使其落到 pull 区。这正是 hybrid 阈值的理论依据。
问题本质:B 走 pull 本就不预物化——「关注瞬间」其实不需要往 A 的 timeline 写历史帖,A 下次刷新时读路径会自动 merge B 的最新帖。这是 hybrid 的隐性好处:关注名人无需 backfill。
若 B 是普通账号(走 push):B 历史帖不在 A 的预物化 timeline 里。做法:读时按需拉 B 近期帖合并(简单常用),或后台异步 zadd 进 A 的 timeline。坑:别在「关注」这个同步请求里回填——若 A 一次性关注一批人(导入通讯录),同步回填会放大成一场小型 fanout 风暴,必须异步且限速,并尊重有界截断。
关键认知:home timeline 是派生数据,不是 source of truth——真正的帖子在 Tweet Store。timeline cache 丢了不是「数据丢失」,是「物化视图失效」,可以重建。
恢复策略:① 短期降级到 pull——该分片用户刷新时实时归并关注对象最新帖,慢但能用,给重建争取时间;② 后台重新 fanout 重建,代价是读路径退化 + 一波写风暴(要限速)。这也解释了 timeline 为何要有界——重建只需回填最近几百条而非全历史。反面教训:若把 timeline 当唯一存储(不存 Tweet Store),这就是真·数据丢失——派生层永远不能当权威。
怎么来的:时间序下「关注即必达」,用户有稳定心智。切到排序流后,模型按预测参与度筛选/降权,低互动好友的帖被埋掉;用户不知排序存在,只觉「明明关注了却没看到」。这是排序流的固有代价:用整体参与度的提升,换个体可预测性与信任的下降。
缓解:① 给强关系/明确订阅保底曝光位;② 提供「最新」时间序入口作为可切换视图(Twitter/Instagram 都做了);③ 排序加多样性与覆盖率约束,别让头部账号霸屏;④ 产品上明示「为你推荐 vs 最新」。本质是承认离线参与度指标 ≠ 用户信任,需生态侧指标兜底(呼应 Day 13 的茧房讨论)。
朴素估算:1e8 用户 × 800 条 × 8 字节 ≈ 640 GB 仅 id 裸数据。但 Redis sorted set 每个元素还要存 score(8 字节 double)+ 跳表/字典的指针开销,实际每元素 50~100 字节量级,整体膨胀到 数 TB。
架构含义:① 单机放不下 → timeline cache 必须分片(按 uid 哈希,呼应 Day 4);② 这还只是 id——幸好没存正文,否则 800 × 几 KB × 1e8 = PB 级,这就是「只存 id + hydrate」的硬约束来源;③ 内存太贵 → 活跃度感知和分层(热放内存、冷回落 Cassandra)不是优化是必需;④ 也解释了有界 800 为何重要——它直接决定缓存层总内存预算,翻倍上限就翻倍成本。