资讯动态

MyBatis-Plus 插件协同:逻辑删除、自动填充、乐观锁与多租户在真实项目中的冲突与排序

发布时间:2026/9/27 22:47:23 来源:尧图企业网站定制
MyBatis-Plus 插件协同逻辑删除、自动填充、乐观锁与多租户在真实项目中的冲突与排序1. 先看一个真实场景为什么四个“开关”一起打开就出问题假设你在做一个 SaaS 后台订单表t_order里有四个字段id、tenant_id、deleted、version另外还有created_by、created_time、updated_by、updated_time用于审计。业务上要求数据逻辑删除而不是物理删除多租户之间数据严格隔离并发更新要用乐观锁防止覆盖审计字段由框架自动填充业务代码不手写。配置很自然mybatis-plus.global-config.db-config.logic-delete-fielddeletedMybatisPlusInterceptor里注册分页、乐观锁、多租户实体类字段上打TableField(fill FieldFill.INSERT)、Version上线后开始收到奇怪反馈某个“更新订单”接口调用后返回影响行数是 1但数据库里updated_time没变另一个接口在更新时抛出唯一键冲突还有一个查询在单租户测试环境正常上线后查到了别的租户的数据。这些现象背后通常不是 MyBatis-Plus 有 bug而是四个插件各自都在改写同一条 SQL而它们的注入点和顺序没有被想清楚。本文先建立整体模型再逐个拆解最后给出排序与排查方法。2. 一句话模型四个插件都在改写同一条 SQL先记住一个最小模型Mapper 接口是代理对象SQL 是一段可以被拦截器逐层改写的字符串四个插件就是串在一条链上的改写器。把整体拆成三部分来看入口层BaseMapper和Wrapper负责拼出“原始 SQL”和参数。插件层MybatisPlusInterceptor内部维护多个InnerInterceptor按注册顺序依次改写 SQL。执行层改写后的 SQL 交给 JDBC 执行结果由ResultSetHandler映射回对象。一次典型的updateById会按下面的顺序流转业务方法调用 updateById(entity) | v BaseMapper 代理生成原始 SQL: UPDATE t_order SET name?, version? WHERE id? | v MybatisPlusInterceptor 开始逐个 InnerInterceptor 改写 | -- TenantLineInnerInterceptor: 追加 AND tenant_id ? -- OptimisticLockerInnerInterceptor: 改写 WHERE 增加 version ? -- 其他插件分页、防全表更新等 | v 最终 SQL 交给 JDBC 执行 | v MetaObjectHandler 在参数装配阶段完成自动填充 | v LogicDeleteInnerInterceptor: 读操作追加 deleted 0注意自动填充和逻辑删除并不是同一类东西。自动填充发生在参数装配阶段ParameterHandler之前由 MP 的参数处理逻辑写入逻辑删除本质上也是 SQL 改写只是它改写的条件更贴近“查询和删除语义”。把它们混在一起记忆是后续所有混乱的根源。3. 四个插件的分工与注入点对比在进入细节前先用一张表把四者定位清楚能力典型实现改写对象注入阶段是否所有语句都生效逻辑删除LogicDeleteInnerInterceptor/ 全局配置WHERE 条件与 DELETE 语义SQL 解析后改写仅匹配到实体或指定表自动填充MetaObjectHandler实体的字段值参数装配前仅 INSERT/UPDATE乐观锁OptimisticLockerInnerInterceptorSET 与 WHERESQL 解析后改写仅 update 且带 Version多租户TenantLineInnerInterceptorWHERE 条件SQL 解析后改写配置的表范围内全部生效可以看到自动填充在“参数层”其他三个在“SQL 文本层”。这决定了自动填充写错了是数据不对SQL 改写写错了是条件不对甚至可能扫全表。4. 逻辑删除不是删不掉而是把删除变成了更新4.1 场景与最小示例假设订单表结构如下CREATETABLEt_order(idBIGINTPRIMARYKEY,tenant_idBIGINTNOTNULL,order_noVARCHAR(64)NOTNULL,amountDECIMAL(12,2)NOTNULL,deletedTINYINTNOTNULLDEFAULT0,versionINTNOTNULLDEFAULT0,created_byVARCHAR(64),created_timeDATETIME,updated_byVARCHAR(64),updated_timeDATETIME);逻辑删除的本质是把DELETE FROM t_order WHERE id?改写成UPDATE t_order SET deleted1 WHERE id? AND deleted0读操作再把deleted0追加到 WHERE 后面。// 实体节选仅展示关键字段DataTableName(t_order)publicclassOrder{TableId(typeIdType.ASSIGN_ID)privateLongid;privateLongtenantId;privateStringorderNo;privateBigDecimalamount;TableLogicprivateIntegerdeleted;VersionprivateIntegerversion;TableField(fillFieldFill.INSERT)privateStringcreatedBy;TableField(fillFieldFill.INSERT)privateLocalDateTimecreatedTime;TableField(fillFieldFill.INSERT_UPDATE)privateStringupdatedBy;TableField(fillFieldFill.INSERT_UPDATE)privateLocalDateTimeupdatedTime;}4.2 关键边界第一个边界是唯一索引。如果原表上有UNIQUE(order_no)逻辑删除后同一order_no无法再插入因为旧行还在表里。常见做法是把唯一索引改成UNIQUE(order_no, deleted)或者把deleted设计成“删时间戳”而非 0/1。第二个边界是手工 SQL。手写Select或 XML 中的 SQL 默认不会被逻辑删除自动改写除非使用 MP 的SqlHelper或明确配置。团队里经常出现“MyBatis-Plus 的查询查不到已删除数据但手写 SQL 全查到了”原因就在这里。第三个边界是级联与关联。逻辑删除不会自动删除子表数据需要应用层显式处理。5. 自动填充最容易和乐观锁“抢字段”的插件5.1 它到底在什么时候执行自动填充由MetaObjectHandler实现在 MP 执行 INSERT/UPDATE 之前由参数装配逻辑调用insertFill或updateFill。它不是 SQL 改写而是直接修改实体对象或参数值。ComponentpublicclassAuditMetaObjectHandlerimplementsMetaObjectHandler{OverridepublicvoidinsertFill(MetaObjectmetaObject){this.strictInsertFill(metaObject,createdTime,LocalDateTime.class,LocalDateTime.now());this.strictInsertFill(metaObject,updatedTime,LocalDateTime.class,LocalDateTime.now());this.strictInsertFill(metaObject,createdBy,String.class,CurrentUserHolder.get());this.strictInsertFill(metaObject,updatedBy,String.class,CurrentUserHolder.get());}OverridepublicvoidupdateFill(MetaObjectmetaObject){this.strictUpdateFill(metaObject,updatedTime,LocalDateTime.class,LocalDateTime.now());this.strictUpdateFill(metaObject,updatedBy,String.class,CurrentUserHolder.get());}}5.2 和乐观锁的典型冲突OptimisticLockerInnerInterceptor会在 UPDATE 时把version作为 WHERE 条件并在 SET 里把版本号加一。如果自动填充把version也当成普通字段重新填值就会出现“版本号被重置”的假成功。正确的做法是版本号只交给乐观锁插件处理自动填充只碰审计字段。strictUpdateFill的“strict”含义就是“字段已经有值时不覆盖”这能避免不少误伤但前提是你要清楚哪些字段由谁负责。5.3 什么时候适合用审计字段、租户字段、创建人这类“上下文相关”的字段适合自动填充。业务语义字段金额、状态不适合自动填充容易掩盖业务逻辑。自动填充依赖ThreadLocal保存当前用户或租户时要记得在请求结束时清理否则线程复用会串数据。6. 乐观锁看起来是并发控制实际是 WHERE 增加一列6.1 一次完整更新的状态变化以“把订单金额从 100 改成 120”为例实体version3原始 SQL: UPDATE t_order SET amount120, version3 WHERE id1001 乐观锁改写: UPDATE t_order SET amount120, version4 WHERE id1001 AND version3如果另一个线程已经把它改成version4本次更新影响行数为 0业务层通常抛出“数据已被修改请重试”。6.2 边界与常见错误只对updateById和update(entity, wrapper)生效手写 SQL 或update(null, wrapper)不会带上版本条件。必须先把 version 查出来直接 new 一个实体只设 id 和 version可能覆盖其他字段。version 初始值数据库默认 0插入时不要手动设成 null否则第一次更新条件为version null永远更新不到。批量更新默认乐观锁对批量更新不友好逐条更新会放大版本冲突概率需要评估重试成本。7. 多租户最强的改写器也最容易踩坑7.1 它做了什么TenantLineInnerInterceptor会在解析 SQL 后为没有租户条件的语句追加tenant_id ?参数来自你配置的TenantLineHandler。它常用于 SaaS 场景但也会在很多“非业务 SQL”上误伤。7.2 三个高频问题唯一索引跨租户冲突如果唯一索引没有带tenant_id不同租户的相同业务编号会冲突。公共表被误加租户字典表、系统配置表通常没有tenant_id需要在ignoreTable中排除。联表查询多租户插件对 JOIN 的每张表都会尝试加条件稍不注意就会产生笛卡尔积或错误过滤。BeanpublicMybatisPlusInterceptormybatisPlusInterceptor(){MybatisPlusInterceptorinterceptornewMybatisPlusInterceptor();// 多租户通常放在最前保证后续插件看到的 SQL 已带租户条件interceptor.addInnerInterceptor(newTenantLineInnerInterceptor(newTenantLineHandler(){OverridepublicExpressiongetTenantId(){returnnewLongValue(TenantContext.getTenantId());}OverridepublicStringgetTenantIdColumn(){returntenant_id;}OverridepublicbooleanignoreTable(StringtableName){returnsys_dict.equalsIgnoreCase(tableName);}}));interceptor.addInnerInterceptor(newPaginationInnerInterceptor(DbType.MYSQL));interceptor.addInnerInterceptor(newOptimisticLockerInnerInterceptor());returninterceptor;}8. 插件执行顺序为什么顺序错了会出现“看似成功”的更新8.1 注册顺序就是改写顺序MybatisPlusInterceptor内部维护一个ListInnerInterceptor遍历顺序就是改写顺序。建议的顺序是多租户先加租户条件让后续插件基于完整 WHERE 工作。分页分页依赖完整查询条件。乐观锁乐观锁改写 UPDATE 的 SET 和 WHERE要在逻辑删除之前或之后取决于语义。防全表更新、非法 SQL 拦截最后兜底。请求进入 Mapper -- TenantLineInnerInterceptor 追加 tenant_id -- PaginationInnerInterceptor 改写分页 -- OptimisticLockerInnerInterceptor 改写 version -- 最终 SQL 执行8.2 顺序错乱的典型现象现象可能原因排查方向更新返回 1 但数据没变乐观锁 version 被自动填充覆盖检查 MetaObjectHandler 是否碰了 version查询到别的租户数据多租户插件注册在分页之后且 SQL 已被改写检查 InnerInterceptor 顺序逻辑删除数据仍出现手写 SQL 未走插件检查是否用了Select或 XML唯一键冲突逻辑删除 租户维度索引缺失检查唯一索引设计9. 一个完整的可运行示例四个插件协同的订单更新9.1 目标与环境目标复现“多租户 逻辑删除 乐观锁 自动填充”四者协同下的一次更新。环境Spring Boot 2.7 MyBatis-Plus 3.5.3 MySQL 8Java 8 或 11。9.2 配置与代码mybatis-plus:global-config:db-config:logic-delete-field:deletedlogic-delete-value:1logic-not-delete-value:0configuration:log-impl:org.apache.ibatis.logging.stdout.StdOutImplServicepublicclassOrderService{AutowiredprivateOrderMapperorderMapper;Transactional(rollbackForException.class)publicvoidupdateAmount(Longid,BigDecimalnewAmount){OrderorderorderMapper.selectById(id);if(ordernull){thrownewIllegalStateException(订单不存在);}order.setAmount(newAmount);intaffectedorderMapper.updateById(order);if(affected0){thrownewIllegalStateException(更新失败数据可能已被修改请重试);}}}9.3 关键步骤与预期输出selectById会自动带上tenant_id和deleted0。updateById会带上version条件并把version加一。updated_time、updated_by由自动填充写入。控制台打印的最终 SQL 形如UPDATEt_orderSETamount120.00,version4,updated_byu1,updated_time2024-05-01 10:00:00WHEREid1001ANDversion3ANDtenant_id7ANDdeleted09.4 容易改错的地方忘记给version加Version导致条件里没有版本号。多租户getTenantId返回 null导致 SQL 变成tenant_id null永远查不到数据。自动填充把version也设了值导致乐观锁失效。10. 常见误区误区一逻辑删除等于“看不见的物理删除”。它只是查询过滤数据仍在表里索引和存储成本照旧。误区二自动填充会覆盖手工设置的值。使用strictFill时不会但不同版本行为有差异最好写单测固定。误区三乐观锁能解决所有并发。它只解决丢失更新解决不了重复提交和幂等。误区四多租户插件是安全边界。它只是 SQL 改写真正的权限校验仍要在业务层做。11. 生产实践建议插件顺序一旦确定写进团队规范文档不要每个人各自注册。逻辑删除字段与唯一索引一起设计优先考虑(业务键, deleted)或(业务键, tenant_id, deleted)。自动填充只处理审计和上下文字段业务字段显式赋值。乐观锁失败的接口要有重试提示不要静默吞掉影响行数为 0 的情况。多租户要覆盖到所有查询入口包括手写 SQL 和定时任务。12. 排障清单打开 SQL 日志确认最终 SQL 是否包含预期的租户、删除、版本条件。检查InnerInterceptor注册顺序。检查实体字段注解是否齐全TableLogic、Version、TableField(fill...)。检查自动填充是否误改了版本字段。检查唯一索引是否包含租户和删除维度。检查ThreadLocal上下文是否在请求结束后清理。13. 面试/复盘问题逻辑删除的原理是什么为什么它无法替代物理归档自动填充和 SQL 改写分别发生在哪个阶段乐观锁如何保证不会覆盖别人的更新失败后应该怎么处理多租户插件为什么可能把公共表也改坏如何避免如果四个插件同时开启你会怎么排列它们的顺序理由是什么14. 总结把四个插件放回同一条链路里看它们的分工其实很清楚自动填充在参数层补值逻辑删除和多租户在 SQL 层加条件乐观锁在 SQL 层改 SET 和 WHERE。真正难的不是单个插件怎么用而是它们同时改一条 SQL 时的顺序和边界。记住一个判断标准谁依赖完整条件谁就应该排在前面谁会改动取值谁就应该被限制作用范围。15. 参考资料MyBatis-Plus 官方文档插件与扩展章节MyBatis 官方文档拦截器与插件机制MySQL 8.0 Reference Manual索引与唯一约束

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑