回顾我这几年做 Java 后端,凡是和数据库打交道的项目,几乎没人能绕开分页。早期用 JDBC 手动拼LIMIT,后来用 MyBatis 还得自己封装一套分页逻辑,直到接触到 PageHelper 这个 MyBatis 物理分页插件,才算是把“列表分页”这件又基础又琐碎的事给理顺了。今天这篇就围绕 PageHelper 和它背后真正干活的 SQL 关键字——LIMIT,把原理、集成、使用、坑点一次说透。
这篇文章适合谁看?正在用 MyBatis 做业务的开发,准备面试时被问到“分页原理”的候选人,以及手上有老项目想要平滑引入分页插件的维护者。我会尽量把底层机制讲明白,也会把实际项目中遇到的“怪问题”拿出来拆解,争取让你读完既能上手,也能在别人面前把话说清楚。
1. 分页问题的根源:为什么“物理分页”如此关键
1.1 逻辑分页与物理分页:从“全量加载”到“只取需要的数据”
分页这个概念很简单:一页展示 20 条,用户点第二页就接着显示后面 20 条。但实现方式却有本质区别。早期很多初学者会写类似这样的代码:先把表里所有数据查出来放到一个List里,然后用subList(start, end)截取当前页的数据。这就是典型的逻辑分页,也叫内存分页。
逻辑分页的最大问题是数据量一大就崩。假设订单表有 100 万条记录,每次请求都把这 100 万条查出来放在 JVM 堆里,再只拿 20 条给前端,这个内存开销和网络传输开销完全不可接受。更不用说多个用户同时访问时,服务器内存会迅速被拖垮。
物理分页则是把分页动作直接下推到数据库层,通过 SQL 语句限制数据库只返回我们需要的那一页数据。以 MySQL 为例,核心命令就是LIMIT offset, pageSize。数据库在存储引擎层面就只扫描并返回指定范围内的行,内存和 IO 占用都小得多。这也是为什么我在项目里一直强调:分页必须做“物理分页”,除非你处理的数据量小到可以忽略不计,否则别碰subList这种方案。
1.2 让 LIMIT 变得通用:主流数据库的分页语法差异
很多人会误以为LIMIT是所有数据库通用的,这是个大坑。LIMIT是 MySQL、PostgreSQL、SQLite 等数据库支持的语法,但 SQL Server 用的是OFFSET ... FETCH NEXT ... ROWS ONLY,Oracle 12c 之前的主流做法是ROWNUM配合子查询,12c 之后才引入了FETCH FIRST ... ROWS ONLY。
这就带来一个现实问题:同一个分页逻辑,在不同数据库上要写完全不同的 SQL。如果项目从 MySQL 迁移到 PostgreSQL,所有手写分页 SQL 都要改一遍。PageHelper 存在的一个重要意义,就是把这些方言差异“屏蔽”掉。你只需要写标准查询,PageHelper 根据配置的helperDialect自动生成对应数据库的分页语句。
我当年接手过一个老项目,底层数据库从 MySQL 切到达梦,几十个 DAO 里手写的分页 SQL 改到崩溃,后来统一换成 PageHelper,本质问题才解决。所以我不止一次跟同事说:分页这种通用能力,能交给成熟插件就别自己造轮子,尤其是涉及多数据库方言的场景。
2. PageHelper 核心原理:MyBatis 拦截器如何把查询改写成 LIMIT
2.1 MyBatis 插件机制与拦截点选择
PageHelper 本质上是一个 MyBatis 插件(Interceptor)。MyBatis 允许你在 Executor、StatementHandler、ParameterHandler、ResultSetHandler 这四个核心组件上做拦截。PageHelper 拦截的是Executor的query方法。
为什么选Executor?因为它是 MyBatis 执行 SQL 的入口,不管是selectList、selectOne还是自定义方法,最终都会走到Executor.query()。在这个位置做手脚,能覆盖几乎所有查询场景,而且能在 SQL 真正发往数据库之前完成改写。
用大白话讲:你写的select * from user where age > 18是“原料”,PageHelper 拦截器就是这个“加工车间”,它会在 MyBatis 解析完这条 SQL、准备执行之前,把语句偷偷改写成select * from user where age > 18 LIMIT 0, 20。你甚至不需要改变原有 DAO 方法。
2.2 从 SQL 解析到 count 查询生成,一次分页请求的完整路径
我们来看一次带分页的查询请求,PageHelper 内部到底做了哪些事。假设业务代码是这样:
PageHelper.startPage(1, 10); List<User> users = userMapper.selectByCondition("admin");第一步,startPage方法会把分页参数(页码、每页条数)保存到ThreadLocal中。这一步很关键,后面讲坑的时候还会再提到。
第二步,当userMapper.selectByCondition执行时,MyBatis 进入Executor.query(),PageHelper 拦截器在这里被触发。它先从ThreadLocal里取出分页参数,判断当前查询是否需要分页。
第三步,拦截器会生成一条 count 查询语句,用于计算符合条件的总记录数。这种自动生成有专门的处理逻辑,对group by、distinct等情况要做区分,否则统计总数会出错。
第四步,改写原 SQL,根据配置的数据库方言生成对应的分页语句。MySQL 就是LIMIT,PostgreSQL 是LIMIT ... OFFSET ...,SQL Server 则是OFFSET ... FETCH NEXT。
第五步,执行改写后的 SQL,拿到当前页数据,同时执行 count 查询拿到总数,然后把两者封装成分页结果返回。
我第一次读 PageHelper 源码时印象最深的是它对BoundSql的处理。MyBatis 中每条 SQL 都被封装在BoundSql对象里,包含了最终 SQL 语句和参数列表。PageHelper 拿到BoundSql后,需要重新解析参数、拼接新 SQL,再构造一个新的BoundSql来替代原来的对象。这个过程复杂且容易出错,所以才有了 PageHelper 在特定版本里对某些 SQL 场景支持不佳的问题。
3. Spring Boot 集成 PageHelper:依赖、配置与版本取舍
3.1 Maven 依赖选择与版本注意事项
Spring Boot 项目引入 PageHelper 很简单,一个依赖搞定:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency>如果你用的是老版本 MyBatis 项目,也可以只引入核心包:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper</artifactId> <version>5.3.3</version> </dependency>这里特别提醒一句版本匹配问题。PageHelper 5.x 对应 MyBatis 3.x,而 pagehelper-spring-boot-starter 1.4.x 最好配合 Spring Boot 2.x 使用。如果你用的 Spring Boot 3.x,需要选择支持 MyBatis 3.5+ 的更新版本,或者单独引入 pagehelper 核心包并在配置类中注册插件。
遇到“分页不生效”或“插件未注册”这类问题,八成是 starter 自动配置失效,而不是代码写错了。我的建议是:新项目直接上官方最新稳定版本,老项目在升级前先看 release notes 里的兼容性说明。
3.2 配置项逐项拆解:从 dialect 到 reasonable
在application.yml中可以做很多定制,我常用这几个:
pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: count=countSql return-page-info: truehelper-dialect:指定数据库方言。不配置时 PageHelper 会自动识别,但多数据源场景下我建议显式配置。自动识别偶尔会因为连接池代理问题失败,显式配置最稳妥。reasonable:是否启用合理化。默认false。如果启用,当页码超过总页数时会自动归一到最后一页,页码小于 1 时自动归一到第一页。这个功能在面向用户的接口中很实用,能避免因为前端传了个超大页码导致数据库执行深分页。support-methods-arguments:是否支持接口参数透传分页参数。开启后可以不用PageHelper.startPage,直接在 Mapper 方法参数里传Page子类参数。params:用于配置参数映射,count=countSql表示若 Mapper 方法参数中有countSql字段则使用它来自定义 count 查询。return-page-info:是否返回 PageInfo 类型。开启后方法返回PageInfo时自动填充总条数等信息。
还有一个容易被忽略的配置是page-size-zero,默认为false。如果设置为true,当pageSize=0时不执行分页,直接查询全部数据。某些导出功能中这个开关很好用,但在线列表接口建议保持关闭。
3.3 老项目集成中的常见冲突
老项目集成 PageHelper 时,最常遇到的就是 MyBatis 已有自定义拦截器,多个拦截器之间的执行顺序问题。MyBatis 拦截器用责任链模式执行,后注册的拦截器会先执行。如果你有自定义的拦截器,比如数据权限拦截器、SQL 日志拦截器,要留意它们会不会和 PageHelper 的执行顺序冲突。
我遇到过一种情况:项目里自定义了一个拦截器,它会对 SQL 做字符串替换,结果把 PageHelper 改写后的LIMIT 0, 10也一并替换了,导致分页参数被破坏。排查了很久才发现是拦截器顺序问题。
解决办法很简单:在注册拦截器时明确顺序,或者把自定义拦截器的逻辑限定在某类 SQL 上。不要图省事在多个拦截器里做“无差别字符串处理”,这是很多诡异问题的温床。
4. PageHelper 的实用姿势与常见坑
4.1 startPage 的调用陷阱:ThreadLocal 的前世今生
PageHelper 的startPage底层是基于ThreadLocal的。这是一个特别好用也特别容易出错的机制。startPage之后,下一次查询会自动使用分页参数,查询完成后 PageHelper 会清理ThreadLocal。
但如果你这样写:
PageHelper.startPage(1, 10); List<User> list = userMapper.selectAll(); PageHelper.startPage(2, 10); List<Role> roles = roleMapper.selectAll();第二次startPage会覆盖第一次的分页参数,第一个查询实际拿到的就会是第一页还是第二页?经验不足的同学很容易在这种连续分页的代码里翻车。正确做法是每次查询前紧挨着调用startPage,查询后马上使用结果,不要穿插其他分页调用。
另一个常见问题是startPage后面跟的不是查询而是其他操作。比如:
PageHelper.startPage(1, 10); User user = userMapper.selectOne(...); List<User> list = userMapper.selectAll();selectOne如果也能被 PageHelper 拦截,就会先执行分页改写,导致后面的查询分页参数错乱。我自己碰到过类似场景,最后总结的经验就是:startPage必须紧跟在真正需要分页的那条查询前面,中间不要插入任何可能触发 SQL 的操作。
再补充一个细节:在 Spring 的@Transactional方法中,ThreadLocal属性照样生效,但得注意事务代理和查询方法是不是同一个线程。异步调用、线程池并发等场景下,ThreadLocal会失效或串号,分页参数会莫名其妙被其他线程共享。遇到这种场景,宁可放弃startPage,改用supportMethodsArguments传参方式,或者显式用Page对象作为 Mapper 方法参数。
4.2 PageInfo 与 Page 对象返回的包装逻辑
分页查询后的返回结果,我建议使用PageInfo对象,原因很简单:它已经帮你封装好了 total、pageNum、pageSize、pages、isFirstPage、isLastPage、navigatePages 等一系列前端列表展示需要的属性。
PageHelper.startPage(1, 10); List<User> users = userMapper.selectAll(); PageInfo<User> pageInfo = new PageInfo<>(users);这里有个隐含知识点:userMapper.selectAll()返回的List其实是一个Page对象(PageHelper 返回的ArrayList子类)。PageInfo接收这个Page对象后,能从中取出 total、pageNum 等信息。
所以如果你把查询结果直接返回给前端,而前端又需要 total 字段,请记得包装成PageInfo。有些同学只返回了List,发现前端拿不到 total,其实就是漏了这一步。
另外注意,Page对象继承自ArrayList,序列化时字段传递有一定讲究。如果你用 Jackson 直接序列化Page,可能带出一些不期望的字段,比如countColumn、orderBy等。稳定做法是统一用PageInfo作为接口返回模型,或者自己定义统一的返回 DTO。
4.3 分页中的排序、条件与动态 SQL 优化
分页和排序经常一起用。最佳实践是排序条件尽可能写在 SQL 里,让数据库在分页之前完成排序,这样每一页的数据才是连续且正确的。
<select id="selectUserPage" resultType="User"> SELECT * FROM user WHERE status = #{status} ORDER BY create_time DESC </select>有人会问:能不能像startPage那样 PageHelper 来帮我排序?PageHelper 确实支持PageHelper.orderBy("create_time desc"),但个人不太推荐在业务代码里写字符串排序,一方面有 SQL 注入风险,另一方面字符串拼接的排序条件难以维护。排序字段我倾向于用白名单方式,在后端做字段映射,再拼到 SQL 中,不让前端直接传任意字段名。
还有一个很容易踩的性能坑:在分页查询中使用select *。如果一个表有几十个字段,里面还有text类型的大字段,物理分页虽然只返回一页数据,但查询过程中可能仍然会把大字段读入内存,严重影响性能。建议分页查询只查列表页需要的字段,详情接口单独查全字段。这个优化实践上往往比调 PageHelper 配置更见效。
5. count 的生成与性能优化实践
5.1 自动 count 查询的工作方式与优化
PageHelper 在分页时,会自动执行一条select count(*)类型的查询。自动生成的 count 查询会把原 SQL 的select部分替换为select count(*),同时去掉order by子句。
常规场景下这是没问题的,但遇到下面几种情况,自动 count 可能出错:
- SQL 中包含
group by时,自动生成的 count 可能统计的是分组数量还是组内行数,逻辑会变得复杂。 - SQL 中包含
distinct时,简单的select count(*)无法处理去重后的统计。 - 复杂子查询或
union场景下,自动改写可能生成不合法的 count 语句。
遇到这种情况,有两种解决方案。一种是手动分页,不依赖 PageHelper 的自动 count,而另一种是使用 PageHelper 支持的“自定义 count 查询”机制。
PageHelper 允许你定义一个以_count结尾的 Mapper 方法,来替代自动生成的 count。比如:
// 自定义 count 查询,分页时 PageHelper 会优先调用这个方法 long countByCondition(@Param("status") Integer status);再配合params配置,PageHelper 会优先执行自定义 count 方法,而不是去改写原查询。这在复杂统计场景下能显著提升准确性。
5.2 大 offset 分页下的性能攻坚
分页性能问题,最典型的场景就是“深分页”。假设一页 10 条,用户翻到第 100000 页,SQL 会变成:
SELECT * FROM user ORDER BY id LIMIT 999990, 10;数据库需要把前面 999990 行全部扫描并跳过,才能返回最后 10 条。这就是LIMIT关键字的最大短板。offset 越大,性能越差。
应对方案一:基于游标(cursor)的分页。不传页码,而是传上一页最后一条记录的 id:
SELECT * FROM user WHERE id > #{lastId} ORDER BY id LIMIT 10;这种方案在数据分布连续、按主键排序时非常高效,也是目前 C 端业务中比较推荐的方案。缺点是用户不能随意跳页,只能“下一页”。
应对方案二:子查询延迟关联。先查出当前页主键集合,再关联原表取其他字段:
SELECT u.* FROM user u JOIN (SELECT id FROM user ORDER BY id LIMIT 999990, 10) tmp ON u.id = tmp.id;这条 SQL 让内层查询尽量只扫描主键索引,减少了回表的开销。实际效果取决于表的索引情况,但通常比直接大偏移量查询好很多。
PageHelper 并没有完全替你解决深分页问题,它只是生成了 LIMIT 语句,底层的执行性能还得靠优化手段背。这也是面试官很喜欢问的一个点:PageHelper 的底层是什么,深分页怎么优化。一句话总结,就是“SQL 层面加 LIMIT,但查询性能需要你自己兜底”。
5.3 与“手动改写 SQL”的取舍
有些团队会因为深分页、复杂查询等问题,选择放弃 PageHelper,全部手写 SQL。我不否认手写 SQL 在特定场景下更可控,但你要权衡代价。
手写 SQL 意味着你每次都要处理:
- 页码和偏移量的换算,比如
(pageNum - 1) * pageSize - 当前数据库方言的差异
- count 和 list 两条 SQL 的维护
- 排序、去重、分组场景的处理
如果项目只有几个分页接口,手写完全没问题。但如果业务体量大、分页接口多,用 PageHelper 统一处理能省大量时间和维护成本。我通常的做法是:通用列表接口用 PageHelper,极端性能要求的核心查询手写 SQL,二者结合而不是二选一。
6. 手动 LIMIT 还是 PageHelper?两种方案的取舍
6.1 LIMIT 语法细节与实际执行规则
前面说到了LIMIT的分页能力,这里展开讲讲它的语法细节,因为很多面试问题就是从这里开始的。
MySQL 中LIMIT有两种写法:
LIMIT 10; -- 返回前 10 条 LIMIT 10, 20; -- 跳过 10 条,返回接下来 20 条 LIMIT 20 OFFSET 10; -- 等价于上面,跳过 10 条,返回 20 条注意两种写法的区别:LIMIT offset, row_count第一个参数是偏移量,第二个参数是返回行数;LIMIT row_count OFFSET offset则第一个是返回行数,第二个是偏移量。混淆这个顺序是写 SQL 最常见的低级错误之一。
还有一个常被忽视的细节:LIMIT可以和ORDER BY组合,也可以和WHERE组合。执行顺序上,数据库通常是先WHERE过滤,再ORDER BY排序,最后才应用LIMIT。所以如果排序字段没有索引,或者排序规则会变化,分页结果可能在不同请求间出现重复或跳行。
这里还有个面试中常见的问题:LIMIT能不能接受表达式?在 MySQL 中,LIMIT后面可以接变量或表达式,但在 Prepared Statement 中使用参数绑定会更安全。
6.2 手写分页时如何规避注入与性能问题
既然讲到了LIMIT,就必须提醒安全细节。很多人写手分页时喜欢直接把页码拼到 SQL 里:
String sql = "SELECT * FROM user LIMIT " + offset + ", " + pageSize;这种方式极其危险。如果 offset 或 pageSize 来自前端且没有做类型校验,传入负数或特殊构造值可能破坏整条 SQL 的结构。要严格使用PreparedStatement参数绑定:
String sql = "SELECT * FROM user ORDER BY id LIMIT ?, ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setInt(1, offset); ps.setInt(2, pageSize);数据库对LIMIT参数绑定有各自的支持程度,但主流数据库都能安全处理。使用参数绑定后,注入风险大幅降低,同时数据库还能复用执行计划。
另外还要意识到一个约束:LIMIT的 offset 和 row count 本质上是整数,如果业务上允许用户传入超大的 offset,你需要自己判断是否合理。参数校验不仅是安全问题,也是性能问题。我在接口层通常会对分页参数做最大限额限制,比如 pageSize 上限 100,超过就报参数异常,这对防止接口被恶意刷流量也有帮助。
6.3 什么时候放弃 PageHelper,直接手写 LIMIT
虽然我前面一直在给 PageHelper 说好话,但有几个场景我会明确选择手写LIMIT。
场景回归类报表,比如固定维度的统计报表、即席查询,SQL 往往非常复杂,含有大量group by、rollup、临时表,PageHelper 的自动 count 和 SQL 改写可能出错。与其调试插件行为,不如稳扎稳打手写 SQL,明确控制 count 和 list 两套语句。
多表 join 大字段分页,同样是深分页问题。手写 SQL 可以用“先查主键再回表”的模式来优化,而 PageHelper 对这类查询的改写是通用的、不一定能拿到最佳执行计划。
需要实时响应的超大结果集导出场景,恐怕也不是分页的问题,而是流式查询的问题。手写 SQL 配合流式读取比 PageHelper 更适合。
做一个简单的对比总结:
| 维度 | PageHelper | 手写 LIMIT |
|---|---|---|
| 开发效率 | 高,一行代码出分页 | 低,每个接口都要处理参数 |
| 跨数据库方言 | 自动适配 | 需要自己维护多套 SQL |
| 深分页优化 | 常规能力,不自动优化 | 可以针对场景极致优化 |
| 复杂 count 统计 | 可能出现兼容问题 | 完全可控 |
| SQL 注入风险 | 低,机制安全 | 取决于写法,需谨慎 |
7. 问题排查实录:那些年我踩过的 PageHelper 坑
7.1 典型问题速查表
下面这个表是我在实际业务和团队排查中沉淀下来的高频问题,做成了速查表,方便大家直接对号入座。
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 分页不生效,返回全部数据 | startPage 后没有紧跟查询;或查询方法被其他拦截器提前消费 | 检查 startPage 与查询之间是否有其他 SQL 操作 |
| 分页结果 total 始终为 0 | 返回的是包装对象而非 Page 对象;或 PageInfo 构造参数错误 | 确认查询返回的是Page或List<Entity>,再构造 PageInfo |
| 多数据源下分页失效 | PageHelper 拦截器未对第二个数据源生效;或方言识别错误 | 显式配置helper-dialect,检查各数据源的 SqlSessionFactory |
| count 查询结果错误 | SQL 含 group by/distinct/union,自动 count 改写不准 | 自定义 count Mapper 方法,或改用手写分页 |
| 分页 SQL 中 order by 失效 | 自动 count 会去掉 order by,但主查询的 order by 被误删 | 检查 SQL 中 order by 位置,必要时手动处理 |
| 出现“不可执行的 SQL”报错 | 插件版本与 MyBatis 版本不兼容 | 统一升级或降级版本 |
7.2 跟 MyBatis 二级缓存配合时的特殊情况
MyBatis 的二级缓存区域是按 namespace(Mapper)划分的。分页查询如果开启了二级缓存,可能会因为缓存命中而跳过 SQL 执行,此时 PageHelper 的分页拦截逻辑根本没机会触发,导致 Page 信息丢失或数据不一致。
同时,二级缓存本身在分页场景下意义有限,因为不同分页参数会导致 key 膨胀,缓存命中率低。我以前排查过一个“换页后数据不正确”的线上问题,最后定位到就是因为二级缓存中存了上一次查询的完整结果,参数不同却复用了缓存。最终的解决办法是:分页查询的 Mapper 关闭二级缓存,只对字典等稳定数据开启缓存。
7.3 关于大小写、参数格式等“小问题”
不要小看这些“小问题”。分页参数pageNum、pageSize如果传入字符串,PageHelper 在类型转换时可能抛出异常。有些前端会把页码写成"1",正常情况下能自动转换,但如果传了"abc",异常信息会让人摸不着头脑。
另一个隐蔽问题是PageHelper.startPage(1, 10)传入pageSize为负数或 0 时的行为。在默认配置下,pageSize 小于等于 0 时不会分页。合理利用这一点可以实现“分页开关”,但也会让部分接口在传错参数时静默返回全量数据,一旦表数据量大,线上就是事故。所以我在接入层做参数校验,pageNum 必须大于 0,pageSize 必须在 1 到 100 之间,从源头掐断这种现象。
8. 我的使用体会与几句经验总结
用过 PageHelper 五六年,从它早期的 4.x 版本一路用到 5.x,说说个人最直观的感受。
这个插件最大的价值不是帮你写那行LIMIT,而是把“分页”这个横切关注点从业务代码里剥离出来。你不需要在每个 Mapper 方法里维护 count SQL 和 list SQL,不需要在不同数据库之间切换语法,只需要关注业务查询本身。对团队规范来说,这本身就是一种约束和收敛。
但它绝不是万能的。深分页优化、复杂统计、多表超大结果集,这些场景下我依然会选择手写 SQL,甚至用游标、流式查询等更底层的手段。工具的价值在于解决普遍问题,而方案的价值在于解决具体问题。我会在项目里建立一条基本准则:默认列表查询用 PageHelper;核心报表、深分页接口单独评审 SQL 方案。
另外一个体会是:无论用哪个版本,都要把源码读一读。PageHelper 的源码不算长,核心类就那么几个,读懂了之后很多问题都不是玄学,而是机制推导的自然结果。比如你知道它是靠ThreadLocal传参,就不会写出跨方法的startPage调用;你知道它自动改写 count,就不会把复杂统计交给它盲目执行。
分页这个功能看起来简单,但真正做深了,里面有方言适配、执行顺序、缓存一致性、深分页优化这些门道。希望这篇内容能帮你少踩几个坑,也把 PageHelper 和LIMIT这套组合用得更清楚。最后再分享一个我踩过多次的小技巧:上线前一定要用真实数据量做一次分页压测,不要在小数据量环境验证了就以为万事大吉——很多分页问题,都是数据量上来之后才暴露的。