普通翻页
相信大家平时接触使用最多的翻页方式就是普通翻页,包括我自己在内,在没接触游标翻页之前,也都是使用的普通翻页。普通翻页的sql语句如下:
select * from table where limit 10,10;
熟悉mysql的应该知道,limit语句的作用是:第一个参数,表示跳过的查询数,第二个表示要查询的数量,上述语句表示从table中查询11到20的十条数据。
大家可以想象,如果在项目中数据量比较大,那么,查询效率会非常低,比如,如果现在要查询10001到10010的十条数据,那么sql语句就为:
select * from table where limit 10000, 10;
上述语句表示跳过前一万条数据,只取10001到10010的十条数据,那么前一万条数据就是无用数据,那么扫描这一万条数据的时间就是白白浪费了。
深翻页问题
其实,上面的描述已经差不多介绍了深翻页问题,这里再提出来说一下,加深大家印象。
当翻页进行到一定程度,比如翻到1000页时,此时,对应的sql语句:
select * from table where id > 1 limit 10000, 10;
出现了上述普通翻页出现的问题,此时mysql会丢弃掉前面的10000条数据,只取后面的10条数据,降低性能。
游标翻页
然普通翻页会出现深翻页问题,那么有没有解决方案呢?答案是肯定的。游标翻页就可以解决这一问题。
细想一下,出现上述问题的主要原因是什么?是因为要白白浪费时间去查很多无关数据。那么我们直接跳过前面的无关数据是不是就可以提高查询效率了。
比如,我们现在希望查询10001到100010的数据,那么我们希望直接跳过前面的10000条数据,然后返回10001到10010十条数据,怎么能让mysql快速跳过呢?看下面的sql语句:
select * from table where id > 10000 limit 0, 10;
上述sql语句中,快速地跳过了前面10000条数据(id <= 10000的数据),然后limit表示跳过0条数据,直接取10001开始的10条数据,这样就省去了很多查询无关数据的时间,提升了数据库的查询性。这里的10000就是游标。
注意:一般来说,对于游标都要加索引,才能快速定位到相应的位置,不然,使用游标翻页依旧是一个非常耗时的操作。
由于,mysql中对于表的主键id默认是索引,所以上面的sql语句会很快地定位到id为10000的数据。
实现游标翻页
说了这么多,怎么实现游标翻页呢?我们需要定义游标的基本请求类和基本响应类。
基本请求类:
@Data
@ApiModel("游标翻页请求")
@AllArgsConstructor
@NoArgsConstructor
public class CursorPageBaseReq<T> {
@ApiModelProperty("页面大小")
@Min(0)
@Max(100)
private Integer pageSize = 10;
@ApiModelProperty("游标(初始为null,后续请求附带上次翻页的游标)")
private String cursor;
/**
* 直接根据当前类中的pageSize信息生成一个page类,方便后面的查询
*/
public Page plusPage() {
return new Page(1, this.pageSize, false);
}
}
前端每次只需要发送游标和每页的大小即可。
基本响应类:
@Data
@ApiModel("游标翻页返回")
@AllArgsConstructor
@NoArgsConstructor
public class CursorPageBaseResp<T> {
@ApiModelProperty("游标(下次翻页带上这参数)")
private String cursor;
@ApiModelProperty("是否最后一页")
private Boolean isLast = Boolean.FALSE;
@ApiModelProperty("数据列表")
private List<T> list;
}
需要每次给前端返回查询完之后的新的游标,以便前端下次请求时携带游标,还需要返回给前端此次返回的数据是不是最后一页。当然最重要的是要返回数据。
看看具体使用的代码,清晰一下流程:
LambdaQueryChainWrapper<UserFriend> wrapper = lambdaQuery();
//游标字段(id)(快速定位索引位置)
wrapper.lt(UserFriend::getId,cursorPageBaseReq.getCursor());
//游标方向,降序查询
wrapper.orderByDesc(UserFriend::getId);
//额外查询条件
wrapper.eq(UserFriend::getUid,uid);
//游标翻页结果
Page<UserFriend> page = page(cursorPageBaseReq.plusPage(), wrapper);
//计算游标位置
String cursor = Optional.ofNullable(CollectionUtil.getLast(page.getRecords()))
.map(UserFriend::getId)
.map(String::valueOf)
.orElse(null);
//是否最后一页判断
Boolean isLast = page.getRecords().size() != cursorPageBaseReq.getPageSize();
return new CursorPageBaseResp<>(cursor, isLast, page.getRecords());
相信从上述代码可以清晰地知道游标翻页的流程了,
- 首先完善查询器LambdaQueryChainWrapper,其中包括了游标信息,查询条件,
- 然后通过mybatisplus中的方法直接分页请求,拿到分页的结果
- 然后更新游标位置,返回给前端,以便前端下次请求时携带
- 判断当前数据是否是最后一页
这是一个完整的游标翻页的流程,非常清晰。
但是,如果每当有一个游标翻页请求时,我们都需要写一遍上述代码,会不会太臃肿了。答案也是肯定的。
所以,这促使我们去编写一个工具类,将每次使用游标翻页时重复使用到的代码进行抽象,对于不同场景的翻页需要的参数,从业务传参即可。那么我们现在就来将上述完整的游标翻页的代码进行抽象,封装为一个工具类。
游标翻页工具类
要将上述代码抽象成一个工具类,需要找出代码中不需要业务场景传参的地方
如图,红色框中是各种业务场景中公共的部分,黄色框中的额外查询条件是需要从业务场景中传参过来的,另外,不同场景的游标也是不一样的,所以额外的查询条件和游标是需要从业务场景中传过来的。另外,要使用mybatisplus中的查询方法,就要传入Iservice接口。当然必不可少的游标翻页当然需要游标相关信息,即游标的基本请求类,这样一分析,工具类的参数就已经确定了:
- Isevice接口
- 游标翻页基本请求类
- 额外的查询条件
- 游标
除了第三个参数,其余三个参数都很好处理。我们怎么传入额外的查询条件呢?这里可以借助函数式接口,consumer接口就很适合,consumer接口只有入参,没有返回值。可以通过consumer的参数来设置额外的查询条件。我也是从其他地方学习到的这样一种思路,感觉非常的奇妙。
至此,游标翻页工具类的四个参数就已经确定好了,这里给出游标翻页的工具类的代码:
public static <T> CursorPageBaseResp<T> getCursorPageByMysql(IService<T> mapper, CursorPageBaseReq request, Consumer<LambdaQueryWrapper<T>> initWrapper, SFunction<T, ?> cursorColumn) {
//游标字段类型
Class<?> cursorType = LambdaUtils.getReturnType(cursorColumn);
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
//给查询器初始化额外条件
initWrapper.accept(wrapper);
//游标条件
if (StrUtil.isNotBlank(request.getCursor())) {
wrapper.lt(cursorColumn, parseCursor(request.getCursor(), cursorType));
}
//游标方向
wrapper.orderByDesc(cursorColumn);
Page<T> page = mapper.page(request.plusPage(), wrapper);
//取出游标
String cursor = Optional.ofNullable(CollectionUtil.getLast(page.getRecords()))
.map(cursorColumn)
.map(CursorUtils::toCursor)
.orElse(null);
//判断是否最后一页
Boolean isLast = page.getRecords().size() != request.getPageSize();
return new CursorPageBaseResp<>(cursor, isLast, page.getRecords());
}
怎么样?代码跟没抽象时的完整代码是不是非常相似,封装成代码后,将来要想实现游标翻页就很轻松了。只需要调用方法,然后传入参数即可。
使用工具类的示例代码:
@Service
public class UserFriendDao extends ServiceImpl<UserFriendMapper, UserFriend> {
public CursorPageBaseResp<UserFriend> getFriendPage(long uid, CursorPageBaseReq request) {
return CursorUtils.getCursorPageByMysql(this, request, wrapper -> wrapper.eq(UserFriend::getUid, uid), UserFriend::getId);
}
}
参数解释:
- this:传入工具类中定义的Iservice类型的接口,因为ServiceImpl是实现了Iservice接口的,所以可以直接将本类作为第一个参数传入
- 就是游标翻页的基本请求
- 这第三个参数就是一个consumer接口,wrapper -> wrapper.eq(UserFriend::getUid, uid)这行代码就是实现了consumer接口,具体功能就是在查询器中初始化额外查询条件。
工具类中的这两行代码就是将wrapper传入consumer接口,然后将其初始化为wrapper.eq(UserFriend::getUid, uid)即额外条件,如果还有其他的查询条件,只需要给wrapper设置查询条件即可。
//给查询器初始化额外条件
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
initWrapper.accept(wrapper);
- 就是给游标工具类设置游标字段。
这样,以后如果需要使用游标翻页,只需要调用工具类中的方法即可。