外观
从全推到推拉结合:大 V 十万粉丝收件箱裁剪实录
问题背景:大 V 一发博,为什么先打爆 Redis
Feed 流系统最经典的矛盾,是写扩散与读扩散的成本不对称。普通用户发一条微博,如果采用纯推模式,系统需要把消息 ID 写入每个粉丝的收件箱;一个百万粉丝大 V 单次发博,就可能产生百万次 Redis List 或 ZSet 写入。热点账号集中发博时,CPU、网络缓冲区和主从复制都会被瞬间放大。
纯拉模式则把成本转移到读路径:用户刷新 Feed 时,需要实时查询所有关注人的发件箱,再做归并排序。用户关注几百人时,请求延迟明显升高,缓存命中率也不稳定。
因此,生产环境通常采用推拉结合:普通用户仍以写扩散为主,保证读取简单;大 V 不向全部粉丝主动投递,只维护发件箱,粉丝读取时再把大 V 内容合并进自己的 Feed。为了进一步降低 inactive 用户的无效写入,还可以只给活跃粉丝维护收件箱,实现十万、百万粉丝量级下的收件箱裁剪。
三种 Feed 方案对比:推、拉、推拉结合
| 方案 | 写入动作 | 读取动作 | 适用问题 |
|---|---|---|---|
| 纯推模式 | 作者发博后写入每个粉丝收件箱 | 直接读个人收件箱 | 大 V 写扩散严重 |
| 纯拉模式 | 只写作者发件箱 | 拉取所有关注人发件箱并归并 | 关注多时读放大严重 |
| 推拉结合 | 普通用户推活跃粉丝,大 V 只写发件箱 | 读收件箱并实时合并大 V 发件箱 | 兼顾延迟、成本和热点 |
推拉结合的关键不是简单地“大 V 拉、普通用户推”,而是要同时定义四件事:如何判定大 V、哪些粉丝有收件箱、读路径如何合并、冷用户回来后如何补齐。
Redis 关键数据结构设计
推荐使用 ZSet 保存 Feed 条目,而不是 List。ZSet 的 member 是帖子 ID,score 是发布时间戳或内部自增版本号,天然支持按时间倒序分页。
feed:outbox:{userId}:作者发件箱,保存用户最近发布的帖子 ID。feed:inbox:{userId}:用户收件箱,保存普通关注人的投递内容。relation:following:{userId}:用户关注集合,使用 Set 存储。relation:fans:{userId}:作者粉丝集合,使用 Set 存储。vip:authors:大 V 账号集合,使用 Set 存储,也可替换为带粉丝数的 ZSet。user:active:近期活跃用户集合,可用带过期时间戳的 ZSet 表示。
下面的命令可以直接在 redis-cli 中执行,用来构造一个最小可运行示例。
bash
# 1. 构造关系:用户 1001 关注普通作者 2001 和大 V 3001
SADD relation:following:1001 2001 3001
SADD relation:fans:2001 1001 1002 1003
SADD relation:fans:3001 1001 1002 1003 1004 1005
# 2. 标记大 V;实际系统可由粉丝数阈值、阅读量或人工策略决定
SADD vip:authors 3001
# 3. 标记活跃用户,score 可以是最近访问时间戳
ZADD user:active 1710000100 1001 1710000200 1002 1710000300 1003
# 4. 普通作者 2001 发帖 9001,先写自己的发件箱
ZADD feed:outbox:2001 1710001000 9001
# 5. 仅向活跃粉丝收件箱投递,避免给长期不登录用户制造无效写放大
ZADD feed:inbox:1001 1710001000 9001
ZADD feed:inbox:1002 1710001000 9001
ZADD feed:inbox:1003 1710001000 9001
# 6. 大 V 3001 发帖 9002,只写发件箱,不遍历十万粉丝
ZADD feed:outbox:3001 1710001200 9002
# 7. 每个发件箱和收件箱只保留最近 1000 条,防止冷数据无限增长
ZREMRANGEBYRANK feed:outbox:3001 0 -1001
ZREMRANGEBYRANK feed:inbox:1001 0 -1001这里的 ZREMRANGEBYRANK key 0 -1001 表示保留 score 最大的 1000 个成员。ZSet 默认按 score 从低到高排列,删除排名 0 到倒数第 1001 个之前的数据,就等于裁剪旧内容。
发博路径:普通作者推活跃粉丝,大 V 只落发件箱
服务端发博时先写帖子详情缓存或数据库,再执行 Feed 分发。核心判断逻辑如下伪代码:
bash
# 发布成功后统一写作者发件箱
# ZADD feed:outbox:{authorId} {publishTime} {postId}
# 如果作者不是大 V,则取“粉丝集合”和“活跃用户集合”的交集
# 只给近 7 天或近 30 天活跃粉丝投递
ZINTERSTORE tmp:active:fans:2001 2 relation:fans:2001 user:active AGGREGATE MAX
# 查看需要投递的活跃粉丝
ZRANGE tmp:active:fans:2001 0 -1 WITHSCORES
# 实际系统中不建议一次性全量处理,应分批扫描并投递
# 每批 500 到 2000 个用户,配合消息队列重试和限速
DEL tmp:active:fans:2001需要注意,relation:fans 如果使用 Set,而 user:active 使用 ZSet,交集结果可以落到临时 ZSet;但更直接的工程做法是粉丝关系也维护为 ZSet 或在应用层分批拉取后过滤。无论采用哪种实现,目标都是避免 SMEMBERS 一次性取出十万粉丝造成 Redis 阻塞。
大 V 的写路径应保持极短:
bash
# 大 V 发博:写详情、写发件箱、发布失效或通知事件即可
SET post:9002 '{"authorId":3001,"content":"hello feed","createdAt":1710001200}'
ZADD feed:outbox:3001 1710001200 9002
ZREMRANGEBYRANK feed:outbox:3001 0 -1001
PUBLISH feed:vip:new 3001:9002:1710001200读取路径:收件箱与大 V 发件箱实时归并
用户读取 Feed 时,先查个人收件箱,再查询自己关注的大 V 发件箱,最后按时间倒序合并。为了控制读放大,不应每次请求都遍历全部关注关系,而是维护用户关注的大 V 列表。
bash
# 1. 查询用户关注的大 V,建议由关系变更时异步维护
SADD relation:following_vip:1001 3001
# 2. 读取个人收件箱第一页,例如 20 条
ZREVRANGE feed:inbox:1001 0 19 WITHSCORES
# 3. 读取大 V 发件箱第一页
ZREVRANGE feed:outbox:3001 0 19 WITHSCORES
# 4. 如果关注多个大 V,可使用 ZUNIONSTORE 做临时归并
ZUNIONSTORE tmp:feed:1001 2 feed:inbox:1001 feed:outbox:3001 AGGREGATE MAX
ZREVRANGE tmp:feed:1001 0 19 WITHSCORES
DEL tmp:feed:1001临时 key 方案适合演示,不适合高并发线上直接使用。线上更推荐在应用层并发请求多个 ZSet,然后做多路归并;或者使用 Lua 脚本一次性完成读取,保证逻辑原子并减少网络往返。
bash
# EVAL 脚本示例:读取收件箱和一个大 V 发件箱,实际可传入多个大 V key
EVAL "local inbox = redis.call('ZREVRANGE', KEYS[1], 0, 19, 'WITHSCORES'); local vip = redis.call('ZREVRANGE', KEYS[2], 0, 19, 'WITHSCORES'); return {inbox, vip};" 2 feed:inbox:1001 feed:outbox:3001应用层拿到帖子 ID 后,再通过 Pipeline 批量获取帖子详情:
bash
# 批量取帖子详情,避免逐条网络往返
MGET post:9001 post:9002冷用户回访:异步回填而不是登录瞬间补偿
收件箱裁剪后,长期不活跃用户的收件箱可能缺失历史内容。此时不应在登录请求中同步补偿几周甚至几个月的 Feed,否则一次登录会触发大量 ZADD,形成新的突发流量。
更稳妥的方式是:登录后先读大 V 发件箱和仍保留的收件箱,快速返回第一屏;同时发送回填消息,由离线任务按时间片补齐普通关注人的近期内容。回填必须带边界,例如只补最近 7 天、最多 500 条,并在完成后更新用户活跃时间。
bash
# 用户回访,先进入活跃集合
ZADD user:active 1710002000 1004
# 应用发送异步回填事件:queue:feed-backfill -> {"userId":1004,"days":7,"limit":500}
# 消费者按批次写入 feed:inbox:1004,每批后短暂 sleep 或受令牌桶限速
# 回填完成后同样裁剪收件箱
ZREMRANGEBYRANK feed:inbox:1004 0 -501避坑总结:三个高频故障与修正方法
1. 使用 SMEMBERS 遍历十万粉丝,Redis 出现慢查询
错误现象是大 V 发博后 Redis 延迟突增,慢日志中出现对 relation:fans:{vipId} 的 SMEMBERS,应用线程池也被批量投递占满。修正方法是使用 SSCAN 分批迭代,或将粉丝分片存储为 relation:fans:{userId}:{shard},再通过消息队列异步投递。大 V 本身不应进入全量推链路。
2. 只裁剪粉丝数量,不裁剪 ZSet 长度
有些系统识别了大 V,却忘记限制发件箱和收件箱长度,运行几个月后单个 ZSet 达到数十万成员,ZREVRANGE 分页虽然还能工作,但内存和主从复制成本持续上升。应在每次写入后执行 ZREMRANGEBYRANK,或由定时任务统一裁剪;普通收件箱保留 500 到 1000 条通常足够,具体按首屏和翻页策略决定。
3. 分页在实时归并中重复或丢帖
错误现象是用户下拉刷新时看到重复内容,或者翻第二页时漏掉新帖。根因通常是 offset 分页基于不断变化的 ZSet,第一页与第二页之间有新内容插入。修正方法是使用游标分页:客户端记录上一页最小 score,下次查询 ZREVRANGEBYSCORE key (cursor -inf LIMIT 0 20,并在多源归并后按 postId 去重。
4. 大 V 判定来回抖动,导致同一帖子重复投递
如果作者粉丝数在阈值附近波动,一会儿按普通用户推,一会儿按大 V 拉,读路径可能重复看到同一帖子。修正方法是给大 V 标识增加滞后区间和最短生效时间,例如粉丝数超过 10 万进入大 V,降到 8 万以下才退出;合并结果再用帖子 ID 去重。
5. 异步投递无幂等控制,重试后收件箱出现脏顺序
消息队列重试可能重复执行 ZADD。以帖子 ID 为 member 时,重复执行不会产生重复成员,但如果使用了随机 member 或把业务状态拼进 member,就可能重复。应固定 member=postId、score=publishTime,并为消费者设计幂等键,禁止用本地执行时间作为 score。
结语
Feed 流推拉结合的本质,是把昂贵的全量写扩散改造成“普通作者轻推、大 V 读时合并、活跃用户才维护收件箱”。Redis 在其中承担的是高频时间线索引,而不是无限期存储库。只要控制好粉丝遍历方式、ZSet 长度、分页游标和异步回填,十万甚至百万粉丝账号的发博峰值,都可以被裁剪成稳定可预测的流量。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。