分页这东西,但凡做过几个正经的后台管理系统,都免不了跟它打交道。刚用SpringBoot + MyBatis做项目那会儿,最烦的就是每次写分页都要手动拼LIMIT、再单独写一条COUNT语句,数据量小的时候还能忍,一旦列表页多了,光这些样板代码就能把人写吐。后来换了PageHelper,几行代码就解决了问题,项目里一直用到现在。这篇文章就把我在SpringBoot项目中使用PageHelper的完整经验整理出来,从集成配置、核心用法到各种踩过的坑,一次性说清楚。
1. 项目背景与PageHelper的定位
1.1 为什么分页总是躲不开
做Web后端,列表接口几乎百分之百要分页。用户在前端看到的是一页一页的数据,后端就得告诉数据库"我只要第2页的20条",同时还得让前端知道"总共有多少条、一共多少页",这样翻页组件才能正常工作。
在没有插件之前,常规做法是这样的:先写一条COUNT语句查总数,再写一条带LIMIT的查询语句拿当前页数据。两条SQL,两套代码,还要手动算偏移量。更麻烦的是,只要SQL里带条件,这两条语句就得同步改成一致的WHERE条件,少改一处就出数据对不上的问题。
这种重复劳动干久了,自然就想找个工具来解放双手。PageHelper就是干这个的:你只管写业务查询SQL,它自动在背后给你补上COUNT语句和LIMIT语句,把分页的脏活累活全部包了。
1.2 PageHelper凭什么能"火"这么久
PageHelper是国内用得最多的MyBatis分页插件,没有之一。它能在众多分页方案里站稳脚跟,主要是三个原因。
第一,使用方式极度简单。在查询之前调一句PageHelper.startPage(pageNum, pageSize),后面的查询语句返回的List就自动变成了分页数据,不需要改任何SQL。
第二,对业务代码侵入小。它基于MyBatis的拦截器机制实现,在SQL执行前动态改写SQL,业务层代码看起来跟普通查询没有任何区别,不需要继承特殊的基类,也不需要引入额外的查询对象。
第三,支持的主流数据库非常全。MySQL、Oracle、PostgreSQL、SQL Server、达梦、人大金仓等全都覆盖,切换数据库时不需要重写分页逻辑,插件会根据方言自动生成对应的分页语句。
从2014年左右开始流行,到现在十多年了,社区一直很活跃,版本迭代也没断过,很多老项目和新项目都在用它。
1.3 插件原理:一句话讲清楚它做了什么
PageHelper之所以能不改变业务代码就实现分页,核心是MyBatis的插件机制。它注册了一个Interceptor,拦截的是Executor的query方法。当你调用了startPage后,PageHelper会在当前线程的ThreadLocal里放一个分页参数对象。接下来MyBatis只要有SQL要执行,插件先拦截住,从ThreadLocal里取出分页参数,然后做两件事:
- 自动改写原SQL,包一层COUNT语句,先查询总记录数;
- 再改写出一条带LIMIT/OFFSET(或其他数据库方言)的分页SQL,替换原SQL去执行。
最后把两条结果一拼,返回一个包含了总数和当前页数据的Page对象。这个Page本身是ArrayList的子类,所以你从Mapper返回的类型不用改,还是List,但实际拿到的对象里多出了total、pageNum、pageSize这些属性。
这里有个关键点要记住:分页参数是存在ThreadLocal里的,所以它只对紧接着的下一条查询语句生效,查完就失效。这个设计既是优点也是坑,后面第五章我会专门讲它带来的问题。
2. SpringBoot项目集成PageHelper的准备工作
2.1 依赖引入:一个坐标就把事办了
SpringBoot项目集成PageHelper非常简单,Maven里加一个依赖就行。
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency>注意,这里用的是pagehelper-spring-boot-starter,不是pagehelper。前者是PageHelper官方为SpringBoot提供的自动配置包,引入后无需手动声明PageInterceptor的Bean,避免了很多人一开始配了pagehelper依赖却始终不生效的尴尬。
如果你的项目还在用SpringBoot 2.x,上面的版本完全够用。SpringBoot 3.x需要确认下依赖的兼容性,尽量选1.4.6以上版本,同时确认项目JDK版本,PageHelper本身对JDK 8+支持很好,但SpringBoot 3强制JDK 17,这点要先想清楚。
要是项目用的是MyBatis-Plus,那就要注意了:MyBatis-Plus自带了分页插件PaginationInnerInterceptor,这时候再引入PageHelper反而会多一套机制,容易出现分页被重复处理的怪问题。两者不要混用,选一个就行。
2.2 yml配置说明
引入starter之后,在application.yml里加上基础配置:
pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: count=countSql这几个配置项我逐个说一下,贴实际场景讲,避免你照着抄了不知道有什么用。
helper-dialect是指定数据库方言。不配的话,PageHelper会尝试自动检测数据源类型,大多数情况能检测对,但遇到多数据源、连接池代理或者一些特殊中间件时可能检测错,所以建议显式指定。
reasonable是合理化参数,默认关闭。开启后,当pageNum小于1时自动查询第一页,当pageNum超过总页数时会自动查询最后一页。不少团队把这个设为true,目的是防止前端传了个超大页码导致数据库执行一条LIMIT 999999, 20这种灾难性SQL。但从我自己维护的项目看,我倾向于不开启,因为分页参数异常属于异常请求,应该在接口层直接拦截返回错误,而不是静默修正。当然如果你的接口是纯内部分页且前端不可控,开着也是个兜底选择。
support-methods-arguments是支持在Mapper方法参数中直接接收分页参数。开启后,如果你的Mapper接口方法里定义了PageRowBounds、PageParam这类参数,插件也能自动识别并分页,不需要显式调用startPage。这个功能我用得少,因为会让Mapper方法的参数语义变得不明确,代码review时别人容易看不懂。
params是参数映射,count=countSql的意思是说,当你在Mapper方法的参数中有countSql属性时,用它来控制是否执行COUNT查询。这个更多是给高级用法准备的,普通项目不用深究。
2.3 与MyBatis版本配套的那些坑
PageHelper对MyBatis版本是有要求的。如果你是用pagehelper-spring-boot-starter,它会自动依赖一个匹配的MyBatis版本,通常问题不大。但如果你是在一个老项目里手动升级了MyBatis版本,或者项目里同时有不同来源的MyBatis依赖,就可能碰到插件注册失败、分页不生效的情况。
遇到这种问题,先看启动日志。正常情况下启动时会看到PageInterceptor初始化成功的相关信息。如果没看到,多半是依赖冲突,用mvn dependency:tree查一下实际的MyBatis版本,然后对齐PageHelper要求的版本范围。
另外提一句,PageHelper 5.x和4.x的配置方式有差异,比如4.x支持dialect配置,5.x更推荐helper-dialect。网上很多资料还是老版本的写法,你搜到配置后在地检查一下,别拿4.x的写法套到5.x上。
3. 核心使用场景与代码实操
3.1 最基础的用法:PageHelper.startPage()
看一个最典型的场景:用户列表分页查询。
Controller层:
@GetMapping("/list") public Result<PageInfo<UserVO>> list( @RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize, String keyword) { PageHelper.startPage(pageNum, pageSize); List<User> users = userMapper.selectByKeyword(keyword); PageInfo<User> pageInfo = new PageInfo<>(users); // 转成VO后返回 return Result.success(convert(pageInfo)); }Mapper接口:
public interface UserMapper { List<User> selectByKeyword(@Param("keyword") String keyword); }对应的XML:
<select id="selectByKeyword" resultType="com.example.User"> SELECT * FROM user <where> <if test="keyword != null and keyword != ''"> AND name LIKE CONCAT('%', #{keyword}, '%') </if> </where> ORDER BY create_time DESC </select>看到没有,除了在Service或者Controller里调了PageHelper.startPage之外,其他代码和普通查询完全一样。插件会拦截这条SQL,自动改成分页SQL,然后返回一个Page对象作为查询结果。
这里有一个必须强调的原则:startPage调用之后,紧接着的下一条查询语句才会被分页。中间不要穿插任何其他Mapper查询,否则分页会作用到错误的SQL上。比如:
PageHelper.startPage(pageNum, pageSize); User admin = userMapper.selectByUsername("admin"); // 分页实际作用在这一条!长度只有1条 List<User> users = userMapper.selectByKeyword(keyword); // 这条反而没分页这种代码我在Code Review里见过不止一次,排查起来非常隐蔽,因为数据少的场景下可能看起来正常,数据一多就出问题。
3.2 PageInfo对象里到底装了什么
PageInfo是PageHelper封装的完整分页信息对象,前端要的字段它基本都有。把convert(pages)的返回改一下,直接看它的结构:
PageInfo<User> pageInfo = new PageInfo<>(users); pageInfo.getPageNum(); // 当前页码 pageInfo.getPageSize(); // 每页条数 pageInfo.getTotal(); // 总记录数 pageInfo.getPages(); // 总页数 pageInfo.getList(); // 当前页数据 pageInfo.getIsFirstPage(); // 是否第一页 pageInfo.getIsLastPage(); // 是否最后一页 pageInfo.getHasPreviousPage(); // 是否有上一页 pageInfo.getHasNextPage(); // 是否有下一页 pageInfo.getNavigatePages(); // 导航页码数量 pageInfo.getNavigatepageNums(); // 所有导航页码前端表格组件需要的total和list它都有,翻页组件需要的hasNextPage、pages也都齐了,基本不用自己在后端算。
有个实际经验:拿到PageInfo之后,如果项目里需要把List<User>转成List<UserVO>,注意不要直接new PageInfo<>(userVOList),因为这样等于把原分页信息丢了,需要重新设置。更好的做法是先从pageInfo.getList()取出原始数据,转换后手动复制分页属性,或者直接用PageHelper提供的PageInfo构造一个副本再setList。我习惯写一个通用的转换方法:
public static <T, V> PageInfo<V> convertPageInfo(PageInfo<T> source, Function<T, V> converter) { PageInfo<V> target = new PageInfo<>(); target.setPageNum(source.getPageNum()); target.setPageSize(source.getPageSize()); target.setTotal(source.getTotal()); target.setPages(source.getPages()); target.setList(source.getList().stream().map(converter).collect(Collectors.toList())); // 其他字段视需要复制 return target; }这样Controller里转VO就很清爽,不会丢分页信息。
3.3 多表JOIN查询怎么分页
分页插件在多表JOIN场景下也直接支持,但它有一个隐藏坑:如果是一对多关系,比如一个订单对应多个订单项,直接JOIN查询会让结果行数放大,分页的"每页10条"成了"每页10行JOIN结果",订单数就不再准确。
举例说明:订单表一条订单有3个商品,你查第1页10条,JOIN之后实际可能有20多行结果,页面上看到的订单可能只有六七个,而且订单是重复的,这明显不对。
这个问题的核心在于:分页要分的是"主表记录数",不是"JOIN结果行数"。解决思路一般是三种:
第一种,先分页查询主表ID,再用ID去查关联表数据。也就是说,第一步只查订单表符合条件的分页ID列表,第二步用WHERE order_id IN (...)查订单项,然后在内存中组装。这样每个分页请求其实执行了两条SQL,但是结果准确,而且两个查询都可以走索引,性能相当不错。
PageHelper.startPage(pageNum, pageSize); List<Long> orderIds = orderMapper.selectPageIds(conditions); // 只查ID List<OrderVO> orders = orderMapper.selectByIds(orderIds); // 再查详情+关联数据第二种,仍然用JOIN查全量数据,然后用GROUP BY配合数据库的ROW_NUMBER()或者DISTINCT去重,但这样SQL写起来复杂,且不同数据库写法不同,维护成本高,我一般不建议。
第三种,干脆不用PageHelper,自己写一条子查询分页SQL:先分页子查询主键,再JOIN主表。这种方式兼容性好,但就回到手写的范畴了,除非特殊情况,我不会这么干。
我的建议是优先用第一种,ID分页加二次查询。这个方案既保证了分页语义准确,又不会因为JOIN把大量放大后的数据传到应用层,实际项目里非常稳。
3.4 排序怎么处理最稳妥
分页和排序经常一起出现。PageHelper对排序的处理很简单:你的业务SQL里写没写ORDER BY,它都不管,分页只负责改LIMIT部分。所以排序在业务SQL里正常写就行。
但这里有个细节值得一说:如果排序字段是前端传的,一定要做白名单校验,防止SQL注入。虽然MyBatis用${}拼接ORDER BY时不受#{}预编译保护,很多人会直接:
<select id="selectUsers" resultType="User"> SELECT * FROM user ORDER BY ${orderBy} ${orderDir} </select>这样写如果orderBy直接被前端字符串传进来,等于把SQL列名暴露给了用户,是非常危险的。我基本不用这种方式,而是让前端传字段的枚举值(比如createTime、name),后端自己映射到数据库真实列名,再拼接固定字符串,这样既安全又可控。
另一个经验:分页查询一定要有明确的排序字段,否则数据在分页过程中可能重复或遗漏。MySQL单表查询时物理顺序在数据量小的时候看起来稳定,但底层存储结构变化、数据发生更新后,无排序的分页结果就会飘。最典型的现象是翻页时同一行数据在两次请求里都出现,或者某一行数据从下一页跳到了上一页。给个稳定的排序字段(比如主键DESC或者创建时间DESC加主键兜底),这个坑就能避开。
4. 进阶:复杂场景下的分页策略
4.1 前端参数规范与分页模型设计
用PageHelper的项目多了之后,你会发现不同团队对外暴露的分页参数命名不统一,有的用pageNum/pageSize,有的用page/pageCount,有的用pageIndex/pageRows,前端对接起来非常痛苦。
我在项目里一般会定一个固定的分页请求模型,用PageQuery基类统一接收:
public class PageQuery { private Integer pageNum = 1; private Integer pageSize = 10; // getter/setter }Controller方法接收这个对象,直接传给Service:
public PageInfo<UserVO> queryUserList(PageQuery pageQuery, UserQuery userQuery) { PageHelper.startPage(pageQuery.getPageNum(), pageQuery.getPageSize()); List<User> users = userMapper.selectByCondition(userQuery); return convertPageInfo(new PageInfo<>(users), this::toVO); }这样所有列表接口的入参保持一致,前端可以用同一套逻辑处理。同时把pageSize上限限死,比如最大100,防止有人一次拉全量数据把数据库压垮。
返回结果也可以统一定义,我用得最多的前端返回模型是{ code, message, data: { list, total, pageNum, pageSize } },而不是直接把PageInfo放进去。PageInfo的字段有点多,直接序列化出去会让接口文档显得臃肿。自定义一个PageResult<T>,里面只放必要的字段,反而清爽:
public class PageResult<T> { private List<T> list; private long total; private int pageNum; private int pageSize; private int pages; // 构造方法、getter/setter }4.2 一对多嵌套结果的分页困境
前面3.3提到了多表JOIN的分页问题,这里再展开说一下。除了订单+订单项的经典场景,实际开发里权限系统经常遇到"用户-角色"一对多,"角色-菜单"一对多,这类嵌套查询如果用collection映射,分页结果是最容易出问题的。
一个我印象很深的例子:权限列表按用户查询,每个用户关联了多个角色。Mapper用collection一次性把角色数据装配好,SQL是一个多表JOIN。这时候PageHelper分页的是JOIN后的行数,而不是用户数。假设用户表有100个用户,平均每个用户5个角色,JOIN后变成500行,你要求每页10条,实际返回的可能只有2-3个用户,但总数是500,接口就是错的。
解决思路跟3.3一样,核心是改变查询粒度。我的固定做法是两步查询:
第一步,用PageHelper分页查用户主表字段,得到当前页用户ID列表。
第二步,用WHERE user_id IN (...)查询用户角色关联表,同时查出角色ID列表,再查角色表补全信息。
第三步,在Service层把两个结果集组装成嵌套的VO。
代码大概长这样:
PageHelper.startPage(pageNum, pageSize); List<User> users = userMapper.selectPage(condition); List<Long> userIds = users.stream().map(User::getId).collect(Collectors.toList()); List<UserRole> userRoles = userRoleMapper.selectByUserIds(userIds); List<Role> roles = roleMapper.selectByIds(userRoles.stream() .map(UserRole::getRoleId).distinct().collect(Collectors.toList())); // 在内存中组装这个模式有两个好处:一是分页语义正确,二是SQL简单,每个查询都有索引命中,性能可控。坏处是代码量会多一点,但这点代码量换来的正确性和可维护性,绝对值。
4.3 分页与缓存、异步任务的兼容问题
PageHelper的分页参数存在ThreadLocal里,这个机制在同步链路里没有问题,但是一旦碰到异步线程,就很容易踩坑。
举个例子,如果你的Service里用了@Async方法去执行某个Mapper查询,而这个查询恰好排在一个startPage调用之后,异步线程拿不到主线程ThreadLocal里的分页参数,结果就是异步查询不分页。反过来,如果在异步方法里调用了startPage,而这个异步方法又内部自己新开了线程,同样会丢失。
还有一个常见场景是事务监听器。有些项目在事务提交后异步发送消息,消息处理器里又调了Mapper查询,这时候分页参数不会跨线程传播,所以不要在异步链路里依赖PageHelper的分页状态。
我的经验是:分页参数的传递应该显式化,跨线程的场景可以手动把pageNum和pageSize作为方法参数传到异步方法里,在异步方法内部重新调用startPage,而不是依赖ThreadLocal的隐式传递。这个习惯能省掉很多线上诡异问题。
4.4 不查询COUNT的优化手段
PageHelper默认每次分页都会执行一条COUNT查询。在数据量大的单表场景,COUNT查询本身往往很快,但在复杂SQL里,COUNT可能会执行得很慢,甚至比你真正要分页的SQL还慢。
如果你对总数不敏感,比如某些滚动加载场景只需要"有没有下一页",可以通过PageHelper.startPage(pageNum, pageSize, false)这个重载方法禁用COUNT查询。第三个参数count传false,表示不查询总数,返回的Page对象里total就是0。这样能省掉一条SQL,在高频接口上的性能提升是肉眼可见的。
还有一种情况:总数统计非常复杂,但仍然需要返回。这时候可以手动覆盖countSql,用一条简化的统计SQL替代。具体做法是开启params: count=countSql,然后在Mapper方法参数里带一个countSql属性。这个用法不那么常见,但遇到慢COUNT时真的能救命,我建议知道有这回事就行,用的时候再查文档对照着写。
5. 高频问题与排查技巧实录
5.1 分页突然失效的几大原因
分页失效是PageHelper最经典的问题,表现形式是:你明明调了startPage,但返回的List是全部数据,PageInfo里的total是0或者不对。归纳下来,常见的失效原因就这几类。
第一类:startPage和Mapper查询之间隔了其他SQL。我前面反复强调过,分页参数对下一条SQL生效。如果你在中间执行了其他查询,分页就跑偏了。
第二类:查询被嵌套在其他执行器动作里,比如在查询前先做了selectKey、selectOne等操作,这些都可能被拦截器当作"下一条SQL"处理。
第三类:Mapper查询返回类型不是List,而是Page。有人为了拿分页信息,直接把Mapper方法返回类型定义成Page<User>,这样虽然也能拿到数据,但PageHelper内部处理时可能出现类型判断问题,导致分页结果异常。规范做法是返回List<User>,然后外面包一层PageInfo。
第四类:同一个请求里多次调用startPage,最后一次覆盖了之前的参数,前面的分页设置全部白费。这种情况通常出现在封装方法里,底层方法已经自带startPage,上层又调了一次。
排查思路很简单:确认好调用顺序,确认Mapper返回类型,确认日志里打印的分页SQL是否真的带了LIMIT。一看SQL就全明白了。
5.2 COUNT查询太慢怎么办
COUNT慢的问题在复杂业务里经常出现。比如分页的SQL有十几个条件、关联四五张表,PageHelper自动生成的COUNT语句往往是把查询字段替换成count(0),但它不会智能地裁掉不需要的JOIN,导致COUNT执行时间甚至超过分页查询本身。
排查步骤是这样的:先打开MyBatis的SQL日志,把自动生成的COUNT语句和分页SQL都打出来,手动执行一下看耗时分布。
如果确认COUNT语句因为多余的JOIN变慢,一种直接的优化是:在手写SQL里用@SelectProvider或者XML的countQuery属性自定义一条更精简的COUNT语句。PageHelper从4.2.x开始支持QueryInterceptor和自动识别countSql,可以通过配置让插件优先使用你提供的COUNT语句。
另一种思路是业务层面减少COUNT次数:给总数加缓存,或者只在第一页的时候查总数,翻页时沿用第一次的总数。这个方案需要跟产品确认可接受度,但很多列表页的"总数"其实并不需要实时精确,几分钟的延迟完全够用。
5.3 插件版本冲突与不生效排查
插件不生效的另一个高发原因是版本冲突。SpringBoot项目里如果同时引入了mybatis-spring-boot-starter和pagehelper(注意不是starter),容易出现PageInterceptor没有注册到SqlSessionFactory的情况。
正确做法是引入pagehelper-spring-boot-starter,它会在MybatisAutoConfiguration之后自动注册拦截器。如果你必须用原生pagehelper依赖(比如公司内部封装的Starter环境),那就要手动声明配置类:
@Configuration public class PageHelperConfig { @Bean public PageInterceptor pageInterceptor() { PageInterceptor pageInterceptor = new PageInterceptor(); Properties properties = new Properties(); properties.setProperty("helperDialect", "mysql"); properties.setProperty("reasonable", "false"); pageInterceptor.setProperties(properties); return pageInterceptor; } }同时要在MyBatis配置里把这个拦截器加进插件链。SpringBoot的MybatisProperties里可以通过mybatis.configuration.interceptors配置,也可以自己注册ConfigurationCustomizer:
@Bean public ConfigurationCustomizer pageHelperCustomizer(PageInterceptor pageInterceptor) { return configuration -> configuration.addInterceptor(pageInterceptor); }配置完成后,启动日志里能看到PageHelper相关的Interceptor注册信息,也有[PageHelper]打头的一些初始化日志。如果都没出现,基本可以断定是没注册成功,先检查依赖树。
5.4 高频问题速查表
我把实际项目里遇到的高频问题整理成了一张表,方便排查时直接对照。
| 问题现象 | 主要原因 | 解决方向 |
|---|---|---|
| 返回了全量数据,total为0 | startPage之后没有紧跟分页查询,或分页参数被覆盖 | 检查调用顺序,确保startPage后直接调用目标Mapper方法 |
| 分页计数错误,总数不对 | Mapper返回了Page而非List,或SQL本身有GROUP BY导致COUNT语义变化 | 返回类型改回List,复杂SQL手动指定countSql |
| JOIN查询结果行数被放大 | 一对多JOIN导致分页语义错误 | 改用主表ID分页+二次查询方案 |
| 翻页后数据重复/缺失 | 原SQL没有稳定的排序字段 | 补上主键或其他稳定字段的ORDER BY |
| 异步方法里分页无效 | ThreadLocal不跨线程传递 | 把pageNum/pageSize作为方法参数显式传递 |
| 启动报SqlSessionFactory相关错误 | 依赖冲突或拦截器未注册 | 检查依赖树,使用starter,或手动注册PageInterceptor |
| MySQL和Oracle部署时SQL不兼容 | 未显式配置方言 | 配置helper-dialect,或按环境切换 |
这张表里的问题,除了第一行第三个那种需要改表结构的特殊情况,其他基本都能通过调整代码解决,遇到类似现象先别慌,照着排查一遍基本能定位。
6. 结合项目的一些实用建议
6.1 统一分页返回结构的封装
团队项目里,最怕每个人写一套分页返回结构。有人直接返回PageInfo,有人返回自定义Map,前端对接时每个接口都要单独适配。这个问题的解法很朴素:定义统一的分页返回模型,所有Controller都返回它。
我在工作中的做法是定义PageResult<T>,固定包含list、total、pageNum、pageSize四个核心字段,pages等字段按需添加。Service层统一把PageInfo转换到PageResult,Controller只做简单透传。这样不管底层用什么插件,前端看到的接口结构始终一致。
这里还有个细节:序列化时注意PageInfo里的泛型。如果直接把PageInfo<User>序列化成JSON,list字段类型在Jackson里可能会带上一堆Page自身的额外属性,导致响应体里出现无关字段。用自定义的PageResult之后就不存在这个问题,接口文档干净很多。
6.2 PageHelper之外的替代方案
用PageHelper久了,也要知道有哪些替代方案,方便做技术决策的时候权衡。
MyBatis-Plus自带的分页插件PaginationInnerInterceptor是最大的替代选择。它跟PageHelper的区别在于:MyBatis-Plus的推荐方式是使用Page<T>对象作为Mapper方法的参数,分页信息是显式传递的,不依赖ThreadLocal,因此不存在"分页作用到下一条SQL"的问题。如果你项目里MyBatis-Plus用得深,直接用自带的就行,没必要再引一套PageHelper。
Spring Data JPA的分页则是基于Pageable和Sort的,思路完全不同,用JPA的项目不会考虑PageHelper。
还有像mybatis-mate、Fluent-MyBatis这些小众方案,各有各的特色,但都没有PageHelper在传统XML派系里这么通用。我的建议是:老项目用PageHelper,新项目如果用了MyBatis-Plus可以参考其内置插件,如果纯MyBatis-XML派且需要开箱即用的分页,PageHelper依然是最省心的选择。
6.3 一些经验总结
最后聊一点我在实际项目里的体会。
分页不是功能,是基础设施。一个项目里到处是分页代码,如果每个接口都自己写一遍分页逻辑,维护成本会失控。把分页当基础设施来统一设计,Controller入参、Service调用、返回结构都标准化,才是真正把PageHelper用好了。
关于性能,分页查询优化的核心永远不在插件本身,而在索引和SQL。只要WHERE条件能走索引,LIMIT的偏移量大一些也能接受;但如果SQL本身是全表扫描,再好的分页插件也救不回来。所以每次优化分页接口时,我会先用EXPLAIN看一下执行计划,再谈其他。
再分享一个小技巧:在开发阶段可以打开PageHelper的SQL日志,把自动生成的COUNT语句和分页SQL都打印出来,一眼就能看出插件有没有正常工作,也能及时发现COUNT慢的苗头。上线前记得把日志级别调回去,不然压测时日志本身就成了性能瓶颈。
根据个人经验,PageHelper用得好不好,核心在于理解它的ThreadLocal机制和拦截器原理。把这些底层的东西搞明白了,遇到任何诡异问题都能顺着执行链路找到原因。希望这篇整理能帮你在SpringBoot项目里少踩几个坑。