Feed流系统设计实战:从推拉模式到Redis ZSet缓存架构

发布时间:2026/9/8 6:09:02
Feed流系统设计实战:从推拉模式到Redis ZSet缓存架构 做后端这几年Feed 流系统基本是绕不开的硬骨头。微博首页、朋友圈时间线表面看就是刷出一列动态但背后牵扯的“推拉模式取舍、缓存层级设计、排序策略、数据一致性”这些问题足够写好几篇长文。借着这个标题把我在实际项目里设计 Feed 流系统时踩过的坑、验证过的方案、还有那些文档里不会写明白的细节一起拆开聊聊。这篇文章适合正在做社区类产品、社交类 App 后端或者准备面试系统设计岗位的朋友。我不会只堆概念会把从需求拆解到上线维护的完整链路讲透很多参数和策略是直接可以抄去用的。1. 从需求到模型Feed 流的本质是“读扩散”和“写扩散”的博弈1.1 先想清楚你的产品到底要哪种时间线很多新手一上来就搜“Feed 流系统怎么设计”然后被各种名词绕晕。其实不管微博还是朋友圈核心问题只有一个当用户 A 发了一条动态怎么让关注 A 的用户 B 在首页刷到这条内容围绕这个问题业界基本就两条路推模式写扩散 / Fanout-on-WriteA 发动态时系统主动把这条动态写到所有粉丝的时间线里。B 刷新首页时只需要读取自己的时间线列表不需要实时聚合。拉模式读扩散 / Fanout-on-ReadA 发动态只写自己的主页。B 刷新首页时系统先查 B 关注了哪些人再挨个去这些人的主页拉取最新动态最后合并排序返回。推模式的优点是读接口极快B 的首页就是一条现成的列表IO 开销很小缺点是写操作会被放大——如果一个大 V 有 5000 万粉丝发一条动态要写 5000 万条数据这个扇出Fanout压力是恐怖的。拉模式的优点是写操作很轻谁发动态都只动自己那行数据缺点是读接口变重B 每次刷新都要实时查 N 个关注对象的主页再在内存里做合并和排序。如果关注列表里有几个发动态特别勤快的人一次刷新可能拉回几千条候选数据接口延迟直接飙红。这两条路没有绝对好坏真实的微博和朋友圈实际上是二者混合。我的建议是判断标准就看你的产品是“强者愈强”的头部效应明显还是“人人平等”的社交关系链为主。头部大 V 多的产品不能纯推普通用户关系链密集的产品不能纯拉。这个道理想清楚后面所有设计才有方向。1.2 微博和朋友圈的差异决定了架构选型虽然标题把微博和朋友圈放在一起说但这两个产品的 Feed 流设计思路差别非常大不能照搬一套方案。微博的社交关系是“关注”模型而且是非对称关注——你可以关注一个不认识的大 V但对方不一定关注你。这种模型下用户之间的粉丝数差异是巨大的头部大 V 一条动态的传播范围可能是普通人的几万倍。所以微博的 Feed 流必须考虑大 V 特殊处理不能把大 V 的每一条动态都实时扇出给所有粉丝否则写库就爆了。朋友圈则是“好友”模型对称好友关系而且微信限制单账号好友上限大约 5000 人。这就意味着任何一个用户发一条朋友圈最多只需要推给 5000 个人。这个量级对数据库来说完全可以接受。所以朋友圈可以大胆采用“写扩散 读扩散混合”的思路发朋友圈时把动态写入自己的发件箱Outbox并异步推送给在线的活跃好友的时间线对于不活跃的好友可以等他们上线或刷新时再拉取。我把这个差异总结成一张对比表方便你对照自己的业务场景对比维度微博时间线朋友圈时间线关系模型非对称关注粉丝量级差异大对称好友上限稳定核心瓶颈大 V 扇出压力读接口合并开销默认方案拉模式为主 大 V 特殊缓存推拉结合活跃好友走推典型存储Redis 大规模对象存储MySQL 本地缓存 Redis所以当你准备动手设计系统时第一件事不是画架构图而是拿产品经理的需求文档看清楚你的用户关系模型是什么样的。这个决定直接影响后续的存储选型和接口设计。2. 时间线的核心存储Redis ZSet 为什么是标配2.1 ZSet 的排序特性刚好命中 Feed 流场景确定推拉模式之后下一步就是解决“时间线数据存哪、怎么排序”的问题。我在项目里最常用也最推荐的就是Redis 的有序集合ZSet。原因很简单Feed 流的时间线本质是一个按时间排序的 ID 列表而 ZSet 的成员Member天然是去重的分值Score天然可以用来存时间戳。一条动态只有一个 ID不会重复按时间倒序排列直接用 ZSet 的分值排序就行。以推模式为例用户 B 的时间线在 Redis 里的 key 可以设计成feed:timeline:{userId}每次用户 A 发动态时系统执行ZADD feed:timeline:10001 1699999999 8001234567这条命令的意思是把动态 ID8001234567以时间戳1699999999为分值写入用户10001的时间线。当 B 刷新首页时只需要ZREVRANGE feed:timeline:10001 0 19就能拿到最近 20 条动态的 ID再根据 ID 批量查动态详情返回给客户端。这里有一个关键细节为什么用时间戳做分值而不是自增 ID因为 Feed 流系统通常是分布式的多个服务实例同时发动态如果用数据库自增 ID 做排序依据很难保证全局的时间顺序。而服务器时间戳是统一的虽然会有时钟漂移但 Redis 的ZADD命令在多实例写入时ZSet 内部是单线程处理的会按照实际到达顺序和分值综合排序基本能满足业务需求。2.2 推模式的完整写链路和失败兜底用 ZSet 存时间线写扩散时的完整流程是这样的用户 A 发动态先写入动态内容表拿到动态 ID。查询 A 的粉丝列表从关注关系表里查。把动态ID 时间戳批量写入每个粉丝的feed:timeline:{followerId}。如果粉丝数量巨大走消息队列异步扇出不让发动态的请求阻塞。第 4 步是重点。同步扇出在粉丝量几百的时候没问题但粉丝量过万之后一次发动态要写几万个 Redis Key网络开销和序列化开销会直接拖垮发动态接口。我第一版系统就是同步扇出结果有个账号粉丝 3 万发布一次动态接口耗时 4 秒多直接被运维找上门。后来改成异步扇出发动态接口只做两件事写动态内容 发一条 MQ 消息。后台消费者拉取消息后再分批把动态 ID 写入粉丝的时间线。实测下来发布接口耗时降到 200ms 以内粉丝时间线的更新延迟在 1-2 秒左右——这个延迟对普通用户来说是无感的。异步扇出还要考虑一个问题如果消息队列积压或者消费者挂了用户的动态可能很久不出现在粉丝首页。解决办法是给 MQ 加一个延迟重试机制消费失败的消息自动进入重试队列超过 3 次失败就记录日志人工介入。同时在拉时间线时加一个兜底逻辑如果某个粉丝的feed:timeline:{userId}不存在或数据量过少系统自动切换为拉模式直接去查 TA 关注的博主的最新动态补上。这个兜底我后面会详细讲。2.3 内存量估算别让 Redis 成为成本黑洞用 Redis 存时间线最怕的就是内存爆炸。朋友圈那种单用户上限 5000 好友的场景还好微博这种头部大 V 粉丝几千万的场景如果不做裁剪一个 ZSet 就能把 Redis 撑爆。我的实践经验是每个用户的时间线只保留最近的 500-1000 条动态 ID超过的部分直接丢弃。为什么可以这么做因为绝大多数用户刷新 Feed 流的次数是有限的超过 1000 条之后的内容基本不会被翻到。如果用户真的需要看很早以前的动态产品上通常会引导去“个人主页”里翻Feed 流失效没有任何问题。做一下内存估算。假设一个用户时间线保存 1000 条动态 ID每个成员 ID 约 8 字节用数字 ID分值加成员 ID 的 ZSet 整体开销大约在 10KB 左右。1000 万用户就是约 100GB 内存。这个量级在 Redis 集群里是可控的但你要是不裁剪保存 10 万条就是 1TB直接玩脱。除了裁剪还可以按活跃度分层活跃用户的时间线常驻 Redis冷用户的时间线不做推模式等他们上线时再实时拉取。这样可以把内存成本降低 50% 以上。判定活跃的标准可以定为“7 天内是否有登录行为”这是很常见的做法。3. 排序策略不只是时间产品想要什么样的首页3.1 纯时间排序的局限和改造方案最简单的 Feed 流排序就是纯时间倒序谁后发谁在上面。这个方案实现成本最低ZSet 天然支持但产品经理大概率不会满意。原因很多人应该都有体感你关注的人里如果有几个发动态特别勤的“刷屏党”他们发的内容会把你真正关心的内容挤出首页。微博很早就不是纯时间排序了朋友圈也通过“折叠”来处理这种现象。业界通用的改造方案是在时间戳上叠加权重因子。核心思路是动态的排序分值不直接用时间戳而是用时间戳 权重值其中权重值取决于发布者与当前用户的关系亲密度、动态本身的互动量转发、评论、点赞等。我在一个社区项目里用的排序公式很简单但效果不错score 发布时间戳 互动热度分 * 时间衰减系数互动热度分可以用点赞数 * 1 评论数 * 2 转发数 * 3来算时间衰减系数则是一个小于 1 的数字用来保证旧动态即使互动很高也会逐渐沉底。这里有个关键问题如果每次拉取时间线都实时计算所有候选动态的 score性能会非常差。我的做法是在写入 ZSet 时就计算好 score之后定期比如每 5 分钟用后台任务更新高分动态的 score。也就是说ZSet 里存储的 score 是已经融合了时间、热度的复合值。每次刷新首页时直接用ZREVRANGE拿前 N 个就行性能很高。3.2 热度分计算的实战经验热度分如果做得太复杂运营看不懂开发也难维护。我踩过的坑是搞了一个 10 多个指标加权的模型结果上线后产品经理自己都调不明白参数最后只能推倒重来。后来我学乖了热度分只用三个指标互动总量点赞、评论、转发、发布时间衰减、关注关系加权。互动总量可以直接从动态表里的计数字段读取每次点赞评论转发时用 RedisINCR异步更新。时间衰减函数我用的是对数衰减热度分 原始互动分 * 0.5 ^ (ageHours / 24)这个公式的含义是每过去 24 小时动态的热度贡献衰减一半。48 小时前的高互动内容不如 2 小时前的中等互动内容排得靠前。这个衰减速度对大多数社区产品来说比较合适。关注关系加权则取决于你和发布者的关系如果是互关好友权重乘 2如果只是单向关注权重乘 1如果是官方号或优质创作者再额外加一个运营权重。这些权重值都做成可配置的让运营和产品随时调整。热度分计算有一个容易忽略的问题权重调整后已经写入 ZSet 的旧 score 不会自动更新。所以需要定期给 ZSet 里的元素重算 score。我用的方案是每天凌晨跑一个批量任务把前一天的 Top 动态挑出来重新计算 score 并更新 ZSet。实测下来这个任务的执行频率不需要太高24 小时更新一次足够支撑产品需求。4. 读链路优化首页刷不出来再好的排序也白搭4.1 聚合逻辑和缓存层级设计用户刷新首页时后端要干的事情不止是查一个 ZSet。详细拆解一下一次 Feed 流请求的完整链路是从feed:timeline:{userId}拉取动态 ID 列表。根据动态 ID 批量查询动态详情。根据动态里的作者 ID 批量查询用户信息昵称、头像等。对结果做排序、去重、截断返回给客户端。第 2、3 步看起来简单实则是性能杀手。如果一次返回 20 条动态每条动态的发布者都不同那就涉及 20 次用户信息的查询。如果逐条查数据库一次请求会产生上百次 SQL数据库连接直接被打满。我的做法是全部走批量接口 多级缓存。缓存层级从上到下是本地缓存Caffeine / Guava Cache单机内存缓存用于热点动态和热点用户信息。分布式缓存Redis存储动态详情、用户信息的序列化数据。数据库MySQL / TiDB最终数据源缓存未命中时回源查询。以动态详情为例缓存 key 设计为feed:post:{postId}value 是动态内容的 JSON。查询时先查本地缓存不命中再查 Redis再不命中才回源数据库。数据库查询结果回填 RedisRedis 回填本地缓存。这套三级缓存的命中率在正常业务下动态详情能达到 95% 以上。因为 Feed 流的特点是热点集中——大部分用户看的都是少数头部内容本地缓存对热点的拦截效果特别明显。4.2 缓存穿透、击穿、雪崩的三重防护缓存这块如果不做防护线上事故分分钟教你做人。我重点说三个最常见的坑。缓存穿透查询一个不存在的动态 ID缓存里没有数据库里也没有每次请求都会打到数据库。刷接口的人如果恶意构造一批不存在的 ID数据库直接跪。解决办法是布隆过滤器把所有存在的动态 ID 都加入布隆过滤器请求进来先用布隆过滤器判断不存在直接返回空不打数据库。缓存击穿某个热点动态的缓存突然过期同一时刻大量请求同时回源数据库。解决办法很简单查询的时候加互斥锁同一个 key 只有一个请求能回源数据库其他请求等锁后直接读缓存。缓存雪崩大量 key 在同一时间过期导致数据库压力暴增。解决办法是给缓存过期时间加一个随机偏差比如基础过期时间 1 小时实际过期时间在 50-70 分钟之间随机生成。Redis 中批量设置的 key 就不会在同一时刻集体失效。这三个问题我建议在系统上线前就做好。不要觉得量小不需要我用亲身经历告诉你缓存穿透只需要一个恶意脚本一分钟就能把数据库打挂。提前防护的成本很低上线后的代价却很高。4.3 降级策略让系统在异常下“烂而不死”Feed 流系统的高可用不是保证每个请求都成功而是在部分组件异常时系统不会整体挂掉。我在生产环境验证过的降级方案如下Redis 挂了怎么办降级为“直查数据库 本地缓存”。虽然性能下降但核心功能还能用。数据库慢查询严重怎么办开启 Feed 流只读模式首页返回本地缓存中的数据动态详情若缓存失效则返回简版信息。大 V 动态扇出堆积怎么办临时把大 V 的粉丝扇出策略改为“拉模式”也就是说他们的新动态不再主动推给所有粉丝粉丝刷新时实时去大 V 主页拉最新动态。这里最容易被忽略的是大 V 的动态扇出降级。很多团队在正常流程里没有做这层保障遇到大 V 发爆款内容导致消息队列积压时只能干瞪眼。我在设计系统时会把所有准备发布期间会产生的异常场景列一个清单每个场景都制定对应的降级预案。5. 消息队列和异步化解耦扇出压力的关键手段5.1 为什么必须用消息队列做异步扇出前面提到了异步扇出这里展开讲一下它在整个系统架构中的位置。Feed 流系统中写扩散的扇出操作天然适合异步化。也就是说用户发动态这个动作不需要等待所有粉丝的时间线都更新完成才返回成功。我的设计是用 Kafka 或者 RocketMQ 作为消息队列扇出逻辑在消费者端执行。具体流程如下用户发布动态动态写入数据库后立刻发送一条 MQ 消息内容包含动态 ID 和发布者 ID。发布接口直接返回成功用户无感知。后台消费者收到消息后查询发布者的粉丝 ID 列表。将动态 ID 批量写入每个粉丝的 ZSet 时间线。这套设计的核心价值是把“用户等待时间”和“后台处理时间”彻底解耦。哪怕粉丝数量是 1000 万用户发动态的体验也不会受影响因为扇出是在后台慢慢做的。5.2 消息积压的监控和应对消息队列用得不好最常见的坑就是消息积压。大 V 凌晨三点发一条动态粉丝 3000 万如果消费者处理速度跟不上早上八点用户刷首页时这条动态还没进时间线那就是事故。我做过的有效应对措施有这几个监控告警对 MQ 的消费延迟做监控延迟超过阈值比如 30 秒自动报警。这个必须做不能等用户反馈。消费者扩容消费者服务支持横向扩容积压时可以快速加机器。关键点是把扇出任务拆分成可并行处理的小批次比如每批处理 1000 个粉丝多个消费者同时消费不同的批次。限流降级如果 MQ 积压到一定程度主动丢弃非核心任务比如普通用户的扇出优先保证大 V 和活跃粉丝的扇出。等积压缓解后再通过兜底逻辑把漏掉的动态补上。我自己在项目里的经验是不要过度设计。消息积压处理的核心是把“可丢弃”和“不可丢弃”的任务分清楚。粉丝时间线的短暂缺失是可以接受的用户下次刷新时会通过兜底逻辑补上但动态内容本身不能丢这个是必须保证消息不丢失的。6. 拉模式的补充手段当推模式管不了所有场景6.1 大 V 动态和冷启动用户的特殊处理前面说到推模式在粉丝量巨大时会遇到瓶颈这里具体说一下大 V 动态的解决方案。我的常用做法是“大 V 不走粉丝时间线写入而是加入一个全局热榜 ZSet”。具体来说系统维护一个全局 ZSetkey 是feed:hot。大 V 发动态时动态 ID 写入全局 ZSet不扇出到粉丝时间线。普通用户的 Feed 流刷新时除了查自己的时间线 ZSet还会额外查feed:hot并把其中与自己关注关系匹配的动态合并进去。这个方案的好处是大 V 的动态只需要写一个 ZSet不管粉丝是 1000 万还是 1 个亿写开销都是常数。坏处是普通用户刷新时需要多查一次全局热榜但这个请求可以通过缓存优化成本很低。冷启动用户新注册用户或者长时间未登录用户也是拉模式的重点处理对象。新用户关注的人很少如果走推模式他们的时间线几乎是空的。我的方案是新用户的前 50 条动态直接从全局内容池里拉取并根据他们关注的话题、兴趣标签做粗略推荐。等他们的关注关系建立起来之后再慢慢切回常规的推拉模式。6.2 兜底合并推拉结合的最终细节推拉结合的模式下用户刷新首页时数据的最终构成是三个部分自己的时间线 ZSet推模式写入的内容主要是普通关注对象的动态。全局热榜 ZSet大 V 的最新动态。关注列表实时检查对于活跃关注对象如果他们的最新动态 ID 大于时间线里已有的最大值则实时补充拉取。这个兜底合并逻辑是在接口层做的代码量不大但能解决两个很实际的问题一是异步扇出延迟导致的新动态没及时出现在时间线里二是大 V 动态没扇出导致的内容缺失。实现上我用的方式是每次刷新首页时除了查时间线 ZSet再并行查一下关注列表里 Top 20 活跃用户各自的最新动态 ID取并集后按 score 重新排序。这个过程因为只查最新 ID不走数据库详情所以开销很小实测增加耗时不超过 10ms。7. 从微博和朋友圈延伸到通用 Feed 流平台7.1 不同内容形态的 Feed 流差异聊到这里Feed 流系统的核心设计基本都覆盖了。最后想提一下内容形态对系统的影响因为很多人会把“图文 Feed 流”和“视频 Feed 流”混为一谈。视频 Feed 流比如抖音、快手的信息流和图文 Feed 流微博、朋友圈虽然从系统架构上大同小异但有一个关键差异视频内容本身不能直接放在关系链的时间线里需要考虑 CDN 分发、播放鉴权、首帧优化、预加载策略等。如果你们的业务是设计一个视频 Feed 流除了上面说的推拉模式还要在详情层做视频元信息封面、播放地址、时长的缓存确保列表接口不直接返回视频流只返回元信息由客户端按需播放。图文 Feed 流则更依赖动态详情的缓存和列表接口的拼接速度因为图文的渲染成本远低于视频瓶颈在服务端的数据聚合。电商类的 Feed 流比如商品推荐信息流又不太一样它需要做个性化排序每个用户的向量不同ZSet 只能做粗排精排要交给推荐系统。7.2 设计 Feed 流系统的取舍原则最后分享一个我在多个项目里反复验证的认知Feed 流系统没有完美的方案只有适应当前业务阶段的方案。刚开始做社区产品时日活几千粉丝关系简单直接用 MySQL 存时间线、发动态时实时插入粉丝列表完全没问题。不要一开始就上 Kafka、Redis Cluster、分库分表那是给自己找麻烦。当用户量增长到需要优化时分阶段演进日活 1 万以内MySQL 存关系链和时间线单机 Redis 做缓存足够。日活 10 万级引入 Redis ZSet 存时间线推模式为主异步扇出走 MQ。日活 100 万级大 V 独立处理全局热榜 推拉结合消息队列做大规模扇出。日活千万级多级缓存、冷热分离、分片集群、智能降级每个环节都要精雕细琢。我的体会是与其一开始就照搬微博的完整架构不如先把最简单的方案跑起来让业务验证需求再逐步演进。很多团队死在过度设计上而不是死在架构不够先进上。用户量上来之后你会发现 Feed 流系统的复杂度不在于某个单点技术而在于各个组件之间的配合和异常处理。能把推拉模式切换、缓存降级、消息积压处理这些细节做扎实你的 Feed 流系统就已经超越了绝大多数同行。