【PageHelper】 【Spring Boot + MyBatis + PageHelper】 完整项目示例+PageHelper核心原理深度解析

文章目录

  • [【Spring Boot + MyBatis + PageHelper 完整项目示例】](#【Spring Boot + MyBatis + PageHelper 完整项目示例】)
    • 一、项目结构
    • 二、代码实现
      • [1. Maven 依赖(pom.xml)](#1. Maven 依赖(pom.xml))
      • [2. 配置文件(application.yml)](#2. 配置文件(application.yml))
      • [3. 数据库初始化脚本(schema.sql)](#3. 数据库初始化脚本(schema.sql))
      • [4. 实体类(User.java)](#4. 实体类(User.java))
      • [5. Mapper 接口(UserMapper.java)](#5. Mapper 接口(UserMapper.java))
      • [6. Mapper XML(UserMapper.xml)](#6. Mapper XML(UserMapper.xml))
      • [7. Service 接口(UserService.java)](#7. Service 接口(UserService.java))
      • [8. Service 实现(UserServiceImpl.java)](#8. Service 实现(UserServiceImpl.java))
      • [9. Controller 层(UserController.java)](#9. Controller 层(UserController.java))
      • [10. 启动类(DemoApplication.java)](#10. 启动类(DemoApplication.java))
    • 三、运行与测试
    • 四、常见报错排查
  • [【PageHelper 核心原理深度解析】](#【PageHelper 核心原理深度解析】)
    • [一、底层基石:MyBatis 拦截器机制](#一、底层基石:MyBatis 拦截器机制)
      • [1. MyBatis 拦截器的核心概念](#1. MyBatis 拦截器的核心概念)
      • [2. PageHelper 的拦截器实现](#2. PageHelper 的拦截器实现)
    • 二、完整分页流程:从参数到结果的全链路
      • [步骤 1:分页参数传递(ThreadLocal 机制)](#步骤 1:分页参数传递(ThreadLocal 机制))
      • [步骤 2:拦截 Executor.query() 方法](#步骤 2:拦截 Executor.query() 方法)
      • [步骤 3:生成 COUNT 查询(获取总记录数)](#步骤 3:生成 COUNT 查询(获取总记录数))
      • [步骤 4:改写原 SQL(添加分页语法)](#步骤 4:改写原 SQL(添加分页语法))
      • [步骤 5:执行分页查询](#步骤 5:执行分页查询)
      • [步骤 6:封装结果并清理 ThreadLocal](#步骤 6:封装结果并清理 ThreadLocal)
    • [三、核心组件:PageHelper 的"左膀右臂"](#三、核心组件:PageHelper 的“左膀右臂”)
      • [1. PageInterceptor(拦截器入口)](#1. PageInterceptor(拦截器入口))
      • [2. SqlUtil(分页逻辑核心)](#2. SqlUtil(分页逻辑核心))
      • [3. Dialect(数据库方言)](#3. Dialect(数据库方言))
    • [四、关键细节:为什么 PageHelper 能"自动"分页?](#四、关键细节:为什么 PageHelper 能“自动”分页?)
      • [1. ThreadLocal 的线程隔离性](#1. ThreadLocal 的线程隔离性)
      • [2. MyBatis 的 BoundSql 可变性](#2. MyBatis 的 BoundSql 可变性)
      • [3. 方言的扩展性](#3. 方言的扩展性)
    • 五、常见问题的原理性解释
      • [1. 为什么 `startPage()` 必须紧跟查询方法?](#1. 为什么 startPage() 必须紧跟查询方法?)
      • [2. 为什么大表 COUNT 查询慢?](#2. 为什么大表 COUNT 查询慢?)
      • [3. 为什么多数据源需要配置 `auto-runtime-dialect`?](#3. 为什么多数据源需要配置 auto-runtime-dialect?)

【Spring Boot + MyBatis + PageHelper 完整项目示例】

下面是一个可直接运行的完整项目代码,包含数据库初始化、依赖配置、各层代码实现及测试步骤。

一、项目结构

复制代码
pagehelper-demo
├── pom.xml                          # Maven 依赖配置
└── src
    └── main
        ├── java
        │   └── com.example.demo
        │       ├── DemoApplication.java       # 启动类
        │       ├── controller
        │       │   └── UserController.java    # Controller 层
        │       ├── service
        │       │   ├── UserService.java       # Service 接口
        │       │   └── impl
        │       │       └── UserServiceImpl.java # Service 实现
        │       ├── mapper
        │       │   └── UserMapper.java        # Mapper 接口
        │       └── entity
        │           └── User.java               # 实体类
        └── resources
            ├── mapper
            │   └── UserMapper.xml              # Mapper XML
            ├── application.yml                 # 配置文件
            └── schema.sql                      # 数据库初始化脚本

二、代码实现

1. Maven 依赖(pom.xml)

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.18</version> <!-- 兼容 PageHelper 的稳定版本 -->
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>pagehelper-demo</artifactId>
    <version>1.0-SNAPSHOT</version>

    <dependencies>
        <!-- Spring Boot Web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- MyBatis -->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.3.2</version>
        </dependency>

        <!-- PageHelper 分页插件 -->
        <dependency>
            <groupId>com.github.pagehelper</groupId>
            <artifactId>pagehelper-spring-boot-starter</artifactId>
            <version>1.4.7</version>
        </dependency>

        <!-- MySQL 驱动 -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.33</version>
        </dependency>

        <!-- Lombok(简化实体类代码) -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
</project>

2. 配置文件(application.yml)

yaml 复制代码
server:
  port: 8080

# 数据库配置
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/pagehelper_demo?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root
    password: your_password  # 请修改为你的数据库密码

# MyBatis 配置
mybatis:
  mapper-locations: classpath:mapper/*.xml  # Mapper XML 文件位置
  type-aliases-package: com.example.demo.entity  # 实体类包路径

# PageHelper 配置
pagehelper:
  helper-dialect: mysql           # 数据库方言
  reasonable: true                # 分页合理化(pageNum<=0 返回第一页,>总页数返回最后一页)
  auto-runtime-dialect: true      # 多数据源自动检测方言

3. 数据库初始化脚本(schema.sql)

启动项目前,先在 MySQL 中执行以下脚本创建数据库和表:

sql 复制代码
-- 创建数据库
CREATE DATABASE IF NOT EXISTS pagehelper_demo DEFAULT CHARACTER SET utf8mb4;
USE pagehelper_demo;

-- 创建用户表
CREATE TABLE IF NOT EXISTS user (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    name VARCHAR(50) NOT NULL COMMENT '姓名',
    age INT COMMENT '年龄',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

-- 插入测试数据(100 条)
INSERT INTO user (name, age) VALUES
('张三', 20), ('李四', 25), ('王五', 30), ('赵六', 28), ('孙七', 35),
('周八', 22), ('吴九', 27), ('郑十', 32), ('钱一', 24), ('陈二', 29),
('张三三', 21), ('李四四', 26), ('王五五', 31), ('赵六六', 28), ('孙七七', 36),
('周八八', 23), ('吴九九', 27), ('郑十十', 33), ('钱一一', 25), ('陈二二', 30),
('张三1', 20), ('李四1', 25), ('王五1', 30), ('赵六1', 28), ('孙七1', 35),
('周八1', 22), ('吴九1', 27), ('郑十1', 32), ('钱一1', 24), ('陈二1', 29),
('张三2', 21), ('李四2', 26), ('王五2', 31), ('赵六2', 28), ('孙七2', 36),
('周八2', 23), ('吴九2', 27), ('郑十2', 33), ('钱一2', 25), ('陈二2', 30),
('张三3', 20), ('李四3', 25), ('王五3', 30), ('赵六3', 28), ('孙七3', 35),
('周八3', 22), ('吴九3', 27), ('郑十3', 32), ('钱一3', 24), ('陈二3', 29),
('张三4', 21), ('李四4', 26), ('王五4', 31), ('赵六4', 28), ('孙七4', 36),
('周八4', 23), ('吴九4', 27), ('郑十4', 33), ('钱一4', 25), ('陈二4', 30),
('张三5', 20), ('李四5', 25), ('王五5', 30), ('赵六5', 28), ('孙七5', 35),
('周八5', 22), ('吴九5', 27), ('郑十5', 32), ('钱一5', 24), ('陈二5', 29),
('张三6', 21), ('李四6', 26), ('王五6', 31), ('赵六6', 28), ('孙七6', 36),
('周八6', 23), ('吴九6', 27), ('郑十6', 33), ('钱一6', 25), ('陈二6', 30);

4. 实体类(User.java)

java 复制代码
package com.example.demo.entity;

import lombok.Data;
import java.time.LocalDateTime;

@Data
public class User {
    private Long id;
    private String name;
    private Integer age;
    private LocalDateTime createTime;
}

5. Mapper 接口(UserMapper.java)

java 复制代码
package com.example.demo.mapper;

import com.example.demo.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;

@Mapper
public interface UserMapper {
    // 根据姓名模糊查询(不传 name 则查询所有)
    List<User> selectByCondition(@Param("name") String name);
}

6. Mapper XML(UserMapper.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.demo.mapper.UserMapper">

    <select id="selectByCondition" resultType="com.example.demo.entity.User">
        SELECT id, name, age, create_time
        FROM user
        <where>
            <if test="name != null and name != ''">
                AND name LIKE CONCAT('%', #{name}, '%')
            </if>
        </where>
        ORDER BY create_time DESC
    </select>

</mapper>

7. Service 接口(UserService.java)

java 复制代码
package com.example.demo.service;

import com.example.demo.entity.User;
import com.github.pagehelper.PageInfo;

public interface UserService {
    PageInfo<User> getUserList(int pageNum, int pageSize, String name);
}

8. Service 实现(UserServiceImpl.java)

java 复制代码
package com.example.demo.service.impl;

import com.example.demo.entity.User;
import com.example.demo.mapper.UserMapper;
import com.example.demo.service.UserService;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;

@Service
public class UserServiceImpl implements UserService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public PageInfo<User> getUserList(int pageNum, int pageSize, String name) {
        // 1. 开启分页(必须紧跟查询方法)
        PageHelper.startPage(pageNum, pageSize);
        
        // 2. 执行查询
        List<User> userList = userMapper.selectByCondition(name);
        
        // 3. 封装为 PageInfo 并返回
        return new PageInfo<>(userList);
    }
}

9. Controller 层(UserController.java)

java 复制代码
package com.example.demo.controller;

import com.example.demo.entity.User;
import com.example.demo.service.UserService;
import com.github.pagehelper.PageInfo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/users")
public class UserController {

    @Autowired
    private UserService userService;

    /**
     * 分页查询用户列表
     * @param pageNum 当前页(默认 1)
     * @param pageSize 每页条数(默认 10)
     * @param name 姓名模糊查询(可选)
     */
    @GetMapping("/list")
    public PageInfo<User> list(
            @RequestParam(defaultValue = "1") int pageNum,
            @RequestParam(defaultValue = "10") int pageSize,
            @RequestParam(required = false) String name) {
        return userService.getUserList(pageNum, pageSize, name);
    }
}

10. 启动类(DemoApplication.java)

java 复制代码
package com.example.demo;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.example.demo.mapper") // 扫描 Mapper 接口
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

三、运行与测试

1. 启动项目

  1. 先在 MySQL 中执行 schema.sql 初始化数据库和数据。
  2. 修改 application.yml 中的数据库密码为你的实际密码。
  3. 运行 DemoApplication.java 启动项目。

2. 测试接口

使用浏览器或 Postman 访问以下接口:

(1)基础分页查询
复制代码
http://localhost:8080/users/list?pageNum=1&pageSize=10

返回结果示例

json 复制代码
{
  "pageNum": 1,
  "pageSize": 10,
  "total": 100,
  "pages": 10,
  "list": [
    {"id": 100, "name": "陈二6", "age": 30, "createTime": "2024-05-20T10:00:00"},
    {"id": 99, "name": "钱一6", "age": 25, "createTime": "2024-05-20T10:00:00"}
    // ... 共 10 条数据
  ],
  "isFirstPage": true,
  "isLastPage": false,
  "prePage": 0,
  "nextPage": 2
}
(2)带条件的分页查询
复制代码
http://localhost:8080/users/list?pageNum=1&pageSize=5&name=张三
(3)测试分页合理化

访问 pageNum=0pageNum=11(超过总页数),会自动返回第一页或最后一页:

复制代码
http://localhost:8080/users/list?pageNum=0&pageSize=10

四、常见报错排查

  1. 分页不生效 :检查 PageHelper.startPage() 是否紧跟在查询方法前,中间无其他数据库操作。
  2. 数据库连接失败 :确认 application.yml 中的数据库地址、用户名、密码正确,且 MySQL 服务已启动。
  3. Mapper 找不到 :检查 @MapperScan 注解的包路径是否正确,或 Mapper 接口是否加了 @Mapper 注解。

【PageHelper 核心原理深度解析】

PageHelper 的核心是基于 MyBatis 拦截器机制 实现的 SQL 自动改写与物理分页。下面从底层机制、完整流程、核心组件三个维度进行详细拆解。


一、底层基石:MyBatis 拦截器机制

PageHelper 本质是一个 MyBatis 拦截器(Interceptor),通过拦截 Executor 的 query 方法,在 SQL 执行前植入分页逻辑。

1. MyBatis 拦截器的核心概念

  • 拦截目标Executor(执行器),负责 SQL 的执行和结果映射。
  • 拦截方法Executor.query(),所有查询操作的入口。
  • 拦截时机:SQL 执行前(预处理阶段)。

2. PageHelper 的拦截器实现

PageHelper 实现了 Interceptor 接口,核心代码逻辑如下:

java 复制代码
@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 {
        // 1. 检查是否需要分页
        // 2. 获取分页参数
        // 3. 改写 SQL(添加分页语法)
        // 4. 执行 COUNT 查询(获取总记录数)
        // 5. 执行分页查询
        // 6. 封装结果并返回
        return invocation.proceed();
    }
}

二、完整分页流程:从参数到结果的全链路

PageHelper 的分页流程可分为 6 个核心步骤,下面逐一解析:

步骤 1:分页参数传递(ThreadLocal 机制)

当调用 PageHelper.startPage(pageNum, pageSize) 时,PageHelper 会将分页参数存入 ThreadLocal,确保同一线程内的后续查询能获取到这些参数。

java 复制代码
// PageHelper 源码简化版
public static <E> Page<E> startPage(int pageNum, int pageSize) {
    Page<E> page = new Page<>(pageNum, pageSize);
    // 将分页参数存入 ThreadLocal
    LOCAL_PAGE.set(page);
    return page;
}

步骤 2:拦截 Executor.query() 方法

PageHelper 的 PageInterceptor 拦截 Executor.query() 方法,检查 ThreadLocal 中是否存在分页参数:

  • 存在:执行分页逻辑。
  • 不存在:直接放行,执行原 SQL。

步骤 3:生成 COUNT 查询(获取总记录数)

为了计算总页数,PageHelper 会先自动生成并执行一条 COUNT 查询

  1. 获取原 SQL :从 MappedStatement 中提取用户编写的 SQL。
  2. 改写 COUNT SQL :将原 SQL 包裹为 SELECT COUNT(0) FROM (原 SQL) tmp
  3. 执行 COUNT 查询 :获取总记录数 total,存入分页参数中。

优化点 :若原 SQL 包含 ORDER BY,PageHelper 会自动移除(避免 COUNT 查询不必要的排序开销)。

步骤 4:改写原 SQL(添加分页语法)

根据不同的数据库方言(Dialect),PageHelper 会在原 SQL 后添加物理分页语法:

  • MySQLLIMIT offset, pageSize
  • OracleROWNUM 嵌套子查询
  • SQL ServerOFFSET ... FETCH NEXT

示例(MySQL)

  • 原 SQL:SELECT * FROM user WHERE age > 18 ORDER BY create_time DESC
  • 改写后:SELECT * FROM user WHERE age > 18 ORDER BY create_time DESC LIMIT 0, 10

步骤 5:执行分页查询

将改写后的 SQL 交给 MyBatis 执行,获取分页后的结果集。

步骤 6:封装结果并清理 ThreadLocal

  1. 封装结果 :将查询结果和分页信息(pageNumpageSizetotalpages 等)封装为 Page 对象(继承 ArrayList)。
  2. 清理 ThreadLocal:防止内存泄漏,确保线程复用时分页参数不会混淆。
java 复制代码
// 结果封装简化逻辑
Page<E> page = LOCAL_PAGE.get();
page.addAll(resultList); // 填充查询结果
LOCAL_PAGE.remove(); // 清理 ThreadLocal
return page;

三、核心组件:PageHelper 的"左膀右臂"

PageHelper 的核心功能由以下 3 个组件协作完成:

1. PageInterceptor(拦截器入口)

  • 职责:拦截 Executor.query() 方法,协调分页流程的执行。
  • 核心逻辑:判断是否分页 → 调用 SqlUtil 处理分页 → 执行查询 → 封装结果。

2. SqlUtil(分页逻辑核心)

  • 职责:SQL 解析、改写、COUNT 查询执行的核心工具类。
  • 核心方法:
    • getPage():从 ThreadLocal 获取分页参数。
    • count():执行 COUNT 查询。
    • pageQuery():改写 SQL 并执行分页查询。

3. Dialect(数据库方言)

  • 职责:定义不同数据库的分页语法实现。
  • 核心实现类:
    • MySqlDialect:MySQL 分页实现。
    • OracleDialect:Oracle 分页实现。
    • SqlServer2012Dialect:SQL Server 2012+ 分页实现。

Dialect 接口定义

java 复制代码
public interface Dialect {
    // 生成 COUNT 查询 SQL
    String getCountSql(MappedStatement ms, BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey countKey);
    // 生成分页查询 SQL
    String getPageSql(MappedStatement ms, BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey pageKey);
}

四、关键细节:为什么 PageHelper 能"自动"分页?

1. ThreadLocal 的线程隔离性

  • 每个线程的分页参数独立存储,避免多线程环境下参数混淆。
  • 查询完成后自动清理,防止内存泄漏。

2. MyBatis 的 BoundSql 可变性

PageHelper 通过修改 BoundSql 对象的 sql 属性,实现 SQL 的动态改写,无需修改用户代码。

3. 方言的扩展性

通过 Dialect 接口,PageHelper 可以轻松支持新的数据库,只需添加对应的方言实现类即可。


五、常见问题的原理性解释

1. 为什么 startPage() 必须紧跟查询方法?

  • 若中间有其他数据库操作,ThreadLocal 中的分页参数可能被"误用"到其他查询上,导致分页错误。

2. 为什么大表 COUNT 查询慢?

  • 默认 COUNT 查询是 SELECT COUNT(0) FROM (原 SQL) tmp,若原 SQL 复杂(如包含多表关联),会导致全表扫描。
  • 解决原理 :自定义 COUNT 查询,直接查主键或索引字段(如 SELECT COUNT(id) FROM user)。

3. 为什么多数据源需要配置 auto-runtime-dialect

  • 若不配置,PageHelper 会使用默认方言,导致在非默认数据库上分页语法错误。
  • 解决原理auto-runtime-dialect 会根据当前 Connection 的元数据自动检测数据库类型,选择对应的方言。

相关推荐
重庆兔巴哥2 小时前
如何在Windows上配置Java环境变量?
java·开发语言·windows
weixin_704266052 小时前
Spring AOP事务控制实战指南
java·后端·spring
JamesYoung79712 小时前
第九部分 — 打包、调试和发布 发布前的打包与发布检查清单(Chrome 应用商店)
前端·chrome
多加点辣也没关系2 小时前
Node.js 与 npm 的安装与配置(详细教程)
前端·npm·node.js
张3蜂2 小时前
OpenClaw如何调用Cursor
前端·chrome
爱敲代码的憨仔2 小时前
SpringAI 集成 MCP
java·windows
KIO no way2 小时前
npm全局安装命令不可用解决方案
服务器·前端·npm·node.js
A923A2 小时前
【Vue3大事件 | 项目笔记】第五天
前端·vue.js·笔记·前端项目
bugcome_com2 小时前
全面入门 ASP.NET:从 Web Pages 到 MVC 与 Web Forms 的系统教程
前端·asp.net·mvc