63.7 信息流(推 / 拉 / 推拉结合)
一句话
信息流只有两种基本操作:写时扇出(推) 与 读时聚合(拉)。选型不看技术偏好,只算两个乘积——写侧的
最大扇出 × 发帖频率,读侧的读 QPS × 聚合规模;多数大系统最终落在推拉结合:普通用户推给粉丝,大 V 留给粉丝来拉。
先破除三个误区
| 误区 | 现实 |
|---|---|
| 信息流就是 “查我关注的人发的帖子,按时间排序” | 那是最朴素的拉模式,只在关注数有限时才成立;关注 2000 人、每秒几十万次请求时,一次查询要聚合 2000 路 |
| 推模式最符合直觉,应该更快 | 推模式把成本从读搬到写:一条微博扇出 1.2 亿行、约 2.9 GB,大 V 发一条博文要写几小时 |
| 拉模式省存储,所以成本低 | 省的是存储,贵的是读放大与带宽:10 万 QPS × 每次 1 kB ≈ 102 MB/s,单节点千兆网卡直接打满 |
三个硬指标
| 指标 | 量级 | 决定什么 |
|---|---|---|
| 入口读 QPS | 每秒几十万 | 读路径必须缓存前置,不能直查存储(缓存分层见 41.2 本地缓存与分布式缓存) |
| 单次出流延迟 | 100 ms 内 | 聚合的次数与网络跳数被钉死 |
| 出流要聚合的数据 | 微博正文 + 作者 + 四类计数 + 是否已赞/已关注/拉黑 | 一次出流是十几次甚至几十次读 |
“100 ms 内聚合十几次读” 这条约束,比 “存多少数据” 更能决定架构:它意味着任何一次出流都不能有同步的跨机房或数据库查询。
推模式:写时扇出
结构就是邮箱:发件箱(outbox)+ 每个粉丝的收件箱(inbox)。
-- 发布:自己发件箱 + 每个粉丝收件箱各插一条(A 有粉丝 B、C、D)
insert into outbox(user_id, feed_id, create_time) values ('A', $feed_id, $now);
insert into inbox(user_id, feed_id, create_time) values ('B', $feed_id, $now),
('C', $feed_id, $now),
('D', $feed_id, $now);
-- 读:只查自己的收件箱
select feed_id from inbox where user_id = 'B' order by create_time desc limit 20;
两张表的分片键不同,这一点常被忽略:
| 表 | 内容 | 分片键 | 原因 |
|---|---|---|---|
| Feed 表 | 微博 ID、作者、内容、状态 | feed_id 哈希 | 按 ID 查正文 |
| TimeLine 表 | user_id、feed_id、create_time | user_id 哈希 | 按人查列表,必须同分片 |
扇出的代价可以算出来:
1.2 亿粉丝 × 24 字节/行(user_id 8 + feed_id 8 + create_time 8)
= 2.88 GB / 条微博
一条微博 2.9 GB,写放大 1.2 亿倍——这就是推模式不适合千万级粉丝账号的算术原因,跟 “数据库够不够快” 无关。
推模式的工程补丁:
| 问题 | 补丁 | 代价 |
|---|---|---|
| 写入太慢 | 多线程并行写收件箱;换写优化引擎(如分形树 TokuDB,把随机写变顺序写,压缩率也高:4 TB → 200 GB) | 查询/删除性能弱于 InnoDB |
| 存储爆炸 | 收件箱只保留最近 1 个月,更早的丢弃(用户很少翻很久以前) | 老内容需走 “回源查发件箱” |
| 分组(明星/技术/旅游) | 一个用户多个收件箱,发布时复制更多份 | 扇出与存储再翻倍 |
| 删博 / 取关 | 不要回删所有收件箱;改成读时判断 “是否已删、是否仍关注” | 读时多一次判断 |
拉模式:读时聚合
只写发件箱,读的时候把关注列表里每个人的发件箱做 k 路归并:
我的信息流 = merge( B 的 outbox, C 的 outbox, ..., 最多 2000 路 ) 取前 20
它把 2.9 GB 的写放大变回 “一份数据”,代价全在读侧:
| 成本项 | 数字 | 怎么压 |
|---|---|---|
| 聚合路数 | 关注上限 2000 | 产品上限 + 只取最近 5 天的 ID(97% 的用户只看最近 5 天) |
| 聚合跳数 | 6 个节点并行,约 5 ms 返回 | 按 user_id 把发件箱分片,客户端并行拉 |
| 带宽 | 10 万 QPS × 1 kB ≈ 102 MB/s,单节点千兆网卡(上限约 125 MB/s)刚好打满 | 加副本摊读:4 组副本、60% 命中 → 主缓存降到 40 MB/s,每组约 25 MB/s |
“带宽先于 CPU 打满” 是拉模式最容易被低估的一点:一台机器的网卡是 125 MB/s,不是 “无限”。
实测:归并的 CPU 成本其实不高
把 2000 路有序列表归并取前 20 条,本机实测(Python 3.14 / macOS arm64):
import heapq, time, random
random.seed(7)
FOLLOWS, PER_BOX, TOP = 2000, 10, 20
boxes = [[random.randrange(0, 10**9) for _ in range(PER_BOX)] for _ in range(FOLLOWS)]
def merge_top():
out = []
for fid in heapq.merge(*boxes): # k 路归并
out.append(fid)
if len(out) >= TOP:
break
return out
ROUNDS = 2000
t0 = time.perf_counter()
for _ in range(ROUNDS):
merge_top()
el = time.perf_counter() - t0
print(f"{FOLLOWS} 路归并取前 {TOP}: 单次 {el/ROUNDS*1e6:.0f} µs, 单核 {1/(el/ROUNDS)/1e3:.1f}k 次/秒")
2000 路归并取前 20: 单次 598 µs, 单核 1.7k 次/秒
结论不是 “归并很快”,而是 CPU 归并不是瓶颈:Python 慢一个数量级,编译型语言约几十微秒;真正压死拉模式的是带宽与 RTT(102 MB/s、6 次跨节点请求)。所以优化方向是 “减少拉取次数与数据量”(只取 5 天、只取 ID、批量多路复用),而不是 “换更快的归并算法”。
推拉结合:用阈值把两类成本切开
| 角色 | 策略 | 理由 |
|---|---|---|
| 普通用户(粉丝 < 50 万) | 推:发布时写进粉丝收件箱 | 扇出有限,写成本可接受,读最快 |
| 大 V(粉丝 ≥ 50 万) | 拉:只写发件箱,粉丝读时补拉 | 扇出 1.2 亿不可接受 |
| 活跃粉丝 | 对活跃粉丝仍推(列表定长,超长剔除最先加入者) | 活跃用户读频繁,推省读成本 |
| 非活跃粉丝 | 不推,登录时异步补插大 V 最近博文 | 不浪费写 |
“活跃” 的判定要能在线算:最近几天刷新过信息流、发过博、互动过、关注过人。代价是多维护一份用户在线状态与活跃粉丝列表,存储与写成本都比纯推高——但比 “给 1.2 亿人各写一行” 便宜几个数量级。
存储分层:99% 的读要靠缓存挡住
信息流的读 QPS 比写 QPS 大两三个数量级,所以 “存哪” 比 “怎么聚合” 更早成为瓶颈。
三个输入决定存储架构:
| 输入 | 微博的量级 | 影响 |
|---|---|---|
| 发博 QPS | 平时几千,元旦零点等瞬间数万 | 决定写入通道与队列深度 |
| 读 QPS | 热点事件数万 → 乘平均关注数 200 → 几百万 QPS | 决定缓存层规模 |
| 冷热比 | 一周内的微博占访问量的 99% | 决定缓存放什么 |
把这三个数带进三种存储的容量上限里,结论立刻出来:
| 存储 | 单机能力(量级) | 在信息流里的位置 |
|---|---|---|
| MySQL(磁盘) | 读 < 1 万 QPS、写千级 | 全量持久化,做 “真相源” |
| Memcached / Redis(内存) | 几十万 QPS、容量几十到几百 GB | 只放最近一周的微博 |
| HBase(分布式) | 海量写入、按 rowkey 读 | 超大规模/冷数据归档 |
算一遍:几百万读 QPS,纯 MySQL 要上千台;而一周内命中率 99%,缓存扛掉 99%,落到 MySQL 的只剩几万 QPS——几十台就够。这就是 “缓存不是为了快,而是为了把机器数从四位数降到两位数”。
🔑 信息流的缓存只放 ID 与最近窗口(微博 ID、作者、时间),正文与计数按需回查。放全量会把内存撑爆,而放 ID 的命中率依然接近 99%——越靠前的内容越热,这个分布是信息流的结构性红利。
⚠️ 冷数据回源要限流:一周外的请求虽然只占 1%,但在几百万 QPS 的基数下就是几万 QPS,足以把 MySQL 打满。做法是给回源通道单独的连接池与限流上限,宁可让 1% 的用户多等一会儿,也不能让回源打穿数据库。
决策表:按规模选
| 规模特征 | 选型 | 依据 |
|---|---|---|
| 粉丝有上限、关系对等(朋友圈 5000 好友上限) | 推 | 扇出有天花板,写成本可控 |
| 粉丝百万级、活跃粉丝十万级 | 推拉结合 | 大 V 门槛 50 万粉 |
| 粉丝千万~亿级、关注上限 2000 | 拉 | 1.2 亿行/条不可接受;靠 6 节点 5 ms + 副本摊带宽 |
| 读 QPS 几十万、100 ms 出流 | 以上任一 + 缓存前置 | 单节点 100 MB/s 已到网卡上限 |
| 内容必须 “一条不丢”(合规/审计) | 推(收件箱可持久化)+ 拉兜底 | 拉模式对已删内容的可见性不可控 |
生产落地:指名 → 可抄 → 可验 → 可退
指名:收件箱用 Redis ZSET(score 用时间戳,天然按时间排序,ZREMRANGEBYRANK 裁剪长度);持久化与海量用 HBase / Cassandra(按 user_id 分片、按时间倒序存储);发件箱用 MySQL 分库分表(44.14 分库分表)或对象存储。
可抄(Redis ZSET 做收件箱,单机验证):
# 发布:写进每个粉丝的收件箱(score 用毫秒时间戳,member 用 feed_id)
redis-cli ZADD inbox:u1001 1730000000000 feed:9001
redis-cli ZADD inbox:u1001 1730000000123 feed:9002
# 读:取最新 20 条
redis-cli ZREVRANGE inbox:u1001 0 19
# 裁剪:只保留最近 1000 条,控制内存
redis-cli ZREMRANGEBYRANK inbox:u1001 0 -1001
⚠️ 以上命令未在本机验证(本机没有 Redis)。
ZADD是 O(log N),扇出 1.2 亿次就是 1.2 亿次 O(log N) 写——这正是推模式在大 V 上失效的原因。
可验:
| 检查 | 指标 | 期望 |
|---|---|---|
| 扇出是否可控 | 单条博文的收件箱写入行数 | 普通用户 < 1 万;超过阈值应转拉 |
| 出流延迟 | P99 出流耗时 | < 100 ms;超过说明聚合/回源太多 |
| 缓存是否够用 | 收件箱命中率、单节点出网带宽 | 命中率 < 95% 或带宽 > 80% 都该扩容/分片 |
可退:
| 退路 | 做法 |
|---|---|
| 扇出阻塞 | 异步队列写收件箱,前端先返回成功 |
| 大 V 突发 | 临时把该账号切到 “拉”,粉丝读时补拉 |
| 收件箱不可用 | 降级为实时拉模式(慢但可用) |
| 存储暴涨 | 收紧收件箱长度、只留最近 N 天 |
反模式
| 反模式 | 后果 |
|---|---|
| 无差别用推模式 | 大 V 发一条博文写 2.9 GB,延迟以小时计 |
| 收件箱不做长度裁剪 | 存储随关注数 × 发布量无限增长 |
| 删博时回删所有收件箱 | 一次删除触发上亿次写,比发布还贵 |
| 拉模式每次实时查发件箱 | 100 ms 内聚合 2000 路,带宽先打满 |
| 出流做同步跨机房查询 | 单次多几十毫秒,100 ms 预算直接爆掉 |
| 用同一分片键存 Feed 与 TimeLine | 要么按 ID 查不到列表,要么按人查不到正文 |
| 把 “展示过的不再推荐” 这类状态写进信息流 | 状态与内容耦合,压测/回放时把线上数据刷坏 |
| 只压测 “已缓存” 的信息流 | 冷用户首次出流才是真实成本,压测测不出来 |
三条铁律
🔑 先算扇出,再选模式:
粉丝数 × 每行字节就是单条内容的写放大;超过 “一次请求能承受的写”,就必须转向拉或推拉结合。
🔑 读侧预算 100 ms,写侧允许异步:发布链路可以进队列异步扇出,出流链路不允许任何同步跨机房查询。
🔑 推拉阈值是个数字,不是感觉:50 万粉丝、2000 关注上限、5 天可见窗口——每个数字都要能从 “带宽 / 扇出 / 命中率” 算出来。
自检
Q1 一条微博在推模式下产生 2.9 GB 数据,这个数怎么算出来的?
▸ 答案:1.2 亿粉丝 × 24 字节/行。每行是 user_id(8) + feed_id(8) + create_time(8)。这个乘法说明推模式的上限由粉丝数决定,而不是数据库性能——换更强的库也改变不了要写 1.2 亿行。
Q2 朋友圈为什么可以用推模式,而微博不行?
▸ 答案:朋友圈是双向好友且有上限(约 5000),扇出有天花板,写成本可控;微博是单向关注、粉丝可达 1.2 亿,扇出无上限。同样是 “信息流”,关系模型决定了可行方案。
Q3 拉模式的瓶颈为什么常常是带宽而不是 CPU?
▸ 答案:归并 2000 路有序列表的 CPU 成本是微秒级(本机 Python 实测 598 µs,编译型语言更快),但每次出流要取约 1 kB 数据:10 万 QPS × 1 kB ≈ 102 MB/s,已经接近千兆网卡上限 125 MB/s。所以优化方向是减少拉取次数与数据量(只取 5 天、只取 ID、批量),而不是优化算法。
Q4 推模式下 “取消关注” 和 “删除微博” 该怎么处理?
▸ 答案:不要回删。回删意味着一次操作触发上亿次写,比发布还贵。正确做法是读时判定:出流时检查该微博是否已删、作者是否仍被关注,不满足就跳过。用读时的少量判断换写时的大量删除。
Q5 推拉结合的 “大 V 阈值” 该怎么定?
▸ 答案:从写成本反推:粉丝数 × 24 字节 超过单次发布可承受的写入量与延迟(例如超过 50 万粉丝 ≈ 12 MB/条、扇出耗时不可接受),就转拉。阈值不是拍脑袋,是 “单条内容的写放大上限 ÷ 每行字节”。
Q6 为什么信息流要 “只缓存最近 5 天的 ID”?
▸ 答案:因为绝大多数用户只看最近几天的内容(公开案例里约 97% 只看最近 5 天),更早的内容几乎不被翻。缓存 5 天的 ID 把单次拉取的数据量压到 1 kB 量级,直接决定带宽能否支撑;更早的内容走 “回源查发件箱” 的低频路径。
Q7 压测信息流时最容易漏掉什么场景?
▸ 答案:冷用户首次出流与缓存失效。压测常用 “已预热、已缓存” 的账号,测出来的是缓存速度;真实成本在 “关注 2000 人、收件箱为空、缓存未命中” 的首次请求,以及大 V 发布瞬间的扇出风暴。这两类场景不压,上线必炸。
关联
- 前置:44.23 未读数系统(同一组关注关系的 “已读” 侧)、44.14 分库分表
- 并列:63.2 召回 / 排序 / 重排(信息流之后的排序阶段)、41.2 本地缓存与分布式缓存
- 下游:44.22 计数系统、34.2 性能建模 (利特尔法则、排队论)