行业资讯

MyBatis-Plus分页插件实战:从配置到性能优化全解析

发布时间:2026/8/26 4:07:34
MyBatis-Plus分页插件实战:从配置到性能优化全解析 1. 从单页500条限制说起为什么需要MyBatis-Plus分页插件如果你用过原生的MyBatis或者更早的JDBC肯定对分页查询的“痛”深有体会。最原始的写法你得在SQL里手动拼上LIMIT ?, ?每次查询都得算偏移量还得写一个查询总数的SQL。这还不算完一旦业务复杂涉及多表关联那个查询总数的SQL性能就可能直线下降。更别提很多新手容易踩的坑LIMIT在大偏移量下的性能问题或者手滑把offset和limit参数传反了。我接手过一个老项目里面充斥着这样的代码SELECT * FROM user LIMIT 0, 500SELECT * FROM order LIMIT 500, 500。当时没觉得有什么直到数据量涨到百万级一个简单的翻到第100页的查询LIMIT 50000, 500慢得让人怀疑人生。这就是典型的“单页500条限制”思维下的产物——不是业务只需要500条而是开发者图省事或者根本没想到数据量会这么大用了一个固定的分页值。这种硬编码的方式极其不灵活前端想调整每页大小改代码重新部署吧。想优化深分页几乎无从下手。MyBatis-Plus简称MP的分页插件就是为了把开发者从这种繁琐和潜在的性能陷阱中解放出来的。它不是一个简单的语法糖而是一套声明式的分页解决方案。你只需要告诉MP“我要分页”并传入一个分页对象包含了当前页码、每页条数等它就能在背后自动为你完成在原SQL上智能地拼接上数据库方言对应的分页语句如MySQL的LIMITOracle的ROWNUM。自动执行一条优化后的COUNT查询获取总记录数用于计算总页数。将分页数据和查询结果封装成一个标准的Page对象返回里面包含了数据列表、总记录数、总页数、当前页等所有分页信息。这样一来你的Service层代码会变得非常清晰// 创建一个分页对象查询第2页每页10条 PageUser page new Page(2, 10); // 执行分页查询 PageUser result userMapper.selectPage(page, queryWrapper); // 从result中可以直接拿到数据列表、总数等信息 ListUser records result.getRecords(); long total result.getTotal();你再也不用关心SQL怎么写COUNT怎么优化不同数据库的语法差异。这才是现代Java后端开发该有的效率。下面我就结合最新的Spring Boot 3环境带你从零开始把MP分页插件配置明白、用透彻并分享一些实战中积累的“血泪”经验。2. 环境准备与核心依赖配置工欲善其事必先利其器。配置是使用任何插件的第一步也是最容易出岔子的一步。很多人照着教程配完了一跑发现分页不生效多半是配置环节出了问题。2.1 依赖引入别小看版本兼容性首先确保你的pom.xml里已经引入了MyBatis-Plus的Spring Boot Starter。现在MP已经很好地支持了Spring Boot 3所以版本要选对。我强烈建议使用3.5.0及以上的版本这个版本之后的兼容性和功能都更稳定。dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.7/version !-- 以当前最新稳定版为例 -- /dependency !-- 数据库驱动以MySQL 8为例 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency注意这里有个大坑如果你项目里同时有MyBatismybatis-spring-boot-starter和MyBatis-Plus的依赖务必移除MyBatis的原生Starter。MP的starter已经包含了所有必要的MyBatis依赖两者共存会导致配置冲突分页插件可能无法正常注册。我就曾因为没注意这个排查了半天为什么拦截器没生效。2.2 分页插件配置两种主流方式MP分页插件的核心是一个叫做PaginationInnerInterceptor的拦截器。你需要将它配置到MyBatis的插件链中。在Spring Boot环境下通常有两种方式使用Configuration配置类或者直接通过application.yml配置高版本MP支持。我推荐第一种因为它更灵活、更直观。方式一Java配置类推荐创建一个配置类例如MybatisPlusConfigimport com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class MybatisPlusConfig { /** * 添加Mybatis-Plus插件链。 * 注意此处的Interceptor是MP的核心拦截器用于装载各种内部插件InnerInterceptor。 */ Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 1. 添加分页插件 PaginationInnerInterceptor paginationInnerInterceptor new PaginationInnerInterceptor(); // 设置数据库类型主要用于分页方言MP会自动识别但建议显式设置 paginationInnerInterceptor.setDbType(DbType.MYSQL); // 2. 设置请求的页面大于最大页后操作true调回到首页false继续请求。默认false paginationInnerInterceptor.setOverflow(false); // 3. 设置最大单页限制数量默认500条-1表示不受限制。 // 这里就是解决“单页500条限制”的关键配置你可以根据业务需要调整。 paginationInnerInterceptor.setMaxLimit(1000L); interceptor.addInnerInterceptor(paginationInnerInterceptor); // 未来还可以在这里添加其他插件比如乐观锁插件、动态表名插件等 // interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }关键配置项解读setDbType(DbType.MYSQL)虽然MP能根据连接URL自动检测数据库类型但显式设置可以避免一些极端情况下的识别错误。如果你的项目要支持多数据库如同时连接MySQL和PostgreSQL这个配置需要更复杂的处理。setOverflow(false)这个配置非常实用。假设总共有10页数据用户请求了第11页。如果设置为trueMP会自动将查询重置为第1页如果为false则返回一个空的数据列表。具体用哪种取决于你的产品逻辑。我一般设为false让前端根据空列表自己处理。setMaxLimit(1000L)这是打破默认500条限制的地方MP为了防止有人误操作或者恶意请求导致limit值过大比如limit 100000拖垮数据库默认设置了单页最多查询500条。你可以通过这个参数修改上限。如果设置为-1则完全取消限制但强烈不建议在生产环境这样做必须对前端传入的size参数做校验。方式二YAML配置MP 3.4对于简单的项目你也可以在application.yml中配置mybatis-plus: configuration: # ... 其他mybatis原生配置 global-config: db-config: # ... 全局数据库配置 # 插件配置 interceptor: inner-interceptor: - com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptorYAML方式更简洁但自定义能力较弱比如你无法方便地设置maxLimit和overflow属性。对于有定制化需求的场景还是Java配置类更强大。3. 基础使用从Service到Controller的完整链路配置好插件我们就可以在代码里愉快地使用分页了。MP的分页API设计得非常简洁主要围绕Page这个类展开。3.1 核心对象PagePageT既是分页参数的载体也是分页查询结果的容器。在查询前你构造它来传递分页信息查询后MP会把数据填充进去。构造方法// 最常用的构造器当前页每页大小 PageUser page new Page(1, 10); // 可以附加一些额外参数例如是否进行count查询 PageUser page new Page(1, 10, true); // 第三个参数为true表示执行count查询 PageUser page new Page(1, 10, false); // 为false则不查询总数适用于仅知道有下一页的场景如手机端上拉加载更多重要属性records:ListT类型当前页的数据列表。total:long类型总记录数在selectPage方法调用后被填充。size:long类型每页显示条数。current:long类型当前页码。pages:long类型总页数由total和size计算得出。3.2 Service/Mapper层执行分页查询执行分页查询的核心方法是selectPage。它可以在BaseMapper接口中直接使用通常我们在Service层调用。示例在UserService中查询用户列表Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { Override public PageUser getUserPage(Integer pageNum, Integer pageSize, String username) { // 1. 构建分页对象 PageUser page new Page(pageNum, pageSize); // 2. 构建查询条件使用QueryWrapper QueryWrapperUser queryWrapper new QueryWrapper(); if (StringUtils.hasText(username)) { queryWrapper.like(username, username); } queryWrapper.orderByDesc(create_time); // 3. 执行分页查询 return baseMapper.selectPage(page, queryWrapper); // 等价于 this.page(page, queryWrapper); } }这里baseMapper.selectPage(page, queryWrapper)就是MP提供的分页查询方法。它会自动将page对象和queryWrapper条件组合生成带分页的SQL并执行count查询。3.3 Controller层接收参数与返回结果Controller层负责接收前端的请求参数并返回统一格式的分页数据。RestController RequestMapping(/api/user) public class UserController { Autowired private UserService userService; GetMapping(/page) public ResultPageUser getUserPage( RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, RequestParam(required false) String username) { PageUser page userService.getUserPage(pageNum, pageSize, username); return Result.success(page); } }这里我返回了MP原生的Page对象。在实际项目中你可能会有一个全局的Result封装类。Page对象本身已经包含了前端分页组件所需的所有数据records,total,pages,current,size直接返回它非常方便。3.4 进阶使用自定义分页查询与VO转换很多时候我们查询的不仅仅是单表而是多表关联的复杂结果并且需要将DO数据库实体转换成VO视图对象再返回给前端。MP同样支持。场景查询用户信息及其订单数量假设有User表和Order表我们需要分页查询用户列表并附带每个用户的订单总数。1. 定义VOData public class UserWithOrderCountVO { private Long userId; private String username; private String email; private Integer orderCount; // 订单数量 }2. 在Mapper.xml中编写自定义SQL!-- UserMapper.xml -- select idselectUserPageWithOrderCount resultTypecom.example.vo.UserWithOrderCountVO SELECT u.id as userId, u.username, u.email, COUNT(o.id) as orderCount FROM user u LEFT JOIN order o ON u.id o.user_id ${ew.customSqlSegment} !-- 这是关键用于接入QueryWrapper的条件 -- GROUP BY u.id /select注意${ew.customSqlSegment}这个写法它允许我们在Java代码中通过QueryWrapper动态添加WHERE条件而无需在XML里写死。3. 在Mapper接口中定义方法public interface UserMapper extends BaseMapperUser { // 使用IPage接收分页参数和结果 IPageUserWithOrderCountVO selectUserPageWithOrderCount(IPageUserWithOrderCountVO page, Param(Constants.WRAPPER) QueryWrapperUserWithOrderCountVO wrapper); }这里使用了IPage接口它是Page的父接口更通用。4. 在Service中调用Override public IPageUserWithOrderCountVO getUserPageWithOrderCount(Integer pageNum, Integer pageSize, String username) { PageUserWithOrderCountVO page new Page(pageNum, pageSize); QueryWrapperUserWithOrderCountVO queryWrapper new QueryWrapper(); if (StringUtils.hasText(username)) { queryWrapper.like(u.username, username); // 注意表别名 } // 调用自定义的Mapper方法 return userMapper.selectUserPageWithOrderCount(page, queryWrapper); }通过这种方式我们实现了复杂SQL的分页查询并且依然能享受MP分页插件自动计算总数、拼接分页语句的便利。查询结果直接就是VO对象无需在Service层再做转换。4. 性能优化与深度分页陷阱分页用起来简单但用不好就是性能杀手。其中最著名的就是“深度分页”问题。4.1 理解深度分页为什么慢当我们执行SELECT * FROM table LIMIT 100000, 20时数据库引擎需要先扫描并跳过前100000条记录然后才取出接下来的20条。即使你用了索引这个“跳过”的过程OFFSET成本也非常高因为数据库需要定位到偏移量的位置。数据量越大偏移量越大查询就越慢。MP的分页插件只是帮你自动拼接了LIMIT offset, size它无法改变数据库执行这个语句的固有成本。所以当你发现查询第1000页很慢时问题不在MP而在SQL本身。4.2 优化方案一基于主键或有序索引的“上一页/下一页”查询这是应对深度分页最有效的方案尤其适用于无限滚动或只有“上一页/下一页”按钮的场景。思路是不使用OFFSET而是记录上一页最后一条记录的ID或排序字段值查询时直接从这个ID之后开始取。实现步骤确保你的查询结果有一个连续且唯一的排序依据通常就是自增主键id或者是create_time需确保时间不重复或结合ID排序。前端除了传递pageSize还要传递lastId上一页最后一条记录的ID。后端使用WHERE id lastId ORDER BY id ASC LIMIT pageSize进行查询。MP中如何实现你可以继续使用QueryWrapper但分页逻辑需要自己控制public ListUser getUsersByLastId(Long lastId, Integer pageSize) { QueryWrapperUser wrapper new QueryWrapper(); wrapper.gt(id, lastId) // id lastId .orderByAsc(id) // 按id正序 .last(LIMIT pageSize); // 手动拼接limit注意防SQL注入 return userMapper.selectList(wrapper); }注意.last(“LIMIT”)方法会直接拼接SQL字符串有SQL注入风险。如果pageSize来自前端请求必须进行严格的校验或使用预编译参数。更安全的做法是使用Page对象但设置page.setSearchCount(false)不查总数然后利用wrapper的gt和orderBy条件MP生成的SQL依然是LIMIT ?, ?但OFFSET为0。不过这样就需要计算lastId对应的偏移量失去了“游标”查询的意义。因此对于这种优化模式往往需要脱离MP的Page对象直接写SQL或使用last方法在参数安全的前提下。这种方案的缺点是无法直接跳转到任意页码比如从第1页直接跳到第50页因为你需要知道第49页最后一条记录的ID。它适合顺序浏览的场景。4.3 优化方案二子查询优化对于需要跳转到任意页码的场景可以尝试用子查询先定位到offset的位置减少扫描的行数。SELECT * FROM table WHERE id (SELECT id FROM table ORDER BY id LIMIT 100000, 1) LIMIT 20;这个查询先快速定位到第100000条记录的id利用了索引然后再从这个id开始取20条。性能比直接LIMIT 100000, 20好很多。在MP中你可以通过自定义SQL来实现这个逻辑。4.4 优化方案三禁止过大的页码请求这是最直接有效的“保底”策略。在产品层面很少有用户会真的翻到第1000页去查看数据。我们可以在后端对请求的页码和每页大小做强制限制。// 在Service方法入口处校验 public PageUser getUserPage(Integer pageNum, Integer pageSize) { // 限制最大页码 if (pageNum 100) { pageNum 100; // 或者直接抛出业务异常 } // 限制每页大小呼应分页插件中的maxLimit配置 if (pageSize 1000) { pageSize 1000; } PageUser page new Page(pageNum, pageSize); // ... 后续查询 }同时结合分页插件配置的setMaxLimit(1000L)形成双保险。4.5 COUNT查询优化MP分页插件默认会执行一条COUNT(*)语句来获取总数。在单表简单查询时这很快。但在多表关联或条件复杂时COUNT可能会很慢。优化建议缓存总数对于数据更新不频繁的表可以将总记录数缓存起来如放入Redis定期更新。分页查询时直接从缓存获取total。不查询总数对于“加载更多”的场景可以设置Page的第三个参数为falsenew Page(pageNum, pageSize, false)这样MP就不会执行COUNT查询能显著提升性能。前端只需要判断返回的数据列表是否小于pageSize如果小于则说明没有更多数据了。覆盖COUNT查询在极少数复杂场景下你可以通过XML自定义一个高效的COUNT查询SQLMP会优先使用你定义的。但这需要较高的SQL优化能力。5. 实战避坑指南与高级技巧配置和使用都跑通了但在真实项目里还是会遇到一些稀奇古怪的问题。下面是我总结的几个常见坑点和应对技巧。5.1 坑点一分页失效查询出了全部数据现象代码里明明用了Page对象和selectPage方法但执行的SQL却没有LIMIT查回了所有数据。排查检查插件配置是否生效在应用启动时查看日志是否有MybatisPlusInterceptor相关的初始化信息。或者直接在你的配置类MybatisPlusConfig的Bean方法里打个断点。检查Mapper方法签名确保你调用的是selectPage(Page page, Param(Constants.WRAPPER) Wrapper queryWrapper)这个方法。如果你自定义了一个方法比如selectListPage但这个方法在XML里没有正确处理分页参数分页就会失效。自定义分页方法必须将Page或IPage对象作为第一个参数。检查是否有多数据源或动态数据源在多数据源配置下分页插件可能没有正确添加到某个特定的SqlSessionFactory中。你需要确保每个SqlSessionFactory都配置了插件。5.2 坑点二total总数不对或者为0现象分页查询能正常返回当前页数据但page.getTotal()总是0或者一个错误的值。原因这几乎总是因为COUNT查询的SQL出了问题。排查开启MP的SQL日志在application.yml中设置mybatis-plus.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl。查看控制台打印的两条SQL一条是分页的数据查询SQL另一条是COUNT查询SQL。分析COUNTSQLMP会自动将你的原查询SQL改写成COUNT语句。如果原SQL非常复杂包含GROUP BY、多个JOIN或者子查询自动生成的COUNT语句可能会出错比如把GROUP BY去掉了但字段还在导致语法错误或者性能极差。解决方案简单场景确保你的QueryWrapper没有使用groupBy方法。如果使用了MP生成的COUNT语句可能不准确。复杂场景对于包含GROUP BY的复杂分页查询不要依赖MP的自动COUNT。你应该使用我们在3.4节介绍的方法自定义分页查询方法在XML里分别编写数据查询SQL和COUNT查询SQL。MP会优先使用你在XML里定义的COUNT语句。5.3 坑点三排序ORDER BY混乱或失效现象分页数据的顺序和预期不符或者加了排序条件没效果。原因和解决没有显式指定排序数据库在没有ORDER BY的情况下返回顺序是不确定的。分页查询必须显式指定排序规则否则翻页时可能出现数据重复或丢失。一定要在QueryWrapper中加上.orderByAsc/orderByDesc。排序字段不唯一导致分页数据错乱这是一个高级坑。假设你按create_time排序但同一秒内创建了多条数据。当你查询第1页ORDER BY create_time LIMIT 0, 10和第2页ORDER BY create_time LIMIT 10, 10时由于create_time值相同数据库在每一页内部排序可能是不稳定的可能导致某条记录既出现在第1页又出现在第2页。解决方案是使用唯一字段如主键id作为排序的第二条件.orderByAsc(“create_time”, “id”)。5.4 高级技巧自定义分页插件行为MP的分页插件PaginationInnerInterceptor提供了一些钩子方法允许你在分页查询前后进行自定义操作。例如你可以继承它来优化所有查询的COUNT语句public class CustomPaginationInterceptor extends PaginationInnerInterceptor { Override protected String getCountSql(String originalSql) { // 这里可以对你认为复杂的原始SQL返回一个优化后的COUNT SQL // 例如对于简单的单表查询直接使用MP的优化逻辑 // 对于特定的复杂SQL返回一个手写的、高效的COUNT语句 if (originalSql.contains(“你的复杂SQL特征”)) { return “SELECT COUNT(*) FROM (” originalSql “) mp_count_table”; } // 否则调用父类默认逻辑 return super.getCountSql(originalSql); } }然后在你的配置类里使用这个自定义的拦截器代替原来的PaginationInnerInterceptor。这需要对MP和SQL有较深的理解属于高阶用法。5.5 与其它插件如多租户、动态表名的协作如果你的项目还使用了MP的多租户插件TenantLineInnerInterceptor或动态表名插件DynamicTableNameInnerInterceptor需要注意插件的添加顺序。拦截器的执行顺序就是MybatisPlusInterceptor的addInnerInterceptor的添加顺序。对于分页和多租户通常建议将多租户插件放在分页插件之前。因为多租户插件需要在SQL上添加租户条件这个操作应该在分页插件计算总数和修改分页语句之前完成否则COUNT查询可能不会包含租户条件导致总数计算错误。Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 1. 先添加多租户插件 interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(...)); // 2. 再添加分页插件 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }配置和使用MyBatis-Plus分页插件本身并不复杂真正的挑战在于如何在高并发、大数据量的生产环境中用得稳健、高效。它帮你屏蔽了数据库方言的差异自动化了繁琐的步骤但数据库分页本身的性能规律和最佳实践仍然需要开发者心中有数。从打破默认的500条限制到警惕深度分页再到优化COUNT查询和排序每一步都是通往更健壮后端服务的阶梯。我的经验是在项目初期就采用MP的分页方式并建立好分页参数校验、性能监控的规范能为后续的平稳运行省去大量排查和重构的功夫。