Mybatis-Plus分页插件深度解析:从基础配置到性能优化实战
1. 项目概述为什么Mybatis-Plus分页是开发者的“效率加速器”如果你正在用Spring Boot做后端开发十有八九绕不开数据库分页这个需求。无论是管理后台的表格数据展示还是移动端App的上拉加载更多分页都是保证应用性能和用户体验的核心技术点。过去我们可能需要手写一大堆limit和offset的SQL还要自己计算总条数代码冗长且容易出错。而Mybatis-Plus简称MP的出现尤其是它内置的分页插件可以说彻底改变了这个局面。它不是一个简单的语法糖而是一套经过大量生产环境验证的、开箱即用的分页解决方案。今天我就以一个老开发的身份跟你聊聊MP分页从“简单使用”到“深度掌控”的全过程这里面有很多官方文档没写的细节和踩坑经验相信能帮你省下不少摸索的时间。简单来说Mybatis-Plus分页插件帮我们做了两件最核心的事一是自动将传入的Page对象转换为数据库方言如MySQL的LIMITOracle的ROWNUM的分页SQL二是自动执行一次COUNT查询来获取数据总数无需我们手动编写。这听起来简单但要想用得稳、不出错里头的门道可不少。比如如何防止分页慢查询如何应对多表联查分页的总数不准问题如何自定义分页逻辑以满足特殊业务需求这些才是真正体现一个开发者功力的地方。接下来我们就从零开始拆解MP分页的每一个环节。2. 环境准备与核心依赖引入在开始写代码之前确保你的项目环境是正确搭建的。这里假设你已有一个基础的Spring Boot项目。2.1 Maven依赖配置首先在pom.xml中引入Mybatis-Plus的Spring Boot Starter。请注意版本的选择建议使用较新的稳定版因为不同版本的分页插件配置方式可能有细微差别。dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version !-- 请检查并使用最新稳定版本 -- /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-extension/artifactId version3.5.3.1/version /dependency为什么是这两个依赖mybatis-plus-boot-starter是主启动器包含了核心功能和自动配置。而mybatis-plus-extension则包含了分页插件PaginationInnerInterceptor等扩展功能。虽然在某些版本中分页插件可能已集成在starter里但显式引入extension依赖是更稳妥的做法能避免因版本迭代带来的类找不到问题。2.2 分页插件配置核心步骤这是整个分页功能能否生效的关键。你需要在你的配置类中通常是标注了Configuration的类将分页插件作为一个Bean注入到Spring容器中。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(); // 设置数据库类型这里以MySQL为例 paginationInnerInterceptor.setDbType(DbType.MYSQL); // 设置请求的页面大于最大页后操作 true调回到首页false 继续请求 默认false paginationInnerInterceptor.setOverflow(false); // 设置最大单页限制数量默认 500 条-1 不受限制 paginationInnerInterceptor.setMaxLimit(500L); interceptor.addInnerInterceptor(paginationInnerInterceptor); return interceptor; } }配置项解读与避坑指南DbType必须设置正确这决定了MP生成分页SQL的方言。如果你的数据库是Oracle、PostgreSQL等一定要修改为对应的DbType.ORACLE、DbType.POSTGRE_SQL。设置错误会导致生成的SQL语法错误。setOverflow(true/false)这个配置很实用。假设总页数只有10页但用户请求了第11页。如果设置为true则自动返回第一页的数据如果为false则返回空数据。根据你的业务逻辑来定通常后台管理系统设置为true体验更好。setMaxLimit(500L)这是一个重要的安全与性能设置。它限制了单次分页查询能返回的最大记录数。如果没有这个限制前端如果错误地传了一个极大的size如10000会导致数据库一次性查询大量数据极易引发慢查询甚至拖垮数据库。设置为一个合理的值如500是线上项目的必备操作。注意在较早的MP版本如3.4.x之前分页插件可能是通过PaginationInterceptor类配置的。新版本统一使用MybatisPlusInterceptor作为插件容器通过addInnerInterceptor方法添加各种内部插件分页、乐观锁等。如果你看到旧代码请注意区分。3. 基础使用快速上手分页查询配置好插件后我们就可以在Service或Mapper层进行分页查询了。MP提供了两种非常便捷的方式。3.1 使用Page对象进行分页查询这是最常用、最直观的方式。你需要创建一个Page对象指定当前页码和每页大小然后调用MP提供的方法。第一步创建分页参数对象。import com.baomidou.mybatisplus.extension.plugins.pagination.Page; // 查询第1页每页10条数据 PageUser page new Page(1, 10);Page是一个泛型类User是你期望返回的数据实体类型。第二步执行分页查询。在你的Service或Mapper中使用MP增强后的方法。// 方式1在Service层使用IService的page方法推荐 public interface UserService extends IServiceUser { // ... 其他方法 } Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { public PageUser getUserPage(PageUser page) { // 使用lambda构建查询条件 LambdaQueryWrapperUser queryWrapper new LambdaQueryWrapper(); queryWrapper.eq(User::getStatus, 1) // 状态为1的用户 .orderByDesc(User::getCreateTime); // 按创建时间倒序 return this.page(page, queryWrapper); } } // 方式2在Mapper层直接使用selectPage方法 public interface UserMapper extends BaseMapperUser { // 无需额外定义直接使用BaseMapper的方法 } // 在Service中调用Mapper Autowired private UserMapper userMapper; public PageUser getUserPage(PageUser page) { LambdaQueryWrapperUser queryWrapper Wrappers.lambdaQuery(); queryWrapper.eq(User::getStatus, 1); return userMapper.selectPage(page, queryWrapper); }第三步处理返回结果。执行page或selectPage方法后入参的Page对象就会被填充上数据。PageUser resultPage userService.getUserPage(page); // 获取分页数据列表 ListUser userList resultPage.getRecords(); // 获取数据总条数 long total resultPage.getTotal(); // 获取总页数 long pages resultPage.getPages(); // 当前页码 long current resultPage.getCurrent(); // 每页条数 long size resultPage.getSize();你可以将这些数据封装成通用的PageResult类返回给前端。实操心得我强烈推荐在Service层使用IService的page方法。因为它与MP的Service层封装结合得更紧密代码更简洁。而且Page对象在作为参数和返回值时是同一个对象这种“原地修改”的模式需要稍微适应一下。3.2 使用IPage接口接收前端参数在实际项目中分页参数current,size通常由前端通过API传递。我们不应该在业务代码里硬编码new Page(1,10)而是应该接收一个IPage接口的实现对象。控制器Controller层可以这样写RestController RequestMapping(/user) public class UserController { Autowired private UserService userService; GetMapping(/page) public RPageUser getUserPage(RequestParam(defaultValue 1) long current, RequestParam(defaultValue 10) long size) { // 直接使用前端传递的current和size构建Page对象 PageUser page new Page(current, size); PageUser result userService.getUserPage(page); return R.ok(result); // R是自定义的通用返回结果类 } }这样前端请求/user/page?current2size20就能获取第二页的20条数据非常灵活。4. 进阶技巧与深度优化掌握了基础用法只能算“会用”。要想在复杂场景下游刃有余以下这些进阶技巧你必须了解。4.1 自定义分页查询手写SQL分页MP的自动分页虽然强大但遇到复杂的多表关联查询时自动生成的COUNT语句可能会非常慢甚至出错。这时我们就需要自定义分页查询。第一步在Mapper接口中定义方法返回IPage类型。public interface UserMapper extends BaseMapperUser { /** * 自定义复杂分页查询查询用户及其部门信息 * param page 分页参数对象 * param userName 用户名可选 * return 分页结果 */ IPageUserDepartmentVO selectUserWithDepartmentPage(IPageUserDepartmentVO page, Param(userName) String userName); }这里UserDepartmentVO是一个自定义的视图对象VO包含了用户信息和关联的部门名称等字段。第二步在对应的Mapper XML文件中编写SQL。?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.mapper.UserMapper select idselectUserWithDepartmentPage resultTypecom.example.vo.UserDepartmentVO SELECT u.id, u.name as userName, u.email, d.name as departmentName FROM sys_user u LEFT JOIN sys_department d ON u.department_id d.id WHERE u.is_deleted 0 if testuserName ! null and userName ! AND u.name LIKE CONCAT(%, #{userName}, %) /if ORDER BY u.create_time DESC /select /mapper关键点你只需要编写查询数据的主SQL完全不需要在SQL里写LIMIT和COUNT(*)。MP的分页插件会根据你方法返回的IPage类型自动对你的SQL进行两次改造第一次将你的SQL包装成一个COUNT查询用于获取总数。第二次在你的SQL末尾加上数据库方言的分页语句如LIMIT #{offset}, #{size}。第三步在Service中调用。public IPageUserDepartmentVO getUserDepartmentPage(long current, long size, String userName) { PageUserDepartmentVO page new Page(current, size); return userMapper.selectUserWithDepartmentPage(page, userName); }为什么这是最佳实践对于复杂关联查询让MP自动生成COUNT语句可能会变成SELECT COUNT(*) FROM (你的复杂JOIN SQL)这个子查询效率极低。而自定义分页允许你为COUNT查询优化SQL见下一点是解决性能问题的银弹。4.2 优化COUNT查询性能在自定义分页中你可以通过Select注解或在XML中为分页查询单独指定一个更高效的COUNT查询语句。这是处理大数据量分页慢的核心技巧。在Mapper接口中使用Select注解public interface UserMapper extends BaseMapperUser { Select({ script, SELECT u.*, d.name as deptName FROM user u LEFT JOIN department d ON u.dept_id d.id, where, if testname ! null AND u.name LIKE CONCAT(%, #{name}, %) /if, /where, /script }) IPageUserVO selectUserPage(IPageUserVO page, Param(name) String name); }MP会自动识别这是一个分页查询。但如果你发现自动生成的COUNT语句很慢可以这样做在XML中定义并指定id为selectUserPage_COUNT固定格式原方法名 _COUNT!-- 主查询SQL -- select idselectUserPage resultTypeUserVO SELECT u.*, d.name as deptName FROM user u LEFT JOIN department d ON u.dept_id d.id where if testname ! null AND u.name LIKE CONCAT(%, #{name}, %) /if /where /select !-- 专为分页优化的COUNT查询 -- select idselectUserPage_COUNT resultTypejava.lang.Long SELECT COUNT(*) FROM user u where if testname ! null AND u.name LIKE CONCAT(%, #{name}, %) /if /where /select注意看selectUserPage_COUNT的SQL中我去掉了LEFT JOIN department。因为在这个场景下JOIN部门表只是为了获取部门名对于统计用户总数来说JOIN是不必要的甚至可能因为重复数据导致统计错误。去掉JOIN后COUNT查询的性能会得到数量级的提升。重要提示当你自定义了*_COUNT语句后MP插件将完全使用你提供的语句进行总数统计而不会对原SQL进行包装。你必须确保这个COUNT语句的结果与主查询语句在相同的查询条件下逻辑上一致总数相同。这是自定义COUNT查询的责任。4.3 分页查询的“坑”与解决方案实录在实际开发中我遇到过不少关于MP分页的“坑”这里分享几个典型的案例和解决方案。问题一自定义SQL分页查询返回的Page对象中total总条数为0但records数据列表却有值。排查过程首先检查SQL在数据库客户端直接运行确认数据存在。检查Mapper方法返回值是否为IPage或Page类型。检查XML中是否同时存在id为[methodName]和[methodName]_COUNT的两个select。如果只有主查询没有_COUNT查询MP会尝试自动生成COUNT语句但在某些复杂SQL如包含UNION,WITH子句或特定数据库方言下可能会失败导致total为0。解决方案方案A推荐如4.2节所述显式地在XML中编写一个优化后的*_COUNT查询语句。方案B如果确定自动生成可行检查SQL中是否包含script标签或某些让MP解析器困惑的语法。尝试简化SQL。方案C在代码中手动设置总数。这是一种“兜底”策略在极少数自动和自定义都失效时使用。PageUserVO page new Page(current, size); // 先关闭MP的自动count查询否则会执行两次 page.setSearchCount(false); ListUserVO records userMapper.selectCustomPage(page, param); // 手动执行一次count查询 Long total userMapper.selectCustomCount(param); page.setRecords(records); page.setTotal(total);问题二多租户系统下分页查询的总数包含了其他租户的数据。背景项目使用了MP的多租户插件TenantLineInnerInterceptor但在自定义分页查询时自动生成的COUNT语句可能没有自动加上租户ID条件。解决方案对于自动分页确保你的多租户插件配置正确且在执行分页查询的Wrapper中没有手动覆盖掉租户字段的条件。对于自定义SQL分页这是重灾区。你必须在你自定义的*_COUNT语句的WHERE条件中显式地加上租户过滤条件例如AND tenant_id #{tenantId}。不能依赖插件自动添加因为插件可能无法正确解析你的自定义SQL。问题三前端传递的排序字段参数如何安全地应用到分页查询中直接使用字符串拼接ORDER BY ${orderField} ${orderType}有SQL注入风险。MP提供了安全的方式。解决方案在Service层动态构建QueryWrapper。public PageUser getPage(PageUser page, String orderField, String orderType) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); // ... 其他条件 // 使用MP的排序方法它内部会对字段名进行安全处理 boolean isAsc asc.equalsIgnoreCase(orderType); // 这种方法要求orderField是实体类的属性名MP会将其转换为数据库列名 // 注意这里需要根据orderField手动判断MP没有直接传字符串排序的方法 // 更安全的做法是使用枚举或白名单 if (createTime.equals(orderField)) { wrapper.orderBy(true, isAsc, User::getCreateTime); } else if (name.equals(orderField)) { wrapper.orderBy(true, isAsc, User::getName); } else { // 默认排序 wrapper.orderByDesc(User::getCreateTime); } return userService.page(page, wrapper); }更优雅的方案是使用注解或工具类将前端传递的字段名映射到实体类的SFunction上完全避免字符串拼接。5. 性能考量与最佳实践分页查询尤其是深度分页查询靠后的页码是数据库的性能杀手。下面是一些至关重要的性能优化实践。5.1 深度分页优化Keyset Pagination游标分页传统的LIMIT offset, size在offset非常大时如LIMIT 100000, 20数据库需要先扫描并跳过前10万条记录效率极低。优化方案使用“上一页最大值”作为游标。假设你的列表按id自增主键和create_time降序排列。第一页SELECT * FROM table ORDER BY id DESC LIMIT 20第二页获取第一页最后一条记录的id假设为lastId然后查询SELECT * FROM table WHERE id #{lastId} ORDER BY id DESC LIMIT 20在MP中如何实现public PageUser getPageByCursor(PageUser page, Long lastId) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.orderByDesc(User::getId); if (lastId ! null) { // 查询id小于lastId的记录 wrapper.lt(User::getId, lastId); } // 这里page的current参数实际上用不上了size参数仍然有用 PageUser resultPage userService.page(page, wrapper); // 将本次查询结果集的最后一条id返回给前端作为下一次查询的cursor ListUser records resultPage.getRecords(); if (!records.isEmpty()) { Long nextCursor records.get(records.size() - 1).getId(); // 可以将nextCursor放入page对象的某个扩展字段或自定义返回体 } return resultPage; }优点无论翻到第几页查询速度都只和size有关性能恒定。缺点不支持“跳页”只能“上一页/下一页”。适合无限滚动的feed流场景。5.2 避免不必要的COUNT查询在某些场景下我们可能不需要知道总条数比如手机端的上拉加载更多通常只判断“还有没有下一页”。MP允许你关闭自动COUNT查询以提升性能。// 创建Page对象时设置不进行count查询 PageUser page new Page(current, size); page.setSearchCount(false); // 关键设置 PageUser result userService.page(page, wrapper); // 此时 result.getTotal() 为0 // 判断是否有下一页如果返回的records数量等于请求的size则很可能还有下一页如果小于size则肯定是最后一页。 boolean hasNext result.getRecords().size() size;5.3 分页与大数据量导出经常有需求是“查询所有数据并导出Excel”。绝对不要用分页查询循环调用直到取完所有数据这会给数据库造成巨大压力。正确做法使用MP的Cursor流式查询如果数据量巨大。try (CursorUser cursor userMapper.selectCursor(queryWrapper)) { cursor.forEach(user - { // 处理每一条数据写入Excel }); }如果数据量不是特别大可以一次性查询需评估内存但务必在Wrapper中只选择需要的字段select(字段列表)避免SELECT *。对于超大数据量应考虑在业务低峰期通过数据库命令行工具直接导出为文件或使用专门的数据同步/ETL工具。6. 与前端联调构建标准分页响应体前后端协作需要一个清晰的分页数据协议。一个通用的分页响应体结构如下Data public class PageResultT { private Long current; // 当前页 private Long size; // 每页大小 private Long total; // 总记录数 private Long pages; // 总页数 private ListT records; // 数据列表 public static T PageResultT of(IPageT page) { PageResultT result new PageResult(); result.setCurrent(page.getCurrent()); result.setSize(page.getSize()); result.setTotal(page.getTotal()); result.setPages(page.getPages()); result.setRecords(page.getRecords()); return result; } }在Controller中GetMapping(/page) public RPageResultUserVO getPage(RequestParam(defaultValue 1) Long current, RequestParam(defaultValue 10) Long size) { PageUser pageParam new Page(current, size); IPageUser pageData userService.page(pageParam, queryWrapper); return R.ok(PageResult.of(pageData)); }这样前端就能以一个固定的格式如{code:0, data:{current:1, size:10, total:100, pages:10, records:[...]}}来解析分页数据了。7. 总结与个人心得Mybatis-Plus的分页功能从简单的page(new Page(1,10))到复杂的自定义SQL与性能优化体现了一个工具从“能用”到“好用”再到“精通”的过程。回顾这些内容我最想强调的几点个人体会是第一配置是基础理解配置项的含义比复制粘贴更重要。特别是DbType和MaxLimit一个关系到功能是否正常一个关系到系统是否安全稳定。第二自定义分页是解决复杂业务场景的钥匙。当自动分页力不从心时不要犹豫用手写XML的方式来自定义查询和COUNT语句。这不仅是功能的实现更是对SQL性能和准确性的主动掌控。第三性能问题必须前置考虑。在设计和评审阶段就要问清楚数据量有多大、是否需要深度分页、导出功能怎么做。提前决定使用传统分页还是游标分页是否要关闭COUNT查询能避免很多上线后的性能危机。第四与前端约定好协议。一个清晰、稳定的分页响应体格式能减少大量的联调沟通成本。把current、size、total、records这些字段名定死前后端都省心。最后任何工具都有其边界。Mybatis-Plus的分页插件极大地提升了开发效率但对于极端复杂或对性能有极致要求的场景可能仍然需要你回归到最原始的JDBC或使用更专业的数据库访问方案。了解工具的极限也是资深开发者必备的能力。希望这篇长文能帮你把Mybatis-Plus的分页功能吃透在下次遇到分页需求时能够更加从容和高效。