PageHelper 分页total失效或当前页含数量不匹配问题解决方案 含原理分析
前言
开发过程中遇到的关于PageHelper失效的问题,一个是因为对PageHelper作用返回的list结果集进行二次操作导致的total总数等于查询到的数量。一个是因为使用了collection 和association 标签处理复杂映射时
PageHelper 分页total失效或当前页含数量不匹配问题解决方案 含原理分析
- 前言
- 1 第一种情况,返回list结果之后进行了二次封装
- 2 问题原因
- 3 解决方式
- 方式1:不要加工了嘛,mapper 返回啥,就直接给调用方返回啥。
- 方式2:直接让 mapper 返回最终返回给调用方的类型,不要在加工的时候生成新的 List 了。
- 方式3:在构造 PageInfo 的时候稍加修改就可以了
- 4 原理分析
- 5 问题二 使用了mybatis的collection或association标签
- 6 问题原因
- 7 解决方案
- 8 原理分析
- 9 源码分析
- 创建分页
- 利用 MyBatis 拦截器机制
- 最后 PageInfo 构造(这步可以明显看到出现total不准的原因)
- 清除线程数据
1 第一种情况,返回list结果之后进行了二次封装
这个问题的流程基本如下:
- 设置分页参数 PageHelper.startPage(1,10)
- 通过一个 Mapper 查询出结果集
- 通过上一步的结果集构造 PageInfo
- 这时候,构造出的 PageInfo 是没问题的
- 真实情况,
还要对结果集进行加工,将结果集转变了类型
- 这时候,通过新的结果集构造 PageInfo,分页信息就是错误的
如下简单的代码逻辑
public PageInfo<DataDetailVo> search(String keyword) {
List<DataDetailVo> voList = new ArrayList<>();
// 1.设置分页,第1页,10条
PageHelper.startPage(1,10);
// 2.查询结果集
List<DataVo> dataVos = xxxMapper.searchDataList(keyword);
// 3.通过上一步的结果集构造 PageInfo
PageInfo<DataVo> pageSuccess = new PageInfo<>(dataVos);
// 结果是对的
log.info("pageSuccess:" + JSON.toJSONString(pageSuccess));
// 4.真实情况,还要对结果集进行加工,将结果集转变了类型
for (DataVo dataVo : dataVos) {
DataDetailVo vo = new DataDetailVo();
BeanUtils.copyProperties(dataVo, vo);
voList.add(vo);
}
// 5.这时候,通过新的结果集构造 PageInfo,分页信息就是错误的
PageInfo<DataDetailVo> pageFail = new PageInfo<>(voList);
log.info("pageFail:" + JSON.toJSONString(pageFail));
return pageFail;
}
2 问题原因
因为第5步构造 PageInfo 时使用了一个新的 List,才导致分页失效的。
3 解决方式
方式1:不要加工了嘛,mapper 返回啥,就直接给调用方返回啥。
方式2:直接让 mapper 返回最终返回给调用方的类型,不要在加工的时候生成新的 List 了。
这种也可以,但是改动可能比较大,因为有的 Mapper 层的方法是供很多其他方法调用的,Mapper 层基本上只需要返回最通用的类型。不能为了某个方法调用方,而让其他调用方也做出改变。
方式3:在构造 PageInfo 的时候稍加修改就可以了
只需要将原本构造错误的 PageInfo
.
PageInfo<DataDetailVo> pageFail = new PageInfo<>(voList);
log.info("pageFail:" + JSON.toJSONString(pageFail));
改为下面这样既可,还是用 Mapper 层返回的dataVos 集合来构造 PageInfo,只不过稍后将加工后的新的List 赋值给 PageInfo 的 list 属性即可。
PageInfo pageSuccess2 = new PageInfo<>(dataVos);
pageSuccess2.setList(voList);
4 原理分析
Mapper 查出来的是一个 List(dataVos),经过加工的也是 List(voList),怎么就一个正常,一个不正常呢,难道这两个 List 有什么不一样的吗?
我先说结论,这俩 List 确实不一样,确切的说,Mapper 查出来的那个 List 是被 PageHelper 包装后的List,再确切的说是 PageHelper 里的 Page 对象。
通过调试代码可以看出来,dataVos 就是一个披着 List 外衣的 Page 对象,你可以直接在这个对象上调用 Page 中的方法,比如 getTotal(),可以直接返回数量的。
如下调试代码:
也有这种样式的调试窗口:
而你自己加工后的集合,就真的是个单纯的 ArrayList 了,所以在使用 PageInfo 构造分页对象的时候,使用自己加工后的集合是绝对不可能获取到真实的分页参数的,比如总条数、总页数等。
5 问题二 使用了mybatis的collection或association标签
如下
public class User {
private Long id;
private String name;
private List<Order> orders; // 一对多关系
// getter和setter方法
}
public class Order {
private Long id;
private String orderName;
// getter和setter方法
}
MyBatis Mapper XML
<resultMap id="userResultMap" type="User">
<id property="id" column="user_id"/>
<result property="name" column="user_name"/>
<collection property="orders" ofType="Order">
<id property="id" column="order_id"/>
<result property="orderName" column="order_name"/>
</collection>
</resultMap>
<select id="selectUsersWithOrders" resultMap="userResultMap">
SELECT u.id AS user_id, u.name AS user_name,
o.id AS order_id, o.order_name
FROM user u
LEFT JOIN order o ON u.id = o.user_id;
</select>
6 问题原因
使用了collection 和association 标签处理复杂映射时,可能会导致 PageHelper 失效。这是因为 PageHelper 的分页逻辑依赖于 SQL 查询的结果集,而 MyBatis 的嵌套映射可能会影响分页的正确性。
7 解决方案
- 拆分查询,手动分页。对主表查询进行分页,其它的再通过关联字段查询 增加代码量
- 在 SQL 中使用 JOIN 查询并手动组装,一次性获取所有数据,然后通过代码组装,代码量剧增 不推荐
- 对需要分页的数据查询之后在结果集中使用collection 和association 标签,在这些标签里写入子查询。将子查询的结果封装进去。(每条数据对应的子查询都需要单独查询,在一些对性能要求较高的场景下,可能有性能更高的解决方案,需要根据情况使用)
如下
<resultMap id="userResultMap" type="User">
<id property="id" column="user_id"/>
<result property="name" column="user_name"/>
<collection property="orders" ofType="Order" select="findByOrderIdLogs" column="id" >
</collection>
</resultMap>
<select id="selectUsersWithOrders" resultMap="userResultMap">
SELECT u.id AS user_id, u.name AS user_name
FROM user u
</select>
<select id="findByOrderIdLogs" resultMap="findLogsMap">
SELECT o.id AS order_id, o.order_name
FROM order o WHERE o.user_id = #{id}
</select>
8 原理分析
PageHelper 的核心原理是通过拦截sql执行,自动在sql语句中加入limit 和offset分句,以实现分页功能。但是如果使用了嵌套查询,如多条记录对应了一个对象,但是PageHelper并不知道这个情况,还会将原来查询的数量返回,导致数量不匹配。
9 源码分析
创建分页
首先通过代码 PageHelper.startPage(1,10);设置分页参数,这个过程很简单,就是初始化 Page 对象,然后存到 ThreadLocal 中。
/**
* 开始分页
*
* @param pageNum 页码
* @param pageSize 每页显示数量
* @param count 是否进行count查询
* @param reasonable 分页合理化,null时用默认配置
* @param pageSizeZero true且pageSize=0时返回全部结果,false时分页,null时用默认配置
*/
public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
Page<E> page = new Page<E>(pageNum, pageSize, count);
page.setReasonable(reasonable);
page.setPageSizeZero(pageSizeZero);
//当已经执行过orderBy的时候
Page<E> oldPage = getLocalPage();
if (oldPage != null && oldPage.isOrderByOnly()) {
page.setOrderBy(oldPage.getOrderBy());
}
setLocalPage(page);
return page;
}
ThreadLocal 是 Java 提供的一种用于保存线程内部局部变量的工具类。它为每个线程提供了独立的变量副本,每个线程只能访问自己存储的变量,互不干扰。这种设计可以避免多个线程之间共享变量时的并发问题。
在 PageHelper 中,分页的上下文需要在多个方法之间传递,但它是和当前线程强相关的。如果直接使用普通成员变量,那么在高并发情况下,多个线程可能会互相干扰。
利用 MyBatis 拦截器机制
然后就是利用了 MyBatis 的拦截器机制,拦截器主要做两件事,第一件就是在查询数据集合前先count一下,把数量查出来。第二件就是将查询出来的数据集包装成 Page 对象,当然了 Page 是继承自 ArrayList 的,要不然它也不能伪装的这么好。
在 PageHelper 源码中有 PageInterceptor.java这个拦截器,主要是里面的 intercept 方法。这里面就是实现核心逻辑的主战场。
public Object intercept(Invocation invocation) throws Throwable {
try {
// ...
List resultList;
//调用方法判断是否需要进行分页,如果不需要,直接返回结果
if (!dialect.skip(ms, parameter, rowBounds)) {
//判断是否需要进行 count 查询
if (dialect.beforeCount(ms, parameter, rowBounds)) {
//查询总数
Long count = count(executor, ms, parameter, rowBounds, null, boundSql);
//处理查询总数,返回 true 时继续分页查询,false 时直接返回
if (!dialect.afterCount(count, parameter, rowBounds)) {
//当查询总数为 0 时,直接返回空的结果
return dialect.afterPage(new ArrayList(), parameter, rowBounds);
}
}
resultList = ExecutorUtil.pageQuery(dialect, executor,
ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
} else {
//rowBounds用参数值,不使用分页插件处理时,仍然支持默认的内存分页
resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
}
// 处理加工后的结果集
return dialect.afterPage(resultList, parameter, rowBounds);
}
}
先判断是否需要进行分页,如果不需要,直接返回结果。也就是这行代码,你可以点进去看一下 skip 这个方法,就是获取 ThreadLocal 中的Page对象,看是不是存在,是不是有分页参数,有的话就是需要分页,没有就直接按照正常的查询走了。
if (!dialect.skip(ms, parameter, rowBounds))
如果需要分页的话,先查询一下数量。
Long count = count(executor, ms, parameter, rowBounds, null, boundSql);
然后根据分页参数,查询分页结果集。
resultList = ExecutorUtil.pageQuery(dialect, executor,
ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
之后对结果集加工并返回。
return dialect.afterPage(resultList, parameter, rowBounds);
最终加工成 Page 的方法,看到没,还是先从 ThreadLocal中拿,然后将原始结果集放进去。
public Object afterPage(List pageList, Object parameterObject, RowBounds rowBounds) {
Page page = getLocalPage();
if (page == null) {
return pageList;
}
page.addAll(pageList);
if (!page.isCount()) {
page.setTotal(-1);
} else if ((page.getPageSizeZero() != null && page.getPageSizeZero()) && page.getPageSize() == 0) {
page.setTotal(pageList.size());
} else if (page.isOrderByOnly()) {
page.setTotal(pageList.size());
}
return page;
}
最后 PageInfo 构造(这步可以明显看到出现total不准的原因)
创建分页时 PageInfo调用了super(list)
在父类的构造方法中,如果时page类型的调用getTotal(),如果不是,则调用size()方法
@SuppressWarnings("unchecked")
public PageSerializable(List<? extends T> list) {
this.list = (List<T>) list;
if(list instanceof Page){
this.total = ((Page<?>)list).getTotal();
} else {
this.total = list.size();
}
}
清除线程数据
public static void clearPage() {
LOCAL_PAGE.remove(); // 清除当前线程的 Page 对象
}
为什么需要手动清理 ThreadLocal?
ThreadLocal 的变量存储在当前线程中,如果线程是线程池中的线程,它会被重复使用。如果不手动清理,可能会导致内存泄漏问题,或者后续任务错误地使用到上一次分页的残留数据。
因此,PageHelper 通常会在分页逻辑结束后调用 clearPage() 方法: