MyBatis-Plus 的增强机制与深度整合

5 阅读34分钟

文章概述

前文衔接

前文《MyBatis 架构全解》《映射器原理》和《插件开发与拦截链实战》分别深入剖析了 MyBatis 原生的 ExecutorMapperProxyInterceptor 机制。MyBatis-Plus 正是在这些扩展点之上构建的一套增强框架——它没有修改任何 MyBatis 源码,却通过自定义 SqlSessionFactoryBean、扩展 Interceptor 和动态注入 MappedStatement,为开发者带来了通用 CRUD、分页、乐观锁等开箱即用的能力。本文将正面拆解这些增强机制的底层实现,揭示 MyBatis-Plus 如何成为 MyBatis 扩展点体系的集大成者。

核心观点

从原生 MyBatis 到 MyBatis-Plus,开发者最直观的感受是“连 Mapper XML 都不需要写了”。这一魔法的背后,是 MyBatis-Plus 对 MyBatis 四大对象和扩展点的深度运用:AbstractSqlInjector 动态生成通用的 InsertDeleteUpdateSelect 方法对应的 MappedStatement,悄然注入到 Configuration 中;MybatisPlusInterceptor 利用拦截链实现了比原生分页插件更优雅的方言适配;TableInfoHelper 通过解析实体注解构建了类似 JPA 的元数据模型。本文将沿 MyBatis-Plus 的增强架构,逐一拆解这些核心机制的源码实现。

核心要点

  • 通用 CRUD 自动注入AbstractSqlInjector 的扫描、构造与注册机制。
  • 分页插件增强MybatisPlusInterceptorPaginationInnerInterceptor 的拦截链设计。
  • SqlSessionFactory 增强MybatisSqlSessionFactoryBean 如何整合自动注入。
  • 实体元数据解析TableInfoHelper@TableName@TableId 等注解的处理。
  • 乐观锁与逻辑删除:通过 InterceptorSqlInjector 实现的透明增强。
  • 设计模式:策略、模板方法、工厂、建造者模式的综合应用。

文章组织架构图

flowchart TD
    1[1. MyBatis-Plus 增强架构总览: 替换点与扩展点全景]
    2[2. 通用 CRUD 的自动注入: AbstractSqlInjector 的源码拆解]
    3[3. 分页插件的增强实现: MybatisPlusInterceptor 与方言适配]
    4[4. MybatisSqlSessionFactoryBean 的增强整合]
    5[5. 实体元数据解析: TableInfoHelper 与注解处理]
    6[6. 乐观锁与逻辑删除的透明增强]
    7[7. 设计模式总结与原生对比]
    8[8. 生产事故排查专题]
    9[9. 面试高频专题]

    1 --> 2
    1 --> 3
    1 --> 4
    1 --> 5
    2 --> 6
    3 --> 6
    5 --> 6
    6 --> 7
    7 --> 8
    8 --> 9

架构图说明

  • 总览说明:全文 9 个模块从 MyBatis-Plus 的增强架构总览出发,逐步深入通用 CRUD 注入、分页增强、工厂增强、元数据解析、乐观锁与逻辑删除,最后通过设计模式总结、事故排查和面试完成闭环。

  • 逐模块说明:模块 1 建立 MyBatis-Plus 替换了哪些原生组件的全局认知;模块 2 深入最核心的 SQL 自动注入机制;模块 3-4 剖析分页插件和工厂增强;模块 5 讲解实体元数据的构建;模块 6 展示乐观锁和逻辑删除的透明实现;模块 7 提炼设计模式并与原生 MyBatis 对比;模块 8-9 落地排查与应试。

  • 关键结论MyBatis-Plus 的本质是对 MyBatis 扩展点体系的全面应用——通过替换 SqlSessionFactoryBean 整合增强能力,通过动态注入 MappedStatement 实现零 XML 的 CRUD,通过扩展 Interceptor 强化分页和乐观锁。理解 MyBatis-Plus 即理解了 MyBatis 扩展点的最大潜力。


1. MyBatis-Plus 增强架构总览:替换点与扩展点全景

MyBatis-Plus 的设计哲学是“只做增强不做改变”,所有增强均通过 MyBatis 开放的扩展点实现,不修改 MyBatis 一行源码。其全局架构围绕三个层面的替换与扩展展开:

  • SqlSessionFactory 层MybatisSqlSessionFactoryBean 替换原生 SqlSessionFactoryBean,在构建过程中触发实体元数据解析与 CRUD 方法自动注入。
  • Configuration 层MybatisConfiguration 扩展原生 Configuration,增强对 MappedStatement 注册的管理,并添加对自定义 TypeHandler 和逻辑删除的全局支持。
  • 插件层MybatisPlusInterceptor 作为新一代多拦截器链,统一管理分页、乐观锁、多租户等内部拦截器,完全兼容原生 Interceptor 接口。

这些替换组件与原生组件的对应关系以及它们利用的扩展点如下全景类图所示。

classDiagram
    class SqlSessionFactoryBean {
        +buildSqlSessionFactory() SqlSessionFactory
    }
    class MybatisSqlSessionFactoryBean {
        +afterPropertiesSet()
        +getObject() SqlSessionFactory
    }
    MybatisSqlSessionFactoryBean --|> SqlSessionFactoryBean : 继承

    class Configuration {
        +MappedStatement getMappedStatement(String id)
        +void addMappedStatement(MappedStatement ms)
    }
    class MybatisConfiguration {
        +void addMappedStatement(MappedStatement ms)
        +void addInterceptor(Interceptor interceptor)
    }
    MybatisConfiguration --|> Configuration : 继承

    class Interceptor {
        +intercept(Invocation invocation) Object
    }
    class MybatisPlusInterceptor {
        +intercept(Invocation invocation) Object
        +addInnerInterceptor(InnerInterceptor inner)
    }
    MybatisPlusInterceptor ..|> Interceptor : 实现
    class InnerInterceptor {
        +beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql)
        +beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout)
    }

    class AbstractSqlInjector {
        +inspectInject(MapperBuilderAssistant builderAssistant, Class<?> mapperClass)
    }
    class DefaultSqlInjector {
        +getMethodList(Class<?> mapperClass, TableInfo tableInfo) List<AbstractMethod>
    }
    AbstractSqlInjector --> MappedStatement : 动态构造并注册
    AbstractSqlInjector --> TableInfo : 读取表元数据

    class TableInfoHelper {
        -static Map<String, TableInfo> tableInfoMap
        +initTableInfo(MapperBuilderAssistant assistant, Class<?> clazz) TableInfo
    }
    TableInfoHelper --> TableInfo : 构建缓存

    class TableInfo {
        +String tableName
        +String keyColumn
        +List<TableFieldInfo> fieldList
    }

图表主旨概括:展示 MyBatis-Plus 如何通过继承和实现原生组件,在 SqlSessionFactory、Configuration、Interceptor 三大核心层面植入增强逻辑,同时利用独立组件完成元数据解析和 SQL 动态注入。

逐层/逐元素分解

  • MybatisSqlSessionFactoryBean 继承原生 SqlSessionFactoryBean,在 Spring 容器初始化时通过 afterPropertiesSet() 方法劫持工厂构建流程,触发 TableInfoHelper 的全局扫描,最终生成一个包含自动 SQL 能力的 SqlSessionFactory
  • MybatisConfiguration 继承原生 Configuration,重写 addMappedStatement() 方法以确保动态注入的 MappedStatement 能够正确注册,并与业务手写 SQL 共存。
  • MybatisPlusInterceptor 实现原生 Interceptor 接口,但内部维护了一个 InnerInterceptor 列表,通过组合方式将分页、乐观锁等拦截逻辑拆分为内聚的拦截器单元,解决了原生多拦截器排序难的问题。
  • AbstractSqlInjector 作为 SQL 注入的核心抽象,依赖 TableInfoHelper 提供的实体元数据,动态构造 MappedStatement 并注册到 ConfigurationmappedStatements 集合中。

设计原理映射:该架构广泛运用了模板方法模式AbstractSqlInjector 定义注入骨架)、工厂模式MybatisSqlSessionFactoryBean 作为增强工厂)、组合模式MybatisPlusInterceptor 组合多个 InnerInterceptor)和策略模式(内部拦截器的不同实现)。

工程联系与关键结论MyBatis-Plus 并非另起炉灶,而是精准识别了 MyBatis 设计中的关键扩展点(SqlSessionFactory 构建、MappedStatement 注册、拦截器链),通过继承和多态进行无侵入增强。理解这一架构是精通 MyBatis-Plus 的基础。


2. 通用 CRUD 的自动注入:AbstractSqlInjector 的源码拆解

通用 CRUD 是 MyBatis-Plus 最核心的增强能力。它的实现完全依赖 MyBatis 的 MappedStatement 动态注册机制:对每一个 Mapper 接口,MyBatis-Plus 会动态生成对应的 InsertDeleteUpdateSelectList 等方法的 MappedStatement,并将其注入到全局 Configuration 中。这个过程由 AbstractSqlInjector 及其子类 DefaultSqlInjector 完成。

2.1 AbstractSqlInjector.inspectInject 的扫描与注入流程

AbstractSqlInjector.inspectInject() 是通用 CRUD 注入的入口方法。当 Mapper 接口被 MyBatis 扫描并注册时,MyBatis-Plus 会调用此方法,为每个 Mapper 检查并注入通用方法。

关键源码com.baomidou.mybatisplus.core.injector.AbstractSqlInjector):

@Override
public void inspectInject(MapperBuilderAssistant builderAssistant, Class<?> mapperClass) {
    // 1. 获取实体类 Class
    Class<?> modelClass = extractModelClass(mapperClass);
    if (modelClass != null) {
        // 2. 解析实体,构建 TableInfo
        TableInfo tableInfo = TableInfoHelper.initTableInfo(builderAssistant, modelClass);
        // 3. 获取要注入的方法列表(模板方法)
        List<AbstractMethod> methodList = getMethodList(mapperClass, tableInfo);
        if (CollectionUtils.isNotEmpty(methodList)) {
            // 4. 遍历注入每一个方法对应的 MappedStatement
            for (AbstractMethod method : methodList) {
                method.inject(builderAssistant, mapperClass, modelClass, tableInfo);
            }
        }
    }
}

解读

  • extractModelClass 通过 Mapper 的泛型签名解析出实体类型。
  • 调用 TableInfoHelper.initTableInfo 解析实体注解,构建 TableInfo 元数据对象。
  • getMethodList 是抽象方法,由子类实现,返回包含 InsertDeleteUpdateSelectByIdAbstractMethod 子类的列表。这是模板方法模式的核心:骨架流程固定,方法列表的具体构成由子类决定。
  • 遍历 AbstractMethod 列表,调用 inject 方法完成具体 MappedStatement 的构造和注册。

2.2 AbstractMethod.inject 构造 MappedStatement

每个具体的 AbstractMethod(如 InsertDelete)负责生成一条特定的 SQL 模板,并借助 MappedStatement.Builder 构造出完整的 MappedStatement

Insert 方法为例(com.baomidou.mybatisplus.core.injector.methods.Insert):

public class Insert extends AbstractMethod {
    @Override
    public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
        // 拼接 SQL: INSERT INTO table (columns) VALUES (#{property1}, #{property2} ...)
        String sql = String.format(SqlMethod.INSERT_ONE.getSql(),
                                   tableInfo.getTableName(),
                                   tableInfo.getKeyColumn(),
                                   tableInfo.getAllSqlSelect()); // 所有字段
        SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, modelClass);
        // 构造 MappedStatement
        return this.addInsertMappedStatement(mapperClass, modelClass, SqlMethod.INSERT_ONE.getMethod(), sqlSource);
    }
}

解读

  • SqlMethod.INSERT_ONE 是预定义的 SQL 模板,形如 INSERT INTO %s (%s) VALUES (%s)
  • tableInfo.getAllSqlSelect() 返回实体所有列名拼接的字符串。
  • languageDriver.createSqlSource 会基于 SQL 字符串和实体类型创建 SqlSource。默认 XMLLanguageDriver 会将 #{} 参数识别为动态参数,从而生成 StaticSqlSource(因为 SQL 是静态文本,没有 OGNL 动态标签)。
  • addInsertMappedStatement 最终调用 MappedStatement.Builder 构建对象并注册到 Configuration

内联示例:观察自动 CRUD 的 SQL 输出

// 实体类
@TableName("user")
public class User {
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    private String username;
    private Integer age;
    // getter/setter...
}

// Mapper 接口
public interface UserMapper extends BaseMapper<User> {
}

// 测试代码
@SpringBootTest
public class AutoCrudTest {
    @Autowired
    private UserMapper userMapper;

    @Test
    public void testInsert() {
        User user = new User();
        user.setUsername("zhangsan");
        user.setAge(25);
        userMapper.insert(user); // 自动注入的 Insert 方法
    }
}

控制台日志输出(MyBatis-Plus 配置 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl):

==>  Preparing: INSERT INTO user ( username, age ) VALUES ( ?, ? )
==> Parameters: zhangsan(String), 25(Integer)

关键结论AbstractSqlInjector 将实体映射信息转化为标准 MappedStatement,并注入到原生 Configuration 中。整个过程完全遵循 MyBatis 的 SQL 执行流程,业务手写的 XML 或注解 SQL 不会受到任何影响。

2.3 通用 CRUD 注入的序列图

sequenceDiagram
    participant Spring as Spring 容器
    participant MSSFB as MybatisSqlSessionFactoryBean
    participant SFB as SqlSessionFactoryBean
    participant MBA as MapperBuilderAssistant
    participant ASI as AbstractSqlInjector
    participant TH as TableInfoHelper
    participant AM as AbstractMethod (Insert)
    participant CFG as MybatisConfiguration

    Spring ->> MSSFB: afterPropertiesSet()
    MSSFB ->> SFB: super.buildSqlSessionFactory()
    loop 扫描每个 Mapper 接口
        SFB ->> MBA: 处理 mapperClass
        MSSFB ->> ASI: inspectInject(builderAssistant, mapperClass)
        ASI ->> TH: initTableInfo(modelClass)
        TH -->> ASI: TableInfo
        ASI ->> AM: inject(builderAssistant, mapperClass, modelClass, tableInfo)
        AM ->> AM: 构造 SqlSource (INSERT INTO user (...) VALUES (...))
        AM ->> CFG: addMappedStatement(ms)
    end
    MSSFB -->> Spring: SqlSessionFactory

图表主旨概括:展示 Spring 容器初始化 MyBatis-Plus 时,如何从 MybatisSqlSessionFactoryBean 出发,串行触发实体元数据解析和通用 SQL 方法注入的全过程。

逐层/逐元素分解

  • Spring 调用 MybatisSqlSessionFactoryBean.afterPropertiesSet(),该方法首先调用父类 SqlSessionFactoryBean.buildSqlSessionFactory() 完成原生 MyBatis 的解析和工厂构建。
  • 在解析每个 Mapper 接口时,MyBatis-Plus 劫持 MapperBuilderAssistant 的处理流程,将 Mapper 接口传递给 AbstractSqlInjector.inspectInject
  • inspectInject 内部先通过 TableInfoHelper 获取表元数据,再将所有 AbstractMethod 子类实例依次执行 inject,构造并注册 MappedStatement

设计原理映射AbstractSqlInjector 是典型的模板方法模式骨架,getMethodList() 交由子类实现具体注入方法集合;AbstractMethod 的各个子类通过建造者模式MappedStatement.Builder)一步一步构建出完整的映射语句。

工程联系与关键结论自动注入完全依托 MyBatis 标准的 MappedStatement 注册通道,注入的 MappedStatement 与 XML 中手动定义的没有本质区别。这意味着它们共享相同的缓存、插件拦截和 SQL 执行器,不会破坏 MyBatis 的内核一致性。


3. 分页插件的增强实现:MybatisPlusInterceptor 与方言适配

分页是 MyBatis-Plus 最常用的增强功能之一。其实现基于 MyBatis 的拦截器机制,但相比于原生分页插件的单一拦截逻辑,MyBatis-Plus 从 3.4.0 开始引入新一代 MybatisPlusInterceptor,将单个拦截器拆分为“主拦截器 + 内部拦截器链”的架构,使得分页、乐观锁、多租户等功能能够按需组合且执行顺序可控。

3.1 MybatisPlusInterceptor 的多拦截器链设计

MybatisPlusInterceptor 实现了 org.apache.ibatis.plugin.Interceptor 接口,但其核心是维护一个 List<InnerInterceptor> 集合,所有内部拦截器按添加顺序执行。

关键源码com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor):

public class MybatisPlusInterceptor implements Interceptor {
    private List<InnerInterceptor> interceptors = new ArrayList<>();

    public void addInnerInterceptor(InnerInterceptor innerInterceptor) {
        this.interceptors.add(innerInterceptor);
    }

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object target = invocation.getTarget();
        Object[] args = invocation.getArgs();
        if (target instanceof Executor) {
            for (InnerInterceptor interceptor : interceptors) {
                interceptor.beforeQuery((Executor) target, (MappedStatement) args[0], args[1], 
                        (RowBounds) args[2], (ResultHandler) args[3], (BoundSql) args[4]);
            }
            Object result = invocation.proceed();
            for (int i = interceptors.size() - 1; i >= 0; i--) {
                interceptors.get(i).afterQuery(...);
            }
            return result;
        }
        // ... 类似处理 StatementHandler、ParameterHandler、ResultSetHandler
        return invocation.proceed();
    }
}

解读

  • InnerInterceptor 接口定义了 beforeQuerybeforePreparebeforeUpdate 等生命周期回调方法,覆盖了 Executor、StatementHandler 等关键拦截点。
  • MybatisPlusInterceptor 通过 invocation.getTarget() 判断当前拦截的目标类型,分别执行相应的内部拦截器链。
  • 这种“集中入口 + 责任链”的设计解决了原生 MyBatis 多插件通过 @Intercepts 注解排序困难的问题,用户只需按顺序添加内部拦截器即可精确控制执行顺序。

3.2 PaginationInnerInterceptor 的方言适配与 BoundSql 改写

PaginationInnerInterceptor 是负责分页的核心内部拦截器,它拦截 Executor.queryStatementHandler.prepare,根据数据库方言和分页参数动态改写 SQL 并执行 count 查询。

执行流程(详见 beforeQuery 方法):

  1. 判断是否需要分页:检查传入参数是否为 IPage 子类,且 searchCounttrue(默认开启)。
  2. 执行 count 查询:根据原始 SQL 构建 count 语句,通过 jsqlparser 优化(如移除不必要的 ORDER BY),然后调用 Executor.query 获取总记录数。
  3. 拼接分页 SQL:使用方言实现类(如 MySQLDialect)在原始 SQL 后追加 LIMIT offset, limit
  4. 改写 BoundSql:生成新的 BoundSql 对象,替换原有 SQL 和参数映射,并传递给后续执行器。

分页 SQL 改写源码片段PaginationInnerInterceptor.beforeQuery 及相关辅助方法):

protected void beforeQuery(Executor executor, MappedStatement ms, Object parameter,
                           RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
    IPage<?> page = ParameterUtils.findPage(parameter).orElse(null);
    if (page == null || page.getSize() < 0 || !page.searchCount()) {
        return;
    }
    // count 查询
    long total = queryCount(executor, ms, parameter, boundSql);
    page.setTotal(total);
    // 分页 SQL
    String originalSql = boundSql.getSql();
    String pageSql = dialect.buildPaginationSql(originalSql, page.offset(), page.getSize());
    // 覆盖 BoundSql
    BoundSql newBoundSql = new BoundSql(ms.getConfiguration(), pageSql, 
        boundSql.getParameterMappings(), parameter);
    // 通过反射将 newBoundSql 设置回 BoundSql 关联的 StatementHandler
    MetaObject metaObject = SystemMetaObject.forObject(boundSql);
    metaObject.setValue("sql", pageSql);
}

解读

  • dialect.buildPaginationSql 是策略模式的体现,针对 MySQL、PostgreSQL、Oracle 等不同数据库有不同的方言实现。
  • count 查询默认会通过 JSqlParser 进行优化,例如去除 ORDER BY、去掉不必要的列等,这可以通过 optimizeCountSql 选项控制。
  • 改写后的 BoundSql 直接替换了原对象的关键属性,由于后续 StatementHandler 使用的就是该 BoundSql 实例,因此实际执行的 SQL 已经被修改。

配置示例与验证

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
    paginationInterceptor.setOptimizeCountSql(true); // 开启 count 优化
    interceptor.addInnerInterceptor(paginationInterceptor);
    return interceptor;
}

测试分页查询:

Page<User> page = new Page<>(2, 10);
userMapper.selectPage(page, Wrappers.<User>lambdaQuery().gt(User::getAge, 18));
System.out.println("总记录数:" + page.getTotal());
System.out.println("当前页记录:" + page.getRecords().size());

日志输出:

==>  Preparing: SELECT COUNT(*) FROM user WHERE age > ?
==>  Preparing: SELECT id,username,age FROM user WHERE age > ? LIMIT 10,10

关键结论MybatisPlusInterceptor 把复杂的拦截逻辑切分为独立的 InnerInterceptor,实现了对 MyBatis 拦截器机制的再造升级。分页插件通过在 Executor 层拦截并改写 BoundSql,完全复用 MyBatis 原生的 SQL 执行引擎。

3.3 分页拦截器改写 BoundSql 序列图

sequenceDiagram
    participant Executor as MyBatis Executor
    participant MPI as MybatisPlusInterceptor.intercept
    participant PII as PaginationInnerInterceptor
    participant Dialect as JdbcDialect (MySQL)
    participant BoundSql as BoundSql
    participant ExecutorQ as Executor.query (count)

    Executor ->> MPI: intercept (target=Executor)
    MPI ->> PII: beforeQuery(executor, ms, param, rowBounds, boundSql)
    PII ->> PII: 检查参数存在 IPage && searchCount=true
    PII ->> Dialect: buildCountSql(originalSql)
    Dialect -->> PII: countSql
    PII ->> ExecutorQ: executor.query(ms, param, rowBounds, countBoundSql)
    ExecutorQ -->> PII: totalCount
    PII ->> page: setTotal(totalCount)
    PII ->> Dialect: buildPaginationSql(originalSql, offset, limit)
    Dialect -->> PII: pageSql
    PII ->> BoundSql: 反射修改 sql 为 pageSql
    PII -->> MPI: 返回
    MPI ->> Executor: 继续执行 (invocation.proceed)
    Executor ->> BoundSql: 执行改写后的 SQL

图表主旨概括:完整演示分页拦截器如何在 MyBatis 执行器层面截获查询操作,执行 count 查询并改写原始 SQL,最终将分页 SQL 送回原执行流程。

逐层/逐元素分解

  • MybatisPlusInterceptor 收到 Executor.query 调用后,依次调用内部拦截器的 beforeQuery
  • PaginationInnerInterceptor 提取 IPage 参数,决定是否进行分页处理。
  • 使用 dialect 构建 count SQL 并执行一次单独的查询,将结果写入 page.setTotal
  • 再次使用 dialect 构建分页 SQL,并通过 MyBatis 的 MetaObject 反射机制直接修改 BoundSqlsql 字段,使其指向分页 SQL。
  • invocation.proceed() 继续执行,此时 Executor 拿到的是更改后的 BoundSql,实际对数据库执行分页查询。

设计原理映射JdbcDialect 接口及 MySQLDialectOracleDialect 等实现构成策略模式,用于屏蔽不同数据库的分页语法差异;MybatisPlusInterceptor 采用组合模式管理多个内部拦截器,是对责任链模式的灵活实现。

工程联系与关键结论分页插件不依赖 XML 或注解配置,完全通过拦截器透明织入。这种设计启示我们,任何需要统一修改 SQL 或干预执行流程的需求(如多租户字段填充、数据权限过滤)都可以借鉴这种“Executor 层拦截 + BoundSql 改写”的模式。


4. MybatisSqlSessionFactoryBean 的增强整合

MybatisSqlSessionFactoryBean 是 MyBatis-Plus 整合 Spring Boot 的枢纽,它继承原生的 SqlSessionFactoryBean,在工厂构建的关键节点插入增强逻辑,从而使自动 CRUD 注入、实体元数据解析等能力无缝融入 MyBatis 的生命周期。

4.1 类结构与增强点

MybatisSqlSessionFactoryBean 通过重写 afterPropertiesSet()getObject() 方法,在父类构建完成 SqlSessionFactory 后触发一系列后置处理。

核心源码骨架com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean):

public class MybatisSqlSessionFactoryBean extends SqlSessionFactoryBean {
    @Override
    public void afterPropertiesSet() throws Exception {
        // 1. 调用父类构建原生 SqlSessionFactory
        super.afterPropertiesSet();
        // 2. 获取 Configuration(此时为 MybatisConfiguration 实例)
        MybatisConfiguration configuration = (MybatisConfiguration) getSqlSessionFactory().getConfiguration();
        // 3. 初始化全局配置(如枚举处理、SQLLogger 等)
        // 4. 启用 TableInfoHelper 全局初始化钩子
        // 此步骤并非简单扫描,实际注入在 XML 映射解析阶段触发
    }
}

实际上,自动注入并非在 afterPropertiesSet 中一次性完成所有扫描,而是通过 MyBatis 的 XMLMapperBuilder 解析 Mapper XML 或 MapperAnnotationBuilder 解析注解接口时,MP 劫持了 MapperBuilderAssistant,进而触发 AbstractSqlInjector.inspectInject。在 Spring Boot 自动配置中,MybatisPlusAutoConfiguration 通过 MapperScannerConfigurer 提前扫描 Mapper 包,并在构建 SqlSessionFactory 时确保 Mapper 接口与 XML 一同被解析。

4.2 初始化与自动注入触发序列图

sequenceDiagram
    autonumber
    participant Spring as Spring Boot 启动
    participant AutoConf as MybatisPlusAutoConfiguration
    participant MSSFB as MybatisSqlSessionFactoryBean
    participant SFB as SqlSessionFactoryBean
    participant CFG as MybatisConfiguration
    participant MapperReg as MapperRegistry
    participant MBA as MapperBuilderAssistant
    participant Injector as AbstractSqlInjector

    Spring ->> AutoConf: 自动配置
    AutoConf ->> MSSFB: 初始化并调用 afterPropertiesSet
    MSSFB ->> SFB: buildSqlSessionFactory()
    SFB ->> CFG: 创建 Configuration
    loop 扫描每个 Mapper 接口
        SFB ->> MapperReg: addMapper(mapperInterface)
        MapperReg ->> MBA: 创建 MapperAnnotationBuilder
        MBA ->> MSSFB: (钩子) 触发 MP 增强处理
        MSSFB ->> Injector: "inspectInject(mapperAssistant, mapperClass)"
    end
    MSSFB -->> Spring: SqlSessionFactory 就绪

图表主旨概括:描述 Spring Boot 集成 MyBatis-Plus 时,MybatisSqlSessionFactoryBean 如何在原生工厂构建过程中注入自动 CRUD 能力。

逐层/逐元素分解

  • MybatisPlusAutoConfiguration 使用 @MapperScan 注册的 Mapper 在构建 SqlSessionFactory 时被一一解析。
  • 原生 MapperRegistry.addMapper 会创建 MapperAnnotationBuilder 并遍历接口方法解析,但对于 BaseMapper 继承的方法,原生无法处理。
  • MyBatis-Plus 替换了 MapperAnnotationBuilder 的处理逻辑(通过 MybatisMapperAnnotationBuilder),在处理 Mapper 时额外调用 AbstractSqlInjector.inspectInject,从而将通用 CRUD 方法注入进去。
  • 这一过程完全透明,业务代码无需感知。

设计原理映射MybatisSqlSessionFactoryBean 是典型的装饰器模式/工厂方法模式应用,它在不改变父类接口的前提下对构建过程进行了增强。

工程联系与关键结论SqlSessionFactoryBean 的替换是 MyBatis-Plus 整合能力的基础。开发者若要开发自己的 MyBatis 增强框架,首要步骤就是提供自定义的 SqlSessionFactoryBean,以获得对工厂构建过程的完全控制权。


5. 实体元数据解析:TableInfoHelper 与注解处理

实体元数据是 MyBatis-Plus 各模块运行的基础。通用 CRUD 注入需要知道表名、主键列、字段映射;分页插件处理逻辑删除时需要知道逻辑删除字段;乐观锁需要识别版本字段。所有这些信息均通过 TableInfoHelper 解析实体注解并缓存为 TableInfo 对象。

5.1 TableInfoHelper.initTableInfo 的解析逻辑

TableInfoHelper 内部维护了一个静态 ConcurrentHashMap,存放每个实体类与 TableInfo 的映射关系。当某个 Mapper 接口首次被处理时,会触发对应实体的解析。

核心源码com.baomidou.mybatisplus.core.toolkit.TableInfoHelper):

public static synchronized TableInfo initTableInfo(MapperBuilderAssistant builderAssistant, Class<?> clazz) {
    String cacheKey = builderAssistant.getCurrentNamespace() + ":" + clazz.getName();
    TableInfo tableInfo = TABLE_INFO_CACHE.get(cacheKey);
    if (tableInfo == null) {
        tableInfo = new TableInfo(clazz);
        // 解析 @TableName 注解
        TableName table = clazz.getAnnotation(TableName.class);
        String tableName = table == null ? StringPool.EMPTY : table.value();
        // ... 自动驼峰转下划线
        tableInfo.setTableName(tableName);

        // 解析 @TableId
        List<Field> allFields = ReflectionKit.getFieldList(clazz);
        for (Field field : allFields) {
            TableId tableId = field.getAnnotation(TableId.class);
            if (tableId != null) {
                tableInfo.setKeyColumn(columnName);
                tableInfo.setKeyProperty(field.getName());
                tableInfo.setKeyType(tableId.type());
            }
            // 解析 @TableField
            TableField tableField = field.getAnnotation(TableField.class);
            if (tableField != null && tableField.exist()) {
                TableFieldInfo fieldInfo = new TableFieldInfo(field, tableField);
                tableInfo.getFieldList().add(fieldInfo);
            }
        }
        // 缓存
        TABLE_INFO_CACHE.put(cacheKey, tableInfo);
    }
    return tableInfo;
}

解读

  • 方法使用同步锁保证线程安全,确保一个实体类仅解析一次。
  • TableName 注解可指定表名,若不指定则按驼峰转下划线规则生成。
  • 遍历所有字段,识别 @TableId 确定主键及 ID 生成策略(AUTOINPUTASSIGN_ID 等)。
  • @TableField 注解标记普通列,可配置映射列名、是否参与插入/更新、是否为逻辑删除字段等。
  • 解析后的 TableInfo 包含了 keyColumnkeyPropertyfieldList 等完整信息,供 SQL 注入和拦截器使用。

5.2 实体注解解析序列图

sequenceDiagram
    participant MBA as MapperBuilderAssistant
    participant TH as TableInfoHelper
    participant Cache as TABLE_INFO_CACHE
    participant Class as User.class
    participant TI as TableInfo
    participant Field as Field: id/username/age

    MBA ->> TH: initTableInfo(assistant, User.class)
    TH ->> Cache: 检查 User.class 是否已有缓存
    Cache -->> TH: null (未缓存)
    TH ->> TI: new TableInfo(User.class)
    TH ->> Class: 获取 @TableName 注解
    Class -->> TH: 表名 "user"
    TI ->> TI: setTableName("user")
    loop 遍历所有字段
        TH ->> Field: 获取 @TableId 注解
        Field -->> TH: 主键字段 id
        TI ->> TI: setKeyColumn("id"), setKeyProperty("id")
        TH ->> Field: 获取 @TableField 注解
        Field -->> TH: 普通字段信息
        TI ->> TI: addFieldInfo(TableFieldInfo)
    end
    TH ->> Cache: 放入缓存
    TH -->> MBA: TableInfo 实例

图表主旨概括:展示 TableInfoHelper 如何按需解析实体类的 JPA 风格注解,构建一个包含完整映射信息的 TableInfo 对象并缓存。

逐层/逐元素分解

  • 每当 MapperBuilderAssistant 请求实体元数据时,TableInfoHelper 先查询缓存。
  • 缓存未命中时,实例化 TableInfo,反射读取 @TableName@TableId@TableField 等注解。
  • 构建完成的 TableInfo 放入 ConcurrentHashMap,后续所有对该实体的访问都会直接命中缓存。

设计原理映射:采用单例缓存模式,确保每个实体只解析一次;使用反射进行元数据提取,属于内省模式的实践。

工程联系与关键结论TableInfoHelper 是 MyBatis-Plus 的“元数据中心”。任何依赖实体-表映射信息的增强组件(如 SQL 注入器、拦截器)都应通过它获取结构化的元数据,而不是各自去解析注解,这是保证一致性和性能的关键设计。


6. 乐观锁与逻辑删除的透明增强

MyBatis-Plus 的乐观锁和逻辑删除分别体现了对 MyBatis InterceptorSqlInjector 扩展点的进一步运用,实现了无感知的功能增强。

6.1 乐观锁实现原理

乐观锁通过 OptimisticLockerInnerInterceptor 实现,拦截 Executor.update 方法,在执行 UPDATE 语句前自动追加版本号字段的 WHERE 条件和 SET 更新。

使用要求:实体类中定义一个整数类型版本字段,并标注 @Version 注解。

public class User {
    @TableId
    private Long id;
    private String username;
    @Version
    private Integer version;
}

乐观锁拦截器核心源码OptimisticLockerInnerInterceptor.beforeUpdate):

protected void beforeUpdate(Executor executor, MappedStatement ms, Object parameter) {
    if (parameter instanceof Map) {
        Map<String, Object> map = (Map<String, Object>) parameter;
        // 识别实体对象中带有 @Version 字段
        Object entity = map.getOrDefault("et", map.get("param1"));
        if (entity != null) {
            TableInfo tableInfo = TableInfoHelper.getTableInfo(entity.getClass());
            if (tableInfo != null && tableInfo.isWithVersion()) {
                String versionColumn = tableInfo.getVersionColumn();
                Object versionValue = tableInfo.getVersionValue(entity);
                // 在原 SQL 上追加 WHERE version = #{versionValue} AND SET version = version + 1
                String originalSql = ms.getBoundSql(parameter).getSql();
                String newSql = addVersionToUpdate(originalSql, versionColumn, versionValue);
                // 修改 BoundSql 和 MappedStatement 的 SqlSource
                // ...类似分页插件的 BoundSql 改写
            }
        }
    }
}

执行示例:执行 userMapper.updateById(user),假设 user.version = 1

原生 SQL 本为:

UPDATE user SET username = ? WHERE id = ?

拦截后变为:

UPDATE user SET username = ?, version = version + 1 WHERE id = ? AND version = ?

参数:usernameidversion(1)

若更新影响 0 行,说明版本号已被其他事务修改,MyBatis-Plus 会抛出 OptimisticLockException(取决于配置)。

内联验证

@Test
public void testOptimisticLock() {
    User user = userMapper.selectById(1L);
    user.setUsername("lisi");
    userMapper.updateById(user);  // version 自动 +1,WHERE version = oldVersion
}

日志:

UPDATE user SET username=?, version=version+1 WHERE id=? AND version=?

关键点:乐观锁的实现完全依赖拦截器对 BoundSql 的修改,不侵入业务 SQL 编写。

6.2 逻辑删除实现原理

逻辑删除通过 @TableLogic 注解标记字段,并由 SqlInjector 改写通用删除、查询操作的 SQL 模板。

实体定义

@TableName("user")
public class User {
    @TableId
    private Long id;
    @TableLogic(value = "0", delval = "1")
    private Integer deleted;
}

MyBatis-Plus 的 DefaultSqlInjector 会根据 TableInfo 中是否存在逻辑删除字段,自动调整 SQL 行为:

  • 删除操作DELETE FROM user WHERE id = ? 被改写为 UPDATE user SET deleted = 1 WHERE id = ? AND deleted = 0
  • 查询/更新操作:所有 SELECTUPDATE 语句自动在 WHERE 中追加 AND deleted = 0

这种改写不是在拦截器中动态修改 BoundSql,而是在注入阶段就构造了包含逻辑删除条件的 SqlSource。例如 SelectById 的 SQL 模板会被拼接为:

SELECT id,username,deleted FROM user WHERE id = #{id} AND deleted = 0

因此注册的 MappedStatement 本身就带有逻辑删除条件,性能更好且无运行时开销。

逻辑删除注入源码片段com.baomidou.mybatisplus.core.injector.methods.DeleteById 条件拼接):

String logicSql = tableInfo.getLogicDeleteSql(true, true);
String sql = String.format(sqlMethod.getSql(), tableInfo.getTableName(), 
                           tableInfo.getKeyColumn(), logicSql);
// 其中 logicSql = " AND deleted = 0 " (条件) 或 " SET deleted = 1 " (删除转换)

验证

userMapper.deleteById(1L);

输出 SQL:

UPDATE user SET deleted=1 WHERE id=? AND deleted=0

查询时自动追加:

SELECT id,username,deleted FROM user WHERE deleted=0

关键结论乐观锁采用拦截器运行时改写,逻辑删除采用 SQL 注入时静态改写。两种策略的选择取决于增强行为的时机:乐观锁需要在每次执行时动态注入版本号值,而逻辑删除的条件是静态的,提前在 SQL 模板中写死更为高效。


7. 设计模式总结与原生对比

7.1 设计模式提炼

MyBatis-Plus 的增强架构中大量运用了经典设计模式,梳理如下:

  • 策略模式:不同数据库方言的分页处理(JdbcDialect 接口与 MySQLDialectOracleDialect 等具体实现);主键生成策略(IdType.AUTOASSIGN_ID 等)。
  • 模板方法模式AbstractSqlInjector.inspectInject 定义扫描、解析、注入骨架,子类 DefaultSqlInjector 通过 getMethodList() 决定具体注入哪些方法;AbstractMethod.inject 负责构造 MappedStatement
  • 工厂模式MybatisSqlSessionFactoryBean 作为增强的 SqlSessionFactory 工厂;MybatisPlusAutoConfiguration 根据配置自动组装。
  • 建造者模式MappedStatement.Builder 被各类 AbstractMethod 子类用于逐步构建映射语句对象。
  • 组合模式/责任链模式MybatisPlusInterceptor 组合多个 InnerInterceptor,形成可定制的拦截器链。
  • 单例与享元模式TableInfoHelper 全局缓存实体元数据,避免重复解析。

7.2 与原生 MyBatis 的对比

能力维度MyBatis 原生MyBatis-Plus 增强
CRUD SQL需手动编写 XML 或注解 SQL通用 CRUD 自动注入,零配置
分页自行编写插件或使用第三方内置 MybatisPlusInterceptor,方言自动适配,count 查询自动优化
乐观锁需手工在 SQL 中加入 version 条件添加 @Version 注解即可,拦截器自动改写
逻辑删除手工修改所有 SQL 添加 deleted 条件@TableLogic 注解即可,SQL 注入期自动拼接条件
实体映射ResultMap 手动配置@TableName@TableId@TableField 注解驱动,自动驼峰映射
插件管理单一 @Intercepts 定义,顺序难控MybatisPlusInterceptor 内部拦截器链,顺序精准控制
扩展方式编写自定义插件、TypeHandler可基于 AbstractSqlInjectorInnerInterceptorTableInfoHelper 等快速扩展

核心区别感知:原生 MyBatis 给予开发者理论上无限的灵活性,但缺乏开箱即用的生产力增强。MyBatis-Plus 在原生扩展点之上提供了层次化的增强组件,开发者既可以完全遵循 MyBatis 标准进行深入定制,又可以直接享受 90% 常见场景的自动化。


8. 生产事故排查专题

8.1 案例一:逻辑删除字段未加索引导致全表扫描雪崩

现象:某生产环境在业务高峰期频繁出现慢查询告警,数据库 CPU 飙升到 100%。排查发现大量查询都带有 WHERE deleted = 0 条件,但 deleted 字段没有索引。

排查过程

  1. 通过 SHOW FULL PROCESSLIST 发现大量 SELECT ... FROM order WHERE deleted = 0 状态为 Sending data
  2. 查看慢查询日志,该 SQL 平均扫描行数达 500 万,rows examined 与表记录数一致,说明为全表扫描。
  3. 检查表结构,deleted 字段为 tinyint,无任何索引。
  4. 确认实体类使用了 @TableLogic,MyBatis-Plus 自动为所有查询追加了 AND deleted = 0

根因:MyBatis-Plus 的逻辑删除机制透明添加 deleted 条件,但开发者忽略了该字段的选择性,未创建索引。当表数据量增大后,全表扫描导致磁盘 I/O 瓶颈。

解决

  • 立即为 deleted 字段添加索引:ALTER TABLE order ADD INDEX idx_deleted (deleted);
  • 同时考虑复合索引,将查询高频的 user_id + deleted 建立联合索引。

最佳实践

  • 逻辑删除字段必须添加索引,尤其在数据量较大的表上。
  • 在项目初期就应规划好逻辑删除字段的索引设计,避免后期被动。
  • 监控慢查询,对自动追加的条件保持敏感。

8.2 案例二:分页 count 查询因复杂子查询导致性能雪崩

现象:某报表查询接口返回数据超时,监控显示 count 查询耗时超过 15 秒,而数据查询仅需 0.5 秒。

排查

  1. 通过 MyBatis-Plus 日志发现 count SQL 长这样:
    SELECT COUNT(*) FROM (SELECT a.*, b.name FROM order a LEFT JOIN user b ON a.user_id=b.id WHERE ...) tmp_count
    
  2. 原因:业务 XML 中使用了复杂的左连接查询,MyBatis-Plus 的 optimizeCountSql 优化原本只移除了 ORDER BY,但 JSqlParser 未能优化掉不必要的左连接,导致 count 实际是全表扫描子查询结果。
  3. 查看 PaginationInnerInterceptor 配置,optimizeCountSqltrue(默认),但优化能力有限。

根因JSqlParser 对非常复杂的 SQL 优化可能不彻底,甚至改变语义,但该案例中优化后的 count 仍保留了不必要的外部连接,性能极差。

解决

  • 临时关闭 optimizeCountSql,并自定义 count 查询:在业务层手动调用 page.setSearchCount(false),然后单独编写轻量 count SQL 执行后 page.setTotal(count)
  • 或者重写 PaginationInnerInterceptor.dialect.buildPaginationSql 进行针对性优化。

最佳实践

  • 对于复杂关联查询,不要依赖自动 count 优化,应手动编写优化的 count SQL。
  • 在分页查询中,若 count 不是必须(如前端滚动加载),可设置 searchCount = false 减少一次查询。
  • 定期审查慢 SQL,对自动生成的 count 语句进行评估。

事故排查序列图

sequenceDiagram
    participant App as 应用服务
    participant MPI as MybatisPlusInterceptor
    participant Parser as JSqlParser
    participant DB as 数据库

    App ->> MPI: 分页查询复杂报表 page(current, size)
    MPI ->> Parser: 优化原始 SQL 生成 count SQL
    Parser -->> MPI: 优化不充分,保留 JOIN
    MPI ->> DB: 执行 count SQL (包含大表 JOIN)
    DB -->> MPI: 耗时15s,CPU飙升
    MPI ->> MPI: 继续分页 SQL 执行 (快速)
    MPI -->> App: 分页结果,但总耗时过长

    Note over App,DB: 根因:自动 count 优化对复杂 SQL 无效
    Note over App,DB: 解决:手动编写 count 或关闭自动 count

9. 面试高频专题

  1. MyBatis-Plus 如何在不修改 MyBatis 源码的前提下实现通用 CRUD?

    一句话回答:通过替换 SqlSessionFactoryBeanMybatisSqlSessionFactoryBean,在 Mapper 接口解析阶段利用 AbstractSqlInjector 动态构造 MappedStatement 并注册到 Configuration 中,完全遵循 MyBatis 原生扩展点机制。

    详细解释:MyBatis 在构建 SqlSessionFactory 时会解析每个 Mapper 接口或 XML 文件,生成 MappedStatement 并存入 Configuration.mappedStatementsMybatisSqlSessionFactoryBean 继承原生的 SqlSessionFactoryBean,重写了构建过程的关键步骤:当 MapperRegistry 调用 addMapper 时,MyBatis-Plus 劫持了 MapperBuilderAssistant 的流程,针对每个 Mapper 接口(尤其是继承 BaseMapper 的接口)触发 AbstractSqlInjector.inspectInject。该方法通过 TableInfoHelper 获取实体的表名、主键、字段映射等元数据,然后调用 DefaultSqlInjector.getMethodList 获取需要注入的方法列表(如 InsertDeleteUpdateSelectByIdAbstractMethod 子类)。每个 AbstractMethod 内部使用 MappedStatement.Builder 一步一步构造出完整的 MappedStatement,最终调用 Configuration.addMappedStatement 注册,与手写 SQL 的 MappedStatement 平等共存。所以整个过程没有修改 MyBatis 源码,只是利用了其开放的扩展点。

  2. AbstractSqlInjectorDefaultSqlInjector 的关系?

    一句话回答AbstractSqlInjector 定义了自动注入的模板骨架,DefaultSqlInjector 实现了 getMethodList() 方法,返回包含标准 CRUD 操作的 AbstractMethod 列表。

    详细解释AbstractSqlInjector 是典型的模板方法模式实现。它提供了一个不可被覆盖的 inspectInject 方法,其骨架流程包括:提取实体类 → 调用 TableInfoHelper.initTableInfo 解析元数据 → 调用抽象的 getMethodList() 获取方法集合 → 遍历每个方法执行 inject 注册。getMethodList() 被推迟到子类实现,这使得框架用户可以通过继承 DefaultSqlInjector 并覆盖该方法来自定义需要注入的通用方法。例如,若某项目不希望注入 Delete 方法,只需创建一个自定义 Injector 去掉该方法对应的 AbstractMethod 即可。DefaultSqlInjector 提供了 MyBatis-Plus 标准的 CRUD 方法集合,包括 InsertDeleteDeleteByMapDeleteByIdDeleteBatchByIdsUpdateUpdateByIdSelectByIdSelectBatchByIdsSelectByMapSelectCountSelectListSelectPage 等。

  3. MybatisPlusInterceptor 与原生 Interceptor 的异同?

    一句话回答:两者都实现 org.apache.ibatis.plugin.Interceptor 接口,但 MybatisPlusInterceptor 内部维护了一个 List<InnerInterceptor> 链,通过组合方式实现多拦截器的统一管理和顺序控制,比原生多插件通过 @Intercepts 注解排序更灵活。

    详细解释:原生 MyBatis 支持配置多个 Interceptor,通过 InterceptorChain 按顺序执行。然而开发者很难精准控制这些插件的执行先后,尤其是在引入第三方插件时。MybatisPlusInterceptor 将拦截逻辑进一步拆分为“主拦截器 + 内部拦截器”两层:主拦截器实现 Interceptor 接口,负责接收 Invocation 并判断目标类型(Executor、StatementHandler 等),然后按顺序调用内部拦截器的对应生命周期方法(beforeQuerybeforeUpdateafterQuery 等)。每个 InnerInterceptor 只关注特定的增强逻辑(分页、乐观锁、多租户等),开发者只需按 addInnerInterceptor 的添加顺序决定它们的先后执行顺序,彻底解决了排序难题。同时这种设计使得功能模块高度解耦,便于按需组合。

  4. 分页插件如何执行 count 查询?如何优化?

    一句话回答:分页插件在拦截 Executor.query 时,通过 JSqlParser 将原 SQL 解析并改写为 count SQL 单独执行,优化手段包括去除 ORDER BY、不必要的列以及将 SELECT * 替换为 COUNT(*),复杂场景可关闭自动优化并手动编写 count SQL。

    详细解释:当执行带有 IPage 参数的方法时,PaginationInnerInterceptor.beforeQuery 首先检查 page.searchCount 是否为 true(默认开启)。若开启,则取出 BoundSql 中的原始 SQL,交由方言的 buildCountSql 方法生成 count 语句。这一步利用 JSqlParser(若引入)进行优化:移除 ORDER BY 子句、将非必要的 GROUP BY 转换为简化形式、将 SELECT 列列表替换为 COUNT(*) 等。optimizeCountSql 开关控制是否启用 JSqlParser 优化(3.4.0+ 默认开启)。优化后生成新的 BoundSql 并通过 Executor.query 执行,获取总记录数写入 page.setTotal。但 JSqlParser 对极为复杂的 SQL(如多层嵌套、窗口函数)可能优化不彻底甚至改变语义,此时可设置 page.setSearchCount(false) 并在业务层手动执行一个专门的轻量 count,或自定义方言实现。

  5. 逻辑删除与物理删除的实现异同?

    一句话回答:逻辑删除不真正删除数据,通过 @TableLogic 标记字段,在 SQL 注入阶段将 DELETE 语句转换为 UPDATE SET deleted=1,查询自动追加 WHERE deleted=0;物理删除则是直接 DELETE 记录。

    详细解释:MyBatis-Plus 的逻辑删除是在注入阶段完成的。TableInfoHelper 解析实体类时,若发现 @TableLogic 注解,会记录其未删除值(如 0)和删除值(如 1)。DefaultSqlInjector 中的 DeleteByIdDeleteAbstractMethod 在构造 SqlSource 时,会检查 TableInfo 是否配置了逻辑删除。若是,则生成 UPDATE table SET deleted = 1 WHERE id = ? AND deleted = 0,而非 DELETE FROM table WHERE id = ?。同样,所有 SelectUpdate 方法的 SQL 模板都会在 WHERE 中自动拼接 AND deleted = 0。这意味着逻辑删除的 SQL 在注册时就已经是静态确定的,没有拦截器运行时开销。物理删除则完全不走逻辑删除路径,MyBatis-Plus 仍保留了物理删除方法(如 deletePhysicalById)供特殊场景使用。

  6. 乐观锁插件在并发场景下如何保证更新安全?

    一句话回答:乐观锁插件在 Executor.update 执行前拦截,自动在 UPDATE 语句的 SET 部分追加 version = version + 1,在 WHERE 部分追加 version = 查询时的旧版本值,若更新影响行数为 0,则说明版本冲突,重试或抛异常。

    详细解释OptimisticLockerInnerInterceptor 实现在 beforeUpdate 方法中。它先识别传入参数中是否包含带有 @Version 注解字段的实体对象,通过 TableInfoHelper 获取当前版本字段名和旧版本值。然后它通过 JSqlParser 或字符串处理,在原 SQL 的 SET 子句后添加 version = version + 1,在 WHERE 条件后添加 AND version = #{oldVersion}。注意每次更新时,旧版本值是从实体对象中取出作为新的参数追加到 SQL 参数列表中。执行更新后,MyBatis 返回影响行数。若行数为 0,MyBatis-Plus 会进行冲突检测(取决于配置,默认不自动抛异常,但提供了 ParameterUtils 等工具辅助判断)。开发者通常结合 UpdateWrapper 手动设置版本条件,或使用 updateById 自动体验乐观锁。

  7. TableInfoHelper 的线程安全与缓存策略?

    一句话回答TableInfoHelper 内部使用 ConcurrentHashMap 作为缓存容器,并在 initTableInfo 方法上加 synchronized 锁,确保每个实体类在多线程环境下仅被解析一次,后续读取无锁高性能。

    详细解释:在 Spring Boot 环境下,SqlSessionFactory 构建可能并发处理多个 Mapper 接口。TableInfoHelper.initTableInfo 是静态方法,为防止同一实体类被重复解析,它在方法级使用了 synchronized 关键字。方法内部首先根据 builderAssistant.getCurrentNamespace() + ":" + className 生成缓存 key 从 Map 中查询,若未命中则反射读取注解、构建 TableInfo 对象并放入 Map。解析过程涉及反射和字符串操作,相对较重,一旦完成缓存,后续所有对该实体的访问都直接从 ConcurrentHashMap 读取,无锁竞争。这种单例 + 懒加载的缓存策略保证了安全与性能。

  8. 如何自定义通用方法?例如批量插入。

    一句话回答:创建一个继承 AbstractMethod 的子类,在 injectMappedStatement 中构造对应的 SQL 和 MappedStatement,然后继承 DefaultSqlInjector 并重写 getMethodList,将该自定义方法加入列表即可。

    详细解释:MyBatis-Plus 提供了丰富的扩展点来自定义通用方法。以批量插入 insertBatch 为例:首先创建 InsertBatch 类,继承 com.baomidou.mybatisplus.core.injector.AbstractMethod,实现 injectMappedStatement。在其中编写 SQL 模板,例如遍历 collection 参数拼接多个 VALUES,使用 languageDriver.createSqlSource 生成 SqlSource,然后调用 this.addInsertMappedStatement 注册。接着创建 MySqlInjector 继承 DefaultSqlInjector,覆盖 getMethodList,在 super 返回的列表中添加 new InsertBatch()。最后在配置类中将自定义 Injector 设置给 MybatisSqlSessionFactoryBean 或通过 Spring Boot 的 @Bean 声明为 GlobalConfig 的一部分。

  9. MyBatis-Plus 的多租户是如何实现的?

    一句话回答:通过 TenantLineInnerInterceptor 拦截查询和修改操作,动态在 SQL 的 WHERE 条件中追加租户 ID 过滤条件(如 AND tenant_id = ?),实现透明化的多租户数据隔离。

    详细解释TenantLineInnerInterceptor 同样作为 MybatisPlusInterceptor 的内部拦截器使用。它通过 TenantLineHandler 接口供开发者定义如何获取当前租户 ID、哪些表需要过滤、是否忽略特定 SQL 等。在 beforeQuerybeforeUpdate 回调中,它利用 JSqlParser 解析原始 SQL 的 WHERE 子句,动态添加 tenant_id = currentValue。例如,查询 SELECT * FROM order 会被改写为 SELECT * FROM order WHERE tenant_id = 1。与逻辑删除的静态条件不同,多租户的条件值需在运行时从上下文获取(如 ThreadLocal 中的登录用户租户 ID),因此它采用拦截器运行时改写的方式,而不是 SQL 注入时静态写入。

  10. 自动注入的 MappedStatement 和 XML 中手写的如何共存?

    一句话回答:两者通过唯一的 id(格式为 mapperFullClassName.methodName)区分,存储在 Configuration.mappedStatements 这个 Map<String, MappedStatement> 中,互不冲突,执行时根据接口方法全限定名路由到对应的 MappedStatement

    详细解释:MyBatis 的方法执行核心是 MapperMethod.execute,它会根据接口的 method 对象拼接出 statement id:mapperInterface.getName() + "." + methodName,然后通过 configuration.getMappedStatement(id) 获取要执行的 SQL。自动注入的 CRUD 方法(如 insertupdateById)的 id 遵循同样的命名规则,只要不与手动编写的 statement id 重名就不会冲突。由于手动 SQL 通常由开发者自定义方法名(如 selectUserByOrder),而注入的方法是基类方法名(如 selectById),因此它们在 Map 中是天然隔离的。如果开发者需要覆盖某一个注入方法,可以在自己的 XML 或注解中定义一个同名同参数的方法,手动编写的 MappedStatement 会覆盖注入的(取决于加载顺序,通常手动定义在后,会替换)。

  11. MyBatis-Plus 与 JPA 在注解映射上的设计差异?

    一句话回答:MyBatis-Plus 的注解(@TableName@TableId@TableField)侧重于 SQL 生成和结果映射,提供细粒度的字段控制(如 existselectfill);JPA 的注解(@Entity@Id@Column)建立在完整的持久化上下文和生命周期管理之上,依赖实体管理器自动脏检查。

    详细解释:JPA 是一个全自动的 ORM 框架,@Entity 注解标记的类被纳入持久化上下文管理,对象的每一个状态变更最终都会自动同步到数据库(脏检查)。因此 JPA 注解除了列映射,还涉及关系映射(@OneToMany@ManyToMany)和缓存(一级缓存)等。MyBatis-Plus 本质仍是 SQL 映射框架,其注解主要解决两个问题:表-实体映射(@TableName@TableId)和字段行为配置(@TableFieldfill 自动填充、exist 是否数据库存在该列、select 是否默认查询等)。MyBatis-Plus 没有一级缓存的脏检查机制,SQL 执行时机完全由开发者显式调用 Mapper 方法决定,因此更轻量且对 SQL 掌控度更高。

  12. 系统设计题:如何在现有 MyBatis-Plus 基础上实现数据审计功能(自动填充创建人、修改人)?

    一句话回答:实现 MetaObjectHandler 接口,在 insertFillupdateFill 中通过 strictInsertFill / strictUpdateFill 方法为标记了 @TableField(fill = FieldFill.INSERT) 的字段自动设置当前用户和当前时间,利用 MyBatis 的参数处理扩展点,在填充阶段注入审计信息。

    详细解释:MyBatis-Plus 的自动填充功能是 MetaObjectHandler 的实现,它在 PreparedStatementHandler.parameterize 方法执行前被调用。具体步骤为:创建 MyMetaObjectHandler 类实现 MetaObjectHandler 接口,重写 insertFillupdateFill。在 insertFill 中,通过 this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()) 设置创建时间,通过 this.strictInsertFill(metaObject, "createBy", String.class, getCurrentUser()) 设置创建人。在 updateFill 中类似设置修改时间和修改人。实体类上相应字段标注 @TableField(fill = FieldFill.INSERT)FieldFill.UPDATE(或 FieldFill.INSERT_UPDATE)。获取当前登录用户通常从 RequestContextHolderSecurityContextHolder 提取,需要确保填充时 ThreadLocal 中的用户信息已设置。该机制本质上是利用 MyBatis 在执行 SQL 前设置实体的属性值,然后将完整参数交给 ParameterHandler 处理,不侵入业务 SQL 编写。


文末速查表

核心组件作用利用的扩展点
MybatisSqlSessionFactoryBean增强 SqlSessionFactory 构建SqlSessionFactoryBean
MybatisConfiguration扩展 ConfigurationConfiguration
AbstractSqlInjector动态注入通用 MappedStatementMapperBuilderAssistantMappedStatement 注册
MybatisPlusInterceptor统一多拦截器链Interceptor 接口
PaginationInnerInterceptor分页 SQL 改写与 count 查询Executor 拦截点、BoundSql
OptimisticLockerInnerInterceptor乐观锁版本号自动追加Executor 拦截点
TableInfoHelper实体注解解析与元数据缓存反射、@TableName/@TableId/@TableField
DefaultSqlInjector提供标准 CRUD 方法注入列表AbstractSqlInjector 模板方法
逻辑删除DELETE 转 UPDATE、查询追加条件@TableLogic、SqlInjector SQL 构造

Demo 代码附录

(提供完整可运行项目结构,此处给出核心文件)

pom.xml 关键依赖

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.3</version>
</dependency>

实体类

@TableName("user")
public class User {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String username;
    private Integer age;
    @Version
    private Integer version;
    @TableLogic(value = "0", delval = "1")
    private Integer deleted;
    // ... getter/setter
}

配置类

@Configuration
public class MybatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
        return interceptor;
    }
}

测试类

@SpringBootTest
class UserMapperTest {
    @Autowired UserMapper userMapper;

    @Test
    void testCrudAndPageAndLock() {
        // 插入
        User user = new User(); user.setUsername("demo"); user.setAge(20);
        userMapper.insert(user);
        // 分页
        Page<User> page = new Page<>(1, 10);
        userMapper.selectPage(page, Wrappers.lambdaQuery());
        System.out.println(page.getRecords().size());
        // 乐观锁更新
        user.setUsername("updated");
        userMapper.updateById(user); // version 自动 +1
        // 逻辑删除
        userMapper.deleteById(user.getId());
        // 查询不再返回被逻辑删除记录
        Assertions.assertNull(userMapper.selectById(user.getId()));
    }
}

延伸阅读

  1. MyBatis-Plus 官方文档:baomidou.com
  2. MyBatis-Plus 源码仓库:github.com/baomidou/my…
  3. MyBatis 系列之插件开发与拦截链实战(本系列第 7 篇)
  4. MyBatis 系列之 Spring Boot 整合核心(本系列第 5 篇)
  5. 深入理解 MyBatis 缓存与拦截器原理 - 《MyBatis 3 源码深度解析》

本文以 MyBatis 扩展点为知识锚点,完整剖析了 MyBatis-Plus 在不修改一行 MyBatis 源码的前提下,如何通过替换核心工厂、扩展拦截器体系和动态注入 MappedStatement 实现通用 CRUD、分页、乐观锁、逻辑删除等高级特性。理解这些增强机制,不仅能够熟练运用 MyBatis-Plus,更能深刻掌握 MyBatis 的可扩展设计哲学,为构建企业内部增强框架提供扎实的参考范式。