
做分页查询这活儿十个 Spring Boot 项目里九个绕不开。后台管理系统的用户列表要分页日志中心的操作记录要分页设备监控平台的告警记录要分页消息推送的历史列表也得一页页往前翻。凡是用 MyBatis 的项目提到分页PageHelper 基本是默认答案。作为 MyBatis 生态里普及率极高的分页插件PageHelper 和 Spring Boot 搭配使用非常顺理成章但真正上手后你会发现集成只是第一步配置参数的取舍、ThreadLocal 的生效机制、一对多查询下的数据错乱、深分页的性能瓶颈才是真正决定你这个分页功能是能用还是好用的分水岭。这篇文章我打算把 Spring Boot 集成 PageHelper 做分页查询的整个链路从头拆到尾从选型思路到依赖配置从标准写法到踩坑复盘从 count 优化到深分页方案全是我在实际项目里验证过的经验和教训适合刚接触分页的初学者也适合想排查线上分页问题的老手。1. 为什么 Spring Boot 项目里分页绕不开 PageHelper1.1 手写分页和插件分页的差距在哪先说说分页这个需求本身。数据库分页说白了就是一次只取一段数据MySQL 方言是LIMIT offset, sizeOracle 是ROWNUM包裹三层子查询SQL Server 是OFFSET FETCH。如果不用插件你得在每个需要分页的 Mapper 方法里手写两条 SQL一条 count 查总数一条 list 查当前页数据。这两条 SQL 必须是同一套过滤条件哪怕 where 条件里多一个空格前端列表的总数和数据就对不上。手写分页的痛点是系统性的。你可能会封一个 PageResult 类把 pageNum、pageSize、total、list 都塞进去但每个 Mapper 方法都要重复写LIMIT #{offset}, #{pageSize}每次排序字段变更还得改 SQL。这还只是麻烦更危险的是参数传递容易出错——页大小传成页码、offset 算错、排序字段拼 SQL 拼出注入漏洞这些我都见过。PageHelper 解决的就是这个一致性和效率问题你在查询前调用一行PageHelper.startPage(pageNum, pageSize)插件在 MyBatis 执行层拦截 SQL自动帮你改写成 count 查询和带分页关键字的数据查询业务代码里完全不需要关心数据库方言。1.2 PageHelper 在 Spring Boot 生态里走红的原因PageHelper 能成为事实标准不光是好用。它和 MyBatis 的协作方式本质上是利用了 MyBatis 的 Executor 拦截器机制。MyBatis 执行一条查询会经过 Executor、StatementHandler、ParameterHandler、ResultSetHandler 这几个核心组件PageHelper 的PageInterceptor就是挂在 Executor 层的一个拦截器在查询执行前拿到原始 SQL用内置的 SqlParser 解析改写。这一套机制决定了它是框架级的分页方案对业务代码的侵入性极低。再加上pagehelper-spring-boot-starter这个自动配置包以前要在 mybatis-config.xml 里手写plugin标签注册拦截器的操作Spring Boot 里引入一个依赖就全自动搞定了。对 Spring Boot 项目来说分页从来没有比这更顺滑的姿势。它适用的场景相当广后台管理系统的列表查询、运营后台的报表分页、监控大屏的滚动记录凡是 MyBatis 查数据库做列表展示的地方它都能接。当然PageHelper 也不是万能钥匙事务里的大结果集导出、流式查询、深分页这类场景需要你结合业务去权衡后面我会专门说。2. 版本与配置依赖集成这一步就够劝退一半人2.1 依赖引入与 Spring Boot 版本匹配PageHelper 的集成从选择一个版本就开始了。很多项目的分页问题追根溯源是版本不匹配。Spring Boot 2.x 时代引入pagehelper-spring-boot-starter1.4.x 版本是比较稳的组合它能和 mybatis-spring-boot-starter 2.x 协作自动注册拦截器。到了 Spring Boot 3.x因为底层从 javax 迁移到了 jakarta 命名空间PageHelper 也必须升级到 1.4.7 及以上版本同时 MyBatis 相关 starter 要用 2.3.x 版本这套组合测试下来才是正常的。dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency这里有一个特别容易踩的坑pagehelper-spring-boot-starter传递依赖里带了mybatis-spring-boot-starter。如果你的项目因为别的原因单独又显式引入了 mybatis starter就会出现两个 mybatis 自动配置类互相打架结果就是 MyBatis 的 SqlSessionFactory 初始化顺序异常或者分页拦截器莫名其妙失效。我见过最离谱的一个现象是本地启动一切正常线上用 JUnit 跑集成测试时分页失效最后定位到是 maven 依赖树里 mybatis starter 出现了两个不同版本。排查方法很直接mvn dependency:tree看一下依赖冲突该排除的exclusion掉。2.2 application.yml 里的关键配置逐项拆解依赖进了之后配置才是重头戏。PageHelper 的PageInterceptor有一堆属性Spring Boot 场景下可以通过pagehelper.前缀直接配置到 application.yml 里。下面这套是我在多项目里验证过、相对稳妥的基础配置pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: countcountSql auto-runtime-dialect: falsehelper-dialect指定数据库方言。MySQL 项目你可以写死mysql这样省去插件自动探测的成本也能避免某些连接池环境下拿到错误方言。但一旦你的服务要读写多种数据库比如 MySQL 加 PostgreSQL 双数据源那helper-dialect写死就是一个定时炸弹后面我会详细说多数据源的坑。reasonable这个参数很多人不理解它是做页数归并的。开启后如果请求页码小于 1PageHelper 会强制查第一页如果请求页码大于总页数会强制查最后一页。这功能对防止有人乱传pageNum99999很有用避免了无意义的深分页查询。但要注意如果你的业务场景是页码越界必须返回空列表而不是返回最后一页这个参数就别开。support-methods-arguments和params是配套使用的。开启后PageHelper 会尝试从 Mapper 接口方法的参数对象里提取分页参数。params: countcountSql的意思是分页的时候自动调用一个名为countSql的参数值所对应的方法来做 count 查询。这个机制稍微有点绕实际开发里我更习惯显式调用PageHelper.startPage()参数自动绑定用不好会让后来接手的人一脸懵。2.3 如何验证配置真的生效了配置完别急着写业务代码先验证拦截器有没有被注册。最简单的验证方式是启动项目看日志里有没有PageInterceptor初始化的信息或者直接写一个临时接口调用PageHelper.startPage(1, 10)后执行一条查询再把返回结果强转成com.github.pagehelper.Page看getTotal()是否正常。如果配置没生效典型的问题是分页 SQL 没有被改写。你可以打开 MyBatis 的 SQL 日志观察执行的语句里有没有LIMIT ?或者SELECT COUNT(0)。没有的话说明 PageHelper 的拦截器根本没被 MyBatis 的 Executor 链捕获优先检查是不是依赖冲突或者 starter 没被加载。这里还有一个常用技巧直接注入SqlSessionFactory在启动后打印出InterceptorChain里的拦截器列表一眼就能看出PageInterceptor在不在里面。3. 从 Controller 到 Mapper一个标准分页接口的完整套路3.1 代码分层与依赖方向Spring Boot 工程规范里分页接口涉及 Controller、Service、Mapper 三层。Controller 负责接收并校验pageNum和pageSizeService 层做业务组装并调用分页查询Mapper 层只定义查询方法。依赖方向从 Controller 指向 ServiceService 指向 Mapper分页相关的 DTO 和 VO 放到独立的 model 模块里这样多个业务模块都能复用统一的分页返回结构不会出现这个接口返回 PageInfo、那个接口返回自定义 Map 的混乱局面。需要特别提一句PageHelper.startPage()的位置必须在 Service 层或者 Mapper 层调用而且要保证它和接下来要分页的查询方法在同一个方法调用栈里。如果把 startPage 放到 Controller 层中间跨了 Service 层又跨了事务代理很容易触发分页不生效的经典问题这个坑我在第 4 节会详细拆解。3.2 核心 API 的正确打开方式PageHelper 使用上没有比这更核心的法则了PageHelper.startPage方法只对接下来执行的第一条MyBatis 查询方法生效而且这条查询必须紧随其后中间不能夹带任何其他数据库操作。它的底层用的是 ThreadLocal 存储分页参数查询执行完成后插件会主动清理 ThreadLocal所以你不会担心线程复用导致参数污染。但这既是优点也是风险——如果一时疏忽在 startPage 之后又调用了别的查询那个别的查询就会被错误分页而真正想分页的查询反而拿不到参数这正是后续要讲的高频事故源。正确姿势是这样先写一个统一的分页结果对象public class PageResultT { private long total; private int pageNum; private int pageSize; private int pages; private ListT list; // 构造方法、getter/setter 略 }Service 层这样写public PageResultUserVO pageUsers(int pageNum, int pageSize, String keyword) { PageHelper.startPage(pageNum, pageSize); ListUser users userMapper.selectUsersByKeyword(keyword); PageInfoUser pageInfo new PageInfo(users); return PageResult.of(pageInfo); }Mapper 接口正常定义public interface UserMapper { ListUser selectUsersByKeyword(Param(keyword) String keyword); }如果你的 SQL 比较简单用注解方式即可复杂一点的联表、动态条件建议放到 XML 里维护。PageHelper 对注解 SQL 和 XML SQL 都支持得很好因为它是在 Executor 层拦截与 SQL 的书写方式无关。3.3 PageInfo 与前端分页组件的字段映射PageHelper 自带一个PageInfo类封装了比较全面的分页元数据包括 pageNum、pageSize、total、pages、list、prePage、nextPage、isFirstPage、isLastPage、hasPreviousPage、hasNextPage、navigatePages 等。很多前端分页组件比如 Element Plus 的 Pagination、Bootstrap 的 Pagination字段名和 PageInfo 并不是一一对应的。Element Plus 需要current、page-size、total而 PageInfo 里是pageNum、pageSize、total。所以我不太建议直接把 PageInfo 序列化给前端字段对不上是一方面暴露太多内部字段也不是好事。更稳的做法是像上面的PageResult一样在 Service 层做一次瘦身只把前端需要的字段输出。这个封装类一旦定下来所有分页接口都可以复用后续如果要调整分页返回结构比如增加接口耗时字段只需要改一个类。3.4 排序字段的扩展与安全红线分页查询经常伴随排序需求。PageHelper 在startPage方法里提供了orderBy参数比如PageHelper.startPage(pageNum, pageSize, create_time desc)插件会自动在 SQL 末尾追加ORDER BY create_time desc。这很方便但也埋了一个 SQL 注入的雷。排序字段和排序方向如果直接接受前端传参拼接进 orderBy 里恶意用户传一个id; drop table user之类的东西那就不只是分页错误的问题了。我的处理习惯是排序字段做成白名单映射前端传createTime后端映射成create_time前端传id、createTime、updateTime之外的字段一律拒绝或忽略。排序方向只允许asc和desc大小写不规范就统一转小写再校验。这个规则放到一个统一的查询入参校验类里所有分页接口共用一劳永逸。4. 分页不生效、数据对不上实战踩坑复盘4.1 startPage 只对第一条查询生效到底有多坑我一度以为这个规则所有同事都知道直到线上有个报表接口的分页数据来回跳定位发现 Service 层代码长这样PageHelper.startPage(pageNum, pageSize); ListAuditLog logs auditLogMapper.countTodayLogs(); // 这条先被分页了 ListDetailVO result auditLogMapper.selectDetailPage(...); // 这条反而没有分页前一行方法名明明是 countTodayLogs却被 PageHelper 拦截改写了 count 加 LIMIT而后面的真正列表查询完全不受控返回了整表数据。这种问题最恐怖的地方在于它不是必现的小数据量时结果看着是对的一旦数据量大了或者并发上来了问题就藏不住了。深入理解一下PageHelper 是基于 ThreadLocal 存储分页参数的startPage执行后参数进入线程上下文必须由紧接着的那次 MyBatis 查询消费掉插件在查询结束清理 ThreadLocal。这个机制保证了线程隔离但也意味着你在 startPage 和真正查询之间做的任何可能触发 MyBatis 操作的动作都可能成为半路截胡的查询。排查这种问题时关掉分页参数把 Mapper 方法调用顺序理一遍比盯着配置看半天有效得多。4.2 一对多关联查询的分页数据错乱这个坑基本是所有 MyBatis 分页方案里最经典的。场景很常见订单列表要带出每个订单的商品明细主表 orders 和子表 order_items 是一对多关系。你写 SQL 时用了 join 或者嵌套结果映射把一个订单对应的多条 item 合并到同一个 OrderVO 里。此时 PageHelper 拦截的是 join 之后的 SQL 去分页出来的结果往往是当前页明明设置 10 条但 list 里只有 5 个订单因为一个订单占了好几行的 join 结果LIMIT 10切完行再按主表 id 映射自然不够数。count 同样会被膨胀变成子表记录的总数而不是订单的总数。三种解法我实际都用过。第一种是先分页主表再 in 子查询取子表这是最通用的方案查询次数可控映射逻辑清晰。第二种是保持一条 SQL 返回 flat 结构在 Service 层做内存组装适合子表数据量不大、一次最多几十条的场景。第三种是子查询占位法让分页只发生在主查询上但多数据库方言下子查询写法要小心。我的建议是优先选择第一种它把分页和组装两个责任清晰地分开产品和前端拿到的数据总数永远是主表的准确值。4.3 多数据源与 MyBatis-Plus 并存时的连环坑监控类的 Spring Boot 项目经常要接多个数据源比如业务库和日志库分开。PageHelper 的PageInterceptor在单数据源下可以自动探测方言多数据源下就可能分不清当前连接是 MySQL 还是 PostgreSQL。我曾经在一个项目里遇到这种情况MySQL 数据源分页正常PostgreSQL 数据源执行分页查询直接报语法错误报错信息显示封装出的 SQL 带了LIMIT ?但方言解析走了 MySQL 分支。解决办法有两个方向一是把helper-dialect配置成auto让插件通过 JDBC 连接元数据自动识别方言二是显式设置auto-runtime-dialect: true让插件在运行期根据当前连接动态选择方言。前者简单后者更可靠我一般推荐后者代价是每次分页多一点点方言判断的开销对于业务接口完全可接受。再一个很容易翻车的是 PageHelper 和 MyBatis-Plus 同时出现在一个项目里。MyBatis-Plus 自带MybatisPlusInterceptor和PaginationInnerInterceptor如果你同时引入了 PageHelper两个拦截器会同时对 SQL 做改写分页关键字被加两次轻则 count 翻倍重则 SQL 直接报错。我在接手的遗留工程里见过这种双分页插件组合排查了一下午才发现是依赖打架。结论很明确二者选其一别在同一个项目里同时启用。如果因为历史原因依赖无法清理干净至少要保证只有一个插件的自动配置生效另一个手动排除。4.4 count 不准Group By、Distinct 和手写 count 覆盖PageHelper 默认生成的 count 语句是包一层SELECT COUNT(0) FROM (原SQL) table_count。这在普通查询下没问题但遇到原 SQL 带了 GROUP BY或者 SELECT 里全是聚合函数、DISTINCT 等场景count 的结果和实际行数往往对不上。最典型的是按用户分组统计操作次数这类报表查询PageHelper 包一层之后 count 出来的其实是分组前的明细行数而列表展示的行数是分组后的条数总数直接翻了好几倍。PageHelper 给了你一个覆盖机制在 Mapper 接口里定义原查询方法同名的 count 方法方法名用_count后缀例如selectUserReport对应的 count 方法是selectUserReport_count返回类型long。插件执行分页时如果检测到存在这个_count方法就会优先调用它来计算总数。这个特性非常实用那些复杂的报表分页、带有业务逻辑的 count SQL都可以用这个方式自己控制。注意这个_count方法必须和原方法在同一个 Mapper 里参数保持一致否则插件匹配不上。5. 分页性能优化从 count 到深分页的完整思路5.1 count 查询慢别把锅全甩给 PageHelper分页接口慢很多时候慢在 count 那条 SQL 上。PageHelper 帮你生成的 count 是把原查询包一层子查询如果原 SQL 本身很重多表 join、大范围 in、函数运算那这个 count 查询会把所有满足条件的行都扫描一遍再计数耗时自然惊人。正确做法是从两条线同时优化。第一是优化原查询本身。建立复合索引让过滤条件尽量命中索引避免在 where 条件里对索引列使用函数导致索引失效。第二是合理使用手写 count 覆盖机制。比如你的列表查询里有大段的子查询关联但真正决定总数的是主表过滤条件那就可以写一个简化版的_count方法只 count 主表过滤后的数量省去大段无用的 join。我在实际项目里用这个方式把一个 3 秒的报表分页接口优化到了 300 毫秒以内count SQL 从原来的一屏长 join 缩成了几个条件的单表 count。另外提醒一点count 结果在很多场景下是可以做缓存的。比如后台管理系统的列表数据变更不频繁可以在 Service 层对 total 做短时间缓存分页数据仍然实时查询。这种做法对运营后台效果显著但注意不要用在实时性要求高的交易类列表上。5.2 深分页LIMIT 1000000, 10 真的有性能炸弹深分页是 PageHelper 这类物理分页插件无法回避的问题。你翻到第 100 页LIMIT 990000, 10数据库要扫描 99 万行后丢掉前 99 万行只取最后 10 行。这个性能消耗是实打实的而且随着页码往后翻越来越严重。用前面的 demo 数据看不出问题生产环境千万级表就扛不住了。我常用的优化方案有三个。第一个是延迟关联核心思路是先分页查出主键/唯一标识再用 join 回原表取完整数据SELECT u.* FROM user u INNER JOIN ( SELECT id FROM user ORDER BY create_time DESC LIMIT 990000, 10 ) tmp ON u.id tmp.id ORDER BY u.create_time DESC;子查询里只查 id 和排序字段可以利用覆盖索引回表成本集中在最后 10 条上性能提升非常明显。第二个是游标分页也叫 keyset pagination。它不走 pageNum而是要求客户端把上一次列表最后一条记录的排序值比如 id 或 create_time传回来SQL 直接写成WHERE id #{lastId} ORDER BY id DESC LIMIT 10。这种方式下每次查询都是等值加范围条件能够完全命中索引翻到多少页性能都不退化。缺点是无法一次跳转到任意页适合加载更多这类滚动场景。第三个是从产品层面限制比如后台管理列表最多只能翻到前 100 页超过就提示用户精细化查询条件。这三种方案可以组合使用我的经验是普通后台用 PageHelper 加延迟关联C 端滚动列表用游标分页别让用户真的翻到第几万页去。5.3 分页接口的性能监控闭环性能优化不能靠感觉要通过监控数据说话。Spring Boot 项目里即使只是简单分页接口也可以借助 actuator 暴露的 metrics 来观察接口耗时。你可以给分页接口加一层 Micrometer 计时器把每次查询的执行时间、深分页的 offset 大小作为 tag 记录到 Prometheus配合 Grafana 或者直接在 admin 面板里看趋势很容易发现某个接口的耗时是否随之数据增长而劣化。如果公司监控体系不完善最低成本的方案是在 SQL 日志里按阈值打印慢查询查出 PageHelper 生成的 count SQL 和分页 SQL 的具体耗时一样能定位瓶颈。再补一个常被忽略的点分页接口要小心N 次分页查询。有的同学在 Service 层写循环循环体里调用了分页查询并 startPage结果一次请求发出了几十条 count 和列表 SQL。这种问题不全是 PageHelper 的锅但 PageHelper 因为 startPage 和查询的绑定关系很容易让这种错误变得隐蔽。我一般会在团队规范里明确分页方法禁止出现在 for 循环中批量数据的组装要用 in 查询一次拿全再在内存里关联。6. 分页常见问题速查表与个人经验6.1 高频问题排查速查表下面这张表是我在项目里实操总结的高频问题速查表遇到类似现象可以直接对照现象可能原因解决办法完全不分页SQL 末尾没有 LIMIT依赖未引入或拦截器未注册检查 pagehelper starter 版本和依赖冲突某个查询被莫名分页startPage 后有其他查询先执行调整方法调用顺序startPage 紧跟目标查询返回类型强转 Page 报错查询返回的不是 List 类型保证 Mapper 方法返回 List 或 Page翻页越界返回异常数据reasonable 未开启或配置失效设置 reasonable: true确认配置被加载多数据源下分页 SQL 方言错误helper-dialect 写死单一方言设置 auto-runtime-dialect: true一对多 join 后总数膨胀分页拦截器作用在 join 结果集先分页主表再 in 子查询子表count 与列表行数不一致SQL 有 group by/distinct手写同名_count方法覆盖深分页越来越慢LIMIT offset 过大延迟关联/游标分页/限制最大页码与 MyBatis-Plus 同时使用时 SQL 报错两个分页拦截器重复改写 SQL二选一保留一个分页插件这张表不是万能的但它覆盖了我这几年在分页上遇到的大部分问题。很多问题表面上看是 PageHelper 的 bug实际是使用姿势不对。6.2 用 PageHelper 这几年最深的几个体会最后聊几句实在的。PageHelper 本质上是帮你把分页 SQL 怎么写这个重复劳动自动化了它从来不负责解决数据库的性能问题。我见过太多项目分页是上了但深分页照样慢、count 照样卡、一对多照样乱最后代码审查的时候才发现插件用得很熟但每一页数据是怎么查出来的、总数是怎么算的业务开发的人完全没概念。真正稳妥的做法是先搞懂你的查询会生成什么样的 SQL再去选择分页方案。我个人的操作习惯是每个分页接口在联调阶段都会打开 MyBatis SQL 日志把 count 查询和列表查询的 SQL 复制出来在数据库客户端里执行一遍看执行计划和响应时间。这个动作花不了几分钟但能把深分页的问题、索引缺失的问题提前掐死在测试环境。PageHelper 的配置也一样不要照抄网上别人贴的配置先看项目里的数据库类型、数据源数量、前端分页组件的字段要求再决定开启哪些参数。再给一个建议分页接口的入参pageNum和pageSize一定要做后端校验。不要相信前端传什么就是什么pageSize设置为 10000 甚至 100000 的时候你再好的优化方案都扛不住。我一般在 Controller 层就做个兜底单页最大 500 条超过直接警告并截断。这个约束不限制正常业务却能挡住绝大多数顺手写出来的低质量查询。