支持游标风格的分页

2025-05-30, 星期五, 17:15

已经有了基于 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};

可以看到实现游标查询的关键在于将游标列添加到 whereorder 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 实现或其子类时,修改原始查询,为其 whereorder 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 逻辑。

ICursorIPage 都符合 List records + metadata 的模式,那直接将 ICursor 伪装成 IPage 是否可行?

能行。