十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

SpringBoot3 + MybatisPlus 查询开发与性能优化实战

SpringBoot3 + MybatisPlus 查询开发与性能优化实战 1. 先理清楚SpringBoot3 MybatisPlus 这套东西到底解决了什么我最早接触 MybatisPlus 还是 2.x 时代当时公司老项目还在用 SSM 手写 XML 映射。后来切到 SpringBoot 后最直观的体验就是单表 CRUD 再也不用写 XML 了BaseMapper 里那些 selectById、selectOne、selectList 基本能满足日常 80% 的需求。现在换成 SpringBoot3其实核心逻辑没变只是底层的依赖包、初始化方式有一些要注意的地方特别是 MybatisPlus 的版本必须要匹配 SpringBoot3 的 jakarta 命名空间。我见过不少直接把 Boot2 项目的依赖搬过来然后启动直接报 ClassNotFoundException 的大多是这步没对齐。先说结论SpringBoot3 MybatisPlus 用起来本质上就是利用 MybatisPlus 帮你封装好的通用 Mapper 接口加上一套条件构造器来拼查询条件然后让 MybatisPlus 在底层把 Java 方法调用翻译成 SQL 执行。对于查询这块你只要弄明白两件事第一BaseMapper 到底给了你哪些现成方法第二QueryWrapper 和 LambdaQueryWrapper 怎么用才能写清楚、写安全。这两件事通了查询开发效率至少翻一倍。这套方案适合谁刚接触 SpringBoot3 想快速上手数据库操作的初级开发也适合在旧项目里被 XML 里大段动态 SQL 折磨过、想换个清爽写法的老手。另外如果你在带团队统一用 MybatisPlus 之后代码风格会收敛很多Review 成本也会降下来这个收益往往是隐性的但非常值钱。1.1 为什么我在 SpringBoot3 里还是选 MybatisPlus其实 SpringBoot3 出来了以后社区里也有人讨论是不是该换 JPA、或者直接上 jOOQ。我的看法是选型这事别追新得看你团队和项目的实际情况。MybatisPlus 在国内生态扎根太深了网上资料多、招人成本低、同事之间交接也方便而且它对 Mybatis 的增强非常克制你要用原生能力随时可以写 XML 或者注解 SQL不存在被框架锁死的问题。SpringBoot3 相比 Boot2 最大的变化是底层从 javax 迁移到了 jakarta所以 MybatisPlus 必须用 3.5.3 以上版本3.5.3 是比较好用的里程碑版本3.5.7 之后对 SpringBoot3 的支持已经非常稳定。我之前一个项目用的是 mybatis-plus-boot-starter 3.5.5配合 SpringBoot 3.2.x整个迁移过程基本没啥大坑。如果你用的是 3.5.3 之前的版本会出现各种奇怪的不兼容问题比如 Mapper 扫描不到、启动时 Bean 注入失败这类问题排查起来非常耗时。还有一个值得说的点MybatisPlus 对 Mybatis 的增强是透明的你该用 #{} 参数占位还是用 ${} 拼接该注意的 SQL 注入风险一个都不能少。换句话说MybatisPlus 不等于无脑安全它只是把简单查询变简单了复杂查询和底层 SQL 优化还是得靠你自己的基本功。1.2 项目基础结构与依赖配置这一节直接给你一套可以复制粘贴的配置省得你自己去翻文档踩版本坑。我用的环境是 JDK 17 SpringBoot 3.2.x MybatisPlus 3.5.5这套组合现在非常成熟建议你照着来。dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency注意SpringBoot3 的 starter 里已经包含了 mybatis 和 mybatis-spring 的适配不需要额外引入 mybatis 依赖否则可能版本冲突。spring: datasource: url: jdbc:mysql://localhost:3306/demo_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这里我重点解释两个细节。map-underscore-to-camel-case 这个配置默认就是 true但我建议你显式写出来因为一旦有人把全局配置覆盖掉字段映射会出很多隐蔽问题。log-impl 配成 StdOutImpl 是开发阶段的调试利器它能让你在控制台直接看到 MybatisPlus 帮你生成的 SQL 和参数排查问题效率翻倍。2. Mapper 层到底能做什么从 BaseMapper 到条件构造器Mapper 层在 MybatisPlus 里承担的角色相当于把你对数据库的操作声明成一个个 Java 接口方法。按我的理解它的核心价值有三块第一单表基础操作不需要手写 SQL方法名即语义第二条件构造器让你用 Java 代码表达查询条件编译期就能发现字段名写错的问题Lambda 表达式版本第三复杂查询可以继续用注解或 XML不会断了你的后路。很多初学者容易有个误区觉得 Mapper 层就是放 SQL 的地方于是把所有查询逻辑都堆在 Mapper 里。实际上MybatisPlus 推荐的做法是 Mapper 只做数据访问业务判断和条件组装放到 Service 层去。你可以在 Service 里构建 LambdaQueryWrapper然后调用 mapper.selectList(wrapper)这样 Mapper 接口保持干净Service 层负责业务编排测试和维护都更舒服。2.1 BaseMapper 内置方法最常用的几个往死里用BaseMapper 提供的现成方法说实话够你写大部分业务了。我按使用频率排一下你心里就有数了。方法作用使用场景selectById按主键查询单条详情页、编辑回显selectOne按条件查询单条多于一调会报错唯一业务查询、登录校验selectList按条件查询列表列表页、批量查询selectPage分页查询后台管理系统列表selectCount按条件统计数量总数展示、权限校验selectBatchIds按主键集合批量查询根据 ID 列表拉数据selectMaps返回 Map 列表动态字段、报表查询insert/updateById/deleteById写操作常规增删改selectOne 这个方法我要敲个黑板如果你查出来有多条记录MybatisPlus 默认是会直接抛异常的但这恰好是你提前发现脏数据的机会。比如一个用户表理论上手机号唯一如果 selectOne 报错说明数据已经有问题这时候要做的不是改成 selectList 然后取第一条而是去查数据为什么会重复不然问题会被掩盖掉。还有一个比较常用的场景是 selectBatchIds它生成的 SQL 是 WHERE id IN (...)。我之前优化过一个批量查询接口以前是循环调 selectById一次请求几百个 ID 就要几百次数据库连接改成 selectBatchIds 之后性能提升了十倍不止。这是一个非常经典且容易忽略的性能优化点一定要养成习惯。2.2 LambdaQueryWrapper写查询条件的最佳姿势QueryWrapper 和 LambdaQueryWrapper 的区别用一句话说就是前者字段名用字符串后者用 Lambda 方法引用。我强烈建议你直接用 LambdaQueryWrapper因为字段名写字符串容易在重构时漏改编译期也发现不了运行起来才知道 SQL 报错。Lambda 方式直接用实体类的 getter 方法重构时 IDE 能自动同步安全性高一大截。举一个实际的例子LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getStatus, 1) .like(StringUtils.hasText(keyword), User::getUsername, keyword) .between(User::getCreateTime, beginTime, endTime) .orderByDesc(User::getId); ListUser userList userMapper.selectList(wrapper);这段代码里有几个点值得细说。第一个是 eq、like、between 这些方法的第一个参数我都传了一个 boolean 条件这是 MybatisPlus 的一个经典设计当这个条件为 false 时对应的 SQL 片段会自动省略。所以用 StringUtils.hasText(keyword) 配合 like就能优雅地处理前端传参为空的情况不用自己写一堆 if else 去拼条件。第二个是条件构造器生成的 SQL 是带 WHERE 的动态 SQL且所有参数都是用 #{} 占位符预编译的所以天然防 SQL 注入。这一点非常重要你可以去看控制台打印出来的 SQLlog-impl 开启后一目了然比如上面这段会生成类似 SELECT id,username,status,create_time FROM user WHERE status? AND username LIKE ? AND create_time BETWEEN ? AND ? ORDER BY id DESC 这样的语句参数通过占位符传递。当然LambdaQueryWrapper 也不是万能钥匙。比如你要查一张表里两个字段之间的比较a 大于 bwrapper 里的 gt 方法直接传的是值不是字段引用这种场景就做不了。这时你就需要写自定义 SQL 或者在 XML 里处理。所以正确的态度是简单条件用 wrapper 快速搞定复杂查询果断走自定义 SQL不要硬拗。2.3 注解 SQL 与 Provider复杂查询的正确打开方式当条件构造器不够用的时候MybatisPlus 允许你在 Mapper 接口方法上直接写注解 SQL形式是这样Select(SELECT * FROM user WHERE id #{id}) User selectUserById(Long id);注解 SQL 的优点是轻量方法定义和 SQL 在同一个文件里改起来方便。缺点是如果 SQL 特别长Java 注解里写起来很难受而且没有 XML 的语法高亮和自动补全。我个人经验是长度控制在 5 行以内的 SQL 用注解没问题超过这个规模建议直接上 XML。另外还有一个进阶玩法叫 SelectProvider它可以通过一个类的方法来动态生成 SQL适合复用性高、条件分支多的查询。举个例子SelectProvider(type UserSqlProvider.class, method selectPageByCondition) ListUser selectPageByCondition(Param(query) UserQuery query);然后 Provider 类里就可以用 Mybatis 的 SQL 构建器或者拼接 String 的方式写动态 SQLpublic class UserSqlProvider { public String selectPageByCondition(UserQuery query) { return new SQL() {{ SELECT(*); FROM(user); if (query.getStatus() ! null) { WHERE(status #{query.status}); } if (StringUtils.hasText(query.getUsername())) { WHERE(username LIKE CONCAT(%, #{query.username}, %)); } }}.toString(); } }这种方式能让复杂的条件查询逻辑集中在一个地方管理而且避开了 XML 文件的路劲配置和加载问题。不过我的建议是团队里如果没人用过 Provider那就不要轻易上学习成本和维护成本都算进去很多时候标准 XML 反而更稳妥。技术选型最怕的不是某个方案不好而是团队里一半人懂一半人不懂。3. 查询性能与细节优化踩过的坑和调优经验查询开发出来只是第一步能经受住生产环境的流量考验才是关键。这一节我把自己在做查询优化时踩过的坑和沉淀下来的方法讲一下尤其是分页慢、字段映射、N1 这些常见问题基本每个项目都会遇到。3.1 分页查询的正确姿势与配置MybatisPlus 的分页查询不需要你手写 LIMIT它默认提供 Page 对象和分页插件但有个关键点分页拦截器必须显式配置成 Bean否则 selectPage 只是内存分页数据量一大就会 OOM。这是新手最容易踩的坑。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInterceptor new PaginationInnerInterceptor(DbType.MYSQL); paginationInterceptor.setMaxLimit(500L); interceptor.addInnerInterceptor(paginationInterceptor); return interceptor; } }setMaxLimit(500L) 是我故意加的这个参数用来限制单次查询最大条数防止有人调接口时传一个巨大的 pageSize 把数据库打爆。之前我看网上有人问“mybatisplus 单页 500 条限制怎么去掉”其实那个限制不是 MybatisPlus 默认的而是有同学在配置里设置了 maxLimit 或者分页参数被统一拦截处理了如果你没设置过它不会默认限制 500 条。实际使用分页的时候Service 里一般是这么写的PageUser page new Page(pageNum, pageSize); LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(StringUtils.hasText(status), User::getStatus, status) .orderByDesc(User::getCreateTime); PageUser result userMapper.selectPage(page, wrapper); ListUser records result.getRecords(); long total result.getTotal();这里要注意selectPage 返回的 Page 对象里除了 records 之外total 是插件自动执行 count 查询得到的。如果你查询的表数据量非常大、且列表几乎不需要显示总数可以手动设置 page.setSearchCount(false) 来跳过 count 查询能省掉一次全表 count 的开销分页速度会有明显提升。3.2 大数据量查询慢查询、内存与分页优化分页查询慢是后端开发最常见的问题之一。这里我先说一个生产环境里常见的现象数据量到了几十万、上百万以后Deep Page 翻页会越来越慢原因是 MySQL 执行 LIMIT 100000, 20 时需要先扫描、丢弃前 10 万行再取出目标 20 行相当于每次翻页都在白白扫数据。解决思路有两种。第一种是前端限制深度翻页只允许访问前 200 页超过就提示用户条件筛选这在很多管理系统里已经够用了。第二种是改成游标分页Keyset Pagination用上次查询结果里的某个排序字段值作为下页起点。举个例子// 第一页 LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.gt(User::getId, 0) .orderByAsc(User::getId) .last(LIMIT 20); ListUser list userMapper.selectList(wrapper); // 下一页拿上一页最后一条记录的 id 作为游标 wrapper.gt(User::getId, lastId) .orderByAsc(User::getId) .last(LIMIT 20);这种方式的性能非常稳定因为每次查询都走了主键索引数据量翻倍查询耗时基本不变。缺点是你不能再随意跳页只能一页一页往后翻所以适合“加载更多”这类场景不适合传统分页条控件。还有一个补充优化如果业务上必须用传统分页且数据量大可以考虑在 Redis 里缓存热门查询条件的 ID 列表分页时先用 Redis 拿 ID 集合再到数据库 IN 查询这样能明显缓解深分页压力。不过缓存一致性要考虑好数据变更后要及时清理缓存不然列表数据会不准确。3.3 查询结果的字段映射细节MybatisPlus 默认开启下划线转驼峰所以你的数据库字段是 create_time实体类是 createTime这是能自动映射的。但有几个容易出问题的场景我给你列一下第一如果数据库字段和实体字段对不上比如历史表里有个字段叫 user_name_old实体里叫 oldName这时你需要用 TableField 注解显式指定TableField(user_name_old) private String oldName;第二查询结果里如果有聚合字段比如 SUM、COUNT 的别名而实体类里没有对应字段可以用 Map 接收。比如查用户的订单总数Select(SELECT u.id, COUNT(o.id) AS order_count FROM user u LEFT JOIN orders o ON u.id o.user_id GROUP BY u.id) ListMapString, Object selectUserOrderCount();返回的 Map 里 key 是查询列名value 是值前端拿到后再做数据组装即可。注意这里不要把下划线转驼峰想当然地用在 Map 的 key 上Mybatis 对 Map 的 key 默认不做转换除非你做了特殊配置。第三一对多查询时最忌讳的是在 Mapper 里用循环调单表查询也就是 N1 查询问题。比如查一个部门下所有用户如果先查部门列表再循环查每个部门下的用户那就是 N1。正确做法是先批量查出所有部门 ID再用 IN 查询拿到所有用户最后在 Java 内存里根据部门 ID 分组组装或者直接用 XML 里的 collection 标签做嵌套结果映射。我做过一个优化把循环查询改成一次 IN 查询接口响应时间从 2 秒降到了 100 毫秒以内效果极其显著。4. 常见问题与排查技巧实录这一节直接把我在群里和实际项目中见到的典型问题整理成一个速查表每个问题都有现场表现和解决思路方便你以后遇到同类问题直接对号入座。4.1 Mapper 注入空指针定时任务里最常见的坑有个非常典型的报错在定时任务里用 Autowired 注入 Mapper结果一执行就抛 NullPointerException。我第一次遇到时排查了很久后来发现问题出在定时任务类的实例化方式上。如果你是在 SpringBoot 的 Scheduled 方法里注入 Mapper正常来说 Spring 容器会管理这个 Bean不会空指针。但如果你的定时任务类是自己 new 出来的或者用了一些动态代理机制里没有交给 Spring 管理那 Autowired 自然就是 null。解决办法很直接所有用到 Mapper 的类都必须交给 Spring 管理最简单的方式是加上 Component 注解。另外还有一个常见场景是用 Timer 或者 Quartz 执行的 Job 类里注入了 Mapper但 Job 是通过反射创建的不在 Spring 容器内此时可以通过 ApplicationContext.getBean() 来获取 Mapper或者在 Job 类里实现一个 ApplicationContextAware 的工具类来手动拿 Bean。我在项目里写过一个 SpringContextUtils 工具类专门解决这类问题大家可以参考这种做法。Component public class SpringContextUtils implements ApplicationContextAware { private static ApplicationContext applicationContext; Override public void setApplicationContext(ApplicationContext context) { applicationContext context; } public static T T getBean(ClassT clazz) { return applicationContext.getBean(clazz); } } // 使用方式 UserMapper userMapper SpringContextUtils.getBean(UserMapper.class);这个问题在 SpringBoot3 里也一样网上搜“timer 执行查询是报空指针”的同学十有八九就是这个原因建议先检查类有没有被 Spring 管理。4.2 in 查询报错与关键字冲突in 查询报错这个问题我见过的场景大概有三类。第一类是 collection 参数没有加 Param 注解Mybatis 解析不到集合名报语法错误。解决办法就是方法参数上显式加 Param(ids)。第二类是传入的集合为空时生成的 SQL 是 WHERE id IN ()这在 MySQL 里直接语法报错。所以你在调用 in 查询前一定要判空或者在 XML/注解里加一个条件判断集合为空就跳过这个条件。第三类是 in 查询列表太大超过 MySQL 对 IN 参数数量和查询语句长度的限制。这种情况需要分批查询比如每 500 个 ID 一批然后合并结果。我封装过一个批量查询工具方法实现思路是先用 Lists.partition 把大集合切分成小批次然后循环查询并 addAll。另外还要注意如果 in 查询的字段没走索引大列表查询会非常慢一定要在对应字段上建索引。再补充一个关键字冲突的问题。MybatisPlus 里有些字段名是数据库关键字比如 describe、order、group直接写在 LambdaQueryWrapper 里会生成非法 SQL。解决方式有两种一种是给实体字段加上 TableField 注解指定反引号包裹的列名另一种是在自定义 SQL 里手动给关键字加反引号例如 SELECT describe FROM user。这里最稳的方案还是建表时尽量避开关键字如果历史原因没法改那就老老实实用注解处理。4.3 关于“单页 500 条限制”和慢查询日志的澄清先回答热搜里那个高频问题MybatisPlus 并没有默认的单页 500 条限制。如果你遇到每页最多 500 条的情况基本是项目里有人配置了 PaginationInnerInterceptor 的 maxLimit或者你在 Controller 层对 pageSize 做了统一上限校验。我建议你不要随便调大这个限制而是分析业务到底需不需要一次渲染几百条数据大多数情况是前端交互设计的问题而不是后端限制的问题。慢查询这块有两个层面可以做。第一个是开启 MySQL 的慢查询日志定位哪些 SQL 执行时间超阈值SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;第二个是在 MybatisPlus 层面把 log-impl 配置成 StdOutImpl 只能看到本地控制台输出生产环境建议配合 p6spy 或者 SkyWalking 这类工具来做线上 SQL 性能监控它们能看到实际执行耗时和参数绑定。我之前有个接口在测试环境一切正常上线后缓慢就是用 SkyWalking 抓出来是某个表缺少索引导致的全表扫描加上索引后问题立刻消失。数据库查询优化第一条永远是看执行计划、看索引不要一上来就在代码层折腾方向反了事倍功半。根据我个人经验MybatisPlus 的查询大部分性能问题都出在业务代码的查询次数上而不是单条 SQL 本身。能用一次查询解决的就别拆成多次能用批量查询就别用循环查询能走索引就让条件字段都带上索引。这几点做到位你的查询接口基本不会出现大的性能事故。最后再分享一个小技巧每次改完查询逻辑都去控制台把 MybatisPlus 打印的 SQL 复制出来放到 Navicat 里跑一下执行计划看一眼走了什么索引做到这一步你说自己不会优化查询都没人信。
返回列表