推荐系统 · 写扩散 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 打开首页,看到一个"关注流"—— 只显示他关注的人的最新动态,按时间倒序。
作为工程师,你首先想到的实现:
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 202.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 fanoutPostQueryApplicationService.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 列表 |
| KOL | Key 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 混合模式时沉淀