Skip to content

推荐系统 · 写扩散 vs 读扩散 · 长期演变路径 ​

日期: 2026-07-09 | 作者: sunxin(+ Cursor AI 结对讲解) 关联路线图: 推荐系统建设路线图.md §三 Phase 1 预计触发: ADR-014「关注扩散通知走写扩散 + 批量 + 慢队列 + 幂等」 关联笔记: Phase1 摸底(未收录)

TL;DR ​

  • 写扩散(Push / Fan-out on Write)= 发帖时当场把这条帖子/通知写入所有粉丝的信箱;读的时候直接读自己信箱。
  • 读扩散(Pull / Fan-out on Read)= 发帖只写自己的箱子;读的时候实时聚合所有关注对象的最新帖。
  • 二者是空间 vs 时间的对偶:写扩散省了读的时间,但花了大量存储;读扩散省了存储,但每次读都要现场聚合。
  • 业界的最终形态是"混合模式":普通用户走写扩散、大 V(粉丝 > N)走读扩散、超级 KOL 走"推给活跃粉丝 + 冷粉丝走 pull"—— Twitter/Instagram/微博/小红书大同小异。
  • 本项目 Phase 1 走纯写扩散,因为无真实大 V;未来出现 KOL 时按下面的"演变四阶段"升级。

目录 ​


1. 场景:为什么这件事重要 ​

1.1 一个具体的痛点 ​

想象你在做 Twitter/微博类应用。用户 A 打开首页,看到一个"关注流"—— 只显示他关注的人的最新动态,按时间倒序。

作为工程师,你首先想到的实现:

sql
SELECT * FROM posts
WHERE author_id IN (SELECT following_id FROM user_follows WHERE follower_id = :A)
ORDER BY create_time DESC
LIMIT 20

这就是读扩散的雏形(每次读都聚合)。

问题一:A 关注了 500 人 → IN 里 500 个 ID → MySQL 走全索引扫、执行计划抖动、20ms 完成还可以。 问题二:如果 A 是活跃用户,每 5 秒下拉刷新一次 → 每秒 QPS ↑。 问题三:如果全站 100 万 DAU 都在刷这个 → 数据库压力全部落到 posts 表 + user_follows 表 → 崩。

于是有人提出反过来做:

A 每次发帖时,把这条 postId 复制一份塞进每个粉丝的"信箱"表;A 读关注流时只需要 SELECT * FROM inbox WHERE user_id = :A ORDER BY inbox_time DESC LIMIT 20(一张表 + 一个索引,1ms 内返回)。

这就是写扩散。

1.2 为什么这是行业最经典的架构 trade-off ​

写扩散 vs 读扩散是分布式系统里最经典的"读写权衡"之一,跟以下问题同源:

  • 数据库的"预计算物化视图 vs 视图现算"
  • 缓存的"缓存所有可能的组合 vs 每次现算再缓存"
  • 静态站点生成器的"build 时生成所有页面 vs SSR 时现渲染"

理解了这个 trade-off,能迁移到很多场景。所以值得单独用一篇笔记讲透。

2. 两种模式的原理对比 ​

2.1 写扩散(Fan-out on Write) ​

用户 A 发帖:
      │
      ├──▶ posts 表 insert 1 条
      │
      ├──▶ 查 A 的粉丝列表:B, C, D, E, F (5 人)
      │
      └──▶ user_inbox 表 insert 5 条
              (user_id=B, post_id=X)
              (user_id=C, post_id=X)
              (user_id=D, post_id=X)
              (user_id=E, post_id=X)
              (user_id=F, post_id=X)

用户 B 读关注流:
      │
      └──▶ SELECT post_id FROM user_inbox WHERE user_id=B ORDER BY inbox_time DESC LIMIT 20
             ↓
           从 posts 表按 post_id IN (...) 拉正文

2.2 读扩散(Fan-out on Read) ​

用户 A 发帖:
      │
      └──▶ posts 表 insert 1 条(就这一步)

用户 B 读关注流:
      │
      └──▶ SELECT * FROM posts
           WHERE author_id IN (SELECT following_id FROM user_follows WHERE follower_id=B)
           ORDER BY create_time DESC
           LIMIT 20

2.3 复杂度对比表 ​

写扩散读扩散
写复杂度O(follower_count)O(1)
读复杂度O(1)O(following_count × posts_per_person)
存储放大×N(N = 关注者数)×1
首屏延迟低(预计算好了)中高(现场聚合)
一致性弱(发帖到粉丝可见有延迟)强(发帖立刻可见)
删帖/编辑麻烦(要更新所有信箱)简单(只改 posts)
大 V 场景❌ 灾难(10 万粉丝要写 10 万行)✅ 平稳
死粉场景❌ 浪费(僵尸账号也要收)✅ 无浪费

2.4 为什么二者天然是"对偶" ​

信息论视角:无论哪种方案,用户看到的最终结果一样。区别只在"这份'我关注的人的动态'快照"是什么时候被计算的:

  • 写扩散:在写入时刻计算(帖子被复制到每个粉丝信箱)
  • 读扩散:在读取时刻计算(现场聚合关注列表)

这就是所谓"时间换空间 vs 空间换时间"。选择依据取决于:读远多于写 → 写扩散;写多读少 → 读扩散。

社交产品是极端"读多写少"(发一条帖被读几千次)→ 天然偏写扩散。

3. 演变四阶段 ​

真实业界系统不是二选一,而是分阶段混合。

阶段 1 · 纯写扩散(MVP · 本项目当前) ​

触发场景:DAU < 1 万,无真实 KOL,最大关注者数 < 1000。

特点:所有用户一视同仁,发帖时把 postId 塞入所有粉丝信箱。

架构:

PostPublishedEvent
      ↓
ContentEventListener.onPostPublished(AFTER_COMMIT + Async)
      ↓
IUserRepository.findFollowerIds(authorId) 拉粉丝
      ↓
NotificationApplicationService.sendNotificationBatch(followers) 批量写通知

上限:粉丝 5000+ 的用户开始感受到发帖延迟;粉丝 10 万+ 会直接卡死单机线程池。

阶段 2 · 写扩散 + 慢队列保护(Phase 1 目标) ​

触发场景:出现"小 KOL"(粉丝 500-5000),偶发写扩散拖慢发帖响应。

改动:加"慢队列"分流:

if (followerCount <= 500) {
    // 快路径:事件线程池内直接批量写
    notificationService.sendBatch(...)
} else {
    // 慢队列:投递到独立线程池 + 分批限流
    fanoutExecutor.submit(new FanoutTask(...))
}

// FanoutTask 内部:每批 200,批间 sleep 100ms

关键:发帖 API 本身立刻返回,用户体感无差异;差异只在"粉丝多久收到通知"(快路径 <1s、慢队列可能 30s+)。

阶段 3 · 写扩散 + 读扩散混合(DAU 10w+ / 出现 KOL) ​

触发场景:出现真正大 V(粉丝 10 万+);此时纯写扩散一次发帖要写 10 万行,存储 + 写压力都成瓶颈。

方案:按用户角色分流:

角色判定策略
普通用户粉丝 < 5000走写扩散(老路径)
大 V粉丝 ≥ 5000走读扩散(只写 posts 表,不写粉丝信箱)

用户读关注流时:

SELECT posts_from_inbox                        # 从我的信箱拉普通关注对象的帖
UNION ALL
SELECT posts FROM posts
  WHERE author_id IN (
    SELECT following_id FROM user_follows
    WHERE follower_id = :me AND following_is_KOL = true
  )
ORDER BY create_time DESC LIMIT 20

难点:合并 pull 和 push 的结果需要客户端时间戳排序;两部分的分页处理复杂。

业界方案:Twitter Timeline Service 的经典设计(Yahoo 2011 论文)。

阶段 4 · 三段式:热粉推 + 冷粉拉(DAU 100w+ / 超级 KOL) ​

触发场景:出现超级 KOL(粉丝百万+),阶段 3 里"KOL 走 pull"会让粉丝多的人读得很慢(要现场聚合几百个 KOL 的帖子)。

方案:进一步细分:

粉丝角色判定大 V 发帖时的策略
活跃粉丝近 7 天有登录写扩散(马上推)
僵尸/沉默粉丝近 30 天未登录不推(等他真登录时走读扩散补拉)

业界数据:微博里活跃粉丝占比约 20-30%,直接省了 70% 的存储/写入。

架构复杂度:需要维护"活跃粉丝集合"(Redis Set,每次登录更新 TTL);需要独立的"补拉服务"处理沉默粉丝突然回归的场景。

4. 数字直觉 ​

4.1 存储放大的量级感 ​

假设 DAU 10 万,人均关注 200 人、粉丝 200 人、每天发 1 条帖:

  • 写扩散:

    • 每天新帖 10 万
    • 信箱表增量:10 万 × 200 粉丝 = 2000 万行/天 = 73 亿行/年
    • 单表最多可以扛,但要按用户 hash 分表
    • 存储成本:2000 万行 × 50 字节 ≈ 1 GB/天 = 365 GB/年
  • 读扩散:

    • 每天新帖 10 万
    • posts 表增量:10 万行/天 = 3650 万行/年
    • 存储成本:10 万 × 500 字节 ≈ 50 MB/天 = 18 GB/年
    • 省 20 倍存储

结论:读扩散在存储上碾压,代价是每次读都要跑一个稍复杂的 JOIN。

4.2 读写次数对比 ​

假设 DAU 10 万,用户每天:读关注流 20 次 / 发帖 1 次 / 关注比 200

  • 写扩散:

    • 读操作:10 万 × 20 = 200 万次(每次简单 SELECT)
    • 写操作:10 万 × 200 = 2000 万次(发帖扇出)
    • 读:写 = 1:10(写占主导)
  • 读扩散:

    • 读操作:10 万 × 20 = 200 万次(每次复杂 JOIN)
    • 写操作:10 万次(就 insert posts)
    • 读:写 = 20:1(读占主导)

结论:写扩散把"读多写少"的假设故意反转,因为数据库对"简单读"的优化 >> "复杂读"的优化。

4.3 阶段 3 混合模式的"平衡点"计算 ​

在什么关注者数 K 时,写扩散和读扩散代价相等?

  • 写扩散代价:写入 K 次
  • 读扩散代价:读操作被拖慢的次数,取决于关注了这个用户的人分别读多少次

假设该 KOL 的每个粉丝日均读关注流 20 次,读扩散拖慢率是每多 100 关注对象增加 2ms:

  • 写扩散代价:K × cost_write (cost_write ≈ 0.5ms/行)
  • 读扩散代价:K × 20 × 2ms/100 = K × 0.4ms 增量拖慢

平衡点约在 K = 5000 附近(不同项目略有差异,但数量级都在千到万),所以业界常见的 KOL 阈值 = 5000-10000。

5. 项目里的具体落地 ​

5.1 Phase 1 · 阶段 2 版本(目标) ​

代码位置:ContentEventListener.onPostPublished

if (followerCount <= 500) {
    // 快路径:批量写
    List<NotificationCommand> cmds = followers.stream()
        .map(fid -> buildNewPostNotification(fid, event))
        .toList();
    notificationService.sendNotificationBatch(cmds);
} else {
    // 慢队列:投递到 FanoutTaskExecutor
    fanoutExecutor.submit(new FanoutTask(event, followerIds));
}

关键设计:FanoutTaskExecutor 是独立 Bean,未来升级到阶段 3 时只改内部实现,接口不变:

  • 阶段 2 · 分批 200 + sleep 100ms
  • 阶段 3 · 投递到 Kafka topic + 消费者水平扩展
  • 阶段 4 · 判断粉丝活跃度,只推活跃粉丝

5.2 何时触发升级到阶段 3 ​

触发条件(AND 关系,全部满足才启动):

  • 数据库监测到 user_inbox 表(Phase 1 用 notifications 复用)写 QPS > 1000/s
  • 出现粉丝数 > 5000 的用户 ≥ 10 个
  • 发帖 API P99 延迟 > 500ms

升级动作:

  • 新建"KOL 判定"服务(IsKolPort 查 Redis)
  • ContentEventListener 里加 if (isKol(authorId)) skip fanout
  • PostQueryApplicationService.getRelatedFeed 里加 UNION 分支(信箱 + KOL 直查)
  • 沉淀 ADR-024「关注流走写扩散 + KOL 读扩散混合模式」

关键约束:阶段 2 → 阶段 3 的升级不允许要求粉丝历史数据迁移。设计时就要保证"新增 KOL 用户的粉丝老信箱数据不清理,只是新帖不再入信箱"。

5.3 项目当前不做的:真正的"关注流页" ​

注意:本项目 Phase 1 只做通知触达("XX 发了新动态"塞进消息中心红点),不做完整的关注流页面(首页动态流按关注对象排序)。

区别:

  • 通知触达:低频、有明确红点/未读语义、消息中心 tab
  • 关注流页:高频、大规模刷新、首页 tab

关注流页是独立更大的建设,属于推荐流召回的"关注召回"子路(见路线图 §六 P4 §6.2.2 ①)。Phase 1 只做通知触达,是因为它是"最小可交付 + 最容易看到效果"。

6. 教学收获 ​

6.1 "时间 vs 空间"是分布式系统的第一原理 ​

写扩散/读扩散的本质是"预计算 vs 现场计算",这个 pattern 在很多场景反复出现:

场景预计算(=写扩散)现场算(=读扩散)
SQL 视图物化视图(MATERIALIZED VIEW)普通视图(VIEW)
前端渲染静态站点生成(Next.js SSG)服务端渲染(SSR)
图片缩略图上传时生成所有规格请求时按需生成并缓存
排行榜定时 Job 刷新 Redis ZSet每次 SELECT ORDER BY 现算
全文搜索建倒排索引grep 现搜

认知升级:以后遇到"读多写少"的场景就想想是不是可以"写时多做一点、读时轻松点",反之亦然。这是架构直觉。

6.2 演进式设计的经典范例 ​

四个阶段的每次升级,接口都不变,只改实现:

  • 阶段 1 → 2:加一个 if (followerCount > 500) 分支
  • 阶段 2 → 3:FanoutTaskExecutor 内部改成"KOL 跳过"
  • 阶段 3 → 4:判定条件加"活跃度"过滤

这就是"面向接口编程 + 策略模式"的实战意义 —— 不是为了"看起来解耦",而是让未来升级不需要 breaking change。

反面案例:如果 Phase 1 直接把 fanoutTo(followerIds) 写在 ContentEventListener 里而不抽 Executor,未来阶段 3 要改动 EventListener 本身,就会破坏"事件监听器只做协议翻译,不做业务逻辑"的分层原则。

6.3 判断"何时升级"的思维方法 ​

新手常见误区:"以后可能会有大 V,所以现在就上混合模式"。

正确姿势:用量化指标定义升级触发条件,等指标真正触发才做。因为:

  • 提前上带来的当下复杂度是确定的(要维护 KOL 判定服务、要写混合查询逻辑)
  • 未来收益是不确定的(可能项目根本没上量、可能 KOL 场景永远不出现)
  • 确定性成本 vs 不确定收益 = 净负

除非:升级不可逆 / 涉及数据结构不兼容 / 存量数据要大迁移 —— 这些情况才需要"提前设计但延后实现"。

阶段 1 → 2 → 3 → 4 的每次升级都可以在不迁移历史数据的前提下平滑推进,所以延后完全没有 risk。这也是本笔记 §5.2 特意强调"新增 KOL 用户的粉丝老信箱数据不清理"的原因 —— 保证升级的可行性。

7. 附录 ​

7.1 术语表 ​

术语英文说明
写扩散Fan-out on Write / Push发帖时预计算,塞进所有粉丝信箱
读扩散Fan-out on Read / Pull读时现场聚合
信箱Inbox / Feed用户的"关注流缓存",存 postId 列表
KOLKey Opinion Leader大 V / 高粉用户
关注流Following Feed / Following Timeline只看关注对象的动态
混合模式Hybrid Fan-out普通用户写扩散 + KOL 读扩散

7.2 延伸阅读 ​

  • Yahoo 2011 论文《Feeding Frenzy: Selectively Materializing Users' Event Feeds》—— 混合模式的经典论述
  • Twitter Engineering Blog《The Infrastructure Behind Twitter: Scale》—— Timeline Service 的演变实录
  • 微博技术团队《微博 Feed 流架构演进之路》—— 中文最佳阅读
  • 《Designing Data-Intensive Applications》(Martin Kleppmann) 第 1 章 "Timeline Delivery" 案例讨论 —— 教科书级讲解

7.3 关联决策 ​

  • ADR-014(起草中):本项目 Phase 1 走"写扩散 + 500 阈值慢队列 + 幂等键"
  • 未来 ADR-024(预留):升级到阶段 3 混合模式时沉淀

Powered by VitePress