如果你在 Java 后端写过列表接口,大概率被“分页”这件事恶心过。MySQL 里一条 LIMIT 就能解决,换到 Oracle 就得套 ROWNUM,换到金仓、达梦这些国产数据库,又是另一套方言;更别提还要统计 total、处理排序、包装返回结构,一套流程写完,几十行代码起步。后来我用了 PageHelper.startPage(),才发现原来分页可以这么干净:一行代码,页码和页大小丢进去,SQL 自动被改写成当前数据库能识别的分页语句,count 也顺手查了。这篇博文就围绕 PageHelper 这套机制,把 startPage() 的原理、用法、边界坑、跨数据库迁移、性能优化和与 MyBatis Plus 分页插件的选型对比一次讲清楚。无论是刚开始写 Java 分页的初学者,还是正在做国产数据库适配、分页查询变慢需要排查的团队,都能从这里拿走可以直接用的方案。
1. 为什么我最终把分页交给了 PageHelper 而不是自己拼 SQL
1.1 手写分页不只是加一条 LIMIT
很多新同学第一次接分页需求,以为事情很简单:SELECT * FROM user LIMIT offset, size,完事。但等你真正把整个列表接口做完,会发现手写分页要处理的细节远不止这一条 SQL。
首先你得先查一次 count,算出总共有多少条数据,然后根据总条数和每页大小算出总页数;再取当前页数据;还要处理页码越界、排序字段拼接、SQL 注入、后端返回值结构。整个流程串起来,至少是下面这样的步骤:
- 根据查询条件写一条
count(*)SQL; - 再写一条带
LIMIT/ROWNUM的分页查询 SQL; - 把 count 结果和列表数据封装成一个 PageResult 对象;
- 在 Service 层手动计算 pageNum、pageSize、pages、total 这些字段;
- 前端分页组件还需要 isFirstPage、isLastPage、hasNextPage 这类判断。
问题在于,count 的查询条件和列表查询条件一模一样,只是查的东西不同。你稍微改一个 where 条件,就得同步改两条 SQL,忘了改就出 bug。这种重复劳动特别消耗精力,而且一点技术含量都没有。
实际项目里我见过太多自己封装分页工具类的代码:有人用 List.subList 做内存分页,数据量一大就直接内存爆炸;有人把分页逻辑写死在 XML 里,每个 Mapper 都配 limit #{offset}, #{size},等数据库从 MySQL 换成 Oracle,所有 SQL 全要返工;还有人把 count 单独手写,条件漏了导致列表总数永远不对。这些都验证了一件事:分页这件事,重复造轮子得不偿失。
1.2 方言差异才是分页里最扎手的部分
如果你只在 MySQL 上开发,可能觉得分页很简单,但一旦涉及跨数据库,问题就来了。不同数据库的分页语法差异非常大,我列几个常见的你们感受下:
| 数据库 | 分页 SQL 示例(取第 2 页,每页 20 条) |
|---|---|
| MySQL / MariaDB | SELECT * FROM user ORDER BY id LIMIT 20, 20 |
| PostgreSQL | SELECT * FROM user ORDER BY id LIMIT 20 OFFSET 20 |
| Oracle(12c 前) | SELECT * FROM ( SELECT TMP.*, ROWNUM ROW_ID FROM ( SELECT * FROM user ORDER BY id ) TMP WHERE ROWNUM <= 40 ) WHERE ROW_ID > 20 |
| SQL Server 2012+ | SELECT * FROM user ORDER BY id OFFSET 20 ROWS FETCH NEXT 20 ROWS ONLY |
| 金仓 / 达梦 | 多为兼容 Oracle 的 ROWNUM 写法,部分模式支持 LIMIT/OFFSET |
看到区别了吗?MySQL 最简洁,Oracle 要嵌套三层,SQL Server 又是另一种语法。如果项目一开始在 MySQL 上开发,后面客户要求部署到金仓或达梦,你所有手写在 XML 里的 LIMIT #{offset}, #{size} 全部作废,需要逐条改成目标数据库的分页写法。几十个 Mapper 文件改下来,既耗时间又容易漏。
更重要的是,分页不仅仅是改写 SQL 末尾的 limit 语法。Oracle 的分页还需要考虑 ROWNUM 和 ORDER BY 的执行顺序,必须先排序再套 ROWNUM,否则取出来的数据乱序;SQL Server 2012 之前的版本还得用 ROW_NUMBER() OVER();还有一些数据库在分页的同时对 count 查询的语法也有限制。这些细节,非亲历者很难意识到有多卡人。
1.3 PageHelper 解决的是“数据库隔离”,不是“帮你少写几行”
PageHelper 的出现,本质上是把“分页 SQL 方言差异”这件事收敛到了一个统一入口。你在代码里只需要写一个 PageHelper.startPage(pageNum, pageSize),然后正常写你的业务查询 SQL,剩下的方言适配由分页插件内部完成。这和使用 JDBC 屏蔽数据库驱动的思路是一样的:JDBC 让你不用关心连的是 MySQL 还是 Oracle,PageHelper 让你不用关心分页 SQL 怎么写。
它能在 MySQL、Oracle、PostgreSQL、SQL Server、H2、金仓、达梦等主流数据库之间自由切换,核心原因在于它把 SQL 解析、方言生成、count 查询、参数绑定这些逻辑全部内置了。你面向的业务代码始终只有一套,数据库怎么换,Mapper 里的 SQL 都不用动。
所以我的建议很直接:原生 MyBatis 项目做分页,第一选择就是引入 PageHelper,而不是自己写一套分页工具。下面的内容,我会从源码运行机制、使用边界、跨数据库配置、性能优化几个维度,把 startPage() 彻底讲透。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. startPage() 一行代码背后,到底发生了什么
2.1 startPage() 先将分页参数放进 ThreadLocal
PageHelper.startPage(pageNum, pageSize) 这行代码看起来简单,其实它的核心动作就是把分页参数塞进当前线程的 ThreadLocal 里。
我翻过 PageHelper 的源码,它是这样做的:startPage() 方法内部会创建一个 Page 对象,Page 继承自 ArrayList,里面保存了 pageNum、pageSize、count 等属性。然后这个 Page 对象被放到了 PageMethod 类中定义的一个 ThreadLocal<Page> 里。
这块设计我觉得非常巧妙。为什么要用 ThreadLocal?因为分页参数需要从 Service 层的 startPage() 调用点,传递给底层 MyBatis 的查询过程,但你又不想在每个 Mapper 方法参数上都加 pageNum、pageSize 两个字段。ThreadLocal 相当于一个当前线程独有的“隐式传参通道”,调用线程在任意地方都能把参数放进去,后续执行到 MyBatis 查询时再取出来,整个链路不需要改方法签名。
startPage() 方法还有返回值,返回的就是那个 Page 对象。如果你不需要返回它,直接调用不接收返回值也没问题。但有一点要注意:ThreadLocal 是绑定当前线程的,如果后续查询发生在另一个线程里,这个 Page 对象在那边是取不到的。这也是后面要讲多线程坑的根源。
2.2 拦截器在 Executor 层“劫走”查询
PageHelper 能够改写 SQL,靠的是 MyBatis 的拦截器机制。MyBatis 允许通过 @Intercepts 注解拦截 Executor 的 query 方法,而 PageHelper 的 PageInterceptor 正是这样做的。
当你的代码调用到 Mapper 方法时,MyBatis 最终会走 Executor.query(...)。这个调用会被 PageInterceptor 拦截,然后它先检查当前线程的 ThreadLocal 里有没有 Page 对象。如果有,说明这是被 startPage() 标记过的分页查询;如果没有,就直接放行,不做任何处理。
拦截到之后,PageInterceptor 会做三件事:
- 把 ThreadLocal 中的 Page 对象取出来,和当前查询参数合并;
- 调用
CountSqlParser生成并执行 count 查询,拿到总条数存到 Page 里; - 修改当前要执行的 SQL,生成对应数据库方言的分页语句,再把带分页参数的 SQL 交给真正的 Executor 执行。
整个过程中你写的 Mapper 方法没有任何感知,SQL 就被“偷梁换柱”了。这也是 PageHelper 最大的优点:侵入性极低,不需要改动你的 Mapper 接口和 XML。
2.3 BoundSql 解析:count 和分页 SQL 是怎么生成的
PageHelper 的 SQL 改写依赖 JSqlParser 这个库。它会把你写的 SQL 解析成语法树,然后针对性地做修改。
count 查询的生成逻辑大致是:解析原始 SQL,去掉 ORDER BY 子句(因为排序对 count 没有意义,留着反而可能降低性能),然后把查询部分替换成 SELECT count(0)。这里有个细节:如果原始 SQL 里带了 DISTINCT、GROUP BY 或者 UNION,count 的生成会复杂很多,它必须把这些特殊语法考虑进去,否则算出来的总数就是错的。PageHelper 针对这些场景做了兼容,但在一些极端复杂的 SQL 上仍然可能失败,后面我会讲到怎么避开。
分页 SQL 的生成则依赖 Dialect 接口。PageHelper 内置了大量数据库方言实现,比如 MySQLDialect、OracleDialect、PostgreSQLDialect、SQLServerDialect、达梦的 DmDialect、金仓的 KingbaseESDialect 等等。每个方言类知道目标数据库的分页语法是什么样,能根据解析后的 SQL 生成对应的语句。
用 MySQL 举例,原始 SQL 是:
sql复制SELECT * FROM user WHERE age > 18 ORDER BY id DESC
方言类会在末尾加上 LIMIT ?, ?,并把 pageSize 和 offset 计算好作为参数传入。换成 Oracle,它会生成前面展示的三层嵌套 ROWNUM 查询。
2.4 查询结束后 ThreadLocal 必须被清理
使用 ThreadLocal 有一个老生常谈的问题:用完之后必须清理,否则可能造成内存泄漏和参数串用。
PageHelper 在拦截器的逻辑里,会在一轮查询结束后调用 clearPage() 方法清理 ThreadLocal。也就是说,正常一次查询走完,ThreadLocal 里的 Page 对象会被移除。这是 PageHelper 自己做的兜底,正常情况下不需要你手动清理。
但要注意,这个清理动作必须在拦截器正常执行到的情况下才会发生。如果你的业务代码在 startPage() 之后、Mapper 查询执行之前抛了异常,或者查询走了某种绕过拦截器的路径,理论上 Page 对象可能会残留在当前线程的 ThreadLocal 里。更常见的是线程池场景:Tomcat 的工作线程会复用,如果上一次请求异常导致没清理,下一次请求如果碰巧也用了这个线程,就可能出现“莫名奇妙的查询被分页”的现象。
所以我的习惯是:在比较关键的复杂逻辑里,自己加上 try/finally 并调用 PageHelper.clearPage() 兜底。虽然大多数情况下多余,但关键时刻能救命。
3. startPage() 的使用边界:这些坑我基本都踩过
3.1 最核心的一条规矩:startPage() 后只能跟一个查询
PageHelper 官方文档里反复强调:startPage() 方法后面的第一个 MyBatis 查询会被分页,之后的查询不会被分页。
这句话看起来清晰,但实际使用时很多人会犯一个错误:在 startPage() 和真正要分页的查询之间,插入了其他查询语句。比如下面这种:
java复制PageHelper.startPage(pageNum, pageSize);
User user = userMapper.selectById(1001); // 这个查询被分页了!
List<User> list = userMapper.selectUserList(condition); // 这个查询完全没分页
结果就是,第一个查询 selectById 被自动加上了 LIMIT,返回结果可能直接为空;而真正要分页的列表查询却返回了全量数据。这个问题排查起来极其迷惑,因为 SQL 看起来都没问题,只是数据不对。
代码审查时,我会特别强调这条规矩:startPage() 和分页查询之间不要写任何其他数据库操作,包括查询、插入、更新都不行。如果业务逻辑里必须先查一些辅助数据,把它们放到 startPage() 之前执行。
3.2 PageInfo 包装时,前端组件最关心的几个字段
startPage() 之后拿到的查询结果,是一个 Page 对象,它继承自 ArrayList。你可以直接把它当作 List 返回给前端,但这样做会丢失总页数、是否有上一页/下一页这些元数据。所以更规范的做法是把它包装成 PageInfo:
java复制PageHelper.startPage(pageNum, pageSize);
List<User> list = userMapper.selectUserList(condition);
PageInfo<User> pageInfo = new PageInfo<>(list);
PageInfo 在构造时,如果发现传入的 List 是 Page 类型,就会自动从中读取 total、pages 等分页信息。它提供的字段和前端组件的需求基本一一对应,比如前端用 Element UI 的 el-pagination 组件时,通常需要 total(总条数)、currentPage(当前页)、pageSize(每页条数)、pages(总页数)。PageInfo 里还包含 prePage、nextPage、hasPreviousPage、hasNextPage、navigatepageNums 这些辅助字段,用于生成页码导航列表。
我建议在实际项目里不要直接把 PageInfo 返回给前端,而是再封装一层统一响应体,比如 Result<PageResult<T>>,把 list、total、pageNum、pageSize、pages 这些字段透传出去。这样可以避免前端依赖 PageHelper 这个内部实现类,以后如果替换分页方案,接口层面不用变。
3.3 orderBy 很爽,但别直接从外部拼字符串进来
PageHelper 除了 startPage(),还提供了一个 PageHelper.orderBy(String orderBy) 方法,可以在分页的同时指定排序字段。例如:
java复制PageHelper.startPage(pageNum, pageSize);
PageHelper.orderBy("id desc, create_time asc");
List<User> list = userMapper.selectUserList(condition);
这个能力确实方便,但隐患非常大。因为 orderBy 接收的是一个字符串,这个字符串最终会被拼接到 SQL 的 ORDER BY 后面。如果你的排序字段来自前端参数,那等于把自己家的 SQL 暴露给了调用方,用户可以传 id; DROP TABLE user; -- 这类恶意字符串。
也许你会说,MyBatis 用的是预编译参数,拼接字符串不会导致注入。但注意,ORDER BY 后面的内容在很多数据库方言里不支持参数绑定,PageHelper 的 orderBy 生成 SQL 时也是直接拼接。攻击者不需要分号执行多条语句,光是传一个 (SELECT ...) 子查询或者 CASE WHEN 条件,就可能造成信息泄露。
我的建议是:排序字段做成后端白名单。前端传 sortField=createTime&sortOrder=desc,后端映射成 create_time desc,一旦出现不在白名单里的字段,直接拒绝。不要图省事让排序字符串直接穿透到 orderBy。
3.4 嵌套查询、union、group by 等复杂 SQL 要留意
PageHelper 处理普通单表查询和简单多表 join 基本没问题,但复杂 SQL 偶尔会有性能问题或者 count 不正确的情况。
先说说嵌套查询。如果你用了 MyBatis 的嵌套结果映射,比如 association 或 collection 标签里通过 select 属性关联了另一个查询,分页插件只拦截外层查询,内层子查询不会被分页,这在多数情况下是合理的。但如果 count 查询生成时没有处理好外层的 join 和条件,可能算出错误的 total。另一种情况是,外层查询分页后每页只有 10 条,但内层嵌套查询因为关联关系导致结果膨胀,出现“每页明明设置了 10 条却返回了 30 条”的诡异现象。这个问题本质不是 PageHelper 的 bug,而是 SQL 本身的映射逻辑和物理分页冲突。遇到这种情况,我一般建议改写成单条 join 查询或子查询,而不是依赖嵌套映射。
再说 union。PageHelper 对 union 的支持是分版本的,旧版本在那个地方翻过车,count 会把 union 的 SQL 包装成 SELECT count(0) FROM (union 语句),如果 union 两边有重复数据,得到的结果可能不符合预期,并要求你手动指定 count 或改写 SQL。
至于 group by,count 生成会保留 group by,并统计分组后的行数,这个行为基本正确。但 group by 的 SQL 本身在分页时往往性能很差,因为数据库需要先把所有数据分组完,才能 limit,这时候分页并没有省下多少计算量,深分页会更痛苦。
3.5 多线程和异步任务里的 ThreadLocal 会串
startPage() 依赖 ThreadLocal 传参,意味着它天然不支持跨线程。我在项目中遇到过两个典型场景:
第一个场景:Service 方法里用线程池并发查询多个列表,本意是加快响应,于是每个子线程里都调用了 PageHelper.startPage(),结果分页不生效。原因很简单,主线程的 ThreadLocal 不会传给子线程,子线程里虽然 startPage() 了,但查询本身也在子线程执行,理论上应该生效。真正出问题的地方是,如果子线程复用了池里的旧线程,而旧线程的 ThreadLocal 里残留了上一次分页参数,新的查询就会莫名其妙被分页。
第二个场景:startPage() 在主线程调用,然后使用 CompletableFuture 异步执行 Mapper 查询。主线程的 ThreadLocal 在异步线程里取不到,分页失效。
解决思路分两种:一是尽量避免在异步任务里做分页,分页查询并发执行的意义不大,数据库压力反而更大;二是如果必须异步,在每个子线程内部重新调用 startPage(),并且把 PageHelper.clearPage() 放在 finally 里兜底。
4. 跨数据库实战:从 MySQL 迁到金仓/达梦/Oracle 的经验
4.1 一个多数据库部署项目的真实配置
去年我参与了一个项目,客户要求系统必须能在 MySQL 和两家国产数据库上部署,因为不同项目的现场对数据库的要求不一样。这个需求听起来简单,真正落地时才发现,除了分页 SQL,还有驱动、URL、方言、字段大小写、数据类型等一系列兼容问题要处理。
分页这块,因为有 PageHelper 做缓冲,算是整个迁移里最顺利的部分。我们最终采用的是 MyBatis XML 配置方式,把分页插件写进 mybatis-config.xml:
xml复制<plugins>
<plugin interceptor="com.github.pagehelper.PageInterceptor">
<property name="helperDialect" value="mysql"/>
<property name="reasonable" value="true"/>
<property name="supportMethodsArguments" value="true"/>
<property name="params" value="pageNum=pageNum;pageSize=pageSize"/>
</plugin>
</plugins>
如果你的项目是 Spring Boot,也可以直接用官方提供的 starter,配置放到 application.yml 里更简洁:
yaml复制pagehelper:
helper-dialect: mysql
reasonable: true
support-methods-arguments: true
params: pageNum=pageNum;pageSize=pageSize
在迁移初期,我们把 helperDialect 写死为 mysql,先在 MySQL 环境跑通;换库测试时再把配置改为 oracle 或 kingbasees。等验证了所有环境后,才开启后面要讲的 autoRuntimeDialect。
4.2 helperDialect、reasonable、autoRuntimeDialect 等参数怎么定
PageHelper 的参数说多不多,说少不少,有几个关键参数直接影响跨数据库体验。
helperDialect 用来指定数据库方言。默认情况下 PageHelper 会自动从数据库连接信息里推导,但有时候驱动识别不准确,所以显式指定更稳妥。可选值包括 mysql、oracle、postgresql、sqlserver、dm、kingbasees 等,具体看版本支持列表。
reasonable 是个很实用的参数。设为 true 后,如果前端传入的 pageNum 小于 1,会默认查第一页;如果 pageNum 大于总页数,会默认查最后一页。这个能力能避免很多由于前端页码越界导致的报错,我建议生产环境都开启。
supportMethodsArguments 和 params 是配合使用的。开启后,你可以不用在代码里显式调用 startPage(),而是直接在 Mapper 方法参数里加 pageNum 和 pageSize 两个参数,PageHelper 会自动识别并分页。这样代码更简洁,但也增加了隐式依赖,团队里如果新人不知道这个机制,可能会疑惑为什么方法突然自动分页了。我倾向于保持显式调用,因为可读性更好。
maxPageSize 可以限制单页最大条数。如果前端误传或者恶意传了一万条一页,后端可以直接钳制到配置的最大值,这是一个非常有效的保护手段。
4.3 几种数据库最终生成的分页 SQL 对比
我实际操作中,让 PageHelper 在几种数据库上分别运行,并打印了最终执行的 SQL,差异确实很大。
MySQL(helperDialect=mysql)生成的 SQL 还是熟悉的配方:
sql复制SELECT * FROM user WHERE age > 18 ORDER BY id DESC LIMIT 20, 20
PostgreSQL(helperDialect=postgresql)会使用 LIMIT 和 OFFSET:
sql复制SELECT * FROM user WHERE age > 18 ORDER BY id DESC LIMIT 20 OFFSET 20
Oracle(helperDialect=oracle)默认使用的是三层嵌套 ROWNUM 写法:
sql复制SELECT * FROM (
SELECT TMP.*, ROWNUM ROW_ID FROM (
SELECT * FROM user WHERE age > 18 ORDER BY id DESC
) TMP
WHERE ROWNUM <= 40
) WHERE ROW_ID > 20
金仓(KingbaseES)和达梦(DM)在兼容 Oracle 的模式下,生成的分页 SQL 和 Oracle 基本一致;如果它们运行在 MySQL 兼容模式下,则可能使用 LIMIT 写法。这个取决于现场数据库的初始化模式,所以配置方言前,务必确认现场数据库跑的是哪种兼容模式。
4.4 国产数据库适配阶段最容易踩的坑
跨数据库迁移时,PageHelper 能解决分页语法,但解决不了所有问题。我在适配金仓和达梦时踩过几个坑,分享出来供参考。
第一个坑是驱动类名和 JDBC URL 差异很大,网上资料又杂又旧。比如达梦的驱动类是 dm.jdbc.driver.DmDriver,URL 是 jdbc:dm://ip:5236;金仓的驱动类是 kingbase8.Driver,URL 是 jdbc:kingbase8://ip:54321/数据库名。这些配置错了,项目启动直接报找不到驱动,而且报错信息经常不直观,容易误判成 Maven 依赖问题。
第二个坑是字段大小写。Oracle 风格的数据库默认把不带引号的字段名转成大写,而 MySQL 建表时如果用反引号保留了小写,SQL 里的字段名对不上就会报“无效标识符”。我们的解决方案是在 XML 里统一给字段名加双引号,或者干脆让建表脚本统一使用大写字段名。这个约束必须在数据库设计阶段就和 DBA 确认,否则后期改造成本极高。
第三个坑是分页插件版本。老版本 PageHelper 对金仓、达梦的支持不够完善,我建议使用 5.3.x 以上版本,最好是最新的 6.x 版本,新的方言类才比较齐全。升级时也要注意 JSqlParser 的依赖冲突,如果项目里其他地方用了不同版本的 JSqlParser,可能导致 SQL 解析时报错。
4.5 运行时自动识别数据库:autoRuntimeDialect 的用法
如果项目需要一套代码同时部署到不同数据库,且部署现场的数据库类型不确定,可以开启 autoRuntimeDialect。
yaml复制pagehelper:
auto-runtime-dialect: true
开启后,PageHelper 在每次请求时都会识别当前连接对应的数据库类型,然后动态选择方言。这套机制适配多环境部署非常爽:同一个安装包,现场连 MySQL 就用 MySQL 方言,连金仓就用金仓方言,不需要改配置重启。
不过要注意,这个识别过程依赖数据库连接的元数据信息,性能上会有少量开销。我实测下来,对普通业务接口的影响几乎可以忽略,但如果你的分页查询极其频繁且对响应时间极为敏感,也可以考虑连接池缓存方言信息。官方文档建议:如果不需要动态识别,生产环境还是显式指定 helperDialect 更好。我的做法是:通用产品默认开 autoRuntimeDialect,定制项目则写死。
5. 分页查询变慢时,我是怎么定位和优化的
5.1 先分清是 count 慢还是取数 SQL 慢
分页查询变慢,最常见的情况是“列表接口越来越卡”。很多人第一反应是加 Redis 缓存,但缓存加得不对,反而掩盖了真正的性能问题。
我会先做一个基础排查:把 PageHelper 打印的 SQL 日志打开,看一次分页请求到底执行了几条 SQL,分别花了多少时间。PageHelper 默认会先执行一条 count SQL,再执行一条分页数据 SQL。慢在哪里,一目了然。
如果 count SQL 慢,说明统计逻辑需要优化;如果分页数据 SQL 慢,说明取数逻辑或者索引有问题;如果两条都慢,大概率是查询条件本身导致的,跟分页关系不大。
定位 SQL 慢的通用手段是 EXPLAIN。MySQL 用 EXPLAIN SELECT ...,Oracle 用执行计划,金仓和达梦也类似。重点看 where 条件里的字段有没有走索引,排序字段是否在索引里,有没有出现全表扫描、临时文件排序等危险信号。
5.2 count 优化的几个实用手段
count 查询慢,第一个优化点是确认 PageHelper 生成的 count SQL 到底是什么。正常情况下,它会把 ORDER BY 去掉,避免排序开销。这里有个参数 countSuffix 可以指定 count 查询的 MappedStatement 后缀,默认是 _COUNT,一般不需要动。
如果你的列表查询里有多个 join,count SQL 会 join 所有表再统计。某些场景下,count 并不需要 join 这么多表,只需要主表计数就行。这时候可以在 Mapper 里手动写一个 selectUserList_COUNT 方法,PageHelper 的 count 逻辑会优先使用你写好的 count 查询,而不是自己拼 SQL。这个手动 count 的性能提升非常明显,尤其当主表数据少、关联表数据多时,能省掉一大半 join 开销。
还有一个容易忽略的问题:distinct 或 group by 会让 count 变慢。PageHelper 处理带 distinct 的查询时,count 会变成 SELECT count(DISTINCT ...);带 group by 时会统计分组数。这两种写法都无法利用普通索引快速统计。如果业务上确实需要去重分页,可以考虑把要去重的数据预先物化成一张表,或者使用其他统计方案。
5.3 深分页场景:LIMIT 100000, 20 为什么慢
深分页是分页性能问题的重灾区。用户翻到第 5000 页,你的 SQL 变成 LIMIT 100000, 20,数据库并不是直接跳到第 10 万条开始读,而是需要先扫描出前 10 万条,然后丢弃前 99980 条,只留下最后 20 条。前面的扫描成本完全白费,页数越深,性能越差。
解决深分页问题,业界常见方案有几种:
- 限制最大页码和最大 pageSize,让用户根本翻不到那么深;
- 基于游标(keyset)分页,用
WHERE id > :lastId ORDER BY id LIMIT 20代替LIMIT 100000, 20,利用索引直接定位,速度非常快,但缺点是无法跳页; - 延迟关联,先查出当前页的主键或索引字段,再回表查询完整数据,减少大字段传输;
- 针对搜索引擎类场景,使用 Elasticsearch 等专业搜索引擎处理深分页。
PageHelper 本身支持前两种思路:你可以在参数里配置 maxPageSize 限制单页大小,然后自己在代码里限制 pageNum 的范围;但对于 keyset 分页,startPage() 的 LIMIT 模型并不适用,需要手写 SQL。我在后台管理系统的列表页,一般使用“禁止深翻页 + 跳页限制”的方案,在业务上做约束比在技术上硬扛更有效。
5.4 Redis 缓存分页数据,只适合这几种情况
很多人一听到“分页查询慢怎么优化”,第一反应就是上 Redis。但实际上,Redis 缓存分页数据并不是万能的,如果缓存策略设计不当,不但性能没提升,还可能造成数据不一致。
结合我自己的经验,以下几种情况用 Redis 缓存分页数据才有明显收益:
第一种,数据量很小但查询非常频繁。比如城市列表、字典项、配置项,总共几百条。你可以把全量数据缓存到 Redis 的 List 或 String 结构里,每次分页直接在内存里 slice。这种情况下,PageHelper 甚至都不需要参与,完全走缓存。
第二种,热点集中在前几页。比如新闻资讯列表,用户基本只看第 1 到第 3 页,后面的页很少访问。可以把前几页的数据分别缓存到 Redis,缓存 key 带上查询条件和页码,第 1 页被频繁请求时,直接从缓存返回。
第三种,count 结果非常昂贵且实时性要求不高。比如一个多条件筛选的列表,count 要扫描大量数据,可以单独把 total 缓存到 Redis,设置 30 秒或者 1 分钟的过期时间。PageHelper 的数据页仍然走数据库,但省掉了最耗时的 count 查询。注意,只缓存 total 而不缓存列表数据,逻辑上更安全,也不容易出现列表和总数不一致的尴尬。
Redis 缓存的坑,我总结为三点:缓存穿透、缓存雪崩、数据一致性。列表接口的缓存 key 一定要包含所有查询条件,否则不同条件的人会看到相同数据;缓存过期时间要加随机抖动,避免整点大面积失效;写操作频繁的数据不要缓存列表,因为每次更新都要处理缓存同步,成本可能比重新查询还高。
6. PageHelper 和 MyBatis Plus 分页插件选谁
6.1 两个方案的底层定位完全不同
很多团队同时用着 PageHelper 和 MyBatis Plus,然后在某一天突然发现分页乱了,于是来问我选哪个好。我的回答一般先看项目架构。
PageHelper 是一个独立于 MyBatis 的分页插件,适用于任何基于 MyBatis 或 MyBatis Plus 的项目。它的核心优势是通用性强,不依赖 MyBatis Plus 的 BaseMapper,你再复杂的 XML SQL 都能分页。
MyBatis Plus 自带的分页插件是 PaginationInnerInterceptor,必须配合 MyBatis Plus 使用。它的优点是完全面向 MyBatis Plus 的,调用 IService.page() 时体验很丝滑,不需要你自己写 startPage,而且是利用 JSqlParser 做 SQL 解析的,支持多数据源类型。
6.2 同时引入会出什么问题
项目里如果同时存在 PageHelper 和 MyBatis Plus 的分页插件,分页拦截器会对同一条查询执行两次 SQL 改写。比如你先调用了 PageHelper.startPage(),又用了 MyBatis Plus 的 Page 对象传参数,两个拦截器各自识别到自己认识的分页参数,各自往 SQL 上拼一次 LIMIT,最终执行出来的 SQL 可能是 LIMIT 10, 10 LIMIT 10, 10 这样非法语法,直接报 SQL 异常;就算不报错,双层的分页逻辑也会导致返回数据不符合预期。
正确的做法是在一个项目里只保留一种分页方案。如果团队已经在用 MyBatis Plus 的 BaseMapper,就不要额外引 PageHelper;如果项目是原生 MyBatis,那 PageHelper 就是最合适的选择。真的碰到历史项目两边都用的情况,就把其中一个分页拦截器从配置里移除,改掉对应的代码,而不是试图让它们共存。
6.3 我的选型经验
我个人是这么判断的:项目刚启动、团队以 MyBatis Plus 为主,用它的分页插件就足够了,不要再叠加 PageHelper;项目是原生 MyBatis、大量 XML SQL 是从老系统迁移过来的,用 PageHelper 改动最小;项目有跨数据库部署需求,尤其要适配金仓、达梦这类国产数据库,两者都支持,但 PageHelper 的方言覆盖列表更全,遇到问题时参考资料也更多。
如果只是为了分页功能,不必在这两者之间纠结太多,选一个坚持用下去,把使用边界和性能优化策略搞清楚,都比频繁切换方案更有价值。
最后再分享一个我实际项目里的习惯:把分页参数封装成一个独立的 PageQuery 对象,Service 层接收这个对象后,先做参数校验,再调用 PageHelper.startPage(),紧接着执行 Mapper 查询,最后用 PageInfo 包装返回给前端。这套组合我用了好几年,几乎没有因为分页本身出过线上故障。你如果刚接入 PageHelper,建议也按这个套路来:先把最简单的用法跑通,再逐步理解它背后的 SQL 改写机制,最后再做复杂场景的适配和优化。分页这件事,说小不小,说大也不大,但把它做得可靠、可维护,确实能省掉很多半夜排查问题的烦恼。
