PageHelper 是 Java 生态里最常用的 MyBatis 分页插件,上手简单,但线上出问题往往让人怀疑人生——返回数据莫名其妙变少、分页“失效”、甚至影响到完全不相关的接口。本文从源码原理出发,逐一拆解这些坑的根因和最佳实践。

先搞懂原理:ThreadLocal + 拦截器

PageHelper 的核心机制非常简洁:

  1. PageHelper.startPage(pageNum, pageSize) 把分页参数塞进一个静态 ThreadLocal
  2. 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 方法参数里自动嗅探 pageNumpageSize 字段:

// 你的 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 残留的定时炸弹。

记住两句话就够了:

  1. startPage 和查询之间零代码。做不到就用 doSelectPage
  2. 全局 clearPage 兜底,永远不亏。

这两条守住了,PageHelper 就是个安静可靠的好插件

Logo

一站式 AI 云服务平台

更多推荐