设计一个类似微博、朋友圈的 Feed 流

深入系统设计场景题高频约 11 分钟读完

一句话回答

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 批量查帖子内容(缓存),过滤已删除、不可见的 → 返回内容和新游标

第三步:核心模块和数据模型

SQL
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):

JavaScript
// 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 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。