ARTICLE DETAIL

资讯详情

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

MyBatis-Plus 分页查询全解析:原理、配置与性能优化

MyBatis-Plus 分页查询全解析:原理、配置与性能优化 先说结论MyBatis-Plus 的分页查询不算难但很多人在配置上、自定义 SQL 分页上、深层分页性能上都会踩坑。这篇文章我把 MyBatis-Plus 分页从原理、配置、基础用法、条件分页、多表自定义 SQL 分页到常见问题和性能优化全部梳理一遍尽量做到“超全面”新老手都能从中找到自己需要的内容。开头先交代一下适用人群如果你是刚接触 MyBatis-Plus 的初学者可以从第 2 节和第 3 节看起按步骤把配置和基础分页跑通如果你已经在项目里用了分页但经常遇到问题建议重点看第 6 节和第 7 节那里面的坑基本都属于“平时文档里看不到”的类型。1. 先说清楚MyBatis-Plus 分页到底解决了什么问题1.1 逻辑分页和物理分页的差异很多新手对“分页”的理解停留在查出来再截取这个层面这就是逻辑分页。逻辑分页的做法是先把符合条件的全部数据从数据库查出来放到内存里然后再从结果集中截取当前页的数据。这种方式的缺点很明显数据量小的时候感觉不明显数据量一旦到了几十万上百万内存直接被撑爆查询速度也会肉眼可见地变慢。MyBatis-Plus 分页属于物理分页它的核心思路是让数据库直接返回一页数据比如 MySQL 最终会帮你生成类似LIMIT 0, 10这样的 SQL数据库层面只取 10 条内存压力小、响应速度快。1.2 自己手写分页和用插件的区别有人会问分页不就是拼接一个LIMIT吗我直接在 XML 里写不就行了自己写确实能实现但有几个痛点数据库方言不同MySQL 用LIMITOracle 用ROWNUMSQL Server 用OFFSET FETCH切换数据库时 SQL 要重写。每次分页都要手写”总数统计“要么用COUNT(*)单独写一条要么用SQL_CALC_FOUND_ROWS之类的 hack 写法维护成本高。参数拼接容易出错尤其当条件很多、动态拼 SQL 时一个空格、一个逗号都能让你排查半天。MyBatis-Plus 的分页插件会拦截 SQL自动帮你生成 count 查询和分页语句。你在业务代码里只需要传一个Page对象进去剩下的活交给插件这也就是它流行的原因。这里有件事要说清楚MyBatis-Plus 分页插件不是通过内存过滤实现的它是真的在改写 SQL。插件内部用到 JSqlParser 去解析 SQL 语法树然后重新组装出 count 语句和带分页的语句。这也是为什么后面会提到“SQL 太复杂导致解析失败”这类问题——因为只要是解析 SQL就会遇到 SQL 语法兼容性的边界。1.3 分页插件在 MyBatis 执行链路上的位置MyBatis 的执行链路大致是Executor→StatementHandler→PreparedStatement→ 数据库。MyBatis-Plus 的拦截器实现的是Interceptor接口它作用在Executor层。当调用selectPage这类分页方法时拦截器会先拿到即将执行的 SQL判断第一个参数是否为IPage类型如果是就先用 JSqlParser 改写 SQL再交给数据库执行。理解了这一层很多问题就通了如果你的分页方法签名不对、参数不是IPage拦截器根本不会触发SQL 就不会被改写了。提示分页生效的前提是拦截器已注册到 Spring 容器中并且被 MyBatis 正确识别。有人说“我加了插件但没生效”大概率是Bean没加、类没有被扫描到、或者方法参数类型写错了。2. 环境准备与插件配置这一步最容易翻车2.1 引入依赖版本选择与注意事项如果你用的是 Spring Boot 项目引入 mybatis-plus-boot-starter 是最常规的操作dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency版本建议使用 3.5.x 系列较新的版本对 Spring Boot 3.x 和 JDK 17 的支持会更好。如果项目还在用 Spring Boot 2.x3.5.1 以上的版本也能兼容但需要看清自己项目的具体环境。这里有个隐藏坑如果你的项目本身还引了mybatis或mybatis-spring的旧依赖可能会和 MyBatis-Plus 自带的版本冲突导致启动报错或者分页插件不生效。解决办法是把原来手动的 mybatis 相关依赖去掉统一由 mybatis-plus-boot-starter 管理。2.2 新版分页拦截器的配置方式MyBatis-Plus 从 3.4.0 开始分页配置换成了MybatisPlusInterceptorPaginationInnerInterceptor。网上很多老文章还在用PaginationInterceptor那个类在新版本中已经不再推荐了使用不当会在运行时出现各种奇怪问题。正确配置如下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 paginationInterceptor new PaginationInnerInterceptor(DbType.MYSQL); interceptor.addInnerInterceptor(paginationInterceptor); return interceptor; } }MybatisPlusInterceptor是一个复合拦截器你可以往里面添加多种InnerInterceptor比如分页、乐观锁、防全表更新等。分页拦截器只是其中一种。2.3 方言参数与 maxLimit 的设置PaginationInnerInterceptor构造方法可以直接传DbType也可以用setDbType设置。指定方言的目的是让插件知道该给 SQL 拼什么样的分页语法。常见的数据库方言映射如下数据库DbType 枚举MySQLDbType.MYSQLPostgreSQLDbType.POSTGRE_SQLOracleDbType.ORACLESQL ServerDbType.SQL_SERVERDM 达梦DbType.DM如果你的项目用的是多数据源且不同数据源的数据库类型不同那么需要针对不同数据源分别配置。大多数项目只有一种数据库像我上面那样直接指定即可。maxLimit是一个很容易被忽略但很实用的参数。它可以限制单页最大记录数防止前端把size传成巨大数值造成数据库压力PaginationInnerInterceptor paginationInterceptor new PaginationInnerInterceptor(DbType.MYSQL); paginationInterceptor.setMaxLimit(500L);设置了之后即使前端传入size10000插件也会把实际查询条数限制在 500 以内。注意maxLimit和Page对象上的setSize不是一回事maxLimit是插件层面的安全阀setSize是你代码里设定的每页条数。两个配合使用才能既灵活又安全。2.4 旧版配置为什么会导致分页失效如果项目里同时存在PaginationInterceptor和MybatisPlusInterceptor或者使用了一个已经被标记废弃的版本运行时可能有两种表现完全不报错但 SQL 里没有LIMIT接口返回全部数据。启动时报错java.lang.NoSuchMethodError或者类冲突。原因通常是依赖版本混乱。建议做一次彻底的依赖检查排除掉非必要的 mybatis 旧包统一 mybatis-plus 版本并确保配置类里只有一个分页拦截器。3. 基础分页查询实操从 Page 对象开始3.1 分页核心对象Page 与 IPageMyBatis-Plus 为我们提供了现成的分页模型PageT它实现了IPageT接口。Page里几个常用的属性current当前页码从 1 开始size每页条数total总记录数pages总页数records当前页数据列表searchCount是否执行 count 查询optimizeCountSql是否优化 count 查询你最常用的无外乎new Page(current, size)创建一个分页对象然后把数据放进去再取出来。3.2 快速开始selectPage 完成第一页查询假设有一张用户表实体类如下Data TableName(user) public class User { TableId(type IdType.AUTO) private Long id; private String name; private Integer age; private Integer status; private LocalDateTime createTime; }Mapper 继承BaseMapperT就自带selectPage方法public interface UserMapper extends BaseMapperUser { }查询第一页每页 10 条PageUser page new Page(1, 10); PageUser result userMapper.selectPage(page, null); ListUser records result.getRecords(); // 当前页数据 long total result.getTotal(); // 总记录数 long pages result.getPages(); // 总页数 long current result.getCurrent(); // 当前页码 long size result.getSize(); // 每页条数这里第二个参数传的是null表示不带条件查询。这样做没问题但我建议业务中尽量都通过Wrapper传条件哪怕是空的 wrapper也方便后续加过滤逻辑时改动最小。3.3 返回结果的正确解读selectPage返回的Page对象其实和传入的page是同一个对象。插件在执行完 SQL 之后会把总数和记录列表填充回这个对象中。所以你既可以用返回值接收也可以不用返回值直接读原对象PageUser page new Page(1, 10); userMapper.selectPage(page, null); ListUser records page.getRecords(); // 同样能拿到数据从可读性角度我更推荐用返回值接收语义更清晰。这里还涉及一个经常会遇到的问题selectPage(page, null)如果page是nullMyBatis-Plus 会把它当成普通查询执行也就是不进行分页直接返回所有数据。所以调用之前一定要判断分页参数是否为空尤其在后端接收前端参数时要对current和size做默认值处理。3.4 分页结果的统一封装你可能不想把PageUser直接暴露给前端因为前端只需要records、total、current、size、pages这些字段而且实体内还有一些你不想返回的敏感字段。此时最好做一个统一的结果封装Data public class PageResultT { private Long total; private Long pages; private Long current; private Long size; private ListT records; public static T PageResultT of(IPageT page) { PageResultT result new PageResult(); result.setTotal(page.getTotal()); result.setPages(page.getPages()); result.setCurrent(page.getCurrent()); result.setSize(page.getSize()); result.setRecords(page.getRecords()); return result; } }Controller 层使用GetMapping(/users) public ResultPageResultUserVO pageUsers(RequestParam(defaultValue 1) long current, RequestParam(defaultValue 10) long size) { PageUser page new Page(current, size); PageUser result userMapper.selectPage(page, null); return Result.success(PageResult.of(result)); }把实体转成 VO 的时候再利用 BeanUtil 之类的工具做批量复制接口层保持整洁。4. 条件分页与动态查询业务开发最常用的姿势4.1 基于 QueryWrapper 的条件分页selectPage方法的第二个参数就是条件构造器WrapperT。如果你想查询“状态正常、年龄大于等于 18、姓名包含某个关键字”的用户可以这样写QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(status, 1) .ge(age, 18) .like(name, 张) .orderByDesc(create_time); PageUser page new Page(1, 10); PageUser result userMapper.selectPage(page, wrapper);QueryWrapper需要写数据库字段名比如status、age、create_time。这种写法简单直接也支持动态条件。但缺点是不够安全字段名写错只有在运行时才会发现而且如果数据库字段改名这种写死的字符串很容易漏改。4.2 LambdaQueryWrapper 的规范写法更推荐用LambdaQueryWrapper它通过 lambda 表达式引用实体字段编译期就能检查字段是否正确LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(User.class); wrapper.eq(User::getStatus, 1) .ge(User::getAge, 18) .like(User::getName, 张) .orderByDesc(User::getCreateTime); PageUser page new Page(1, 10); PageUser result userMapper.selectPage(page, wrapper);lambda 写法有如下几个好处字段名发生重构时可以自动同步代码更易读配合 IDEA 能识别实体字段引用4.3 动态拼接条件避免参数为空导致查询错误实际业务中查询条件往往不是固定的。用户可能传姓名、可能不传姓名可能按时间筛选、可能不按时间筛选。如果盲目把所有条件加上会导致搜不到数据。MyBatis-Plus 的Wrapper支持“条件成立才拼接”的写法可以直接在方法第一个参数传一个布尔条件String name 张; Integer status 1; LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(User.class); wrapper.eq(status ! null, User::getStatus, status) .like(StrUtil.isNotBlank(name), User::getName, name) .orderByDesc(User::getCreateTime); PageUser page new Page(1, 10); PageUser result userMapper.selectPage(page, wrapper);这种“参数为空就不加条件”的写法能让代码非常简洁也不会因为空字符串、null 参数导致 SQL 出现问题。4.4 排序与分页的组合使用排序可以直接写在 wrapper 里也可以写进Page对象。差别在于wrapper 里的排序是写死的业务逻辑Page 里的排序适合接收前端传来的排序字段比如前端点表格的表头排序。用 Page 对象传排序PageUser page new Page(1, 10); page.addOrder(OrderItem.desc(create_time));这也意味着前端传sortField和sortOrder时可以动态构造OrderItem。不过要非常注意动态排序字段一定不要直接拼接字符串要使用白名单校验否则会有 SQL 注入风险。比如SetString allowedSortFields Set.of(createTime, age); if (!allowedSortFields.contains(sortField)) { throw new IllegalArgumentException(非法排序字段); }5. 自定义 SQL 多表分页进阶必备5.1 场景分析为什么需要自定义 SQLBaseMapper的selectPage只能支持单表查询。但业务里最常见的其实是多表联查用户表关联部门表、用户表关联角色表、订单表关联商品表等此时你就必须自己写 SQL。有人会问能不能先用单表分页查出用户再根据用户信息补查部门、角色小数据量可以但数据量大了就会出现经典的 “1 N” 查询问题性能会很差。所以最合理的做法是多表 join 的 SQL 直接支持分页。5.2 使用 IPage 参数实现多表查询分页MyBatis-Plus 对自定义分页的支持方式很灵活。你只需要在 Mapper 方法中把第一个参数设为IPageT返回值设为IPageT即可。public interface UserMapper extends BaseMapperUser { IPageUserVO selectUserPage(IPageUserVO page, Param(name) String name, Param(deptId) Long deptId); }注意这里的泛型是UserVO不是User因为查询结果要拼上部门名称、角色名称等额外字段。5.3 XML 中分页 SQL 的写法Mapper XML 里写 SQL 时不用手动加LIMIT。你写业务查询本身MyBatis-Plus 会在执行时自动把分页语句拼上去select idselectUserPage resultTypecom.example.vo.UserVO SELECT u.id, u.name, u.age, u.status, u.create_time, d.dept_name, GROUP_CONCAT(r.role_name) AS roleNames FROM user u LEFT JOIN dept d ON u.dept_id d.id LEFT JOIN user_role ur ON ur.user_id u.id LEFT JOIN role r ON r.id ur.role_id where if testname ! null and name ! AND u.name LIKE CONCAT(%, #{name}, %) /if if testdeptId ! null AND u.dept_id #{deptId} /if /where GROUP BY u.id ORDER BY u.create_time DESC /select调用方式PageUserVO page new Page(1, 10); IPageUserVO result userMapper.selectUserPage(page, name, deptId);关键在于如果你把第一个参数写成了普通参数没有传IPage插件就不会触发分页。这也是很多人自定义 SQL 分页不生效的最常见原因。5.4 复杂分页与 Count 优化当你的 SQL 里有GROUP BY、DISTINCT、子查询或者表关联特别多时MyBatis-Plus 自动生成的 count SQL 可能会不符合预期常见表现是 total 数值偏大或 count 执行很慢。针对这类情况可以将 count 查询单独定义。Page对象提供了一个countId属性用来指定自定义 count 方法 idPageUserVO page new Page(1, 10); page.setCountId(selectUserPageCount); IPageUserVO result userMapper.selectUserPage(page, name, deptId);在同一个 Mapper 中定义 count 查询select idselectUserPageCount resultTypelong SELECT COUNT(DISTINCT u.id) FROM user u LEFT JOIN dept d ON u.dept_id d.id where if testname ! null and name ! AND u.name LIKE CONCAT(%, #{name}, %) /if if testdeptId ! null AND u.dept_id #{deptId} /if /where /select注意 count 的方法参数也要带上和主查询一样的Param参数这样才能正确拼接动态条件。6. 常见问题排查与避坑指南6.1 分页不生效查回了全部数据这是遇到最多的问题没有之一。分页不生效的原因通常是下面几个配置类没生效MybatisPlusConfig没有被 Spring 扫描到把配置类放到启动类能扫描到的包下。方法第一个参数类型不对自定义 SQL 的分页方法第一个参数必须是IPage如果你写成了Long current, Long size然后自己拼 limit那插件自然不会动你的 SQL。方法返回值类型不对如果你想拿 total返回值必须写成IPageT或PageT。依赖冲突项目中存在多份 mybatis-plus 版本导致拦截器类被不同 ClassLoader 加载。排查步骤先在本地打开 SQL 日志观察实际打印出来的 SQL 是否有LIMIT没有就说明拦截器没生效。6.2 拦截器不生效或版本冲突启动时报错大多是版本问题。比如java.lang.NoSuchMethodError: com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor这种一般是代码里用了旧版 API而实际引用的 jar 已经是新版。处理方式全局搜索项目里是否用了PaginationInterceptor统一 mybatis-plus-boot-starter 的版本清除本地 Maven 仓库中残留的旧版本 mybatis-plus6.3 总数统计异常total 不对主要有两类总数比实际大多见于多表 join 之后存在一对多关系比如用户和角色一对多直接 count 会把重复用户算进去。解决办法是使用COUNT(DISTINCT u.id)或者自定义 count SQL。总数比实际小多见于 SQL 里有GROUP BY插件生成的 count 可能去掉了某些条件。解决办法同样是自定义 count。6.4 分页参数校验的重要性当前端传入非法参数时如果你的代码不做校验会出现诡异的分页结果甚至拖垮数据库if (current 1) { current 1; } if (size 1 || size 100) { size 10; }这里的 size 上限可以按业务定但一定要有上限保护和拦截器的maxLimit形成双重保险。7. 性能优化与深层分页实践7.1 深分页的效率问题假设你使用 MySQL分页到第 100 页每页 10 条最终的 SQL 是LIMIT 990, 10。MySQL 会先扫描前 1000 行再丢弃前 990 行只返回最后 10 行。如果偏移量到了一千万它就会扫描一千万行再丢掉性能会急剧下降。所以不要觉得“物理分页就一定快”物理分页只是避免了全量数据通过网络传输到应用层数据库的扫描成本仍然存在。7.2 基于游标的分页方式对于大数据量场景更推荐“基于游标”的分页也就是根据上一页最后一条记录的排序字段来取下一页SELECT id, name, create_time FROM user WHERE create_time #{lastCreateTime} ORDER BY create_time DESC LIMIT 10这种方式不会产生大偏移量不管翻到多少页扫描行数都很稳定。适合典型的“加载更多”场景但不适合用户直接点击跳转到第 1000 页的场景。如果业务必须要深分页也可以考虑延迟关联这种技巧SELECT u.* FROM user u INNER JOIN ( SELECT id FROM user ORDER BY create_time DESC LIMIT 990, 10 ) t ON u.id t.id这里子查询只在索引上做排序和偏移再和原表关联拿完整数据数据库处理效率比直接大偏移要好一些。7.3 count 性能优化策略数据量大时COUNT(*)本身也不便宜。有两种处理思路业务允许时直接关闭 count 查询比如“加载更多”模式每次只返回下一页有没有数据不需要精确总页数。使用近似总数把总数放到缓存里定时更新页面展示“约 XX 万条记录”即可。关闭 count 的方式PageUser page new Page(1, 10); page.setSearchCount(false);7.4 接口联调中的分页返回规范前端在联调时最好统一使用以下字段命名避免各写一套字段含义records当前页数据列表total总记录数current当前页size每页条数pages总页数后端返回时直接使用前面提到的PageResultT统一包装。这样前端写分页组件时只需要固定读这几个字段不用改来改去。我在实际项目里发现很多前端出错其实是因为后端把 records 返回成了别的字段名比如 list、rows、data。接口返回结构稳定比什么都重要。最后再分享一个小技巧如果你自己封装了分页组件建议在后端把current和size都加默认值并且在前端传参时统一走同一个分页参数解析工具。我见过不少因为current从 0 开始计数、从 1 开始计数的混乱问题后端多做一层校验和转换能省掉大把联调时间。分页这个东西看起来简单但它横跨 SQL 解析、数据库方言、接口设计、前端联调任何一个环节粗心都会折腾半天。希望这篇整理能帮你把这条路走顺。
返回列表