ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Mybatis-Plus 实战:Spring Boot 中高效 CRUD 与进阶技巧

Mybatis-Plus 实战:Spring Boot 中高效 CRUD 与进阶技巧 1. 项目概述为什么是 Mybatis-Plus如果你正在用 Java 做后端开发尤其是和数据库打交道那你肯定绕不开 MyBatis。它是个好东西灵活、强大但写多了 XML 映射文件尤其是那些简单的增删改查CRUD难免会觉得有点“啰嗦”。一个简单的User表你得写insert、deleteById、updateById、selectById这些 SQL虽然不复杂但架不住表多啊每个表来一套代码量就上去了而且重复性极高。Mybatis-Plus简称 MP就是为了解决这个“啰嗦”而生的。它不是要取代 MyBatis而是在 MyBatis 的基础上做增强核心目标就一个简化开发、提高效率。你可以把它理解成 MyBatis 的“瑞士军刀”扩展包内置了大量开箱即用的功能最招牌的就是通用 CRUD 操作。你只需要定义好实体类继承 MP 提供的BaseMapper那些基础的增删改查方法就直接可用了一行 XML 都不用写。这对于快速构建业务原型、开发管理后台这类 CRUD 密集型的应用来说效率提升是立竿见影的。这个教程我会从一个实际开发者的角度带你从零开始手把手搭建一个整合了 Mybatis-Plus 的 Spring Boot 项目并完成一套完整的、包含一些进阶技巧的 CRUD 操作。我们不止讲“怎么做”更会聊清楚“为什么这么做”以及在实际项目中我踩过哪些坑、有哪些最佳实践。目标是让你看完就能在自己的项目里用起来并且用得明白、用得踏实。2. 环境准备与项目初始化工欲善其事必先利其器。我们先来把项目架子搭好。我习惯用 Spring Boot 来快速构建项目这里以目前比较稳定的 Spring Boot 2.7.x 版本和 MySQL 数据库为例。2.1 依赖引入与关键配置首先创建一个新的 Spring Boot 项目。你可以用 IDEA 的 Spring Initializr或者直接在pom.xml里添加依赖。核心依赖就三个Spring Boot Web Starter用于构建Web应用、MySQL Driver数据库驱动、以及 Mybatis-Plus 的 Spring Boot Starter。这里有一个细节需要注意Mybatis-Plus 已经包含了 MyBatis 的核心依赖所以你不需要再单独引入mybatis-spring-boot-starter引入反而可能引起版本冲突。我的pom.xml关键依赖部分通常长这样dependencies !-- Spring Boot Web -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- Mybatis-Plus 起步依赖 (关键) -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version !-- 请使用当时最新稳定版 -- /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency !-- Lombok用于简化实体类代码非必须但强烈推荐 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency !-- 测试 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies注意Mybatis-Plus 的版本请务必去官方仓库查看最新稳定版。版本不匹配是启动报错的常见原因之一。依赖搞定后就是application.yml或application.properties的配置了。基础的数据库连接和 MP 的配置必不可少spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/mp_demo?useUnicodetruecharacterEncodingutf-8useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password # Mybatis-Plus 配置 mybatis-plus: configuration: # 控制台打印执行 SQL 及参数开发环境非常有用 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: # 全局表前缀如果所有表都有共同前缀如 t_可以在这里配置 # table-prefix: t_ # 全局主键类型AUTO 表示数据库自增INPUT 表示手动输入ASSIGN_ID 表示雪花算法ID id-type: ASSIGN_ID这里我强烈建议在开发阶段开启log-impl配置它会在控制台打印出 MP 最终执行的 SQL 语句和参数对于调试和理解 MP 的行为至关重要。看到生成的 SQL你心里才踏实。2.2 实体类与 Mapper 接口定义接下来我们创建一个简单的User实体类对应数据库中的user表。这里会用到 MP 的核心注解。package com.example.mpdemo.entity; import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; Data // Lombok 注解自动生成 getter/setter/toString 等方法 TableName(user) // 指定关联的数据库表名如果类名和表名一致忽略大小写可省略 public class User { /** * 主键 * TableId 注解用于标识主键 * type IdType.ASSIGN_ID使用雪花算法生成 Long 类型 ID (默认策略) * 如果数据库是自增则使用 IdType.AUTO */ TableId(type IdType.ASSIGN_ID) private Long id; /** * 用户名 */ private String username; /** * 密码 */ private String password; /** * 年龄 */ private Integer age; /** * 邮箱 */ private String email; /** * 创建时间 * TableField 注解用于配置非主键字段 * fill FieldFill.INSERT在插入操作时自动填充 */ TableField(fill FieldFill.INSERT) private LocalDateTime createTime; /** * 更新时间 * fill FieldFill.INSERT_UPDATE在插入和更新操作时自动填充 */ TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; /** * 逻辑删除标识0-未删除1-已删除 * TableLogic 注解标识逻辑删除字段 * value “0” 表示未删除的值 * delval “1” 表示已删除的值 */ TableLogic(value “0”, delval “1”) private Integer deleted; }实体类定义好了Mapper 接口就简单得不可思议。你只需要创建一个接口继承 MP 提供的BaseMapper并指定泛型为你的实体类。package com.example.mpdemo.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.mpdemo.entity.User; // 无需写任何方法继承 BaseMapper 即拥有了基础的 CRUD 方法 public interface UserMapper extends BaseMapperUser { }是的就这样。BaseMapperUser这个泛型接口已经为你预定义了数十个通用方法。你不需要写实现类MP 会在运行时通过动态代理帮你生成。记得在 Spring Boot 主类或者配置类上加上MapperScan(“com.example.mpdemo.mapper”)注解告诉 Spring 去哪里扫描这些 Mapper 接口。3. 核心 CRUD 操作详解与实战环境搭好基础代码写完现在我们来真正操作数据库。我会把增、删、改、查分开讲并融入一些实际开发中更实用的技巧。3.1 插入Create操作的多场景应用基础的插入就是insert方法。但实际项目中我们往往有更多需求。基础插入Autowired private UserMapper userMapper; public void testInsert() { User user new User(); user.setUsername(“张三”); user.setPassword(“123456”); user.setAge(25); user.setEmail(“zhangsanexample.com”); // createTime 和 updateTime 由 MP 的自动填充处理器处理这里不用set int rows userMapper.insert(user); // 返回影响的行数 System.out.println(“插入成功影响行数” rows); System.out.println(“插入后用户ID为” user.getId()); // MP 会自动回填主键 ID }执行后控制台会打印类似INSERT INTO user ( id, username, password, age, email, create_time, update_time ) VALUES ( ?, ?, ?, ?, ?, ?, ? )的 SQL。你会发现即使我们没有设置id、create_time、update_timeMP 也帮我们处理了。id根据TableId的策略这里是雪花算法生成并回填到实体对象中时间字段则由我们后面要配置的“元对象处理器”自动填充。批量插入对于一次性插入多条数据使用insert方法循环效率较低。MP 提供了insertBatchSomeColumn方法需配合 SQL 注入器默认已注入但更通用和高效的做法是使用Service层的saveBatch方法后面会讲到 Service。这里先提一个思路在实际高性能场景下可以考虑使用 MP 的SqlInjector配合自定义方法或者直接使用 MyBatis 的foreach标签编写 XML。对于大多数业务Service.saveBatch足够用。插入时忽略字段假设User对象是从前端接收的里面可能包含一个前端生成的临时ID而数据库ID是自增或雪花算法生成的。你希望插入时忽略实体类中的某个字段比如一个非数据库字段的token可以使用TableField(exist false)注解标记该字段MP 在构建 SQL 时会自动忽略它。3.2 查询Read操作的丰富 API查询是业务中最频繁的操作。BaseMapper提供了从根据ID查询到复杂条件组合查询的一系列方法。根据 ID 查询User user userMapper.selectById(1544865477235572737L);非常简单直接。根据条件查询单个QueryWrapperUser queryWrapper new QueryWrapper(); queryWrapper.eq(“username”, “张三”) // WHERE username ‘张三’ .eq(“age”, 25); // AND age 25 User user userMapper.selectOne(queryWrapper);selectOne期望查询结果只有一条。如果查询到多条会抛出TooManyResultsException。QueryWrapper是 MP 条件构造器的核心用于构建WHERE子句。根据条件查询列表QueryWrapperUser queryWrapper new QueryWrapper(); queryWrapper.like(“username”, “张”) // WHERE username LIKE ‘%张%’ .gt(“age”, 20) // AND age 20 .orderByDesc(“age”); // ORDER BY age DESC ListUser userList userMapper.selectList(queryWrapper);like、gt(greater than)、orderByDesc这些方法语义非常清晰像拼积木一样构建查询条件。分页查询分页是后台管理系统标配。MP 提供了强大的分页插件需要先配置。配置分页插件在配置类中注入PaginationInnerInterceptor。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 添加分页插件 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }使用分页查询Test public void testSelectPage() { PageUser page new Page(1, 10); // 查询第1页每页10条 QueryWrapperUser queryWrapper new QueryWrapper(); queryWrapper.isNotNull(“email”); PageUser userPage userMapper.selectPage(page, queryWrapper); System.out.println(“总记录数” userPage.getTotal()); System.out.println(“总页数” userPage.getPages()); System.out.println(“当前页数据” userPage.getRecords()); }执行后MP 会先执行一条COUNT(*)查询总记录数再执行带LIMIT的分页查询。这是物理分页数据量大时要注意性能。选择特定字段查询有时我们只需要部分字段避免SELECT *。QueryWrapper的select方法可以指定字段。QueryWrapperUser queryWrapper new QueryWrapper(); queryWrapper.select(“id”, “username”, “email”) // 只查询这三个字段 .like(“username”, “admin”); ListUser userList userMapper.selectList(queryWrapper);这生成的 SQL 是SELECT id, username, email FROM user WHERE username LIKE ‘%admin%’对于大表或宽表能有效减少网络传输和内存占用。3.3 更新Update操作与乐观锁实践更新操作最常用的是根据 ID 更新。但直接全字段更新可能带来风险MP 推荐使用UpdateWrapper进行动态更新。根据 ID 更新实体User user new User(); user.setId(1544865477235572737L); user.setAge(26); user.setEmail(“new_emailexample.com”); int rows userMapper.updateById(user); // UPDATE user SET age?, email? WHERE id?这里只会更新user对象中非null的字段。这是 MP 的默认策略非常智能。根据条件更新UpdateWrapperUser updateWrapper new UpdateWrapper(); updateWrapper.eq(“age”, 25) // WHERE age 25 .set(“age”, 26) // SET age 26 .set(“email”, “updatedexample.com”); int rows userMapper.update(null, updateWrapper); // 第一个参数为 null 表示不传入实体或者使用 Lambda 表达式避免字段名写错字符串LambdaUpdateWrapperUser lambdaUpdateWrapper new LambdaUpdateWrapper(); lambdaUpdateWrapper.eq(User::getAge, 25) .set(User::getAge, 26) .set(User::getEmail, “updatedexample.com”); int rows userMapper.update(null, lambdaUpdateWrapper);乐观锁实现在高并发更新场景下防止数据覆盖是关键。MP 内置了乐观锁支持。在实体类版本字段上加Version注解。Version private Integer version;在配置类中注册乐观锁插件。// 在刚才的 MybatisPlusConfig 的拦截器里添加 interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());更新时MP 会自动使用版本号。User user userMapper.selectById(1L); // 此时 user.version 0 user.setAge(user.getAge() 1); userMapper.updateById(user); // SQL: UPDATE user SET age?, version? WHERE id? AND version? // 如果此时该记录已被其他线程修改version已变则本次更新影响行数为0可据此判断更新失败。乐观锁能有效解决“丢失更新”问题是并发编程的常用手段。3.4 删除Delete操作与逻辑删除配置删除分为物理删除和逻辑删除。物理删除直接从数据库抹掉数据风险高。生产环境更推荐使用逻辑删除。物理删除// 根据ID删除 int rows userMapper.deleteById(1L); // 根据条件删除 QueryWrapperUser queryWrapper new QueryWrapper(); queryWrapper.lt(“age”, 18); // WHERE age 18 int rows userMapper.delete(queryWrapper);逻辑删除逻辑删除是更新一个标记字段而非真正删除记录。我们之前在User实体中定义的deleted字段就是用于此目的。配置全局逻辑删除规则在application.yml中mybatis-plus: global-config: db-config: logic-delete-field: deleted # 全局逻辑删除的实体字段名 logic-delete-value: 1 # 逻辑已删除值默认为 1 logic-not-delete-value: 0 # 逻辑未删除值默认为 0在实体类字段上添加TableLogic注解前面已加。执行删除操作userMapper.deleteById(1L);此时MP 实际执行的 SQL 是UPDATE user SET deleted1 WHERE id? AND deleted0。所有自动生成的查询语句也会自动加上AND deleted0条件。这样对于业务层来说调用delete方法“删除”了数据调用select方法也查不到被“删除”的数据实现了无感知的逻辑删除。如果需要查询包含已逻辑删除的数据需要使用自定义 SQL 或Wrapper手动覆盖条件。4. 进阶技巧与 Service 层封装直接使用Mapper进行 CRUD 在简单场景下没问题但业务复杂后我们通常会在Mapper之上再封装一层Service。MP 同样为我们提供了强大的IService接口和ServiceImpl实现类。4.1 创建 Service 接口与实现// UserService 接口继承 MP 的 IService public interface UserService extends IServiceUser { // 这里可以定义自己的业务方法 ListUser selectUsersByComplexCondition(CustomQuery query); } // UserServiceImpl 实现类 Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { Autowired private SomeOtherComponent component; // 可以注入其他组件 Override public ListUser selectUsersByComplexCondition(CustomQuery query) { // 实现自定义复杂业务逻辑 LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); // ... 构建复杂条件 return this.list(wrapper); } }Service层继承了IService它提供了比BaseMapper更丰富的业务方法例如saveBatch批量插入、listByIds根据ID集合查询、updateBatchById根据ID批量更新等这些方法都经过了优化。4.2 自动填充与类型处理器自动填充MetaObjectHandler我们之前实体类中的createTime和updateTime字段标记了TableField(fill …)现在需要实现处理器来为它们赋值。Component // 必须声明为 Spring 组件 public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, “createTime”, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, “updateTime”, LocalDateTime.class, LocalDateTime.now()); // 也可以填充其他字段例如当前用户ID // this.strictInsertFill(metaObject, “createBy”, String.class, getCurrentUserId()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, “updateTime”, LocalDateTime.class, LocalDateTime.now()); } }这样在执行insert或update方法时MP 会自动调用相应方法为指定字段填充值。strictInsertFill和strictUpdateFill是 MP 3.3.0 后推荐的方法它能进行严格的类型匹配避免出错。类型处理器TypeHandler如果数据库字段类型和 Java 属性类型不是常规映射比如把对象存成 JSON 字符串可以使用TableField(typeHandler …)指定类型处理器。MP 内置了常用的处理器如JacksonTypeHandler用于 JSON。4.3 条件构造器的 Lambda 表达式写法之前我们用QueryWrapper时条件字段名是用字符串表示的这容易写错且重构不友好。MP 强烈推荐使用基于 Lambda 表达式的条件构造器。LambdaQueryWrapperUser lambdaQueryWrapper new LambdaQueryWrapper(); lambdaQueryWrapper.eq(User::getUsername, “张三”) // 方法引用编译期安全 .between(User::getAge, 20, 30) .isNotNull(User::getEmail) .orderByDesc(User::getCreateTime); ListUser list userMapper.selectList(lambdaQueryWrapper);LambdaUpdateWrapper用法类似。这种方式利用 Java 编译器的类型检查彻底告别字段名的魔法字符串是提升代码健壮性的最佳实践。5. 常见问题排查与性能优化心得在实际使用中你肯定会遇到一些“坑”。这里我总结几个高频问题和我的处理经验。5.1 启动或运行时常见异常Invalid bound statement (not found)原因这是最经典的 MyBatis 异常。MP 动态代理 Mapper 接口需要找到对应的 SQL 映射。可能的原因有Mapper 接口没有被扫描到。检查MapperScan注解的路径是否正确。MybatisPlusConfig配置类没有被 Spring 管理缺少Configuration。实体类的主键注解TableId配置错误或者实体类与数据库表映射有问题。排查首先检查控制台启动日志看是否有Mapped “…”这样的语句确认你的 Mapper 被成功加载。开启 MP 的 SQL 日志看执行时是否生成了正确的 SQL。插入时主键冲突原因TableId策略配置与数据库不匹配。例如数据库字段是AUTO_INCREMENT但实体类配置了TableId(type IdType.ASSIGN_ID)雪花算法插入时 MP 会尝试设置一个 Long 型 ID与数据库自增冲突。解决确保id-type配置与数据库表设计一致。自增用AUTO手动赋值用INPUT分布式场景用ASSIGN_ID雪花算法或ASSIGN_UUID。字段更新为 null 失效现象使用updateById(entity)时即使 entity 的某个字段为null该字段在数据库中也未被更新为NULL。原因MP 的默认更新策略是“非 null 更新”即只更新实体对象中不为null的字段。这是为了防止意外覆盖。解决如果确实需要更新为null使用UpdateWrapper的set方法.set(“fieldName”, null)。或者在字段上使用TableField(strategy FieldStrategy.IGNORED)但这样会全局忽略该字段的 null 判断需谨慎。5.2 性能优化与最佳实践慎用select *尽量使用QueryWrapper.select(…)指定需要查询的字段尤其是在表字段很多或有关联查询时。这能显著减少数据库服务器和网络传输的数据量。分页查询优化MP 的物理分页Page在数据量极大百万级以上时COUNT(*)操作可能会很慢。对于不需要精确总数的情况可以考虑使用“滚动查询”或者前端“加载更多”的模式。如果需要精确分页且COUNT慢可以尝试对条件字段加索引或者使用其他近似计数方案。批量操作使用 Service 方法IService提供的saveBatch、updateBatchById等方法内部使用了ExecutorType.BATCH模式比在循环中单条执行insert/update性能高得多。对于大批量数据操作务必使用这些批量方法。复杂查询或联表查询MP 的条件构造器对于单表动态 SQL 构建是利器但对于复杂的多表关联查询或者非常定制化的 SQL如窗口函数、复杂子查询它的表达能力会受限。不要强行用 Wrapper 去拼装过于复杂的 SQL那样代码可读性会变得很差。这时退一步回到 MyBatis 的老本行——在 XML 映射文件或注解中编写清晰、可维护的 SQL是更明智的选择。MP 和原生 MyBatis 是兼容的你完全可以在同一个 Mapper 接口中既有 MP 生成的通用方法也有自己写的自定义 SQL 方法。监控生成的 SQL始终在开发环境开启mybatis-plus.configuration.log-impl配置。观察生成的 SQL 是否符合你的预期特别是条件构造器生成的WHERE子句、分页的LIMIT语句。这是调试和性能分析的第一步。最后我想说的是Mybatis-Plus 是一个极大地提升了开发效率的工具但它不是银弹。理解它的原理知道它的边界比如复杂 SQL在合适的场景使用它才能让它真正成为你开发中的得力助手而不是新的负担。从简单的 CRUD 开始逐步尝试条件构造器、分页、乐观锁、自动填充这些特性你会慢慢体会到它“简化开发”的哲学。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进