Skip to content

结论先行

MyBatis-Plus 是 MyBatis 的增强层,不是替代品。不 fork、不修改核心,100% 兼容。实际项目里两者共存,MP 覆盖 80% 单表 CRUD,MyBatis XML 覆盖 20% 复杂场景。

MP 的四层体系

MyBatis 原生链路:SqlSession → Executor → StatementHandler → ParameterHandler/ResultSetHandler

MP 在它上面加了四层:

Service 层 (IService/ServiceImpl)  ← 批量操作、事务
Mapper 层 (BaseMapper<T>)          ← 零 XML CRUD
SQL 注入层 (AbstractMethod)        ← 自动生成 MappedStatement
元数据层 (TableInfo/TableFieldInfo) ← 实体-表映射缓存

每层可以独立替换。比如重写 ServiceImpl.saveBatch(默认是逐条 INSERT 不是真 batch),不影响 Mapper 和注入层。

一个常见的混乱:BaseMapper.selectByIdIService.getById 功能几乎一样,区别在前者返回 int 后者返回 boolean。团队里得统一规范到底用哪层 API。

SQL 注入:CRUD 自动生成链路

Spring Boot 启动时的注入过程:

MybatisPlusAutoConfiguration
  → MybatisSqlSessionFactoryBean.buildSqlSessionFactory()
    → MybatisMapperRegistry → MapperAnnotationBuilder.parse()
      → MybatisPlusInjector.inspectInject()
        → 遍历所有 AbstractMethod 子类
          → SelectById.injectMappedStatement()

每个 CRUD 方法对应一个 AbstractMethod 子类:

java
public class SelectById extends AbstractMethod {
    @Override
    public MappedStatement injectMappedStatement(
            Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
        SqlMethod sqlMethod = SqlMethod.SELECT_BY_ID;
        SqlSource sqlSource = super.createSqlSource(configuration,
            String.format(sqlMethod.getSql(),
                sqlSelectColumns(tableInfo, false),
                tableInfo.getTableName(),
                tableInfo.getKeyColumn(),
                tableInfo.getKeyProperty(),
                tableInfo.getLogicDeleteSql(true, true)
            ), Object.class);
        return this.addSelectMappedStatementForTable(
            mapperClass, methodName, sqlSource, tableInfo);
    }
}

SQL 模板在 SqlMethod 枚举里定义,占位符由 TableInfo 填充。模板方法模式:子类提供模板和方法名,父类管注册、缓存、ResultMap。

自定义全局方法的扩展方式: 写一个 AbstractMethod 子类 → 注册到注入器 → 所有 Mapper 自动获得该方法。

抽象泄漏: 自定义方法里需要手动调用 tableInfo.getLogicDeleteSql() 这类方法才能支持逻辑删除/多租户——这些本应是"自动"的能力。

TableInfo 缓存

java
public class TableInfo {
    private Class<?> entityType;
    private String tableName;
    private String keyColumn;
    private String keyProperty;
    private IdType idType;
    private List<TableFieldInfo> fieldList;
    private boolean withLogicDelete;
}

启动时解析,运行时零反射。缓存到 TABLE_INFO_CACHEMap<Class<?>, TableInfo>)。

初始化:解析 @TableName → 解析 @TableId/@TableField → 构建 ResultMap → 缓存。

运行时改表名/字段映射不会刷新缓存。动态表名插件的替换发生在 SQL 生成的下游,不碰缓存。

伸缩性: 100 个实体类时内存占用几 MB。500+ 时启动注解解析开销累积——遍历字段和方法上的注解是同步完成的。解决方案:按模块拆分 SqlSessionFactory,每个只管理自己模块的 Mapper。

LambdaQueryWrapper 字段名解析

qw.eq(Book::getId, 2) → 提取字段名 id 的链路:

AbstractWrapper.eq → AbstractLambdaWrapper.columnToString → LambdaUtils.extract

三种策略降级:

java
public static <T> LambdaMeta extract(SFunction<T, ?> func) {
    if (func instanceof Proxy)           // 1. IDEA 调试模式
        return new IdeaProxyLambdaMeta((Proxy) func);
    try {
        Method m = func.getClass().getDeclaredMethod("writeReplace");
        return new ReflectLambdaMeta(...);  // 2. 反射(主路径)
    } catch (Throwable e) {
        return new ShadowLambdaMeta(...);   // 3. 序列化降级
    }
}

SFunction 继承了 Function + Serializable,编译器为 Lambda 自动生成 writeReplace,返回的 SerializedLambda.implMethodName 即方法名(如 getId)。PropertyNamer.methodToProperty 去掉 get 前缀 + 首字母小写 → id

Tradeoff 表:

决策收益成本风险
writeReplace 反射解析编译期类型安全,字段重命名自动同步仅支持方法引用,不支持 λ 表达式JDK 升级可能改变行为
反射优先 + 序列化降级主路径纯反射,性能好双路径维护成本极端 JVM 两者都失败
WeakRef 缓存解析结果防类加载器泄漏GC 后缓存失效需重解析高 GC 频率下命中率降

使用限制:

java
qw.eq(Book::getId, 2);              // ✅ 方法引用
qw.eq(b -> b.getId(), 2);           // ❌ λ 表达式,implMethodName 是编译器合成名
qw.eq(new SFunction<Book,Long>(){}); // ❌ 匿名内部类,无 writeReplace

延迟失败风险: writeReplace 被 JDK 安全策略阻止(如 --illegal-access=deny)时,且在序列化降级也失败的情况下,异常只在首次调用该方法时抛出,而不是启动时。一个条件查询可能写了大半年没人调,某天才发现它挂了。

链式调用 + 插件体系

链式调用:泛型自引用 Children extends AbstractWrapper<T, R, Children> + typedThis = (Children) this

插件通过 MyBatis 的 Interceptor 实现:

插件拦截点行为
分页Executor.query()拼接物理分页 SQL
乐观锁Executor.update()检查版本号 +1
逻辑删除SQL 注入阶段DELETE → UPDATE set deleted=1
多租户SQL 解析后追加租户条件
动态表名SQL 构建时运行时替换表名

内存分页坑:不配置 PaginationInnerInterceptor 时数据全量查出再截取 → 高数据量 OOM。

多插件顺序问题: 分页 + 多租户 + 动态表名同时启用时,执行顺序靠配置,框架不保证。顺序错误 → SQL 语法/语义错误。需要理解每个插件的拦截阶段。

选型决策

实际项目不二选一,混用。边界:单表用 MP,跨表用 XML。

架构 tradeoff

维度MP 为主MyBatis 为主
开发速度单表 CRUD 零代码每个查询手写
SQL 可控性自动 SQL 需 review每行手写,DBA 可审计
框架耦合代码强依赖 MP API纯 MyBatis,可后加增强
抽象泄漏复杂查询退回 XML无(本来就手写)
升级风险MP 大版本可能 breakingMyBatis 稳定

MP 适用

  • 中后台管理,单表 CRUD >70%
  • 字段频繁变更(条件构造器免改 XML)
  • 微服务(代码生成器批量产出骨架)

MyBatis XML 适用

  • 多表联查、存储过程、报表
  • DBA 需审查 SQL(XML 比 Java 字符串拼接好读)
  • 金融/交易核心系统
  • 存量大量 XML 的项目

演化轨迹

v1 启动期:纯 MyBatis(快速验证,团队熟悉)
v2 增长期:引入 MP,新功能 MP,旧 XML 不动(重复 CRUD 开始堆积)
v3 成熟期:MP 90%,XML 10%,引入代码生成器(团队扩大,需统一规范)
v4 优化期:审视自动 SQL,高频查补手写(自动生成不一定是性能最优)

v2→v3 的拐点最容易出问题:当团队发现"既要维护 XML 又要维护条件构造器"时,说明混用边界没划清。在规范里写死:单表 MP,跨表 XML。

决策树

新项目?→ 是 → 默认 MP(复杂 SQL >30% 加 XML)
         → 否 → 大量 XML?→ 是 → 保留 MyBatis,增量 MP
                           → 否 → 按新项目评估

踩坑记录

  • 版本: MP 3.5.x → MyBatis 3.5.x,错配不直接报错但可能 NoSuchMethodError
  • 分页: 不配插件走内存分页 → OOM
  • 条件构造器: 深层嵌套可读性差,调试没法直接拿 SQL,复杂动态条件走 XML
  • @TableField 驼峰转下划线一致时不标
  • 多表: ResultMap 的 association/collection 回 XML
  • 插件顺序: 多插件时分页放最后

MP 的设计边界决定了它不会成为 Hibernate 那样的全功能 ORM——没有一级缓存、脏检查、级联持久化。需要这些能力直接上 JPA/Hibernate,别指望 MP 补齐。

它的核心价值在于:随时可以退回到原生 MyBatis。不 fork、不修改、不绑架。