PageHelper:分页陷阱避免与最佳实践

前言

本文主要介绍在Java SpringBoot项目中使用github的PageHelper分页拦截器可能会导致遇到的问题和解决实践方案。一句话简单来说,在使用Github的PageHeleper分页工具时,需要做两件事:一是要对分页数据做好sql order by 排序,二是要对Page对象进行手动释放

在日常项目开发汇总,我主要是用的java项目ORM层用的是MyBatis。其domain、mapper、mapper.xml等文件使用mybatis generator生成器生成。

在一次日常业务开发过程中遇到了PageHelper使用不规范导致的代码缺陷,由于发现问题的时间比较及时也没有造成较大的损失。这篇文章主要围绕如下两个问题展开:

  1. 由于未对分页查询的SQL语句添加order by 排序,导致分页数据缺失
  2. 由于未对PageHelper生成的page进行手动释放,导致线程资源泄漏和脏数据

首先给出一个不好的代码写法,在下文中会逐步分析和优化:

java 复制代码
@Override
public Page<Info> queryByBizIdAndFuzzyName(Long bizId, String name,Integer pageNum, Integer pageSize) {
    InfoExample example = new InfoExample();
    InfoExample.Criteria criteria = example.createCriteria();
    criteria.andBizIdEqualTo(bizId)
        .andIsDeletedEqualTo(Boolean.FALSE);
    if (StringUtils.isNotBlank(name)) {
        criteria.andNameLike("%" + StringUtils.trim(name) + "%");
    }
    return PageHelper.startPage(pageNum, pageSize, true).doSelectPage(() -> mapper.selectByExample(example)))
}

分页查询需要添加order by进行排序

在文章前言里面提到的代码片段里,我们可以看到这个分页查询并没有规定按什么字段进行排序,也就是说这个代码最终生成的sql只是简单的分页查询,类似下面的几句SQL:

sql 复制代码
(SQL1)SELECT * FROM info_table WHERE biz_id = 13 LIMIT 0, 50;

(SQL2)SELECT * FROM info_table WHERE biz_id = 13 LIMIT 50, 50;

(SQL3)SELECT * FROM info_table WHERE biz_id = 13 LIMIT 100, 50;

上面这几个sql查出来,会有一些重复的情况,导致一些数据不会被查出来。举个例子,有一条数据记录A出现在SQL1的查询结果里面,同时由于MySql查询顺序不确定会导致一些随机性,使得记录A也出现在SQL2的查询结果里面。这样的结果相当于SQL2这个句子查询结果有一条数据是已经查询过了是无效的,即SQL2只查询出了49条有效数据,这显然是一个不合理、不易发现、严重的bug,在一些特殊场景中会导致业务资损。

对上述问题的原因分析如下:

  1. 底层存储机制的固有特性:数据的物理存储顺序(如基于聚簇索引的组织方式)会因插入、删除、更新等写操作而改变。数据库为了查询性能,可能会选择不同的索引路径来执行查询,从而导致不同的结果集顺序。
  2. 并发环境下的不确定性:在多用户并发访问的场景下,事务的隔离级别、锁的获取时机等因素都可能微妙地影响数据的读取顺序。
  3. 数据库引擎回表随机:当这个查询是回表的情况下,会先根据索引查出主键列表->MRR(Multi-Range Read)如果有->然后回表获取数据->limit。这里的问题在于获取主键列表进行limit的时候会出现重复,这个是有点随机的。如果是完全的覆盖索引不回表或者只走主键的话就不会有这种问题了,其他情况应该都是有这个问题的。

解决方案如下:加上order by排序字段

java 复制代码
example.setOrderByClause("id asc");

PageHelper 获得的Page资源需要手动释放

下面这个代码片段使用PageHelper创建了一个分页对象:

java 复制代码
PageHelper.startPage(pageNum, pageSize, true).

涉及到的关键源码如下:

java 复制代码
public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count) {
    return startPage(pageNum, pageSize, count, (Boolean)null, (Boolean)null);
}


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;
}

这里面用到了线程变量ThreadLocal,分页变量的参数都被放入到其中,并且后续会通过拦截器对SQL添加分页参数:

java 复制代码
protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal();

public static <T> Page<T> getLocalPage() {
    return (Page)LOCAL_PAGE.get();
}

因此上面代码会有一个问题,假设startPage方法获得的分页Page没有释放掉,那么这里的ThreadLocal也不会清空,从而造成脏数据污染。可以想象一个场景:

  1. 一个Spring Boot应用使用线程池处理HTTP请求。
java 复制代码
PageHelper.startPage(1, 10); // 在ThreadLocal中设置了分页参数 (pageNum=1, pageSize=10) 
userMapper.selectAll(); // 查询被拦截,SQL被改写为 `SELECT ... LIMIT 0, 10` 
// ... 业务逻辑 // !!!假设此处发生异常,或程序员忘记清理,Page未被关闭 !!! 
// ThreadLocal中的分页参数 (pageNum=1, pageSize=10) 依然存在于线程①的上下文中
  1. 线程①被线程池回收,准备处理下一个请求。
  2. 请求B 进入线程①,执行一个完全不相关的查询:
java 复制代码
// 这个查询本意是获取所有数据,用于生成报表 
reportMapper.generateFullReport();
  1. 然而,由于线程①的ThreadLocal中残留着之前的分页参数,PageInterceptor 拦截器会再次生效! generateFullReport() 方法对应的SQL会被错误地加上 LIMIT 0, 10

对于上述问题一共有如下几种方法解决:

  1. 使用try-with-resources语法,保证Page对象及时关闭。
  2. 使用try-finally语法,手动关闭Page对象
  3. 使用AOP编程创建注解@PageClean,快速关闭Page对象
  4. 使用模版方法,创建通用的PageUtils进行分页查询

try-with-resources自动关闭分页Page对象

java 复制代码
// Service 层方法
public PageInfo<User> getUsersByPage(int pageNum, int pageSize) {
    // 关键:使用 try-with-resources,Page 实现了 AutoCloseable 接口
    try (Page<User> page = PageHelper.startPage(pageNum, pageSize)) {
        // 紧跟着的第一个查询会被自动分页
        List<User> userList = userMapper.selectAllUsers();
        // 用 PageInfo 包装结果,包含分页信息
        return new PageInfo<>(userList);
    }
    // try 块结束后,page.close() 会自动调用,清理 ThreadLocal
}

try-finally手动关闭分页Page对象

java 复制代码
public PageInfo<User> getUsersByPage(int pageNum, int pageSize) {
    // 先调用 startPage
    Page<User> page = PageHelper.startPage(pageNum, pageSize);
    try {
        // 执行查询
        List<User> userList = userMapper.selectAllUsers();
        return new PageInfo<>(userList);
    } finally {
        // 在 finally 块中确保清理,无论是否发生异常
        if (page != null) {
            page.close(); // 内部调用 PageHelper.clearPage()
        }
    }
}
  • 注意点:千万不要写catch块,理论上这里的异常需要正常抛出给上层异常处理代码进行处理,而不是在这里当场消化,这里逻辑需要简单一点,实现分页和资源释放即可。

AOP实现@PageClean注解关闭分页Page对象

  1. 定义注解
java 复制代码
@Target(ElementType.METHOD) // 该注解用于方法
@Retention(RetentionPolicy.RUNTIME) // 运行时保留
public @interface PageClean {
}
  1. 编写AOP切面
java 复制代码
@Aspect
@Component
public class PageCleanAspect {

    /**
     * 定义切点:拦截所有带有 @PageClean 注解的方法
     */
    @Pointcut("@annotation(com.yourpackage.annotation.PageClean)")
    public void pageCleanPointcut() {
    }

    /**
     * 环绕通知:方法执行后清理分页参数
     */
    @Around("pageCleanPointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            // 执行原方法
            return joinPoint.proceed();
        } finally {
            // 无论成功与否,最终都清理 PageHelper 的 ThreadLocal
            PageHelper.clearPage();
        }
    }
}
  1. 在 Service 方法上使用注解
java 复制代码
// Service 层方法
@PageClean // 一个注解替代所有清理代码
public PageInfo<User> getUsersByPage(int pageNum, int pageSize) {
    PageHelper.startPage(pageNum, pageSize);
    List<User> userList = userMapper.selectAllUsers();
    return new PageInfo<>(userList);
}
  • 注意点:使用AOP形式实现分页资源释放会存在一个缺点,由于这里注解是放在方法层面的,整体的粒度会比较粗,并不是一个好的实现方式。比如当一个方法里面存在多个分页查询语句的时候,这里也只能是从整体层面控制分页资源释放,而不是每个分页查询的粒度进行控制。
  • 注意点:AOP如果是动态代理的形式,对性能或多或少也会造成损失。

模版方法PageUtils关闭分页Page对象

将分页查询的固定套路封装成一个工具类,是 DRY(Don't Repeat Yourself)原则的完美实践。

  1. 创建分页工具类 PageUtils
java 复制代码
public class PageUtils {

    /**
     * 分页查询模板方法
     * @param pageNum   页码
     * @param pageSize  每页大小
     * @param query     具体的查询逻辑(一个Supplier函数)
     * @param <T>       查询结果类型
     * @return PageInfo<T>
     */
    public static <T> PageInfo<T> doPage(int pageNum, int pageSize, Supplier<List<T>> query) {
        // 使用 try-with-resources 保证安全
        try (Page<T> page = PageHelper.startPage(pageNum, pageSize)) {
            // 执行传入的查询逻辑
            List<T> list = query.get();
            return new PageInfo<>(list);
        }
    }
}
  1. 在Service中调用模版方法
java 复制代码
// Service 层方法
public PageInfo<User> getUsersByPage(int pageNum, int pageSize) {
    // 调用工具类,传入查询逻辑(通常是一个lambda表达式)
    return PageUtils.doPage(pageNum, pageSize, () -> userMapper.selectAllUsers());
}

总结

总的来说,在使用PageHelper进行分页查询的时候,需要注意如下两个事情:

  1. 分页查询语句是否添加了order by排序
  2. 分页查询如果是拦截器使用了线程变量,要观察每次查询完毕是否清空资源对象

另外,下面是上文提到的几种分页资源释放解决方案的对比分析,仅供参考:

方法 优点 缺点 推荐度
1. try-with-resources 简洁、安全、现代 需JDK1.7+ ⭐⭐⭐⭐⭐ (首选)
2. try-finally 可靠、兼容性好 代码稍冗长 ⭐⭐⭐⭐ (备用)
3. AOP注解 解耦、代码干净 需AOP知识,配置稍复杂 ⭐⭐⭐ (团队规范)
4. 模板方法 复用性强,强制规范 灵活性略有下降 ⭐⭐⭐⭐⭐ (强烈推荐)
相关推荐
我命由我1234517 分钟前
Java 并发编程 - Delay(Delayed 概述、Delayed 实现、Delayed 使用、Delay 缓存实现、Delayed 延迟获取数据实现)
java·开发语言·后端·缓存·java-ee·intellij-idea·intellij idea
我是天龙_绍2 小时前
java 比对两对象大小 重写 comparator
后端
IT_陈寒2 小时前
Python 3.12新特性实测:10个让你的代码提速30%的隐藏技巧 🚀
前端·人工智能·后端
BingoGo2 小时前
从零开始打造 Laravel 扩展包:开发、测试到发布完整指南
后端·php
9号达人2 小时前
普通公司对账系统的现实困境与解决方案
java·后端·面试
golang学习记2 小时前
Go 1.26 新特性:netip.Prefix.Compare —— 标准化 IP 子网排序能力
后端
花落已飘2 小时前
openEuler容器化实践:从Docker入门到生产部署
后端
Cache技术分享2 小时前
233. Java 集合 - 遍历 Collection 中的元素
前端·后端
回家路上绕了弯3 小时前
五分钟内重复登录 QQ 号定位:数据结构选型与高效实现方案
分布式·后端