Skip to content

MyBatis-Plus 核心全过程原理

MyBatis-Plus 的定位是“增强 MyBatis,而不是替代 MyBatis”。它不会改变 SQL 最终由 MyBatis、JDBC、数据库执行这个事实,只是在常见单表 CRUD、条件拼接、分页、自动填充、逻辑删除、乐观锁、代码生成这些重复工作上提供统一封装。

如果只会 BaseMapperLambdaQueryWrapper,很容易在项目里写出看似简洁、实际难排查的代码。学 MyBatis-Plus 必须同时知道:它帮你生成了什么 SQL、哪些插件改写了 SQL、哪些字段是自动追加的、哪些场景必须退回 MyBatis XML。

学习目标

学完本章要能说清楚:

  1. MyBatis-Plus 和 MyBatis 的关系。
  2. BaseMapper 为什么不用写 XML 也能执行 CRUD。
  3. Wrapper 链式调用如何变成 SQL 条件。
  4. 分页插件为什么能自动加 limit,为什么还会执行 count SQL。
  5. 逻辑删除为什么不是简单加一个字段,和唯一索引、历史查询有什么关系。
  6. 自动填充、乐观锁、租户插件、防全表更新插件分别解决什么问题。
  7. 商业项目里什么时候用 MyBatis-Plus,什么时候必须回到 MyBatis XML。

它到底增强了什么

普通 MyBatis 写一个单表查询,需要 Mapper 方法和 XML。

java
AssetDO selectById(Long id);
xml
<select id="selectById" resultMap="AssetMap">
    select id, asset_code, asset_name, status
    from asset
    where id = #{id}
</select>

MyBatis-Plus 让 Mapper 继承 BaseMapper 后,直接拥有通用 CRUD。

java
public interface AssetMapper extends BaseMapper<AssetDO> {
}
java
AssetDO asset = assetMapper.selectById(1L);

它省掉的是通用 SQL 样板代码,但没有省掉 SQL 原理、索引设计、事务设计和执行计划分析。

总体链路

mermaid
flowchart TD
    A["业务代码调用 BaseMapper 或 IService"] --> B["MyBatis-Plus 根据实体和方法生成 SQL 信息"]
    B --> C["Wrapper 条件转换为 SQL 片段"]
    C --> D["插件链处理分页、租户、逻辑删除、乐观锁"]
    D --> E["交给 MyBatis 的 MappedStatement"]
    E --> F["Executor 执行"]
    F --> G["JDBC 访问数据库"]
    G --> H["ResultSet 映射成实体对象"]

核心结论:MyBatis-Plus 只是在 MyBatis 前面帮你“生成或改写 SQL”,后面的执行链路仍然是 MyBatis。

BaseMapper 为什么能工作

MyBatis-Plus 启动时会根据实体类和 Mapper 泛型推断表信息。

java
@TableName("asset")
public class AssetDO {
    @TableId(type = IdType.AUTO)
    private Long id;

    @TableField("asset_code")
    private String assetCode;

    private String assetName;
}

它会整理出一份表元数据:

元数据来源用途
表名@TableName 或类名转换生成 from asset
主键字段@TableId生成 where id = ?
普通字段@TableField 或属性名转换生成列名和映射
字段填充策略@TableField(fill = ...)插入和更新时自动填充
逻辑删除字段@TableLogic查询自动追加未删除条件
乐观锁字段@Version更新时追加版本判断

所以 selectById 不是魔法,它是根据表元数据拼出类似 SQL:

sql
select id, asset_code, asset_name
from asset
where id = ?

如果实体注解、字段名、数据库列名不一致,生成 SQL 就可能不符合预期。

Wrapper 怎么变成 SQL

Wrapper 的本质是把链式调用记录成条件片段和参数。

java
LambdaQueryWrapper<AssetDO> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(AssetDO::getStatus, 1)
       .like(AssetDO::getAssetName, "监护仪")
       .orderByDesc(AssetDO::getCreateTime);

List<AssetDO> list = assetMapper.selectList(wrapper);

大致生成:

sql
select id, asset_code, asset_name, status, create_time
from asset
where status = ?
  and asset_name like ?
order by create_time desc
mermaid
flowchart TD
    A["调用 wrapper.eq / like / orderBy"] --> B["记录字段、操作符、参数值"]
    B --> C["Lambda 方法引用解析成实体字段"]
    C --> D["实体字段转换成数据库列名"]
    D --> E["生成 SQL 条件片段"]
    E --> F["参数交给 MyBatis 绑定"]

LambdaQueryWrapperQueryWrapper 更推荐,因为字段来自方法引用,重构时更安全。

java
new QueryWrapper<AssetDO>().eq("asset_code", code);
new LambdaQueryWrapper<AssetDO>().eq(AssetDO::getAssetCode, code);

但 LambdaWrapper 不是性能优化工具,它只是降低字段名写错的概率。最终 SQL 是否快,仍然取决于索引、过滤条件、排序和扫描行数。

Wrapper 不能滥用

适合 Wrapper:

  1. 单表列表查询。
  2. 简单条件过滤。
  3. 简单排序。
  4. 后台管理 CRUD。

不适合 Wrapper:

  1. 多表复杂 join。
  2. 大段统计 SQL。
  3. 需要窗口函数、CTE、复杂子查询。
  4. 强制指定索引、特殊数据库语法。
  5. 需要长期由 DBA 审核的核心 SQL。

如果把复杂查询全写成链式调用,代码表面很短,但最终 SQL 不直观,排查慢 SQL 时反而更难。

分页插件原理

MyBatis-Plus 分页依赖插件拦截 MyBatis 执行链路。常见做法是拦截 SQL,生成分页 SQL 和 count SQL。

java
Page<AssetDO> page = new Page<>(1, 20);
Page<AssetDO> result = assetMapper.selectPage(page, wrapper);

执行时通常涉及两类 SQL:

sql
select count(*) from asset where status = ?
sql
select id, asset_code, asset_name
from asset
where status = ?
order by create_time desc
limit ?, ?
mermaid
flowchart TD
    A["selectPage 调用"] --> B["分页插件拦截查询"]
    B --> C["生成 count SQL 统计总数"]
    C --> D["改写原 SQL 添加分页方言"]
    D --> E["数据库执行分页查询"]
    E --> F["封装 records、total、pages"]

分页常见坑:

  1. 没配置分页插件,分页参数不生效。
  2. count SQL 对复杂 join 很慢。
  3. 深分页 limit 1000000, 20 很慢。
  4. 排序字段没有索引,分页越靠后越慢。
  5. 查询字段太多,回表成本高。

优化方向:

  1. 后台普通列表控制最大页数。
  2. 深分页改成游标分页或基于最后一条记录翻页。
  3. count 很慢时评估是否需要精确总数。
  4. 核心列表用手写 SQL 并配合覆盖索引。

逻辑删除不是简单加 deleted

逻辑删除是把删除操作改成更新标记。

java
@TableLogic
private Integer deleted;

删除时生成:

sql
update asset set deleted = 1 where id = ? and deleted = 0

查询时自动追加:

sql
where deleted = 0
mermaid
flowchart TD
    A["deleteById"] --> B["发现 @TableLogic"]
    B --> C["不执行 delete"]
    C --> D["改成 update deleted = 1"]
    E["selectList"] --> F["自动追加 deleted = 0"]

商业项目要重点考虑唯一索引。

如果资产编码唯一:

sql
unique key uk_asset_code (asset_code)

逻辑删除后,再新增同一个 asset_code 仍然可能冲突,因为旧数据还在表里。常见方案:

  1. 唯一索引包含删除字段,例如 (asset_code, deleted),但多个删除记录仍可能冲突。
  2. 删除时把唯一字段改写,例如 asset_code = asset_code + '_deleted_' + id
  3. 使用业务状态替代删除,谨慎允许重复新增。
  4. 对强审计表保留历史表,而不是主表无限逻辑删除。

结论:逻辑删除是数据建模问题,不只是框架注解问题。

自动填充原理

自动填充常用于创建时间、更新时间、创建人、更新人。

java
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;

处理器:

java
@Component
public class AuditMetaObjectHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
        this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
        this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
    }
}

原理是 MyBatis-Plus 在插入或更新前,拿到实体对象的元信息,判断哪些字段需要填充,再通过反射或 MetaObject 设置字段值。

常见不生效原因:

  1. 字段没有配置 fill
  2. MetaObjectHandler 没被 Spring 扫描。
  3. 更新时传入的是 UpdateWrapper,没有实体对象可填。
  4. 字段已有值,严格填充不会覆盖。

乐观锁原理

乐观锁适合解决“多人同时修改同一条数据,后提交覆盖先提交”的问题。

java
@Version
private Integer version;

更新时原本可能是:

sql
update asset set asset_name = ? where id = ?

启用乐观锁后变成:

sql
update asset
set asset_name = ?, version = version + 1
where id = ? and version = ?
mermaid
flowchart TD
    A["线程 A 和线程 B 读取同一行 version=1"] --> B["线程 A 更新 where version=1"]
    B --> C["更新成功,version 变成 2"]
    A --> D["线程 B 更新 where version=1"]
    D --> E["影响行数为 0,说明数据已被别人修改"]

乐观锁不是自动重试的万能方案。更新失败后应该让业务决定:

  1. 提示用户刷新后重试。
  2. 重新读取最新数据后合并。
  3. 对库存扣减这类高并发场景,改用数据库条件更新、Redis、队列或专门库存模型。

防全表更新和删除

商业系统最怕这种事故:

java
assetMapper.update(asset, new LambdaUpdateWrapper<>());

如果没有条件,可能更新全表。MyBatis-Plus 提供非法 SQL 或阻断攻击类插件,用来拦截无条件更新、删除等危险 SQL。

但插件只能兜底,不能替代代码规范。正确做法:

  1. Service 层校验更新条件。
  2. 核心表更新必须带主键、唯一键或明确业务条件。
  3. 生产权限限制,应用账号不要拥有过大权限。
  4. 重要更新加审计日志。

多租户和数据权限

多租户插件通常会给 SQL 自动追加租户条件。

sql
select * from asset where status = ?

改写成:

sql
select * from asset where tenant_id = ? and status = ?

这类插件很方便,但风险也大:

  1. 哪些表需要加租户条件必须明确。
  2. 公共字典表不能误加租户条件。
  3. 手写 SQL、复杂子查询、union 可能改写不符合预期。
  4. 管理员跨租户查询要有明确绕过机制和审计。

数据权限同理,不能只依赖插件“自动加条件”,还要有权限模型设计和测试覆盖。

商业 Demo:资产台账 CRUD

实体:

java
@TableName("asset")
public class AssetDO {
    @TableId(type = IdType.AUTO)
    private Long id;

    private String assetCode;
    private String assetName;
    private Long deptId;
    private Integer status;

    @TableLogic
    private Integer deleted;

    @Version
    private Integer version;

    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;

    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
}

Mapper:

java
public interface AssetMapper extends BaseMapper<AssetDO> {
}

Service:

java
@Service
public class AssetService {
    private final AssetMapper assetMapper;

    public AssetService(AssetMapper assetMapper) {
        this.assetMapper = assetMapper;
    }

    public Page<AssetDO> pageAssets(AssetQuery query) {
        LambdaQueryWrapper<AssetDO> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(query.getDeptId() != null, AssetDO::getDeptId, query.getDeptId())
               .eq(query.getStatus() != null, AssetDO::getStatus, query.getStatus())
               .like(query.getKeyword() != null && !query.getKeyword().isBlank(),
                       AssetDO::getAssetName, query.getKeyword())
               .orderByDesc(AssetDO::getCreateTime);

        Page<AssetDO> page = new Page<>(query.getPageNo(), query.getPageSize());
        return assetMapper.selectPage(page, wrapper);
    }

    @Transactional(rollbackFor = Exception.class)
    public void disableAsset(Long id) {
        AssetDO asset = assetMapper.selectById(id);
        if (asset == null) {
            throw new IllegalArgumentException("资产不存在");
        }
        asset.setStatus(0);
        int rows = assetMapper.updateById(asset);
        if (rows == 0) {
            throw new IllegalStateException("资产已被其他人修改,请刷新后重试");
        }
    }
}

这个 Demo 说明:

  1. 简单分页列表可以用 Wrapper。
  2. 更新要放在事务里。
  3. 乐观锁更新失败要检查影响行数。
  4. 复杂报表不要硬塞 Wrapper。

线上排查流程

分页慢

mermaid
flowchart TD
    A["分页接口慢"] --> B["打印最终 SQL 和 count SQL"]
    B --> C{"count 是否很慢?"}
    C -->|"是"| D["优化 count、取消精确总数或改统计方案"]
    C -->|"否"| E{"分页 SQL 是否深分页?"}
    E -->|"是"| F["改游标分页或限制最大页"]
    E -->|"否"| G{"排序和过滤是否走索引?"}
    G -->|"否"| H["调整索引和查询条件"]
    G -->|"是"| I["检查返回字段、回表、锁等待和数据库资源"]

自动填充不生效

排查:

  1. 实体字段是否加了 @TableField(fill = ...)
  2. MetaObjectHandler 是否是 Spring Bean。
  3. 当前操作是 insert 还是 update。
  4. 是否传入实体对象。
  5. 字段是否已有值导致严格填充不覆盖。

逻辑删除后新增失败

排查:

  1. 唯一索引是否仍然只包含业务字段。
  2. 旧逻辑删除数据是否仍占用唯一值。
  3. 是否需要删除时改写唯一字段。
  4. 是否应该拆历史表或审计表。

面试标准回答

MyBatis-Plus 和 MyBatis 的关系

text
MyBatis-Plus 是 MyBatis 的增强工具,不改变 MyBatis 的执行本质。它根据实体元数据和 Wrapper 生成通用 CRUD SQL,通过插件实现分页、逻辑删除、乐观锁等能力,最后仍然交给 MyBatis 的 Executor 和 JDBC 执行。复杂 SQL、性能敏感查询仍然要回到 MyBatis XML 和数据库执行计划。

Wrapper 原理是什么

text
Wrapper 把链式条件记录成 SQL 片段和参数,LambdaWrapper 会把方法引用解析成实体字段,再转换成数据库列名,最后拼入 MyBatis-Plus 的通用 SQL 中。它适合简单单表条件,不适合复杂 join、统计和强 SQL 可读性场景。

分页插件原理是什么

text
分页插件通过 MyBatis 插件机制拦截 SQL,根据数据库方言生成 count SQL 和分页 SQL,例如 MySQL 会追加 limit。分页慢通常不是插件问题,而是 count 慢、深分页、排序字段无索引、返回列过多或回表成本高。

逻辑删除有什么坑

text
逻辑删除是把 delete 改成 update deleted 标记,并在查询时自动追加未删除条件。它会影响唯一索引、历史数据查询、表体积和统计逻辑。不能只加 @TableLogic,还要设计唯一索引、历史保留和清理策略。

关联知识点

  1. MyBatis 核心全过程原理
  2. Wrapper 条件构造器
  3. MyBatis 拦截器
  4. MyBatis 缓存机制
  5. ORM 事务与一致性
  6. MySQL EXPLAIN 执行计划

本章小结

MyBatis-Plus 的正确学习方式不是背 API,而是看它生成了什么 SQL、插件改写了什么 SQL、字段策略追加了什么条件。简单 CRUD 用它提高效率,复杂 SQL 回到 MyBatis 和数据库本身,这才是商业项目里稳定可控的用法。