新手必看:所有的分页方式及原理

3,808 阅读7分钟

前言

大家好我是 提前退休的java猿,今天在看八股文的时候,才发现实现分页的形式有这么多种!今天就分享一下用法和核心原理,特别是不是知道分页插件原理的同学,一定要点赞收藏

实现分页的写法

一、分页插件:PageHelper

分页插件用的最多的就是 PageHelper, 今天简单看看PageHelper 的使用方式,以及原理,还有需要注意的问题!

使用方式

对应的jar包肯定是要有的,这个就不用代码展示了。直接看使用代码吧:

// 开启分页
PageHelper.startPage(1, 10); 
// 紧接着写查询SQL,不需要写limit,eg: `select * from user`
List<User> userList = userMapper.selectAllUsers();
// 转成分页对象
PageInfo<User> pageInfo = new PageInfo<>(userList);

核心原理

  1. PageHelper.startPage(XX) 核心逻辑主要讲 页码页码大小 封装成Page 对象 存到 ThreadLocal 对象中。
    startPage核心代码如下:

    public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
       Page<E> page = new Page(pageNum, pageSize, count);
       page.setReasonable(reasonable);
       page.setPageSizeZero(pageSizeZero);
       Page<E> oldPage = getLocalPage();
       if (oldPage != null && oldPage.isOrderByOnly()) {
           page.setOrderBy(oldPage.getOrderBy());
       }
    
       setLocalPage(page);
       return page;
    }
    

    setLocalPage():

    //ThreadLocal 对象
    protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal();
    // 将page对象存到ThreadLocal中
    protected static void setLocalPage(Page page) {
        LOCAL_PAGE.set(page);
    }
    
  2. SQL拦截,PageInterceptor会拦截所有查询SQL,会去TreadLocal中查询是否有Page信息,有则需要进行分页处理;
    核心逻辑代码:PageInterceptor.intercept:

     public Object intercept(Invocation invocation) throws Throwable {
         try {
            .................................
             List resultList;
             // 1. 是否执行分页判断:true 跳过,返回默认查询结果,false 执行分页查询
             if (!this.dialect.skip(ms, parameter, rowBounds)) {
                 this.debugStackTraceLog();
                 // 2.是否执行count 判断
                 if (this.dialect.beforeCount(ms, parameter, rowBounds)) {
                     // 3.执行count 查询
                     Long count = this.count(executor, ms, parameter, rowBounds, (ResultHandler)null, boundSql);
                     // 4.是否继续进行查询判断:如果count =0 就不用再select了,直接返回空数组
                     if (!this.dialect.afterCount(count, parameter, rowBounds)) {
                         Object var12 = this.dialect.afterPage(new ArrayList(), parameter, rowBounds);
                         return var12;
                     }
                 }
                   //5. 对SQL 重新处理,并执行select 分页查询
                 resultList = ExecutorUtil.pageQuery(this.dialect, executor, ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
             } else {
                 resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
             }
    
             Object var16 = this.dialect.afterPage(resultList, parameter, rowBounds);
             return var16;
         } finally {
             ........
    
         }
     }
     
    

注意事项

  1. 必须紧跟查询语句,避免分页参数泄露,或者错误读取.
    PageHelper.startPage() 需直接放在查询方法前,避免中间插入其他逻辑导致 ThreadLocal 被错误读取。

    错误示例一:本身是对queryList分页,当Conditionfalse 时就会错误的对bannerList 分页

    PageHelper.startPage(1,10);
    //当条件不满足时,不执行
    if(condition){
        List<SaBanner> banners = saBannerMapper.queryList();
    }
    bannerMapper.bannerList(null,null);
    return null;
    

    错误示例二:开启了分页,但是没有执行select查询,引发分页参数泄露。当改线程被重用时,并且执行到不用分页的select语句,也会会对改select语句进行分页处理,因为ThreadLocal中有分页信息。

    PageHelper.startPage(1,10);
    //当条件不满足时,不执行
    if(condition){
        List<SaBanner> banners = saBannerMapper.queryList();
    }
    return Collections.emptyList();
    

    错误示例三PageHelper.startPage() 后跟其他逻辑代码,如果中间的逻辑代码报错,那么也会导致 分页拦截器不执行,不能清理分页参数,引发分页参数泄露

    PageHelper.startPage(1,10);
    //如果方法报错
    function();
    List<SaBanner> banners = saBannerMapper.queryList();
    bannerMapper.bannerList(null,null);
    return null;
    
  2. count 覆盖与优化
    (分页插件默认也会对统计sql 简单的优化,比如去除select 末尾的order by,详情见源码:CountSqlParser)。
    在实际开发中,可能会遇到需要优化的分页查询的情况,当 select 无法优化的时候,可以考虑从count入手;

    PageInterceptor.count 代码如下:

    private Long count(Executor executor, MappedStatement ms, Object parameter,
                       RowBounds rowBounds, ResultHandler resultHandler,
                       BoundSql boundSql) throws SQLException {
        String countMsId = countMsIdGen.genCountMsId(ms, parameter, boundSql, countSuffix);
        Long count;
        //先判断是否存在手写的 count 查询,ms.getId() + countSuffix(默认:_COUNT 可配置)
    
        MappedStatement countMs = ExecutorUtil.getExistedMappedStatement(ms.getConfiguration(), countMsId);
        if (countMs != null) {
            count = ExecutorUtil.executeManualCount(executor, countMs, parameter, boundSql, resultHandler);
        } else {
            ...............................
        }
    

二、MyBatis-Plus 分页插件

mybatis-plus 现在也用的很多,plus 分页插件入口是MybatisPlusInterceptor,它会遍历拦截器链。分页插件的具体实现是PaginationInnerInterceptor拦截器中。

使用方式

1.注册插件:

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
    return interceptor;
}

2.mapper 编写SQL写法:分页参数封装到page对象中就行了,具体的SQL实现就不用管分页参数了

public interface BanerMapper extends BaseMapper<Banner> {

    /**
     * 分页列表查询
     * @param page
     * @param req
     * @return
     */
    IPage<Banner> queryList(Page page, @Param("req") QueryReq req);

调用baseMapper写好的方法:

<P extends IPage<T>> P selectPage(P page, @Param(Constants.WRAPPER) Wrapper<T> queryWrapper);

核心原理

分页插件的原理都差不多,都是实现Interceptor对SQL进行拦截,然后再处理。 plus的分页插件执行原理 和 PageHelper的执行原理类似,并且count sql 也能使用覆盖方式,也能通过参数指定,也会对自动生成的count sql 进行优化。

详情见历史文章:mybatis plus 分页原理剖析

分页拦截器实现在PaginationInnerInterceptor.java中,核心方法是beforeQuery:

public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
    IPage<?> page = ParameterUtils.findPage(parameter).orElse(null);
    if (null == page) {
        return;
    }

    // 处理 orderBy 拼接
    boolean addOrdered = false;
    String buildSql = boundSql.getSql();
    List<OrderItem> orders = page.orders();
    if (CollectionUtils.isNotEmpty(orders)) {
        addOrdered = true;
        buildSql = this.concatOrderBy(buildSql, orders);
    }

    // size 小于 0 且不限制返回值则不构造分页sql
    Long _limit = page.maxLimit() != null ? page.maxLimit() : maxLimit;
    if (page.getSize() < 0 && null == _limit) {
        if (addOrdered) {
            PluginUtils.mpBoundSql(boundSql).sql(buildSql);
        }
        return;
    }

    handlerLimit(page, _limit);
    IDialect dialect = findIDialect(executor);

    final Configuration configuration = ms.getConfiguration();
    DialectModel model = dialect.buildPaginationSql(buildSql, page.offset(), page.getSize());
    PluginUtils.MPBoundSql mpBoundSql = PluginUtils.mpBoundSql(boundSql);

    List<ParameterMapping> mappings = mpBoundSql.parameterMappings();
    Map<String, Object> additionalParameter = mpBoundSql.additionalParameters();
    model.consumers(mappings, configuration, additionalParameter);
    mpBoundSql.sql(model.getDialectSql());
    mpBoundSql.parameterMappings(mappings);
}

注意事项

1.count 覆盖与优化PageHelper插件类似自动构建的count sql 某些情况不会进行优化。

哪些情况count 优化限制

  1. SQL 中 有 这些集合操作的 INTERSECT,EXCEPT,MINUS,UNION 直接不优化count
  2. 包含groupBy 不去除orderBy
  3. order by 里带参数,不去除order by
  4. 查看select 字段中是否动态条件,如果有条件字段,则不会优化 Count SQL
  5. 包含 distinct、groupBy不优化
  6. 如果 left join 是子查询,并且子查询里包含 ?(代表有入参) 或者 where 条件里包含使用 join 的表的字段作条件,就不移除 join
  7. 如果 where 条件里包含使用 join 的表的字段作条件,就不移除 join
  8. 如果 join 里包含 ?(代表有入参) 就不移除 join

详情见历史文章:mybatis plus 分页原理剖析

三、RowBounds分页

MyBatis的RowBounds分页方式是一个用于基于内存的分页,它包含两个属性offset和limit,分别表示分页查询的偏移量和每页查询的数据条数。

使用方式

在使用RowBounds进行逻辑分页的时候,我们的SQL语句中是不需要指定分页参数的,在查询的时候,将RowBounds当做一个参数传递:

1.查询SQL getUserList:

<select id="getUsers" resultType="User">
    select * from user
</select>

2.执行分页查询:

int offset = 10; 
// 偏移量
int limit = 5;
// 每页数据条数
RowBounds rowBounds = new RowBounds(offset, limit);
List<User> userList = sqlSession.selectList("getUserList", null, rowBounds);

核心原理

MyBatis的RowBounds分页方式是一个用于基于内存的分页。会根据 RowBounds 对象中的 offset 和 limit 属性,对结果集进行截取。这个过程主要在 DefaultResultSetHandler 类的 handleRowValues 方法中实现。以下是简化后的代码逻辑::

private void handleRowValues(ResultSetWrapper rsw, ResultMap resultMap, ResultHandler<?> resultHandler, RowBounds rowBounds, ResultMapping parentMapping) throws SQLException {
    if (resultMap.hasNestedResultMaps()) {
        ensureNoRowBounds();
        checkResultHandler();
        handleRowValuesForNestedResultMap(rsw, resultMap, resultHandler, rowBounds, parentMapping);
    } else {
        handleRowValuesForSimpleResultMap(rsw, resultMap, resultHandler, rowBounds, parentMapping);
    }
}

private void handleRowValuesForSimpleResultMap(ResultSetWrapper rsw, ResultMap resultMap, ResultHandler<?> resultHandler, RowBounds rowBounds, ResultMapping parentMapping) throws SQLException {
    DefaultResultContext<Object> resultContext = new DefaultResultContext<>();
    skipRows(rsw.getResultSet(), rowBounds);
    while (shouldProcessMoreRows(resultContext, rowBounds) && rsw.getResultSet().next()) {
        ResultMap discriminatedResultMap = resolveDiscriminatedResultMap(rsw.getResultSet(), resultMap, null);
        Object rowValue = getRowValue(rsw, discriminatedResultMap);
        storeObject(resultHandler, resultContext, rowValue, parentMapping, rsw.getResultSet());
    }
}
//根据 `offset` 属性,跳过前面的记录
private void skipRows(ResultSet rs, RowBounds rowBounds) throws SQLException {
    if (rs.getType() != ResultSet.TYPE_FORWARD_ONLY) {
        if (rowBounds.getOffset() != RowBounds.NO_ROW_OFFSET) {
            rs.absolute(rowBounds.getOffset());
        }
    } else {
        for (int i = 0; i < rowBounds.getOffset(); i++) {
            rs.next();
        }
    }
}
//根据 `limit` 属性,判断是否已经获取到足够的记录
private boolean shouldProcessMoreRows(ResultContext<?> context, RowBounds rowBounds) {
    return !context.isStopped() && context.getResultCount() < rowBounds.getLimit();
}

注意事项

不推荐使用
在查询出所有数据后在内存中进行分页,当数据量较大时,会占用大量的内存。

四、基于 SQL 语句的分页

这个就是最原始的方法了,相信大家都清楚,就不细说了。
查询SQL:

<select id="getUsersByPage" resultType="User">
    SELECT * FROM users
    LIMIT #{offset}, #{limit}
</select>

需要统计总数,就再写一个count SQL ,where条件一样就行了。

总结

目前我收集到的就这四种,同时也给大家分析了每种插件的原理,以及使用插件分页时候的注意事项优化手段,还不赶快点赞收藏!🧡