PageHelper 核心原理:拦截器 + ThreadLocal + SQL 改写,一次讲透
做了这么多年 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 // groupIdcom.github.pagehelper/groupId // artifactIdpagehelper-spring-boot-starter/artifactId // version2.1.0/version // /dependency // 业务代码 PageHelper.startPage(1, 10); ListUser userList userMapper.selectByAge(18); PageInfoUser 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); ListUser list userMapper.selectByAge(age); PageInfoUser pageInfo new PageInfo(list);注意startPage()和查询之间不要插任何别的数据库操作否则分页就会落到别的查询上。这个坑后面我会单独展开。1.3 Page 和 PageInfo别把两个对象搞混了很多新手分不清Page和PageInfo这里我对比着说对象本质典型获取方式核心信息PageTArrayList的子类真实的分页查询结果PageHelper.startPage()后查询直接返回total、pageNum、pageSize、pages等PageInfoT独立的包装类包含 Page 所有信息 导航栏数据new PageInfo(list)total、list、hasNextPage、navigatepageNums等Page是工具内部的“原始产物”PageInfo是给前端展示用的“成品”。比如你需要在页面上显示“共 123 条共 13 页当前第 2 页首页/末页/上一页/下一页”这一系列信息直接用PageInfo就行它把这些字段都算好了。2. 原理拆解PageHelper 是怎么“偷梁换柱”完成分页的2.1 根基MyBatis 插件机制PageHelper 的底层能力建立在 MyBatis 的插件机制之上。MyBatis 允许你在它执行 SQL 的四大核心组件上做拦截这四个组件分别是ExecutorSQL 执行器负责对底层 JDBC 操作的统一调度StatementHandler、ParameterHandler、ResultSetHandler都是它协调的。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 分页请求在源码层面的执行流程画成文字描述你看完应该就能把从上到下的调用链串起来了。业务代码调用PageHelper.startPage(1, 10)。这个静态方法最终调用了PageMethod.setLocalPage(page)它的本质是往ThreadLocal里塞了一个Page对象。此时还没发生任何数据库操作。业务代码继续调用userMapper.selectByAge(age)MyBatis 通过动态代理进入 Mapper 方法最终调用到Executor.query(...)。因为Executor已被 PageHelper 代理所以控制流先进入PageInterceptor.intercept方法。PageInterceptor从ThreadLocal中取出Page对象判断查询是否需要进行分页处理。如果需要拦截器先调用方言处理器生成 count 用的 SQL并执行一次 count 查询得到总记录数。然后根据数据库方言把原始 SQL 改写成带LIMITMySQL或ROWNUMOracle等语法的物理分页 SQL。使用改写后的 SQL 继续执行真实的数据库查询返回当前页的数据列表。拦截器把 count 查询得到的 total 值设置到Page对象中并把Page对象作为查询结果的强转类型返回这也是为什么ListUser userList userMapper.selectByAge(age)实际上拿到的是一个Page。在finally块里调用PageMethod.clearPage()清理ThreadLocal避免内存泄露和线程串扰。这 9 步就是整个 PageHelper 的生命周期。你只要记住一句话startPage 负责“存参数”Executor 拦截器负责“改写 SQL”ThreadLocal 负责“传递参数”finally 负责“收拾战场”。2.4 方言适配与 SQL 改写它是怎么在不同数据库间游走的PageHelper 的分页 SQL 生成并不是写死在拦截器里的而是交给了Dialect接口。官方实现里有MySqlDialect、OracleDialect、SqlServerDialect、PostgreSqlDialect、H2Dialect等你可以通过配置项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 查询的改写也很有讲究。对于简单的 SQLPageHelper 会基于 SQL 解析器做一些优化比如去掉ORDER BY子句因为统计总数时排序毫无必要还白白浪费性能。它能识别出 SQL 的主查询和子查询尽量把最外层的ORDER BY移除。对于复杂场景比如含GROUP BY、DISTINCT、集合操作等改写结果会更复杂但核心思路都是把原始 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 ThreadLocalSimplePage 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 会把offset和pageSize作为参数绑定到PreparedStatement上而不是拼进 SQL 字符串。拼字符串风险很大如果pageSize或pageNum来自前端且未被校验一旦被攻击者利用就能注入恶意 SQL。虽然 LIMIT 后面的参数通常只接受整数你可以在业务层强转成 int但更规范的做法仍然是参数化。改进后的参数处理大致是这样的我们不光要改 SQL 文本还要往参数列表里追加两个参数。BoundSql里维护了一个additionalParameters集合新增参数需要动态注册// 追加分页参数 ListObject 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 BY、DISTINCT、UNION等情况时。参数处理真实实现完整处理了附加参数、类型处理器、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); // 这条查询被拦截了分页参数被消费 ListUser list userMapper.selectByAge(18); // 这条查询反而没有分页这就是分页失灵的典型原因。但这里有一个常常被忽略的“隐蔽触发器”——只要执行了任何 MyBatis 查询PageHelper 就会把分页参数挂到这条查询上。所以如果你在 startPage 之后先调了一个别的查询后续真正想分页的查询就不会再分页了。除此之外还有几种常见的分页“假失效”场景startPage和查询不在同一个方法里。比如你先调用一个 service 方法处理 startPage再在另一个方法里查询。只要中间隔了其他 DB 操作分页就可能失效。SQL 本身带LIMIT。如果你的 mapper SQL 里已经写了LIMITPageHelper 再套一层 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 BY或DISTINCT。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 个页面编号对大多数后台管理系统其实有点冗余。如果你只是需要total和list直接拿Page对象就够没必要序列化整个PageInfo到前端白白增加传输量。第三敏感业务查询尽量不要依赖分页框架做 count。如果 count 很慢多半是查询本身太复杂。与其调 PageHelper 的countSuffix之类的参数不如把查询拆成 count 和 list 两条独立 SQL分别优化各自索引。PageHelper 给了一条快速路但不代表你要因为 PathHelper 而放弃对 SQL 本身的审查。第四当你需要排查分页问题时先关掉自定义插件再用最小 Demo 复现。我见过大量“分页 bug 查了半天最后发现是自定义拦截器搞的鬼”的情况。MyBatis 插件是链式执行的PageHelper 只是其中一环插件之间的顺序和干扰才是很多疑难杂症的源头。从使用者的角度PageHelper 是个开箱即用的工具从学习者的角度它又是一个很好的 MyBatis 插件教学案例。看懂了它的PageInterceptor源码你对 MyBatis 的 Executor、MappedStatement、BoundSql 体系都会有更立体的理解。即使以后项目中不再用它这套“拦截器 动态 SQL 改写 线程上下文参数传递”的设计思想在手写其他通用组件时也能复用得上。