☰
MyBatis-Plus 插件协同:逻辑删除、自动填充、乐观锁与多租户在真实项目中的冲突与排序
2026/9/27 22:47:18 网站建设 项目流程

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-field=deleted
  • MybatisPlusInterceptor里注册分页、乐观锁、多租户
  • 实体类字段上打@TableField(fill = FieldFill.INSERT)、@Version

上线后开始收到奇怪反馈:某个“更新订单”接口调用后返回影响行数是 1,但数据库里updated_time没变;另一个接口在更新时抛出唯一键冲突;还有一个查询在单租户测试环境正常,上线后查到了别的租户的数据。

这些现象背后通常不是 MyBatis-Plus 有 bug,而是四个插件各自都在改写同一条 SQL,而它们的注入点和顺序没有被想清楚。本文先建立整体模型,再逐个拆解,最后给出排序与排查方法。

2. 一句话模型:四个插件都在改写同一条 SQL

先记住一个最小模型:Mapper 接口是代理对象,SQL 是一段可以被拦截器逐层改写的字符串,四个插件就是串在一条链上的改写器。

把整体拆成三部分来看:

  1. 入口层:BaseMapper和Wrapper负责拼出“原始 SQL”和参数。
  2. 插件层:MybatisPlusInterceptor内部维护多个InnerInterceptor,按注册顺序依次改写 SQL。
  3. 执行层:改写后的 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 deleted=1 WHERE id=? AND deleted=0,读操作再把deleted=0追加到 WHERE 后面。

// 实体节选,仅展示关键字段@Data@TableName("t_order")publicclassOrder{@TableId(type=IdType.ASSIGN_ID)privateLongid;privateLongtenantId;privateStringorderNo;privateBigDecimalamount;@TableLogicprivateIntegerdeleted;@VersionprivateIntegerversion;@TableField(fill=FieldFill.INSERT)privateStringcreatedBy;@TableField(fill=FieldFill.INSERT)privateLocalDateTimecreatedTime;@TableField(fill=FieldFill.INSERT_UPDATE)privateStringupdatedBy;@TableField(fill=FieldFill.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”为例,实体version=3:

原始 SQL: UPDATE t_order SET amount=120, version=3 WHERE id=1001 乐观锁改写: UPDATE t_order SET amount=120, version=4 WHERE id=1001 AND version=3

如果另一个线程已经把它改成version=4,本次更新影响行数为 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(){MybatisPlusInterceptorinterceptor=newMybatisPlusInterceptor();// 多租户通常放在最前,保证后续插件看到的 SQL 已带租户条件interceptor.addInnerInterceptor(newTenantLineInnerInterceptor(newTenantLineHandler(){@OverridepublicExpressiongetTenantId(){returnnewLongValue(TenantContext.getTenantId());}@OverridepublicStringgetTenantIdColumn(){return"tenant_id";}@OverridepublicbooleanignoreTable(StringtableName){return"sys_dict".equalsIgnoreCase(tableName);}}));interceptor.addInnerInterceptor(newPaginationInnerInterceptor(DbType.MYSQL));interceptor.addInnerInterceptor(newOptimisticLockerInnerInterceptor());returninterceptor;}

8. 插件执行顺序:为什么顺序错了会出现“看似成功”的更新

8.1 注册顺序就是改写顺序

MybatisPlusInterceptor内部维护一个List<InnerInterceptor>,遍历顺序就是改写顺序。建议的顺序是:

  1. 多租户:先加租户条件,让后续插件基于完整 WHERE 工作。
  2. 分页:分页依赖完整查询条件。
  3. 乐观锁:乐观锁改写 UPDATE 的 SET 和 WHERE,要在逻辑删除之前或之后取决于语义。
  4. 防全表更新、非法 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 8,Java 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.StdOutImpl
@ServicepublicclassOrderService{@AutowiredprivateOrderMapperorderMapper;@Transactional(rollbackFor=Exception.class)publicvoidupdateAmount(Longid,BigDecimalnewAmount){Orderorder=orderMapper.selectById(id);if(order==null){thrownewIllegalStateException("订单不存在");}order.setAmount(newAmount);intaffected=orderMapper.updateById(order);if(affected==0){thrownewIllegalStateException("更新失败,数据可能已被修改,请重试");}}}

9.3 关键步骤与预期输出

  1. selectById会自动带上tenant_id和deleted=0。
  2. updateById会带上version条件,并把version加一。
  3. updated_time、updated_by由自动填充写入。
  4. 控制台打印的最终 SQL 形如:
UPDATEt_orderSETamount=120.00,version=4,updated_by='u1',updated_time='2024-05-01 10:00:00'WHEREid=1001ANDversion=3ANDtenant_id=7ANDdeleted=0

9.4 容易改错的地方

  • 忘记给version加@Version,导致条件里没有版本号。
  • 多租户getTenantId返回 null,导致 SQL 变成tenant_id = null,永远查不到数据。
  • 自动填充把version也设了值,导致乐观锁失效。

10. 常见误区

  • 误区一:逻辑删除等于“看不见的物理删除”。它只是查询过滤,数据仍在表里,索引和存储成本照旧。
  • 误区二:自动填充会覆盖手工设置的值。使用strictFill时不会,但不同版本行为有差异,最好写单测固定。
  • 误区三:乐观锁能解决所有并发。它只解决丢失更新,解决不了重复提交和幂等。
  • 误区四:多租户插件是安全边界。它只是 SQL 改写,真正的权限校验仍要在业务层做。

11. 生产实践建议

  • 插件顺序一旦确定,写进团队规范文档,不要每个人各自注册。
  • 逻辑删除字段与唯一索引一起设计,优先考虑(业务键, deleted)或(业务键, tenant_id, deleted)。
  • 自动填充只处理审计和上下文字段,业务字段显式赋值。
  • 乐观锁失败的接口要有重试提示,不要静默吞掉影响行数为 0 的情况。
  • 多租户要覆盖到所有查询入口,包括手写 SQL 和定时任务。

12. 排障清单

  1. 打开 SQL 日志,确认最终 SQL 是否包含预期的租户、删除、版本条件。
  2. 检查InnerInterceptor注册顺序。
  3. 检查实体字段注解是否齐全:@TableLogic、@Version、@TableField(fill=...)。
  4. 检查自动填充是否误改了版本字段。
  5. 检查唯一索引是否包含租户和删除维度。
  6. 检查ThreadLocal上下文是否在请求结束后清理。

13. 面试/复盘问题

  • 逻辑删除的原理是什么?为什么它无法替代物理归档?
  • 自动填充和 SQL 改写分别发生在哪个阶段?
  • 乐观锁如何保证不会覆盖别人的更新?失败后应该怎么处理?
  • 多租户插件为什么可能把公共表也改坏?如何避免?
  • 如果四个插件同时开启,你会怎么排列它们的顺序,理由是什么?

14. 总结

把四个插件放回同一条链路里看,它们的分工其实很清楚:自动填充在参数层补值,逻辑删除和多租户在 SQL 层加条件,乐观锁在 SQL 层改 SET 和 WHERE。真正难的不是单个插件怎么用,而是它们同时改一条 SQL 时的顺序和边界。记住一个判断标准:谁依赖完整条件,谁就应该排在前面;谁会改动取值,谁就应该被限制作用范围。

15. 参考资料

  • MyBatis-Plus 官方文档:插件与扩展章节
  • MyBatis 官方文档:拦截器与插件机制
  • MySQL 8.0 Reference Manual:索引与唯一约束

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

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

立即咨询