前言(技术选型):
Feed流
- 概念:Feed 流(信息流),即“关注页”看到的动态列表。类似于微信朋友圈、微博关注页,内容由用户关注的人发布,按时间顺序排列。
- 需求:在项目中,当 A 用户发布了一篇探店笔记(Blog),关注了 A 的粉丝应该能在自己的“关注”列表里刷到这篇笔记。
推模式
- 原理:博主发笔记时,主动推送到所有粉丝的收件箱(Redis)。
- 优点:读操作极快,直接读自己的收件箱即可。
- 缺点:大V发贴由于粉丝多,写入压力大(适合中小规模用户体量)。
Redis ZSet
- 设计:我们将 BlogId 作为 Value,时间戳作为 Score。
- 策略:不按“第几页”查,而是按“在这个时间之前”查。
第一次查:查当前时间之前的 10 条。
第二次查:查上一页最后一条动态的时间戳之前的 10 条。 - 优势:无论头部插入了多少新数据,只要我记得上次读到了哪个时间点,我就能准确地接着往下读,完全不受新数据插入的影响
推送笔记
流程:
- 保存笔记到数据库。
- 查询当前用户的所有粉丝 。
- 循环推送:遍历粉丝,通过 stringRedisTemplate.opsForZSet().add(key, blogId, timestamp) 将笔记 ID 写入每个粉丝的 Redis ZSet 中。
代码实现:
@Override
public Result saveBlog(Blog blog) {
// 获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 保存探店博文
blogService.save(blog);
//查询作者所有粉丝
List<Follow> fans = followService.query().eq("follow_user_id", user.getId()).list();
if (fans == null || fans.isEmpty()){
return Result.ok();
}
//推送笔记id给所有粉丝
for (Follow fan : fans) {
String key = FEED_KEY + fan.getUserId();
stringRedisTemplate
.opsForZSet()
.add(key, blog.getId().toString(), System.currentTimeMillis());
}
//返回id
return Result.ok(blog.getId());
}
注意事项:
将笔记推送到粉丝邮箱时,以当前时间戳作为分数。
读取推送
流程:
- 数据处理:lastId为上次查询数据的最大时间戳,offset为偏移量,如果是第一次查询,两者可能为空,因此要防止出现空指针异常。
- 查询redis邮箱:
- key为要查询的key
- 0, maxScore为分数查询范围
- safeOffset为偏移量
- 2为每页展示数据
- 处理查询结果,得到本次查询的lastId和safeOffset。
- 查询数据库返回数据。
代码实现:
@Override
public Result queryBlogOfFollow(Long lastId, Integer offset) {
// 获取当前用户
Long userId = UserHolder.getUser().getId();
// 防御式处理,避免请求参数缺失导致空指针
long maxScore = (lastId == null || lastId <= 0) ? Long.MAX_VALUE : lastId;
int safeOffset = (offset == null || offset < 0) ? 0 : offset;
// 查询收件箱 ZREVRANGEBYSCORE key Max Min WITHSCORES LIMIT offset count String key = FEED_KEY + userId;
Set<String> ids = stringRedisTemplate.opsForZSet()
.reverseRangeByScore(key, 0, maxScore, safeOffset, 2);
if (ids == null || ids.isEmpty()) {
return Result.ok();
}
// 解析出blogId和score
List<Long> blogIds = ids.stream().map(Long::valueOf).collect(Collectors.toList());
long minTime = 0;
int os = 1;
for (String id : ids) {
Double score = stringRedisTemplate.opsForZSet().score(key, id);
if (score == null) {
continue;
}
long time = score.longValue();
if (time == minTime) {
os++;
} else {
minTime = time;
os = 1;
}
}
// 根据id查询blog
String idStr = StrUtil.join(",", blogIds);
List<Blog> blogs = query().in("id", blogIds)
.last("ORDER BY FIELD(id," + idStr + ")")
.list();
for (Blog blog : blogs) {
queryBlogUser(blog);
isBlogLiked(blog);
}
ScrollPageResult scrollPageResult = new ScrollPageResult(blogs, minTime, os);
return Result.ok(scrollPageResult);
}
注意事项
- redis查询命令为reverseRangeByScore,按分数(时间戳)倒序查找范围内的元素。
- lastId和safeOffset的获取:增强for按顺序遍历ids集合,拿到的score是递减的,如果当前时间time与已经记录的minTime不等,那么time一定是更小的,将time赋值给minTime并将偏移量重置为1。如果当前时间time与已经记录的minTime相等,偏移量自增1。
- 数据库查询:如果直接使用in操作符则查询结果是无序的,使用last()方法手动添加ORDER BY FIELD,确保数据库查询结果有序