ARTICLE DETAIL

资讯详情

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

MyBatis-Plus分页插件:原理、配置与性能优化实践

MyBatis-Plus分页插件:原理、配置与性能优化实践 分页这个问题几乎每个做后端开发的人都会碰上。我记得自己刚接触MyBatis-Plus那会儿最直观的感受就是原来分页可以不用手写LIMIT、不用单独维护count语句、不用为切换数据库方言发愁。只要配置一个插件再传一个Page对象剩下的事框架全帮你干了。这篇内容不是简单的API罗列我会把“为什么要用分页插件”“插件底层在做什么”“实际开发中怎么落地”“哪些坑我踩过”这些事从头到尾捋一遍尽量让刚入门的同学能直接照着用也让有经验的同事能查到一些平时容易忽略的细节。1. 为什么需要分页插件先搞懂原生MyBatis的分页之痛1.1 传统手写分页的痛点在MyBatis-Plus普及之前我们自己写分页大致是这么一套流程先在Mapper接口里定义两个方法一个查列表数据一个查总数然后在SQL里手动拼接LIMIT参数再在Service层计算起始偏移量。// 旧版做法接口定义两个方法 User selectUserList(Param(offset) int offset, Param(size) int size); long countUser(Param(keyword) String keyword);这种写法乍一看没什么问题但一到真实业务场景就会很痛苦。第一每个业务模块都要重复写“查询列表”和“查询总数”两个方法代码量翻倍第二两张表关联分页的时候count语句要单独处理稍不留意计数逻辑和列表逻辑就出现了不一致第三如果哪天项目从MySQL换成PostgreSQLLIMIT ? , ? 这种写法又要全部改一遍。这还不算最难受的。更麻烦的是很多老项目的分页逻辑散落在Service层里有人直接用List.subList()内存分页数据量小的时候无所谓一旦表里几十万条数据内存直接被打爆。1.2 MyBatis-Plus分页插件的核心价值MyBatis-Plus提供的PaginationInnerInterceptor本质是一个基于MyBatis拦截器机制实现的SQL增强组件。它做的事情可以简单归纳为三点拦截需要分页的SQL语句、自动生成COUNT语句、自动拼接数据库方言对应的分页语法。这样一来业务代码根本不用关心“怎么分页”这件事。你只需要把“第几页”“每页几条”这两个参数告诉框架剩下的SQL改写全部由插件内部完成。最直观的好处是项目里不再需要维护成堆的count查询也不需要为不同数据库写不同风格的分页SQL。我举个例子下面这段代码就是MyBatis-Plus分页的标准姿势PageUser page new Page(1, 10); LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.eq(User::getStatus, 1); PageUser result userMapper.selectPage(page, wrapper);一行Page构造、一行selectPage调用查询条件和分页参数天然解耦。如果后面需求变更需要增加“按创建时间过滤”的条件只需要在Wrapper上叠加orderByDesc或者ge方法完全不影响原有分页逻辑。1.3 分页插件与其他分页方案的横向对比我在项目里也用过高性能的MyBatis分页插件PageHelper两者思路不同但各有侧重。PageHelper采用的是ThreadLocal参数传递分页参数放在线程上下文中使用时需要小心处理“分页后未清除上下文导致后续查询被莫名分页”的经典坑。MyBatis-Plus则是把分页参数显式地封装在Page对象里作为方法参数传入这种设计让代码更可控不会因为漏掉清理ThreadLocal而出现“串页”问题。从团队协作的角度讲显式的Page参数有利于代码评审。看代码的时候一眼就能判断这个方法是否分页、分页参数是什么而不是像PageHelper那样翻到几层调用栈才能确认当前线程有没有分页上下文。2. 环境准备与插件配置5分钟把分页跑起来2.1 依赖引入与版本选择用MyBatis-Plus分页之前先确认依赖引入是否正确。目前主流选择是mybatis-plus-boot-starter具体版本要根据项目的Spring Boot版本搭配。以Spring Boot 2.x为例通常使用3.5.x版本比较稳妥如果项目用的是Spring Boot 3.x考虑到Jakarta命名空间的变更建议直接上3.5.5以上版本或者跟随官方推荐的最新稳定版。dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency这里有个容易踩的坑只引入starter还不够分页插件不是默认生效的。很多人引完依赖之后直接调selectPage结果发现SQL语句没有被拦截改写查出来的数据只有第一页还以为是自己代码哪里写错了。其实原因很简单——PaginationInnerInterceptor需要手动注册到MybatisPlusInterceptor中。2.2 插件注册配置类MyBatis-Plus 3.4.0之后的版本采用拦截器链机制把所有内置拦截器统一挂载到一个MybatisPlusInterceptor上再用Spring的Bean把它交给容器管理。分页功能的配置类通常长这样Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }我在实际项目里看到过有人把DbType直接省略不传插件会通过JDBC连接自动识别数据库类型这种方式在大部分场景下也能跑通。但我个人建议还是显式指定数据库类型一方面省去自动识别的开销另一方面在多数据源项目中能避免误判。2.3 数据库方言自动适配的细节PaginationInnerInterceptor最贴心的地方在于它内部封装了常见数据库的分页语法转换。MySQL用的是LIMIT offset, sizePostgreSQL和Oracle的用法又不相同SQL Server还有自己的OFFSET ... FETCH NEXT语法。如果项目需要做多数据库支持插件帮我们屏蔽了这些差异。具体实现上Dialect接口定义了一组和分页语法相关的方法每种数据库对应一个实现类。插件在改写SQL时会根据我们配置的DbType找到对应的Dialect实现再拼接分页语句和count语句。这部分逻辑对业务透明但理解之后能帮你解决很多“换数据库之后分页不对”的疑问。3. 基础分页用法剖析Page对象与Wrapper组合实战3.1 Page对象的核心参数与构造方式MyBatis-Plus的Page类提供了好几个构造函数日常开发用得最多的就是new Page(current, size)。current代表当前页码从1开始size代表每页条数。构造完之后还能手动设置orderBy字段或者isSearchCount等属性。// 最常用的构造方式 PageUser page new Page(1, 10); // 进阶参数 page.setSearchCount(false); // 不查询总条数节省一次count page.setMaxLimit(100L); // 限制每页最大条数防止全表扫把searchCount设为false特别适合那种数据量极大、产品上“加载更多”不需要总条数的场景。举个例子比如查询用户操作日志每次只滚动加载最近20条这时候每次查count其实都在白白消耗数据库资源。既然UI层不需要显示“共多少条”关掉count是一种很直接的性能优化手段。3.2 无条件分页与条件分页的写法最基础的分页查询不需要拼接任何查询条件直接调用selectPage即可。但实际业务里分页往往伴随筛选条件。假如现在要做一个用户管理列表支持按用户名模糊搜索、按状态筛选、按创建时间倒序排列可以这样写public PageResultUserVO pageUsers(int current, int size, String name, Integer status) { // 分页参数 PageUser page new Page(current, size); // 查询条件 LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.like(StringUtils.hasText(name), User::getName, name); wrapper.eq(status ! null, User::getStatus, status); wrapper.orderByDesc(User::getCreateTime); // 执行查询 PageUser result userMapper.selectPage(page, wrapper); // 实体转VO ListUserVO voList result.getRecords().stream() .map(user - convertToVO(user)) .collect(Collectors.toList()); PageResultUserVO pageResult new PageResult(); pageResult.setRecords(voList); pageResult.setTotal(result.getTotal()); pageResult.setCurrent(result.getCurrent()); pageResult.setSize(result.getSize()); return pageResult; }这里有几个细节值得拎出来说一说。like方法的第一个参数是布尔值当name为空字符串时这个条件会被自动忽略这种写法能避免业务层写一堆if-else去动态拼条件。selectPage返回的Page对象records里放的是查询结果实体total是满足条件的总条数这两个属性是后续封装返回结构的基础。3.3 返回结果与Page信息的完整解读很多人分页的时候只关注records里的列表数据对Page对象里的其他属性不够上心。其实这些字段在承接前端参数时非常有用。属性名含义典型使用场景records当前页的数据列表直接渲染或转换后返回给前端total满足条件的数据总条数前端分页器显示总页数、总条数current当前页码回传给前端做高亮展示size每页显示条数回传给前端保持分页器状态一致pages总页数由total和size计算得到前端快速判断是否还有下一页这些信息建议统一封装成一个通用的PageResultT返回对象避免把MyBatis-Plus的Page对象直接暴露给Controller层。因为Page类内部还包含orders、optimizeCountSql等其他字段序列化给前端反而容易造成字段冗余和接口结构不稳定。3.4 防止全表查询的防线maxLimitPage.setMaxLimit这个参数很多人不知道但它真的能救命。设想一个场景前端传了个request参数size 999999如果你没做任何限制这条SQL会变成LIMIT 0, 999999一次把全表数据捞回来。严重的时候数据库CPU飙高接口直接超时。maxLimit的作用就是给每页条数加个上限超出上限后插件会自动把size强制压到上限值。我习惯在项目里统一配置为page.setMaxLimit(100L)这样即使前端参数异常数据库也不会被拖垮。4. 进阶玩法自定义SQL分页与多表联查4.1 Mapper接口自定义分页方法内置的selectPage只能基于单表查询一旦涉及多表JOIN或者复杂子查询就不能指望Mapper自带的方法了。好在MyBatis-Plus允许我们在Mapper接口里自定义分页方法只要方法的第一参数是Page对象插件就能自动识别并进行分页拦截。/** * 自定义分页方法查询用户及其部门信息 */ IPageUserDeptVO selectUserDeptPage(PageUserDeptVO page, Param(deptId) Long deptId);接着在对应的XML文件里写SQL注意一个关键点XML里的SQL本身不需要写LIMIT分页插件会在执行前自动帮你拼上分页语句。select idselectUserDeptPage resultTypecom.example.model.vo.UserDeptVO SELECT u.id, u.name, u.status, d.dept_name AS deptName FROM user u LEFT JOIN department d ON u.dept_id d.id where if testdeptId ! null AND u.dept_id #{deptId} /if AND u.deleted 0 /where ORDER BY u.create_time DESC /select调用方式如下分页交互完全透明PageUserDeptVO page new Page(1, 10); IPageUserDeptVO result userMapper.selectUserDeptPage(page, 1001L);4.2 自定义分页SQL注意事项自定义SQL分页有几个很容易踩的坑。第一Page泛型要和resultType对应否则把查询结果映射到错误的类型上轻则字段丢失重则类型转换异常。我见过有人把PageUser传给一个返回UserDeptVO的查询方法最后编译正常但运行结果全是null。第二JOIN查询时count语句的执行效率不可忽视。插件默认会生成SELECT COUNT(*)语句去统计总数但JOIN本身是有开销的如果业务允许优先对主表做count或者用子查询把复杂关联逻辑收敛起来。比如把JOIN结果先当成一个临时视图再count数据库优化器通常能给出更合理的执行计划。第三XML里如果需要排序排序列不要使用用户直接传入的字符串做拼接。就算前端没有页面向用户开放排序功能谁知道哪天有人会从接口参数里注入一个ORDER BY片段排序字段建议白名单校验只允许固定的几个字段进入SQL。4.3 多表联查分页的实战案例拿一个稍微复杂点的场景举例用户表、订单表、订单明细表三张表关联需要按用户维度分页展示下单汇总信息。这时候如果直接用三表JOIN再分页MySQL要先做关联生成中间结果集再做排序和分页数据量上去之后效率会很差。更稳妥的做法是先在子查询里对订单表做聚合和分页再关联用户表补全用户信息SELECT u.id, u.name, t.order_count, t.total_amount FROM ( SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders WHERE order_status 1 GROUP BY user_id ORDER BY total_amount DESC LIMIT ?, ? ) t LEFT JOIN user u ON t.user_id u.id这种“先分页再关联”的模式能避免大表JOIN后再全量排序的问题是分页场景下最值得养成的习惯之一。不过在MyBatis-Plus里写这种SQL注意把分页参数留给框架而不是自己手写LIMIT。4.4 分页和逻辑删除的联动处理MyBatis-Plus默认支持逻辑删除分页查询发的SQL会自动带deleted 0条件。这个机制在单表查询时很稳但自定义SQL里如果写了JOIN关联逻辑删除了的表框架只会对主表自动追加逻辑删除条件副表的deleted条件必须自己在SQL里写清楚。我复盘过不少线上数据异常最后都是联合查询阶段副表把已删除数据带进来了而主表本身筛选得干干净净排查起来非常隐蔽。5. 高频踩坑与排查技巧这里每个坑我都真金白银填过5.1 分页插件不生效SELECT没加LIMIT最经典的问题代码跑通了但SQL执行结果就是全量数据查看日志发现根本没拼接LIMIT。排查思路按以下顺序来检查配置类是否被Spring扫描到Configuration类不要放在Spring Boot启动类扫描不到的子包里确认引入的是3.4.0以上版本早期版本不是通过MybatisPlusInterceptor注册分页插件的检查是否创建了多个MybatisPlusInterceptor实例重复注册会互相覆盖看MyBatis-Plus的SQL日志确认执行时是否经过拦截器链如果项目里引入了多个MyBatis插件注意插件拦截器的执行顺序分页插件尽量靠前。我在一个多模块项目里就碰到过第三种情况。A模块配置了一个拦截器B模块又配置了一个结果B模块的配置覆盖了A的A模块的分页全部失灵。最后把配置统一到common模块才解决。5.2 COUNT语句性能差与优化方案插件自动生成的count语句接近SELECT COUNT(*) FROM ...对大部分查询来说够用。可要是碰上多表JOIN或者带复杂子查询的场景count的执行时间可能比列表查询还慢。插件提供了optimizeCountSql优化选项把它设为true默认就是true会尝试去掉无关的ORDER BY和JOIN生成更轻量的count语句。如果业务场景真的非常敏感可以在Page对象上指定自定义count查询。在你的Mapper里单独写一个count方法然后调用page.setTotal()或者通过专门的支持方式注入自定义count语句。这招适合那些列表查询逻辑复杂、自动生成count不准确的场景。5.3 深翻页慢LIMIT 1000000, 20 如何破局分页翻到很后面的时候数据库要扫描前1000000行再丢弃这个开销很恐怖。业界通用的解法有几种对于网页端管理系统一般限制最大页码不允许用户翻到几十万页之后对于移动端信息流改成基于游标的分页用上次查询结果的最后一条ID作为下一次查询的起始条件。在MyBatis-Plus中实现游标分页也不难重点是把查询条件从偏移量改成主键范围LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.lt(User::getId, lastId) // 向下翻页时带上上次最后一条的ID .orderByDesc(User::getId) .last(LIMIT pageSize);这种方式的潜力在于只要ID上的索引能命中最新的数据区间翻页再深也不会退化成全表扫描。注意游标分页的排序字段必须具有唯一性否则可能出现排序不稳定导致数据漏查或重复。5.4 排序字段注入与类型转换问题分页查询接排序参数时最容易忽略的是SQL注入风险。MyBatis-Plus的orderByAsc/orderByDesc接收的是Lambda字段引用相对安全。但一旦为了解决灵活性开放了字符串排序字段比如page.addOrder(OrderItem(...))就要格外小心字段值的白名单校验。另一个不起眼但实测会出问题的点字段类型转换。用Page查出来的实体字段与数据库字段类型不匹配时比如数据库是DECIMAL实体里写成了Integer分页插件在改写SQL时可能不会报错但结果集映射会出现ClassCastException。排查这类问题先看MyBatis-Plus是否能正确映射该字段再看分页插件是否对ORDER BY之后的字段做了类型改写。5.5 分页与分布式部署的兼容性MyBatis-Plus分页插件完全基于SQL拦截实现不持有本地状态因此天然支持多实例部署。相比ThreadLocal方案不存在“A机器上的分页上下文被B机器读到”的问题。针对这类天然优势我在架构选型时也更倾向于使用MyBatis-Plus的显式分页参数。6. 性能优化与最佳实践分页查询不只是调一个API6.1 大页场景下降级为Keyset分页前文提到的游标分页也就是Keyset分页是应对深翻页最有效的方法之一。它利用索引的有序性每次都从上一个游标位置继续往后扫描复杂度不随翻页深度增长。MyBatis-Plus本身不直接提供官方的Keyset分页组件但我们可以在业务层封装一套自己的工具。具体做法是在查询参数里增加lastId字段第一次进页面时传0后续传上一次列表最后一条记录的ID。Service层构造条件时加上wrapper.lt(User::getId, lastId)并按ID倒序排列再固定只查pageSize1条多出来的那1条用来判断是否还有更多数据。这套方案在我负责的一个流水查询系统里实测从第1页翻到第100000页都不存在性能衰减的问题。6.2 统一封装分页请求与返回结构分页请求参数建议封装成一个统一的PageQuery对象包含current、size、orderBy、orderDirection等字段配合Spring MVC的参数绑定直接接收。为了避免有人乱传每页条数Service层在构造Page对象时统一执行current Math.max(current, 1); size Math.min(size, 100);这样即使用户暴力传参也不会对数据库造成实质伤害。对应的返回结构统一为PageResultT内部包含list、total、current、size、pages前端对接起来非常省心。6.3 分页查询与缓存策略的配合分页查询能否使用缓存得看业务特性。如果列表数据更新频率低、查询性能有瓶颈可以对热点分页结果做缓存。但要注意分页结果缓存的核心是“查询条件页码每页条数排序规则”组成的key任何一个维度变化都会导致缓存命中率下降。缓存更新策略也是个难点。表数据变更后如果采用“更新时主动淘汰所有相关分页缓存”的策略在高频写场景下缓存会被频繁清空。更稳妥的做法是缓存短TTL加上低频写触发全部淘汰让缓存只挡住热点流量。如果要求数据强一致就不要引入缓存分页查询直接走数据库是最简单可靠的方案。6.4 多数据源场景下的分页配置多数据源项目里不同数据库类型可能不一样分页插件的DbType要跟着数据源切换。MyBatis-Plus的路由数据源方案中可以在路由时动态修改分页插件的dialect类型。一个比较省心的做法是配置多个PaginationInnerInterceptor每个拦截器指定一种DbType按照数据源路由规则挂到不同SqlSessionFactory上。还有个容易踩的深坑分页插件会改写SQL语句加上COUNT查询。在主从分离的架构里如果写库压力大建议把分页查询路由到只读从库上避免每次分页都往主库压一个额外的count操作。别小看这个count并发高的时候它足够把主库拖慢。6.5 从性能日志里找出分页瓶颈排查分页性能问题时不要只盯着SQL本身。每次分页其实对应两到三次数据库交互count查询、列表查询所占用的时间需要分别观察。开启MyBatis-Plus的SQL日志分析或者接上慢查询日志系统重点看两个指标count耗时和列表查询耗时。多数情况下列表查询慢是因为排序字段没有索引count慢是因为JOIN了不必要的表。我在项目里建立过一套简单规则凡是列表查询响应超过500ms的接口第一步排查是否存在未走索引大表扫描第二步用EXPLAIN看执行计划第三步分析是否因为分页插件自动生成的count拖了后腿最后再看有没有必要改造成游标分页。经验小结把这些扎实落到项目里我个人在实际项目里最推荐的做法是把分页相关能力沉淀成一个基础组件统一处理Page构造、参数校验、结果封装、异常兜底这些事。这样业务代码只需要关心查询条件和返回结果不再每处都重复写new Page()和PageResult转换的样板代码。把分页插件的跳坑经验沉淀在组件里团队里就算有新同学接手也不会再踩“插件没配置导致全表查询上线”那种低级事故。最后再分享一个小技巧如果你们项目里经常出现“分页返回数据时JSON序列化Date字段格式不对”这种问题建议在PageResult里统一使用自定义的序列化器或者格式化注解把时间格式问题控制在返回层解决不要让每个业务都各自处理。分页这件事看起来简单但做深了全是对细节的把控。配置对了只是第一步真正的分水岭在于对不同数据规模下分页策略的理解。愿你在项目里遇到深翻页的时候想起这篇文章里提到的游标分页方案。
返回列表