大 V 发一条动态,系统为什么要写一亿次:Feed 流的推拉账
Feed 流是用户打开首页看到的那串内容,工程上要解决的不是把帖子查出来,而是在读多写少、关注关系极不均匀的条件下,用可接受的成本稳定给出下一页。常见做法是提前把收件箱算好(写扩散),但一个大 V 发帖会放大成百万次写入;全改成读时合并(读扩散),一次刷新又要扇出成百上千次查询。真正的设计是在两者之间按粉丝规模分流,再叠加只存 ID 的收件箱、复合游标分页和读时重排。读完你能说清推拉各自把成本压在哪一端、分页为什么会重复漏项,以及大 V、删除、取关这些边界该怎么处理。
是什么
Feed 流是用户打开首页看到的那串按某种顺序排列、可以不断下拉的内容。它看起来只是"把关注的人发的帖子查出来",实际要解决的是一个更窄的问题:在关注关系极度不均匀、读远多于写的条件下,用可接受的成本稳定地给出下一页。
一句话直觉:推模式像报社把报纸塞进每家报箱,用户回家直接拿;拉模式像用户自己去报刊亭,现买现拼。前者的代价在投递,后者的代价在每次购买。
典型场景是微博、朋友圈、Instagram 首页、Twitter 的 Following 时间线,以及带推荐排序的"发现"流。它们共享三个约束:粉丝数呈幂律分布,少数账号贡献绝大多数投递量;用户刷新远多于发帖;一页内容必须可翻、可重排、可删除。Feed 设计的所有取舍,都是在这三个约束下分配成本。关注流的候选来自关注关系,推荐流的候选来自多路召回;投递与分页的机制对两者通用,只是推荐流在读路径上多一层召回和模型排序。
核心原理
先算一笔账。设一个用户有 N 个粉丝、关注了 M 个人。写扩散(fan-out on write)在发帖时把帖子 ID 写进每个粉丝的收件箱,写成本 O(N),读成本 O(1);读扩散(fan-out on read)发帖只写自己的发件箱,读时把 M 个人的最近内容取回来归并,写成本 O(1),读成本 O(M)。两者不是"谁更先进",而是把成本放在写端还是读端。
写扩散的放大有多可怕:一个亿级粉丝的账号发一条动态,就是上亿次收件箱写入。假设投递集群合计每秒能写十万条,这条动态也要一千秒才能铺完,约十七分钟;这期间其他用户的投递还在排队。更糟的是它没有减少总工作量,只是把工作从读时挪到了写时。大 V 越活跃,写放大越频繁。
于是主流方案是推拉结合:按粉丝数设一个阈值,普通账号走写扩散,大 V 走读扩散。读的时候先取自己收件箱里的一页 ID,再单独拉取所关注大 V 的最近若干条,按时间归并后一起排序。阈值不能拍脑袋,要由投递集群的吞吐、粉丝数分布和可接受的新内容延迟共同决定;粉丝数只是表象,真正要看的是"这条帖子会触发多少次写"。
收件箱本身通常用 Redis 的有序集合:member 是帖子 ID,score 是"时间 + 序号"拼出的唯一排序键。只存 ID,不存正文;正文放在 KV 或数据库里,按 ID 批量取。这样做有两个好处:一是同一条帖子被重复投递时,ZADD 会覆盖同一个 member,天然幂等;二是正文修改、删除只需要动一处,不会在几百万份收件箱里各留一份旧副本。
def fanout(post, follower_ids, r, cap=1000):
score = post.created_at_s * 1_000_000 + post.seq % 1_000_000 # 秒级时间 + 每秒内唯一序号
pipe = r.pipeline()
for uid in follower_ids:
key = f"feed:{uid}"
pipe.zadd(key, {post.id: score}) # 重复投递幂等
pipe.zremrangebyrank(key, 0, -cap - 1) # 只留最近 cap 条
pipe.expire(key, 7 * 86400) # 不活跃用户的收件箱自然过期
pipe.execute()
def page(uid, cursor, r, n=20):
hi = "(%d" % int(cursor) if cursor else "+inf" # 开区间,避免重复
ids = r.zrevrangebyscore(f"feed:{uid}", hi, "-inf", start=0, num=n)
return ids, (r.zscore(f"feed:{uid}", ids[-1]) if ids else None)
这段代码只表达两件事:投递要幂等且可截断,翻页要用"上一页最后一条的排序键"做开区间。真实系统还要处理分片、批量、失败重试和队列积压,但骨架就是这些。
分页是第二个容易翻车的地方。LIMIT 20 OFFSET 10000 并不是"跳过一万条",数据库仍要扫描并丢弃前一万条,越翻越慢;更严重的是,如果这期间有人发了新帖,原来的第 10001 条会被挤到第 10002 条,用户看到的上一页和下一页之间就会重复或漏项。游标分页(keyset pagination)改成"从排序键小于 X 的位置继续取",定位靠索引,代价与页码无关,也不会因为插入而漂移。代价是它只能顺序翻,不能随机跳到第 500 页——这正好符合 Feed 的使用方式。
读路径还不止取 ID。完整链路是召回、过滤、排序、打散:从收件箱和拉取的大 V 内容里取候选,过滤掉已删除、被屏蔽、已读过的内容,再按时间或模型分数排序,最后做作者多样性和内容类型的打散。排序需要多取一些候选,通常取目标条数的两到五倍,否则前二十条里只要有几条被过滤掉,这一页就会短一截,甚至返回空页。
最后是一致性。线上写扩散通常是异步的,帖子从发布到出现在粉丝首页有秒级延迟。作者本人应该立刻看到自己的帖子,做法是在读路径额外并入自己的最近内容;删除通常先在正文存储打墓碑,收件箱里的 ID 读到后再过滤;取关后旧帖子可能还留在收件箱里,要么读时按当前关注关系过滤,要么异步清理。新关注一个人时收件箱是空的,需要回填他最近的若干条,否则用户会以为关注没生效。
常见考点与误区
- "Feed 就是 JOIN 关注表再排序":小规模可以,关注上千人时,一次刷新要查上千个发件箱再归并,p99 会被尾部的慢查询拖垮。面试时先说清规模;如果日活只有几万,单库 JOIN 加缓存就够了,上来就写扩散反而是过度设计。
- "写扩散一定比读扩散好":它买到的是读延迟,付出的是写放大和内存。每个活跃用户一份收件箱,哪怕只存 800 个 ID,一千万活跃用户就是八十亿条,按有序集合每条约五十到一百字节算,四百到八百 GB 内存,还没算副本。只给活跃用户建、设 TTL、截断长度,都是必需的成本控制。
- "OFFSET 只是慢一点":它还会改变结果。翻页期间有新内容插入,偏移量整体后移,用户就会看到重复项或漏掉整条。游标分页解决的是正确性问题,不只是性能问题;游标要编码唯一且稳定的排序键,不能只编码时间戳,否则同一秒的多条内容会互相覆盖。
- "排序可以在写入时一次算好":时间线可以,个性化排序不行。模型、特征、用户兴趣每天都在变,给每个用户预计算所有候选的分数既贵又僵。常见做法是写入时只保证粗排和时间序,读时对候选集做精排,并按会话缓存一小段时间;缓存过期后重新排,用户下拉时用会话快照保证同一轮翻页的顺序稳定。
- "收件箱里直接存正文":一条帖子被投递给一百万粉丝,就有一百万份正文副本;作者改一个字或删帖,要改一百万处,延迟和一致性都不可控。正确做法是收件箱只存 ID 和排序键,正文集中存储、批量读取,删除只打墓碑,读时统一过滤。
- "异步投递了就等于最终一致,不用管":产品要的是"作者发完立刻可见""取关后不再出现""删掉的帖子不能再被翻到",这些都不是最终一致自动给的。必须明确读己所写、墓碑过滤、关注关系过滤这三条读路径规则,并把投递延迟当成 SLO 监控,而不是等用户投诉。
实践建议
- 先量粉丝数分布,再定推拉阈值。 把账号按粉丝数分桶,估算每个桶发帖会触发多少写、占多少投递容量;阈值设在"再往上推会挤占普通用户延迟"的位置。大 V 名单可以动态调整,发帖频率高的账号要单独限流。
- 收件箱只放 ID,长度控制在几百到一千,配合 TTL。 用 pipeline 批量写,ZADD 保证重试幂等,ZREMRANGEBYRANK 截断;正文用 MGET 批量取,做好空值缓存。不要为了"一次取全"把正文塞进收件箱。
- 游标用"时间 + 序号"的唯一键,并做不透明编码。 对客户端只暴露 base64 后再加签名的 cursor,服务端验签后做开区间查询;不要暴露自增 ID 或可猜测的偏移量,也明确告知产品不支持随机跳页。
- 读路径先多取候选再过滤排序。 取目标条数的两到五倍,先过滤删除、屏蔽、已读,再排序打散;热门用户的首页结果可以缓存几十秒,模型服务超时则降级为纯时间序,保证首页始终能开。
- 把边界写进设计文档。 新关注回填多少条、取关是懒过滤还是异步清理、作者自己发的帖子怎么合并、投递失败重试几次、积压到什么程度触发降级,这些问题的答案比"用 Redis 还是用 MySQL"更能决定系统是否可用。
一句话总结:Feed 流的难点不是把内容查出来,而是用可控的写放大换可控的读延迟,并让分页和一致性在边界上仍然成立。