MyBatis PageHelper 分页插件:7 个你一定要避开的坑
PageHelper 是 Java 生态里最常用的 MyBatis 分页插件,上手简单,但线上出问题往往让人怀疑人生——返回数据莫名其妙变少、分页“失效”、甚至影响到完全不相关的接口。本文从源码原理出发,逐一拆解这些坑的根因和最佳实践。
先搞懂原理:ThreadLocal + 拦截器
PageHelper 的核心机制非常简洁:
PageHelper.startPage(pageNum, pageSize)把分页参数塞进一个静态 ThreadLocal。- MyBatis 执行 SQL 时,PageInterceptor 拦截器从 ThreadLocal 取出分页参数,自动拼上
LIMIT,并在finally块里调用clearPage()清理。
⚠️ 问题就出在第 2 步的
finally上——如果拦截器压根没机会执行,ThreadLocal 就永远不会被清理。
理解了这一点,下面所有的坑你都能一眼看穿根因。
坑一:ThreadLocal 污染(最致命、最难排查)
现象
某个分页接口报错后,接下来其他完全不相关的接口返回的数据量突然变少了,日志里 SQL 完全没有异常,但就是少了数据。
根因
看这段代码:
❌ 危险写法:异常导致 ThreadLocal 残留
// ❌ 危险写法
public List<User> getUsers(int pageNum, int pageSize) {
PageHelper.startPage(pageNum, pageSize); // ThreadLocal 设值
someBusinessLogic(); // 这里抛了异常
return userMapper.selectAll(); // 永远走不到
}
startPage 执行后,ThreadLocal 里已经有了分页参数。someBusinessLogic() 一抛异常,MyBatis 查询根本没执行,拦截器的 finally 自然也不会跑——ThreadLocal 里的脏数据就这么留下来了。
Tomcat 的线程池复用这个线程处理下一个请求时,那个请求的普通查询就会被悄悄加上 LIMIT,返回结果截断,而你排查日志时看不到任何异常。
解决
方案一(最推荐):用 doSelectPage 闭包
✅ 闭包写法,自动清理,异常安全
// ✅ 闭包写法,自动清理,异常安全
PageHelper.startPage(pageNum, pageSize)
.doSelectPage(() -> userMapper.selectAll());
ISelect 接口内部保证了 startPage 和查询之间零代码,异常也能正确清理。
方案二:紧贴查询,中间不要写任何逻辑
✅ 可以接受,但不如闭包安全
// ✅ 可以接受,但不如闭包安全
PageHelper.startPage(pageNum, pageSize);
List<User> list = userMapper.selectAll();
方案三:全局兜底拦截器
@Component
public class PageHelperClearInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) {
PageHelper.clearPage();
return true;
}
}
三个方案不是互斥的,建议方案一 + 方案三组合使用,双重保险。
坑二:分页“失效”,返回了全部数据
“分页失效”有好几种不同的根因,很多人的第一反应是“PageHelper 坏了”,其实每种情况排查点都不一样:
2.1 pageSize=0 被当作“查全表”
当 pageSizeZero 配置为 true(部分旧项目会这么配),传入 pageSize=0 时不会拼 LIMIT,直接返回全量数据。
PageHelper.startPage(1, 0); // pageSizeZero=true → 不分页
解决:关闭 pageSizeZero 配置,或对参数做非空校验。
2.2 reasonable=true 自动修正页码
reasonable 开启后,如果请求页码超过总页数,PageHelper 会静默修正到最后一页。这在某些场景下会让“应该返回空”变成“返回了数据”,看起来像是分页失效。
解决:关闭 reasonable,由业务层自己做参数校验,返回空列表更符合预期。
2.3 根本没配拦截器
PageHelper 的核心是 PageInterceptor,如果 Spring Boot 项目没用 pagehelper-spring-boot-starter 而是手动引了裸包,或者拦截器配置被其他配置覆盖了,分页完全不生效。
解决:Spring Boot 项目必须用:
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>最新版</version>
</dependency>
2.4 startPage 后的第一个查询被消费
PageHelper.startPage(1, 10);
userMapper.countUser(); // 这个 COUNT 成了“第一个查询”,分页被它消费了
List<User> list = userMapper.selectAll(); // 这个查询不会再分页
解决:startPage 后只执行你要分页的那一个查询。
2.5 流操作后分页对象丢失
❌ 错误写法:流操作后丢失分页信息
PageHelper.startPage(1, 10);
List<User> list = userMapper.selectAll();
list = list.stream()
.filter(u -> u.getAge() > 18)
.collect(Collectors.toList()); // Page 对象丢失
PageInfo<User> pageInfo = new PageInfo<>(list); // total 永远等于 list.size()
解决:先拿 Page 对象,再做流操作。
✅ 正确写法:先获取分页信息
PageHelper.startPage(1, 10);
List<User> list = userMapper.selectAll();
PageInfo<User> pageInfo = new PageInfo<>(list); // 先拿 pageInfo
List<User> filtered = list.stream()
.filter(u -> u.getAge() > 18)
.collect(Collectors.toList()); // 再做过滤
坑三:嵌套结果映射(Nested Results)导致 count 错误
如果 Mapper XML 里用了 <collection> 或 <association> 的嵌套结果映射:
<resultMap id="orderMap" type="Order">
<id column="id" property="id"/>
<collection property="items" ofType="OrderItem">
<!-- 嵌套映射 -->
</collection>
</resultMap>
PageHelper 自动拼的 SELECT COUNT(0) 返回的是 JOIN 后的行数,但 MyBatis 嵌套映射会把多行折叠成一条——count 出来的总数远大于实际分页后的记录数。
解决:自己写 count 查询,或者改成子查询方式加载关联数据。
坑四:FOR UPDATE 分页的性能隐患
❌ 危险:COUNT 查询也会带 FOR UPDATE
PageHelper.startPage(1, 10);
userMapper.selectForUpdate(); // SQL: SELECT ... FOR UPDATE
PageHelper 会自动生成 SELECT COUNT(0) FROM (...) tmp 来做 count 查询。如果原 SQL 带了 FOR UPDATE,count 子查询也会带上行锁,在高并发下可能引发锁竞争甚至死锁。
解决:
// 先分页查出 ID
PageHelper.startPage(1, 10);
List<Long> ids = userMapper.selectIds();
// 再用 ID 去加锁
List<User> users = userMapper.selectByIdsForUpdate(ids);
坑五:supportMethodsArguments 的“隐形分页”
这个配置项一开,PageHelper 会从 Mapper 方法参数里自动嗅探 pageNum、pageSize 字段:
// 你的 DTO 正好有个 pageSize 字段
public class QueryDTO {
private String name;
private Integer pageSize; // 这个字段被 PageHelper 自动识别了!
}
// 你根本没调 startPage,但 PageHelper 自动帮你分了页
List<User> list = userMapper.selectByDTO(queryDTO);
这种“隐形分页”线上排查极其困难——代码里看不到任何分页调用,SQL 却有 LIMIT。
解决:直接关掉 supportMethodsArguments,统一显式调用 startPage。
pagehelper:
support-methods-arguments: false
坑六:PageInterceptor 被重复注册
如果你同时在两个地方配置了 PageHelper:
<!-- mybatis-config.xml 里配了一次 -->
<plugins>
<plugin interceptor="com.github.pagehelper.PageInterceptor"/>
</plugins>
// Spring Boot 配置里又配了一次
@Bean
public PageInterceptor pageInterceptor() {
return new PageInterceptor();
}
拦截器被注册两遍,可能引发重复分页、SQL 被拼两次 LIMIT 等诡异行为。
解决:只在一个地方配置,Spring Boot 项目用 starter 的自动配置就够了,不要手动再注册。
坑七:依赖包选错
❌ 错误依赖:不会自动配置
<!-- ❌ 不会自动配置 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper</artifactId>
</dependency>
Spring Boot 的自动配置不会生效,你得手动注册 PageInterceptor。很多新人直接搜“pagehelper maven”就加了裸包,然后发现怎么配都不生效。
解决:
✅ 正确依赖:Spring Boot 项目专用
<!-- ✅ Spring Boot 项目专用 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
</dependency>
最终 Checklist
建议收藏这张清单,Code Review 或上线前逐项过一遍:
| # | 检查项 | 动作 |
|---|---|---|
| 1 | startPage 和 SQL 之间是否有其他代码? |
改成 doSelectPage 闭包,或确保零代码间隔 |
| 2 | 是否加了全局 PageHelper.clearPage() 拦截器? |
加一个作为兜底 |
| 3 | supportMethodsArguments 是否关闭? |
关掉,统一显式分页 |
| 4 | reasonable 是否关闭? |
关掉,业务层自己做参数校验 |
| 5 | pageSizeZero 是否关闭? |
关掉 |
| 6 | Mapper XML 有无嵌套结果映射? | 有则自己写 count |
| 7 | 分页 SQL 有无 FOR UPDATE? |
改成先分页查 ID 再加锁 |
| 8 | PageInterceptor 是否只注册了一次? | 只保留一处配置 |
| 9 | 依赖用的是 pagehelper-spring-boot-starter? |
检查 pom.xml |
写在最后
💡 PageHelper 的设计本身没有问题——ThreadLocal + 拦截器的思路简洁高效。问题出在使用方式上:只要打破
startPage → 紧跟查询 → 自动清理这条链路,就会留下 ThreadLocal 残留的定时炸弹。
记住两句话就够了:
startPage和查询之间零代码。做不到就用doSelectPage。- 全局
clearPage兜底,永远不亏。
这两条守住了,PageHelper 就是个安静可靠的好插件。
更多推荐



所有评论(0)