Redis --- 使用Feed流实现社交平台的新闻流
要实现一个 Feed 流(类似于社交媒体中的新闻流),通常涉及以下几个要素:
- 内容发布:用户发布内容(例如文章、状态更新、图片等)。
- 内容订阅:用户可以订阅其他用户的内容,获取实时更新。
- 内容展示:根据用户订阅的内容,将符合其兴趣或权限的内容按时间顺序展示出来。
- 实时更新:当有新的内容发布时,相关用户的 feed 流应该即时更新。
在社交平台、新闻流或类似应用中,Timeline(时间线)和智能排序是两个非常关键的功能。它们决定了用户在页面中看到的内容排序方式,直接影响用户体验。下面我将详细解释这两个概念,并且提供一些思路来实现它们。
在分布式系统和消息队列中,拉模式(Pull)和推模式(Push)是两种常见的数据传输方式。它们在不同的场景下有不同的应用。推拉结合模式(Push-Pull)结合了这两者的优点,能够更好地应对复杂的业务需求。
- 拉模式是指消费者主动向服务端请求数据,服务端在接收到请求时返回数据。消费者控制请求的时机和频率。
- 推模式是指服务端主动将数据推送到消费者,消费者不需要发起请求,只需要接收数据。
- 推拉结合模式结合了推模式和拉模式的优点,消费者既可以主动拉取数据,也可以被服务器主动推送数据。通过这种模式,系统可以根据不同的场景灵活地选择推送或拉取方式,提升系统的性能和可靠性。
另外,Feed流不能采用传统的分页模式:
所以采用滚动分页:
在 Redis 中,ZSET
(有序集合)是一个非常常用的数据结构,它可以用来存储带有分数的元素,并按分数进行排序。分页查询是获取ZSET
部分元素的一种方式,通常通过 ZRANGE
或 ZREVRANGE
命令来实现。
在分页查询时,主要的目的是限制返回的数据量,并且支持通过“偏移量”(offset)和“数量”(limit)来控制分页的效果。Redis 的 ZSET
本身并不直接支持传统数据库那种基于页码的分页,但可以通过索引和 ZRANGE
命令来实现分页效果。
分页查询原理:
假设我们有一个存储博客点赞信息的
ZSET
,其中每个博客的 ID 和点赞数是按分数score存储的。我们可以使用ZRANGE
(或ZREVRANGE
)命令来返回指定区间内的元素。基本操作:
ZRANGE key start stop [WITHSCORES]
:按分数升序返回ZSET
中从start
到stop
索引范围内的元素。WITHSCORES
可选,表示返回元素的分数。ZREVRANGE key start stop [WITHSCORES]
:按分数降序返回ZSET
中从start
到stop
索引范围内的元素。分页查询示例:
假设有一个
ZSET
,它存储了用户对某个博客的点赞数,键为blog:likes:{id}
,其中id
为博客的唯一标识。我们希望按照点赞数降序返回该博客的前10名用户。ZRANGE blog:likes:1 0 9 WITHSCORES # 获取从第1到第10个用户,WITHSCORES 返回每个用户的点赞数
上述命令会返回
ZSET
中按分数升序排的前10个用户和他们的点赞数。如果我们希望按点赞数降序排列,可以使用ZREVRANGE
:ZREVRANGE blog:likes:1 0 9 WITHSCORES # 获取从第1到第10个用户,按分数降序
@Data
public class ScrollResult {
private List<?> list; // 存储查询结果的列表
private Long minTime; // 存储分页查询中最小的时间戳,用于下一页查询
private Integer offset; // 存储当前分页的偏移量(用于计算下一页的偏移)
}
@RestController
@RequestMapping("/blog")
public class BlogController {
@Resource
private IBlogService blogService;
@Resource
private IUserService userService;
@GetMapping
public Result queryBlogOfFollow(
@RequestParam("lastId") Long max, @RequestParam(value = "offset", defaultValue = "0") Integer offset) {
return blogService.queryBlogOfFollow(max, offset);
}
}
offset
是用来分页的:
offset
控制的是 跳过多少条数据,也就是说,它指定了从查询结果的第几条记录开始返回。例如:
offset = 0
表示从第一页的第一条记录开始查询。offset = 2
表示从第二页的第一条记录开始查询,跳过前面两条。这对于分页来说是非常重要的,可以确保你一次加载的数据不会过多,降低数据库和 Redis 的负担。
0
和max
控制查询的时间范围(避免查询过多数据):
0
和max
作为 分数范围 的参数,限制了 Redis 查询的 数据范围。具体来说:
0
:表示从时间戳最早的动态开始查找。这是为了确保不会遗漏从最早时间点开始的数据。max
:表示查询到的时间戳不会超过max
的值。max
可能是一个具体的时间戳(如当前时间),用于限制查询的数据不超过这个时间点的数据。因此,
0
和max
控制的是 查询的时间范围,确保你只查询到特定时间段内的数据。
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
// 获取当前登录用户的ID
Long userId = UserHolder.getUser().getId();
// 定义Redis的key,存储的是当前用户的动态信息(博客)
String key = "feed:" + userId;
// 从Redis ZSET中获取按时间戳降序排列的动态数据,返回指定的范围和分页
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2); // 获取分数(时间戳)小于max的前2条数据
// 如果查询结果为空,直接返回空的响应
if (typedTuples == null || typedTuples.isEmpty()) {
return Result.ok(); // 如果没有数据,返回空的分页结果
}
// 用于存储动态的ID集合
List<Long> ids = new ArrayList<>(typedTuples.size());
// 用于记录分页查询中最小的时间戳
long minTime = 0;
// 用于记录当前页面的偏移量(即当前分页的位置)
int os = 1;
// 遍历查询结果
for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
// 获取动态ID(字符串形式)
String idStr = typedTuple.getValue();
// 将动态ID从String转换为Long,并添加到ids列表中
Long id = Long.valueOf(idStr);
ids.add(id);
// 获取动态的时间戳(作为分数存储)
long time = typedTuple.getScore().longValue();
// 如果当前时间戳与上一条数据的时间戳相同,说明是同一时间段的动态,偏移量加1
if (time == minTime) {
os++; // 同一时间戳的动态,增加偏移量
} else {
// 如果时间戳不同,更新最小时间戳,并重置偏移量
minTime = time;
os = 1; // 该时间戳下的动态的偏移量从1开始
}
}
// 将动态ID列表转化为逗号分隔的字符串,用于查询数据库
String idStr = StrUtil.join(",", ids);
// 根据ID查询博客数据,返回的结果按照ID顺序排序
List<Blog> blogs = query().in("id", ids).last("order by ids" + idStr + ")").list();
// 创建一个ScrollResult对象,用于封装分页查询的结果
ScrollResult r = new ScrollResult();
r.setList(blogs); // 设置当前页面的博客动态列表
r.setOffset(os); // 设置当前页的偏移量(分页位置)
r.setMinTime(minTime); // 设置当前页面的最小时间戳,用于下一次分页查询
// 返回封装好的结果
return Result.ok(r); // 返回查询结果
}
方法签名与参数说明
@Override public Result queryBlogOfFollow(Long max, Integer offset) {
- max:这是一个时间戳,表示查询的最大时间。Redis 中的 ZSET 是根据分数(score)排序的,通常我们用时间戳作为分数,因此
max
表示最大时间戳,通常是当前时间或某个固定的时间点。因此max
表示查询的时间范围的上限。当我们查询 ZSET 时,可以通过max
来限制查询返回的元素的时间戳范围。例如,查询max
小于某个时间戳的所有动态,保证我们只获取当前时间之前的动态。- offset:表示分页的偏移量,用来指定从查询结果的哪个位置开始返回。它帮助我们在查询时跳过前
offset
条记录,从而实现分页。在分页的过程中,每次查询都需要传递不同的offset
,以便从正确的记录位置开始查询。通常offset
是通过上次查询结果的偏移量计算出来的。举个例子: 假设每页显示
2
条动态:
- 第一页:
offset = 0
- 第二页:
offset = 2
(跳过前2条数据,查询从第3条开始的数据)- 第三页:
offset = 4
(跳过前4条数据,查询从第5条开始的数据)该方法的目标是查询当前用户的关注者发布的博客(动态),并分页返回结果。
Redis 查询:获取关注者发布的博客动态
String key = "feed:" + userId; Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet() .reverseRangeByScoreWithScores(key, 0, max, offset, 2);
key = "feed:" + userId
:每个用户的动态(博客)存储在以feed:
为前缀的 Redis ZSET 中,userId
是动态数据的唯一标识符。reverseRangeByScoreWithScores(key, 0, max, offset, 2)
:
reverseRangeByScoreWithScores
:该命令用于按分数降序返回指定范围的元素及其分数。这里的score
对应的是时间戳,因此可以按时间顺序从最新的动态开始查询。0
到max
:表示查询的时间戳范围。查询score
从0
到max
的元素。offset
:表示分页查询的起始偏移量,通常是上一页的最后一条记录的索引。2
:每次查询时返回的结果数量。也就是说,这一命令会返回最多 2 条动态。根据业务需求,这里设置为每次返回 2 条数据。通过调整这个数字,可以控制每次查询返回的数据量。通过
offset
和2
实现分页查询:
offset
:用来控制查询的起始位置,避免一次查询返回所有数据。2
:每次查询返回 2 条记录。这可以减少一次查询的结果集大小,提高查询效率。灵活控制查询范围:
0
到max
使得我们能够灵活地控制查询的时间范围。通常,0
是为了兼容性的写法,表示从最小时间开始查询。max
用于控制查询的上限,确保返回的动态时间不会超过指定时间。返回值:
返回的是一个
Set<ZSetOperations.TypedTuple<String>>
,每个TypedTuple
包含两个部分:
- 值(blogId):即每个动态的 ID,存储在
typedTuple.getValue()
中。- 分数(时间戳):即该动态的时间戳,存储在
typedTuple.getScore()
中。
解析数据:获取动态 ID 和时间戳
List<Long> ids = new ArrayList<>(typedTuples.size()); long minTime = 0; int os = 1; for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) { String idStr = typedTuple.getValue(); Long id = Long.valueOf(idStr); ids.add(id); long time = typedTuple.getScore().longValue(); if (time == minTime) { os++; } else { minTime = time; os = 1; } }
- ids:用于存储查询到的动态 ID。
- minTime:用于记录当前页面最早的时间戳。在分页中,
minTime
的作用是保证在下一次查询时,从上一次查询的时间戳之后开始获取数据,避免重复数据。- os:表示当前页的偏移量。每次分页查询时,
os
递增,用于记录当前页面的偏移量。在循环中:
- 从
typedTuple
中获取 动态 ID 和 时间戳。- 时间戳相等时,偏移量(
os
)加一,表示当前的时间戳下有多个动态,显示顺序为相同时间戳下的顺序。- 时间戳不相等时,更新
minTime
和偏移量os
。
根据动态 ID 查询博客
String idStr = StrUtil.join(",", ids); List<Blog> blogs = query().in("id", ids).last("order by ids" + idStr + ")").list();
StrUtil.join(",", ids)
:将动态 ID 列表ids
转换为一个逗号分隔的字符串,生成用于 SQL 查询的 ID 列表。- 查询博客:使用
query().in("id", ids)
根据 ID 列表查询对应的博客。.last("order by ids" + idStr + ")")
用于保证查询的博客顺序与 Redis 中 ZSET 的顺序一致。这里的order by
语句应该是通过动态拼接idStr
来确保顺序正确。注意:拼接 SQL 语句时要小心 SQL 注入问题,避免使用不安全的字符串拼接方法。
如何计算
offset
(偏移量)
假设数据库中的动态(博客)是这样的:
Blog ID TimeStamp (ms) Content 1 1609459200000 (2021-01-01) Blog 1 (2021-01-01) 2 1609459200000 (2021-01-01) Blog 2 (2021-01-01) 3 1609462800000 (2021-01-01) Blog 3 (2021-01-01) 4 1609466400000 (2021-01-01) Blog 4 (2021-01-01) 5 1609470000000 (2021-01-01) Blog 5 (2021-01-01) 我们的目标是 分页查询 这些博客动态,使用
offset
和minTime
来控制查询结果。1. 第一次查询
- 假设我们查询第一页的内容,查询条件:
- 每页返回 2 条数据。
offset = 0
(第一页,从第1条数据开始查询)。max = 1609470000000
(查询的时间范围上限,保证不超出当前最大时间戳)。reverseRangeByScoreWithScores("feed:123", 0, max, offset, 2);
返回结果:
- 从 Redis 返回的动态列表为:
Blog 1
、Blog 2
(时间戳1609459200000
)。
minTime
计算:
minTime
是当前分页查询结果中 最小的时间戳。这里的minTime
就是1609459200000
,这是Blog 2
的时间戳。
os
计算:
os
从1
开始,因为这是第一页。如果os
在同一时间戳的动态中增加,它会递增。此时,os = 2
,因为在相同时间戳(1609459200000
)下,第一条动态Blog 1
的os = 1
,第二条动态Blog 2
的os = 2
。封装返回结果:
ScrollResult r = new ScrollResult(); r.setList([Blog 1, Blog 2]); // 返回这两条数据 r.setMinTime(1609459200000); // 设置最小时间戳,供下一页查询 r.setOffset(2); // 下一页的偏移量从2开始
2. 第二次查询
- 下一页的查询,
offset = 2
(跳过前两条动态,从第三条数据开始查询),minTime = 1609459200000
(上一页的最小时间戳)。- 查询参数:
reverseRangeByScoreWithScores("feed:123", minTime + 1, max, offset, 2);
查询条件:
minTime + 1
表示从上一页返回的 最小时间戳之后 开始查询,因此查询的范围从时间戳大于1609459200000
开始。offset = 2
,意味着查询从第三条数据开始。返回结果:
- 从 Redis 返回的动态为:
Blog 3
和Blog 4
(时间戳1609462800000
和1609466400000
)。
minTime
计算:
minTime
是当前页面的最小时间戳,这里是1609462800000
,即Blog 3
的时间戳。
os
计算:
os
重置为 1,因为Blog 3
的时间戳是新的时间段,os = 1
表示该时间段的第一条动态。封装返回结果:
ScrollResult r = new ScrollResult(); r.setList([Blog 3, Blog 4]); // 返回这两条数据 r.setMinTime(1609462800000); // 设置最小时间戳,供下一页查询 r.setOffset(2); // 下一页的偏移量为2,表示如果继续分页,应该从第5条数据开始查询
3. 第三次查询
- 下一页的查询,
offset = 4
(跳过前四条数据,从第五条数据开始查询),minTime = 1609462800000
(上一页的最小时间戳)。- 查询条件:
reverseRangeByScoreWithScores("feed:123", minTime + 1, max, offset, 2);
查询条件:
minTime + 1
表示从上一页返回的 最小时间戳之后 开始查询,查询的时间戳范围是1609462800000
之后的数据。offset = 4
,意味着查询从第五条数据开始。返回结果:
- 从 Redis 返回的动态为:
Blog 5
(时间戳1609470000000
)。
minTime
计算:
minTime
是当前页面的最小时间戳,这里是1609470000000
,即Blog 5
的时间戳。
os
计算:
os
重置为 1,因为这条动态是当前时间段下的第一条动态。封装返回结果:
ScrollResult r = new ScrollResult(); r.setList([Blog 5]); // 返回这条数据 r.setMinTime(1609470000000); // 设置最小时间戳,供下一页查询(下一页无数据) r.setOffset(2); // 假设这已经是最后一页,下一次查询会返回空数据
分页查询总结:
- 第一次查询:使用
offset = 0
,从第一页开始查询,返回Blog 1
和Blog 2
。- 第二次查询:使用
minTime = 1609459200000
和offset = 2
,从第三条数据开始查询,返回Blog 3
和Blog 4
。- 第三次查询:使用
minTime = 1609462800000
和offset = 4
,从第五条数据开始查询,返回Blog 5
。
offset
和minTime
的作用:
offset
:控制查询从哪个位置开始,分页跳过之前的数据。每次查询后,offset
会更新以确保下一页的查询从正确的位置开始。minTime
:控制查询的时间范围。每一页返回的结果中,minTime
是当前页最小的时间戳,帮助下一页查询跳过已经返回的数据。这个过程保证了 按时间戳分页查询 的效果,并避免了重复数据。