MyBatis-Plus CRUD操作深度解析:从基础使用到多租户实战
2026/8/24 8:44:40 网站建设 项目流程

1. 从“手写SQL”到“开箱即用”:MyBatis-Plus的CRUD革命

如果你和我一样,是从原生的MyBatis时代一路走过来的开发者,那么你一定对那段“手写增删改查”的时光记忆犹新。每个实体类都要配一个XML映射文件,哪怕只是简单的根据ID查询,也得写上一段<select id="selectById" resultMap="BaseResultMap">SELECT * FROM table WHERE id = #{id}</select>。项目初期还好,随着业务表越来越多,这种重复劳动不仅枯燥,还极易出错,比如字段名写错、参数类型不匹配,或者忘了更新那个冗长的BaseResultMap。那时候,我们渴望一种能“偷懒”的方式,让基础的数据库操作能像调用List.add()一样简单直接。

MyBatis-Plus(简称MP)的出现,正是这场“偷懒革命”的产物。它并不是要取代MyBatis,而是在其强大灵活的基础上,套上了一层极其便利的“糖衣”。它的核心愿景之一,就是通过内置的通用Mapper,彻底解放开发者对于单表基础CRUD(Create, Read, Update, Delete)的操作。你不再需要为每一个UserMapperOrderMapper去编写那些千篇一律的insertupdateByIdselectList方法。MP通过泛型技术,在运行时为我们动态生成了这些方法。这意味着,只要你的实体类继承了MP的Model类或者你的Mapper接口继承了BaseMapper<T>,你就立刻拥有了数十个强大的、开箱即用的数据库操作方法。

今天,我们就来深入聊聊MP这些内置方法,特别是最常用的插入、更新、删除和查询。但我们的讨论不会停留在简单的API调用层面。我会结合自己多年在复杂业务场景下的使用经验,带你看看这些方法在平静表面下的“暗流涌动”,比如updateById为什么有时无法将字段更新为null,如何优雅地实现“插入或更新”(SaveOrUpdate),以及在多租户等高级场景下,这些方法行为会发生哪些微妙变化。理解这些,你才能真正从“会用MP”进阶到“精通MP”。

2. 插入方法:不仅仅是insert

当我们拿到一个需要持久化的实体对象时,insert方法通常是第一选择。MP提供了最基础的int insert(T entity)方法。使用起来非常简单:

User user = new User(); user.setName("张三"); user.setAge(25); user.setEmail("zhangsan@example.com"); int rows = userMapper.insert(user); // 返回受影响的行数 System.out.println("插入成功,主键ID为:" + user.getId());

这里有一个MP非常贴心的设计:自动回写主键。如果你的表主键是自增的(如MySQL的AUTO_INCREMENT),在执行insert之后,MP会自动从数据库获取生成的主键值,并塞回实体对象的对应属性中。这一点在需要立即使用该ID进行后续关联操作(如插入子表记录)时,非常方便,无需再次查询。

2.1 字段策略与null值处理

插入操作第一个容易踩的坑,就是关于null值的处理。假设我们的User表有一个avatar(头像)字段,允许为NULL。在创建用户时,我们可能没有头像信息,所以user.setAvatar(null),或者干脆不设置。那么,这个null值会被插入数据库吗?

这取决于MP的字段策略(FieldStrategy)。MP默认的全局插入策略是FieldStrategy.NOT_NULL。什么意思?它表示:当字段值为null时,这个字段将不会出现在最终的INSERT SQL语句中。对于数据库来说,这个字段会使用表定义的默认值(如果设置了的话),或者就是NULL。

这听起来很合理,但有时会引发问题。例如,你的业务逻辑明确要求将某个字段置为NULL,以清空之前的数据。如果你直接setXxx(null)并调用insert,MP会忽略这个字段,导致插入的不是NULL,而是旧值或默认值。为了解决这个问题,你有几种选择:

  1. 局部注解控制:在实体类的特定字段上使用@TableField注解。

    public class User { // 其他字段... @TableField(insertStrategy = FieldStrategy.IGNORED) // 插入时忽略判断,null值也会拼入SQL private String avatar; }

    insertStrategy设置为FieldStrategy.IGNORED,MP在生成插入语句时就会无条件包含此字段,null值也会被写入。

  2. 全局配置修改:在MP的全局配置中,修改默认的插入策略。

    # application.yml mybatis-plus: global-config: db-config: insert-strategy: ignored # 全局设置插入策略为“忽略”

    但请注意,这通常不是好主意,因为它会影响所有实体所有字段,可能导致一些非空字段被意外插入NULL而引发数据库异常。

实操心得:对于允许为NULL且有明确“置空”业务的字段,我推荐使用第一种方式(@TableField(insertStrategy = FieldStrategy.IGNORED))进行精确控制。保持全局策略为默认的NOT_NULL,可以避免大多数意外的NULL值插入,更安全。

2.2 批量插入的“性能陷阱”

MP提供了insertBatch方法,但这里有一个巨大的认知误区:MP 3.x版本默认的insertBatch并不是真正的批量SQL!我们来看看源码或行为:在默认情况下,insertBatch方法实际上是在循环中执行单条INSERT语句。这意味着如果有1000条数据,它会向数据库发送1000次请求,执行1000条独立的SQL,性能开销极大。

List<User> userList = ... // 1000个用户对象 userService.saveBatch(userList); // 默认情况下,是循环单条插入

真正的批量插入(Batch Insert),是指将多条INSERT语句合并成一条INSERT INTO table (cols) VALUES (vals1), (vals2), (vals3)...的SQL,一次网络通信,一次数据库执行,效率有数量级的提升。

如何启用真正的批量插入?

  1. 配置SQL注入器(旧版方式,已过时):早期版本需要自定义。

  2. 使用SqlInjector并开启rewriteBatchedStatements(推荐):这是目前的主流做法。

    • 首先,在数据库连接URL中(以MySQL为例)添加参数rewriteBatchedStatements=true
      jdbc:mysql://localhost:3306/db?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=true
      这个参数会告诉MySQL驱动,尝试重写批量语句。
    • 其次,在Service层使用saveBatch方法时,还需要配合正确的使用方式。MP在开启rewriteBatchedStatements后,其saveBatch方法会利用MyBatis的ExecutorType.BATCH模式,将多个插入操作打包提交。
  3. 使用MP的executeBatch方法:你可以自己获取SqlSession,将其执行器类型设置为Batch,然后手动循环插入,最后统一提交。这种方式更底层,控制更细。

踩坑记录:我曾经在一個数据迁移任务中,因为没开rewriteBatchedStatements,用默认的saveBatch插入10万条数据,花了将近10分钟。开启后,同样的数据量不到30秒就完成了。这个参数对批量操作性能的影响是决定性的,务必检查。

3. 更新方法:动态SQL与“null”值难题

更新操作是业务中最频繁的之一,MP最常用的更新方法莫过于updateById(T entity)。它根据实体对象的主键(@TableId标注的字段)生成UPDATE table SET ... WHERE id = ?的语句。

User user = new User(); user.setId(1L); // 指定主键 user.setName("李四"); // 只更新name字段 user.setEmail(null); // 试图将email字段更新为null int rows = userMapper.updateById(user);

这段代码的意图是:将id为1的用户的姓名改为“李四”,同时将邮箱清空(设为NULL)。但结果很可能事与愿违:姓名成功修改了,但邮箱字段没有被更新为NULL

3.1updateById无法更新null值的根源

这个问题的根源和插入时的null值问题同宗同源,都出在字段策略上。MP默认的更新字段策略是FieldStrategy.NOT_NULL。这意味着,在生成UPDATE语句的SET部分时,MP会检查每个字段的值:如果为null,则跳过这个字段。

所以,上面代码生成的SQL大概是:

UPDATE user SET name = '李四' WHERE id = 1;

email字段因为值为null,被策略过滤掉了,根本没有出现在SQL里,自然无法更新。

3.2 解决方案:策略控制与UpdateWrapper

要让updateById能更新null值,我们有几种武器:

  1. 字段注解控制:和插入一样,在实体类字段上使用@TableField

    public class User { @TableField(updateStrategy = FieldStrategy.IGNORED) // 更新时忽略判断 private String email; }

    这样,无论email字段是什么值,都会参与更新。

  2. 全局配置修改(慎用):同样可以在yml中配置update-strategy: ignored,但风险同上。

  3. 使用UpdateWrapper(动态SQL):这是更灵活、更推荐的方式。UpdateWrapper允许你完全脱离实体对象,直接指定要更新的SET内容和WHERE条件。

    UpdateWrapper<User> updateWrapper = new UpdateWrapper<>(); updateWrapper.eq("id", 1L) // WHERE id = 1 .set("name", "李四") // SET name = ‘李四’ .set("email", null); // SET email = NULL int rows = userMapper.update(null, updateWrapper); // 第一个参数为null,表示不依赖实体对象

    通过UpdateWrapper.set()方法设置的字段,会直接拼接到SQL的SET部分,MP的字段策略对其无效。因此,你可以明确地将一个字段设置为null

3.3 更新方法的其他“姿势”

除了updateById,MP还有其他更新方法:

  • update(T entity, Wrapper<T> updateWrapper):根据Wrapper条件更新,同时entity中非null字段也会参与更新(受策略控制)。两者是“与”的关系。
  • updateBatchById(Collection<T> entityList):批量根据ID更新。注意:和批量插入一样,默认可能不是最优的批量模式,性能优化思路类似。

经验之谈:对于简单的、全字段更新,updateById配合@TableField(updateStrategy = FieldStrategy.IGNORED)很简洁。但对于复杂的、条件动态的更新,尤其是涉及null值操作时,我几乎总是选择UpdateWrapper。它代码意图更清晰,不受实体对象状态和字段策略的干扰,尤其是在方法参数中接收更新字段的场景下,优势明显。

4. 插入或更新方法:saveOrUpdate的智慧

“如果存在则更新,不存在则插入”,这是一个非常常见的业务需求,通常被称为“upsert”。MP的Service层提供了saveOrUpdate方法族来实现这个逻辑。

// 传入一个实体对象 userService.saveOrUpdate(user); // 传入一个实体对象集合 userService.saveOrUpdateBatch(userList);

这个方法用起来很简单,但它的内部逻辑值得深究,因为它直接关系到数据的正确性。

4.1saveOrUpdate的判断逻辑

saveOrUpdate如何判断是“插入”还是“更新”呢?它的核心判断依据是:实体对象的主键是否为空

  1. 如果主键为null0(对于数值类型)或者空字符串(如果主键是String类型),MP会认为这是一条新记录,执行插入操作。
  2. 如果主键有值(非null且非空),MP会先根据这个主键去数据库查询记录。
    • 如果查询到了记录,则执行更新操作(更新时同样受字段策略影响)。
    • 如果没有查询到记录,则执行插入操作,并且插入时会使用你提供的这个主键值。

这个逻辑在大多数自增主键场景下工作良好:新对象不设ID,MP执行插入,数据库生成ID;更新时,对象携带从数据库查出来的ID,MP执行更新。

4.2 非自增主键与业务主键的坑

问题出现在非自增主键的场景,比如你的主键是业务ID(如订单号order_no)、UUID或者来自其他系统的ID。

场景模拟:你有一个Device表,主键是设备序列号sn(String类型)。你从外部接口收到一个设备信息Device(sn="SN123456", status=1)。你调用deviceService.saveOrUpdate(device),希望如果数据库没有SN123456就新增,有就更新状态。

这里有一个潜在风险:如果外部传入的sn本身就是null或空字符串,根据MP的逻辑,它会执行插入。但你的数据库表很可能设置了sn字段为NOT NULL且是主键,这会导致插入失败,抛出数据库异常。

更隐蔽的坑:即使sn有值,比如"SN123456",MP会先去查询。如果没查到,它会执行插入。但是,插入时使用的sn值就是"SN123456"吗?是的。这看起来没问题。但如果你同时配置了MP的主键生成策略,比如@TableId(type = IdType.ASSIGN_UUID),问题就来了。这个注解会告诉MP:“在插入时,如果主键为空,我帮你生成一个UUID;如果主键有值,就用你提供的值”。在saveOrUpdate的插入分支中,因为主键有值("SN123456"),所以生成策略不会生效,直接使用你提供的值。这符合预期。但如果你错误地在业务主键上也配置了IdType.AUTO(自增),MP可能会忽略你提供的值,试图用数据库自增,导致主键冲突或数据错乱。

避坑指南:使用saveOrUpdate时,必须清醒地认识你的主键是什么类型,以及MP的主键生成策略如何配置。对于业务主键,确保@TableId(type = IdType.INPUT),这表示“插入时使用我手动输入的值”。同时,在业务逻辑层做好校验,避免传入空的主键值。

4.3 更精确的控制:saveOrUpdate(Wrapper)

有时,判断是否存在,不仅仅依赖于主键,可能还依赖于其他业务字段的组合(唯一索引)。MP提供了更强大的saveOrUpdate(T entity, Wrapper<T> updateWrapper)方法。

它的逻辑是:

  1. 根据updateWrapper指定的条件去数据库查询。
  2. 如果查询到记录,则用entity中的非null字段(受策略控制)去更新这些记录(WHERE条件就是updateWrapper)。
  3. 如果没有查询到记录,则执行插入entity

这给了我们极大的灵活性。例如,用户表用邮箱作为唯一标识,而不是主键ID:

LambdaUpdateWrapper<User> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(User::getEmail, user.getEmail()); // WHERE email = ? userService.saveOrUpdate(user, wrapper);

这段代码实现了“根据邮箱判断用户是否存在,存在则更新,不存在则插入”的功能,非常实用。

5. 删除方法:物理删除与逻辑删除

删除操作相对简单,但涉及到一个重要的业务概念:物理删除 vs 逻辑删除

  • 物理删除:使用DELETE FROM table WHERE ...语句,将数据从磁盘上真正移除。MP的deleteByIddelete(Wrapper)等方法默认就是物理删除。
  • 逻辑删除:实际上并不删除数据,而是通过更新一个标志位(如deleted字段,0表示未删除,1表示已删除)来标记数据已“删除”。查询时自动过滤掉已标记删除的数据。

现代企业级应用,为了数据安全和审计追踪,几乎全部采用逻辑删除。MP对逻辑删除提供了原生支持。

5.1 配置与使用逻辑删除

  1. 实体类字段添加注解

    public class User { @TableLogic // 标记此字段为逻辑删除标志字段 private Integer deleted; // 通常使用 Integer(0/1)或 Boolean(false/true) }
  2. 全局配置逻辑删除值(可选,如果字段名和值不是默认的):

    mybatis-plus: global-config: db-config: logic-delete-field: deleted # 全局逻辑删除的实体字段名 logic-delete-value: 1 # 逻辑已删除值(默认为 1) logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)

配置完成后,所有MP发起的删除操作,都会自动变为更新操作:

userMapper.deleteById(1L); // 实际执行:UPDATE user SET deleted = 1 WHERE id = 1 AND deleted = 0

同时,所有MP发起的查询操作,都会自动加上deleted = 0的条件:

userMapper.selectList(null); // 实际执行:SELECT * FROM user WHERE deleted = 0

5.2 逻辑删除下的“坑”

逻辑删除虽好,但也带来一些需要适应的地方:

  • 唯一索引冲突:这是逻辑删除最经典的坑。假设user表的email字段有唯一索引。你删除了邮箱为a@a.com的用户(deleted置为1)。后来又想新建一个邮箱同为a@a.com的用户。插入时会失败,因为唯一索引约束认为a@a.com已经存在(即使那条记录被标记为删除)。解决方案通常有:1)删除唯一索引,用程序保证;2)建立“邮箱+删除状态”的复合唯一索引((email, deleted)),但这要求deleted只能是0或1;3)使用删除时间戳等字段,并将deleted改为delete_time,未删除时为NULL,删除时填入时间,唯一索引建在(email, delete_time)上,但MP需要自定义逻辑删除处理。

  • 联表查询:当你需要User表和Order表联查,并且两个表都启用了逻辑删除时,MP自动添加的deleted=0条件只会加在User表上。你需要在Order表的查询条件中手动添加逻辑删除条件,或者使用MP的@SqlParser注解(已废弃)或最新版的@InterceptorIgnore来局部关闭逻辑删除,但这需要非常小心。

  • 直接写SQL:如果你在XML中或通过@Select注解编写了自定义SQL,MP的逻辑删除拦截器是不会生效的。你必须在SQL中手动加上AND deleted = 0这样的条件。

最佳实践:在项目启动之初就决定是否使用逻辑删除,并统一规范。一旦使用,所有相关表的查询、删除操作都必须经过MP的Mapper方法,避免直接使用原生SQL或@Select注解时遗漏条件,导致查到已“删除”的数据。对于联表查询,建议使用MP的QueryWrapper进行关联查询,它能更好地处理逻辑删除的自动附加条件。

6. 查询方法:Wrapper的魔法世界

MP的查询方法是其魅力的集中体现,它通过QueryWrapper(或LambdaQueryWrapper)将动态查询的构建变得无比优雅。核心方法是selectList(Wrapper<T> queryWrapper),当然还有selectByIdselectOneselectCount等。

6.1 QueryWrapper vs LambdaQueryWrapper

  • QueryWrapper:使用字符串表示字段名。例如:qw.eq("name", "张三")。它的缺点是容易因为字段名拼写错误导致运行时异常,且重构不友好。
  • LambdaQueryWrapper:使用Lambda表达式和方法引用来表示字段名。例如:lqw.eq(User::getName, "张三")。这是强烈推荐的方式,因为它类型安全,IDE支持代码跳转和重构,编译时就能发现错误。
// 不推荐:QueryWrapper QueryWrapper<User> qw = new QueryWrapper<>(); qw.eq("name", "张三").gt("age", 18).orderByDesc("create_time"); List<User> list = userMapper.selectList(qw); // 推荐:LambdaQueryWrapper LambdaQueryWrapper<User> lqw = new LambdaQueryWrapper<>(); lqw.eq(User::getName, "张三") .gt(User::getAge, 18) .orderByDesc(User::getCreateTime); List<User> list = userMapper.selectList(lqw);

6.2 复杂查询构建

Wrapper支持几乎所有的SQL条件:

  • eq, ne:等于、不等于。
  • gt, ge, lt, le:大于、大于等于、小于、小于等于。
  • like, notLike, likeLeft, likeRight:模糊查询。
  • in, notIn:集合查询。
  • isNull, isNotNull:空值判断。
  • groupBy, orderByAsc, orderByDesc:分组和排序。
  • select:指定查询字段,避免SELECT *

一个综合的例子:查询名字包含“张”,年龄在18到60之间,邮箱不为空,并且按照创建时间倒序排列的用户。

LambdaQueryWrapper<User> lqw = new LambdaQueryWrapper<>(); lqw.like(User::getName, "张") .between(User::getAge, 18, 60) .isNotNull(User::getEmail) .orderByDesc(User::getCreateTime); List<User> users = userMapper.selectList(lqw);

6.3 分页查询

MP的分页功能需要额外配置一个分页插件(PaginationInnerInterceptor),否则分页不会生效。

  1. 配置分页插件

    @Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 根据数据库类型选择 return interceptor; } }
  2. 使用分页查询

    // 构建分页对象,参数:当前页,每页大小 Page<User> page = new Page<>(1, 10); // 构建查询条件 LambdaQueryWrapper<User> lqw = new LambdaQueryWrapper<>(); lqw.gt(User::getAge, 20); // 执行分页查询 Page<User> resultPage = userMapper.selectPage(page, lqw); // 获取分页数据 List<User> records = resultPage.getRecords(); // 当前页数据列表 long total = resultPage.getTotal(); // 总记录数 long pages = resultPage.getPages(); // 总页数

性能提示:MP的分页查询会执行两条SQL:一条是COUNT(*)查询总数,一条是带有LIMIT的分页数据查询。在数据量极大时,COUNT(*)可能会很慢。如果不需要知道精确的总数(比如前端是“加载更多”模式),可以考虑使用PagesetSearchCount(false)来禁用COUNT查询,或者使用更高效的分页方式,比如基于游标的分页。

7. 进阶话题:多租户注解与内置方法的联动

“多租户”(Multi-Tenancy)是SaaS系统常见的设计模式,即一套系统为多个客户(租户)服务,但数据在逻辑或物理上是隔离的。MP通过@TenantId注解和租户处理器(TenantLineHandler)提供了优雅的多租户支持。这会对内置方法产生深远影响。

7.1 如何启用多租户

  1. 实体类添加租户ID字段并标记

    public class User { @TableId private Long id; private String name; @TableField(fill = FieldFill.INSERT) // 通常自动填充 @TenantId // 标记此为租户ID字段 private String tenantId; }
  2. 实现TenantLineHandler接口:这个接口用于告诉MP当前请求的租户ID是什么,以及哪些表需要过滤。

    @Component public class MyTenantLineHandler implements TenantLineHandler { // 获取当前租户ID(通常从ThreadLocal、Session或JWT中获取) @Override public Expression getTenantId() { String tenantId = TenantContext.getCurrentTenantId(); // 假设从上下文获取 return new StringValue(tenantId); } // 返回需要多租户过滤的表名(支持正则,返回null或空集合表示所有表都过滤) @Override public String getTenantIdColumn() { return "tenant_id"; } @Override public boolean ignoreTable(String tableName) { // 忽略一些不需要租户隔离的表,如全局配置表 return "sys_config".equalsIgnoreCase(tableName); } }
  3. 添加多租户插件

    @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new MyTenantLineHandler())); // 如果还有分页插件,注意添加顺序,一般租户插件在前 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }

7.2 内置方法行为的改变

一旦启用多租户插件,MP所有内置的CRUD方法都会自动加上租户过滤条件。

  • 查询selectByIdselectListselectPage等,会自动在WHERE条件后追加AND tenant_id = '当前租户ID'
    -- 你写的:SELECT * FROM user WHERE id = 1 -- MP实际执行:SELECT * FROM user WHERE id = 1 AND tenant_id = 'tenant_a'
  • 插入insert方法会自动将当前租户ID设置到实体对象的@TenantId字段(如果该字段为空)。这通常配合@TableField(fill = FieldFill.INSERT)使用,实现自动填充。
  • 更新和删除updateByIddeleteById等方法,也会自动加上租户条件,确保你只能操作自己租户下的数据。
    -- 你写的:UPDATE user SET name='xx' WHERE id=1 -- MP实际执行:UPDATE user SET name='xx' WHERE id=1 AND tenant_id='tenant_a' -- 你写的:DELETE FROM user WHERE id=1 -- MP实际执行:UPDATE user SET deleted=1 WHERE id=1 AND tenant_id='tenant_a' (逻辑删除场景)

7.3 多租户下的特殊场景处理

多租户带来了数据安全,也带来了复杂性:

  • 全表操作(超级管理员):有时需要一个超级管理员角色能查询或操作所有租户的数据。MP提供了@InterceptorIgnore注解(或旧版的@SqlParser)来在Mapper方法上忽略多租户插件。

    @InterceptorIgnore(tenantLine = "true") // 忽略租户过滤 List<User> selectAllTenantData();

    使用此注解需要非常谨慎,并做好权限控制。

  • 关联查询:在多租户场景下进行联表查询,必须确保关联的表都有tenant_id字段,并且MP能正确地为每个表加上租户条件。使用QueryWrapper进行leftJoin时,需要仔细检查生成的SQL。

  • 数据初始化与迁移:在系统初始化或进行跨租户数据迁移时,需要临时绕过租户限制。可以通过编程方式,在特定代码块中设置或清除当前租户上下文来实现。

架构思考:多租户插件是MP非常强大的一个功能,它几乎无侵入地实现了数据隔离。但在引入前,必须和团队明确租户ID的生成、传递和存储规则。尤其是在微服务架构下,租户信息如何在服务间透传(通常通过Feign请求头或消息上下文),需要设计好。否则,一旦租户上下文丢失,MP注入的过滤条件可能会导致查询不到数据或操作错乱,这类问题排查起来非常困难。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询