ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入解析MyBatis分页插件原理:PageHelper与MyBatis Plus实战

深入解析MyBatis分页插件原理:PageHelper与MyBatis Plus实战 1. 从手写分页到插件接管先聊清楚分页这件事做Java后端的人只要接触过数据库基本都逃不过分页查询。早期用JDBC的时候分页是纯手工活MySQL写LIMIT offset, sizeOracle玩ROWNUMSQL 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 groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.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: countcountSql 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配置countcountSql的意思是当你的SQL里已经包含了COUNT查询插件会优先使用你的COUNT语句否则自动生成一条。auto-runtime-dialect建议开启这个参数能让插件在运行时根据连接URL自动识别数据库方言避免环境迁移比如从MySQL切到PostgreSQL时改了数据源忘记改方言的尴尬。3.3 编写Mapper和Service一个PageInfo搞定所有先看一个最简单但完整的分页查询流程。Mapper接口里不用声明任何分页相关的方法就是一个普通查询public interface UserMapper { ListUser selectUserList(UserQuery query); }Mapper XML照样只写业务查询条件注意不要写LIMITselect idselectUserList resultTypecom.example.entity.User select * from user where if testname ! null and name ! and name like concat(%, #{name}, %) /if if teststatus ! null and status #{status} /if /where order by create_time desc /selectService层的做法是public PageInfoUser pageUser(UserQuery query) { PageHelper.startPage(query.getPageNum(), query.getPageSize()); ListUser 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一定要做上限限制。我见过很多系统没限制每页条数前端传一个pageSize100000直接把数据库打爆。实现方式可以简单粗暴超过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对象里字段很多最常用的是这几个PageInfoUser pageInfo new PageInfo(userList); long total pageInfo.getTotal(); // 总记录数 int pages pageInfo.getPages(); // 总页数 int pageNum pageInfo.getPageNum(); // 当前页码 int pageSize pageInfo.getPageSize(); // 每页条数 ListUser 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 testorderByColumn createTime order by create_time /when when testorderByColumn 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 idselectUserList_COUNT resultTypelong select count(0) from user where if testname ! null and name ! and name like concat(%, #{name}, %) /if /where /selectPageHelper约定如果存在方法名_COUNT的Mapper方法它会优先使用这个方法替代自动生成的COUNT查询。这个约定看似简单大数据量场景下性能差异可能达到几十倍。我优化过一个查询原SQL有四个JOINCOUNT要扫描大量关联数据改成独立COUNT只查主表索引接口响应时间从1.8秒降到0.2秒。5. MyBatis Plus分页与PageHelper的区别到底选哪个5.1 MyBatis Plus分页的核心APIMyBatis 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; } }业务代码里这样用PageUser page new Page(pageNum, pageSize); LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.eq(User::getStatus, 1).orderByDesc(User::getCreateTime); PageUser 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 插件如何改写SQLPageHelper内部对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不跨线程传递子线程内单独执行startPageMySQL分页正常切到Oracle报错方言配置写死开启auto-runtime-dialect多个Mapper方法连续调用第一个正常第二个被分页ThreadLocal残留检查异常分支、排查是否在同一线程复用7.2 嵌套结果查询的分页陷阱如果你的Mapper返回结构是resultMap里配置了collection的嵌套查询就是那种“查订单列表每个订单里再查明细”的写法分页会出现一个非常隐蔽的问题LIMIT作用在主查询上但主查询只有几条订单每个订单的内部集合又查出很多明细最终页面上看到的明细条数可能远大于pageSize。这在功能上其实符合“订单分页”的预期但如果前端预期的是“行分页”两边就对不上了。我的建议是尽量避免这种“嵌套查询分页”的组合。要么改成联表分页查主表然后在Service层聚合明细要么明确告诉前端这里的分页维度是主表记录不是明细行。7.3 动态表名和视图分页的处理有些系统按时间分表比如订单表按月拆分order_202501、order_202502Mapper 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 idselectUserListByPage resultTypecom.example.entity.User select u.* from user u inner join ( select id from user where if testname ! 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重要得多。希望这篇文章能帮你把分页插件用得明明白白少踩几个坑。
返回列表