分页这东西,业务系统里几乎天天见,但真要问一句“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 时使用的查询入口。如果不拦第
