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=deletedMybatisPlusInterceptor里注册分页、乐观锁、多租户- 实体类字段上打
@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 |
| 乐观锁 | OptimisticLockerInnerInterceptor | SET 与 WHERE | SQL 解析后改写 | 仅 update 且带 @Version |
| 多租户 | TenantLineInnerInterceptor | WHERE 条件 | 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>,遍历顺序就是改写顺序。建议的顺序是:
- 多租户:先加租户条件,让后续插件基于完整 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 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 关键步骤与预期输出
selectById会自动带上tenant_id和deleted=0。updateById会带上version条件,并把version加一。updated_time、updated_by由自动填充写入。- 控制台打印的最终 SQL 形如:
UPDATEt_orderSETamount=120.00,version=4,updated_by='u1',updated_time='2024-05-01 10:00:00'WHEREid=1001ANDversion=3ANDtenant_id=7ANDdeleted=09.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:索引与唯一约束