一、 Redis中,使用有序集合(sorted set)实现滚动分页的原理如下:
- 将每个文档的 score 值设置为时间戳(或根据其他规则计算的分数),将文档的 ID 作为 value,然后将其添加到有序集合中。
- 获取当前时间戳,作为查询时间点。
- 使用 ZRANGEBYSCORE 命令根据 score 值范围查询出 score 值在当前时间戳之前的所有文档 ID。
- 返回查询结果作为当前页的结果集。
- 将当前页的最后一个文档 ID 作为新的查询起点,重复以上步骤,直到遍历所有文档。
二、Redis中,(sorted set)命令详细说明
Redis中的sorted set(有序集合)是一个数据结构,它允许你存储一组有序的元素(成员),每个元素可以有一个分数(score),分数可以用于排序、限制范围或聚合操作等。sorted set是自动排序的,并且所有的成员都是唯一的,不允许重复。
以下是在Redis中操作sorted set的命令:
1 ZADD key score member:
向有序集合中添加一个元素。
ZADD myset 7.5 "apple"
ZADD myset 9.0 "orange"
ZADD myset 8.2 "banana"
2 ZRANGE key start stop [WITHSCORES]:
返回有序集合中指定范围内的元素。
ZRANGE myset 0 -1 WITHSCORES
ZRANGE myset 1 2 WITHSCORES
结果:
127.0.0.1:6379> ZRANGE myset 0 -1 WITHSCORES
1) "apple"
2) "7.5"
3) "banna"
4) "8.1999999999999993"
5) "orange"
6) "9"
127.0.0.1:6379> ZRANGE myset 1 2 WITHSCORES
1) "banna"
2) "8.1999999999999993"
3) "orange"
4) "9"
3 ZREVRANGE key start stop [WITHSCORES]:
返回有序集合中指定范围内的元素,按照分数从大到小排序,与 ZRANGE 返回的结果相反。
ZREVRANGE myset 0 -1 WITHSCORES
ZREVRANGE myset 1 2 WITHSCORES
结果:
127.0.0.1:6379> ZREVRANGE myset 0 -1 withscores
1) "orange"
2) "9"
3) "banna"
4) "8.1999999999999993"
5) "apple"
6) "7.5"
127.0.0.1:6379> ZREVRANGE myset 1 2 withscores
1) "banna"
2) "8.1999999999999993"
3) "apple"
4) "7.5"
4 ZREM key member:
从有序集合中删除指定元素。
ZREM myset "apple"
ZREM myset "banana"
5 ZCARD key:
返回有序集合中元素的数量。
ZCARD myset
127.0.0.1:6379> ZCARD myset
(integer) 2
6 ZSCORE key member:
返回指定元素在有序集合中的分数。
ZSCORE myset "banana"
127.0.0.1:6379> ZSCORE myset "banana"
"7.9000000000000004"
7 ZREMRANGEBYSCORE key min max:
删除有序集合中分数在指定范围内的所有元素
127.0.0.1:6379> ZREMRANGEBYSCORE myset 6.5 8.5
(integer) 2
8 ZREMRANGE BY PRIORITY key min max:
删除有序集合中优先级在指定范围内的所有元素。与ZREMRANGE BY SCORE命令类似。
三、具体实现步骤如下:
1 向有序集合中添加文档:
在文档添加时,为每个文档添加一个时间戳作为score值,并将其文档ID作为value值。例如,使用以下Java代码向有序集合中添加文档:
ZAddArgs zAddArgs = new ZAddArgs(score, value);
redis.zAdd("docs", zAddArgs);
2 获取当前时间戳:
使用Java的System.currentTimeMillis()方法获取当前时间戳。
3 查询score值在当前时间戳之前的文档ID
使用以下Java代码查询有序集合中score值在当前时间戳之前的文档ID:
ZRangeArgs zRangeArgs = new ZRangeArgs(0, -1, score -> score < System.currentTimeMillis());
List<String> docIds = redis.zRangeByScore("docs", zRangeArgs);
其中,ZRangeArgs构造函数中的参数0表示起始位置为0,-1表示结束位置为集合末尾,score -> score < System.currentTimeMillis()表示只返回score值小于当前时间戳的文档ID。
4 返回查询结果作为当前页的结果集:
将查到的文档ID作为当前页的结果集返回。
5 将当前页的最后一个文档ID作为新的查询起点,重复以上步骤,直到遍历所有文档。
例如,使用以下Java代码将当前页的最后一个文档ID作为新的查询起点:
String lastDocId = docIds.get(docIds.size() - 1);
docIds = redis.zRangeByScore("docs", new ZRangeArgs(0, -1, score -> score < System.currentTimeMillis() - 86400000L), lastDocId, "LIMIT");
其中,ZRangeArgs构造函数中的参数表示从集合的起始位置开始返回文档ID,lastDocId表示只返回大于lastDocId且score值小于当前时间戳的文档ID,86400000L表示一天的毫秒数,表示向前滚动一页。
四、实例
1 回顾传统分页
传统的分页 前端参数一般传入当前页数curpage和页面长度paegsize 最终通过数据库limit curpage*(pageszie-1),pageszie 实现分页 假设两参数分别为1,5 即 limit 0,5 也就是查询序号0到4的5条数据 这时如果数据库新增了一条数据其序号为1。
如果查询下一页即limit 5,5 查询序号为5 到9的数据
如图所示,很显然值为四的数据被重复查了。 查了下比较流行的做法就是新增一个字段,记录数据插入的时间。 然后查寻第一页的时候记录当前时间,之后每次分页查询都需要带上这个时间 把比这个时间大的数据排除。该方案挺不错,但是要修改数据库,费事.
2 使用Redis的zset分页
1 分页存在的问题
每次插入数据库成功时,额外保存一份<数据id,插入时间>到redis的有序集合里 。这时就可以通过插入时间分页了。第一次查询返回按时间排序前1到5的数据, 然后记录当前的时间6 。 之后的查询带上这个时间。 返回从 比这个时间小的第一个数据(即为5)和其后的四条数据。
但是这也有个问题 假如同一时间新增很多条相同数据怎么办。
引入个新的变量offset 记录返回的数据中有几个和 他们最后一个数据时间相同 上图
第一次查询返回 10 9 8 7 6 6即为2。 然后下一次查询的参数即为最后一个数据的时间戳6 ,偏移量2 ,就能确定是从第三个6开始了
2 分页方法说明
ZREVRANGEBYSCORE key Max Min LIMIT offset count
ZREVRANGEBYSCORE z1 5 0 withscores limit 1 3
分页参数:
max: 当前时间戳 | 上一次查询人最小时间戳
min:0 最小值,不变
offset: 0 | 取决于上一次的结果,与最小元素的个数
count: 3 分页的页面大小,相当于pageSize
3 在点评小程序的应用
在本小程序中,我们要将粉丝关注我的博客数排序
- 保存博客时,将我的博客发送到粉丝的信箱,也就是按粉丝的id与博客id对保存到redis中
@PostMappingpublic Result saveBlog(@RequestBody Blog blog) {// 获取登录用户UserDTO user = UserHolder.getUser();Long userId = user.getId();blog.setUserId(userId);// 保存探店博客if (blogService.save(blog)) {Long currTime = System.currentTimeMillis();
// 博客推送给关注作者的人
// 1获得关注该作者的用户列表List<Follow> follows = followService.query().select("user_id").eq("follow_user_id", userId).list();for (Follow follow : follows) {Long followId = follow.getUserId();
//String key = RedisConstants.FEED_KEY + followId;stringRedisTemplate.opsForZSet().add(key,String.valueOf(blog.getId()),currTime);}// 返回idreturn Result.ok(blog.getId());}return Result.fail("发布笔记失败");}
- 读取信箱,按评分进行分页
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {//1 获取当前用户Long userId = UserHolder.getUser().getId();//2 查询收件箱String key = RedisKey.FEED_KEY + userId;//3 解析数据 blogId、minTime时间戳、offsetSet<ZSetOperations.TypedTuple<String>> tuples = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, 0, max, offset, 2);//4 非空判断if (tuples == null || tuples.isEmpty()) {return Result.ok();}//5 遍历long minTime = 0; //获取时间错最小值,遍历完最后一个值int offsetResutl = 1; //偏移量,为最小值的个数List<Long> ids = new ArrayList<>(tuples.size());for (ZSetOperations.TypedTuple<String> tuple : tuples) {long time = tuple.getScore().longValue();if(time == minTime){offsetResutl ++ ;}else {//如果与最小时间不同,则最小时间重新赋值,将 offsetFirst重新赋值为1minTime = time;offsetResutl = 1;}String blogId = tuple.getValue();ids.add(Long.valueOf(blogId));}//6 根据id 查询blogString idsStr = StrUtil.join(",", ids);List<Blog> blogs = this.lambdaQuery().eq(Blog::getId, ids).last("order by field(id," + idsStr + ")").list();//7 封装结果ScrollResult scrollResult = new ScrollResult();scrollResult.setList(blogs);scrollResult.setOffset(offsetResutl);scrollResult.setMinTime(minTime);return Result.ok(scrollResult);}
UserHolder 实体类
public class UserHolder {private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();public static void saveUser(UserDTO user){tl.set(user);}public static UserDTO getUser(){return tl.get();}public static void removeUser(){tl.remove();}
}
UserDTO 实体类
@Data
public class UserDTO {private Long id;private String nickName;private String icon;
}
Blog 实体类
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_blog")
public class Blog implements Serializable {private static final long serialVersionUID = 1L;/*** 主键*/@TableId(value = "id", type = IdType.AUTO)private Long id;/*** 商户id*/private Long shopId;/*** 用户id*/private Long userId;/*** 用户图标*/@TableField(exist = false)private String icon;/*** 用户姓名*/@TableField(exist = false)private String name;/*** 是否点赞过了*/@TableField(exist = false)private Boolean isLike;/*** 标题*/private String title;/*** 探店的照片,最多9张,多张以","隔开*/private String images;/*** 探店的文字描述*/private String content;/*** 点赞数量*/private Integer liked;/*** 评论数量*/private Integer comments;/*** 创建时间*/private LocalDateTime createTime;/*** 更新时间*/private LocalDateTime updateTime;}
Result实体类
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {private Boolean success;private String errorMsg;private Object data;private Long total;public static Result ok(){return new Result(true, null, null, null);}public static Result ok(Object data){return new Result(true, null, data, null);}public static Result ok(List<?> data, Long total){return new Result(true, null, data, total);}public static Result fail(String errorMsg){return new Result(false, errorMsg, null, null);}
}
Follow实体类
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_follow")
public class Follow implements Serializable {private static final long serialVersionUID = 1L;/*** 主键*/@TableId(value = "id", type = IdType.AUTO)private Long id;/*** 用户id*/private Long userId;/*** 关联的用户id*/private Long followUserId;/*** 创建时间*/private LocalDateTime createTime;}
五、源码下载
https://gitee.com/charlinchenlin/koo-erp