MyBatis-Plus:增强能力的真实语义、插件顺序与越权边界
MyBatis-Plus 不是 MyBatis 的替身,而是一层"模板生成 + SQL 改写"的增强:BaseMapper 把 CRUD 变成内置模板,InnerInterceptor 在 SQL 执行前后改写语句。用错了不会报错,只会静默给错数据——租户条件没进 count、逻辑删除保护不了手写 SQL、插件顺序让全表防护失效。本页独立于 MyBatis 主课(P05),会话/执行器/缓存机制回查 MyBatis,与 Spring 的会话绑定回查 U42。
实验固定组合:mybatis-plus-spring-boot4-starter 3.5.18.1(core/spring 3.5.18.1)、mybatis-plus-extension 3.5.18、mybatis-plus-jsqlparser 3.5.18(JSqlParser 5.4)、mybatis-spring 4.1.0、Boot 4.0.9 / Framework 7.0.10、Liberica JDK 21.0.11、Connector/J 9.7.0,以本机 MySQL 8.4.7 单机实跑。主程序手动装配 MybatisSqlSessionFactoryBean,不启用 Boot 自动配置;自动配置条件与替换点归 U22/U24。完整输入在实验入口,本页行为仅描述该组合,不推成所有版本保证。
通用 CRUD:模板生成与"保存之后"的真实动作
BaseMapper<Item> 在启动时按实体注解生成内置模板:@TableName 定表、@TableId(type=AUTO) 定主键与回填、@Version 定乐观锁版本、@TableLogic 定逻辑删除列。insert 返回的是影响行数,自增 id 由 useGeneratedKeys 回填到实体;updateById 按"非 null 字段"生成 SET——字段更新策略决定了"传 null 是不更新还是清空",不是 Java 语义的直接映射。实验断言 insert 三条全部回填 id,并回读确认每行的 tenant_id 由租户插件补列:插件改写的是 SQL,不是实体对象,插入后实体里的 tenantId 仍是 null,要拿到真实值必须重读。把它当成"实体已被填充"是第一个常见错误。
条件构造器:Wrapper 生成 where,不生成权限
LambdaQueryWrapper 以方法引用定列(Item::getName),编译期可查;eq/likeRight 生成等值与前缀条件,LambdaUpdateWrapper.set 生成 SET 子句,实验各命中 1 行。Wrapper 只描述 where/set,不附带任何授权:条件里没有租户就没有租户,没有归属列就可以跨租户改数据。跨租户防护必须来自数据模型(租户列)、插件(TenantLine)或数据库约束,"我用了 Wrapper"不是边界。last("LIMIT 1")、apply 拼接的原生 SQL 片段不经参数化,允许用户输入进入这些位置等于开放注入,官方文档对分页排序字段明确要求白名单验证。
分页插件:count 是另一条 SQL,maxLimit 是服务端闸门
PaginationInnerInterceptor 把一次分页查询拆成 count 与页查询两条 SQL:count 决定 total,页查询带 LIMIT。实验给当前租户 5 行数据,Page<>(1, 100) 的 count 只统计到 5(不是表里全部 6 行),setMaxLimit(2L) 把 pageSize=100 截断为 2 条记录——maxLimit 是服务端对单页条数的硬闸门,不设则客户端可以用 pageSize 拉全表。count SQL 由插件内部构建与执行,其语句形状与租户/逻辑删除条件的叠加属于插件协作结果,本票在 3.5.18 以 JDBC 层观察到的 count 均带当前条件;官方仍要求"配置多个插件时,分页最后添加",把它当作版本升级时必须复核的约定,而不是已验证的等价性。
租户插件:改写一切可解析的 SQL,包括你手写的
TenantLineInnerInterceptor 持有一个 TenantLineHandler(租户值、租户列名、ignoreTable)。对 INSERT 它直接补列:
protected void processInsert(Insert insert, int index, String sql, Object obj) {
if (tenantLineHandler.ignoreTable(insert.getTable().getName())) {
// 过滤退出执行
return;
}
List<Column> columns = insert.getColumns();
if (CollectionUtils.isEmpty(columns)) {
// 针对不给列名的insert 不处理
return;
}
String tenantIdColumn = tenantLineHandler.getTenantIdColumn();
if (tenantLineHandler.ignoreInsert(columns, tenantIdColumn)) {
// 针对已给出租户列的insert 不处理
return;
}
columns.add(new Column(tenantIdColumn));
Expression tenantId = tenantLineHandler.getTenantId();ignoreTable 跳过整表,ignoreInsert 跳过已给出租户列的语句;否则 columns.add 把租户列塞进 INSERT 并取 getTenantId() 作值。SELECT/UPDATE/DELETE 走 JSqlParser 解析后追加条件——包括 mapper 里手写的 @Select:实验的手写 SELECT COUNT(*) FROM u45_item 被改写后只返回当前租户的 5 行,"手写 SQL 绕过租户"在这个插件上不成立(前提是 SQL 能被 JSqlParser 解析)。真正的绕过路径在 MyBatis 之外:实验用 JdbcTemplate 直连同一 schema,看到全部 6 行——插件只拦 MyBatis 链路,换一条执行路径就全绕开。错误租户的隔离是双向的:切换租户后读不到任何旧租户的行(0 行),新写入也只落在租户 2 且只被租户 2 看到。getTenantId() 每次语句执行时求值,租户上下文(ThreadLocal 等)的生命周期与异步线程的传递是生产必查项,本实验用显式切换代替。
逻辑删除:模板级过滤,不是数据级防护
@TableLogic 让 BaseMapper 的 deleteById 生成 UPDATE ... SET deleted=1 WHERE id=? AND deleted=0,SELECT 模板自动追加 deleted=0。实验物理行仍在表内(JDBC 直查可见 deleted=1),而 BaseMapper 查询只剩 4 行。边界同样清楚:逻辑删除是模板级行为,手写 @Select 与 JdbcTemplate 都不过滤已删行——需要"删除即不可见"的数据用数据库视图或显式条件,不能指望注解。逻辑删除列参与唯一索引时用 bigint+时间戳值(官方建议),避免同一业务键只能删一次。
乐观锁:版本条件进 where,旧值失败后重读
@Version 字段由 OptimisticLockerInnerInterceptor 在更新前接管:
public void beforeUpdate(Executor executor, MappedStatement ms, Object parameter) {
if (SqlCommandType.UPDATE != ms.getSqlCommandType()) {
return;
}
if (parameter instanceof Map) {
Map<String, Object> map = (Map<String, Object>) parameter;
doOptimisticLocker(map, ms.getId());
}
}
protected void doOptimisticLocker(Map<String, Object> map, String msId) {
// updateById(et), update(et, wrapper);
Object et = map.getOrDefault(Constants.ENTITY, null);
if (Objects.nonNull(et)) {
// version field
TableFieldInfo fieldInfo = this.getVersionFieldInfo(et.getClass());
if (null == fieldInfo) {
return;
}
try {
Field versionField = fieldInfo.getField();
// 旧的 version 值
Object originalVersionVal = versionField.get(et);
if (originalVersionVal == null) {
if (null != exception) {
throw exception;
}
return;
}
String versionColumn = fieldInfo.getColumn();
// 新的 version 值
Object updatedVersionVal = this.getUpdatedVersionVal(fieldInfo.getPropertyType(), originalVersionVal);
String methodName = msId.substring(msId.lastIndexOf(StringPool.DOT) + 1);
if ("update".equals(methodName)) {
AbstractWrapper<?, ?, ?> aw = (AbstractWrapper<?, ?, ?>) map.getOrDefault(Constants.WRAPPER, null);
if (aw == null) {
UpdateWrapper<?> uw = new UpdateWrapper<>();
uw.eq(versionColumn, originalVersionVal);
map.put(Constants.WRAPPER, uw);
} else {
aw.apply(versionColumn + " = {0}", originalVersionVal);
}
} else {
map.put(Constants.MP_OPTLOCK_VERSION_ORIGINAL, originalVersionVal);
}
versionField.set(et, updatedVersionVal);
} catch (IllegalAccessException e) {
throw ExceptionUtils.mpe(e);
}
} else if (wrapperMode && map.containsKey(Constants.WRAPPER)) {
setVersionByWrapper(map, msId);
}
}beforeUpdate 只处理 UPDATE 语句;doOptimisticLocker 从实体取 @Version 字段的旧值,getUpdatedVersionVal 算出新值(整数 +1),updateById 路径把旧值放进 MP_OPTLOCK_VERSION_ORIGINAL 供模板生成 WHERE version=?,update(entity, wrapper) 路径往 wrapper 追加 version = 旧值 条件,最后把新值回写实体。实验用两个独立会话各读一份 version=0 的实体:先提交者影响 1 行,旧版本那份影响 0 行,重读后重试成功。这里有一个真实踩到的坑:同一 SqlSession 内两次 selectById 命中一级缓存返回同一实例,两次"并发"其实是同一对象——并发更新演示必须用两个会话(或 clearCache)。updateById 返回 0 不是异常,是并发冲突的正常结果;恢复动作是重新读取、重新判断业务不变量后有限重试,外部副作用仍需幂等。
插件顺序:同一个插件换位置,防护语义就变了
MybatisPlusInterceptor 把 InnerInterceptor 列表按添加顺序接到 Executor/StatementHandler 上:
public Object intercept(Invocation invocation) throws Throwable {
Object target = invocation.getTarget();
Object[] args = invocation.getArgs();
if (target instanceof Executor) {
final Executor executor = (Executor) target;
Object parameter = args[1];
boolean isUpdate = args.length == 2;
MappedStatement ms = (MappedStatement) args[0];
if (!isUpdate && ms.getSqlCommandType() == SqlCommandType.SELECT) {
RowBounds rowBounds = (RowBounds) args[2];
ResultHandler resultHandler = (ResultHandler) args[3];
BoundSql boundSql;
if (args.length == 4) {
boundSql = ms.getBoundSql(parameter);
} else {
// 几乎不可能走进这里面,除非使用Executor的代理对象调用query[args[6]]
boundSql = (BoundSql) args[5];
}
for (InnerInterceptor query : interceptors) {
if (!query.willDoQuery(executor, ms, parameter, rowBounds, resultHandler, boundSql)) {
return Collections.emptyList();
}
query.beforeQuery(executor, ms, parameter, rowBounds, resultHandler, boundSql);
}
CacheKey cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
return executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
} else if (isUpdate) {
for (InnerInterceptor update : interceptors) {
if (!update.willDoUpdate(executor, ms, parameter)) {
return -1;
}
update.beforeUpdate(executor, ms, parameter);
}
}
} else {
// StatementHandler
final StatementHandler sh = (StatementHandler) target;
// 目前只有StatementHandler.getBoundSql方法args才为null
if (null == args) {
for (InnerInterceptor innerInterceptor : interceptors) {
innerInterceptor.beforeGetBoundSql(sh);
}
} else {
Connection connections = (Connection) args[0];
Integer transactionTimeout = (Integer) args[1];
for (InnerInterceptor innerInterceptor : interceptors) {
innerInterceptor.beforePrepare(sh, connections, transactionTimeout);
}
}
}
return invocation.proceed();
}SELECT 按 willDoQuery → beforeQuery 逐个执行并允许短路,UPDATE 走 willDoUpdate → beforeUpdate,StatementHandler 层是 beforeGetBoundSql → beforePrepare。顺序不是风格问题,实验复现了一个真实的语义翻转:BlockAttack(防全表更新)放在租户插件之前时,无 where 的 UPDATE 被拦截;放到租户之后时,租户插件在 beforePrepare 先给这条语句追加了 WHERE tenant_id=1,BlockAttack 看到的 SQL 不再是"全表",更新放行并命中租户内 4 行。防护型插件(BlockAttack、illegal-sql)应在改写型插件(租户、动态表名)之前,分页按官方要求最后添加。插件列表是全局配置,新增一个 starter 或拦截器都可能改变既有语义,改动必须有回归实验。
越权与原生 SQL 边界汇总
| 写法 | 租户条件 | 逻辑删除过滤 | 乐观锁 | 说明 |
|---|---|---|---|---|
| BaseMapper 内置方法 | 插件改写追加 | 模板自带 | @Version 生效 | 本实验全部验证 |
手写 @Select(可解析) | 插件改写追加 | 无 | 无 | 逻辑删除不保护手写 SQL |
@InterceptorIgnore(tenantLine) | 跳过 | 视语句 | 视语句 | 显式豁免要审读 |
| JdbcTemplate / 其他执行路径 | 全部绕过 | 全部绕过 | 全部绕过 | 插件只拦 MyBatis |
"错误租户"实验的结论是读不到也写不进(插件改写),但这依赖 handler 的租户值来源正确;租户上下文被伪造、异步线程没传递、ignoreTable 配错范围,都会把隔离变成摆设。越权的最终防线是数据库约束与授权过滤(回查资源授权与租户边界),插件只是其中一层。
观测、排障与恢复
| 症状 | 先查什么 | 本票依据 |
|---|---|---|
| 插入后实体里租户/版本字段是 null | 插件改写 SQL 不回填实体,重读验证 | crud 观察 |
| 并发更新演示"从不失败" | 同会话一级缓存返回同一实例,用两个会话 | optimistic 观察 |
| 全表更新没被拦截 | BlockAttack 与租户插件的先后 | order 观察 |
| 分页 total 比预期多/少 | count 是独立 SQL,核对租户与逻辑删除条件是否叠加 | page 观察 |
| 手写 SQL 查到了已删数据 | 逻辑删除只在模板里 | nativesql 观察 |
| 换了执行路径数据"全部可见" | JdbcTemplate 不走 MyBatis 插件链 | nativesql 观察 |
容量与取舍
分页 maxLimit 与 count 的代价:count 是每页一条额外聚合,深页码 LIMIT offset 让数据库扫描并丢弃前 N 行,keyset 分页是替代方案但需要稳定唯一排序键;租户插件对每条 SQL 做 JSqlParser 解析与改写,解析失败的语句形态(存储过程、方言语法)会直接抛错而不是降级放行,引入前要用真实语句形态回归;逻辑删除换来了审计痕迹,代价是所有唯一索引都要考虑已删行、表会持续膨胀,需要归档策略。是否引入 MyBatis-Plus 的判断标准是团队能否守住"模板生成 + SQL 改写"的边界:能写清插件顺序与豁免注解的审计规则,增强就是杠杆;守不住就是隐性行为来源。
条件变化与未运行边界
未运行:多数据源与 @DS 动态数据源、DataPermission/动态表名/illegal-sql 等其余官方插件、自定义 SqlInjector 与 IdType 分布式主键、Redis/Mongo 等其他 store 的 MyBatis-Plus 集成、生产容量。构造:租户上下文用静态变量代替 ThreadLocal;并发乐观锁用两个会话顺序执行模拟。插件顺序的 count 行为在 3.5.18 观察为正确,但不作为跨版本保证。教材"已讲清"不代表学习者掌握。
闭卷验收
本页 12 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| MybatisPlusInterceptor.java | v3.5.18 | 插件链执行顺序(固定摘录) |
| TenantLineInnerInterceptor.java | v3.5.18 | INSERT 补租户列(固定摘录) |
| OptimisticLockerInnerInterceptor.java | v3.5.18 | 乐观锁版本填充(固定摘录) |
| 多租户插件文档、乐观锁、分页、逻辑删除 | 查询 2026-10-31 | 官方配置与注意事项 |
| spring-boot4-starter 3.5.18.1 | 3.5.18.1 | Boot 4 集成坐标 |
访问与运行日期:2026-10-31。源码片段为 v3.5.18 tag 的连续行段,整文件 SHA-256 随每段元数据登记;运行版本以证据 JSON 为准。规范、源码与实验是三类证据,不互相替代。