分页插件
导学
本节学习目标:
- 理解RowBounds逻辑分页的原理与局限性
- 掌握物理分页插件的核心实现机制(拦截器改写SQL)
- 通过SQL日志对比两种分页方式的执行差异
- 能够根据数据量选择合适的分页策略
定义
分页是数据查询中最常见的需求之一。MyBatis提供两种分页方案,解决不同规模数据集的结果集截取痛点:
- RowBounds逻辑分页:在内存中对全量结果做截取,实现简单但性能差
- 物理分页插件:在SQL层面添加
LIMIT子句,只查询所需页数据,性能高但需插件支持
选择合适的分页方式,直接决定了大数据量查询的响应速度与内存占用。
核心原理
逻辑分页 vs 物理分页流程对比
RowBounds逻辑分页机制
RowBounds是MyBatis内置的分页对象,包含两个属性:
offset:跳过的记录数limit:返回的最大记录数
使用方式:
RowBounds rowBounds = new RowBounds(0, 2); // 第1页,每页2条
List<Student> list = sqlSession.selectList("selectStudents", null, rowBounds);
本质:MyBatis在ResultHandler中判断已处理行数,小于offset则跳过,达到limit则停止遍历。数据库仍然执行全表查询,所有数据都经过网络传输和JDBC解析。
物理分页插件原理(以PageHelper为例)
物理分页插件基于MyBatis的**拦截器(Interceptor)**机制,核心流程:
- 拦截:通过
@Intercepts注解拦截Executor.query()方法 - 提取:从ThreadLocal或方法参数中获取分页参数(页码、页大小)
- 解析:使用JSqlParser等工具解析原始SQL的SELECT语句
- 改写:在SQL末尾追加数据库方言对应的
LIMIT子句(MySQL用LIMIT offset, limit,Oracle用ROWNUM等) - 执行:将改写后的SQL交给数据库执行
- 统计:可选地执行
COUNT(*)查询,封装PageInfo返回总页数、总记录数等元数据
完整示例
场景说明
乐途公司人事系统需要分页展示员工列表。本节对比RowBounds和PageHelper两种方案,观察SQL输出差异与内存行为。
操作前的数据库表结构及初始数据
CREATE TABLE student (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(20),
age INT,
major VARCHAR(20),
score DECIMAL(5,2)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO student (name, age, major, score) VALUES
('大翔', 22, '计算机科学', 95.5),
('白歌', 21, '软件工程', 88.0),
('小崔', 20, '计算机科学', 92.0),
('黄俪', 21, '信息安全', 90.5),
('李眉', 22, '软件工程', 87.0);
当前数据状态:
| id | name | age | major | score |
|---|---|---|---|---|
| 1 | 大翔 | 22 | 计算机科学 | 95.50 |
| 2 | 白歌 | 21 | 软件工程 | 88.00 |
| 3 | 小崔 | 20 | 计算机科学 | 92.00 |
| 4 | 黄俪 | 21 | 信息安全 | 90.50 |
| 5 | 李眉 | 22 | 软件工程 | 87.00 |
完整代码与配置
pom.xml依赖
<dependencies>
<!-- MyBatis -->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.13</version>
</dependency>
<!-- MySQL驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<!-- PageHelper分页插件 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper</artifactId>
<version>5.3.2</version>
</dependency>
</dependencies>
mybatis-config.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<settings>
<setting name="logImpl" value="SLF4J"/>
</settings>
<!-- 注册PageHelper插件 -->
<plugins>
<plugin interceptor="com.github.pagehelper.PageInterceptor">
<!-- 配置MySQL方言 -->
<property name="helperDialect" value="mysql"/>
<!-- 分页合理化,页码<1时查第一页,页码>总页数时查最后一页 -->
<property name="reasonable" value="true"/>
</plugin>
</plugins>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="com.mysql.cj.jdbc.Driver"/>
<property name="url" value="jdbc:mysql://localhost:3306/fly_db?useSSL=false&serverTimezone=UTC"/>
<property name="username" value="root"/>
<property name="password" value="123456"/>
</dataSource>
</environment>
</environments>
<mappers>
<mapper resource="com/fly/mapper/StudentMapper.xml"/>
</mappers>
</configuration>
StudentMapper.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.fly.mapper.StudentMapper">
<!-- 无分页参数的普通查询 -->
<select id="selectAll" resultType="com.fly.entity.Student">
SELECT id, name, age, major, score FROM student
</select>
<!-- 按专业查询 -->
<select id="selectByMajor" resultType="com.fly.entity.Student">
SELECT id, name, age, major, score FROM student WHERE major = #{major}
</select>
</mapper>
Student.java
package com.fly.entity;
public class Student {
private Integer id;
private String name;
private Integer age;
private String major;
private Double score;
// Getter与Setter省略
}
PageCompareDemo.java
package com.fly.demo;
import com.fly.entity.Student;
import com.fly.mapper.StudentMapper;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.RowBounds;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import java.io.InputStream;
import java.util.List;
public class PageCompareDemo {
public static void main(String[] args) throws Exception {
String resource = "mybatis-config.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
System.out.println("========== 方式一:RowBounds 逻辑分页 ==========");
testRowBounds(sqlSessionFactory);
System.out.println("\n========== 方式二:PageHelper 物理分页 ==========");
testPageHelper(sqlSessionFactory);
}
static void testRowBounds(SqlSessionFactory sqlSessionFactory) {
try (SqlSession session = sqlSessionFactory.openSession()) {
StudentMapper mapper = session.getMapper(StudentMapper.class);
// RowBounds(offset=0, limit=2):第1页,每页2条
RowBounds rowBounds = new RowBounds(0, 2);
List<Student> list = session.selectList(
"com.fly.mapper.StudentMapper.selectAll",
null,
rowBounds
);
System.out.println("当前页数据:");
for (Student s : list) {
System.out.println(" " + s.getId() + " | " + s.getName() + " | " + s.getMajor());
}
System.out.println("返回条数:" + list.size());
// RowBounds不返回总条数,需额外查询
}
}
static void testPageHelper(SqlSessionFactory sqlSessionFactory) {
try (SqlSession session = sqlSessionFactory.openSession()) {
StudentMapper mapper = session.getMapper(StudentMapper.class);
// PageHelper物理分页:第1页,每页2条
PageHelper.startPage(1, 2);
List<Student> list = mapper.selectAll();
// 封装分页元数据
PageInfo<Student> pageInfo = new PageInfo<>(list);
System.out.println("当前页数据:");
for (Student s : list) {
System.out.println(" " + s.getId() + " | " + s.getName() + " | " + s.getMajor());
}
System.out.println("返回条数:" + list.size());
System.out.println("总记录数:" + pageInfo.getTotal());
System.out.println("总页数:" + pageInfo.getPages());
System.out.println("当前页码:" + pageInfo.getPageNum());
System.out.println("每页大小:" + pageInfo.getPageSize());
}
}
}
实际执行结果
控制台SQL日志输出:
========== 方式一:RowBounds 逻辑分页 ==========
[com.fly.mapper.StudentMapper.selectAll] - ==> Preparing: SELECT id, name, age, major, score FROM student
[com.fly.mapper.StudentMapper.selectAll] - ==> Parameters:
[com.fly.mapper.StudentMapper.selectAll] - <== Total: 5
当前页数据:
1 | 大翔 | 计算机科学
2 | 白歌 | 软件工程
返回条数:2
========== 方式二:PageHelper 物理分页 ==========
[com.fly.mapper.StudentMapper.selectAll_COUNT] - ==> Preparing: SELECT count(0) FROM student
[com.fly.mapper.StudentMapper.selectAll_COUNT] - ==> Parameters:
[com.fly.mapper.StudentMapper.selectAll_COUNT] - <== Total: 1
[com.fly.mapper.StudentMapper.selectAll] - ==> Preparing: SELECT id, name, age, major, score FROM student LIMIT ?
[com.fly.mapper.StudentMapper.selectAll] - ==> Parameters: 2(Integer)
[com.fly.mapper.StudentMapper.selectAll] - <== Total: 2
当前页数据:
1 | 大翔 | 计算机科学
2 | 白歌 | 软件工程
返回条数:2
总记录数:5
总页数:3
当前页码:1
每页大小:2
分析
| 对比维度 | RowBounds逻辑分页 | PageHelper物理分页 |
|---|---|---|
| 执行的SQL | SELECT * FROM student | SELECT ... LIMIT 2 + SELECT count(0) |
| 数据库返回行数 | 5条(全表) | 2条(仅当前页) |
| 网络传输量 | 全量数据 | 当前页数据 |
| 内存占用 | 全量数据进入内存后截取 | 仅当前页数据 |
| 总记录数获取 | 需额外手动查询 | 自动执行COUNT,封装到PageInfo |
| 适用数据量 | 小数据量(<1000条) | 任意数据量,大数据量必选 |
| 代码侵入性 | 需传入RowBounds参数 | ThreadLocal传参,Mapper接口无感知 |
- RowBounds:数据库执行无
LIMIT的全表查询,返回全部5条记录,MyBatis在内存中只取前2条。当日志显示Total: 5时,说明JDBC结果集确实遍历了5行 - PageHelper:自动改写SQL为
LIMIT 2,数据库仅返回2条;同时自动发起COUNT查询获取总记录数5,计算总页数3。当日志显示Total: 2时,说明数据库只返回了当前页数据
关键结论:数据量较小时,RowBounds简单可用;数据量超过千级或万级时,必须使用物理分页,否则全表查询会导致数据库压力和内存溢出。
易错场景与常见误区
| 误区 | 正解 |
|---|---|
| RowBounds是物理分页 | RowBounds是逻辑分页,数据库仍执行全表查询,只是MyBatis在内存中跳过前offset条 |
| PageHelper对任何查询都自动分页 | PageHelper通过ThreadLocal传递分页参数,必须在查询前一行调用startPage,且只对紧接着的第一个查询生效 |
| 物理分页插件返回的List就是全部数据 | 物理分页返回的List只包含当前页数据,总记录数需通过PageInfo获取 |
| RowBounds不需要数据库支持 | RowBounds确实不依赖数据库方言,但代价是性能差;这不是优势,是妥协 |
| 一个线程中多次调用startPage会叠加生效 | 每次startPage只影响下一个查询,之后自动清理ThreadLocal;连续调用会覆盖 |
反例:PageHelper位置放错导致分页失效
// 反例:startPage放在查询之后,分页不生效
List<Student> list = mapper.selectAll(); // 先查询
PageHelper.startPage(1, 2); // 错误!startPage必须在查询之前
// 此时list包含全部5条记录,没有分页
// 正解
PageHelper.startPage(1, 2); // 先设置分页参数
List<Student> list = mapper.selectAll(); // 再查询,SQL会被改写为LIMIT 2
反例:RowBounds用于大数据量查询
// 反例:10万条数据用RowBounds
RowBounds rowBounds = new RowBounds(99900, 100);
List<Student> list = session.selectList("selectAll", null, rowBounds);
// 数据库返回10万条,网络传输和内存占用爆炸,可能导致OOM
正解:超过1000条的数据分页,必须使用物理分页插件。
面试考点
Q1:MyBatis分页方式有哪些?RowBounds是物理分页还是逻辑分页?
两种:RowBounds逻辑分页和物理分页插件(如PageHelper)。RowBounds是逻辑分页,它在内存中对全量结果做截取,数据库仍执行全表查询。物理分页插件通过拦截器改写SQL,在数据库层面只查询当前页数据。
Q2:PageHelper的实现原理是什么?
PageHelper基于MyBatis插件机制(Interceptor),拦截
Executor.query()方法。在查询执行前,从ThreadLocal获取分页参数,使用JSqlParser解析并改写原始SQL,追加对应数据库方言的LIMIT子句。同时自动发起COUNT查询,将总记录数、总页数等封装到PageInfo中返回。
Q3:RowBounds在什么场景下可以使用?
仅适用于数据量极小(通常几百条以内)且 simplicity 优先的场景。优点是零依赖、零配置、不依赖数据库方言;缺点是数据库压力和网络开销与数据量成正比,大数据量下会导致严重的性能问题和内存风险。
Q4:BATCH执行器和物理分页插件能一起使用吗?
可以,但需注意BATCH模式仅对insert/update/delete有效,分页针对的是select查询,两者作用于不同类型的语句,通常不会直接冲突。但若在BATCH Session中执行分页查询,行为可能不可预期,建议批量写和分页读使用不同的SqlSession。
小结
分页是ORM框架的必修课。RowBounds以简单性换取性能,适合小数据量的快速开发;物理分页插件以拦截器改写SQL的方式,在数据库层面精准控制返回数据量,是生产环境的标准选择。理解两者的SQL输出差异、内存行为差异和适用边界,才能在项目中做出正确决策。
下一章引子
至此,MyBatis缓存与性能优化的核心内容已完整覆盖:从SqlSession级的一级缓存,到Mapper级的二级缓存及其精细配置,再到自定义缓存对接企业级中间件,以及Executor执行器类型和分页插件的选型。掌握这些机制,你就能在实际项目中精准定位性能瓶颈,选择最合适的优化策略。下一章将进入MyBatis的插件与扩展机制,学习如何编写自己的拦截器,在SQL执行的关键节点植入自定义逻辑。