分页这东西,业务系统里几乎天天见,但真要问一句“MyBatis 分页底层到底怎么做的”,能讲清楚的人并不多。尤其是用 PageHelper 的时候,很多人只知道 PageHelper.startPage(pageNum, pageSize) 后面跟一条查询就能分页,但为什么必须紧跟?为什么有时候分页会失效?为什么 count 查询那么慢?这套机制到底是怎么把一条普通 SQL 变成 limit 语句的?这篇文章就以 PageHelper 为主线,把 MyBatis 分页的实现原理和插件机制一层层剥开来讲,配合源码分析和实际踩坑记录,希望能给正在用 MyBatis 或者准备面试的朋友一些真正有用的参考。
我自己从 MyBatis 3.2 时代就开始用 PageHelper,期间换过手写分页、MyBatis-Plus 分页插件,最后又绕回 PageHelper,中间踩过的坑不算少。这篇文章既讲原理,也把这些实战中容易出问题的地方一并整理出来,适合已经在用 MyBatis 的开发者,也适合那些只听说过 PageHelper 但没搞懂它为什么这么设计的初学者。看完之后你至少能回答这几个问题:分页插件是怎么工作的?为什么它能拦截到 SQL?分页参数在哪个环节被注入?count 查询又是怎么来的?
1. 从原生 MyBatis 分页说起:手写 SQL 的那些痛点
1.1 没有插件之前,我们是怎么分页的
先回到最原始的场景。MyBatis 本身是支持分页的,它内置了一个 RowBounds 参数,可以这么写:
java复制List<User> list = sqlSession.selectList("com.example.mapper.UserMapper.selectUserList", null, new RowBounds(0, 10));
看着挺省事对吧?但实际用过的人都知道,这个 RowBounds 是“内存分页”,它会把所有满足条件的数据全部查出来,然后在内存里截取 offset 到 offset + limit 这一段。数据量小的时候没感觉,数据量一旦上了几十万,内存直接被打满,查询时间也完全不可控。所以生产环境基本没人这么用。
更常见的做法是在 SQL 里手动拼接数据库方言。比如 MySQL 就写 LIMIT #{offset}, #{pageSize},Oracle 就用 ROWNUM,SQL Server 就用 OFFSET ... FETCH NEXT。这就带来几个问题:
- 每写一个查询,都要根据数据库类型写不同的分页语法。
- 查询总条数和查询列表数据要写两条 SQL,count 和 list 的条件还得保持一致,维护成本极高。
- 如果后来换数据库,所有 mapper XML 里的分页 SQL 都得改一遍。
- 更坑的是,很多人会把分页参数和业务参数混在同一个参数对象里,SQL 里
#{}引用的字段会变得特别乱。
后来有一些团队选择自己封装一个拦截器,用 MyBatis 提供的 Interceptor 机制在 SQL 执行前自动改写语句,把 limit 拼接进去,同时自动执行 count 查询。这个思路其实和 PageHelper 是完全一致的,只是 PageHelper 把这些事情做成了通用、成熟、能自动识别方言的组件。
1.2 理解 MyBatis 分页前,先认识四个核心对象
要理解分页插件为什么能生效,得先知道 MyBatis 在一次完整查询过程中会经过哪些关键对象。MyBatis 的整个执行链路大概可以简化成四层:SqlSession → Executor → StatementHandler → ParameterHandler + ResultSetHandler。
Executor 是执行器,负责调度整个查询过程,包括处理二级缓存、调用 StatementHandler 等。StatementHandler 负责把 Java 参数转换成 JDBC 语句,真正去操作数据库。ParameterHandler 负责给 PreparedStatement 的参数占位符赋值。ResultSetHandler 负责把 JDBC ResultSet 映射成 Java 对象。
MyBatis 的插件机制就是围绕这四大对象来的,你可以通过 @Intercepts 注解声明要拦截哪个对象的哪个方法。PageHelper 的切入点主要在 Executor 层,因为它拦截的 Executor.query() 方法刚好是 SQL 执行前最靠前的那一道关口,在这里改写 BoundSql,后续 StatementHandler 拿到的就已经是分页后的 SQL 了。拦截这一层还有一个额外的好处:不管方法调用走的是默认的 SimpleExecutor 还是带缓存的 CachingExecutor,最终都会经过 Executor.query(),分页逻辑不会因为执行器类型不同而漏掉。
1.3 什么是 BoundSql
分页 SQL 能被改写,核心依赖的一个东西叫 BoundSql。BoundSql 可以理解成“一条待执行 SQL 的完整描述”,里面包含了最终的 SQL 语句文本、参数映射关系、参数对象等。在 Executor.query() 执行时,可以通过 MappedStatement 拿到 BoundSql,然后对 BoundSql 里的 SQL 字符串做处理,拼接上分页语句。
这里有个细节很多人不知道:BoundSql.getSql() 拿到的 SQL 里,#{} 参数已经被替换成了 ? 占位符,但 ${} 拼接的内容已经变成实际值了。所以 PageHelper 在生成 count SQL 和分页 SQL 时,处理的是带占位符的 SQL,后面再通过 ParameterHandler 给这些占位符填充真实值。这就是为什么有时候你打印 PageHelper 生成的 SQL,会发现里面有 ?,需要结合参数才能看懂完整语句。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. PageHelper 的工作原理:一个拦截器的全家桶
2.1 插件机制到底拦了什么
先看一段最关键的源码。PageHelper 的入口类是 com.github.pagehelper.PageInterceptor,它的核心注解签名如下:
java复制@Intercepts(
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})
)
public class PageInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// ...
}
}
这段代码为什么重要?因为它决定了 PageHelper 不是靠 AOP 切 Service,也不是靠代理 Mapper 接口,而是直接嵌入 MyBatis 自身的执行链路。它拦截的是 Executor 的 query 方法,而且两个重载都拦了。第一个重载是标准查询入口,第二个重载是 MyBatis 在命中二级缓存、或者需要手工构造 CacheKey 时使用的查询入口。如果不拦第二个重载,某些带缓存场景下分页就会绕过拦截,导致 PageHelper 失效。这里其实也是很多“分页偶尔失效”问题的根源之一,后面我会单独讲。
当 Executor.query() 被拦截时,PageHelper 会先检查当前线程里有没有分页参数,这个参数就是我们调用 PageHelper.startPage() 时塞进去的 Page 对象。如果没有分页参数,直接放行,不改变任何行为。如果有,PageHelper 就开始进入真正的分页流程。
2.2 一次完整的分页执行链路
以最常见的用法为例:
java复制PageHelper.startPage(1, 10);
List<User> list = userMapper.selectUserList();
PageInfo<User> pageInfo = new PageInfo<>(list);
这行代码看起来平淡无奇,但背后发生的事情比你想象的多。我把完整链路拆成下面几个阶段。
PageMethod.startPage(1, 10) 做的事情就是创建一个 Page 对象(这个对象实际上是一个 ArrayList 的子类,这就是为什么查询结果能直接转型成 Page),然后把 Page 对象保存到 ThreadLocal 里。ThreadLocal 是个线程私有变量,同一个线程后续执行到 Executor.query() 时,都能从这个 ThreadLocal 里把分页参数取出来。这也是 PageHelper 要求“startPage 之后必须紧跟要分页的查询”的根本原因,因为只要中间插入了其他 Mapper 查询,那个查询也会拿到这个分页参数,造成张冠李戴。
接下来执行 userMapper.selectUserList(),调用链一路进入到 MyBatis 的 Executor。PageHelper 拦截到这个方法后,开始走分页改造流程:
- 从 ThreadLocal 中取出 Page 对象,并判断是否需要进行 count 查询。
- 如果需要 count,就先改写出一条 count SQL,执行一次查询拿到总条数,把结果设置到 Page.total 字段。
- 根据数据库方言改写原 SQL,生成带分页语句的 SQL。
- 执行分页 SQL,将返回的结果放入 Page 对象中。
- 清除 ThreadLocal 中保存的分页参数,避免线程复用导致参数残留。
到这里,list 变量虽然是 List<User> 类型,但它的实际运行类型是 Page。由于 Page 继承自 ArrayList,所以你可以直接遍历它,也可以通过 new PageInfo<>(list) 拿到 total、pages、pageNum、pageSize 这些和页面展示相关的属性。
2.3 为什么说 ThreadLocal 是把双刃剑
这么多年经常有人在群里问“PageHelper 分页查出来的数据不对,是不是插件有 bug?”其实绝大部分情况不是 bug,而是 ThreadLocal 使用不当。
ThreadLocal 给 PageHelper 带来了很大的使用便利,你不用像 MyBatis-Plus 的分页插件那样必须把分页对象作为方法参数传进去,也不需要改动 Mapper 接口。但这种便利的前提是:startPage 和真正的 Mapper 查询之间,不能夹带任何其他数据库操作,否则分页参数就可能会被前面或后面的其他查询消费掉。
举一个非常容易踩坑的例子:
java复制// 错误示例
PageHelper.startPage(pageNum, pageSize);
User user = userMapper.selectByUserId(userId); // 这个查询会被错误分页
List<User> list = userMapper.selectUserList(); // 这个查询反而没有分页参数了
这种代码如果出现在一个事务方法里,排查起来会让人崩溃。所以 PageHelper 官方文档一直强调:startPage 之后紧跟的那个 Mapper 查询,才是被分页的查询。从我个人的经验来看,最稳妥的写法是让分页查询单独成行,中间不做任何无关操作,也不要尝试在同一个方法里连续对两个 Mapper 方法分页。如果真的需要连续分页两次,那就先手动调用 PageHelper.clearPage() 清一次参数再设置新的参数。
3. 源码视角:PageHelper 是怎样把 SQL 改成分页 SQL 的
3.1 自定义 count SQL 和自动 count 的取舍
分页查询最麻烦的其实不是列表 SQL,而是总条数 SQL。如果你用的是 MySQL,手动写 count 通常就是 SELECT COUNT(*) FROM user WHERE ...,感觉很简单,但它有两个隐患。第一个是 count 里的 WHERE 条件必须和列表查询完全一致,一旦改了一边忘了另一边,前端展示的总页数就是错的。第二个是性能,如果列表查询里有 JOIN、有子查询、有 GROUP BY,那 count 语句的设计就需要重新考虑。
PageHelper 的默认策略是自动生成 count SQL。它的做法是把原始 SQL 里的 order by 去掉,然后把 select 后面的列替换成 count(0),最终生成类似 SELECT count(0) FROM user WHERE ... 的语句。这个策略对绝大多数单表查询是有效的,但对复杂的 SQL,比如带有 DISTINCT、GROUP BY、UNION 的语句,自动生成的 count SQL 可能并不符合预期。
为了应对这种情况,PageHelper 允许你自定义 count 查询。在 Mapper 里专门写一个查询方法,命名上的规律是:如果列表查询的 id 是 selectUserList,那 count 查询的 id 就写成 selectUserList_COUNT。PageHelper 在执行时会先用这个 id 去查找是否存在对应的 MappedStatement,如果存在就用你写的这条 count SQL,如果不存在才走自动生成逻辑。
这里我实际测试过,自定义 count SQL 对复杂报表类分页特别重要。比如查询语句里有多个 JOIN,只需要统计主表记录数时,自动生成的 count 会把所有 JOIN 都带进去,导致 count 执行非常慢。这时候你完全可以把 count SQL 简化成只查主表。不过要注意,自定义 count SQL 必须手动保证查询条件一致,这属于以人力换性能的典型场景。
3.2 方言生成:MySQL、Oracle 和 PostgreSQL 的分页差距
PageHelper 有一个 Dialect 的概念,会根据目标数据库生成对应的分页语句。它会先通过 JDBC 连接的元数据来自动识别数据库类型。如果自动识别失败,也可以在配置里手动指定 helperDialect。不同数据库的分页语法差别很大,这里单独列一下。
MySQL 和 PostgreSQL 使用 LIMIT 语法,区别不大。SQL Server 2012 以上使用 OFFSET ... FETCH NEXT。Oracle 的分页最麻烦,因为老版本没有 limit,要用三层嵌套的 ROWNUM。这里我重点说一下 Oracle,因为热词里有人专门搜“oracle分页”,而且 PageHelper 处理 Oracle 的源码逻辑也确实比 MySQL 复杂不少。
对于 MySQL,PageHelper 生成的语句基本就是:
sql复制SELECT * FROM user WHERE age > ? ORDER BY id LIMIT ?, ?
它会把页码换算成 offset,参数顺序分别是偏移量和每页条数。如果页码从 1 开始,那 offset = (pageNum - 1) * pageSize。
对于 Oracle,PageHelper 会把原始 SQL 包一层:
sql复制SELECT * FROM (
SELECT TMP_PAGE.*, ROWNUM ROW_ID FROM (
SELECT * FROM user WHERE age > ? ORDER BY id
) TMP_PAGE
WHERE ROWNUM <= ?
)
WHERE ROW_ID > ?
为什么要包两层?因为 Oracle 的 ROWNUM 是在结果集生成之前分配的,如果你只写 WHERE ROWNUM > 10 AND ROWNUM <= 20,是查不出数据的,ROWNUM 永远从 1 开始。所以必须先把小于等于最大行号的数据捞出来,再在外层过滤掉前面的行。这是一个很经典的 Oracle 分页写法,PageHelper 内部就是对这个模式做的封装。
在代码里,PageHelper 的方言逻辑主要是在 com.github.pagehelper.dialect 相关的类中完成的。里面有个 PageAutoDialect 类专门负责方言的自动识别和缓存,它会根据 JDBC URL 里的关键字判断数据库类型。比如 URL 里包含 oracle 就加载 Oracle 方言,包含 mysql 就加载 MySQL 方言。需要注意的是,如果项目配置了多数据源,而且连的是不同类型的数据库,那最好显式配置 autoRuntimeDialect 为 true,让 PageHelper 在运行时根据当前连接动态获取方言,而不是启动时只识别一次。
3.3 PageInterceptor 里那些容易忽略的配置项
PageHelper 的配置项很多,很多人在 Spring Boot 里只配了一个 helperDialect 就完事了,但实际上还有几个关键配置直接影响分页行为。
reasonable 这个参数控制页码的合法性校验。如果设置为 true,当请求的 pageNum 小于 1 时,会自动查询第一页;当 pageNum 大于总页数时,会查询最后一页。比如用户在前端手动把 URL 里的 pageNum 改成一个特别大的值,reasonable 为 true 时后端不会返回空数据,而是返回最后一页。这个配置在 API 接口中非常实用,能避免很多无效查询。但它也有副作用,当 pageNum 异常时,你不会收到任何报错,如果前端展示逻辑依赖页码,可能会掩盖参数传错的 bug。
supportMethodsArguments 支持从 Mapper 方法的参数对象中读取分页参数。这是什么意思呢?正常写法是 startPage 方法先设置参数,PageHelper 从 ThreadLocal 拿分页条件。而开启 supportMethodsArguments 后,如果你 Mapper 方法的参数对象里有 pageNum 和 pageSize 字段,PageHelper 也能识别。不过实际项目中我一般不建议开这个,因为这会掩盖掉分页的显式调用意图,代码可读性会变差,别人从 Service 层看代码根本不知道哪个查询会被分页。
还有一个 params 配置,可以自定义参数映射,比如 params=pageNum=pageNum;pageSize=pageSize;。这个一般用的少,但当你使用 PageHelper 的 Page 对象作为参数传给 Mapper 时,它需要知道参数名是怎么对应的。默认情况下 PageHelper 可以识别 Page 对象里的 pageNum 和 pageSize 字段,即使你什么也不配置也能工作。
4. 实际开发中的 PageHelper 经典翻车现场
4.1 分页失效:最容易被忽略的“三连击”
第一类是 startPage 和查询之间隔了其他数据库操作。这种我在上面已经说过,ThreadLocal 里的参数被前面的查询消费掉,等真正要分页的查询执行时,参数已经不存在了。排查方法是在 startPage 和 Mapper 方法调用之间打日志,看有没有其他 Mapper/SqlSession 操作。
第二类是 PageHelper 拦截到了嵌套查询。MyBatis 的嵌套结果映射或者嵌套查询有一个特点:一次外层查询可能触发多条 SQL,比如 resultMap 里配置了 <collection select="...">,外层查完十条用户数据后,MyBatis 会再发十条查询去查每个用户的订单。PageHelper 在改写了外层第一条 SQL 之后,ThreadLocal 里的 Page 对象可能还存在,导致后面的嵌套 SQL 也被当作分页查询处理。这会造成意想不到的结果,比如嵌套数据只有一页,或者干脆报错。
第三类是二级缓存导致的假失效。当 MyBatis 二级缓存开启时,如果同一个查询条件已经缓存过结果,Executor 可能在缓存中直接命中,而不去真正查数据库。PageHelper 的拦截点在 Executor.query() 外层,但 MyBatis 有一些内部环节会导致 PageHelper 里某些分支没有走到重写 SQL 的逻辑,以致结果返回的不是预期的 Page 对象。这种情况不算特别常见,但一旦出现会非常隐蔽。
4.2 count 查询慢到让人怀疑人生
很多分页接口是列表查询本身很快,count 查询反而成了瓶颈。为什么?因为 count SQL 也要执行一遍原查询里的 JOIN 和 WHERE 条件。MySQL 的优化器在处理 count 时,即使逻辑上不需要 JOIN 的表也能优化掉一部分,但复杂的 JOIN 和 GROUP BY 并不会自动简化。
举一个真实案例。我当时在一个报表系统里写过分页查询,列表 SQL 涉及 5 张表 JOIN,主表 20 万数据,明细表关联后有几百万行。列表查询用 LIMIT 20,执行时间 50 毫秒左右,但 PageHelper 自动生成的 count SQL 要把所有 JOIN 完的数据全部统计一遍,执行时间稳定在 2 秒以上。这类问题在 Oracle 里更明显,因为 Oracle 对复杂视图的 count 优化比 MySQL 还要保守。
解决方案就是我上面提到的自定义 count SQL。用 selectList_COUNT 命名专门写一条轻量的 count 语句,不 JOIN 明细表,只统计主表行数,前提是业务上统计口径允许。如果必须 JOIN 才能过滤,那就尽量把 WHERE 子句中的条件写在 JOIN 的 ON 里面,减少结果集大小。
另外,MySQL 里 count SQL 如果检测到慢查询,可以用覆盖索引优化。比如 SELECT COUNT(*) FROM user WHERE status = 1,如果 status 字段有索引,并且 select 的列都在索引里,InnoDB 扫描索引比扫描聚簇索引要快得多。但这些优化是在 SQL 层面的,PageHelper 生成的 count SQL 并不一定走最优索引,所以有时候手工改写反而更快。
4.3 嵌套结果映射 returnType 导致分页结果偏离预期
PageHelper 还有一个经典问题,我自己就遇到过好几次:分页查出来 pageSize 明明是 10,返回的 list 却少于 10 条,甚至只有 5 条,而且 total 是对的。这不是 PageHelper 的问题,而是用了嵌套结果映射(resultMap 的 collection)之后,MyBatis 在内存中对结果做了分组、去重和合并,最终返回给用户的列表行数不等于 SQL 查出来的记录行数。
举个例子。一个订单表和一个订单明细表,通过订单 ID 关联。分页 SQL 查询订单主表,LIMIT 10 取到 10 个订单,然后关联出明细结果。如果使用嵌套结果映射,MyBatis 会把同一个订单的多条明细合并成一个订单对象,里面塞一个明细 List。此时如果用户期望的是“每一行是一条明细”,就会发现返回的数据变少了或者变多了。这其实是 ORM 映射模型和分页模型之间的天然冲突。
如果你的业务确实需要“先分页订单,再加载订单下面的明细”,更稳妥的方案是分页查主表 ID 集合,再通过第二个查询批量查明细,最后在 Service 层手动组装。或者换成嵌套查询(<collection select="...">),让 MyBatis 对每一条主表数据执行一次明细查询,但这时候要注意 N+1 问题,明细查询最好加缓存。
4.4 PageInfo 是什么,它和 Page 有什么区别
在实际接口里,经常有人把 Page 对象直接返回给前端,有人用 PageInfo 包装。Page 本身是一个 List 的子类,你可以通过强转拿到 total 等属性,但它包含的分页信息不够“页面化”。而 PageInfo 是一个全新的类,它把 Page 里的内容复制过来,额外补充了 hasNextPage、hasPreviousPage、isFirstPage、isLastPage、navigatepageNums 等一堆面向页面展示的属性。
PageInfo 的构造过程也很简单,new PageInfo<>(list) 相当于做了一个数据搬运。如果你要自定义返回结构,比如只返回 list + total 给前端,自己封装一个 Result 对象也是完全可行的。我个人的习惯是不直接把 PageInfo 暴露给前端,而是再包一层响应体,把 total、pages、list 这些字段统一放到一个标准分页结构里,这样前端只需要对接一套数据结构。
java复制public class PageResult<T> {
private List<T> list;
private long total;
private int pageNum;
private int pageSize;
private int pages;
}
5. 分页性能优化实战:从 PageHelper 到 Redis
5.1 深分页为什么慢:LIMIT 的偏移陷阱
热搜词里有一条“分页查询慢怎么用 redis 优化”,这确实是很多系统数据量变大之后遇到的真问题。先看一条最典型的分页 SQL:
sql复制SELECT * FROM user WHERE status = 1 ORDER BY create_time DESC LIMIT 100000, 20
这条 SQL 看着只是取了第 10 万条之后的 20 条,但 MySQL 执行时并不能直接跳过前面的 10 万条,它要扫描出前 100020 条记录,然后丢弃前 10 万条,只返回最后 20 条。如果 ORDER BY 的字段没有索引,还要先 filesort 排序。所以 offset 越深,扫描的数据越多,查询就越慢。这个问题在 MyBatis 分页场景下会被 PageHelper 原样放大,因为 PageHelper 只是帮你生成了带 LIMIT ?, ? 的 SQL,并没有优化掉深分页的扫描代价。
优化思路有几个方向。一是限制用户能访问的页数,比如只允许查询前 100 页,超出就给提示,这是一种产品层面的取舍。二是改用游标分页,通过 WHERE id > ? ORDER BY id LIMIT 20 这种条件方式,让数据库利用主键索引直接定位到起始位置,不再从头扫描。三是用延迟关联,先用覆盖索引找到目标行的主键,再 JOIN 回原表查询需要的所有列。
延迟关联在 PageHelper 这种对 SQL 自动改写的框架下不太好直接应用,因为 PageHelper 的自动分页是针对单条 SQL 的,如果你手动把 SQL 拆成分页主查询和关联查询,PageHelper 也能工作,但 SQL 本身要按优化思路写。比如把原来的列表 SQL 改成:
sql复制SELECT u.* FROM (
SELECT id FROM user WHERE status = 1 ORDER BY create_time DESC LIMIT ?, ?
) t
JOIN user u ON u.id = t.id
ORDER BY u.create_time DESC
PageHelper 会在这个外层 SQL 上再套一层 limit,看起来多了一层,但因为子查询只查 id,可以利用覆盖索引,外部再回表查完整数据,实际执行效率比直接 SELECT * FROM user ... LIMIT ? 高很多。这里的关键是 inner 查询只检索 id 字段,避免回表。
5.2 为什么用 Redis 做分页没那么简单
Redis 做分页通常会想到把数据列表存成一个 ZSET,然后用 ZRANGE 或 ZREVRANGE 取出某一页的数据。这种方案优势在于查询速度极快,不管 offset 多大,ZSET 索引的复杂度是 O(log N + M),不会像 MySQL 那样扫描大量行。
实际落地时,我的做法是按业务维度维护一个有序集合。比如一个内容资讯首页的 feed 流,以发布时间作为 score,member 存内容 ID。查询第一页时用 ZREVRANGE feed:user:1001 0 19 拿到 20 个 contentId,再通过 Mapper 里的 WHERE id IN (...) 批量查出完整数据,最后按原来的顺序组装。
这里有一个容易踩坑的地方:IN 查询返回的顺序并不等于传入 id 的顺序,需要在 Service 层根据 id 列表重新排序。我一般会把查出来的记录转成 Map<id, entity>,然后遍历 id 列表组装结果。
Redis 分页更适合的场景是读取多、更新少、数据总量可控的热点列表。如果数据更新频繁,ZSET 的一致性维护成本会很高,每次新增、删除、修改都要同步修改 score 和 member,稍有不慎就会导致页面数据和数据库不一致。而且 Redis 内存是稀缺资源,如果列表数据有几百万条,全部放进 ZSET 也不太现实。所以更常见的组合是:热点数据存 Redis 做前几页的加速,深分页或者超大数据量场景直接走数据库条件分页。
如果要用 MyBatis 分页配合 Redis,一个实践是:只对热点查询做 Redis 缓存列表,然后在应用层实现分页切片。比如缓存了最近 1000 条内容 ID,用户在前 50 页直接从缓存切片返回;超过 50 页的请求再回源数据库查询。这种“浅分页走缓存、深分页走 DB”的模式能明显降低数据库压力。
5.3 常见的前端分页组件如何和后端 API 对接
用 PageHelper 比较多的是配合 ElementUI 的 el-pagination 这类组件。很多人第一次对接时容易在参数上搞混。ElementUI 默认事件参数是 page(当前页码)和 limit(每页条数),而 PageHelper 的 startPage 参数是 pageNum 和 pageSize,中间需要一次转换。有些后端接口直接把这些字段暴露给前端,导致前端传的是 pageNum,后端方法参数叫 current,映射混乱。
比较好的一种做法是后端统一接收一个分页请求对象,比如 PageQuery,里面包含 pageNum、pageSize 字段,然后在 Service 层调用 PageHelper.startPage(pageQuery.getPageNum(), pageQuery.getPageSize())。返回的统一结构里包含 total、pages、list,前端只需要关注这些固定字段,不用关心底层的 PageInfo 到底是哪些属性。个人经验是,分页参数接收和分页结果封装不要太依赖 PageHelper 的内部类,定义自己的请求/响应模型,以后想换其他分页组件时改动会小很多。
6. PageHelper 与 MyBatis-Plus、逻辑删除等场景的边界问题
6.1 PageHelper 和 MyBatis-Plus 的区别:两种分页设计思路
既然热词里出现了“mybatis plus 分页失效”、“mybatis 和 mybatisplus 的区别”,这里也顺带提一下。MyBatis-Plus 自带的分页插件 PaginationInnerInterceptor 和 PageHelper 实现思路不太一样。PageHelper 用的是 ThreadLocal + 拦截自动改写 SQL,MyBatis-Plus 则要求你把 Page 对象作为 Mapper 方法的第一个参数传进去,拦截器通过判断参数类型来触发分页。
这两种方式没有绝对的好坏。PageHelper 对原有 Mapper 方法侵入小,不需要改变方法签名;MyBatis-Plus 的显式传参更可控,不会出现 ThreadLocal 污染导致的分页错乱问题。如果你是纯 MyBatis-Plus 项目,我建议直接用 MyBatis-Plus 的分页插件,不要去额外引入 PageHelper,否则两个拦截器叠加在一起,谁先谁后不好控制,SQL 会被套两层 limit,查出来的数据就有问题了。
还有一点,MyBatis-Plus 的分页插件对实体类的逻辑删除是自动支持的。如果实体上标了 @TableLogic,分页查询会自动加上 deleted = 0 条件。但如果你在某些场景下想强制查询已删除的数据,MyBatis-Plus 也提供了相应的方法。PageHelper 本身没有逻辑删除的概念,它只是改写 SQL,你的逻辑删除条件还是得自己在 mapper 里写。如果你用 MyBatis-Plus 并且启用了逻辑删除,但 PageHelper 改写 SQL 时没有感知这个注解,它只是简单地在原 SQL 上套分页语法,所以逻辑删除条件是否生效取决于你的 Mapper SQL 本身。
6.2 在 Spring Boot 中正确配置 PageHelper
Spring Boot 整合 PageHelper 有两种常见方式,我用着更稳的是显式创建一个 PageInterceptor 的 Bean,然后手动添加到 SqlSessionFactory。示例代码如下:
java复制@Configuration
public class MybatisConfig {
@Bean
public PageInterceptor pageInterceptor() {
PageInterceptor pageInterceptor = new PageInterceptor();
Properties properties = new Properties();
properties.setProperty("helperDialect", "mysql");
properties.setProperty("reasonable", "true");
properties.setProperty("supportMethodsArguments", "false");
properties.setProperty("params", "count=countSql");
pageInterceptor.setProperties(properties);
return pageInterceptor;
}
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource, PageInterceptor pageInterceptor) throws Exception {
MybatisSqlSessionFactoryBean factoryBean = new MybatisSqlSessionFactoryBean();
factoryBean.setDataSource(dataSource);
// 其他 MyBatis 配置...
factoryBean.setPlugins(new Interceptor[]{pageInterceptor});
return factoryBean.getObject();
}
}
如果你的项目用了 pagehelper-spring-boot-starter,那更简单,直接在 application.yml 中配置:
yaml复制pagehelper:
helper-dialect: mysql
reasonable: true
support-methods-arguments: false
两种方式都能用,我个人的建议是如果项目里 MyBatis 配置比较复杂,就用手动创建 Bean 的方式,这样 PageInterceptor 的加载顺序是明确的,不容易出现 starter 自动配置顺序导致的奇怪问题。
6.3 排查 SQL 时的一个实用技巧
PageHelper 改写 SQL 的过程对开发者是透明的,调试时如果想知道实际执行的分页语句是什么,可以把 MyBatis 的日志级别开到 DEBUG。在 Spring Boot 的配置里加上:
yaml复制logging:
level:
com.example.mapper: debug
这样控制台会打印出 mapper 接口对应的 SQL,但 PageHelper 自动生成的 count SQL 和分页 SQL 要区分仔细。你会发现 count 语句往往出现在列表查询语句之前。如果你装了 IDEA 的 MyBatis Log Free 插件,它可以把日志里的 Preparing: ? 和 Parameters: ? 还原成可直接执行的 SQL,排查分页参数是否正确非常方便。建议在遇到分页结果不对时,第一件事就是把还原后的 SQL 复制到数据库客户端里执行一遍,这样能快速确认是 PageHelper 的问题还是 SQL 本身的问题。
PageHelper 这种设计精妙的插件,核心代码量并不大,但能覆盖这么多数据库方言、处理这么多边界场景,确实值得每个使用它的开发者花点时间研究一下源码。我在带团队的时候有一个习惯,凡是项目中引入的公共组件,至少要让核心开发人员能讲清楚它的实现原理,否则出问题的时候只能靠瞎猜。对 MyBatis 分页来说,这个要求尤为适用。
java复制// 最后留一个小彩蛋
// 如果你在 Spring Boot 项目中配置了 PageHelper 和 MyBatis-Plus 的
// PaginationInnerInterceptor,并且发现某个接口的分页结果多了很多条,
// 不妨检查一下是否两个拦截器都在执行时各自生成了一套分页 SQL。
// 这种"双拦截器"问题比单纯分页失效更隐蔽,因为它偶尔正常、偶尔出错。
我自己最近的实践中,已经越来越少用 PageHelper 了。新项目如果是 MyBatis-Plus 体系,直接用它自带的分页插件,参数传递清晰,没有 ThreadLocal 隐患。老项目如果是纯 MyBatis + 自定义 XML 复杂查询,PageHelper 依然是省心高效的选择,只要遵守“startPage 后紧跟查询”的原则,大部分问题都能规避。
在使用分页时,更深层的建议是:所有分页查询在写 SQL 之前都要先想清楚数据量和访问模式。数据量小,用什么框架都差不多;数据量大,就要绕开“深分页 + count 大扫描”这两个难点,在业务层面、SQL 层面、缓存层面提前设计,而不是到最后才换分页工具。
