1. 从手写分页到插件接管,先聊清楚分页这件事
做Java后端的人,只要接触过数据库,基本都逃不过分页查询。早期用JDBC的时候,分页是纯手工活,MySQL写LIMIT offset, size,Oracle玩ROWNUM,SQL Server还得折腾ROW_NUMBER() OVER,换一种数据库就要改一遍SQL。后来用MyBatis舒服了一些,但分页依然要自己在Mapper XML里写LIMIT #{offset}, #{pageSize},每个查询都要算offset,代码一多到处是重复劳动,还容易因为参数顺序写错直接报错或者查出脏数据。
MyBatis分页插件解决的就是这个痛点。市面上最主流的方案是PageHelper,另外MyBatis Plus在框架层面也内置了一套分页能力。它们的共同思路是:你只需要告诉插件“我要查第几页、每页几条”,插件在SQL执行前帮你把SQL改写成分页SQL,执行完顺便把总数查出来,最后把结果封装成带页码信息的分页对象。也就是说,SQL还是那个SQL,业务代码里不用再写offset计算,不用再单独写COUNT查询,页面上的“共多少条、第几页、共几页”这些数据自动就有了。
这篇文章不是光讲怎么调用,我会把背后原理拆开来看。因为只有搞明白分页插件是怎么“拦截”你的查询的,你才能在遇到问题时知道去哪里排查。文章里覆盖了PageHelper的完整配置、分页参数传递、PageInfo封装、常见坑排查,也顺带讲清楚MyBatis Plus分页和PageHelper在设计思路上的区别。适合正在用MyBatis做项目、想规范化分页写法的同学,也适合被分页插件搞出过奇奇怪怪bug、想真正看懂原理的人。
2. 分页插件整体设计思路,为什么它能“拦截”分页
2.1 物理分页与逻辑分页,选错方案的代价
分页说白了有两种实现方式。逻辑分页是把所有数据一次性查出来,在内存里截取需要的部分,早期很多ORM框架的默认行为就是这样。逻辑分页在数据量小的时候没啥问题,但数据一旦上了万级,每次翻页都全表加载,内存撑不住,数据库查询也慢得离谱。物理分页是直接在SQL层面操作,数据库只返回当前页需要的数据,这才是正经的分页方案。MyBatis本身没有内置物理分页能力,需要借助数据库方言写不同的分页SQL,这就为分页插件的出现留下了空间。
分页插件的价值不只是“帮你把SQL拼好”,它做了三件你以为很简单但其实很麻烦的事:一是动态判断当前查询是不是分页查询,不是的话就放行;二是改写SQL,在原SQL外面包一层,变成带LIMIT或ROWNUM的真实分页语句;三是额外生成一条COUNT查询。这三件事如果靠手写SQL,每写一个Mapper方法都要重复一遍,而且业务SQL一旦改动,分页SQL和COUNT SQL都要跟着改,非常容易漏。插件把这些全部自动化了。
2.2 MyBatis插件机制是分页拦截的基础
MyBatis允许通过插件机制在四大核心组件上做拦截,分别是Executor、StatementHandler、ParameterHandler和ResultSetHandler。分页插件拦截的是Executor,因为Executor负责调度整个查询过程,在这里拦截可以拿到完整的MappedStatement、参数对象和SQL信息,也能控制查询是否继续执行。
PageHelper的实现思路是:执行分页查询时,通过ThreadLocal保存分页参数,然后在Executor的query方法里判断当前线程是否存在分页参数,如果有,就把原始SQL改写成分页SQL,同时查询总数,执行完成后清空ThreadLocal。这里有一个很多初学者忽略的关键点:分页参数只对紧接着的下一条查询生效,查询完就失效。所以你在PageHelper.startPage之后如果先执行了别的查询,再执行目标查询,分页参数早就没了,分页就不生效。这是PageHelper最经典的坑之一,后面我会专门讲。
2.3 从“Mapper层分页”到“插件层分页”的演进
早期项目里很多人写分页是这么干的:先写一个Page对象,然后让所有Mapper方法的参数都继承它,SQL里手动拼LIMIT。这个方案能工作,但污染了Mapper接口的职责,每个查询都要关心分页字段,而且不同数据库方言还得自己适配。插件方式把分页从业务SQL里彻底剥离了,SQL只负责查询条件,分页是横切关注点,由插件统一处理。这其实就是AOP思想的体现,也符合MyBatis插件设计的初衷——把公共的横切逻辑集中管理。
3. Spring Boot中集成PageHelper,五个步骤跑通完整分页
3.1 项目依赖引入,一个坐标解决大部分场景
PageHelper对Spring Boot的支持很完善,官方提供了pagehelper-spring-boot-starter,引入这个依赖后只要配置几个参数就能开箱即用。我用的是Spring Boot 2.7.x,对应的PageHelper版本用的1.4.7,稳定性不错。Maven坐标如下:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency>如果你的项目不是Spring Boot,是传统的Spring XML配置,那就用pagehelper普通坐标,然后在mybatis-config.xml里配置PageInterceptor插件。Spring Boot项目我建议直接用starter,省去手工装配的麻烦,配置项也都有默认值,唯一必须关注的是helper-dialect。
3.2 核心配置参数,每个都影响分页行为
在application.yml里添加如下配置:
pagehelper: helper-dialect: mysql reasonable: false support-methods-arguments: true params: count=countSql auto-runtime-dialect: true参数说明:
helper-dialect:指定数据库方言,可选值包括mysql、oracle、postgresql等。配置了这个参数,插件就知道用什么语法生成物理分页SQL。我是MySQL数据库,这里直接写mysql。reasonable:分页合理化。设为true时,如果查询页码超过总页数,会自动回到最后一页;如果小于1,自动回到第一页。这个参数看业务需求,有些场景下用户手动输入page参数,超过范围时后端不做约束很容易查出空数据,合理化之后体验会好一些。但我通常设为false,因为很多管理后台需要让前端明确知道“请求的页码不存在”,返回空页比静默纠正更容易排查问题。support-methods-arguments:支持Mapper方法参数中的分页参数。开启后,如果你在Mapper方法里声明了Page类型的参数,插件会自动识别,不需要手动startPage。params:配置count=countSql的意思是,当你的SQL里已经包含了COUNT查询,插件会优先使用你的COUNT语句,否则自动生成一条。
auto-runtime-dialect建议开启,这个参数能让插件在运行时根据连接URL自动识别数据库方言,避免环境迁移(比如从MySQL切到PostgreSQL)时改了数据源忘记改方言的尴尬。
3.3 编写Mapper和Service,一个PageInfo搞定所有
先看一个最简单但完整的分页查询流程。Mapper接口里不用声明任何分页相关的方法,就是一个普通查询:
public interface UserMapper { List<User> selectUserList(UserQuery query); }Mapper XML照样只写业务查询条件,注意不要写LIMIT:
<select id="selectUserList" resultType="com.example.entity.User"> select * from user <where> <if test="name != null and name != ''"> and name like concat('%', #{name}, '%') </if> <if test="status != null"> and status = #{status} </if> </where> order by create_time desc </select>Service层的做法是:
public PageInfo<User> pageUser(UserQuery query) { PageHelper.startPage(query.getPageNum(), query.getPageSize()); List<User> userList = userMapper.selectUserList(query); return new PageInfo<>(userList); }PageHelper.startPage(pageNum, pageSize)两个参数一个是页码,一个是每页条数,页码从1开始。紧接着执行的selectUserList会被拦截并改写成分页SQL。new PageInfo<>(userList)会从查询结果中解析出总数、总页数、当前页码、每页条数等完整信息,Controller直接把这个PageInfo序列化给前端就行。
你可能会问,为什么COUNT查询没有在Service里写?插件在改写SQL的时候会同时执行一条COUNT查询,自动把总数塞进PageInfo。这条COUNT是插件根据原SQL生成的,一般能覆盖大部分场景。但注意,如果你的原SQL特别复杂,比如带有多个子查询或者UNION,插件生成的COUNT可能不够精确,这时你可以在对应的查询方法上使用@SelectProvider自定义COUNT逻辑,或者用PageHelper的startPage重载方法指定COUNT SQL。
3.4 前端分页参数传递规范,别踩参数名的坑
前端传来的分页参数通常是pageNum和pageSize,也可能是page和limit。很多人在Controller里直接接收再传给Service,这个没问题,但我建议在后端定义一个统一的分页请求基类,避免每个接口重复声明参数。
public class PageRequest { private Integer pageNum = 1; private Integer pageSize = 10; public Integer getPageNum() { return pageNum; } public void setPageNum(Integer pageNum) { this.pageNum = pageNum; } public Integer getPageSize() { return pageSize; } public void setPageSize(Integer pageSize) { this.pageSize = pageSize; } }然后业务查询对象继承这个基类,Controller里统一接收。这样所有分页接口参数风格一致,前端对接成本低很多。另外pageSize一定要做上限限制。我见过很多系统没限制每页条数,前端传一个pageSize=100000,直接把数据库打爆。实现方式可以简单粗暴,超过100就强制设为100,如果你的业务确实需要导出大结果集,那应该走专门的异步导出接口,不该走分页查询。
4. 分页参数与PageInfo深入实践,多表查询和排序技巧
4.1 多表关联查询的分页,到底分的是谁
多表关联分页是最容易出低级错误的地方。看一个场景:查询订单列表,每个订单关联用户表和商品表,SQL是select o.*, u.name as userName, p.name as productName from orders o left join user u on o.user_id = u.id left join product p on o.product_id = p.id。
PageHelper对单表简单查询和多表关联查询的处理方式是一样的——直接在原SQL外层套一个LIMIT。绝大多数情况下,业务想要的就是“订单维度的分页”,也就是订单表满足条件的数据分页,结果集里带上关联表的冗余字段,这种处理是对的。
但有一种场景会翻车:一张订单对应多个商品明细,如果明细也需要在同一页展示,SQL会变成一对多联查,同一订单出现多行。这时候LIMIT截的是“行数”,不是“订单数”,结果可能是订单A有10条明细恰好占了半页,看起来就像数据“分页分碎了”。对于这种场景,分页插件帮不了你,根因是SQL本身的一对多结果集。我的建议是这种页面不要走单条SQL联查,先分页查订单主表,再根据当前页的订单ID集合批量查明细,在内存里组装。这种“主表分页+从表聚合”的做法,数据一致性更好,SQL也好维护。
4.2 PageInfo常用字段与JSON序列化
PageInfo对象里字段很多,最常用的是这几个:
PageInfo<User> pageInfo = new PageInfo<>(userList); long total = pageInfo.getTotal(); // 总记录数 int pages = pageInfo.getPages(); // 总页数 int pageNum = pageInfo.getPageNum(); // 当前页码 int pageSize = pageInfo.getPageSize(); // 每页条数 List<User> list = pageInfo.getList(); // 当前页数据还有一个容易被忽略的字段hasNextPage、hasPreviousPage,前端做“加载更多”或者“上一页/下一页”按钮时会用到。PageInfo本身继承了Page类,Page类又继承了ArrayList,所以序列化时如果直接返回PageInfo,前端拿到的JSON里既能按数组方式拿到list,也能拿到total、pageNum这些字段。但PageInfo里的navigatepageNums等导航页字段是分页条组件用的,如果你用vue-element-admin这类现成模板,可能需要把这些字段一起返回。
4.3 排序和分页一起用,SQL别写死
分页查询几乎都伴随着排序需求。不建议在Mapper XML里面写死order by create_time desc,因为业务上管理员可能要按时间、按金额、按状态分别排序。让前端传排序字段,但一定不能直接拼接SQL,存在注入风险。可以用MyBatis的<choose>标签白名单判断:
<choose> <when test="orderByColumn == 'createTime'"> order by create_time </when> <when test="orderByColumn == 'amount'"> order by amount </when> <otherwise> order by create_time </otherwise> </choose>这样排序字段只能在白名单里选,拼接方向参数时也要校验只能是asc或desc。实际项目中这是很实用的规范,既灵活又安全。
4.4 分页插件的COUNT查询优化
插件自动生成的COUNT查询很多时候是“能用但不够快”的。比如原SQL里有多个LEFT JOIN,但COUNT根本不需要关联那些表,插件生成的select count(0) from ...还是会把JOIN带上。这种情况下,你可以在Mapper里单独定义一个COUNT查询方法,让自己的COUNT SQL只查主表:
<select id="selectUserList_COUNT" resultType="long"> select count(0) from user <where> <if test="name != null and name != ''"> and name like concat('%', #{name}, '%') </if> </where> </select>PageHelper约定,如果存在方法名_COUNT的Mapper方法,它会优先使用这个方法替代自动生成的COUNT查询。这个约定看似简单,大数据量场景下性能差异可能达到几十倍。我优化过一个查询,原SQL有四个JOIN,COUNT要扫描大量关联数据,改成独立COUNT只查主表索引,接口响应时间从1.8秒降到0.2秒。
5. MyBatis Plus分页与PageHelper的区别,到底选哪个
5.1 MyBatis Plus分页的核心API
MyBatis Plus是MyBatis的增强框架,内置BaseMapper,自带很多通用方法,分页也是开箱自带的。使用MyBatis Plus分页需要先配置一个分页插件:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }业务代码里这样用:
Page<User> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery(); wrapper.eq(User::getStatus, 1).orderByDesc(User::getCreateTime); Page<User> result = userMapper.selectPage(page, wrapper);selectPage是BaseMapper内置方法,传入Page对象和查询条件,result里就有记录、总数、页码信息。这个API天然面向对象,不需要手写SQL,写起来确实爽。MyBatis Plus的分页插件机制和PageHelper完全不同,它的核心是一个基于JsqlParser的解析器,能够把查询SQL解析成抽象语法树,再根据方言改写成分页SQL,最后把总数查询、分页查询都处理完。
5.2 两者设计哲学的核心差异
PageHelper和MyBatis Plus分页最本质的区别在于是否“侵入Mapper方法”。
PageHelper是弱侵入的,你的Mapper方法签名不用做任何改动,甚至不用让Mapper继承任何接口,普通接口加个XML就能分页。好处是迁移成本低,原来几十个自定义查询方法不用动,只要调用前加一句PageHelper.startPage就行。坏处是这个操作有隐藏依赖,容易忘记清除ThreadLocal导致后续查询被意外分页。
MyBatis Plus则要求所有分页查询都走BaseMapper的selectPage方法,或者用IService.page方法。查询条件通过Wrapper构造,SQL由框架自动生成。这种方式约束更强,代码结构更规范,但如果你已经有一套复杂的自定义SQL,想迁移过来需要把很多Mapper方法改成Wrapper写法,工作量大。
从使用体验来说,如果你的项目是全新的,并且决定全量使用MyBatis Plus,那直接用框架自带分页最省事。如果你的项目是维护一个老系统,Mapper XML里已经有大量手写SQL,只想要一个“无侵入”的分页能力,PageHelper显然更合适。
5.3 共存场景是否可行
有些项目会同时引入MyBatis Plus和PageHelper,技术上没有硬冲突,两个插件拦截器同时注册也能工作。但不建议这么干,原因很简单:两个插件都会在Executor层做拦截,分页逻辑可能出现双重改写,运维的时候也不清楚每个查询到底走的哪套分页机制,排查问题成本高。二选一就好。
6. 分页插件背后的实现细节,看懂源码才敢深度排查
6.1 ThreadLocal里的page对象生命周期
PageHelper的startPage方法把分页参数放到了ThreadLocal里,紧接着的查询会消费这个参数。这个设计在单线程场景下没问题,因为一次请求一个线程,查询完参数就清掉。但如果你在代码里用了线程池,比如在子线程里去执行查询,ThreadLocal里的分页参数是拿不到的,因为子线程和父线程的ThreadLocal不共享,分页会静默失效。
更隐蔽的问题是,如果startPage之后执行的查询抛了异常,ThreadLocal里的参数可能没被及时清理干净,下一次查询到同一个线程(线程池复用)时,新查询会被无端分页。PageHelper的拦截器在finally块里会做清理,但如果你绕过了Executor走了一些奇怪的查询路径,异常分支就有可能残留。所以有个很实用的习惯:尽量不要把startPage和查询隔得太远,中间别穿插别的数据库操作,保持在同一个方法里连续执行。
6.2 插件如何改写SQL
PageHelper内部对SQL的改写依赖jsqlparser库。它会解析原始SQL,识别出SELECT、FROM、WHERE、ORDER BY这些SQL片段,然后根据方言拼接。比如MySQL方言,PageHelper会把原查询包装成SELECT ... FROM (原SQL) AS TEMP LIMIT ?,?,再单独生成一条SELECT COUNT(0) FROM (原SQL) AS TEMP来查总数。
为什么PageHelper不改原SQL的LIMIT,而是选择套一层子查询?因为原SQL可能自带ORDER BY,直接在外面套LIMIT在某些场景下会破坏排序的语义,而且GROUP BY和DISTINCT的情况下,COUNT的计算逻辑应该是基于子查询的结果来的。套子查询是通用做法,虽然性能上多了一层子查询开销,但正确性优先。当然这也是为什么复杂分页SQL最好自己写COUNT的原因——子查询套得越多,性能越差。
6.3 手写一个简化版分页拦截器
理解了原理之后,自己写一个极简版的分页拦截器其实很有意思,也能加深对MyBatis插件机制的理解。核心步骤是:实现Interceptor接口,用@Intercepts注解声明拦截Executor的query方法,在拦截逻辑里解析SQL、拼接分页部分。
@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 { MappedStatement ms = (MappedStatement) invocation.getArgs()[0]; Object parameter = invocation.getArgs()[1]; // 解析BoundSql,拿到原始SQL BoundSql boundSql = ms.getBoundSql(parameter); String originalSql = boundSql.getSql(); // 判断是否需要分页,这里简化判断条件 if (parameter instanceof Page) { Page page = (Page) parameter; String pageSql = originalSql + " LIMIT " + page.getOffset() + "," + page.getPageSize(); // 替换BoundSql中的SQL Field sqlField = BoundSql.class.getDeclaredField("sql"); sqlField.setAccessible(true); sqlField.set(boundSql, pageSql); } return invocation.proceed(); } }这个简化版本还有很多问题,比如没处理COUNT查询、没考虑不同数据库方言,但足以展示核心机制。理解了这个流程,后面遇到分页插件相关的bug,至少能判断问题出在SQL解析层还是参数传递层。
7. 常见问题与排查技巧实录,都是真实项目里踩过的坑
7.1 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 分页不生效,返回全部数据 | startPage和目标查询之间有其他查询 | 确保startPage后紧跟目标Mapper调用 |
| 分页数据正常但总数不对 | COUNT SQL自动生成不准确 | 自定义方法名_COUNT方式覆盖 |
| 翻页时数据重复或丢失 | ORDER BY字段不唯一 | 追加主键排序,如order by create_time desc, id desc |
| 使用线程池子线程分页失败 | ThreadLocal不跨线程传递 | 子线程内单独执行startPage |
| MySQL分页正常,切到Oracle报错 | 方言配置写死 | 开启auto-runtime-dialect |
| 多个Mapper方法连续调用,第一个正常第二个被分页 | ThreadLocal残留 | 检查异常分支、排查是否在同一线程复用 |
7.2 嵌套结果查询的分页陷阱
如果你的Mapper返回结构是resultMap里配置了collection的嵌套查询,就是那种“查订单列表,每个订单里再查明细”的写法,分页会出现一个非常隐蔽的问题:LIMIT作用在主查询上,但主查询只有几条订单,每个订单的内部集合又查出很多明细,最终页面上看到的明细条数可能远大于pageSize。这在功能上其实符合“订单分页”的预期,但如果前端预期的是“行分页”,两边就对不上了。
我的建议是尽量避免这种“嵌套查询+分页”的组合。要么改成联表分页查主表,然后在Service层聚合明细;要么明确告诉前端这里的分页维度是主表记录,不是明细行。
7.3 动态表名和视图分页的处理
有些系统按时间分表,比如订单表按月拆分order_202501、order_202502,Mapper XML里用${tableName}动态拼表名。PageHelper对这种情况的支持是有的,因为SQL拿到的是已经拼接了真实表名的语句,解析器能正常处理。但要注意,如果表名是通过自定义Interceptor在SQL执行前动态替换的,就要留意分页插件和表名插件的执行顺序,顺序不对可能SQL已经被分页改写了,表名还没替换上去,直接报表不存在。
处理办法是调整两个插件的order属性,确保表名替换插件先执行。配置顺序在Spring Boot中可以通过@Bean的加载顺序间接控制,必要时在插件中显式指定拦截优先级。
7.4 分页插件加持下的性能优化思路
分页查询慢,很多时候不是分页本身慢,而是COUNT慢。定位思路是:先看慢SQL日志,确认是分页SQL慢还是COUNT慢。如果是COUNT慢,优先优化COUNT语句,减少不必要的JOIN,尽量覆盖索引;如果是分页SQL慢且深分页严重(比如翻到第1000页),可以考虑“延迟关联”方案,先分页查出主键ID,再用主键ID关联回原表查完整记录:
<select id="selectUserListByPage" resultType="com.example.entity.User"> select u.* from user u inner join ( select id from user <where> <if test="name != null and name != ''"> and name like concat('%', #{name}, '%') </if> </where> order by create_time desc limit #{offset}, #{pageSize} ) t on u.id = t.id </select>这种写法牺牲了一点SQL复杂度,换来了深分页时临时表扫描量的显著下降,数据量大的时候非常值得用。
8. 分页插件的选型建议与我的个人使用体会
分页插件看似是个小工具,选型时却牵动着整个数据访问层的架构方向。用了这么多年,我的体会是:没有最好的分页插件,只有最适合当前项目的方案。
如果是纯MyBatis项目,XML里自定义SQL多,我首选PageHelper,因为它的侵入性最小,老项目接入成本极低。数据库方言切换频繁的项目,开启auto-runtime-dialect后基本不用改代码。如果是新项目且团队决定用MyBatis Plus,那我直接用它的selectPage,理由很简单,框架已经替你做了分页,没必要再引入一套体系。MyBatis Plus的分页API和Service层结合得也很好,IService.page方法自带Lambda条件构造器,代码写起来干净不少。
最后提醒一件事,分页插件不是万能的。它只能处理“SQL改写和参数传递”这一层,业务上你是否应该用深分页、COUNT查询怎么写更高效、多表关联怎么组织,这些都需要你自己做决策。理解插件的边界,比学会调用API重要得多。希望这篇文章能帮你把分页插件用得明明白白,少踩几个坑。