PageHelper:基于拦截器实现的SQL分页查询工具

1. 前言

PageHelper 是 MyBatis 的一个开源、免费、强大的物理分页插件。它在 GitHub 上非常受欢迎,拥有超过 12k 的 star,是国内 Java 开发者社区中最主流的分页解决方案之一。

  • 官网/GitHub地址github.com/pagehelper/...
  • 核心原理 : 通过 MyBatis 提供的拦截器(Interceptor)接口,在 SQL 执行前,动态地拼接上数据库特定的分页语句(如 MySQL 的 LIMIT,Oracle 的 ROWNUM)。

2. 手动实现分页查询

在没有分页插件的情况下,实现分页需要做两件事:

  1. 写两条 SQL:一条查询总记录数(COUNT),一条查询当前页的数据(带 LIMIT)。
  2. 手动计算分页参数(如 startIndex = (pageNum - 1) * pageSize)。

我们以一个简单的用户查询为例。

2.1 定义分页结果封装类

java 复制代码
@Data
public class PageResult<T> {
    // 当前页数据列表
    private List<T> list;
    // 总记录数
    private long total;
    // 当前页码
    private int pageNum;
    // 每页数量
    private int pageSize;
    // 总页数
    private int totalPage;

    /**
     * 计算总页数的构造方法
     */
    public PageResult(List<T> list, long total, int pageNum, int pageSize) {
        this.list = list;
        this.total = total;
        this.pageNum = pageNum;
        this.pageSize = pageSize;
        // 核心计算:总页数 = (总记录数 + 每页大小 - 1) / 每页大小
        this.totalPage = (int) ((total + pageSize - 1) / pageSize);
    }
}

2.2 Mapper 接口

java 复制代码
public interface UserMapper {
    /**
     * 根据偏移量和限制查询用户列表 (用于分页数据)
     * @param offset 起始索引(从0开始)
     * @param limit 每页记录数
     * @return 用户列表
     */
    List<User> selectUserList(@Param("offset") int offset, @Param("limit") int limit);

    /**
     * 查询用户总数量(用于分页)
     * @return 总数量
     */
    Long selectUserCount();
}

2.3 Mapper XML 文件

xml 复制代码
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.UserMapper">

    <select id="selectUserList" resultType="User">
        SELECT id, name, email
        FROM user
        ORDER BY id ASC  -- 排序很重要,确保分页结果顺序一致
        LIMIT #{offset}, #{limit}  -- MySQL 分页语法:LIMIT 起始索引, 记录数
    </select>

    <select id="selectUserCount" resultType="java.lang.Long">
        SELECT COUNT(*)
        FROM user
    </select>
</mapper>

3. Github PageHelper 实现分页查询

3.1 引入依赖

在项目的 pom.xml 中添加依赖(以 Maven 为例)。

xml 复制代码
<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>2.1.0</version> 
</dependency>

对于非 Spring Boot 项目,需要添加 pagehelper 依赖并手动配置 MyBatis 拦截器。

3.2 配置(application.yml)

Spring Boot 已经提供了自动化配置,通常只需少量配置即可。

yaml 复制代码
# application.yml
pagehelper:
  helper-dialect: mysql # 指定数据库方言,不指定时会自动检测
  reasonable: true # 分页参数合理化。当pageNum<=0时,查询第一页;当pageNum>总页数时,查询最后一页
  support-methods-arguments: true # 支持通过 Mapper 接口参数来传递分页参数

3.3 在代码中使用

这是最核心的部分,用法非常简单。

java 复制代码
@Service
public class UserServiceImpl implements UserService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public PageInfo<User> getUserList(int pageNum, int pageSize) {
        
        // 魔法发生在这里!关键代码:启动分页
        // 这行代码必须紧贴在执行查询的语句之前,中间不能有其它SQL执行,否则会失效。
        PageHelper.startPage(pageNum, pageSize);
        
        // 这是一个普通的查询所有记录的SQL,但会被PageHelper自动分页
        List<User> userList = userMapper.selectAllUsers();
        
        // 用 PageInfo 对结果进行包装,PageInfo 包含了非常全面的分页信息
        PageInfo<User> pageInfo = new PageInfo<>(userList);
        
        return pageInfo;
    }
}
  • 注意点: 这里的startPage(pageNum, pageSize)其实有个缺省参数,本质上等于startPage(pageNum, pageSize,true),这里的true的作用是执行一下count计数操作。

3.4 理解返回结果

PageInfo 对象包含了所有分页相关的信息,非常适合直接返回给前端。它的常用属性如下:

java 复制代码
@RestController
public class UserController {
    @Autowired
    private UserService userService;

    @GetMapping("/users")
    public PageInfo<User> getUsers(@RequestParam(defaultValue = "1") int pageNum,
                                   @RequestParam(defaultValue = "10") int pageSize) {
        PageInfo<User> pageInfo = userService.getUserList(pageNum, pageSize);
        
        // pageInfo 包含的信息示例:
        // pageInfo.getList()        -> 当前页的数据列表 (List<User>)
        // pageInfo.getTotal()       -> 总记录数 (long)
        // pageInfo.getPageNum()     -> 当前页码 (int)
        // pageInfo.getPageSize()    -> 每页显示数量 (int)
        // pageInfo.getPages()       -> 总页数 (int)
        // pageInfo.isIsFirstPage()  -> 是否是第一页 (boolean)
        // pageInfo.isIsLastPage()   -> 是否是最后一页 (boolean)
        // pageInfo.getPrePage()     -> 上一页页码 (int)
        // pageInfo.getNextPage()    -> 下一页页码 (int)
        // ... 还有更多导航页码等信息
        
        return pageInfo;
    }
}

3.5 高级用法

3.5.1 带条件的分页查询

PageHelper 与 MyBatis 的动态 SQL(如 where 条件)完美结合。

java 复制代码
// 在 Mapper 接口中定义带条件的查询
List<User> selectUsersByName(@Param("name") String name);

// Service 中使用
public PageInfo<User> getUsersByName(String name, int pageNum, int pageSize) {
    PageHelper.startPage(pageNum, pageSize);
    List<User> userList = userMapper.selectUsersByName(name);
    return new PageInfo<>(userList);
}

3.5.2 排序 Order By

可以使用 PageHelperorderBy 方法进行排序。

java 复制代码
PageHelper.startPage(pageNum, pageSize).orderBy("create_time desc");
List<User> userList = userMapper.selectAllUsers();

3.5.3 doSelectPage 方法

为了避免在 PageHelper.startPage 和查询语句之间误执行其他 SQL,可以使用 lambda 表达式风格,更加安全。

arduino 复制代码
java
public PageInfo<User> getUserListSafe(int pageNum, int pageSize) {
    // 使用 PageHelper 的 lambda 方法,将查询逻辑封装在里面
    PageInfo<User> pageInfo = PageHelper.startPage(pageNum, pageSize)
            .doSelectPageInfo(() -> userMapper.selectAllUsers()); // 自动返回 PageInfo
    return pageInfo;
}

4. 原理解析

4.1 核心入口:PageHelper

我们常用的 PageHelper.startPage(int pageNum, int pageSize) 方法,其核心作用是将分页参数存入当前线程的 ThreadLocal 变量中

java 复制代码
// com.github.pagehelper.PageHelper
public abstract class PageHelper {
    // 关键!一个ThreadLocal变量,用于存储分页参数,保证线程安全
    protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<>();

    public static <E> Page<E> startPage(int pageNum, int pageSize) {
        // 创建一个Page对象,包含页码和大小
        Page<E> page = new Page<>(pageNum, pageSize);
        // 将page对象设置到当前线程的ThreadLocal中
        setLocalPage(page);
        return page;
    }

    protected static void setLocalPage(Page page) {
        LOCAL_PAGE.set(page);
    }
    // ... 其他方法,如 getLocalPage()
}

关键点ThreadLocal 确保了每个线程都有自己的分页参数副本,多线程环境下不会相互干扰。

4.2 核心引擎:PageInterceptor 拦截器

这是 PageHelper 执行的关键,它实现了 MyBatis 的 Interceptor 接口,会在 Executor 执行 SQL 时进行拦截。

java 复制代码
// com.github.pagehelper.PageInterceptor
@Intercepts(@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}))
public class PageInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        try {
            Object[] args = invocation.getArgs();
            MappedStatement ms = (MappedStatement) args[0]; // 获取MappedStatement
            Object parameter = args[1];
            RowBounds rowBounds = (RowBounds) args[2];

            // 1. 判断是否需要分页:从ThreadLocal中获取Page对象
            Page page = PageHelper.getLocalPage();
            if (page != null) {
                // 2. 关键!清除ThreadLocal,防止重复分页
                PageHelper.clearPage();

                // 3. 判断是否是支持分页的SQL(例如,不是存储过程)
                if (isSupportMethod(ms, parameter)) {
                    // 4. 执行分页逻辑
                    return doProcessPage(ms, parameter, rowBounds, invocation, page);
                }
            }
            // 如果不分页,直接执行原方法
            return invocation.proceed();
        } finally {
            // 确保在任何情况下都清空ThreadLocal,防止内存泄漏
            PageHelper.clearPage();
        }
    }

    // ... 其他方法
}

4.3 分页处理核心:doProcessPage 方法

这个方法完成了最核心的工作:

java 复制代码
private Object doProcessPage(...) throws Throwable {
    // 1. 获取原始的SQL和参数
    String originalSql = getOriginalSql(ms, parameter, rowBounds);
    Object parameterObject = getParameterObject(ms, parameter, rowBounds);

    // 2. 创建 Count MS 并执行 COUNT 查询
    MappedStatement countMs = createCountMappedStatement(ms);
    Long total = (Long) executeCountQuery(countMs, parameterObject, originalSql);

    // 3. 计算总页数
    int totalPage = (int) ((total + page.getPageSize() - 1) / page.getPageSize());
    page.setTotal(total);
    page.setPages(totalPage);

    // 4. 生成分页SQL(例如,将 SELECT * FROM table 变成 SELECT * FROM table LIMIT 0, 10)
    String pageSql = generatePageSql(originalSql, page, dialect);

    // 5. 将生成的分页SQL设置为新的BoundSql,并替换到参数中
    BoundSql pageBoundSql = createNewBoundSql(ms, parameterObject, pageSql);
    MappedStatement pageMs = createNewMappedStatement(ms, new BoundSqlSqlSource(pageBoundSql));

    // 6. 用新的MappedStatement继续执行查询(此时SQL已经是带LIMIT的了)
    args[0] = pageMs; // 替换MappedStatement
    args[2] = RowBounds.DEFAULT; // 将RowBounds设为默认,因为分页信息已经在SQL里了

    // 7. 执行查询,得到当前页的数据列表
    List resultList = (List) invocation.proceed();

    // 8. 返回一个Page对象,它既包含了数据列表,也包含了分页信息
    return new PageImpl(page, resultList);
}

5. 总结

  1. 设置参数PageHelper.startPage() 将分页参数存入 ThreadLocal
  2. 拦截SQL :MyBatis 执行查询时,PageInterceptor 被触发。
  3. 判断分页 :拦截器从 ThreadLocal 中取出分页参数。如果存在,则进行分页处理。
  4. 执行Count :自动生成并执行 COUNT(*) 查询,获取总数。
  5. 改写SQL :根据数据库方言,将原 SQL 改写成带分页(如 LIMIT)的 SQL。
  6. 执行分页查询:执行改写后的 SQL,得到当前页数据。
  7. 封装结果 :将数据列表和分页信息封装到 Page 对象中返回。
  8. 清理现场 :清除 ThreadLocal,防止污染后续操作。
相关推荐
璨sou2 小时前
IDE集成开发工具-IDEA
后端
程序员小假2 小时前
我们来说一说动态代理
java·后端
武子康3 小时前
大数据-108 Flink 流批一体化入门:概念解析与WordCount代码实践 批数据+流数据
大数据·后端·flink
秦禹辰4 小时前
开源多场景问答社区论坛Apache Answer本地部署并发布至公网使用
开发语言·后端·golang
追逐时光者4 小时前
一款开源免费、组件丰富的 WPF UI 控件库,提供了 100 多款常用控件!
后端·.net
小旺不正经4 小时前
数据库表实现账号池管理
数据库·后端·算法
Penge6664 小时前
结构体内存计算:从字段到中文字符深挖
后端
流星稍逝4 小时前
后端实现增删改查功能
后端
s9123601014 小时前
[rust] temporary value dropped while borrowed
开发语言·后端·rust