本文由Gemini生成
在 Java 后端开发中,MyBatis PageHelper 几乎是分页功能的“默认标准”。它使用起来极其顺手,一行代码就能搞定分页。
但你是否真的读懂了它?
- 为什么写了一句
startPage,查询结果就变了? - 为什么
Page对象明明没被操作,里面却装满了数据? - 如果我想把数据库里的 1000 万条数据全部遍历出来,该怎么用 PageHelper 实现才不会内存溢出?
- 前端为什么老是抱怨拿不到
total总数?
本文将由浅入深,带你彻底搞懂 PageHelper 的“黑魔法”与最佳实践。
一、 核心谜题:PageHelper 是如何“魔法”介入的?
我们先看一段最经典的疑惑代码:
@Test
public void testMagic() {
SqlSession sqlSession = sqlSessionFactory.openSession(true);
EmpMapper mapper = sqlSession.getMapper(EmpMapper.class);
// 1. 发号施令:开启第一页,每页4条
Page<Object> page = PageHelper.startPage(1, 4);
// 2. 执行查询 (看起来跟分页没关系)
List<Emp> emps = mapper.selectByExample(null);
// 3. 见证奇迹:page 对象里竟然有了 emps 的数据?
System.out.println("Page size: " + page.size()); // 输出 4
System.out.println("Are they same? " + (emps == page)); // 输出 true
}
疑问:为什么 page 变量明明没有和 emps 进行任何显式操作(比如 page.addAll(emps)),page 里却装满了查询结果?而且 emps 和 page 竟然是同一个对象?
揭秘:ThreadLocal 与 拦截器
PageHelper 的工作流程其实是一场精密的“偷天换日”,利用了 Java 的 ThreadLocal 和 MyBatis 的 Interceptor(拦截器) 机制。
- 埋点 (
startPage): 当你调用startPage时,PageHelper 创建了一个Page对象(它本质上是一个ArrayList)。- 它将这个对象存入当前线程的
ThreadLocal中(当前线程的私有仓库)。 - 同时,它把这个对象的引用返回给了你的变量
page。
- 它将这个对象存入当前线程的
- 拦截 (
select): 当 MyBatis 准备执行 SQL 时,PageHelper 的拦截器介入。它去检查ThreadLocal,发现里面有个Page对象,于是:- 改写 SQL:自动在 SQL 后面加上
LIMIT 0, 4。 - 统计总数:自动生成并执行一条
SELECT COUNT(0)语句(为了计算总页数)。
- 改写 SQL:自动在 SQL 后面加上
- 填充 (
Result): 当数据库返回数据后,拦截器再次从ThreadLocal取出那个Page对象,将查询结果addAll进去,并设置Total等属性。 - 返回:
MyBatis 最终返回给
emps的,其实就是ThreadLocal里那个已经被拦截器填满的Page对象。
结论:page 和 emps 在内存中指向同一个对象。PageHelper 利用 ThreadLocal 实现了参数的隐式传递和结果回填。
二、 标准姿势:PageHelper 与 PageInfo 的分工
在日常开发中,我们经常混用这两个类。理清它们的分工非常重要,这决定了你的 API 是否规范。
1. PageHelper:发号施令者 (Action)
- 作用:告诉插件“下一条 SQL 需要分页”。
- 时机:必须在 Mapper 查询方法之前调用。
- 注意:
startPage必须紧跟查询语句,中间不要穿插其他逻辑,防止线程变量污染。
2. PageInfo:报表生成器 (Container)
- 作用:对查询结果
List进行包装,计算出“总页数”、“导航页码”、“是否有下一页”等前端需要的元数据。 - 时机:在 Mapper 查询方法之后调用。
标准 Controller/Service 代码模板:
public PageInfo<Emp> getEmpList(int pageNum, int pageSize) {
// 1. 设置分页
PageHelper.startPage(pageNum, pageSize);
// 2. 获取数据 (返回的是被 Page 装饰过的 List)
List<Emp> emps = mapper.selectByExample(null);
// 3. 包装结果 (生成详细的分页信息给前端)
// 这里的 5 表示底部导航条显示 5 个页码,如 [1, 2, 3, 4, 5]
return new PageInfo<>(emps, 5);
}
三、 数据结构可视化:为什么必须用 PageInfo?
很多开发者最大的困惑在于:“既然 Page 对象里已经有了 total,我直接返回 Page 不行吗?为什么非要包一层 PageInfo?”
我们看一眼两者的 JSON 序列化结果,就一目了然了。
1. Page 对象结构 (Page<E>)
Page 继承自 ArrayList。虽然它有 total 属性,但当它被转为 JSON 时,Jackson/Gson 默认只把它当做数组处理,忽略掉自定义属性。
前端收到的 JSON(惨剧):
[
{ "id": 101, "name": "Smith" },
{ "id": 102, "name": "Allen" }
]
结果:前端一脸懵逼,问你“总条数在哪?”
2. PageInfo 对象结构 (PageInfo<T>)
PageInfo 是一个标准的 POJO,专门为了 API 响应设计。
前端收到的 JSON(完美):
{
"pageNum": 1,
"pageSize": 2,
"total": 10, // 关键数据:总记录数
"pages": 5, // 关键数据:总页数
"list": [ // 实际数据
{ "id": 101, "name": "Smith" },
{ "id": 102, "name": "Allen" }
],
"isFirstPage": true,
"hasNextPage": true,
"navigatepageNums": [1, 2, 3, 4, 5] // 导航条
}
四、 深入解析:为什么会有 SELECT COUNT(0)?
当你执行分页查询时,控制台通常会打印出两条 SQL。
- 数据查询:
SELECT * FROM table LIMIT 0, 10 - 总数查询:
SELECT COUNT(0) FROM table
为什么要执行 Count?
它的唯一目的就是填充 PageInfo 中的 total 和 pages 字段。如果没有它,前端就无法渲染出“共 50 页”的分页条。
性能代价 对于小表,这无所谓。但如果你的表有 1000 万条数据:
LIMIT查询依然很快(配合索引)。COUNT查询可能会非常慢(需要扫描索引树),甚至导致数据库 CPU 飙升。
启示:如果是给用户看的页面,必须查 Count;但如果是后台批处理任务,请务必关掉它!
五、 进阶实战:如何优雅地循环遍历所有页?
假设你需要导出全量数据,或者对 100 万条数据进行清洗。绝对不能直接 selectAll,那会导致内存溢出 (OOM)。
你需要“分批次”循环读取。这里有两种策略:
策略一:While 循环 + Count 优化(推荐:最快、最省内存)
这种方式适合纯后台批处理,不需要知道总页数。
public void loopAllPages() {
int pageNum = 1;
int pageSize = 1000; // 批处理建议设大一点,比如 1000
while (true) {
// 关键点:第三个参数 false
// 告诉插件:我只要数据,不要帮我查总数!(节省大量时间)
PageHelper.startPage(pageNum, pageSize, false);
List<Emp> emps = mapper.selectByExample(null);
// 终止条件:查不到数据了
if (emps == null || emps.isEmpty()) {
break;
}
// 处理当前批次数据
System.out.println("正在处理第 " + pageNum + " 批,共 " + emps.size() + " 条");
process(emps);
// 准备下一页
pageNum++;
}
}
为什么这不会 OOM(内存溢出)?
这就像用同一个水桶去打水。
每次循环执行 select 时,emps 变量都会指向一个新的 List 对象(装载新的 1000 条数据)。
上一次循环产生的那个 List 对象,因为已经没有变量引用它了,会迅速被 Java 垃圾回收器(GC)回收,释放内存。
结论:无论数据库有多少亿条数据,你的程序同一时刻占用的内存,永远只有 pageSize 那么多。
策略二:先查总数 + For 循环(适合需要进度条)
如果你需要在前端显示“正在处理 5/100 页”,则需要先获取总数。
// 1. 先查第一页,目的是拿到 Total 和 Pages
PageHelper.startPage(1, 1000); // 默认查 count
List<Emp> firstPage = mapper.selectByExample(null);
PageInfo<Emp> info = new PageInfo<>(firstPage);
int totalPages = info.getPages();
process(firstPage); // 处理第一页
// 2. 循环处理剩余页码
for (int i = 2; i <= totalPages; i++) {
// 后续查询记得关闭 count,提升性能
PageHelper.startPage(i, 1000, false);
List<Emp> nextBatch = mapper.selectByExample(null);
process(nextBatch);
}
六、 避坑指南
在使用 PageHelper 时,有两个隐蔽的坑最容易踩:
1. reasonable 参数导致的死循环
如果在 mybatis-config.xml 或 application.yml 中开启了“参数合理化” (reasonable=true):
- 现象:当你请求的页码
pageNum > totalPages时(比如总共5页,你查第10页),PageHelper 会自动返回最后一页(第5页)的数据,而不是空集合。 - 后果:在 策略一 的
while循环中,因为永远查不到空集合,程序会无限重复处理最后一页数据,造成死循环。 - 解法:批处理时,务必使用
startPage(p, s, false),或手动检查if (emps.size() < pageSize) break;。
2. DTO 转换丢失 Total
- 错误写法:
PageHelper.startPage(1, 10); List<User> users = mapper.select(); // users 是 Page 对象,有 total List<UserDTO> dtos = convert(users); // dtos 是普通 ArrayList,total 丢了! return new PageInfo<>(dtos); // 这里的 total 变成了当前页的数量 (10) - 正确写法:
PageHelper.startPage(1, 10); List<User> users = mapper.select(); // 1. 先用查询结果创建 PageInfo,保留分页元数据 PageInfo<User> info = new PageInfo<>(users); // 2. 创建新的 PageInfo 并复制属性 PageInfo<UserDTO> dtoInfo = new PageInfo<>(); BeanUtils.copyProperties(info, dtoInfo); // 3. 填入转换后的数据 dtoInfo.setList(convert(users)); return dtoInfo;
七、 总结
PageHelper 虽小,五脏俱全。
- 原理:利用
ThreadLocal传参,拦截器改写 SQL。 - 结构:
Page是隐形容器,PageInfo是展示报表。 - Count:是分页的性能杀手,批处理时记得关掉它。
- 遍历:使用
while循环配合count(false)是处理海量数据的最佳方案。
希望这篇文章能帮你彻底驯服 MyBatis 分页!