分页查询这事,说大不大,说小不小,但几乎每个做后端的人都被它磨过。前段时间我在整理一个老项目的分页逻辑,顺手把MyBatis和PageHelper这套组合又重新捋了一遍,顺手记录了一些踩坑和优化思路。这篇文章就来聊聊分页查询功能相关的那些事,重点围绕MyBatis和PageHelper展开,涵盖集成方式、使用姿势、常见坑位排查和一点点原理层面的理解,帮你在实际开发里少走弯路。
先说清楚,这篇文章适合谁。如果你刚接触Spring Boot + MyBatis,想看分页怎么写最省事;或者你已经在用PageHelper,但遇到过分页失效、统计不对、深分页慢这些奇奇怪怪的问题;再或者你打算在自己的项目里做分页组件的选型,想搞明白PageHelper到底值不值得用——那这篇文章都值得你花十分钟扫一遍。
1. 分页方案的选型与PageHelper的工作方式
1.1 为什么首选PageHelper而不是手写LIMIT
分页这个需求太常见了,常见到很多人条件反射直接写LIMIT offset, size。手写LIMIT本身没有错,但一旦你的查询条件变多、查询场景变多,你会发现自己被迫写大量重复的分页样板代码,而且统计总条数的SQL往往和列表查询的SQL长得一模一样,只是目标字段不同,维护起来非常别扭。
PageHelper解决的核心痛点是:分页逻辑与业务查询解耦。你在业务代码里只需要写普通的列表查询,然后在查询前调用一行PageHelper.startPage(pageNum, pageSize),PageHelper就会在SQL执行阶段自动帮你在SQL上拼接LIMIT,同时还会自动生成一条COUNT查询去拿总数。这一点在实际项目中非常受用,尤其是查询条件复杂、动态SQL多的场景下,你不需要去维护两套SQL或手动拼接分页后缀。
另外还有个容易被忽视的点:PageHelper对多种数据库方言做了适配。你的项目今天用MySQL,明天也许要兼容PostgreSQL或Oracle,PageHelper会自动识别数据库类型,生成对应方言的分页语句,手写LIMIT在换库时就要全部重改。虽然大多数项目一辈子不会换数据库,但这一层方言适配在集成测试和本地开发换数据库时仍然能省不少事。
1.2 PageHelper在MyBatis执行链路里的位置
想要用好一个工具,搞明白它在框架里的位置比死记API重要得多。PageHelper并不是独立工作的,它本质上是一个MyBatis的Interceptor插件,通过拦截MyBatis的Executor来改变SQL的执行行为。
简单画一下调用链路,你在Service层调用Mapper方法时:
- 你先调用了
PageHelper.startPage(pageNum, pageSize),这一步会把分页参数存进ThreadLocal。 - 紧接着调用Mapper的查询方法,MyBatis内部会创建
SqlSession并执行SQL。 - PageHelper的拦截器在
Executor.query()执行前拦截到这次调用,从ThreadLocal里取出分页参数。 - 判断当前查询是否需要分页(这一步很关键,后文会展开说)。
- 需要分页的话,PageHelper会改写原始SQL,生成分页SQL和计数SQL,分别执行。
这个机制决定了PageHelper的一个显著特征:PageHelper只对紧随其后的第一条查询语句生效。如果你在startPage和Mapper查询之间插入了其他查询操作,分页参数会被别的查询消耗掉,当前Mapper查询就不会分页。这是新手最容易踩的坑之一,后面会在问题排查部分专门讲。
1.3 PageHelper与MyBatis-Plus内置分页的取舍
既然搜热词里也有MyBatis-Plus,就顺带对比一下。MyBatis-Plus内置了分页插件PaginationInnerInterceptor,它的用法和PageHelper有些类似但又完全不同。
PageHelper的思路是“自动拦截+自动改SQL”,而MyBatis-Plus的分页则需要你传一个Page对象作为Mapper方法的参数,由框架解析Page对象来拼分页SQL。两者各有拥趸,但我的体感是:
- 如果你的项目就是原生MyBatis的写法,没有引入MyBatis-Plus,那引入PageHelper非常轻量,不会改变你原有的Mapper写法。
- 如果你的项目已经用了MyBatis-Plus,那就没必要再引入PageHelper了,用MyBatis-Plus自带的分页插件更统一,避免两套分页机制在同一个上下文里互相干扰。
- 从可控性角度讲,MyBatis-Plus的显式传
Page参数更直白,分页逻辑一眼可见;PageHelper则更“魔法”,一行startPage就生效,但出了问题也更隐蔽。
我做技术选型时有个习惯:不重复引入功能重叠的组件。同一套代码里又用PageHelper又用MyBatis-Plus分页,一旦分页异常,光排查是哪个插件在起作用就够你喝一壶的。
2. 集成PageHelper的实操步骤与配置解读
2.1 依赖引入与基本配置
项目基于Spring Boot的话,引入PageHelper的依赖非常简洁。我用的是Spring Boot 2.x + MyBatis Starter的组合,Maven里加这一条:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency>注意,这里用的是pagehelper-spring-boot-starter,它已经帮你自动装配了拦截器,不需要再手动在MyBatis配置里添加Plugin。如果你是非Spring Boot的传统项目,那需要在MyBatis的配置文件中手动注册PageInterceptor:
<plugins> <plugin interceptor="com.github.pagehelper.PageInterceptor"> <property name="helperDialect" value="mysql"/> <property name="reasonable" value="true"/> </plugin> </plugins>Spring Boot模式下,推荐直接在application.yml里配置相关参数:
pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: count=countSql auto-runtime-dialect: true配置项不多,但每个都可能影响运行结果,我逐一说一下。
2.2 核心配置项的含义与推荐值
helper-dialect:指定分页方言,可以填mysql、oracle、postgresql等。如果不填,PageHelper会自动检测,但显式指定可以避免某些场景下自动识别不准的问题。如果你配置了auto-runtime-dialect: true,运行时会在多个数据源之间自动识别,这在多数据源项目里更稳。
reasonable:这个参数建议开启为true,它做两件好事:一是当你传入的页码小于1时,自动查询第一页;二是当页码大于总页数时,自动查询最后一页。比如用户直接手改URL里的pageNum=999,如果不开这个配置,数据库会扫描一个极大的偏移量,既慢又没有意义;开了之后PageHelper会直接把页码规整到最后一页,对前端展示很友好。
support-methods-arguments:开启后,支持在Mapper方法参数里直接通过@Param("pageNum")和@Param("pageSize")来接收分页参数,而不需要显式调用startPage。这个特性看个人习惯,我倾向于在Service层显式调用startPage,因为分页的入口在业务代码里会更清晰,也方便在分页前做一些参数校验。
params:这个配置用来定义count参数的获取方式,count=countSql允许你通过Page对象里的countSql属性控制是否执行count查询。在需要精细控制count场景的性能时比较有用。
这里说一个我自己的偏好:reasonable和helper-dialect是我在新项目里必配的两项,前者防止接口被恶意页码打出深分页,后者避免数据库方言识别多一次探测开销。
2.3 最简单的分页代码长什么样
配置搞定后,分页的使用就非常直接了。这是一个最常见的Service层示例:
public PageInfo<UserDO> pageUserList(String keyword, int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); List<UserDO> users = userMapper.selectUserListByKeyword(keyword); return new PageInfo<>(users); }对应的Mapper接口和XML写法完全不用为分页做任何特殊处理:
List<UserDO> selectUserListByKeyword(@Param("keyword") String keyword);<select id="selectUserListByKeyword" resultType="com.example.domain.UserDO"> select id, name, age, email from user <where> <if test="keyword != null and keyword != ''"> and name like concat('%', #{keyword}, '%') </if> </where> order by id desc </select>PageHelper.startPage执行后,紧接着的selectUserListByKeyword查询就会被自动分页,返回的List实际上是一个Page对象,把它传给new PageInfo<>(list)即可获得total、pages、pageNum、pageSize等完整分页信息。
有一个细节要注意:PageInfo和List里承载的数据是同一份引用,在获取PageInfo之后不要再对原List做二次修改,否则会把分页结果也改掉。有人习惯对查询结果做Stream转换再封装返回,如果是这样,建议先new PageInfo<>(list)拿到分页信息,再用转换后的List单独做返回结构。
3. 分页查询的代码实践与场景扩展
3.1 动态SQL条件下的分页写法
实际业务里列表查询十有八九带筛选条件,而且条件数量不是固定的。PageHelper在这种场景的优势最能体现,因为不管你的<where>里拼了多少条件,分页逻辑都不用动。
举个复杂一点的例子,一个订单列表支持按订单号、用户ID、下单时间区间、状态多条件组合查询:
<select id="selectOrderPage" resultType="com.example.domain.OrderDO"> select o.id, o.order_no, o.user_id, o.status, o.amount, o.create_time from order o <where> <if test="orderNo != null and orderNo != ''"> and o.order_no = #{orderNo} </if> <if test="userId != null"> and o.user_id = #{userId} </if> <if test="startTime != null"> and o.create_time >= #{startTime} </if> <if test="endTime != null"> and o.create_time <= #{endTime} </if> <if test="status != null"> and o.status = #{status} </if> </where> order by o.create_time desc </select>Service层依然不需要改动结构:
public PageResult<OrderDO> queryOrderPage(OrderQuery query) { PageHelper.startPage(query.getPageNum(), query.getPageSize()); List<OrderDO> list = orderMapper.selectOrderPage(query); PageInfo<OrderDO> pageInfo = new PageInfo<>(list); return PageResult.of(pageInfo.getTotal(), list); }这种写法有个天然的好处:将来新增筛选条件,你只需要在OrderQuery里加字段、在XML里加<if>判断,Service层和Mapper接口签名都不用动,分页功能天然跟着走了。维护成本非常低,这其实就是PageHelper这类“分离式分页”设计的价值所在。
3.2 关联查询与分页结果的坑位提示
列表查询一旦关联了子表,情况会变得微妙。最常见的坑是:因为关联产生的重复数据导致总数不对。
举个例子,查询用户列表并关联每个用户的订单数量:
<select id="selectUserWithOrderCnt" resultType="map"> select u.id, u.name, count(o.id) as order_cnt from user u left join order o on o.user_id = u.id group by u.id, u.name order by u.id </select>这个SQL本身没什么问题,但如果你把left join写成了inner join的写法,或者某条用户没有订单导致join后行数膨胀,就会影响总数。PageHelper的count查询始终基于原SQL生成,它生成的count语句大致是select count(0) from (原SQL) tmp_count,如果原SQL里本身有group by或distinct,count结果可能是正确的,但性能会变差;如果原SQL里不加group by导致笛卡尔积,count数量就会虚高。
解决这类问题有几个思路:
- 分页的主体查询和统计查询分开写,统计用专门的Mapper方法。PageHelper支持你传一个
Page对象并用countSql参数控制是否执行自动count。 - 如果只是列表展示不需要精确总数,可以禁止count,只做limit分页,前端用“加载更多”代替页码。
- 对于复杂的报表类查询,不要过度依赖PageHelper的自动count,手写统计SQL会更可控。
我个人的建议是:常规多表列表查询,放心交给PageHelper;复杂报表查询,自己掌控SQL,PageHelper只用来做limit拼接,count单独写。
3.3 自定义count查询的姿势
PageHelper提供了一个能力:当你觉得自动生成的count语句不理想时,可以自己写一个count查询,通过@Param("countId")指定,或者直接传入Page对象设置countSql。
实际操作中,如果你在Mapper接口里定义了两个方法,一个查询列表一个查询count,PageHelper 5.x版本之后可以通过官方的Page对象方式控制。更简单的做法是:使用PageHelper.startPage(pageNum, pageSize, false)关闭count查询,然后自己调用count查询方法拿总数。
// 关闭自动count PageHelper.startPage(pageNum, pageSize, false); List<OrderDO> list = orderMapper.selectOrderPage(query); long total = orderMapper.countOrderPage(query);这个模式我经常在复杂报表中使用,因为自动count生成的SQL在极端情况下可能比手写的统计慢一个数量级,自己控制以后,SQL执行计划完全在掌控之中。
3.4 分页结果的统一封装
为了不让分页逻辑散落在各个Service里,我习惯抽一个简单的分页结果对象:
public class PageResult<T> { private long total; private int pages; private List<T> records; public static <T> PageResult<T> of(long total, List<T> records) { PageResult<T> result = new PageResult<>(); result.total = total; result.records = records; result.pages = (int) (total % 10 == 0 ? total / 10 : total / 10 + 1); return result; } }注意这里pages计算用的是硬编码的10,实际使用中可以传入pageSize,或者直接从PageInfo拿现成的getPages()。我给出的方式只是示意,正式代码里建议用PageInfo自带的pages属性,避免重复造轮子。
4. 坑位复盘:PageHelper使用中的典型问题与排查
4.1 分页失效:最常见的ThreadLocal错位问题
分页失效这个问题,我在各种技术群里见过的次数最多。典型表现是调用startPage之后,查询返回了全量数据,完全没分页。
绝大多数原因可以归为一类:startPage后面的第一条查询不是预期的Mapper查询。
我之前在排查一个同事的代码时,发现他是这样写的:
public List<UserDO> pageQuery(int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); // 这里竟然先调了一次别的查询 UserDO admin = userMapper.selectByRole("admin"); // 这条才是真正想分页的查询,但分页参数已经被上面那条消耗掉了 return userMapper.selectUserList(); }由于PageHelper的分页参数保存在ThreadLocal里,并且只对紧随其后的第一条查询生效,上面的写法会导致selectByRole被分页,而selectUserList返回全量。如果selectByRole没有limit需求,那它就会带着一个多余的limit执行,结果你可能还发现不了。
还有一种类似的情况,Service里在startPage之前调用了其他查询,比如先查一遍权限、再分页查列表,这本来没问题,因为startPage在权限查询之后执行。但如果你在startPage和主查询之间打印日志时不小心触发了数据库查询操作(比如MyBatis延迟加载触发),也会干扰ThreadLocal。
我的经验是,startPage和Mapper查询之间只允许出现纯内存操作,不要出现任何可能触发数据库访问的代码。注释里最好也提示后来的维护者,这个位置是雷区。
4.2 count查询总是不对:聚合与分组时的注意点
count不准的另一个高发场景是原SQL里带GROUP BY。PageHelper的count改写会把原SQL包一层select count(0) from (...) tmp_count,在MySQL里这个写法对带group by的SQL会返回分组后的行数,通常是对的。但如果你还用了DISTINCT,又或者left join造成了行数膨胀,count和列表行数就会对不上。
举个例子,一个查询商品列表并关联商品标签表的SQL,如果一个商品有多个标签,join之后会产生多行;列表展示的时候如果用了某种行合并,实际展示条数少于查询条数,count也会虚高。
这种情况下,最直接有效的排查方式是打印PageHelper生成的SQL。在MyBatis的日志配置里开启SQL打印后,你能清楚地看到PageHelper为count生成了什么样的语句,一眼就能看出它包了哪一层。
4.3 深分页性能差的优化方案
分页查询一旦翻到后面,性能急剧下降是必然的。LIMIT 100000, 20意味着数据库要扫描前100020行再丢掉前100000行,这种浪费在数据量大时非常致命。
PageHelper本身不做深分页优化,它只是帮你拼SQL。所以优化深分页要靠SQL层面:
一种常用思路是延迟关联,先查主表的主键或覆盖索引,再回表查详情。写成XML大概是这样:
<select id="selectOrderPageByOptimize" resultType="com.example.domain.OrderDO"> select o.id, o.order_no, o.user_id, o.status, o.amount, o.create_time from order o inner join ( select id from order where create_time >= #{startTime} order by create_time desc limit #{offset}, #{pageSize} ) tmp on tmp.id = o.id order by o.create_time desc </select>另一种思路是基于游标分页,也就是网上常说的“keyset”方式,不需要传页码,靠上一页最后一条记录的排序字段值来做筛选:
where create_time < #{lastCreateTime} order by create_time desc limit #{pageSize}这两种方式PageHelper都不直接支持,需要你手动构造Mapper方法。如果有这种需求,我的建议是放弃PageHelper,直接在XML里手写分页SQL,可控性更高。
4.4 多数据源下的分页方言问题
有些项目配置了多数据源,主库MySQL,从库可能混着PostgreSQL或Oracle。如果你在application.yml里硬编码了helper-dialect: mysql,切换到其他数据库方言的数据源时,分页SQL就会生成错误。
解决方式就是开启auto-runtime-dialect: true,让PageHelper在运行时根据当前数据源连接自动识别方言。如果你的项目用了dynamic-datasource这类动态数据源框架,尤其建议开启,否则你会看到一些很诡异的语法错误。
这里还给一个实用建议:分页插件和动态数据源的加载顺序有时会影响拦截器获取到的连接。如果你配置了多数据源,又发现分页方言始终识别不对,检查一下PageHelper的拦截器是否是在数据源切换之后才生效的。常见的做法是把pagehelper的依赖和配置文件放在主数据源配置之后加载,或者通过配置@AutoConfigureAfter调整自动装配顺序。
4.5 分页参数大小的参数校验
这个不属于PageHelper本身的问题,但属于分页功能必备的防御性编程。线上接口如果允许前端随意传pageSize,传一个10000进来,你的一次分页查询可能就把数据库打挂了。
我一般在Controller或Service层统一做校验:
public static void checkPageParam(int pageNum, int pageSize) { if (pageNum < 1) { throw new BusinessException("页码必须大于0"); } if (pageSize < 1 || pageSize > 200) { throw new BusinessException("每页条数必须在1-200之间"); } }虽然reasonable=true会帮你把页码规整到合理范围,但它不会限制pageSize的上限。对pageSize做上限控制,是对数据库最基本的保护。
5. 从源码角度看PageHelper的关键实现
5.1 ThreadLocal:分页参数的传递机制
PageHelper的分页参数传递机制,核心就是ThreadLocal。PageHelper.startPage(pageNum, pageSize)会创建一个Page对象,其中包含了pageNum、pageSize和是否count等参数,然后把这个Page对象存入一个静态的ThreadLocal字段里。
Page对象本身是个ArrayList的子类,这也就解释了为什么执行查询后返回的List可以强转成Page。MyBatis在执行完查询后,会把查询结果填充到Page的List里,同时Page还持有了total等分页信息,new PageInfo<>(list)实际就是从Page对象里读取这些元数据。
有一点值得注意:ThreadLocal是线程隔离的。如果项目里使用了线程池异步执行SQL,子线程里是拿不到父线程的分页参数的,这会导致异步分页查询失效。这个坑比较隐蔽,一旦遇到,需要你把分页参数显式传给子线程处理。
5.2 拦截器与Executor的交互
PageHelper实现SQL改写的关键在于MyBatis的插件机制。它在初始化时定义了对Executor接口的query方法的拦截:
@Intercepts({ @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class PageInterceptor implements Interceptor { // ... }当MyBatis执行任何一个Mapper查询时,都会先经过这个拦截器。拦截器内部会做几件事:
- 从
ThreadLocal取出Page对象。 - 如果
Page为空,直接放行,不改变原SQL。 - 用
MetaObject获取当前MappedStatement的SQL信息,交给PageAutoDialect去生成对应的分页方言实现。 - 生成分页SQL后,替换
BoundSql中的原始SQL,并创建新的MappedStatement。
这段逻辑的复杂度不低,但对我们使用的人来说,只需要理解一个结论:PageHelper拦截的是Executor层的query请求,而不是Mapper层面,所以它对嵌套查询、存储过程等复杂调用的处理方式需要特别小心。
5.3 为什么PageHelper不建议在循环里调用
因为分页参数存放在ThreadLocal,且需要被后续的第一次查询消费,如果你在循环里反复调用startPage和查询,虽然逻辑上能跑通,但会频繁创建Page对象和改写SQL,性能开销不小。更重要的是,循环内一旦出现判断分支,某个查询可能不被执行或执行顺序变化,会导致分页参数被错误消耗,产生不可预期的数据。
我在一个批处理任务里看到过这种写法:循环遍历供应商列表,对每个供应商查询它的商品分页列表,结果某些供应商的数据时有时无,排查到最后就是循环里startPage的调用和查询没有严格配对,中间插了一个其他查询。最终改成了在循环内单独封装一个分页查询方法,把startPage和查询放一起,问题就消失了。
6. 分页查询性能排查的实操建议
6.1 开启慢SQL日志和分页SQL排查
很多分页问题不是逻辑错误,而是性能问题。建议在项目里开启MyBatis的SQL日志打印,并配合慢查询日志定位那些SQL耗时长的问题。
Spring Boot中配置MyBatis SQL打印可以这样:
logging: level: com.example.mapper: debug这样设置后,控制台会打印Mapper包下每个SQL的预处理语句和参数。配合PageHelper使用时,你可以看到原SQL和PageHelper改写后的分页SQL,方便判断分页SQL是否符合预期。
对于线上环境,不建议打印全量SQL,因为并发高的时候日志量会非常大,磁盘和CPU都可能被拖垮。一般做法是开启数据库层的慢查询日志,只记录超过阈值的语句。
6.2 深分页的索引优化实例
分页查询性能差,很多时候不是分页组件的问题,而是索引没设计好。拿最典型的订单列表来说:
如果查询条件是status = 1 order by create_time desc limit 100000, 20,且表里只有create_time索引,那数据库大概率会走filesort排序,产生很差的执行计划。
比较理想的索引设计是联合索引(status, create_time),既能过滤状态,又能让排序走索引。创建索引后,同样一条分页SQL,执行时间往往能从几秒降到几十毫秒级别。
关于深分页的优化,我之前在一个百万级数据量的表上做过一次真实对比:
| 查询方式 | 耗时 |
|---|---|
| 普通LIMIT 100000, 20 | 约1.8s |
| 子查询INNER JOIN分页 | 约0.12s |
| 游标方式(基于上次排序值) | 约0.06s |
虽然不同环境数据量、配置会有差异,但结论是明确的:深分页场景下,LIMIT偏移量越大,性能下降越明显,延迟关联和游标方式都值得考虑。
6.3 分页结果集过大的缓存设计
如果分页列表的数据不怎么变化,或者变化频率很低,为了避免每次请求都打数据库,可以考虑在Service层加缓存。但这里有一个关键问题:分页结果要不要整体缓存?
我建议分页结果不要整体缓存。原因有两个:一是分页数据的总数和当前页数据经常同步变化,整体缓存容易造成数据不一致;二是分页参数组合非常多,缓存命中率往往不高,反而占用大量内存。
实际做法通常是把底层的基础数据缓存起来,分页查询只对变化的那一部分走数据库。比如商品列表分页,可以把商品基本信息放在Redis里缓存,分页查询只查ID列表,再根据ID批量从缓存中取详情,缓存未命中的再回源数据库查,这样分页和后端数据源都能得到性能优化。
7. 结个尾:我的分页实践心得
回到最开始说的那个项目,我最后把整个分页逻辑统一到了Service层入口处,严格要求startPage与Mapper查询相邻,封装了统一的分页校验方法,并在复杂报表场景中放弃了自动count。这套规范沉淀到团队之后,分页相关的Bug数量直接下降了一大截。
说句掏心窝子的话,分页查询看起来是所有后端功能里最简单的需求之一,但真正在生产环境跑起来,从SQL到索引、从ThreadLocal到拦截器,每一层都有可以琢磨的细节。PageHelper帮我们省掉了大量重复代码,但它并不是“一配永逸”的神器,只有理解了它的设计思路和边界坑位,才能在遇到问题时第一时间定位,并且写出更健壮的分页代码。
最后分享一个小技巧:每当你准备用PageHelper.startPage时,问自己一句——“下一条SQL一定是我要分页的那条吗?”这么一句自我提醒,至少能帮你避免后来版本维护时一半的分页失效问题。