SpringBoot集成PageHelper分页实战:从原理到踩坑全解析
分页这个需求做过几个后端项目的人应该都不陌生。最早的时候我还在用LIMIT #{offset}, #{pageSize}手动拼接SQL每写一个查询接口就得复制一遍分页逻辑一旦表结构或者查询条件变了改起来想死的心都有。后来换成MyBatis体系中比较常见的PageHelper插件才真正体会到什么叫“分页只需一行代码”。这次就拿SpringBoot项目为例把PageHelper的完整使用流程、核心机制和踩坑经验一次性讲透。内容适合正在用SpringBoot写接口的开发者也适合刚接触MyBatis想提升开发效率的新手朋友。PageHelper是一个基于MyBatis的物理分页插件它能拦截你写的SQL自动在语句后面追加数据库方言对应的分页语法同时帮你生成并执行COUNT查询最终把total、pageNum、pageSize这些分页信息一并塞进PageInfo对象里。说白了你只需要在查询前调用一句PageHelper.startPage(pageNum, pageSize)后面的查询就会自动带上分页能力不需要手改SQL不需要自己再写count语句非常省事。1. 整体设计与思路拆解1.1 为什么是PageHelper而不是手写LIMIT很多人刚接触SpringBoot的时候第一次做分页都是在XML里这样写select idselectUserList resultTypeUser SELECT * FROM user WHERE status 1 ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} /select这么写有什么问题最直观的就是每个查询都要单独接收offset和pageSize两个参数代码里到处都是int offset (pageNum - 1) * pageSize这种计算。更要命的是如果你需要返回总条数还得再写一条SELECT COUNT(*)而且必须保证count查询和列表查询的条件过滤逻辑完全一致否则统计数据就和列表对不上。我见过不少项目因为改动查询条件时只改了列表SQL、忘了同步count SQL导致页面上的“总数”长期是错的很坑。PageHelper恰恰把这些问题都收敛到了插件层。它在MyBatis执行SQL前拦截语句自动生成一个count查询和一个带分页关键字的新SQL并且把分页参数通过内部机制传递进去开发者不用碰任何原生分页语句。对于业务系统来说代码里只剩一行startPage加上原本的查询逻辑开发效率提升明显。1.2 逻辑分页和物理分页的取舍在有分页插件的方案流行之前很多人还用过逻辑分页先把全表数据一次性查出来放在内存里再从List中截取某一页的数据。这种方案对于几千条数据的小表没什么感觉但是数据量一上五万、十万性能和内存占用就彻底不行了。PageHelper采用的是物理分页它的本质是在数据库端完成行数裁剪传输到应用层的数据只有一页不会因为页码增大而让内存无限膨胀。我做过的项目中单表数据量上百万之后逻辑分页基本是不可用的状态即便用物理分页也要注意count语句的开销。PageHelper本身提供了一些补偿手段比如关闭count、用PageHelper.startPage(pageNum, pageSize, false)跳过总数统计这在超大表或者非前端分页场景下很有意义。理解了这一点你就知道PageHelper并不只是“省事”它是从架构层面解决了分页性能问题。1.3 SpringBoot中选型的关键考虑点PageHelper在SpringBoot里的接入路径主要有两条一条是用mybatis-spring-boot-starter配合pagehelper-spring-boot-starter自动配置另一条是手动创建PageInterceptor并加到SqlSessionFactory上。前者属于开箱即用的方式缺点是自动配置有时候会和你自定义的MyBatis拦截器叠加导致职责混乱后者更加可控适合已经对MyBatis做了较多自定义配置的项目。从实战角度看我建议大多数SpringBoot项目直接使用pagehelper-spring-boot-starter因为它无需关心拦截器注册顺序还自带了数据库方言的自动识别。如果你的项目用的是多数据源并且每个数据源的方言不同那就要谨慎一些后面第3节我会专门说多数据源下怎么配置才不会报错。2. 核心细节解析与实操要点2.1 依赖引入的版本选择PageHelper的Maven坐标有两个别弄混了。一个是传统的com.github.pagehelper:pagehelper它只是MyBatis插件本身需要你手动注册另一个是com.github.pagehelper:pagehelper-spring-boot-starter它在SpringBoot环境下自动完成配置日常开发直接引这个就行。dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency如果项目的Spring Boot版本是2.x1.4.7这个版本兼容性比较好。如果你的SpringBoot直接上了3.x甚至更新的大版本那就要注意检查starter的依赖是否匹配因为SpringBoot 3基于Jakarta EE规范MyBatis的自动配置类发生了变化PageHelper的旧版starter很可能失效。我实际踩过这个坑某个新项目直接用了SpringBoot 3.2引入旧版starter后startPage完全不起作用排查了半天发现是自动配置没加载换成匹配新版MyBatis的PageHelper版本后才正常。2.2 配置文件准备如果你使用starter方式大部分配置都不用写。不过为了让插件行为符合项目预期我习惯在application.yml里显式配置一下pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: countcountSql逐项解释一下这几个参数的意义。helper-dialect指定数据库方言明确告诉插件要生成哪种SQL语法这里写mysql插件就会在SQL末尾追加LIMIT ?。如果你不写插件会尝试自动检测数据库类型但在多数据源或者某些代理数据库场景下自动检测并不可靠强烈建议手动指定。reasonable是一个容易被忽略但很实用的功能。开启后当你请求页码小于1时会自动查询第一页当页码超过总页数时会查询最后一页。这个特性在接口对外开放时特别有用不会因为前端传了一个超界页码就直接查出一堆空数据。2.3 PageInfo返回结构的理解很多新手第一次用PageHelper都会疑惑Page对象和PageInfo对象有什么区别。简单说PageHelper.startPage()返回的Page对象是ArrayList的子类里面额外记录了pageNum、pageSize、total等分页属性而startPage则是通过ThreadLocal给紧接着执行的SQL注入分页参数的起点。再看看实际开发中怎么获取这些信息。如果你直接返回Page给前端字段是可用的但不够规范因为Page继承了List序列化时除了分页属性还有一堆数组相关的结构对前端不够友好。所以项目里一般都会再包装一层PageInfo把分页信息和数据列表封装成一个清晰的实体返回。PageInfoUserVO pageInfo new PageInfo(userList);PageInfo里有这些关键字段total为总记录数pageNum为当前页码pageSize为每页条数pages为总页数list为当前页数据。如果项目有统一返回体就把PageInfo放到data字段里GetMapping(/list) public ResultPageInfoUserVO list(RequestParam(defaultValue 1) int pageNum, RequestParam(defaultValue 10) int pageSize) { PageHelper.startPage(pageNum, pageSize); ListUserVO userList userMapper.selectUserList(); PageInfoUserVO pageInfo new PageInfo(userList); return Result.success(pageInfo); }注意new PageInfo需要紧跟在查询之后创建因为在调用PageInfo构造函数时它内部会从当前线程的分页上下文中获取分页信息并初始化字段。如果你在中间做了其他数据库查询或者异步操作拿到的分页参数很可能已经不是你想要的那个了。2.4 实现RowBounds与PageHelper的对比有些老项目用的是MyBatis自带的分页方式即在Mapper方法上声明RowBounds参数MyBatis也支持物理分页和内存分页两种模式但配置相对复杂使用上不够简洁。PageHelper在底层其实可以看作是RowBounds能力的高级封装它把分页参数从方法签名里解放出来又比RowBounds多了count查询和多种分页参数的自动装配能力。对于新项目来说直接采用PageHelper是更符合“约定优于配置”思想的做法。3. 实操过程与核心环节实现3.1 从零到一的完整接入流程先快速演示一遍让没接触过的人能照着做通。第一步创建SpringBoot项目引入基础依赖。dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency第二步准备数据源相关配置写一个简单的Mapper。这里以用户表为例查询逻辑放在XML里。Mapper public interface UserMapper { ListUser selectUserList(); }select idselectUserList resultTypecom.example.demo.entity.User SELECT id, username, email, create_time FROM user WHERE status 1 ORDER BY create_time DESC /select这里有个很重要的细节SQL里绝对不要自己写LIMIT也不要用${}拼接页码分页语句完全交给插件拦截生成。如果SQL里已经带了LIMIT拦截器在追加新关键字的时候会冲突甚至直接抛出语法错误。第三步在Service层调用分页方法。public PageInfoUserVO getUserList(int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); ListUser users userMapper.selectUserList(); return new PageInfo(users); }到这一步一个基础分页接口就通了。前端传pageNum和pageSize接口返回total、pages、list等结构化数据。3.2 动态SQL与分页的顺序问题分页最常见的坑之一是出现在动态SQL和startPage的组合上。很多人以为startPage之后执行的第一个查询就会自动分页于是自然而然地把startPage写在方法开头看似没问题但如果查询条件是通过if动态拼接的那你要小心SQL中是否有子查询或者多语句执行。我举个实际场景。列表查询里如果有子查询、或者需要先查询一个中间结果集再查主表那执行时就可能出现两条SQLstartPage的分页参数会应用到第一条SQL上而不是你想要的主查询SQL。举例说明在selectUserList里第一个查询是SELECT id FROM user WHERE type IN (...)第二个查询是SELECT * FROM user_detail WHERE user_id IN (...)PageHelper默认只会拦截第一条分页效果就错位了。解决方案有两个方向。第一个方向是拆分SQL把子查询放到Mapper的XML中当作关联条件而不是独立SQL第二个方向是用PageHelper.startPage前先确保不被其他查询干扰或者对自己编写的SQL做限流保证分页参数只作用于真正要分页的那个查询语句。这里有一个非常实用的技巧在MyBatis的Mapper XML中如果一段SQL里包含多条语句可以用PageHelper.startPage后立刻执行查询避免在调用方法之前有任何其他查询动作。比如PageHelper.startPage(pageNum, pageSize); // 这里必须立刻执行目标查询 ListUser list userMapper.selectUserList();中间不要穿插其他Mapper查询否则分页参数会被消耗掉。3.3 分页参数与前端交互的规范处理前端传参通常是pageNum从1开始pageSize自定义。reasonable开启后浏览器传pageNum0或pageNum-1都不会导致SQL异常PageHelper会自动修正。后端返回结构建议统一至少包含total、pages、pageNum、pageSize、list这几个字段。我在项目里用到的统一返回格式public class PageResultT { private long total; private int pageNum; private int pageSize; private int pages; private ListT list; }这样的好处是前端只需要封装一个分页组件就能在多个列表中复用避免每个接口字段命名不一致带来的联调成本。如果你做的是前后端分离项目前端用的是El-Table或Ant Design Table它们的分页组件默认要求current和pageSize这两个字段搭配total就能自动渲染分页器。把后端的PageInfo包装成PageResult时可以顺手转换字段名让接口更贴合前端需要。3.4 常见业务场景下的完整示例拿一个实际需求举例一个图书管理后台要按照分类、书名关键字、上下架状态三个条件筛选图书列表并进行分页展示。先看Mapper的XML设计select idselectBookPage resultTypeBookVO SELECT b.id, b.book_name, b.category_id, b.status, b.create_time, c.category_name FROM book b LEFT JOIN category c ON b.category_id c.id where if testcategoryId ! null and categoryId ! AND b.category_id #{categoryId} /if if testbookName ! null and bookName ! AND b.book_name LIKE CONCAT(%, #{bookName}, %) /if if teststatus ! null AND b.status #{status} /if /where ORDER BY b.create_time DESC /selectService层public PageResultBookVO queryBookPage(BookQuery query) { PageHelper.startPage(query.getPageNum(), query.getPageSize()); ListBookVO list bookMapper.selectBookPage(query); return new PageResult(new PageInfo(list)); }Controller层GetMapping(/api/book/page) public ResultPageResultBookVO page(BookQuery query) { return Result.success(bookService.queryBookPage(query)); }这个场景覆盖了动态条件拼接、表关联查询、分页查询和统一封装。实际项目里只要把BookQuery换成自己的查询对象结构可以直接复用。3.5 多数据源和国产数据库方言的处理近期有不少项目开始使用国产数据库比如达梦、人大金仓这类兼容Oracle语法的数据库。这时候PageHelper的方言配置就尤为重要。如果你依然配置helper-dialect: mysql后端会生成LIMIT ? OFFSET ?语法而在Oracle系数据库中分页需要借助ROWNUMSQL直接就会报错。解决思路是在启动类或配置类中为不同数据源指定不同的PageInterceptor。因为PageHelper的方言是绑定在拦截器上的一个拦截器只能处理一种方言。如果你的项目是主从库或者多套业务库且它们分属不同类型的数据库那么你就得创建多个PageInterceptor分别注册到对应的SqlSessionFactory上。对于大多数中小项目其实也没必要搞那么复杂。如果全部业务库用的都是MySQL或PostgreSQL方言保持一致那直接用一个拦截器就行。像我们之前有一个项目MySQL存业务数据点击量统计的报表库用PostgreSQL两个库的分页语法完全不同。当时我图省事只注册了一个拦截器结果连接报表库执行查询时SQL语法直接报错。后来改造了配置按数据源拆分了拦截器问题才彻底解决。多数据源下的PageInterceptor配置思路如下Configuration public class MybatisConfig { Bean public PageInterceptor mysqlPageInterceptor() { PageInterceptor interceptor new PageInterceptor(); Properties properties new Properties(); properties.setProperty(helperDialect, mysql); properties.setProperty(reasonable, true); interceptor.setProperties(properties); return interceptor; } Bean public PageInterceptor postgresPageInterceptor() { PageInterceptor interceptor new PageInterceptor(); Properties properties new Properties(); properties.setProperty(helperDialect, postgresql); properties.setProperty(reasonable, true); interceptor.setProperties(properties); return interceptor; } }然后在构建SqlSessionFactoryBean时分别把不同的拦截器添加进对应的Configuration中即可。4. 常见问题与排查技巧实录4.1 startPage之后没有分页效果这个问题太经典了几乎每个用PageHelper的人都遇到过。出现这种情况十有八九是startPage之后执行的第一个数据库操作不是列表查询或者startPage所在的线程和查询线程不一致。我排查过的最典型场景在Service层调用了PageHelper.startPage然后先执行了一个selectCount用于判断权限或校验接着再执行真正的列表查询。因为startPage只能作用于紧接着的一条查询语句count查询把它“吃掉”了列表查询自然就是全量数据。解决办法很简单把startPage调整到列表查询前最后一行或者用PageHelper.startPage(pageNum, pageSize, true)方式显式控制。还有一个隐蔽场景是异步线程。如果你在主线程里调了startPage然后通过线程池去执行查询分页参数是拿不到的。因为startPage的参数保存在主线程的ThreadLocal里子线程没有任何关联。如果你确实需要异步查询分页需要把分页参数一起传递到子线程内部再在子线程中重新调用startPage。4.2 COUNT查询性能瓶颈当单表数据量大、查询条件复杂时PageHelper自动生成的count查询往往是性能瓶颈。它的实现是包一层SELECT COUNT(0) FROM (原SQL) tmp_count原SQL里的关联表、子查询、大字段都会被带着跑一遍性能自然好不了。针对这个问题我推荐两种做法。第一种是写专门的count查询利用SelectProvider或XML中单独提供count语句覆盖默认的包一层逻辑。第二种是在不需要总条数的场景下直接关掉count比如APP列表下拉加载更多本身不需要显示“共多少页”。PageHelper.startPage(pageNum, pageSize, false); // 第三个参数false表示不执行count查询这种方式速度快很多配合“上拉加载更多”这种只按页码追加数据的模式体验反而更好。4.3 页码超大或为负数导致的脏数据如果你记得配置里加reasonable: truePageHelper会自动把pageNum0修正为第一页把超出最大页数的页码修正为最后一页。但如果你没有开这个配置PageHelper会直接按原始参数计算offset比如pageNum-2算出来的offset就是负数SQL的LIMIT -10, 10MySQL会直接报语法错误。前端传参不可控的场景很多所以我在所有对外接口中都会开启reasonable同时在Controller层再做一个基本校验双保险。不要指望所有前端同学都严谨地把页码处理干净后端防护才是底线。4.4 PageInfo序列化后字段丢失有些项目里直接把PageInfo返回给前端但前端反馈拿不到total和pages这多半是因为JSON序列化配置有问题。比如Jackson在没有getter方法时默认不会序列化字段或者字段名被改了。我建议不要再返回原始PageInfo而是封装成自己的PageResult把需要对外暴露的字段复制出来既安全又稳定。如果你使用的是Fastjson或者Gson要注意它们对Page这类继承了List的对象处理逻辑不同。Fastjson在序列化时可能把total、list等字段放在一个奇怪的层级里前端解析时容易踩坑。封装一层什么问题都绕开了。4.5 多表关联查询中分页计数不准多表关联查询中如果关联字段存在重复比如一对多关系COUNT结果自然也和列表结果对不上。这种属于业务语义问题插件没法替你判断。常见的处理方式是使用嵌套结果映射或者子查询来收敛数据重复性而不是依赖简单JOIN。我举个例子。一个订单主表关联订单明细表如果订单明细有多条JOIN之后主记录会重复出现同一个订单可能显示两遍。这时候分页的total统计的是JOIN后的行数而不是实际订单数。解决思路通常是先对明细表做聚合或者用DISTINCT确保主表记录的唯一性。查询上要下功夫不要指望一个插件把所有业务坑都填平。5. 配置参考与避坑清单速查聊聊我在项目中沉淀出来的最终配置和注意事项直接抄作业即可。pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: countcountSql配置项速查表配置项默认值作用我的建议helper-dialect自动检测指定数据库方言务必手动指定避免多数据源出错reasonablefalse页码越界时自动回退建议开启support-methods-argumentsfalse支持从Mapper方法参数自动获取分页参数想偷懒可以开但不建议过度依赖params空参数映射保持默认即可auto-runtime-dialectfalse运行时自动切换方言多数据源时配合专用拦截器更稳定几个额外经验PageHelper的startPage一定要和查询方法放在同一个事务或同一个方法内不要跨方法传递。XML里的SQL不要写LIMIT、OFFSET、ROWNUM这些交给插件。返回给前端时统一封装PageResult不要直接甩PageInfo。分页查询尽量不要SELECT *指定需要的字段减少网络传输和内存压力。还有一个细节值得一提就是support-methods-arguments参数。开启了它之后你可以直接在Mapper接口方法里定义pageNum和pageSize参数PageHelper会自动识别并分页不需要显式调用startPage。这种方式确实简便但会让分页参数隐式地穿过多个调用层代码可读性反而下降。我一般不用这个特性因为一行startPage放在Service里逻辑一目了然。再提一个关于“前端分页参数不能乱传”的问题。如果你的接口是给公司内部系统用可以约定pageNum从1开始如果是给外部开放建议在网关层或Controller层做一次参数合法性校验防止有人传pageSize10000直接拖垮数据库。我见过有同事接口没限流被调用方一个请求把全表查出来数据库CPU瞬间打满的案例。PageHelper对排序的处理也是一个容易忽略的点。分页插件本身不支持排序参数的自动注入排序字段和排序方向通常都要通过ORDER BY拼接实现。如果排序字段是后台配置的注意要用白名单校验不要直接把前端传来的字符串拼进SQL防止引入注入风险。其他容易踩的坑多个SqlSessionFactory时记得每个都要注册PageInterceptor否则对应的数据源分页不生效。事务代理开启时分页参数ThreadLocal的清理一般是自动完成的但如果查询异常ThreadLocal中的参数未必能及时释放长期运行会累积脏数据。稳妥起见可以在查询方法的finally块中调用PageHelper.clearPage()进行清理尤其是的线程池场景下。PageHelper与MyBatis二级缓存同时使用时分页查询不要乱开缓存因为缓存的是全量数据集分页后再缓存会产生错乱。这些坑不一定每个项目都会遇到但一旦遇到排查成本往往不低。提前了解比事后急救要舒服得多。最后分享一点个人习惯每季度或者每次依赖升级后我都会用一个小项目专门跑一遍分页插件下的五种典型场景分别是单表分页、多条件动态SQL分页、关联查询分页、无count分页、多数据源分页。这套回归测试跑通我才能放心地把PageHelper留在生产项目里。一个插件用得好确实能给日常开发节省大量时间但前提是摸清了它的脾性和边界。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →