SpringBoot + PageHelper 分页插件详解:配置、使用与常见坑

发布时间:2026/10/5 11:53:57
SpringBoot + PageHelper 分页插件详解:配置、使用与常见坑 1. 从“假分页”到真分页PageHelper能解决什么问题先聊个实际场景。很多人写SpringBoot MyBatis的分页功能时最粗暴的做法是先list()查出全表然后在Service层用subList()手动切一刀或者借助Page对象自己在内存里做切片。数据量小的时候没什么感觉等表里放了十几万条订单记录这种写法就非常难受每次翻页都把全表数据加载到内存接口响应慢不说数据库连接和内存也吃紧。更隐蔽的问题是subList()切出来的是原列表的视图后续做序列化或修改操作还会带出一些莫名其妙的副作用。PageHelper就是冲着这个痛点来的。它是MyBatis体系里最常用的物理分页插件原理是在MyBatis执行SQL之前通过拦截器动态改写SQL自动拼接上LIMITMySQL、ROWNUMOracle、OFFSET ... FETCHPostgreSQL这类数据库方言的分页语句同时执行一条COUNT查询拿到总记录数。对业务代码来说你只需要在查询前调用PageHelper.startPage(pageNum, pageSize)之后紧跟的那条查询就会被插件拦截并做分页改写查询结果自动封装成PageInfo总页数、总条数、当前页码、是否有上一页下一页这些信息全部现成。这篇文章就围绕SpringBoot项目中PageHelper的使用展开覆盖的内容包括SpringBoot 3.x / 2.x环境下Maven依赖的正确引入方式、配置参数详解、四种不同写法的分页查询及其适用场景、和MyBatis-Plus共存时的冲突处理、以及我在真实项目里排查过的几个坑。适合刚接触PageHelper的初学者也适合已经用了一段时间但碰到过“分页不生效”“COUNT查询异常”这类问题的开发者。2. 新版SpringBoot中PageHelper的Maven依赖踩坑先说依赖引入这一步看着简单实际是坑最多的地方。2.1 不同SpringBoot版本的依赖坐标差异PageHelper有两个官方坐标很多人分不清!-- 方式一pagehelper-spring-boot-starter推荐 -- dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency !-- 方式二pagehelper原生需要手动配置拦截器 -- dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper/artifactId version5.3.3/version /dependency如果你的项目用的是SpringBoot 2.x直接引入pagehelper-spring-boot-starter是最省事的它会自动配置好PageInterceptor你只需要在application.yml里加几个配置项就能跑起来。但如果你用的是SpringBoot 3.x也就是Jakarta EE命名空间那一代情况就不一样了——早期的pagehelper-spring-boot-starter版本是基于javax.*编译的直接引入会在启动时抛出ClassNotFoundException: javax.servlet.*之类的异常或者MyBatis的自动配置根本不生效。我实际验证过的方案是这样dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version2.1.0/version /dependency2.1.0这个版本是PageHelper官方专门适配SpringBoot 3.x的起点内部改用了jakarta.*命名空间同时对齐了MyBatis Spring Boot Starter 3.x。如果你的项目恰好还在用SpringBoot 3.0或3.1建议直接上2.1.0。2.2 MyBatis版本不匹配引发的启动失败还有一个低概率但很折磨人的情况项目原本自己引入了mybatis-spring-boot-starter版本是2.3.x对应MyBatis 3.5.x这时再引入PageHelper的starter理论上兼容。但如果你的项目里同时存在mybatis-plus-boot-starter冲突概率会急剧上升。一个值得记住的结论如果你用了MyBatis-Plus不要再用pagehelper-spring-boot-starter。MyBatis-Plus自己带分页插件PaginationInnerInterceptor两个分页插件叠加使用时MyBatis的拦截器链会同时拦截SQL出现双重分页或分页参数错乱的概率非常高。我后面专门用一个章节讲这个先记住结论二选一别硬凑。如果你项目使用的是SpringBoot 3.2以上且MyBatis版本很新比如3.5.16还要注意一个兼容问题PageHelper 5.3.x的拦截器在解析SQL时依赖jsqlparser如果日志里出现类似net.sf.jsqlparser的NoClassDefFoundError说明包里jsqlparser版本和你的MyBatis版本有冲突手动显式声明一个高版本的jsqlparser依赖即可dependency groupIdcom.github.jsqlparser/groupId artifactIdjsqlparser/artifactId version4.9/version /dependency3. application.yml配置参数详解与执行SQL日志验证依赖引对了接下来就是配置。很多人配置只写一个helper-dialect就完事了结果查询时发现分页条件没有拼接上或者分页总数不对就是因为好几个参数没配明白。3.1 核心配置项及含义以我最常用的配置为例pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: countcountSql auto-dialect: true auto-runtime-dialect: false逐个说明含义helper-dialect指定数据库方言可选mysql、oracle、postgresql、sqlserver等。一般情况下你配了它插件就不再需要自动探测数据库类型。多数据源场景下建议不要写死而使用auto-dialect: true让插件按当前数据源连接自动推断方言。reasonable分页合理化。默认false。当设置为true时如果pageNum 0会查询第一页如果pageNum 总页数会查询最后一页。这个参数在报表、管理后台里很实用可以避免前端传一个越界页码导致返回空数据。support-methods-arguments支持通过Mapper方法参数直接传分页参数。默认false。把它打开后你的Mapper接口方法可以接收Pageable或PageParam这类自定义参数插件会自动识别。params从方法参数中取值的映射规则。比较常用的就是countcountSql意思是通过countSql参数来控制是否执行COUNT查询。配置完之后建议开启MyBatis的SQL日志验证分页是否生效否则你无法判断插件到底有没有改写SQLlogging: level: com.example.demomybatis.mapper: debug如果你的项目用的是MyBatis-Plus共存但还没做迁移SQL日志级别设置在mapper包名下即可。日志里看到SELECT count(0) FROM ...跟着一条带了LIMIT ?, ?的查询就说明分页生效了。3.2 一个配置细节为什么要开 reasonable我的建议是生产环境务必打开reasonable: true。举一个我实际遇到的例子运营后台的分页表格用户停留在第8页这时候管理员在另一页删除了部分数据用户再次点击下一页时pageNum变成9但总页数已经降到了5。这时如果reasonable: false查询返回空列表接口数据正常但前端表格直接空白用户的第一反应是“系统出bug了”。开了reasonable之后插件会自动把页码修正到最后一页返回最后一页的数据用户体验好了很多。不过也要注意这个参数对“精确查第N页”这种需要严格分页语义的场景不适合比如爬虫按页码抓取数据时必须保证页码越界时返回空而非自动纠正。4. PageHelper.startPage、PageInfo与Mapper返回值标准用法详解配置就绪后PageHelper的使用逻辑其实非常简洁核心就是“先调startPage再执行查询”。但越是这种看似简单的API用错的姿势越多。4.1 最标准的用法模板Service public class UserServiceImpl implements UserService { private final UserMapper userMapper; public UserServiceImpl(UserMapper userMapper) { this.userMapper userMapper; } Override public PageInfoUserVO getUserPage(int pageNum, int pageSize) { // 1. 设置分页参数 PageHelper.startPage(pageNum, pageSize); // 2. 紧跟一条查询 ListUser users userMapper.selectByCondition(正常); // 3. 将查询结果封装成PageInfo PageInfoUser pageInfo new PageInfo(users); // 4. 如果需要做VO转换注意先转再封装PageInfo ListUserVO userVOList users.stream() .map(user - new UserVO(user)) .collect(Collectors.toList()); PageInfoUserVO result new PageInfo(userVOList); // 手动复制分页信息 result.setTotal(pageInfo.getTotal()); result.setPages(pageInfo.getPages()); return result; } }这段代码里有几个关键点值得展开。第一PageHelper.startPage只对紧随其后的第一条查询生效。如果你在startPage之后又执行了其他SQL比如先查一遍角色再查用户列表那么分页条件会被错误地作用在“查角色”那条SQL上而真正的用户列表查询反而是全量查询。这是PageHelper最著名的误用场景之一。第二new PageInfo(list)的分页信息从哪来当你调用PageHelper.startPage后插件会通过ThreadLocal保存分页参数查询返回的是一个Page对象ArrayList的子类其中包含了total、pageNum等附加属性。PageInfo的构造函数会从Page对象里提取这些属性。如果你自己手动new ArrayList(page.getResult())转成了普通List再new PageInfo(plainList)这些分页信息就丢了total会变成0。第三如何在Service层拿到total不需要自己写COUNT查询。PageInfo对象里已经包含了完整的分页维度包括属性含义典型用途total总记录数前端分页组件显示总条数pageNum当前页码前端回显当前页pageSize每页条数前端每页条数下拉框pages总页数前端计算是否显示“下一页”isFirstPage/isLastPage是否首页/末页禁用上一页/下一页按钮hasNextPage是否有下一页加载更多的判断逻辑navigatepageNums页码数组前端展示“1 2 3 4 5”分页条4.2 四种分页写法差异对比PageHelper支持多种调用方式我在项目里总结过它们之间的差异写法代码示例适用场景startPage Mapper查询PageHelper.startPage(1, 10); mapper.selectList(...)最简单最常用startPage Mapper方法重载PageHelper.startPage(1, 10); mapper.selectByCondition(name, status)多条件查询PageInfo直接返回return new PageInfo(list)需要返回分页详情总页数等PageHelper.startPage不取其返回的PagePageBean page PageHelper.startPage(1, 10, true);需要直接操作Page对象第三行提到的startPage有一个重载方法public static E PageE startPage(int pageNum, int pageSize, boolean count)第三个参数count表示是否执行COUNT查询。默认是true。如果你明确知道自己只需要列表数据而不需要总数比如“加载更多”按钮的场景可以把count设为false省掉一次COUNT查询性能上会好不少。注意此时PageInfo.total会是0不能用于分页总条数展示。4.3 为什么返回值是List而不是Page还有一个常见的疑问Mapper方法的返回值明明写的是ListUser为什么实际返回对象能强转成Page这在MyBatis的插件体系中叫做“返回对象增强”。PageHelper在拦截器执行完查询后会对返回结果做判断如果方法返回值类型是List它会把原List包装成自定义的Page对象继承ArrayList通过类型擦除机制让Java在编译期不报错运行时却携带了分页信息。这也是PageInfo能够分析出分页详情的原因。这个机制同时揭示了一个性能陷阱如果你在Mapper里对查询结果做了嵌套查询、联合查询或者返回了MapPageHelper的包装逻辑可能识别不到从而无法生成正确的分页信息。我遇到过协作者在Mapper XML里写了resultMap关联查询然后分页总数统计错得一塌糊涂就是因为分页拦截器作用于外层主查询而count查询自动去掉了LEFT JOIN之后总数变了。5. 分页不生效与COUNT查询异常排查链路复盘这一章聚焦我踩过、帮同事排查过的两个最有代表性的问题。先说排查思路再说根因。5.1 分页不生效的完整排查链路现象前端传入pageNum2, pageSize10查询接口返回了全量数据且返回体里没有total等分页字段。排查链路分四步走第一步检查是否执行了两次查询。在PageHelper.startPage和Mapper查询之间如果插入了任何其他数据库访问操作分页参数会被“提前消费”。最常见的场景是代码里先调了一次dictMapper.selectAll()去查数据字典然后才执行目标查询。解决办法是把字典查询放到startPage之前或者把目标查询放在紧跟startPage的位置。第二步检查返回类型是不是被手动重构了。比如你写了ListUser list userMapper.selectPage(); ListUser newList new ArrayList(list); PageInfoUser pageInfo new PageInfo(newList);这会导致total丢失因为newList是普通的ArrayList。你需要直接new PageInfo(list)或者从原list的Page对象里复制分页属性。第三步检查Mapper方法的返回值是否被Param包裹且参数没有设置分页参数。如果Mapper方法签名是个ListUser select(Param(name) String name)这没问题但如果Mapper方法返回的是ListMapString, Object或Integer就可能跳过PageHelper的返回增强逻辑。第四步检查插件是否被错误拦截。看SQL日志。如果日志里没有LIMIT关键词说明拦截器压根没有生效确认一下pagehelper-spring-boot-starter是否成功引入以及在SpringBoot 3.x项目里PageInterceptor是否被MyBatis的自动配置链加载。如果在日志里看到了LIMIT但返回数据还是全量那多半是第二次查询把分页条件作用到了错误的SQL上回到第一步。5.2 COUNT查询慢和COUNT结果不对PageHelper自动生成的COUNT查询默认是SELECT count(0) FROM (原始SQL) tmp_count把原始查询包了一层子查询。这在简单查询下没问题但遇到下面两类情况就需要手动干预情况一原始SQL本身带了ORDER BY或DISTINCT。对带ORDER BY的查询做count时子查询里保留排序没有意义PageHelper一般情况下会帮你剔除掉ORDER BY但如果SQL写得太复杂比如ORDER BY里包含函数、CASE WHEN某些版本的jsqlparser解析不出来count查询仍然会包含排序逻辑性能很差。解决办法是使用countSuffix配置项或者干脆提供一个独立的count查询PageHelper.startPage(pageNum, pageSize); // 通过通用Mapper或自定义SQL指定count查询 userMapper.selectPageWithCount(pageNum, pageSize);更直接的方式是开启paramscountcountSql后在Mapper方法上使用SelectProvider或XML中显式写count查询。情况二查询里包含LEFT JOIN。如果主查询是订单表LEFT JOIN用户表PageHelper默认的count会基于JOIN后结果集统计可能因为一对多关联导致total变大。实际业务中“一对多”导致的count膨胀是最隐蔽的问题。比如一条订单对应三条明细LEFT JOIN明细表后查出来三条记录count结果变成了3倍。我的处理方式是不在分页查询的主SQL里直接JOIN一对多关联的明细表而是先分页查主表再通过IN查询批量补充明细数据。这样总条数统计基于主表分页数据也不会出现重复行。5.3 多数据源下的自动方言识别问题分页SQL最大的区别在于方言。MySQL是LIMIT offset, sizeOracle 12c以下是ROWNUMPostgreSQL是LIMIT ... OFFSET。PageHelper默认通过JDBC连接自动识别方言但如果你配了多个数据源比如主库MySQL、分析库PostgreSQL并且没有显式给每个数据源指定方言插件可能对第一个数据源识别后缓存了方言导致第二个数据源分页SQL用错。处理方案有两个在application.yml中配置auto-runtime-dialect: true让插件在运行时根据当前查询的连接动态识别方言而不是缓存一个。如果有多个SqlSessionFactory为每个数据源单独配置PageInterceptor并给每个拦截器设置不同的helperDialect。6. 与MyBatis-Plus共存及SpringBoot 3.x的兼容性实践这个章节的内容来自两个近期项目的真实经验写的都是网上文档很少提及的部分。6.1 共存冲突为什么我不建议同时引入有一部分项目是从MyBatis-Plus迁移到纯MyBatis PageHelper的或者反过来在PageHelper项目里引入了MyBatis-Plus。共存时常见的问题是MyBatis-Plus自带分页插件PaginationInnerInterceptor它的实现机制同样是改写SQL并且也是在Executor层做拦截。PageHelper的PageInterceptor与MyBatis-Plus的分页插件都在拦截器链上执行顺序不确定时可能出现“PageHelper先改写MyBatis-Plus再改写”两层分页拼接出LIMIT LIMIT的非法SQL。两个插件都会往ThreadLocal里写分页参数一旦嵌套查询可能出现startPage参数被其他插件消费掉的问题。我处理过的一个项目线上日志经常报You have an error in your SQL syntax排查到最后就是这两个插件并存导致的LIMIT重复拼接。解决方案很粗暴选择其一。如果你已经在使用MyBatis-Plus建议直接用它的分页插件如果你用的是原生MyBatis老老实实用PageHelper不要为了“功能更丰富”而强行叠加。6.2 SpringBoot 3.x下的实测兼容方案SpringBoot 3.x下使用PageHelper我用的是这套组合跑了大概三个月没有出过问题组件版本SpringBoot3.2.xMyBatis Spring Boot Starter3.0.3pagehelper-spring-boot-starter2.1.0Java17依赖引入如下dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version2.1.0/version /dependency不需要单独引入mybatis-spring-boot-starter不对如果你的项目是纯MyBatis仍需要引入dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency先后顺序不影响只要不是两个分页插件共存就好。还要提醒一个SpringBoot 3.x特有的坑PageHelper.startPage底层依赖ThreadLocal而SpringBoot 3.x中Async异步方法默认跑在独立线程池中跨线程调用时ThreadLocal分页参数会丢失。如果你的分页查询里有异步调用链请务必在同一线程内完成startPage和Mapper查询否则分页参数在异步线程里读不到查询变成全量。6.3 分页参数传递的规范用什么对象承载很多人在Controller里直接接收pageNum和pageSize两个参数然后传给Service。如果参数少倒还好一旦分页条件多了排序字段、模糊关键字、时间范围参数列表会非常难看。我的实践是定义一个统一的分页请求基类public class PageQuery { private Integer pageNum 1; private Integer pageSize 10; private String orderBy; // getter/setter 省略 }然后业务请求对象继承它Service层统一从PageQuery中取分页参数调用PageHelper.startPage(pageQuery.getPageNum(), pageQuery.getPageSize())。这样Controller和Service之间传递参数清晰也方便未来增加统一的参数校验逻辑比如pageSize最大不超过100。如果需要控制排序注意不要直接把前端传来的字符串拼进ORDER BY否则存在SQL注入风险。建议做一个排序白名单校验只允许传入白名单内的列名和方向比如if (!ALLOWED_SORT_COLUMNS.contains(orderBy)) { throw new IllegalArgumentException(非法的排序字段); }7. 分页性能优化与进一步扩展分页功能跑通只是第一步性能优化才算真正的考验。7.1 COUNT查询的缓存策略PageHelper每次分页查询都会执行一次COUNT如果列表接口调用频繁COUNT查询对数据库的压力会放大。如果业务上对总数实时性要求不高比如“约XX条”可以配置countSuffix减少COUNT频率或者使用Caffeine/Redis把总数缓存几分钟public PageInfoUser getUserPageWithCache(int pageNum, int pageSize) { String cacheKey user:count; Long total cacheManager.get(cacheKey); PageHelper.startPage(pageNum, pageSize); ListUser list userMapper.selectByCondition(正常); PageInfoUser pageInfo new PageInfo(list); if (total ! null) { pageInfo.setTotal(total); } return pageInfo; }这种缓存只适用于总数变更不频繁的业务如果数据每秒都在插入缓存总数会误导“最后一页”的判断需要结合业务场景权衡。7.2 大偏移量场景LIMIT深翻页优化PageHelper生成的是LIMIT offset, size当用户翻到第10000页时offset相当大MySQL需要扫描并丢弃大量行查询会很慢。我对这类场景的处理方式有两条路限制可翻页深度前端分页最多允许翻到第200页超出后提示用户使用筛选条件缩小范围。管理后台的分页组件通常有这个限制。改用游标式分页不以页码为目标而是以“上一页最后一条记录的ID或时间戳”为锚点SQL变成WHERE id ? ORDER BY id DESC LIMIT ?。PageHelper不擅长这种场景需要自己实现。现实中“加载更多”的列表比如订单流、消息流都适合游标式分页。7.3 分页与大数据量导出的屏蔽分页插件最容易误伤的是导出功能。运营后台很常见的情况是同一个查询条件列表页走分页导出走全量。如果导出代码里不小心也调用了PageHelper.startPage导出就会只导出一部分数据而且很难直观发现。我的做法是在导出的Service入口处加一个开关参数明确区分分页和导出两个操作public ListUser queryUserList(UserQuery query, boolean exportMode) { if (!exportMode) { PageHelper.startPage(query.getPageNum(), query.getPageSize()); } return userMapper.selectByCondition(query); }这样代码路径清晰也避免导出时误带分页参数。7.4 PageHelper版本升级策略PageHelper的维护节奏并不快但升级时需要关注几个点5.3.0以上版本对JDK 8以上编译目标做了调整老项目升级时确认JDK版本。页面传pageSize过大时插件内部不会做限制建议在Service层或通过PageQuery做兜底校验。pagehelper-spring-boot-starter和原生pagehelper不要混用依赖传递很容易把jar包重复引入。按照我目前项目的做法优先选择pagehelper-spring-boot-starter2.1.0 SpringBoot 3.x这条组合在近一年的生产环境里没有出现过分页相关的线上故障。回到PageHelper本身这个插件的设计思路给我最大的启发是它用最小的侵入成本解决了业务开发中最枯燥重复的分页逻辑。你只需要一个startPage调用剩下的物理分页、COUNT统计、PageInfo封装全部自动完成。但恰恰因为它“太自动”使用者更需要清楚背后的参数传递、拦截器作用范围和ThreadLocal生命周期否则出了问题往往无从下手。如果让我给出个人经验层面的建议就三条第一startPage和Mapper查询之间保持零SQL调用第二不要在MyBatis-Plus项目里强行叠加PageHelper第三生产环境把reasonable打开日志级别打开分页不生效时先查SQL日志再查代码。做到这三点PageHelper用起来会非常顺。