Day 14 Hard Feed System Push/Pull/Hybrid Fanout Ranking

Feed 系统 — 1 亿 DAU 关注流的 fanout 艺术Feed System: Push vs Pull vs Hybrid, Fanout-on-write Limits, Timeline Storage, Ranking Pipeline

问题场景 + 需求约束

给一个 1 亿 DAU 的关注式 timeline(Twitter/X 的 Home、微博关注流、Instagram Feed)设计后端:你关注了几百个账号,下拉刷新要在 p99 < 200ms 内拿到这些账号最新的、排好序的帖子。难点不是「存帖子」,而是读写放大的不对称——一次发帖要让几百万关注者「看到」,一次刷新要从几百个关注对象里聚合排序。先想清楚:这份合并工作放在写时做,还是读时做?

高层架构(混合 fanout)

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 取,省内存)。

关键技术点

1. Push vs Pull vs Hybrid:合并工作放写时还是读时

一句话 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:
现实案例:

2. Fanout-on-write 的写放大与名人问题

一句话 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:
现实案例:

3. Timeline 存储:有界 list、存 id 不存正文

一句话 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:
现实案例:

4. Ranking Pipeline:从时间序到 ML 排序

一句话 trade-off:用「用户参与度提升」换「实时性、可解释性与算力预算」——排序模型只能跑在聚合后的小候选集上。

原理:早期 feed 是纯时间倒序(reverse-chron),简单、实时、可预测。但信息过载后转向 ML 排序:聚合出候选(push 物化 + pull 名人,几百条)后,对每条用模型预测多个参与信号(点赞/评论/转发/停留概率),加权成分排序。这呼应 Day 13 的多阶段漏斗——区别是 feed 候选来自关注图而非全库召回,候选量小得多(几百 vs 十亿),所以可以直接上较重的排序模型。排序后还要重排注入多样性(同作者打散、避免连刷一个话题)和业务规则(广告插入、时效性提权)。

Trade-off:
现实案例:

扩展与优化(增长后怎么办)

常见陷阱 + 面试问题

1. 一上来就纠结 push 还是 pull。 正确答案几乎永远是 hybrid。面试官想听的是「按关注者数/活跃度分流」的阈值思维,以及读时如何合并两条来源——而不是二选一。
2. 把正文塞进每个 timeline。 内存爆炸 + 删除/编辑要全量重扇出。timeline 只存 id,读时 hydrate。
3. 忘了 timeline 要有界。 不截断老条目,大账号关注者的单 key 无限膨胀,Redis 大 key 拖垮整个分片。
4. 排序排在写时。 push 时就定死顺序,新帖与模型更新无法实时反映;重排序应放读路径。
5. 忽略可见性过滤。 拉黑/删除/隐私的帖子若不在读时过滤,会把已删内容推给用户——hydrate 后必须 filter。

高频追问:① 千万粉名人发帖,怎么不打爆系统?② push/pull 阈值怎么定,依据什么指标?③ 新关注一个人,ta 的历史帖怎么进我的 timeline(backfill)?④ 发帖到关注者可见的端到端延迟预算怎么拆?⑤ 时间序 vs 排序流,各自牺牲什么、什么产品该选哪个?

深入资源

深入思考(点击展开答案)

1. push 的写放大随关注者数线性增长,pull 的读放大随关注数线性增长。能不能用一个不等式估算「该 push 还是 pull」的临界点?

建模:对单个账号,push 划算的条件大致是「该账号被读的次数发帖触发的扇出次数」——临界点本质是扇出写的边际收益:当一次 fanout 写在读侧省下的期望读成本 < 这次写本身的成本时,就该 pull。

直觉化:普通人发帖少、被反复刷 → 读远多于写 → push(写一次省无数次读)。名人粉丝数既抬高写成本,其帖又因被排序埋掉而人均曝光低、降低写收益——两头夹击使其落到 pull 区。这正是 hybrid 阈值的理论依据。

2. 用户 A 刚关注了大 V B(500 万粉)。A 的 timeline 里应不应该立刻出现 B 的历史帖?backfill 怎么做才不踩坑?

问题本质:B 走 pull 本就不预物化——「关注瞬间」其实不需要往 A 的 timeline 写历史帖,A 下次刷新时读路径会自动 merge B 的最新帖。这是 hybrid 的隐性好处:关注名人无需 backfill

若 B 是普通账号(走 push):B 历史帖不在 A 的预物化 timeline 里。做法:读时按需拉 B 近期帖合并(简单常用),或后台异步 zadd 进 A 的 timeline。:别在「关注」这个同步请求里回填——若 A 一次性关注一批人(导入通讯录),同步回填会放大成一场小型 fanout 风暴,必须异步且限速,并尊重有界截断。

3. timeline 用 Redis sorted set 存 id。如果 Redis 某分片宕机,那批用户的 timeline 全没了,怎么办?这是数据丢失吗?

关键认知:home timeline 是派生数据,不是 source of truth——真正的帖子在 Tweet Store。timeline cache 丢了不是「数据丢失」,是「物化视图失效」,可以重建

恢复策略:① 短期降级到 pull——该分片用户刷新时实时归并关注对象最新帖,慢但能用,给重建争取时间;② 后台重新 fanout 重建,代价是读路径退化 + 一波写风暴(要限速)。这也解释了 timeline 为何要有界——重建只需回填最近几百条而非全历史。反面教训:若把 timeline 当唯一存储(不存 Tweet Store),这就是真·数据丢失——派生层永远不能当权威。

4. 从时间序切到 ML 排序流,DAU 参与度涨了,但客服收到「为什么我没看到好友 X 的帖」的投诉激增。这个二阶效应怎么来的?怎么缓解?

怎么来的:时间序下「关注即必达」,用户有稳定心智。切到排序流后,模型按预测参与度筛选/降权,低互动好友的帖被埋掉;用户不知排序存在,只觉「明明关注了却没看到」。这是排序流的固有代价:用整体参与度的提升,换个体可预测性与信任的下降

缓解:① 给强关系/明确订阅保底曝光位;② 提供「最新」时间序入口作为可切换视图(Twitter/Instagram 都做了);③ 排序加多样性与覆盖率约束,别让头部账号霸屏;④ 产品上明示「为你推荐 vs 最新」。本质是承认离线参与度指标 ≠ 用户信任,需生态侧指标兜底(呼应 Day 13 的茧房讨论)。

5. 估算:1 亿用户、人均 800 条有界 timeline、每条存 8 字节 tweet_id。光 timeline cache 要多少内存?这个数字告诉你什么架构含义?

朴素估算: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 为何重要——它直接决定缓存层总内存预算,翻倍上限就翻倍成本。