目录
[1. Feed流业务场景与模式选择](#1. Feed流业务场景与模式选择)
[1.1 Timeline(基于时间线)](#1.1 Timeline(基于时间线))
[1.2 智能排序(基于算法推荐)](#1.2 智能排序(基于算法推荐))
[2. Timeline模式的核心挑战](#2. Timeline模式的核心挑战)
[3. 三种实现方案](#3. 三种实现方案)
[3.1 拉模式(读扩散)](#3.1 拉模式(读扩散))
[3.2 推模式(写扩散)编辑](#3.2 推模式(写扩散)编辑)
[3.3 推拉结合模式(混合读写)](#3.3 推拉结合模式(混合读写))
[4. 方案对比总结](#4. 方案对比总结)
[Redis 滚动分页查询](#Redis 滚动分页查询)
[1.初识有序集合与 ZREVRANGEBYSCORE](#1.初识有序集合与 ZREVRANGEBYSCORE)
[2.1 传统分页的局限](#2.1 传统分页的局限)
[2.2 滚动分页(Cursor-based Pagination)的优势](#2.2 滚动分页(Cursor-based Pagination)的优势)
[3.1 为什么用时间戳作为分数?](#3.1 为什么用时间戳作为分数?)
[3.2 处理分数重复的关键:offset 的妙用](#3.2 处理分数重复的关键:offset 的妙用)
[3.3 参数变化举例](#3.3 参数变化举例)
[4.1 分数范围与 LIMIT 的协作](#4.1 分数范围与 LIMIT 的协作)
[4.2 为何 min 固定为 0?](#4.2 为何 min 固定为 0?)
[4.3 处理空结果和结束条件](#4.3 处理空结果和结束条件)
1. Feed流业务场景与模式选择
在社交类应用中,Feed流(信息流)是核心功能。用户通过关注关系获取动态,系统需要高效地将发布的内容分发到粉丝的收件箱。常见的Feed流模式有两种:
1.1 Timeline(基于时间线)
-
原理:严格按内容发布时间排序,不做智能筛选,常见于朋友圈、关注页。
-
优点:数据全面,无信息遗漏;实现逻辑简单,用户可掌控信息获取节奏。
-
缺点:信息噪音大,用户可能错过感兴趣内容;随着关注量增加,阅读效率降低。
1.2 智能排序(基于算法推荐)
-
原理:通过算法(如协同过滤、CTR预估)过滤不感兴趣内容,推送高价值信息,如抖音、快手推荐页。
-
优点:用户粘性高,容易沉浸;可挖掘长尾内容。
-
缺点:算法不精准会引发用户反感;技术复杂度高,需大量数据训练。
本例场景:个人页面的关注动态流,基于用户主动关注的博主,需确保内容完整性,因此采用Timeline模式。下面重点剖析Timeline的三种实现方案。
2. Timeline模式的核心挑战
在Timeline模式下,核心挑战是数据的高效分发与读取。假设用户A关注了用户B,当B发布一条动态时,如何让A快速看到?系统需要处理两个核心操作:
-
写扩散:发布时,将内容推送给粉丝。
-
读扩散:读取时,实时聚合关注列表的内容。
不同的实现方案,本质是权衡写操作与读操作的复杂度,以及数据一致性、存储成本等因素。
3. 三种实现方案
3.1 拉模式(读扩散)

基本原理
-
每个用户维护自己的发件箱(Outbox),发布的内容写入自己的发件箱。
-
粉丝读取Feed时,实时拉取所有关注用户的发件箱,合并后按时间排序返回。
工作流程
-
发布:用户B发布动态 → 写入B的发件箱(如Redis的ZSet,score为时间戳)。
-
读取:用户A刷新Feed → 查询A关注的所有用户ID列表 → 并行拉取这些用户的发件箱数据 → 内存合并排序 → 返回给A。
优点
-
存储成本低:每条内容只存一份,无需为每个粉丝复制。
-
写操作轻量:发布时仅需写入自己的发件箱,复杂度O(1)。
缺点
-
读操作重:关注越多,拉取、合并开销越大(N次网络IO + 归并排序),延迟随关注数线性增长。
-
热点问题:大V发布时,大量粉丝同时读请求会打爆大V的发件箱(虽然读的是缓存,但并发极高)。
适用场景
-
用户关注数有限(如朋友圈,好友上限5000)。
-
读请求量不大,对实时性要求不那么极致(可接受秒级延迟)。
技术要点
-
使用Redis ZSet存储发件箱,按时间戳排序。
-
合并排序可采用多路归并算法,避免全量排序内存溢出。
-
可配合本地缓存降低重复合并开销(但需注意一致性)。
3.2 推模式(写扩散)
基本原理
-
每个用户维护自己的收件箱(Inbox)。
-
发布时,系统将内容推送给所有粉丝,写入粉丝的收件箱。
-
粉丝读取时,直接从自己的收件箱拉取,无需合并。
工作流程
-
发布:用户B发布动态 → 查询B的所有粉丝列表 → 将动态写入每个粉丝的收件箱(如Redis List/ZSet)。
-
读取:用户A刷新Feed → 直接从A的收件箱获取内容(按时间倒序)。
优点
-
读操作极轻:O(1)读取,完美应对高频刷新。
-
实时性好:内容预先聚合,读取无延迟。
缺点
-
写操作重:粉丝越多,写放大越严重(大V发布需写入百万级收件箱),可能导致发布延迟甚至失败。
-
存储成本高:每一条内容需要为每个粉丝存储一份(粉丝数 * 内容数),浪费空间。
-
删除/更新复杂:若内容被删除,需遍历所有粉丝的收件箱删除,代价巨大。
适用场景
-
粉丝数有限(如企业内部系统)。
-
读多写少,且发布者粉丝数可控。
技术要点
-
异步写扩散:发布请求先写发件箱,再通过消息队列异步推送给粉丝,降低发布延迟。
-
收件箱可选用Redis的Sorted Set,按时间戳排序,并设置过期时间或容量限制(如只保留最近1000条)。
-
对于大V,需特殊处理(如限流、只推活跃粉丝)。
3.3 推拉结合模式(混合读写)
基本原理
-
结合推和拉的优势,将用户分为两类:普通用户(推模式)和大V用户(拉模式)。
-
普通用户发布时,推送给所有粉丝;大V发布时,只推送给在线/活跃粉丝,其余粉丝读取时采用拉模式。
-
或者更通用的做法:推送给最近活跃的粉丝,对不活跃粉丝采用拉模式。
工作流程
-
发布:用户B发布动态。
-
若B是普通用户 → 推送给所有粉丝。
-
若B是大V → 推送给活跃粉丝(近期登录的粉丝),不活跃粉丝则采用拉模式。
-
-
读取:用户A刷新Feed。
-
先读取自己的收件箱(推模式内容)。
-
再读取关注列表中的大V发件箱,合并拉取的内容。
-
两部分合并排序后返回。
-
优点
-
平衡读写压力:减轻大V写扩散的压力,同时保证大部分用户读性能。
-
灵活性高:可动态调整策略(如根据粉丝活跃度、网络状况)。
-
存储与成本折中:相比纯推模式节省大量存储,相比纯拉模式提升读性能。
缺点
-
实现复杂:需要区分用户类型、维护粉丝活跃状态、处理合并逻辑。
-
一致性挑战:推和拉的数据可能重复或遗漏,需设计去重和补偿机制。
适用场景
-
大型社交平台(如微博、Twitter),存在大量普通用户和少量大V。
-
对实时性要求高,且需要控制成本。
技术要点
-
活跃度判定:基于用户最近登录时间、在线状态等,可定期更新。
-
收件箱容量限制:为防止无限膨胀,可设置收件箱上限(如只存最近500条),超限则淘汰。
-
合并拉取优化:使用多线程并发拉取大V发件箱,配合本地缓存减少重复拉取。
-
推拉比例调优:根据业务数据动态调整推拉阈值(例如粉丝数超过10万则采用拉模式)。
4. 方案对比总结
|------|--------|--------|------------|
| 维度 | 拉模式 | 推模式 | 推拉结合 |
| 写复杂度 | O(1) | O(粉丝数) | O(活跃粉丝数) |
| 读复杂度 | O(关注数) | O(1) | O(1 + 大V数) |
| 存储成本 | 低 | 高 | 中 |
| 实时性 | 依赖合并性能 | 高 | 高 |
| 适用场景 | 好友少、读少 | 粉丝少、读多 | 大V存在、读写均衡 |
| 实现难度 | 易 | 中 | 高 |
5.案例实现(推模式)

在博主发布博客后,将把博客推送给他的粉丝,而他的粉丝有多个关注博主,在粉丝读收件箱的推送时要进行分页读取。
java
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
Long userId = UserHolder.getUser().getId();
//查询收件箱 ZREVRANGEBYSCORE key max min LIMIT offset count
String key = RedisConstants.FEED_KEY + userId;
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
if (typedTuples==null||typedTuples.isEmpty()){
return Result.ok();
}
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0;
int os=1;
//解析数据:blogId,minTime(时间戳),offset
for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples){
ids.add(Long.valueOf(typedTuple.getValue()));
//获取分数(时间戳)
long Time = typedTuple.getScore().longValue();
if (Time == minTime){
os++;
}else {
minTime = Time;
os = 1;
}
}
String idstr= StrUtil.join(",", ids);
List<Blog> blogs = query().in("id", ids)
.last("ORDER BY FIELD(id,"+idstr+")").list();
for (Blog blog : blogs){
isBlogLiked(blog);
}
ScrollResult scrollResult = new ScrollResult();
scrollResult.setList(blogs);
scrollResult.setOffset(os);
scrollResult.setMinTime(minTime);
return Result.ok(scrollResult);
}
Redis 滚动分页查询
1.初识有序集合与 ZREVRANGEBYSCORE
Redis 的 有序集合(Sorted Set) 是一种既存储元素(member)又为每个元素关联一个分数(score)的数据结构,集合内的元素按分数从小到大的顺序排列。常用场景包括排行榜、时间轴、带权重的任务队列等。
ZREVRANGEBYSCORE 命令用于按分数从大到小(降序)返回有序集合中指定分数范围内的元素。其基本语法如下:
ZREVRANGEBYSCORE key max min [WITHSCORES] [LIMIT offset count]
-
key:有序集合的键名。 -
max和min:分数范围,返回分数在[min, max]之间的元素(注意ZREVRANGEBYSCORE是先写 max 后写 min,表示从高分到低分)。 -
WITHSCORES:可选,返回元素时同时返回分数。 -
LIMIT offset count:可选,分页参数,从结果中跳过offset个元素,返回最多count个元素。
例如,获取分数在 100 到 200 之间的前 3 个最高分元素:
java
ZREVRANGEBYSCORE myzset 200 100 LIMIT 0 3
2.滚动分页的需求与挑战
2.1 传统分页的局限
在 Web 应用中,常用 LIMIT offset count 进行分页。但对于有序集合,如果使用 ZREVRANGEBYSCORE 配合固定的 offset 分页,会存在两个问题:
-
性能问题:
offset越大,Redis 需要跳过的元素越多,复杂度为 O(log(N) + offset),当 offset 很大时效率降低。 -
数据不一致:如果在两次查询之间集合发生了变化(新增或删除了元素),传统分页可能导致数据重复或遗漏。
2.2 滚动分页(Cursor-based Pagination)的优势
滚动分页不依赖固定的 offset,而是基于上一页最后一条记录的分数作为下一页的查询起点,从而避免重复和遗漏,且查询效率稳定。这种方法特别适合按时间倒序排列的场景(例如新闻列表、消息流),通常用时间戳作为分数。
3.滚动分页的参数设计解析
3.1 为什么用时间戳作为分数?
将元素的发布时间或更新时间作为分数,按时间倒序排列,最新的元素分数最高。这样:
-
第一页查询:
max = 当前时间戳,min = 0,取所有时间 ≤ 现在的最新记录。 -
后续页查询:以上一页的最后一条记录的时间戳作为新的
max,保证只取比该时间更早的记录。
3.2 处理分数重复的关键:offset 的妙用
如果多个元素具有相同的分数(例如同一秒发布的文章),仅仅用分数作为游标会带来问题:
-
如果下一页直接用上一页的最小分数作为
max,那么这些同分数的元素会在下一页被重复返回(因为分数相同,它们既属于上一页,也符合下一页的范围)。 -
如果直接跳过整个分数,则可能漏掉一部分同分数的元素。
解决方案:记录上一页中与最小分数相同的元素个数,并作为下一页查询的 offset 参数。这样,下一页会跳过这些已取过的同分数元素,从下一个不同分数的元素开始返回。
3.3 参数变化举例
假设有序集合 news:timeline 中有以下数据(按时间倒序排列):
|--------|------------|--------|
| member | score(时间戳) | |
| A | 1700000003 | |
| B | 1700000002 | |
| C | 1700000002 | ← 分数重复 |
| D | 1700000001 | |
| E | 1700000000 | |
第一页(每页 2 条):
-
max = 当前时间戳(假设为 1700000003),min = 0,offset = 0,count = 2 -
结果:
[A, B](按分数降序,同分数内按字典序) -
此时最小分数为 1700000002,且这一页中该分数出现了 1 次(只有 B,注意 B 和 C 分数相同但 B 先被返回)。
第二页:
-
max = 上一页的最小分数 = 1700000002 -
offset = 上一页中最小分数出现的次数 = 1 -
count = 2 -
命令:
ZREVRANGEBYSCORE news:timeline 1700000002 0 LIMIT 1 2 -
执行逻辑:在分数 [0, 1700000002] 范围内,按降序取,跳过 1 个元素,然后取 2 个。
-
跳过谁?分数 1700000002 的第一个元素(即 B)被跳过,从该分数的下一个元素(C)开始取。
-
结果:
[C, D](C 是 1700000002,D 是 1700000001) -
记录这一页的最小分数 1700000001,且该分数出现次数为 1(只有 D)。
第三页:
-
max = 1700000001,offset = 1,count = 2 -
结果:
[E](因为后面只有 E 了),可能不足 count。
4.深入理解命令的执行细节
4.1 分数范围与 LIMIT 的协作
ZREVRANGEBYSCORE 先根据分数范围筛选出一个有序子集(降序),然后应用 LIMIT offset count 进行截断。整个过程都在 Redis 服务端完成,高效稳定。
4.2 为何 min 固定为 0?
通常时间戳为正整数,且不会小于 0,所以 min = 0 可以涵盖所有历史数据。如果你的数据中分数可能为负,可以调整,但一般场景 0 足够。
4.3 处理空结果和结束条件
-
当某次查询结果不足
count条时,说明已到末尾(没有更早的数据)。 -
如果某次查询结果为空,则应停止滚动。
5.实际应用中的注意事项
1.命令版本:ZREVRANGEBYSCORE 在 Redis 6.2.0 之后被标记为废弃,官方推荐使用 ZRANGEBYSCORE 并加上 REV 参数,例如:
ZRANGEBYSCORE key min max REV LIMIT offset count
原理完全相同,只是参数顺序和关键字略有调整。
2.并发修改:如果在滚动过程中集合发生变化,可能会导致某一页的 offset 失效。例如,上一页的最小分数在下一页查询前被删除了,此时 offset 的意义会变化。但通常滚动分页用于静态或只追加的数据集(如新闻流),问题不大。
3.性能分析:每次查询的时间复杂度为 O(log(N) + M),其中 M 为跳过和返回的元素总数,无论翻多少页都能保持稳定,不会随着页数增加而变慢。
4.WITHSCORES 选项:在实现滚动分页时,通常需要返回分数,以便客户端记录下一页所需的 max 和 offset。因此建议加上 WITHSCORES 参数。