如果你在 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 相当于一个当前线程独有的“
