已经有了基于 PageSize 和 PageNum 的分页功能,为什么还需要引入游标分页?
基于偏移量的分页无法应对并发场景下数据插入 / 删除导致的重复 / 跳过问题
页码分页基于 OFFSET 和 LIMIT 方法实现。查看第二页时,如果有人在第一页插入数据,就会获取到重复元素;如果有人在第一页删除数据,就会跳过一些元素。
实现不佳的查询语句会造成性能下降
数据库需要扫描并跳过 OFFSET 指定的记录,因此在数据量较大的情况下会有性能损失。通常我们可以通过先获取分页结果的主键集合,再查询主键集对应的记录来减少这个过程的性能损失(或者整成一个嵌套查询)。
另一种方法是使用支持 OFFSET 计算下推的数据库,可用的产品有阿里云的 PolarDB for MySQL 8 或腾讯云的 云数据库 MySQL 等等,可惜在政务云里用不着……
数据量多大算「较大」?云厂商一般设置 OFFSET > 512 时触发计算下推,因此可以大概估摸出一个数字……
爬虫会并发请求支持跳页的接口
聊胜于无吧。
移动端大部分场景下,用户不关心「请求第 3 页的数据」,而是「再来 10 条数据」
因此缺失跳页功能好像不是个大问题,反而在观感上更接近信息流的感觉。
和数据库的游标有什么区别?
MyBatis 等 ORM 框架可以借助数据库提供的游标功能,虽然理念一致,但数据库的游标有若干限制:
- 需要开发者注意主动释放游标,或是使用
try-with-resource
实现自动释放 - 一些框架缺乏动态调整
fetchSize
的能力(如果允许fetchSize
不等于 1 的话),在一些场景中可能会造成麻烦 - 只能返回列表,metadata 需要额外构造
希望像 Mybatis-Plus 的分页插件一样以一种近乎无感的方法(在方法声明中将入参和返回类型设置为 IPage
即可)引入到项目中。
初步尝试
我们的大部分场景使用单一游标即可解决,游标列可以是使用雪花 ID 的主键列。
作为一个饕餮客,如果我们要由新到旧获取本地所有小酒馆的评论,可以使用:
select * from tbl_bistro_review order by create_time desc;
# 引入游标概念,当存在一个有效游标值时,如上查询将被转换为:
select * from tbl_bistro_review
where id > #{cursor}
order by id asc,create_time desc
limit #{fetch_size};
可以看到实现游标查询的关键在于将游标列添加到 where
和 order by
条件中。顺便说下,如果将 create_time
也加入到复合游标,那么实际执行的 SQL 看起来应该长这样:
# 显然没有什么明显的好处
select * from tbl_bistro_review
where
create_time < #{cursor.create_time}
or
(create_time = #{cursor.create_time} and id > #{cursor.id})
order by create_time desc, id asc;
对查询语句的修改可以通过实现并注册 com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor
实例来实现。笔者选择继承 com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor
,Mybatis-Plus 使用该类处理 IPage
分页,当方法入参包含 IPage
实现或其子类时,将利用原始查询拼装 count SQL,计算出符合条件的结果集的大小,并设置到入参 IPage
对象的 total
属性中。
因此可以扩展该类,当方法的参数包含 ICursor
实现或其子类时,修改原始查询,为其 where
和 order by
区域添加新内容并生成 count sql,当结果集比 fetchSize
大时,说明顺着当前翻页的方向至少还有一页(也就是满足迭代器、游标之类的 hasNext
概念)。
仿照 IPage
设计 ICursor
接口,使用 List<E> records
保存查询结果,使用 direction
标识游标移动方向,cursor
游标值使用 Base64 编码,只要能定位表中的某一行即可,出于简单的目的,用该行主键值的 Base64 编码表示。
简单设计 CursorMapper
接口,声明几个相关方法,让 BistroReviewMapper
继承这个接口,就可以在代码中使用了:
// CursorMapper.java
public interface CursorMapper{
void selectList(ICursor<T> cursor, @Param(Constants.WRAPPER) Wrapper<T> queryWrapper, ResultHandler<T> resultHandler);
default <C extends ICursor<T>> C selectCursor(C cursor, @Param(Constants.WRAPPER) Wrapper<T> queryWrapper) {
cursor.setRecords(selectList(cursor, queryWrapper));
return cursor;
}
...
// BistroReviewMapper.java
@Mapper
public interface BistroReviewMapper extends CursorMapper<BistroReview>, BaseMapper<BistroReview> {}
// BistroController.java
@GetMapping("by-cursor")
public ICursor<BistroReview> cursorPagination(CursorRequest cursorRequest) {
Cursor<BistroReview> queryBatch = bistroReviewMapper
.selectCursor(
cursorRequest.getCursor(),
Wrappers.emptyWrapper());
return queryBatch;
}
但是,如果涉及到一些简单的连表,例如在查询时希望同时获取到 bistro_name
,可能就需要引入一点其他方法了。
// 成功
@Select("""
select * from tbl_bistro_review r
left join tbl_bistro b on r.bistro_id = b.id
order by r.create_time desc""")
IPage<BistroReview> selectPage(IPage page);
// 失败
@Select("""
select * from tbl_bistro_review r
left join tbl_bistro b on r.bistro_id = b.id
order by r.create_time desc""")
ICursor<BistroReview> selectCursor(ICursor cursor);
java.lang.NoSuchMethodException: ICursor.<init>()
at java.base/java.lang.Class.getConstructor0(Class.java:3761) ~[na:na]
at java.base/java.lang.Class.getDeclaredConstructor(Class.java:2930) ~[na:na]
上方写法如果换成 ICursor
就会因为 Mybatis 无法将 ICursor
作为 ResultMap 填充而失败。
Mybatis-Plus 对 IPage
的处理是在 com.baomidou.mybatisplus.core.override.MybatisMapperMethod#execute
中进行的,其他未列举类型会尝试通过 org.apache.ibatis.binding.MapperMethod.MethodSignature#convertArgsToSqlCommandParam
进行映射:
case SELECT:
if (method.returnsVoid() && method.hasResultHandler()) {
executeWithResultHandler(sqlSession, args);
result = null;
} else if (method.returnsMany()) {
result = executeForMany(sqlSession, args);
} else if (method.returnsMap()) {
result = executeForMap(sqlSession, args);
} else if (method.returnsCursor()) {
result = executeForCursor(sqlSession, args);
} else {
if (IPage.class.isAssignableFrom(method.getReturnType())) {
result = executeForIPage(sqlSession, args);
} else {
Object param = method.convertArgsToSqlCommandParam(args);
result = sqlSession.selectOne(command.getName(), param);
if (method.returnsOptional()
&& (result == null || !method.getReturnType().equals(result.getClass()))) {
result = Optional.ofNullable(result);
}
}
}
break;
遗憾的是 Mybatis-Plus 未提供相关的自定义逻辑注入点,只能通过继承相关类扩展功能。有人在 2021 年提了个 PR - 方便通过自定义拦截器构造返回属于自己的类型。比如扩展一套属于自己的分页组件(集成 spring-data-commons page 组件)#3988,不过并没有什么响应。
另辟蹊径
要扩展 com.baomidou.mybatisplus.core.override.MybatisMapperMethod#execute
的功能,可以创建 CustomMapperMethod 继承或替换它。要做到这点,需要先通过 com.baomidou.mybatisplus.core.override.MybatisMapperProxyFactory
创建 CustomMapperMethod 实例。在那之前,你需要替换注册的 MapperFactoryBean,而在这之前,你需要修改 com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration
逻辑。
ICursor
和 IPage
都符合 ListICursor
伪装成 IPage
是否可行?
能行。