MYBATIS-PLUS · SQL INJECTOR · WRAPPER · INNER INTERCEPTOR

你没写的 SQL,是它在三个时刻替你补上的

MyBatis-Plus 没有换掉 MyBatis,执行流程还是那一套。它只是在三个地方插手:启动时把 SQL 模板写好,调用时拼条件、补参数,发 SQL 前用插件改一改。它的每个功能,都落在这三个时刻里的某一个。

① 启动时往 MyBatis 里注入 SQL 模板
  • BaseMapper 的十几个方法,每个生成一条 MappedStatement
  • 表名、列名从实体注解算出来
  • 逻辑删除:查询自动带 deleted=0
  • 字段是 null 就不进 INSERT / SET
② 调用时拼条件、补参数
  • Wrapper 把 .eq()、.like() 攒成一段 SQL 片段
  • Lambda 写法从方法引用里认出列名
  • 自动填充 createTime 这类字段
  • 雪花算法生成主键
③ 发 SQL 前插件改写 SQL 和参数
  • 分页:先 count,再加 LIMIT
  • 多租户:自动加 tenant_id 条件
  • 乐观锁:版本号加一,条件带上旧版本
  • 防全表更新、删除
注入器ISqlInjector

每个 Mapper 登记时,替 BaseMapper 的方法写好 SQL 模板。

默认是 DefaultSqlInjector
表信息TableInfo

读实体上的注解,算出表名、列名、主键、逻辑删除字段。

每个实体一份,启动时建好
条件构造器Wrapper

把链式调用攒成一段带 #{} 的 SQL 片段,值放进参数表。

QueryWrapper · LambdaQueryWrapper
插件总管MybatisPlusInterceptor

本身是一个 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> { }
TableInfo从 BaseMapper<Order> 的泛型认出实体
属性列 id→id主键 · 雪花 orderNo→order_no驼峰转下划线 amount→amount status→status userId→user_id version→version乐观锁 deleted→deleted逻辑删除 createTime→create_time插入时填充

表名来自 @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,删除变成了 UPDATE
UPDATE 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 ( ?, ?, ?, ?, ?, ? )
注入的入口(简化版):每个 Mapper 登记时调用一次
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

能序列化的 Lambda 自带说明书SerializedLambda

里面记着:implMethodName = "getOrderNo"

去掉 get,首字母小写orderNo

这是属性名,还不是列名

查 TableInfoorder_no

写了 @TableField("xxx") 就用 xxx

字符串写法 .eq("order_no", …) 跳过了这一步,列名原样拼进 SQL。列名写错要到运行时才报错,列名来自前端还会被注入。

Wrapper → SQL 翻译机

勾选条件和功能,看 Wrapper 攒出来的片段、参数表,以及最后发给数据库的 SQL。SQL 里每一段按来源上了色。

WRAPPER 里的条件
功能开关
对应的 Java 代码
① Wrapper 攒出的片段 ew.sqlSegment
② 参数表 ew.paramNameValuePairs
③ 启动时注入的 selectList 模板(简化)
SELECT id,order_no,…,create_time FROM t_order
<where>
  deleted=0      ← 有 @TableLogic 才写进模板
  <if test="ew 里有条件">AND ${ew.sqlSegment}</if>
</where>
④ 最后发给数据库的
启动时注入 Wrapper 逻辑删除 多租户插件 分页插件

为什么值不会被注入:${ew.sqlSegment} 先把一段带 #{ew.paramNameValuePairs.MPGENVAL1} 的文本拼进模板,紧接着 MyBatis 再把这些 #{} 换成问号,值还是走 PreparedStatement 传。只有列名、last()、apply() 里直接拼的字符串,才会原样进 SQL。

② 调用时:自动填充和雪花 ID

insert、updateById 把实体交给 MyBatis 填问号之前,MyBatis-Plus 换上的参数处理器先过一遍实体:主键是空的就生成一个,标了填充的字段交给你的处理器。

自动填充实现 MetaObjectHandler,注册成 Bean
@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 开头的方法:字段已经有值就不覆盖
雪花 ID:一个 64 位的 Long,按比例画
0
毫秒时间戳 · 41 位
机房
机器
序列号 · 12
1能用约 69 年55每毫秒 4096 个

同一毫秒、同一台机器上靠序列号区分,所以不用查数据库就能生成,而且大致按时间递增。

Java 里的 id1854371283946876929 前端 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,把别的租户也算进去了。分页查询本身是对的,所以很难发现

常用功能,各在哪个时刻生效

遇到功能“不生效”,先对照这张表看它在哪一步起作用,再看你的调用是不是绕过了那一步。

功能你写的时刻效果绕过它的写法
通用 CRUDextends 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 就好
没变的:启动时注入 SQLWrapper 攒片段、值走参数插件总管按顺序执行雪花 ID

常踩的坑

多数“不生效”,都是调用绕过了那个功能起作用的时刻。

BaseMapper 的方法报 Invalid bound statement (not found)

启动时没走注入:多数据源时自己 new 了 MyBatis 原版的 SqlSessionFactoryBean,或者同时引了 mybatis-spring-boot-starter。换成 MybatisSqlSessionFactoryBean,只留一个 starter。

自己写的 XML 查询,把已删除的数据也查出来了

逻辑删除是注入时写进 MyBatis-Plus 生成的 SQL 里的,你手写的 SQL 它管不到。自己加 AND deleted = 0。

id 传到前端,最后几位变了

19 位的雪花 ID 超出了 JS 数字的精度。把 Long 序列化成字符串:字段上加 @JsonSerialize(using = ToStringSerializer.class),或者在 Jackson 里全局配置。

update(null, wrapper) 时 updateTime 没自动填

没有实体对象,填充处理器无处可填。传一个空实体 update(new Order(), wrapper),或者在 wrapper 里直接 set。

updateById 想把字段改成 null,没改掉

默认字段策略是 NOT_NULL,null 字段不进 SET。用 UpdateWrapper.set("remark", null),或在字段上设 updateStrategy = FieldStrategy.ALWAYS(老版本叫 IGNORED)。

用前端传来的字段名排序,被 SQL 注入

orderByDesc(sortField)、last() 和直接拼值的 apply() 都是原样拼进 SQL。排序字段先过白名单,apply 用 {0} 占位。

加了 @Version,updateById 还是覆盖了别人的修改

没注册 OptimisticLockerInnerInterceptor,或者实体里的 version 是 null。注册之后,冲突时 updateById 返回 0,要自己重试或提示用户。

分页的 total 把别的租户也算进去了

分页插件排在多租户前面,count 时还没加租户条件。按“多租户 → 分页 → 乐观锁 → 防全表”的顺序 add。

Prohibition of table update operation

防全表插件拦下了一条没有 WHERE 的 update。这是在保护你。真要全表改,带上明确的条件,或者手写 SQL。

saveBatch 插 1 万条要好几秒

JDBC 地址没加 rewriteBatchedStatements=true,一批还是一条条执行。加上之后,再按需调 batchSize。