MYBATIS-PLUS · SQL INJECTOR · WRAPPER · INNER INTERCEPTOR
你没写的 SQL,是它在三个时刻替你补上的
MyBatis-Plus 没有换掉 MyBatis,执行流程还是那一套。它只是在三个地方插手:启动时把 SQL 模板写好,调用时拼条件、补参数,发 SQL 前用插件改一改。它的每个功能,都落在这三个时刻里的某一个。
- BaseMapper 的十几个方法,每个生成一条 MappedStatement
- 表名、列名从实体注解算出来
- 逻辑删除:查询自动带
deleted=0 - 字段是 null 就不进 INSERT / SET
- Wrapper 把
.eq()、.like()攒成一段 SQL 片段 - Lambda 写法从方法引用里认出列名
- 自动填充 createTime 这类字段
- 雪花算法生成主键
- 分页:先 count,再加 LIMIT
- 多租户:自动加 tenant_id 条件
- 乐观锁:版本号加一,条件带上旧版本
- 防全表更新、删除
ISqlInjector每个 Mapper 登记时,替 BaseMapper 的方法写好 SQL 模板。
默认是 DefaultSqlInjectorTableInfo读实体上的注解,算出表名、列名、主键、逻辑删除字段。
每个实体一份,启动时建好Wrapper把链式调用攒成一段带 #{} 的 SQL 片段,值放进参数表。
QueryWrapper · LambdaQueryWrapperMybatisPlusInterceptor本身是一个 MyBatis 插件,里面按顺序挂着分页、租户、乐观锁。
顺序会影响结果① 启动时:SQL 是怎么注入的
你的 Mapper 接口一行 SQL 都没写,可启动之后,OrderMapper.selectById 这样的 MappedStatement 已经在 MyBatis 的手册里了,和你自己写在 XML 里的没有区别。
@TableName("t_order")
public class Order {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String orderNo;
private BigDecimal amount;
private Integer status;
private Long userId;
@Version
private Integer version;
@TableLogic
private Integer deleted;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
}
public interface OrderMapper extends BaseMapper<Order> { }
表名来自 @TableName;没写就用类名转下划线。
注入器按 TableInfo 写出来的 SQL(展开后的样子)
OrderMapper.selectById查询自动带上逻辑删除条件SELECT id,order_no,amount,status,user_id,version,deleted,create_time FROM t_order WHERE id=? AND deleted=0
OrderMapper.deleteById有 @TableLogic,删除变成了 UPDATEUPDATE t_order SET deleted=1 WHERE id=? AND deleted=0
OrderMapper.updateByIdnull 字段不进 SET;带乐观锁条件UPDATE t_order SET amount=?, version=? WHERE id=? AND version=? AND deleted=0
OrderMapper.insert模板里每个字段都包着 <if test="x != null">INSERT INTO t_order ( id, order_no, amount, status, user_id, create_time ) VALUES ( ?, ?, ?, ?, ?, ? )
public void inspectInject(MapperBuilderAssistant assistant, Class<?> mapperClass) {
Class<?> modelClass = ReflectionKit.getSuperClassGenericType(mapperClass, Mapper.class, 0); // BaseMapper<Order> → Order
TableInfo tableInfo = TableInfoHelper.initTableInfo(assistant, modelClass); // 读注解,算表名列名
for (AbstractMethod method : getMethodList(configuration, mapperClass, tableInfo)) { // selectById、insert、updateById……
method.inject(assistant, mapperClass, modelClass, tableInfo); // 拼 SQL 脚本,登记 MappedStatement
}
}
orderMapper.selectById(7),走的就是 MyBatis 第 01 篇里那条路:替身翻手册、SqlSession、Executor、JDBC。MyBatis-Plus 换上的是自己的 MybatisConfiguration、MybatisSqlSessionFactoryBean,好在启动时插进这一步。② 调用时:Order::getStatus 怎么变成列名
Lambda 写法比字符串列名安全,是因为列名不是你敲的,是从方法引用里读出来、再去 TableInfo 里查的。
Order::getOrderNo类型是 SFunction,它同时实现了 Serializable
SerializedLambda里面记着:implMethodName = "getOrderNo"
orderNo这是属性名,还不是列名
order_no写了 @TableField("xxx") 就用 xxx
字符串写法 .eq("order_no", …) 跳过了这一步,列名原样拼进 SQL。列名写错要到运行时才报错,列名来自前端还会被注入。
Wrapper → SQL 翻译机
勾选条件和功能,看 Wrapper 攒出来的片段、参数表,以及最后发给数据库的 SQL。SQL 里每一段按来源上了色。
SELECT id,order_no,…,create_time FROM t_order <where> deleted=0 ← 有 @TableLogic 才写进模板 <if test="ew 里有条件">AND ${ew.sqlSegment}</if> </where>
为什么值不会被注入:${ew.sqlSegment} 先把一段带 #{ew.paramNameValuePairs.MPGENVAL1} 的文本拼进模板,紧接着 MyBatis 再把这些 #{} 换成问号,值还是走 PreparedStatement 传。只有列名、last()、apply() 里直接拼的字符串,才会原样进 SQL。
② 调用时:自动填充和雪花 ID
insert、updateById 把实体交给 MyBatis 填问号之前,MyBatis-Plus 换上的参数处理器先过一遍实体:主键是空的就生成一个,标了填充的字段交给你的处理器。
@Component
public class AutoFill implements MetaObjectHandler {
@Override
public void insertFill(MetaObject meta) { // insert 时调用
strictInsertFill(meta, "createTime", LocalDateTime.class, LocalDateTime.now());
}
@Override
public void updateFill(MetaObject meta) { // 带实体的 update 时调用
strictUpdateFill(meta, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}
// strict 开头的方法:字段已经有值就不覆盖
同一毫秒、同一台机器上靠序列号区分,所以不用查数据库就能生成,而且大致按时间递增。
1854371283946876929
前端 JSON.parse 后1854371283946877000
原因JS 的数字只能精确到 9007199254740991(16 位),19 位的 id 后几位就变了
③ 发 SQL 前:插件总管和它的顺序
MybatisPlusInterceptor 本身是一个 MyBatis 插件(第 03 篇)。它拦下查询后,按 add 的顺序把 SQL 交给里面的小插件,每个改完,下一个看到的就是改过的。
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor i = new MybatisPlusInterceptor();
i.addInnerInterceptor(new TenantLineInnerInterceptor(tenantHandler)); // 1 多租户
i.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 2 分页
i.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); // 3 乐观锁
i.addInnerInterceptor(new BlockAttackInnerInterceptor()); // 4 防全表更新删除
return i;
}
for (InnerInterceptor inner : interceptors) { // 按 add 的顺序
if (!inner.willDoQuery(executor, ms, param, rowBounds, handler, boundSql)) {
return Collections.emptyList(); // 比如分页 count 是 0
}
inner.beforeQuery(executor, ms, param, rowBounds, handler, boundSql); // 改 SQL
}
return executor.query(ms, param, rowBounds, handler, cacheKey, boundSql);
租户在前,分页在后
-- 分页插件 count 时,SQL 已经带上租户条件 SELECT COUNT(*) AS total FROM t_order WHERE deleted=0 AND (status = ?) AND tenant_id = 1001
total = 37,只算本租户的
分页在前,租户在后
-- 分页插件先 count,这时租户条件还没加 SELECT COUNT(*) AS total FROM t_order WHERE deleted=0 AND (status = ?)
total = 5280,把别的租户也算进去了。分页查询本身是对的,所以很难发现
常用功能,各在哪个时刻生效
遇到功能“不生效”,先对照这张表看它在哪一步起作用,再看你的调用是不是绕过了那一步。
| 功能 | 你写的 | 时刻 | 效果 | 绕过它的写法 |
|---|---|---|---|---|
| 通用 CRUD | extends BaseMapper<T> | ① 启动时 | 十几个方法自动有 SQL | 没用 MyBatis-Plus 的 SqlSessionFactory |
| 逻辑删除 | @TableLogic | ① 启动时 | 删除变 UPDATE,查询加 deleted=0 | 自己写在 XML 里的 SQL |
| 字段策略 | 默认 NOT_NULL | ① 启动时 | null 字段不进 INSERT / SET | 用 UpdateWrapper 的 set() |
| 自动填充 | @TableField(fill = …) + MetaObjectHandler | ② 调用时 | 插入、更新时自动塞值 | update(null, wrapper),没有实体可填 |
| 主键生成 | @TableId(type = ASSIGN_ID) | ② 调用时 | id 为空时生成雪花 ID | 自己给 id 赋了值 |
| 分页 | Page 参数 + PaginationInnerInterceptor | ③ 发 SQL 前 | 先 count,再加 LIMIT | 没注册插件 |
| 多租户 | TenantLineInnerInterceptor | ③ 发 SQL 前 | 每条 SQL 自动加 tenant_id = … | 在忽略表名单里的表 |
| 乐观锁 | @Version + OptimisticLockerInnerInterceptor | ③ 发 SQL 前 | 新版本号写进 SET,旧版本号进 WHERE | 实体里 version 是 null;没注册插件 |
| 防全表更新删除 | BlockAttackInnerInterceptor | ③ 发 SQL 前 | 没 WHERE 的 update / delete 直接报错 | 不注册它 |
IService 的 saveBatch 是怎么批量的
它不是拼一条超长的 INSERT,而是开一个 BATCH 模式的会话,攒满 1000 条交给 JDBC 批量执行。快不快,还要看驱动参数。
没开 rewriteBatchedStatements
INSERT INTO t_order (…) VALUES (?, ?, …) INSERT INTO t_order (…) VALUES (?, ?, …) INSERT INTO t_order (…) VALUES (?, ?, …) ……一共 1000 条,数据库一条条执行
MySQL 驱动默认不改写,攒批的好处大打折扣。
URL 里加上 rewriteBatchedStatements=true
INSERT INTO t_order (…) VALUES (?, ?, …), (?, ?, …), (?, ?, …), ……
驱动把一批改写成一条多值 INSERT,通常快好几倍:jdbc:mysql://…/shop?rewriteBatchedStatements=true
JDK 8 和 JDK 17
三个时刻、注入器、Wrapper、插件总管,两边都一样。要换的是 starter 和 JSqlParser。
Spring Boot 2
JDK 8+- 用
mybatis-plus-boot-starter - 3.5.9 起,分页、多租户这些靠 JSqlParser 的插件要另外引入;JDK 8 引
mybatis-plus-jsqlparser-4.9
Spring Boot 3
JDK 17+- 用
mybatis-plus-spring-boot3-starter - 3.5.9 起引
mybatis-plus-jsqlparser,里面是 JSqlParser 5,要求 JDK 11+ - 很老的 MyBatis-Plus 在 JDK 16+ 上解析 Lambda 会报
SerializedLambda … not "opens java.lang.invoke",升到 3.5.x 就好
常踩的坑
多数“不生效”,都是调用绕过了那个功能起作用的时刻。
启动时没走注入:多数据源时自己 new 了 MyBatis 原版的 SqlSessionFactoryBean,或者同时引了 mybatis-spring-boot-starter。换成 MybatisSqlSessionFactoryBean,只留一个 starter。
逻辑删除是注入时写进 MyBatis-Plus 生成的 SQL 里的,你手写的 SQL 它管不到。自己加 AND deleted = 0。
19 位的雪花 ID 超出了 JS 数字的精度。把 Long 序列化成字符串:字段上加 @JsonSerialize(using = ToStringSerializer.class),或者在 Jackson 里全局配置。
没有实体对象,填充处理器无处可填。传一个空实体 update(new Order(), wrapper),或者在 wrapper 里直接 set。
默认字段策略是 NOT_NULL,null 字段不进 SET。用 UpdateWrapper.set("remark", null),或在字段上设 updateStrategy = FieldStrategy.ALWAYS(老版本叫 IGNORED)。
orderByDesc(sortField)、last() 和直接拼值的 apply() 都是原样拼进 SQL。排序字段先过白名单,apply 用 {0} 占位。
没注册 OptimisticLockerInnerInterceptor,或者实体里的 version 是 null。注册之后,冲突时 updateById 返回 0,要自己重试或提示用户。
分页插件排在多租户前面,count 时还没加租户条件。按“多租户 → 分页 → 乐观锁 → 防全表”的顺序 add。
防全表插件拦下了一条没有 WHERE 的 update。这是在保护你。真要全表改,带上明确的条件,或者手写 SQL。
JDBC 地址没加 rewriteBatchedStatements=true,一批还是一条条执行。加上之后,再按需调 batchSize。