1. 为什么分页这件事值得单独拎出来讲
做业务系统时间长了,你会发现一个规律:几乎所有列表页都逃不开分页,而分页恰恰是最容易出问题、也最容易被忽视的地方。刚入行的同学写分页,往往是在业务代码里手动拼limit和offset,再写一条count语句,看起来简单直接,但一旦查询条件变复杂、SQL 变长、多表关联变多,这种手写方式就会变成维护噩梦。PageHelper 这个 MyBatis 扩展插件,就是来解决这个痛点的——它让你在几乎不改动原有查询逻辑的前提下,自动完成分页查询和总数统计。
我接触 PageHelper 大概是在做第一个后台管理系统的时候,当时一个订单列表要支持十几个筛选条件,手写分页写到怀疑人生。后来换成 PageHelper,一行PageHelper.startPage(pageNum, pageSize)就搞定,那种感觉确实很爽。但用得越久,踩的坑也越多:线程安全问题、count查询慢、分页失效、嵌套查询结果不对……这些问题文档里往往一笔带过,真正踩过才知道疼。
这篇内容我打算把 PageHelper 从原理到实操完整梳理一遍,包括它到底怎么工作的、Spring Boot 里怎么集成、常见配置项怎么选、count查询慢怎么优化、以及那些只有踩过坑才知道的注意事项。适合正在用 MyBatis 做业务开发的同学,也适合准备面试、想搞清楚分页底层机制的朋友。看完你应该能独立把 PageHelper 用稳、用对,而不是停留在“能跑就行”的阶段。
2. PageHelper 的核心原理与设计思路拆解
2.1 它本质上是一个 MyBatis 拦截器
很多人用 PageHelper 用了很久,却说不清它到底是怎么把分页塞进 SQL 里的。要理解这一点,得先知道 MyBatis 的插件机制。MyBatis 允许你通过Interceptor接口拦截四大核心对象的方法调用:Executor、StatementHandler、ParameterHandler、ResultSetHandler。PageHelper 选择拦截的是Executor的query方法,因为分页本质上是在查询执行前对 SQL 做改写。
具体流程是这样的:当你调用PageHelper.startPage(pageNum, pageSize)时,它会把分页参数放进当前线程的ThreadLocal里。等到 MyBatis 真正执行查询、走到被拦截的Executor.query时,PageHelper 从ThreadLocal里取出参数,判断这次查询需不需要分页。如果需要,它就先根据原 SQL 生成一条count语句查总数,再对原 SQL 拼接limit子句,最后把结果封装成一个Page对象返回。
这里有个关键点:分页参数是存在 ThreadLocal 里的,这意味着startPage和紧接着的那一次查询必须在同一个线程里,而且中间不能插入其他查询。这是后面很多“分页失效”问题的根源,先记住这一点。
2.2 为什么用拦截器而不是手写分页
你可能会问,既然拦截器这么绕,为什么不直接在业务层手写分页?我总结了几个实际开发中的理由。
第一是代码侵入性低。手写分页意味着每个查询方法都要多写一条countSQL,还要处理limit参数拼接。一个系统几十个列表页,重复代码量惊人。PageHelper 把这些逻辑收敛到插件里,业务代码只关心查询条件本身。
第二是方言适配。不同数据库的分页语法不一样,MySQL 用limit,Oracle 用rownum,SQL Server 用top或offset fetch。PageHelper 内置了多种方言的Dialect实现,能根据数据库类型自动生成对应的分页 SQL。如果你的系统要兼容多种数据库,这个价值就体现出来了。
第三是结果封装统一。PageHelper 返回的PageInfo对象里包含了总记录数、总页数、当前页、每页条数、是否为第一页/最后一页等字段,前端直接拿来用就行,不用自己算。
当然,拦截器方案也有代价,最典型的就是count查询的性能问题,以及 ThreadLocal 带来的隐式依赖。这些后面会详细讲。
2.3 核心类之间的关系
理解 PageHelper 的类结构,对排查问题很有帮助。核心的几个类是这样的:
PageHelper:对外暴露的工具类,startPage方法就在这里,负责把参数放进 ThreadLocal。PageInterceptor:真正的拦截器实现,实现Interceptor接口,负责拦截Executor.query。Page:继承自ArrayList,既是一个分页参数载体,也是查询结果的容器。PageInfo:对Page的进一步封装,提供更友好的分页元信息。SqlUtil和Dialect:负责 SQL 改写和方言适配。
调用链大致是:PageHelper.startPage→ 参数存入 ThreadLocal →PageInterceptor.intercept拦截 → 生成 count SQL 并执行 → 改写原 SQL 加 limit → 执行查询 → 封装结果 → 清理 ThreadLocal。
注意:PageHelper 在查询结束后会调用
clearPage清理 ThreadLocal,但如果查询过程中抛异常,或者你startPage之后没有执行查询,ThreadLocal 里的参数可能残留,导致下一次查询被意外分页。这是很隐蔽的 bug,后面会专门讲。
3. Spring Boot 集成 PageHelper 的完整实操
3.1 依赖引入与版本选择
在 Spring Boot 项目里集成 PageHelper,最省事的方式是用 starter。Maven 依赖大概是这样:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency>版本选择上有个经验:starter 版本要和你的 Spring Boot 版本大致匹配。1.4.x 系列适配 Spring Boot 2.x,如果你用的是 Spring Boot 3.x,需要选 2.x 系列的 starter,因为 Spring Boot 3 把底层的一些依赖升级了,老版本 starter 可能会有兼容问题。我见过有同学在 Spring Boot 3 项目里硬塞 1.4.x,结果启动就报NoClassDefFoundError,排查半天。
另外要注意,引入 starter 之后不要再单独引入pagehelper核心包,starter 已经包含了,重复引入容易造成版本冲突。如果你是非 Spring Boot 项目,那就直接引pagehelper核心包,然后手动配置拦截器。
3.2 application.yml 关键配置项
starter 的好处是配置可以写在application.yml里,常用的配置项如下:
pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: count=countSql page-size-zero: false逐项说一下我的理解:
helper-dialect指定方言。虽然 PageHelper 能自动检测数据库类型,但显式指定更稳妥,尤其是在多数据源场景下,自动检测可能出错。常见值有mysql、oracle、postgresql、sqlserver等。
reasonable是“合理化分页”,这个配置很实用。开启后,如果pageNum小于 1,会查第一页;如果pageNum大于总页数,会查最后一页。不开的话,传个负数或者超大页码,可能返回空结果甚至报错。生产环境建议开启。
support-methods-arguments允许你通过 Mapper 方法的参数来传递分页信息,不用显式调startPage。这个功能用好了能简化代码,但也会让分页变得“隐式”,团队协作时容易让人困惑,看情况用。
params里的count=countSql是配合support-methods-arguments用的,指定哪个参数是 count 查询的标识。
page-size-zero默认 false,意思是当pageSize为 0 时,查全部数据(不分页)。这个行为有点危险,如果前端传了 0,可能一次性把整张表拉出来。我一般会保持 false,然后在业务层校验pageSize的合法范围。
3.3 一个完整的分页查询示例
光说配置不够直观,直接上一个能跑的完整例子。假设有一个用户表,我们要按条件分页查询。
先看实体类和 Mapper:
public class User { private Long id; private String name; private Integer age; private String email; // getter setter 省略 }@Mapper public interface UserMapper { List<User> selectByCondition(@Param("name") String name, @Param("age") Integer age); }对应的 XML:
<select id="selectByCondition" resultType="com.example.entity.User"> SELECT id, name, age, email FROM user <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="age != null"> AND age = #{age} </if> </where> ORDER BY id DESC </select>Service 层调用:
@Service public class UserService { @Autowired private UserMapper userMapper; public PageInfo<User> queryByPage(String name, Integer age, int pageNum, int pageSize) { // 关键:startPage 必须紧挨着查询方法 PageHelper.startPage(pageNum, pageSize); List<User> list = userMapper.selectByCondition(name, age); return new PageInfo<>(list); } }Controller 层:
@RestController @RequestMapping("/user") public class UserController { @Autowired private UserService userService; @GetMapping("/list") public PageInfo<User> list(@RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize, @RequestParam(required = false) String name, @RequestParam(required = false) Integer age) { return userService.queryByPage(name, age, pageNum, pageSize); } }这段代码跑起来,PageHelper 会自动帮你做两件事:先执行一条SELECT count(0) FROM user WHERE ...查总数,再执行带LIMIT的查询拿当前页数据。返回的PageInfo里total、pages、pageNum、pageSize、list都齐了,前端直接渲染分页组件。
3.4 startPage 的几种重载与使用场景
PageHelper.startPage有好几个重载,常用的有:
// 最常用:页码 + 每页条数 PageHelper.startPage(int pageNum, int pageSize); // 带排序:第三个参数是排序字段,注意有 SQL 注入风险 PageHelper.startPage(int pageNum, int pageSize, String orderBy); // 带是否统计总数:false 表示不查 count,适合已知不需要总数的场景 PageHelper.startPage(int pageNum, int pageSize, boolean count); // 带合理化分页:覆盖全局配置 PageHelper.startPage(int pageNum, int pageSize, boolean count, boolean reasonable);关于orderBy参数,我要特别提醒:不要直接把前端传来的排序字段拼进去。PageHelper 内部对orderBy做了简单的 SQL 注入过滤,但过滤规则不是万能的。稳妥的做法是在业务层做白名单校验,只允许特定的字段参与排序。
count参数为 false 的场景也值得说。有些列表页前端不需要显示总数,只需要“下一页”按钮,这时候关掉 count 查询能省一次数据库交互,性能提升明显。我做过一个日志查询页面,数据量上千万,关掉 count 之后响应时间从 3 秒降到 300 毫秒。
4. count 查询慢的根因分析与优化实战
4.1 为什么 count 会慢
这是 PageHelper 被吐槽最多的地方,也是面试高频问题。要搞清楚为什么慢,得先看 PageHelper 生成的 count SQL 长什么样。
假设你的原 SQL 是:
SELECT id, name, age, email FROM user u LEFT JOIN department d ON u.dept_id = d.id WHERE u.status = 1 AND d.name LIKE '%技术%' ORDER BY u.id DESCPageHelper 默认会把它改写成:
SELECT count(0) FROM user u LEFT JOIN department d ON u.dept_id = d.id WHERE u.status = 1 AND d.name LIKE '%技术%'注意,它去掉了 ORDER BY,但保留了所有 JOIN。问题就出在这里:如果 JOIN 的表很大,或者 JOIN 条件没有走索引,count 查询会非常慢。更糟的是,有些场景下 JOIN 会产生笛卡尔积式的行数膨胀,count 出来的数字可能都不对。
还有一种情况是WHERE条件里有LIKE '%xxx%'这种前置模糊匹配,索引直接失效,count 全表扫描,数据量一大就卡死。
4.2 优化思路一:手写 count SQL
PageHelper 提供了自定义 count 查询的入口。你可以在 Mapper 里额外写一个 count 方法,然后在startPage时指定用哪个 count 查询。
@Mapper public interface UserMapper { List<User> selectByCondition(@Param("name") String name, @Param("age") Integer age); long countByCondition(@Param("name") String name, @Param("age") Integer age); }XML 里:
<select id="countByCondition" resultType="long"> SELECT count(0) FROM user <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="age != null"> AND age = #{age} </if> </where> </select>调用时用PageHelper.startPage的重载,把 count 方法名传进去:
PageHelper.startPage(pageNum, pageSize, "countByCondition"); List<User> list = userMapper.selectByCondition(name, age);这样 PageHelper 就不会自己生成 count SQL,而是调用你指定的方法。你可以针对 count 做专门的优化,比如去掉不必要的 JOIN、只查主表、利用覆盖索引等。
4.3 优化思路二:用近似值替代精确 count
如果业务上能接受“总数不精确”,比如显示“约 1000+ 条”,那可以用一些取巧的办法。MySQL 的EXPLAIN结果里有个rows字段,是优化器估算的行数,虽然不准,但速度极快。你可以写一个 count 方法,用EXPLAIN拿估算值:
EXPLAIN SELECT id FROM user WHERE status = 1然后解析结果里的rows。这种方式适合数据量极大、对总数精度要求不高的场景,比如后台的日志列表、监控数据列表。
还有一种做法是缓存总数。如果数据变化不频繁,可以把 count 结果缓存起来,比如用 Redis 存 5 分钟。用户翻页时直接用缓存的总数,只有缓存过期才真正查一次数据库。这个方案在商品列表、文章列表这类读多写少的场景很有效。
4.4 优化思路三:避免不必要的 count
前面提过,PageHelper.startPage(pageNum, pageSize, false)可以关闭 count 查询。什么时候适合关?
- 前端是“无限滚动”加载,不需要知道总数。
- 只需要“下一页”按钮,不需要页码跳转。
- 数据量极大,count 成本远高于查询本身。
我个人的经验是,列表页如果超过 100 万行,优先考虑关掉 count 或者用近似值,否则用户体验会被 count 拖垮。
4.5 一个真实的优化案例
之前做过一个订单查询页面,数据量 800 万左右,查询条件涉及订单表、用户表、商品表三表关联。上线后发现列表加载要 5 秒以上,慢查询日志里全是 count 语句。
排查过程是这样的:先看 count SQL,发现它保留了三个表的 JOIN,而实际上用户表和商品表只是为了显示名称,对筛选条件没有贡献。于是手写了一个 count 方法,只查订单表,把用户和商品的筛选条件通过子查询或者冗余字段解决。改完之后 count 从 4 秒降到 200 毫秒。
后来又加了一层 Redis 缓存,把 count 结果缓存 3 分钟,进一步把大部分请求的响应时间压到 50 毫秒以内。这个案例说明,count 优化的核心是减少参与 count 的数据量,能不加 JOIN 就不加,能用索引就用索引。
5. 那些文档不会告诉你的坑与排查技巧
5.1 分页失效的几种典型场景
分页失效是 PageHelper 最常见的问题,表现是查询返回了全部数据,没有分页。我总结了几种原因:
第一种:startPage 和查询方法之间插入了其他查询。因为分页参数存在 ThreadLocal 里,只对紧接着的第一次查询生效。如果你这样写:
PageHelper.startPage(pageNum, pageSize); User user = userMapper.selectById(1L); // 这次查询被分页了 List<User> list = userMapper.selectByCondition(name, age); // 这次没有分页结果就是selectById被莫名其妙分页,而真正想分页的selectByCondition反而没分页。这种 bug 很隐蔽,因为selectById返回单条,分页了也看不出来。
第二种:多数据源场景下方言识别错误。如果项目里配了多个数据源,PageHelper 自动检测方言可能识别成错误的数据库类型,导致生成的 limit 语法不对。解决办法是显式配置helper-dialect,或者用PageHelper.startPage时指定方言。
第三种:查询方法被 AOP 代理或者异步执行。如果查询方法跑在另一个线程里,ThreadLocal 取不到分页参数,分页自然失效。异步查询场景要特别注意。
5.2 ThreadLocal 残留导致的分页错乱
这个坑比失效更恶心,因为它会导致不该分页的查询被分页。场景是这样的:
PageHelper.startPage(pageNum, pageSize); // 中间因为某个条件判断,直接 return 了,没有执行查询 if (condition) { return Collections.emptyList(); } List<User> list = userMapper.selectByCondition(name, age);如果condition为 true 直接返回,ThreadLocal 里的分页参数没有被清理。下一次请求进来,如果恰好是同一个线程(线程池复用),这个残留的参数就会作用到下一次查询上,导致分页错乱。
解决办法有两个:一是确保 startPage 之后一定执行查询,二是在 finally 里手动调用PageHelper.clearPage()。我现在的习惯是,只要用了 startPage,就在 try-finally 里包一层,虽然啰嗦但安心。
5.3 PageInfo 和 Page 的区别与选择
很多人分不清Page和PageInfo。简单说,Page是ArrayList的子类,查询返回的List实际上就是Page类型,它带有total、pageNum、pageSize等属性。PageInfo是对Page的封装,额外提供了isFirstPage、isLastPage、hasNextPage、navigatePages等更友好的字段。
选择上,如果前端只需要总数和当前页数据,用Page就够了。如果需要完整的分页导航信息(比如显示“上一页 1 2 3 4 5 下一页”),用PageInfo更方便。PageInfo的构造函数接收一个List,内部会判断是不是Page类型,如果是就提取分页信息,如果不是就当作普通列表处理(total 等于 list 大小)。
注意:如果你在
startPage之后对返回的 list 做了过滤、转换(比如 stream 操作生成新 list),再传给PageInfo,分页信息会丢失,因为新 list 不是Page类型了。正确做法是先构造PageInfo,再做数据转换。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决办法 |
|---|---|---|---|
| 查询返回全部数据 | startPage 未生效 | 检查 startPage 与查询是否紧邻 | 调整代码顺序 |
| 不该分页的查询被分页 | ThreadLocal 残留 | 检查是否有提前 return | finally 中 clearPage |
| count 查询特别慢 | JOIN 过多或索引失效 | 看慢查询日志 | 手写 count SQL |
| 分页页码越界返回空 | reasonable 未开启 | 检查配置 | 开启 reasonable |
| 多数据源分页语法错误 | 方言识别错误 | 检查数据源配置 | 显式指定 dialect |
| PageInfo 的 total 不对 | list 被转换过 | 检查是否 stream 操作 | 先构造 PageInfo |
5.5 几个实操心得
第一,startPage 尽量写在 Service 层,不要写在 Controller。Controller 只负责参数接收和结果返回,分页逻辑属于业务范畴,放 Service 层更合理,也方便复用。
第二,pageSize 一定要做上限校验。我见过前端传pageSize=100000直接把数据库打挂的案例。一般限制在 100 以内比较稳妥,特殊场景可以放宽到 500。
第三,排序字段用白名单。前面提过orderBy的注入风险,实际项目中我会维护一个允许排序的字段集合,前端传的字段不在集合里就用默认排序。
第四,分页查询尽量走覆盖索引。如果查询字段都能被索引覆盖,数据库不用回表,速度会快很多。这个属于 SQL 优化范畴,但对分页性能影响很大。
6. 面试中关于 PageHelper 的高频问题拆解
6.1 PageHelper 的分页原理是什么
这是最基础的面试题。回答要点:PageHelper 基于 MyBatis 的 Interceptor 机制,拦截Executor.query方法。调用startPage时把分页参数存入 ThreadLocal,拦截器在执行查询前从 ThreadLocal 取出参数,先生成并执行 count SQL 查总数,再改写原 SQL 拼接 limit 子句,最后把结果封装成 Page 对象返回,并清理 ThreadLocal。
如果能补充“为什么用 ThreadLocal”就更好了:因为分页参数需要在不修改方法签名的前提下传递给拦截器,ThreadLocal 是最轻量的方案,但代价是隐式依赖和线程安全问题。
6.2 PageHelper 的 count 查询可以优化吗
可以。三个方向:手写 count SQL 去掉不必要的 JOIN;用近似值或缓存替代精确 count;关闭 count 查询(startPage第三个参数传 false)。面试时如果能结合具体案例讲,比如“我之前做过一个 800 万数据的订单列表,通过手写 count 把响应时间从 4 秒降到 200 毫秒”,会加分很多。
6.3 PageHelper 和 MyBatis-Plus 的分页有什么区别
MyBatis-Plus 的分页是基于它自己的IPage接口和PaginationInnerInterceptor实现的,用法上是传一个Page对象作为方法参数,而不是用 ThreadLocal。相比之下,MyBatis-Plus 的分页更“显式”,不容易出现 ThreadLocal 残留问题,但侵入性稍高(方法签名要改)。PageHelper 胜在无侵入,适合已有项目快速接入。两者没有绝对优劣,看项目情况选。
6.4 分页查询的深分页问题怎么解决
深分页指的是LIMIT 1000000, 10这种,偏移量很大时数据库要扫描并丢弃前 100 万行,效率极低。解决办法有几种:用游标分页(记录上一页最后一条的 id,下一页用WHERE id > lastId LIMIT 10);用子查询先定位 id 再关联;或者限制用户只能翻到前 N 页。PageHelper 本身不解决深分页,需要业务层配合。
7. 一些延伸思考与个人建议
PageHelper 这个组件,用起来简单,但要用好需要理解它的边界。我的建议是:把它当成一个提效工具,而不是万能方案。简单列表页直接用它没问题,但数据量大、查询复杂的场景,一定要针对 count 做优化,必要时手写分页逻辑。
另外,新项目如果还没选型,可以对比一下 MyBatis-Plus 的分页。它的显式传参方式在团队协作和问题排查上更友好,而且 MyBatis-Plus 本身提供了很多 CRUD 的便利。老项目已经在用 PageHelper 的,也没必要迁移,把 count 优化和 ThreadLocal 清理做好就行。
最后分享一个小技巧:如果你在本地调试时想看到 PageHelper 实际生成的 SQL,可以把 MyBatis 的日志级别调到 DEBUG,配置logging.level.com.example.mapper=debug,这样控制台会打印出 count SQL 和分页 SQL,排查问题非常方便。我每次遇到分页结果不对,第一件事就是打开日志看实际执行的 SQL,十有八九能直接定位问题。