尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

MyBatis-Plus CRUD操作深度解析:从基础使用到多租户实战

MyBatis-Plus CRUD操作深度解析:从基础使用到多租户实战 1. 从“手写SQL”到“开箱即用”MyBatis-Plus的CRUD革命如果你和我一样是从原生的MyBatis时代一路走过来的开发者那么你一定对那段“手写增删改查”的时光记忆犹新。每个实体类都要配一个XML映射文件哪怕只是简单的根据ID查询也得写上一段select idselectById resultMapBaseResultMapSELECT * FROM table WHERE id #{id}/select。项目初期还好随着业务表越来越多这种重复劳动不仅枯燥还极易出错比如字段名写错、参数类型不匹配或者忘了更新那个冗长的BaseResultMap。那时候我们渴望一种能“偷懒”的方式让基础的数据库操作能像调用List.add()一样简单直接。MyBatis-Plus简称MP的出现正是这场“偷懒革命”的产物。它并不是要取代MyBatis而是在其强大灵活的基础上套上了一层极其便利的“糖衣”。它的核心愿景之一就是通过内置的通用Mapper彻底解放开发者对于单表基础CRUDCreate, Read, Update, Delete的操作。你不再需要为每一个UserMapper、OrderMapper去编写那些千篇一律的insert、updateById、selectList方法。MP通过泛型技术在运行时为我们动态生成了这些方法。这意味着只要你的实体类继承了MP的Model类或者你的Mapper接口继承了BaseMapperT你就立刻拥有了数十个强大的、开箱即用的数据库操作方法。今天我们就来深入聊聊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(zhangsanexample.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)并调用insertMP会忽略这个字段导致插入的不是NULL而是旧值或默认值。为了解决这个问题你有几种选择局部注解控制在实体类的特定字段上使用TableField注解。public class User { // 其他字段... TableField(insertStrategy FieldStrategy.IGNORED) // 插入时忽略判断null值也会拼入SQL private String avatar; }将insertStrategy设置为FieldStrategy.IGNOREDMP在生成插入语句时就会无条件包含此字段null值也会被写入。全局配置修改在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性能开销极大。ListUser userList ... // 1000个用户对象 userService.saveBatch(userList); // 默认情况下是循环单条插入真正的批量插入Batch Insert是指将多条INSERT语句合并成一条INSERT INTO table (cols) VALUES (vals1), (vals2), (vals3)...的SQL一次网络通信一次数据库执行效率有数量级的提升。如何启用真正的批量插入配置SQL注入器旧版方式已过时早期版本需要自定义。使用SqlInjector并开启rewriteBatchedStatements推荐这是目前的主流做法。首先在数据库连接URL中以MySQL为例添加参数rewriteBatchedStatementstruejdbc:mysql://localhost:3306/db?useUnicodetruecharacterEncodingutf8rewriteBatchedStatementstrue这个参数会告诉MySQL驱动尝试重写批量语句。其次在Service层使用saveBatch方法时还需要配合正确的使用方式。MP在开启rewriteBatchedStatements后其saveBatch方法会利用MyBatis的ExecutorType.BATCH模式将多个插入操作打包提交。使用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值我们有几种武器字段注解控制和插入一样在实体类字段上使用TableField。public class User { TableField(updateStrategy FieldStrategy.IGNORED) // 更新时忽略判断 private String email; }这样无论email字段是什么值都会参与更新。全局配置修改慎用同样可以在yml中配置update-strategy: ignored但风险同上。使用UpdateWrapper动态SQL这是更灵活、更推荐的方式。UpdateWrapper允许你完全脱离实体对象直接指定要更新的SET内容和WHERE条件。UpdateWrapperUser 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 更新方法的其他“姿势”除了updateByIdMP还有其他更新方法update(T entity, WrapperT updateWrapper)根据Wrapper条件更新同时entity中非null字段也会参与更新受策略控制。两者是“与”的关系。updateBatchById(CollectionT entityList)批量根据ID更新。注意和批量插入一样默认可能不是最优的批量模式性能优化思路类似。经验之谈对于简单的、全字段更新updateById配合TableField(updateStrategy FieldStrategy.IGNORED)很简洁。但对于复杂的、条件动态的更新尤其是涉及null值操作时我几乎总是选择UpdateWrapper。它代码意图更清晰不受实体对象状态和字段策略的干扰尤其是在方法参数中接收更新字段的场景下优势明显。4. 插入或更新方法saveOrUpdate的智慧“如果存在则更新不存在则插入”这是一个非常常见的业务需求通常被称为“upsert”。MP的Service层提供了saveOrUpdate方法族来实现这个逻辑。// 传入一个实体对象 userService.saveOrUpdate(user); // 传入一个实体对象集合 userService.saveOrUpdateBatch(userList);这个方法用起来很简单但它的内部逻辑值得深究因为它直接关系到数据的正确性。4.1saveOrUpdate的判断逻辑saveOrUpdate如何判断是“插入”还是“更新”呢它的核心判断依据是实体对象的主键是否为空。如果主键为null、0对于数值类型或者空字符串如果主键是String类型MP会认为这是一条新记录执行插入操作。如果主键有值非null且非空MP会先根据这个主键去数据库查询记录。如果查询到了记录则执行更新操作更新时同样受字段策略影响。如果没有查询到记录则执行插入操作并且插入时会使用你提供的这个主键值。这个逻辑在大多数自增主键场景下工作良好新对象不设IDMP执行插入数据库生成ID更新时对象携带从数据库查出来的IDMP执行更新。4.2 非自增主键与业务主键的坑问题出现在非自增主键的场景比如你的主键是业务ID如订单号order_no、UUID或者来自其他系统的ID。场景模拟你有一个Device表主键是设备序列号snString类型。你从外部接口收到一个设备信息Device(snSN123456, status1)。你调用deviceService.saveOrUpdate(device)希望如果数据库没有SN123456就新增有就更新状态。这里有一个潜在风险如果外部传入的sn本身就是null或空字符串根据MP的逻辑它会执行插入。但你的数据库表很可能设置了sn字段为NOT NULL且是主键这会导致插入失败抛出数据库异常。更隐蔽的坑即使sn有值比如SN123456MP会先去查询。如果没查到它会执行插入。但是插入时使用的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, WrapperT updateWrapper)方法。它的逻辑是根据updateWrapper指定的条件去数据库查询。如果查询到记录则用entity中的非null字段受策略控制去更新这些记录WHERE条件就是updateWrapper。如果没有查询到记录则执行插入entity。这给了我们极大的灵活性。例如用户表用邮箱作为唯一标识而不是主键IDLambdaUpdateWrapperUser wrapper new LambdaUpdateWrapper(); wrapper.eq(User::getEmail, user.getEmail()); // WHERE email ? userService.saveOrUpdate(user, wrapper);这段代码实现了“根据邮箱判断用户是否存在存在则更新不存在则插入”的功能非常实用。5. 删除方法物理删除与逻辑删除删除操作相对简单但涉及到一个重要的业务概念物理删除 vs 逻辑删除。物理删除使用DELETE FROM table WHERE ...语句将数据从磁盘上真正移除。MP的deleteById、delete(Wrapper)等方法默认就是物理删除。逻辑删除实际上并不删除数据而是通过更新一个标志位如deleted字段0表示未删除1表示已删除来标记数据已“删除”。查询时自动过滤掉已标记删除的数据。现代企业级应用为了数据安全和审计追踪几乎全部采用逻辑删除。MP对逻辑删除提供了原生支持。5.1 配置与使用逻辑删除实体类字段添加注解public class User { TableLogic // 标记此字段为逻辑删除标志字段 private Integer deleted; // 通常使用 Integer0/1或 Booleanfalse/true }全局配置逻辑删除值可选如果字段名和值不是默认的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 05.2 逻辑删除下的“坑”逻辑删除虽好但也带来一些需要适应的地方唯一索引冲突这是逻辑删除最经典的坑。假设user表的email字段有唯一索引。你删除了邮箱为aa.com的用户deleted置为1。后来又想新建一个邮箱同为aa.com的用户。插入时会失败因为唯一索引约束认为aa.com已经存在即使那条记录被标记为删除。解决方案通常有1删除唯一索引用程序保证2建立“邮箱删除状态”的复合唯一索引(email, deleted)但这要求deleted只能是0或13使用删除时间戳等字段并将deleted改为delete_time未删除时为NULL删除时填入时间唯一索引建在(email, delete_time)上但MP需要自定义逻辑删除处理。联表查询当你需要User表和Order表联查并且两个表都启用了逻辑删除时MP自动添加的deleted0条件只会加在User表上。你需要在Order表的查询条件中手动添加逻辑删除条件或者使用MP的SqlParser注解已废弃或最新版的InterceptorIgnore来局部关闭逻辑删除但这需要非常小心。直接写SQL如果你在XML中或通过Select注解编写了自定义SQLMP的逻辑删除拦截器是不会生效的。你必须在SQL中手动加上AND deleted 0这样的条件。最佳实践在项目启动之初就决定是否使用逻辑删除并统一规范。一旦使用所有相关表的查询、删除操作都必须经过MP的Mapper方法避免直接使用原生SQL或Select注解时遗漏条件导致查到已“删除”的数据。对于联表查询建议使用MP的QueryWrapper进行关联查询它能更好地处理逻辑删除的自动附加条件。6. 查询方法Wrapper的魔法世界MP的查询方法是其魅力的集中体现它通过QueryWrapper或LambdaQueryWrapper将动态查询的构建变得无比优雅。核心方法是selectList(WrapperT queryWrapper)当然还有selectById、selectOne、selectCount等。6.1 QueryWrapper vs LambdaQueryWrapperQueryWrapper使用字符串表示字段名。例如qw.eq(name, 张三)。它的缺点是容易因为字段名拼写错误导致运行时异常且重构不友好。LambdaQueryWrapper使用Lambda表达式和方法引用来表示字段名。例如lqw.eq(User::getName, 张三)。这是强烈推荐的方式因为它类型安全IDE支持代码跳转和重构编译时就能发现错误。// 不推荐QueryWrapper QueryWrapperUser qw new QueryWrapper(); qw.eq(name, 张三).gt(age, 18).orderByDesc(create_time); ListUser list userMapper.selectList(qw); // 推荐LambdaQueryWrapper LambdaQueryWrapperUser lqw new LambdaQueryWrapper(); lqw.eq(User::getName, 张三) .gt(User::getAge, 18) .orderByDesc(User::getCreateTime); ListUser 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之间邮箱不为空并且按照创建时间倒序排列的用户。LambdaQueryWrapperUser lqw new LambdaQueryWrapper(); lqw.like(User::getName, 张) .between(User::getAge, 18, 60) .isNotNull(User::getEmail) .orderByDesc(User::getCreateTime); ListUser users userMapper.selectList(lqw);6.3 分页查询MP的分页功能需要额外配置一个分页插件PaginationInnerInterceptor否则分页不会生效。配置分页插件Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 根据数据库类型选择 return interceptor; } }使用分页查询// 构建分页对象参数当前页每页大小 PageUser page new Page(1, 10); // 构建查询条件 LambdaQueryWrapperUser lqw new LambdaQueryWrapper(); lqw.gt(User::getAge, 20); // 执行分页查询 PageUser resultPage userMapper.selectPage(page, lqw); // 获取分页数据 ListUser records resultPage.getRecords(); // 当前页数据列表 long total resultPage.getTotal(); // 总记录数 long pages resultPage.getPages(); // 总页数性能提示MP的分页查询会执行两条SQL一条是COUNT(*)查询总数一条是带有LIMIT的分页数据查询。在数据量极大时COUNT(*)可能会很慢。如果不需要知道精确的总数比如前端是“加载更多”模式可以考虑使用Page的setSearchCount(false)来禁用COUNT查询或者使用更高效的分页方式比如基于游标的分页。7. 进阶话题多租户注解与内置方法的联动“多租户”Multi-Tenancy是SaaS系统常见的设计模式即一套系统为多个客户租户服务但数据在逻辑或物理上是隔离的。MP通过TenantId注解和租户处理器TenantLineHandler提供了优雅的多租户支持。这会对内置方法产生深远影响。7.1 如何启用多租户实体类添加租户ID字段并标记public class User { TableId private Long id; private String name; TableField(fill FieldFill.INSERT) // 通常自动填充 TenantId // 标记此为租户ID字段 private String tenantId; }实现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); } }添加多租户插件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方法都会自动加上租户过滤条件。查询selectById、selectList、selectPage等会自动在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)使用实现自动填充。更新和删除updateById、deleteById等方法也会自动加上租户条件确保你只能操作自己租户下的数据。-- 你写的UPDATE user SET namexx WHERE id1 -- MP实际执行UPDATE user SET namexx WHERE id1 AND tenant_idtenant_a -- 你写的DELETE FROM user WHERE id1 -- MP实际执行UPDATE user SET deleted1 WHERE id1 AND tenant_idtenant_a (逻辑删除场景)7.3 多租户下的特殊场景处理多租户带来了数据安全也带来了复杂性全表操作超级管理员有时需要一个超级管理员角色能查询或操作所有租户的数据。MP提供了InterceptorIgnore注解或旧版的SqlParser来在Mapper方法上忽略多租户插件。InterceptorIgnore(tenantLine true) // 忽略租户过滤 ListUser selectAllTenantData();使用此注解需要非常谨慎并做好权限控制。关联查询在多租户场景下进行联表查询必须确保关联的表都有tenant_id字段并且MP能正确地为每个表加上租户条件。使用QueryWrapper进行leftJoin时需要仔细检查生成的SQL。数据初始化与迁移在系统初始化或进行跨租户数据迁移时需要临时绕过租户限制。可以通过编程方式在特定代码块中设置或清除当前租户上下文来实现。架构思考多租户插件是MP非常强大的一个功能它几乎无侵入地实现了数据隔离。但在引入前必须和团队明确租户ID的生成、传递和存储规则。尤其是在微服务架构下租户信息如何在服务间透传通常通过Feign请求头或消息上下文需要设计好。否则一旦租户上下文丢失MP注入的过滤条件可能会导致查询不到数据或操作错乱这类问题排查起来非常困难。
返回列表