设计一个类似微博、朋友圈的 Feed 流
一句话回答
Feed 流要解决的是"关注的人发了帖子,怎么出现在我的时间线里",有两种基本模式:推模式(写扩散)在发帖时把帖子 ID 写进每个粉丝的收件箱,读的时候直接取,读快,但大 V 发一条要写几千万次;拉模式(读扩散)只写自己的发件箱,读的时候实时聚合所有关注人的发件箱,写简单但读放大。实际多用推拉结合:普通用户推、大 V 拉,而且只推给活跃粉丝。时间线只存帖子 ID,放在 Redis ZSet 或收件箱表里,用游标分页;删帖和可见性在读取时过滤;不活跃用户的收件箱不维护,回来时再重建。
详细解析
第一步:澄清需求
- 功能:发帖、关注和取关、首页时间线按时间倒序(不做推荐排序)、下拉刷新和上滑加载、"有新动态"提示、删帖、可见范围(公开、仅粉丝、仅自己)
- 关系特点:微博是单向关注,粉丝数从几个到上千万,差距极大;朋友圈是双向好友,人数有上限。这决定了模式的选择
- 量级估算(假设值):
- DAU 1 亿,每人每天发 0.1 条:1000 万条/天,平均约 116 条/秒
- 平均每人 200 个粉丝,全部用推模式:1000 万 × 200 = 20 亿次收件箱写入/天,约 2.3 万次/秒
- 每人每天刷新 20 次:20 亿次读/天,平均约 2.3 万 QPS,峰值按 3 倍算约 7 万
- 有 5000 万粉丝的大 V 发一条,推模式要写 5000 万次,按每秒写 10 万次算,要 500 秒才能送达所有粉丝
第二步:整体架构
三种模式对比:
| 推模式(写扩散) | 拉模式(读扩散) | 推拉结合 | |
|---|---|---|---|
| 发帖 | 写入所有粉丝的收件箱 | 只写自己的发件箱 | 普通用户推给粉丝;大 V 只写发件箱 |
| 读时间线 | 直接读自己的收件箱,快 | 拉取所有关注人的发件箱再合并,慢 | 读收件箱 + 拉关注的大 V,再合并 |
| 存储 | 一条帖子的 ID 存 N 份 | 只存一份 | 介于两者之间 |
| 主要问题 | 大 V 写放大;给不活跃的粉丝写了也白写 | 关注的人多时读放大,而读请求远多于写 | 实现复杂,阈值要调 |
| 适用场景 | 好友数有上限的场景,如朋友圈这类 | 关注数少、读得少的场景 | 粉丝数差距极大的场景,如微博这类 |
发帖:客户端 → 帖子服务
1 写 posts 表(帖子 ID 用雪花 ID,按时间递增)
2 发"新帖子"消息 → 分发服务(异步)
普通用户:分批读粉丝列表,写入活跃粉丝的收件箱
大 V(粉丝数超过阈值):不分发,帖子留在自己的发件箱
读时间线:客户端(带游标)→ Feed 服务
1 读自己的收件箱:游标之前的 N 条帖子 ID
2 读关注的大 V 的发件箱:各取游标之前的 N 条
3 按帖子 ID 倒序合并,取前 N 条(见代码示例)
4 批量查帖子内容(缓存),过滤已删除、不可见的 → 返回内容和新游标
第三步:核心模块和数据模型
CREATE TABLE posts (
id BIGINT PRIMARY KEY, -- 雪花 ID,越大越新
author_id BIGINT NOT NULL,
content TEXT NOT NULL,
visibility TINYINT NOT NULL, -- 0 公开,1 仅粉丝,2 仅自己
status TINYINT NOT NULL DEFAULT 0, -- 0 正常,1 已删除
KEY idx_author (author_id, id) -- 发件箱:按作者倒序取帖子
);
CREATE TABLE follows ( -- 我关注了谁,按 follower_id 分片
follower_id BIGINT NOT NULL,
followee_id BIGINT NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (follower_id, followee_id)
);
-- "谁关注了我"另建一张反向的粉丝表,按 followee_id 分片,分发时分页读取
Redis 里的时间线:
- 收件箱
feed:in:{uid}:ZSet,member 是帖子 ID,score 是帖子 ID 里的毫秒时间戳;只保留最近 1000 条(假设值),写入后用ZREMRANGEBYRANK key 0 -1001裁掉更早的 - 发件箱
feed:out:{uid}:大 V 最近的帖子,结构相同,读的人很多,是热 Key,见大 Key 和热 Key - score 不直接用帖子 ID:ZSet 的 score 是双精度浮点数,只能精确表示 2^53 以内的整数,64 位的雪花 ID 会丢精度
游标分页:新帖子不断插到最前面,按页码翻页会重复或漏掉,所以用游标(原理见深分页)。客户端带上一页最后一条的帖子 ID,服务端取出它的时间戳 t,执行 ZRANGE feed:in:{uid} t -inf BYSCORE REV LIMIT 0 30(Redis 6.2 起支持这种写法;页大小是 20,多取几条抵消后面被过滤掉的),再在应用里过滤掉 ID 大于等于游标的帖子。查询包含边界 t,是为了不漏掉和游标同一毫秒发出的帖子。
第四步:关键难点
推拉结合的细节:
- 大 V 的阈值:粉丝数超过某个值(比如 10 万,假设值)就不推,读的时候拉
- 只推活跃粉丝:最近 N 天登录过的才推;不活跃的粉丝回来时,拉取关注人的发件箱重建收件箱
- 分发异步、分批:粉丝列表分页读取,每批用 Pipeline 写一次,分发服务可以水平扩展;粉丝晚几秒看到可以接受
- 关注后把对方最近的几条帖子补进收件箱;取关后不急着清理,读取时按关注关系过滤,再异步清理
删帖与可见性:
- 删帖只把 posts 的状态改成已删除,不去几千万个收件箱里删 ID;读取时批量查帖子内容,过滤掉已删除的
- 仅粉丝可见、仅自己可见、屏蔽了我,同样在读取时检查;过滤后不足一页,就继续往后多取一批
- 帖子内容的缓存要在删除时失效,否则删了还能看到,见缓存一致性
未读计数("有 N 条新动态"):
- 记录用户上次刷新时看到的最新帖子时间
last_ts - 推来的部分:
ZCOUNT feed:in:{uid} (last_ts +inf直接算出数量 - 拉的部分:要逐个检查关注的大 V 有没有更新,精确计数代价高,通常只显示红点、不显示数字
- 评论、点赞这类通知的未读数是另一套计数器,比如 Redis Hash 按类型
HINCRBY,查看后清零
冷热数据分离:
- Redis 只保存活跃用户的收件箱,设置过期时间,用户长期不来就自然淘汰,回来时重建
- 翻到收件箱保留范围之外的旧内容,回退到数据库:按作者分别查
idx_author再合并 - 历史帖子归档到更便宜的存储;关注表、粉丝表都是大表,按用户 ID 分库分表
第五步:扩展与优化
- 收件箱换存储:收件箱全放 Redis 太贵时,用宽表或 KV 存储(例如 HBase)持久化,Redis 只缓存活跃用户的部分
- 推荐流:在时间线里混入推荐内容,按分数排序,游标改成"分数 + ID"
- 监控:发帖到出现在粉丝时间线的延迟、分发任务积压量
代码示例
推拉结合时,把收件箱和几个大 V 的发件箱合并成一页(JavaScript,帖子 ID 用 BigInt):
// lists:自己的收件箱 + 关注的每个大 V 的发件箱,每个都是按 ID 倒序的帖子 ID(雪花 ID,越大越新)
// cursor:上一页最后一条的 ID,第一页传 null;返回本页的 ID 和下一页的游标
function mergeFeed(lists, cursor, size) {
// 每个列表跳到游标之后的位置(真实系统里这一步是带游标的 Redis 或数据库查询)
const pos = lists.map((list) => {
const i = cursor === null ? 0 : list.findIndex((id) => id < cursor)
return i < 0 ? list.length : i
})
const items = []
while (items.length < size) {
let best = -1 // 在各列表的当前头部里挑最新的一条
for (let k = 0; k < lists.length; k++) {
if (pos[k] < lists[k].length && (best < 0 || lists[k][pos[k]] > lists[best][pos[best]])) best = k
}
if (best < 0) break // 所有列表都取完了
const id = lists[best][pos[best]++]
if (items.at(-1) !== id) items.push(id) // 同一条帖子可能同时在收件箱和发件箱里,去重
}
return { items, nextCursor: items.length === size ? items.at(-1) : null }
}
const inbox = [9n, 7n, 4n, 2n] // 普通用户推过来的
const bigV = [8n, 7n, 3n] // 拉取的大 V 发件箱(7n 是他成为大 V 之前推过来的,重复)
console.log(mergeFeed([inbox, bigV], null, 3)) // { items: [ 9n, 8n, 7n ], nextCursor: 7n }
console.log(mergeFeed([inbox, bigV], 7n, 3)) // { items: [ 4n, 3n, 2n ], nextCursor: 2n }
面试官可能追问
收件箱全放在 Redis 里,内存够吗?
估算一下:1 亿用户,每人保留 1000 条,每条按 100 字节(帖子 ID、score 和跳表、哈希表的结构开销,假设值,实际用 MEMORY USAGE 测)算,1 亿 × 1000 × 100 字节 = 10 TB,全放内存太贵。所以只给活跃用户维护 Redis 收件箱,并限制条数;完整的收件箱持久化在 KV 存储里,或者干脆不持久化,用户回来时从关注人的发件箱重建。
用户的粉丝数刚好跨过大 V 阈值,之前推出去的帖子怎么办?
不用迁移。跨过阈值后,新帖子不再推送,只写发件箱;之前推出去的帖子还留在粉丝的收件箱里。读取时收件箱和发件箱里可能有同一条帖子,合并时按 ID 去重即可,代码示例就处理了这种情况。阈值附近要留一点缓冲,避免粉丝数在阈值上下波动时反复切换。
下拉刷新拿到了新帖子,怎么和已经加载的列表衔接?
下拉刷新用"第一条之后"的反向游标,取比当前第一条更新的帖子。新帖子不超过一页就直接插到最前面;超过一页说明中间隔了很多,直接丢掉旧列表,从最新开始展示,避免中间出现断层。
朋友圈为什么适合推模式?
好友数有上限,写扩散的放大倍数可控,读的时候只读自己的收件箱,延迟稳定。发帖时就能确定可见范围(分组可见、不给谁看),推模式可以只推给有权限的好友;而微博的粉丝数差距极大,纯推模式扛不住大 V,所以要推拉结合。
易错点
- 用页码分页,新帖子插进来以后翻页出现重复或遗漏
- 把 64 位雪花 ID 直接当 ZSet 的 score,精度丢失后排序错乱
- 删帖时去所有粉丝的收件箱里逐个删除,大 V 删一条帖子就是几千万次写
- 推模式不区分大 V 和不活跃粉丝,写放大大量浪费在不会来看的用户身上
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。