1. 项目概述为什么MyBatis-Plus分页是后端开发的必修课如果你正在用Spring Boot做后端开发尤其是涉及到数据列表展示的业务比如用户管理、订单查询、文章列表那么“分页”这个功能你肯定绕不过去。而一旦你开始用MyBatis-Plus后面简称MP它的分页插件几乎会成为你的首选方案。我见过不少项目从原生的MyBatis手动拼装LIMIT语句或者用PageHelper最终都平滑迁移到了MP的分页上。原因很简单它和MP的集成度太高了用起来太“顺手”了。所谓“分页查询详解”绝不仅仅是告诉你加个Bean配置然后调用page()方法就完了。那只是冰山一角。真正要“详解”的是背后的工作原理、不同场景下的应用姿势、那些官方文档没明说但实际开发中一定会踩的坑以及如何根据你的业务需求进行深度定制。比如当你的查询条件极其复杂涉及多表关联和动态筛选时如何保证分页性能当你想返回给前端的不仅仅是数据列表还有一些自定义的统计字段时该怎么封装这些才是从“会用”到“用好”的关键。这篇文章我就以一个趟过不少坑的过来人身份结合MP最新的常见实践比如围绕3.5.x版本把分页查询这件事掰开了、揉碎了讲清楚。无论你是刚接触MP的新手还是想优化现有分页逻辑的老手都能找到对你有用的干货。我们会从最基础的配置和调用讲起一直深入到拦截器原理、性能优化和复杂业务场景的实战。2. MyBatis-Plus分页插件核心原理与配置在开始写代码之前我们必须先搞清楚MP分页是怎么“无侵入”地帮我们实现分页的。这决定了我们后续如何正确地配置和使用它。2.1 分页插件的工作原理拦截器的魔法MP的分页功能其核心是一个MyBatis的拦截器Interceptor具体是com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor。它的工作流程可以概括为“两次拦截一次改写”第一次拦截查询总数当你执行一个返回类型为IPage的查询方法时拦截器会首先拦截这次SQL执行。它会分析你的原始SQL语句并智能地生成一条用于计算总记录数的COUNT语句。例如你的查询是SELECT id, name FROM user WHERE age 18拦截器会生成SELECT COUNT(1) FROM user WHERE age 18并优先执行获取总条数total。第二次拦截与改写查询分页数据拿到total后拦截器会根据你传入的IPage对象中的当前页码current和每页大小size对原始SQL进行方言适配的改写。对于MySQL它会在SQL末尾加上LIMIT offset, size对于Oracle可能会改为使用ROWNUM。改写完成后再执行这条改写的SQL得到当前页的数据列表records。组装结果最后拦截器将total和recordsset回你传入的IPage对象中并将其返回。整个过程对开发者是透明的你只需要关心查询条件不需要手动编写COUNT和LIMIT语句。这也是它比原生MyBatis方便的地方。2.2 标准配置与版本适配要点理解了原理配置就很简单了。在Spring Boot项目中通常在一个配置类中声明分页插件Bean。import 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 { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 添加分页插件 PaginationInnerInterceptor paginationInnerInterceptor new PaginationInnerInterceptor(); // 设置数据库类型MP会根据类型生成不同的分页SQL paginationInnerInterceptor.setDbType(DbType.MYSQL); // 设置请求的页面大于最大页后操作true调回到首页false继续请求默认false paginationInnerInterceptor.setOverflow(false); // 设置单页分页条数限制默认无限制-1表示不受限制 paginationInnerInterceptor.setMaxLimit(1000L); // 开启 count 的 join 优化,只针对部分 left join 有效。建议在复杂查询场景下手动关闭。 // paginationInnerInterceptor.setOptimizeJoin(false); interceptor.addInnerInterceptor(paginationInnerInterceptor); // 这里还可以添加其他插件比如乐观锁插件、防止全表更新与删除插件 return interceptor; } }关于版本适配的特别提醒 网络热词中提到了“mybatis-plus version3.5.17对应的springboot版本”。这是一个非常实际的问题。MP 3.5.x是一个重要的稳定版本系列它与Spring Boot的版本有隐性的兼容关系。一般来说MP 3.5.17 可以很好地兼容 Spring Boot 2.7.x 和 3.0.x需要JDK17。如果你使用的是更老的Spring Boot 2.5.x或2.6.x使用MP 3.5.17通常也没问题但建议关注一下mybatis-spring-boot-starter的版本。最稳妥的方式是去MP的官方GitHub仓库的Release页面或Wiki里查看版本说明里面通常会注明推荐的Spring Boot版本。盲目组合版本可能会导致类冲突或功能异常。注意PaginationInnerInterceptor是从MP 3.4.0版本开始引入的取代了旧的PaginationInterceptor。如果你在老旧项目或某些教程中看到PaginationInterceptor请注意升级你的MP版本或按新方式配置。2.3 核心模型IPage与PageMP分页的核心模型是IPageT接口及其实现类PageT。IPageT定义了分页的所有契约如获取/设置当前页、页大小、总记录数、数据列表等。PageT最常用的实现类。你在服务层构造它传入DAO层最后MP会把查询结果填充进去。// Page的常用构造方法 PageUser page new Page(1, 10); // 查询第1页每页10条 // 或者不查询总数用于仅获取数据不关心总条数的场景性能更好 PageUser page new Page(1, 10, false);Page对象最终会携带以下核心信息返回给调用者records: 当前页的数据列表ListT。total: 总记录数。size: 每页大小。current: 当前页码。pages: 总页数由total和size计算得出。3. 基础到进阶分页查询的多种使用姿势配置好插件我们就可以开始使用了。MP提供了多种方式进行分页查询从最简单的单表查询到复杂的多表关联都有对应的方案。3.1 姿势一使用BaseMapper的selectPage方法最常用这是最直接、最常用的方式适用于单表查询或简单的条件查询。Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { public IPageUser selectUserPage(Integer pageNum, Integer pageSize, String name) { // 1. 构建分页对象 PageUser page new Page(pageNum, pageSize); // 2. 构建查询条件 LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(name)) { wrapper.like(User::getName, name); } wrapper.orderByDesc(User::getCreateTime); // 3. 执行分页查询 return baseMapper.selectPage(page, wrapper); // 等价于 return page(page, wrapper); } }baseMapper.selectPage(page, wrapper)这行代码背后MP会完成我们之前讲的所有动作自动执行COUNT查询自动改写SQL加上分页最后把结果塞回page对象。3.2 姿势二在Service层使用page方法如果你的Service继承了MP的ServiceImpl那么可以直接使用page方法它内部调用的也是baseMapper.selectPage。public IPageUser selectUserPageByService(Integer pageNum, Integer pageSize) { PageUser page new Page(pageNum, pageSize); LambdaQueryWrapperUser wrapper Wrappers.UserlambdaQuery() .orderByDesc(User::getId); // 使用Service的page方法 return this.page(page, wrapper); }这种方式和上一种本质一样只是调用链更短看起来更“服务层”一些。3.3 姿势三自定义SQL与分页参数传递当查询非常复杂需要写XML映射文件或者Select注解中的自定义SQL时分页该如何使用答案是你只需要在方法参数中传入IPage对象并在SQL中正常写查询语句不要自己写LIMITMP插件会自动帮你完成分页改写。Mapper接口public interface UserMapper extends BaseMapperUser { // 方法一返回IPage IPageUser selectUserWithRolePage(IPageUser page, Param(name) String name); // 方法二返回List但参数包含IPage (更推荐第一种语义更清晰) ListUser selectUserWithRolePage2(IPageUser page, Param(name) String name); }XML映射文件!-- 对应方法一 -- select idselectUserWithRolePage resultTypecom.example.entity.User SELECT u.*, r.role_name FROM user u LEFT JOIN user_role ur ON u.id ur.user_id LEFT JOIN role r ON ur.role_id r.id where if testname ! null and name ! AND u.name LIKE CONCAT(%, #{name}, %) /if /where ORDER BY u.create_time DESC !-- 注意这里绝对不能写 LIMIT插件会自动处理 -- /select !-- 对应方法二SQL写法完全一样 -- select idselectUserWithRolePage2 resultTypecom.example.entity.User SELECT u.*, r.role_name FROM user u LEFT JOIN user_role ur ON u.id ur.user_id LEFT JOIN role r ON ur.role_id r.id WHERE u.name LIKE CONCAT(%, #{name}, %) /selectService调用public IPageUser selectUserWithRolePage(Integer pageNum, Integer pageSize, String name) { PageUser page new Page(pageNum, pageSize); // 调用自定义方法 return userMapper.selectUserWithRolePage(page, name); // 如果调用的是返回List的方法二需要手动将list set回page对象 // ListUser list userMapper.selectUserWithRolePage2(page, name); // page.setRecords(list); // return page; }关键点在自定义SQL的XML中千万不要自己添加LIMIT #{offset}, #{size}之类的分页语句。MP的拦截器会基于你传入的IPage参数自动识别这是一个分页查询并对SQL进行改写。如果你自己写了会导致SQL被错误地改写两次或者分页参数错乱。3.4 姿势四不查询总数的分页优化性能在某些前端“无限滚动”或者“加载更多”的场景下我们可能只需要数据不需要知道总共有多少条。这时查询COUNT语句就是多余的会浪费性能。MP提供了简单的开关。public IPageUser selectPageWithoutCount(Integer pageNum, Integer pageSize) { // 在构造Page对象时传入第三个参数 false表示不执行 COUNT 查询 PageUser page new Page(pageNum, pageSize, false); LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.orderByDesc(User::getId); return baseMapper.selectPage(page, wrapper); }执行这个方法后返回的IPage对象中total字段将为0pages字段也为0但records里是当前页的数据。这在处理海量数据且不关心总条数的场景下能显著提升查询速度。4. 实战痛点解析与高级技巧掌握了基本用法我们来看看实际项目中那些让人头疼的问题和高级玩法。4.1 痛点一多表关联分页的总数查询性能问题这是最经典的坑。假设我们有一个User表和一个Order表想分页查询用户及其订单数量。SQL可能如下SELECT u.*, COUNT(o.id) as order_count FROM user u LEFT JOIN order o ON u.id o.user_id GROUP BY u.id ORDER BY u.create_time DESC LIMIT 0, 10MP插件生成的COUNT语句会是SELECT COUNT(1) FROM user u LEFT JOIN order o ON u.id o.user_id GROUP BY u.id问题来了这个COUNT语句包含了JOIN和GROUP BY在数据量大时可能会非常慢甚至比查数据本身还慢。解决方案1手动指定COUNT查询推荐MP的Page对象允许你手动设置一个total或者通过setSearchCount(false)关闭自动查询然后自己用一条优化过的SQL查询总数。public IPageUserVO selectUserWithOrderCountPage(PageUserVO page, QueryParam param) { // 1. 先关闭自动查询总数 page.setSearchCount(false); // 2. 执行分页数据查询自定义SQLSQL中不要写LIMIT ListUserVO records userMapper.selectUserWithOrderCountList(page, param); // 3. 手动用一条优化的SQL查询总数 // 例如对于上述场景查询用户总数可能比联表COUNT快得多 // 假设业务逻辑允许统计的是“有订单的用户数”那么联表COUNT无法避免。 // 如果业务逻辑是“所有用户数”那么直接 SELECT COUNT(1) FROM user 更快。 Long total userMapper.selectOptimizedCount(param); // 4. 组装结果 page.setRecords(records); page.setTotal(total); // page.setPages(total / page.getSize() (total % page.getSize() 0 ? 1 : 0)); // MP会自动计算 return page; }解决方案2使用InterceptorIgnore注解MP 3.4你可以在Mapper方法上使用此注解忽略插件对特定方法的拦截。这样你就可以在XML里写完整的包含LIMIT的SQL并自己处理COUNT逻辑。但这需要你完全手动管理分页失去了MP的便利性需谨慎使用。4.2 痛点二返回类型映射与VO/DTO封装很多时候我们分页查询返回的并不是实体类User而是一个包含更多关联信息的视图对象UserVO。Data public class UserVO { private Long id; private String name; private String email; private Integer orderCount; // 订单数量需要联表计算 private String roleName; // 角色名称需要联表查询 }Mapper接口和XML需要做相应调整// Mapper接口 IPageUserVO selectUserVOPage(IPage? page, Param(name) String name);!-- XML映射resultType指向UserVO -- select idselectUserVOPage resultTypecom.example.vo.UserVO SELECT u.id, u.name, u.email, COUNT(o.id) as orderCount, r.role_name as roleName FROM user u LEFT JOIN order o ON u.id o.user_id LEFT JOIN user_role ur ON u.id ur.user_id LEFT JOIN role r ON ur.role_id r.id where if testname ! null and name ! AND u.name LIKE CONCAT(%, #{name}, %) /if /where GROUP BY u.id ORDER BY u.create_time DESC /select关键点IPageUserVO中的泛型类型决定了records里每个元素的类型。MP和MyBatis会根据你的XML中的resultType或resultMap将查询结果映射到UserVO对象中。4.3 痛点三排序与动态字段排序排序是分页查询的孪生兄弟。MP的Wrapper提供了便捷的排序方法。// 1. 简单排序 wrapper.orderByDesc(User::getCreateTime); // 按创建时间倒序 wrapper.orderByAsc(User::getId); // 可以链式调用多个排序条件 // 2. 动态排序根据前端传入的字段名和排序方式 String sortField createTime; // 可能来自前端请求 String sortOrder desc; boolean isAsc asc.equalsIgnoreCase(sortOrder); wrapper.orderBy(true, isAsc, sortField); // 注意这里的sortField是数据库字段名下划线风格如create_time更安全。 // 使用Lambda方式时MP会自动将实体属性名转换为数据库字段名。 // 但直接传字符串时需要确保字符串是合法的数据库字段名否则有SQL注入风险。 // 安全的做法是做一个字段白名单校验。更安全的动态排序实践private static final SetString ALLOWED_SORT_FIELDS Set.of(create_time, update_time, name); public LambdaQueryWrapperUser buildWrapper(QueryParam param) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); // ... 其他条件 if (StringUtils.hasText(param.getSortField()) ALLOWED_SORT_FIELDS.contains(param.getSortField())) { boolean isAsc asc.equalsIgnoreCase(param.getSortOrder()); // 使用条件构造器的 orderBy 方法传入数据库字段名字符串 wrapper.orderBy(true, isAsc, param.getSortField()); // 或者更灵活地使用 last() 方法慎用注意防注入 // wrapper.last(ORDER BY param.getSortField() param.getSortOrder()); } else { // 默认排序 wrapper.orderByDesc(User::getCreateTime); } return wrapper; }4.4 技巧自定义Page对象与额外信息封装有时除了分页数据我们还想在返回结果里加一些“佐料”比如当前查询条件下的某种统计信息。方法一继承Page类Data EqualsAndHashCode(callSuper true) public class CustomPageT extends PageT { /** * 自定义的额外信息例如统计字段 */ private MapString, Object extraInfo new HashMap(); public CustomPage(long current, long size) { super(current, size); } public CustomPage(long current, long size, boolean searchCount) { super(current, size, searchCount); } }在Service中public CustomPageUserVO selectUserPageWithStats(QueryParam param) { CustomPageUserVO page new CustomPage(param.getPageNum(), param.getPageSize()); // 1. 查询分页数据 IPageUserVO dataPage userMapper.selectUserVOPage(page, param.getName()); // 2. 查询额外的统计信息例如所有用户的平均年龄 MapString, Object stats userMapper.selectUserStats(); // 3. 将数据和额外信息放入自定义Page page.setRecords(dataPage.getRecords()); page.setTotal(dataPage.getTotal()); page.setExtraInfo(stats); // 设置额外信息 return page; }这样返回给前端的JSON就会多一个extraInfo字段。方法二创建一个独立的响应对象更清晰Data public class PageResultT { private Long current; private Long size; private Long total; private Long pages; private ListT records; private Object stats; // 或其他任何自定义字段 }在Controller层将MP的IPage对象和额外统计信息组装成PageResult返回。这种方式职责更清晰Page只负责分页数据PageResult负责最终API响应格式。5. 常见问题排查与性能优化指南即使按照最佳实践来在实际开发和线上运行中还是会遇到各种奇怪的问题。这里我整理了一个“排坑手册”。5.1 问题排查速查表问题现象可能原因解决方案分页失效返回了所有数据1. 分页插件未配置或配置未生效。2. Mapper方法返回类型不是IPage或参数中没有IPage。3. 在XML中自己写了LIMIT语句。1. 检查Configuration类是否被扫描Bean是否正确声明。2. 确保Mapper方法签名正确。3. 移除XML中的LIMIT让插件自动处理。total总数总是为0或不对1. 使用了new Page(current, size, false)关闭了count查询。2. 复杂的联表查询自动生成的COUNT语句有误。3. 查询条件在COUNT时被错误优化掉。1. 检查Page构造参数。2. 使用4.1节的方法手动指定或优化COUNT查询。3. 检查PaginationInnerInterceptor的optimizeJoin等配置。排序字段不生效或报错1. 动态排序字段名包含特殊字符或SQL关键字。2. 字段名是实体属性名驼峰而非数据库字段名下划线。3. 使用wrapper.orderBy(true, isAsc, “createTime”)但数据库字段是create_time。1. 对前端传入的排序字段做白名单校验和过滤。2. 使用Lambda表达式wrapper.orderByAsc(User::getCreateTime)。3. 如果必须用字符串确保传入的是正确的数据库字段名或开启MP的驼峰下划线转换。多租户或其他插件与分页插件冲突插件执行顺序问题。确保MybatisPlusInterceptor中添加插件的顺序正确。通常分页插件(PaginationInnerInterceptor)应该加在最后。例如interceptor.addInnerInterceptor(new TenantLineInnerInterceptor()); interceptor.addInnerInterceptor(new PaginationInnerInterceptor());查询性能慢特别是深分页LIMIT offset, size在offset很大时如LIMIT 100000, 10MySQL需要扫描大量数据后再丢弃性能极差。1.业务上限制最大分页深度如只允许查前100页。2.使用游标分页Cursor-based Pagination基于上一页最后一条记录的ID进行查询WHERE id last_id ORDER BY id LIMIT size。这需要业务配合且只适用于有序连续数据。3.覆盖索引优化让COUNT和查询都尽可能走索引。5.2 深分页优化实战游标分页示例对于“加载更多”的场景游标分页是比传统LIMIT offset好得多的选择。后端实现public ListUser getUsersByCursor(Long lastId, Integer size) { LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.gt(User::getId, lastId ! null ? lastId : 0) // 查询ID大于lastId的记录 .orderByAsc(User::getId) // 必须按ID有序 .last(LIMIT size); // 这里可以用last因为条件简单且固定 return this.list(wrapper); }前端调用第一次请求lastId0获取第一页数据。拿到最后一行的ID比如是15下次请求就传lastId15。优点性能稳定不受页码影响。缺点无法跳转到任意页不适合需要传统页码导航的场景。5.3 配置项调优建议回到最初的PaginationInnerInterceptor配置一些参数可以根据实际情况调整paginationInnerInterceptor.setDbType(DbType.MYSQL); // 务必设置正确影响分页方言 paginationInnerInterceptor.setOverflow(true); // 建议设为true页码超出范围时返回第一页避免空数据 paginationInnerInterceptor.setMaxLimit(500L); // 根据业务设置一个合理的单页最大条数防止恶意请求拖垮数据库 // paginationInnerInterceptor.setOptimizeJoin(false); // 在复杂LEFT JOIN且COUNT慢时尝试关闭5.4 与“若依”等框架整合的特别提示网络热词中提到了“若依框架不分离版4.8.3版本 想将mybatis 改为mybatis-plus”。若依是一个流行的开源后台管理系统。在将其中的Mybatis替换为Mybatis-Plus时分页部分需要特别注意移除原有分页依赖移除或排除掉PageHelper等原有分页工具的依赖。配置MP分页插件如上文所示在若依的配置类可能是RuoYiConfig或新建一个中添加MP拦截器Bean。改造Service层若依原有的分页查询方法通常是手动计算分页参数并调用Mapper。需要将其改为使用IPage和MP的selectPage或page方法。注意Controller层返回若依的TableDataInfo是其封装的分页返回对象。你需要将MP查询得到的IPage对象中的数据转换并填充到TableDataInfo中以保持前端接口不变。测试复杂SQL重点测试原有项目中的复杂多表关联分页查询确保MP自动生成的COUNT语句性能可接受否则按4.1节进行手动优化。整个迁移过程的核心是保持对外APIController层不变只改变内部数据访问层DAO/Service的实现方式。做好充分的单元测试和集成测试是关键。最后再分享一个我个人的小习惯对于所有分页查询接口我都会在开发阶段打开MySQL的通用日志general log或者使用MP的性能分析插件PerformanceInterceptor旧版或P6Spy亲眼看一下最终执行的SQL语句是什么样子特别是COUNT语句。这能帮你第一时间发现SQL是否被正确改写、是否存在性能隐患。眼见为实这是调试分页问题最直接有效的方法。