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 发布瞬间的扇出风暴。这两类场景不压,上线必炸。

关联