Skip to content

Feed流系统设计 ​

定位:写扩散 vs 读扩散的经典战场,分布式理论里"延迟、存储、一致性怎么交换"的最具象考题。面试官想看的不是背"推拉结合"四个字,而是大 V 场景下每一分成本落在哪、inactive 用户的浪费怎么省。

一、需求与规模 ​

日活 5000 万,普通人粉丝 100~500,腰部 1 万~10 万,大 V 粉丝 100 万~1 亿
动作:刷新首页 = 聚合"我关注的人"的最新内容,按时间倒序,P99 < 500ms
写入:全站日发 2000 万条,峰值 5 万条/秒(热搜爆发时 10 倍)

核心矛盾:一次发布的成本随粉丝数线性放大(写扩散),一次刷新的成本随关注数线性放大(读扩散)。两种扩散必然要选一个扛,或者各扛一半。

二、推模式 / 拉模式 / 推拉结合 ​

1)推模式(push,写扩散,fan-out on write) ​

发布微博 ──→ 写自己 outbox
        └─→ fan-out 异步任务:往每个粉丝的收件箱 inbox 各写一份
            inbox:1 ─┐
            inbox:2 ─┼─ 每个粉丝的 timeline 里都出现这条 feed
            inbox:N ─┘   N = 粉丝数
  • 优点:读 O(1)——刷新只读自己的 inbox,一次 zrevrange 完事,读体验最佳。
  • 缺点:大 V 发一条 = 写 1 亿次;存储同样乘 1 亿;粉丝越多写延迟越长。

2)拉模式(pull,读扩散,fan-out on read) ​

刷新首页 ──→ 取我的关注列表(可能 2000 人)
         ──→ 查每个人 outbox 的最近 N 条
         ──→ 内存归并排序 → 取前 20 条返回
  • 优点:写 O(1),存储只有一份,发布瞬间完成。
  • 缺点:读放大——关注 2000 人 = 一次刷新扇出 2000 次查询再归并,大关注列表用户必超时。

3)推拉结合(hybrid,工业界默认答案) ​

                发布
                 │
      ┌──────────┴──────────┐
   普通用户                大 V(粉丝 > 阈值,如 10 万)
      │                       │
   fan-out 推送             只写自己 outbox,不推
   给全部粉丝                 │
      │                       │
      ▼                       ▼
  粉丝刷新:inbox(推来的) ⊕ 实时拉取"我关注的大V"的 outbox 最近条目
            去重 → 归并 → 返回

三角取舍一张表:

维度推拉推拉结合
写成本O(粉丝数),大 V 爆炸O(1)普通 O(粉丝),大 V O(1)
读成本O(1)O(关注数),重度用户爆炸轻用户读 inbox + 重用户补拉大 V,均可控
存储全量扩散,最贵单份,最省中间态
时效推送有延迟(秒级)刷新即最新分人群:在线看推的延迟,大 V 内容准实时

本质:用"写便宜"和"读便宜"在人群分布上做路由——普通用户写少读多适合推,大 V 写不起适合拉。

三、Timeline 存储:Redis zset + DB 游标分页 ​

1)收件箱用 Redis zset ​

ZADD  inbox:{uid}  1729142400  feed:10086   -- score=发布时间戳
ZREVRANGEBYSCORE inbox:{uid} +inf (lastScore) LIMIT 20 30  -- 翻页
  • zset 按 score 倒序天然就是 timeline,ZREVRANGE/ZREVRANGEBYSCORE 以 score 为游标翻页,永远不用 offset 深翻页。
  • inbox 只存 feedId(不存内容),详情另走批量 mget + 本地缓存——zset 条目 30B 量级,单用户 1000 条也才几十 KB。
  • inbox 是物化视图,可丢可重建:源数据 = 关注关系 + 各 outbox。Redis AOF 持久化 + 容灾时按关注关系重建,这是敢把 inbox 放 Redis 的前提。

2)DB 侧:游标分页 ​

sql
-- 深翻页不用 OFFSET(扫描+丢弃),用上一页末尾 id 做游标
SELECT id, author_id, content FROM feed
WHERE author_id = #{uid} AND id < #{lastId}
ORDER BY id DESC LIMIT 20;

游标能用 id 是因为 id 时间有序(雪花),见《分布式ID:从雪花算法到生产实现》。inbox 过期/被清理后回源 DB 同样走这条游标查询。

四、fan-out 扩散写的工程细节 ​

  • 异步化:发布事件先进 MQ,fan-out worker 消费后批量推送——发布接口 50 ms 返回,扩散延迟用户无感。
  • 批量写:按粉丝 uid 哈希分组,一组一个 Redis pipeline 批量 ZADD,网络往返从 N 次降为 1 次。
  • 速率与优先级:推送任务进优先级队列,先推活跃用户(体验敏感),沉默用户慢慢消化。监控推送积压,P99 推送延迟超阈值告警。
  • 失败处理:单粉丝推送失败重试 + 死信。整体积压超限 → 该发布降级为纯拉模式(读时聚合),保住发布主链路。
  • 存储治理:inbox 保留最近 1000 条或 30 天,冷数据靠拉模式兜底。

五、inactive 用户优化 ​

推模式最大的浪费:给 30 天不登录的用户也照样推,写了存储、占 pipeline,永远不会被读。分级处理:

分级定义策略
热活7 天内有访问正常 fan-out,inbox 全量维护
温活7~30 天只推热门/关注里的大 V 内容,普通内容不推
沉默大于 30 天完全不推,纯拉模式
  • 判定依据:最近活跃时间(登录/刷新心跳),T+1 离线计算 + 在线实时心跳双通道。
  • 沉默用户回归的那一刻是关键路径。登录即触发一次"冷启动拉取",实时聚合关注的人最近内容预热 inbox,之后切回推模式。
  • 收益量化:社交产品 30 天沉默用户常占 40%+。砍掉这部分 fan-out,写流量和存储直接省近半——这是推拉结合之外性价比最高的一刀。

六、非功能问题 ​

  • 删微博:不物理删,打 tombstone 标记,读取时过滤;扩散出去的副本靠读时过滤兜住。
  • 取关后 inbox 残留:读取时用关注关系做 filter(inbox 本来就是视图),离线任务定期对账清理。
  • 时间乱序:score 用服务端时间而非客户端时间;多机房时钟偏差换 score=雪花 id 更稳。
  • 算法流(按兴趣重排):排序策略换了,但存储层(inbox/outbox/游标)完全复用,先答时间线再提一句可演进到重排。

七、追问链 ​

  1. 大 V 阈值怎么定? 粉丝数 × 在线率(有效扩散量),如 10 万粉丝设为阈值。用监控(推送耗时 P99、积压量)反向校准,是运营出来的不是拍出来的。
  2. 推拉结合时怎么保证大 V 的微博在线粉丝立刻看到? 刷新时合并 inbox + 关注的大 V outbox 最近 N 条,去重归并——用额外的 zrange 换准实时。
  3. Redis 挂了 inbox 全没怎么办? inbox 是缓存语义不是真相。AOF + 副本先顶,极端情况按关注关系重建。期间读请求直接走拉模式 DB 游标查询(慢但可用)。
  4. 为什么 inbox 用 zset 不用 list? list 无法按 score 范围翻页、无法按时间去重合并。zset 的 score 就是天然的排序键和游标。
  5. 一条微博爆热(转发千万)算大 V 处理吗? 发布者维度静态分级 + 内容热度动态升级。转发热文按"虚拟大 V"处理,二次传播走拉。
  6. 一致性怎么表述? 收件箱是最终一致(推送有秒级延迟),发布/读取本身无跨节点事务。能主动映射到《分布式理论-BASE》的"基本可用 + 最终一致"语境。
  7. 存储成本怎么压? inbox 只存 id + TTL + 分级推送(第五节)。内容单份存 DB + 详情缓存。穿透击穿防护见《缓存穿透-击穿-雪崩》。

八、面试官视角小结 ​

面试官在 Feed 流题里打量的四件事:

  • 扩散方向的账算得清:推是写放大、拉是读放大。能按"粉丝数/关注数"两个人群维度把成本落到数字上,而不是背结论。
  • 敢做工程折中:阈值、优先级队列、降级为拉——知道工业界答案都是"分人群路由"而不是单选题。
  • 存储选型的理由:zset 的 score 即排序即游标、inbox 是可重建的物化视图。两句话说清就值一个 follow-up 的正面印象。
  • 对浪费的敏感度:inactive 分级是很多人答不出来的点,答出来说明真想过成本这件事。

一句话总结:普通用户推、大 V 拉、沉默用户不推登录再拉,inbox 只是视图、游标代替翻页。

2026-10 新增(P0 补全)。