PageHelper 核心原理:拦截器 + ThreadLocal + SQL 改写,一次讲透
2026/9/14 5:45:15 网站建设 项目流程

做了这么多年 Java 后端,我早期做分页时还是手写LIMIT拼 SQL,后来换成 MyBatis 就接触到了 PageHelper,当时第一反应是这玩意儿有点“玄学”——一个静态方法PageHelper.startPage()调完,紧接着那条查询居然就自动分页了,返回结果还自带总数。后来项目里出了问题,被迫把它源码读了一遍,才发现这个“魔法”背后的原理其实非常清晰,无非就是拦截器 + ThreadLocal + SQL 改写三板斧。这篇文章我尽量把 PageHelper 的核心机制讲透,还会带一个简化版的手写实现,希望能帮你看完就明白它到底是怎么工作的。

这篇文章适合这几类人:正在用 PageHelper 但偶尔被分页失效、count 不对、线程串数据等诡异问题坑到的开发者;想了解 MyBatis 插件机制到底能干什么的读者;以及准备在手写框架里自己实现分页功能的同学。看完你至少能回答三个问题:PageHelper 的调用链是怎么走的?它为什么能自动改写 SQL?以及有哪些坑是源码里早就写明、但我们不看源码就永远踩不明白的。

1. 先认识 PageHelper:一个“分页魔法师”到底解决了什么

1.1 分页这件事,为什么值得一个插件

先说分页的痛点。在没有 PageHelper 之前,我们用 MyBatis 写分页查询无非两条路。

第一条路,在 mapper XML 里手写分页 SQL,比如 MySQL 的LIMIT #{offset}, #{pageSize},Oracle 的ROWNUM包裹,SQL Server 的OFFSET ... FETCH。这搞法的痛点是显而易见的——每换一种数据库,SQL 就得跟着改一遍;而且业务一多,每个 mapper 里都有一坨大同小异的翻页代码,维护起来很烦。

第二条路,用 MyBatis 自带的RowBounds做逻辑分页。MyBatis 确实内置了RowBounds这个分页参数,也支持在查询方法里传RowBounds对象。但它默认是在内存里做分页的——也就是说,它会先把所有满足条件的数据一次性查出来,然后再从内存里截取你要的那一段。数据量小还能忍,一旦数据上了十万百万,一次查询就把全表载入内存,数据库和 JVM 内存双双遭殃。

所以 PageHelper 做的核心事情用一句话说就是:把你写的普通查询 SQL,自动改写成数据库方言对应的物理分页 SQL,并且自动查询总记录数。你写SELECT * FROM user WHERE age > 18,它直接翻译成 MySQL 的SELECT * FROM user WHERE age > 18 LIMIT ?, ?;你在 Oracle 上跑同样的代码,它又翻译成ROWNUM那套语法。这种“物理分页”方式,是在数据库层面直接限制返回行数,性能和“只查一页数据”的直觉完全一致。

1.2 PageHelper 的入门姿势

PageHelper 的使用方式简单到什么程度?就直接上代码感受一下:

// Spring Boot 项目里引入依赖 // <dependency> // <groupId>com.github.pagehelper</groupId> // <artifactId>pagehelper-spring-boot-starter</artifactId> // <version>2.1.0</version> // </dependency> // 业务代码 PageHelper.startPage(1, 10); List<User> userList = userMapper.selectByAge(18); PageInfo<User> pageInfo = new PageInfo<>(userList);

第一行PageHelper.startPage(1, 10)表示要查第 1 页、每页 10 条;第二行照常调用 mapper 方法;第三行把返回的 list 包成PageInfo,然后就能拿到pageInfo.getTotal()(总记录数)、pageInfo.getPages()(总页数)、pageInfo.getList()(当前页数据)等等。

这里有个很容易被忽略的小细节:userList这个变量的真实类型其实不是一个普通的ArrayList,而是PageHelper自定义的Page对象,它继承自ArrayList。所以即使你不包 PageInfo,直接从userList强转成Page也能拿到getTotal()PageInfo只是在这个基础上继续封装了页码、每页大小、是否是首页末页等页导航信息,方便在页面上渲染分页组件。

实际项目中我通常建议这么写:

PageHelper.startPage(pageNum, pageSize); List<User> list = userMapper.selectByAge(age); PageInfo<User> pageInfo = new PageInfo<>(list);

注意startPage()和查询之间不要插任何别的数据库操作,否则分页就会落到别的查询上。这个坑后面我会单独展开。

1.3 Page 和 PageInfo:别把两个对象搞混了

很多新手分不清PagePageInfo,这里我对比着说:

对象本质典型获取方式核心信息
Page<T>ArrayList的子类,真实的分页查询结果PageHelper.startPage()后查询直接返回totalpageNumpageSizepages
PageInfo<T>独立的包装类,包含 Page 所有信息 + 导航栏数据new PageInfo<>(list)totallisthasNextPagenavigatepageNums

Page是工具内部的“原始产物”,PageInfo是给前端展示用的“成品”。比如你需要在页面上显示“共 123 条,共 13 页,当前第 2 页,首页/末页/上一页/下一页”这一系列信息,直接用PageInfo就行,它把这些字段都算好了。

2. 原理拆解:PageHelper 是怎么“偷梁换柱”完成分页的

2.1 根基:MyBatis 插件机制

PageHelper 的底层能力建立在 MyBatis 的插件机制之上。MyBatis 允许你在它执行 SQL 的四大核心组件上做拦截,这四个组件分别是:

  • Executor:SQL 执行器,负责对底层 JDBC 操作的统一调度,StatementHandlerParameterHandlerResultSetHandler都是它协调的。
  • StatementHandler:负责创建Statement对象并填充 SQL 参数。
  • ParameterHandler:负责把用户传入的参数绑定到PreparedStatement上。
  • ResultSetHandler:负责把 JDBC 返回的ResultSet映射成 Java 对象列表。

MyBatis 插件的做法是,使用 JDK 动态代理把这四个核心对象包一层代理,在真实方法调用之前或之后插入你的自定义逻辑。也就是你在 mybatis-config.xml 或 Spring Boot 配置里注册的每个Interceptor,都会让 MyBatis 创建核心对象时用Plugin.wrap(target, this)生成一个代理对象。

@Intercepts({ @Signature( type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class} ) }) public class MyPlugin implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { // 前置逻辑:这里可以改写 SQL 或参数 Object result = invocation.proceed(); // 调用真实的 Executor.query // 后置逻辑:这里可以处理返回结果 return result; } }

这段代码是所有 MyBatis 插件的通用骨架。@Signature指定要拦截哪个接口的哪个方法;invocation.proceed()会继续向后调用真实的方法;Plugin.wrap(target, this)用来生成代理对象。理解了这套机制,你就知道 PageHelper 本质上也只是一个“比较聪明的 MyBatis 拦截器”。

2.2 PageHelper 拦截的是 Executor.query,不是 StatementHandler

很多人以为 PageHelper 是拦截StatementHandler去改 SQL 的,其实不对。PageHelper 拦截的是Executor接口的query方法

为什么是Executor而不是更底层的StatementHandler?我个人的理解是,Executor是整个查询链路的入口,拦截它有两个好处。

第一,能同时覆盖查询入口和 SQL 获取。从Executor.query的参数里能靠MappedStatement拿到BoundSql,进而拿到原始 SQL。在这里改写BoundSql里的 SQL 文本,底层StatementHandler构建PreparedStatement时自然用的就是改写后的分页 SQL。

第二,拦截点更早,逻辑更集中。PageHelper 需要在查询前先执行一条 count 查询,然后在查询后把总数和分页结果一起填充到Page对象里。在Executor层拦截,既能方便地借助MappedStatement复制构造一条 count 查询,又能同时控制两条 SQL 的执行时机。

看真实的 PageHelper 源码PageInterceptor.intercept方法,它的核心判断逻辑大致是这样的:先取出ThreadLocal里是否存在分页参数Page,如果存在且符合分页条件,就走this.dialect.beforePage(...)这段分页处理逻辑,否则直接invocation.proceed()放行。

有一种情况要说清楚:即使你没有调用PageHelper.startPage(),PageHelper 也会检查RowBounds参数。如果检测到RowBounds不为空且不是默认值,它同样会进行分页处理。这算是一个隐式的分页触发条件。

2.3 一次分页查询的完整幕后流程

我把一次 PageHelper 分页请求在源码层面的执行流程画成文字描述,你看完应该就能把从上到下的调用链串起来了。

  1. 业务代码调用PageHelper.startPage(1, 10)。这个静态方法最终调用了PageMethod.setLocalPage(page),它的本质是往ThreadLocal里塞了一个Page对象。此时还没发生任何数据库操作
  2. 业务代码继续调用userMapper.selectByAge(age),MyBatis 通过动态代理进入 Mapper 方法,最终调用到Executor.query(...)
  3. 因为Executor已被 PageHelper 代理,所以控制流先进入PageInterceptor.intercept方法。
  4. PageInterceptorThreadLocal中取出Page对象,判断查询是否需要进行分页处理。
  5. 如果需要,拦截器先调用方言处理器生成 count 用的 SQL,并执行一次 count 查询,得到总记录数。
  6. 然后根据数据库方言,把原始 SQL 改写成带LIMIT(MySQL)或ROWNUM(Oracle)等语法的物理分页 SQL。
  7. 使用改写后的 SQL 继续执行真实的数据库查询,返回当前页的数据列表。
  8. 拦截器把 count 查询得到的 total 值设置到Page对象中,并把Page对象作为查询结果的强转类型返回(这也是为什么List<User> userList = userMapper.selectByAge(age)实际上拿到的是一个Page)。
  9. finally块里调用PageMethod.clearPage()清理ThreadLocal,避免内存泄露和线程串扰。

这 9 步就是整个 PageHelper 的生命周期。你只要记住一句话:startPage 负责“存参数”,Executor 拦截器负责“改写 SQL”,ThreadLocal 负责“传递参数”,finally 负责“收拾战场”。

2.4 方言适配与 SQL 改写:它是怎么在不同数据库间游走的

PageHelper 的分页 SQL 生成并不是写死在拦截器里的,而是交给了Dialect接口。官方实现里有MySqlDialectOracleDialectSqlServerDialectPostgreSqlDialectH2Dialect等,你可以通过配置项helperDialect手动指定,也可以让它自动检测数据源类型。

以 MySQL 为例,原始 SQL 是这样:

SELECT * FROM user WHERE age > 18

经过 PageHelper 改写后,真正执行的 SQL 是这样:

SELECT * FROM user WHERE age > 18 LIMIT ?, ?

两个占位符分别对应 offset(偏移量)和 pageSize(每页条数),offset 的计算公式是(pageNum - 1) * pageSize。比如第 1 页每页 10 条,offset 就是 0;第 2 页就是 10。

在 Oracle 里则是这样的:

SELECT * FROM ( SELECT TMP.*, ROWNUM ROW_ID FROM ( SELECT * FROM user WHERE age > 18 ) TMP WHERE ROWNUM <= ? ) WHERE ROW_ID > ?

可以看出,改写的核心逻辑由方言实现类完成,PageHelper 本身的调度逻辑是不关心 SQL 长什么样的。这种设计非常优雅,以后新接一种数据库,只需要新增一个Dialect实现类。

count 查询的改写也很有讲究。对于简单的 SQL,PageHelper 会基于 SQL 解析器做一些优化,比如去掉ORDER BY子句,因为统计总数时排序毫无必要,还白白浪费性能。它能识别出 SQL 的主查询和子查询,尽量把最外层的ORDER BY移除。对于复杂场景(比如含GROUP BYDISTINCT、集合操作等),改写结果会更复杂,但核心思路都是把原始 SQL 包一层变成子查询,然后在外层做SELECT COUNT(*),保证统计行数不因分页而被截断。如果不做这层包裹,直接在一个带LIMIT的 SQL 外层 count,永远都只能得到一页的行数,这个坑自己实现分页时特别容易踩。

3. 手写一个简化版 PageHelper,验证原理

理解一个框架最好的方式就是亲手写一个最小实现。我在这里带大家写一个只支持 MySQL 的极简版分页拦截器,代码量不大,但完整走一遍拦截、SQL 改写、count 查询和结果返回的流程。这个实现忽略了很多边界情况,仅供学习原理用,不要直接用在生产环境。

3.1 拦截器骨架:拿到 query 调用

第一步,定义我们自己的Page参数对象和ThreadLocal工具类。

public class SimplePage { private int pageNum; private int pageSize; private long total; public SimplePage(int pageNum, int pageSize) { this.pageNum = pageNum; this.pageSize = pageSize; } // getter / setter 省略 } public class SimplePageContext { private static final ThreadLocal<SimplePage> LOCAL_PAGE = new ThreadLocal<>(); public static void setPage(SimplePage page) { LOCAL_PAGE.set(page); } public static SimplePage getPage() { return LOCAL_PAGE.get(); } public static void clear() { LOCAL_PAGE.remove(); } }

第二步,写我们的简化版启动方法:

public class SimplePageHelper { public static void startPage(int pageNum, int pageSize) { SimplePageContext.setPage(new SimplePage(pageNum, pageSize)); } }

第三步,写核心拦截器。这里我为了方便演示,只用Executor的 6 参数query方法做拦截:

@Intercepts({ @Signature( type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class} ) }) public class SimplePageInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { SimplePage page = SimplePageContext.getPage(); if (page == null) { // 没有调用 startPage,直接放行 return invocation.proceed(); } try { MappedStatement ms = (MappedStatement) invocation.getArgs()[0]; Object parameter = invocation.getArgs()[1]; BoundSql boundSql = ms.getBoundSql(parameter); String originalSql = boundSql.getSql().trim(); // 1. 执行 count 查询,填充 page.total long total = queryCount(invocation, ms, parameter, boundSql, originalSql); page.setTotal(total); // 2. 改写分页 SQL String pageSql = originalSql + " LIMIT " + (page.getPageNum() - 1) * page.getPageSize() + ", " + page.getPageSize(); // 3. 反射替换 BoundSql 里的 sql 字段 Field sqlField = BoundSql.class.getDeclaredField("sql"); sqlField.setAccessible(true); sqlField.set(boundSql, pageSql); // 4. 执行真实查询 Object result = invocation.proceed(); // 5. 把 Page 参数附加到结果里(如果结果是 List 则填充 total) if (result instanceof List) { ((List<?>) result).clear(); // 这里简化处理:直接把分页信息放在 ThreadLocal 里 // 实际 PageHelper 会把 Page 对象强转为 List 返回 } return result; } finally { SimplePageContext.clear(); } } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } @Override public void setProperties(Properties properties) { } }

这个骨架里有几个关键动作,我解释一下为什么要这么做。

MappedStatement ms = (MappedStatement) invocation.getArgs()[0]这句是从 query 方法参数里拿当前 mapper 对应的 SQL 元信息。ms.getBoundSql(parameter)可以获得真正待执行的BoundSql,里面封装了 SQL 文本和参数列表。而我们这里要改动 SQL,最直接的方式就是通过反射把BoundSql内部的一个sql字段替换掉。因为每次执行查询时 MyBatis 都会生成新的BoundSql,所以这里反射修改不会影响其他线程的 SQL,但你在生产代码里玩反射是要格外小心的,MyBatis 版本升级后反射字段名可能变。

3.2 SQL 改写和参数处理

你可能已经注意到了,我上面的pageSql生成方式是直接字符串拼接 LIMIT,这确实是最简单粗暴的方式,真实项目里不能这么干,原因是防 SQL 注入和参数类型处理。

真实的 PageHelper 会把offsetpageSize作为参数绑定到PreparedStatement上,而不是拼进 SQL 字符串。拼字符串风险很大,如果pageSizepageNum来自前端且未被校验,一旦被攻击者利用,就能注入恶意 SQL。虽然 LIMIT 后面的参数通常只接受整数,你可以在业务层强转成 int,但更规范的做法仍然是参数化。

改进后的参数处理大致是这样的:我们不光要改 SQL 文本,还要往参数列表里追加两个参数。BoundSql里维护了一个additionalParameters集合,新增参数需要动态注册:

// 追加分页参数 List<Object> params = new ArrayList<>(); if (boundSql.hasAdditionalParameter("pageParam1")) { // 真实实现会动态添加附加参数 }

其实真正负责这件事的是 MyBatis 的ParameterHandler。PageHelper 在改写完 SQL 后,会把分页参数追加到BoundSql.additionalParameters这个 Map 里,然后再通过Configuration.newStatementHandler重新生成StatementHandler,确保新的参数能被 MyBatis 正确处理。我这里只做原理示意,想说明的是:改 SQL 只是第一步,把新的 SQL 需要的参数同时传给 JDBC 才是完整的分页处理。

3.3 COUNT 查询的实现思路

我们刚说的queryCount方法还没实现,这里单独展开一下。

最简单的 count 查询写法,就是拿原始 SQL 包一层:

private long queryCount(Invocation invocation, MappedStatement ms, Object parameter, BoundSql boundSql, String originalSql) throws Exception { // 1. 生成 count 语句 String countSql = "SELECT COUNT(*) FROM (" + originalSql + ") tmp_count"; // 2. 需要一个新的 MappedStatement 来承载 count 查询(简化版省略了反射拷贝) // 真实 PageHelper 会调用 MSUtils.newMappedStatement 拷贝原 MappedStatement, // 把 SqlSource 替换成 StaticSqlSource,并指定结果类型为 Long。 // 3. 用原 ms 的 configuration 执行 count 语句 // ... return 0; }

这里的核心难点是,count 查询和原查询必须共用同一个MappedStatement的配置信息,但又不能修改原来的MappedStatement。所以 PageHelper 的源码里有一个MSUtils.newMappedStatement(...)的方法,它通过反射复制出一个新的MappedStatement,然后把 SQL source 替换成 count SQL 的 StaticSqlSource。之后它复用Executor去执行这条 count SQL,执行方式和普通查询完全一致。

关于去掉ORDER BY这个优化,我再多说两句。如果你自己实现 count,可以先用 SQL 字符串处理的方式,至少把最外层的ORDER BY去掉,再去包一层子查询。虽然正则处理 SQL 是个危险操作,很容易误伤字符串字面量里的 order by,但在简单场景下够用了。生产级的 SQL 解析推荐用像 JSQLParser 这样的开源 SQL 解析器,PageHelper 内部也是类似的思路。

3.4 与真实 PageHelper 的差距在哪里

写完这个简化版你应该能感受到,思路本身不复杂,但 PageHelper 要想在无数边缘情况下站稳脚跟,还需要处理大量细节。我列一下简化版和真实的差距:

  • 方言支持:真实实现有完整的 Dialect 体系,不只是 MySQL 的 LIMIT。
  • SQL 解析器:真实实现对 count SQL 的处理比简单包裹复杂得多,尤其是处理GROUP BYDISTINCTUNION等情况时。
  • 参数处理:真实实现完整处理了附加参数、类型处理器、foreach 动态 SQL 等 MyBatis 的各种参数形式。
  • 缓存处理:真实实现会处理 MyBatis 一级缓存、二级缓存和自定义缓存对分页结果的适配。
  • 安全边界:真实实现不会用字符串拼接 LIMIT 参数,而是走参数绑定 + 类型校验。
  • 嵌套查询兼容:真实实现对 MyBatis 的嵌套结果映射、延迟加载等机制做了很多协调。

所以不要因为看懂了原理就想着自己造轮子,生产环境直接用成熟框架一定是更稳妥的。理解原理的意义更多在于:出问题时知道去哪里排查、遇到性能瓶颈时知道该怎么优化。

4. 最容易踩的坑与排查实录

4.1 分页失灵:startPage 之后不是紧跟着查询

PageHelper 的一个“铁律”是:startPage()只对紧接着的下一条查询生效。源码里的实现方式是,PageInterceptor在处理完分页后或者检测到下一次查询时,会根据配置决定是否清空 ThreadLocal。实际上 PageHelper 默认在拦截器处理完分页后主动清理 ThreadLocal,所以绝不会出现 startPage 影响两次查询的情况。

但如果你在startPage()和查询之间插了别的活,就会出问题。常见错误场景是这样的:

PageHelper.startPage(1, 10); User user = userMapper.selectOne(1); // 这条查询被拦截了,分页参数被消费 List<User> list = userMapper.selectByAge(18); // 这条查询反而没有分页

这就是分页失灵的典型原因。但这里有一个常常被忽略的“隐蔽触发器”——只要执行了任何 MyBatis 查询,PageHelper 就会把分页参数挂到这条查询上。所以如果你在 startPage 之后先调了一个别的查询,后续真正想分页的查询就不会再分页了。

除此之外,还有几种常见的分页“假失效”场景:

  • startPage和查询不在同一个方法里。比如你先调用一个 service 方法处理 startPage,再在另一个方法里查询。只要中间隔了其他 DB 操作,分页就可能失效。
  • SQL 本身带LIMIT。如果你的 mapper SQL 里已经写了LIMIT,PageHelper 再套一层 limit 可能不会报错,但结果不是你预期的,这种属于 SQL 写坏了。
  • 自定义拦截器把参数清掉了。有些团队自己写了 MyBatis 拦截器,拦截器执行顺序会影响 PageHelper 的 ThreadLocal。如果你的自定义拦截器在 PageHelper 之前执行了查询或清理了分页参数,就会导致分页失效。

排查这类问题,最直接的办法就是把 MyBatis 的 SQL 日志打开,看真正执行的 SQL 是不是带了LIMIT以及参数值是什么。如果 SQL 里没有LIMIT,那就是分页参数没传进来或者被消费了。

4.2 线程池复用状态下 ThreadLocal 的隐患

我在前面反复提到 PageHelper 用ThreadLocal传参,这就带来了一个经典问题:线程池中线程复用会导致分页参数串数据。

先声明一个结论:PageHelper 本身在正常情况下是没有内存泄漏风险的,因为它的拦截器finally块一定会执行clearPage()。但我见过不少项目在“异步查询”场景下出过事。

举个例子,你在主线程调用了PageHelper.startPage(1, 10),然后代码里开启了一个异步线程执行 mapper 查询,由于ThreadLocal是线程隔离的,异步线程里根本拿不到主线程的分页参数,这反而是安全的。危险的是反过来:你在异步线程里调用了startPage(),但真正执行查询的却是线程池里的另一个复用线程,或者你在一个 Runnable 里手动调了 startPage 后又去查询,中间线程被重新调度——这时分页参数就可能串到别的请求上。

另外一个高发场景是线程池中的线程没有清理 ThreadLocal。比如你用ExecutorService执行一批查询任务,某个任务里调用了startPage(),但后续逻辑因为异常中断,如果 PageHelper 的拦截器最终没执行到(大多数情况是执行不到的,因为查询就可能失败),那这个线程的 ThreadLocal 可能残留 Page 对象。不过 PageHelper 的拦截器是在 query 调用入口执行的,只要执行就会清理,所以实战中遇到更多的问题是你自己拿 ThreadLocal 缓存了一些业务数据,然后和 PageHelper 的分页参数搞混了

我给一个实际项目的排查经验:如果你发现线上某个请求明明没有调 startPage,但查询结果被截成 10 条了,多半是线程池复用 + ThreadLocal 残留导致的。排查时可以打开ThreadLocal的源码断点,或者直接打印调用链上线程名,看当前线程是否是上一轮请求用过的。

4.3 特殊 SQL 与深度分页问题

PageHelper 对绝大多数 CRUD 场景是没问题的,但遇到这几类 SQL 时,你需要格外留意。

第一类是包含FOR UPDATE的 SQL。PageHelper 改写 SQL 时,如果不小心把FOR UPDATE弄丢了,锁就白加了。虽然新版 PageHelper 做了处理,但我建议你不要对FOR UPDATE语句做分页,先查出来主键再分页加锁会更可控。

第二类是自定义复杂 SQL,尤其是那种非常规的GROUP BYDISTINCT。PageHelper 的 count SQL 生成器虽然能解析大部分语法,但遇到特别复杂的 Oracle 层次查询、MySQL 的WITH ROLLUP、或者含有子查询的复杂 UPDATE 时,生成的 count SQL 可能不是最优的,甚至可能解析报错。如果你发现 count 结果不对,一个保守的手段是把 SQL 改成子查询包一层,让 PageHelper 的 count 处理更简单,或者手动写一个 count 查询。

第三类是深度分页性能问题。无论你用什么分页框架,LIMIT 100000, 10这种写法在 MySQL 里都意味着 MySQL 要扫描并丢弃前 100000 行记录,再取 10 行。数据量一大,翻页越深性能越差,这跟 PageHelper 本身无关,而是分页方案的天然局限。应对方式业界早有共识:游标分页(基于WHERE id > lastId ORDER BY id LIMIT 10)或者键集分页。如果你业务上必须做“页码跳转”,就需要用其他方案兜底,比如把页码映射成某一批主键集合。

4.4 常见问题速查表

我把实际工作中遇到的高频问题整理成一张速查表,方便你排查时对照:

现象常见原因处理建议
查询结果没有分页,返回全部数据startPage()后没有紧跟查询,参数被其他查询消费调整代码顺序,确保startPage()之后只调目标查询
查询结果只返回一页,但 total 不对自定义 SQL 里的GROUP BY/DISTINCT导致 count 不准检查 count 生成 SQL;手动提供 count 查询
页面显示的 total 大于实际数据量同一线程内上一次startPage残留的 Page 被本次查询消费确认finally清理;排查自定义拦截器执行顺序
异步线程查询没有分页ThreadLocal不跨线程传递在异步任务内部重新调用startPage()
项目使用FOR UPDATE分页时锁失效分页改写可能改变 SQL 结构避免对加锁 SQL 直接分页
深翻页时数据库 CPU 飙升LIMIT深度偏移本身性能差改造为游标分页;限制最大页码
启动时报数据库方言找不到数据源类型无法自动识别在配置里显式设置helperDialect
多数据源项目中分页乱套多个数据源共用了同一个PageInterceptor实例为不同数据源分别配置插件,不同数据库用对应方言
返回结果强转Page报类型转换异常查询走了缓存或者返回类型被代理修改检查二级缓存配置;用PageInfo包装

5. 几个实战中的个人经验

最后分享几点我自己的使用心得,算不上什么大道理,但是被真实项目验证过的。

第一,PageHelper 的reasonable配置建议开启。这个配置的含义是,当你请求的页码超过最大页数时,PageHelper 会自动把页码纠正为最后一页;当页码小于等于 0 时,纠正为第一页。对前端友好,也能少写很多参数校验。不过要注意,如果你们的产品需求是“超过范围就返回空数据”而不是纠正到边界页,那reasonable就别开。

第二,尽量用PageInfo但不滥用它PageInfo里那些导航栏字段(比如 8 个页面编号)对大多数后台管理系统其实有点冗余。如果你只是需要totallist,直接拿Page对象就够,没必要序列化整个PageInfo到前端,白白增加传输量。

第三,敏感业务查询尽量不要依赖分页框架做 count。如果 count 很慢,多半是查询本身太复杂。与其调 PageHelper 的countSuffix之类的参数,不如把查询拆成 count 和 list 两条独立 SQL,分别优化各自索引。PageHelper 给了一条快速路,但不代表你要因为 PathHelper 而放弃对 SQL 本身的审查。

第四,当你需要排查分页问题时,先关掉自定义插件,再用最小 Demo 复现。我见过大量“分页 bug 查了半天最后发现是自定义拦截器搞的鬼”的情况。MyBatis 插件是链式执行的,PageHelper 只是其中一环,插件之间的顺序和干扰才是很多疑难杂症的源头。

从使用者的角度,PageHelper 是个开箱即用的工具;从学习者的角度,它又是一个很好的 MyBatis 插件教学案例。看懂了它的PageInterceptor源码,你对 MyBatis 的 Executor、MappedStatement、BoundSql 体系都会有更立体的理解。即使以后项目中不再用它,这套“拦截器 + 动态 SQL 改写 + 线程上下文参数传递”的设计思想,在手写其他通用组件时也能复用得上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询