MyBatis-Plus 核心全过程原理
MyBatis-Plus 的定位是“增强 MyBatis,而不是替代 MyBatis”。它不会改变 SQL 最终由 MyBatis、JDBC、数据库执行这个事实,只是在常见单表 CRUD、条件拼接、分页、自动填充、逻辑删除、乐观锁、代码生成这些重复工作上提供统一封装。
如果只会 BaseMapper 和 LambdaQueryWrapper,很容易在项目里写出看似简洁、实际难排查的代码。学 MyBatis-Plus 必须同时知道:它帮你生成了什么 SQL、哪些插件改写了 SQL、哪些字段是自动追加的、哪些场景必须退回 MyBatis XML。
学习目标
学完本章要能说清楚:
- MyBatis-Plus 和 MyBatis 的关系。
BaseMapper为什么不用写 XML 也能执行 CRUD。Wrapper链式调用如何变成 SQL 条件。- 分页插件为什么能自动加
limit,为什么还会执行 count SQL。 - 逻辑删除为什么不是简单加一个字段,和唯一索引、历史查询有什么关系。
- 自动填充、乐观锁、租户插件、防全表更新插件分别解决什么问题。
- 商业项目里什么时候用 MyBatis-Plus,什么时候必须回到 MyBatis XML。
它到底增强了什么
普通 MyBatis 写一个单表查询,需要 Mapper 方法和 XML。
AssetDO selectById(Long id);<select id="selectById" resultMap="AssetMap">
select id, asset_code, asset_name, status
from asset
where id = #{id}
</select>MyBatis-Plus 让 Mapper 继承 BaseMapper 后,直接拥有通用 CRUD。
public interface AssetMapper extends BaseMapper<AssetDO> {
}AssetDO asset = assetMapper.selectById(1L);它省掉的是通用 SQL 样板代码,但没有省掉 SQL 原理、索引设计、事务设计和执行计划分析。
总体链路
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 泛型推断表信息。
@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:
select id, asset_code, asset_name
from asset
where id = ?如果实体注解、字段名、数据库列名不一致,生成 SQL 就可能不符合预期。
Wrapper 怎么变成 SQL
Wrapper 的本质是把链式调用记录成条件片段和参数。
LambdaQueryWrapper<AssetDO> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(AssetDO::getStatus, 1)
.like(AssetDO::getAssetName, "监护仪")
.orderByDesc(AssetDO::getCreateTime);
List<AssetDO> list = assetMapper.selectList(wrapper);大致生成:
select id, asset_code, asset_name, status, create_time
from asset
where status = ?
and asset_name like ?
order by create_time descflowchart TD
A["调用 wrapper.eq / like / orderBy"] --> B["记录字段、操作符、参数值"]
B --> C["Lambda 方法引用解析成实体字段"]
C --> D["实体字段转换成数据库列名"]
D --> E["生成 SQL 条件片段"]
E --> F["参数交给 MyBatis 绑定"]LambdaQueryWrapper 比 QueryWrapper 更推荐,因为字段来自方法引用,重构时更安全。
new QueryWrapper<AssetDO>().eq("asset_code", code);
new LambdaQueryWrapper<AssetDO>().eq(AssetDO::getAssetCode, code);但 LambdaWrapper 不是性能优化工具,它只是降低字段名写错的概率。最终 SQL 是否快,仍然取决于索引、过滤条件、排序和扫描行数。
Wrapper 不能滥用
适合 Wrapper:
- 单表列表查询。
- 简单条件过滤。
- 简单排序。
- 后台管理 CRUD。
不适合 Wrapper:
- 多表复杂 join。
- 大段统计 SQL。
- 需要窗口函数、CTE、复杂子查询。
- 强制指定索引、特殊数据库语法。
- 需要长期由 DBA 审核的核心 SQL。
如果把复杂查询全写成链式调用,代码表面很短,但最终 SQL 不直观,排查慢 SQL 时反而更难。
分页插件原理
MyBatis-Plus 分页依赖插件拦截 MyBatis 执行链路。常见做法是拦截 SQL,生成分页 SQL 和 count SQL。
Page<AssetDO> page = new Page<>(1, 20);
Page<AssetDO> result = assetMapper.selectPage(page, wrapper);执行时通常涉及两类 SQL:
select count(*) from asset where status = ?select id, asset_code, asset_name
from asset
where status = ?
order by create_time desc
limit ?, ?flowchart TD
A["selectPage 调用"] --> B["分页插件拦截查询"]
B --> C["生成 count SQL 统计总数"]
C --> D["改写原 SQL 添加分页方言"]
D --> E["数据库执行分页查询"]
E --> F["封装 records、total、pages"]分页常见坑:
- 没配置分页插件,分页参数不生效。
- count SQL 对复杂 join 很慢。
- 深分页
limit 1000000, 20很慢。 - 排序字段没有索引,分页越靠后越慢。
- 查询字段太多,回表成本高。
优化方向:
- 后台普通列表控制最大页数。
- 深分页改成游标分页或基于最后一条记录翻页。
- count 很慢时评估是否需要精确总数。
- 核心列表用手写 SQL 并配合覆盖索引。
逻辑删除不是简单加 deleted
逻辑删除是把删除操作改成更新标记。
@TableLogic
private Integer deleted;删除时生成:
update asset set deleted = 1 where id = ? and deleted = 0查询时自动追加:
where deleted = 0flowchart TD
A["deleteById"] --> B["发现 @TableLogic"]
B --> C["不执行 delete"]
C --> D["改成 update deleted = 1"]
E["selectList"] --> F["自动追加 deleted = 0"]商业项目要重点考虑唯一索引。
如果资产编码唯一:
unique key uk_asset_code (asset_code)逻辑删除后,再新增同一个 asset_code 仍然可能冲突,因为旧数据还在表里。常见方案:
- 唯一索引包含删除字段,例如
(asset_code, deleted),但多个删除记录仍可能冲突。 - 删除时把唯一字段改写,例如
asset_code = asset_code + '_deleted_' + id。 - 使用业务状态替代删除,谨慎允许重复新增。
- 对强审计表保留历史表,而不是主表无限逻辑删除。
结论:逻辑删除是数据建模问题,不只是框架注解问题。
自动填充原理
自动填充常用于创建时间、更新时间、创建人、更新人。
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;处理器:
@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 设置字段值。
常见不生效原因:
- 字段没有配置
fill。 MetaObjectHandler没被 Spring 扫描。- 更新时传入的是
UpdateWrapper,没有实体对象可填。 - 字段已有值,严格填充不会覆盖。
乐观锁原理
乐观锁适合解决“多人同时修改同一条数据,后提交覆盖先提交”的问题。
@Version
private Integer version;更新时原本可能是:
update asset set asset_name = ? where id = ?启用乐观锁后变成:
update asset
set asset_name = ?, version = version + 1
where id = ? and version = ?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,说明数据已被别人修改"]乐观锁不是自动重试的万能方案。更新失败后应该让业务决定:
- 提示用户刷新后重试。
- 重新读取最新数据后合并。
- 对库存扣减这类高并发场景,改用数据库条件更新、Redis、队列或专门库存模型。
防全表更新和删除
商业系统最怕这种事故:
assetMapper.update(asset, new LambdaUpdateWrapper<>());如果没有条件,可能更新全表。MyBatis-Plus 提供非法 SQL 或阻断攻击类插件,用来拦截无条件更新、删除等危险 SQL。
但插件只能兜底,不能替代代码规范。正确做法:
- Service 层校验更新条件。
- 核心表更新必须带主键、唯一键或明确业务条件。
- 生产权限限制,应用账号不要拥有过大权限。
- 重要更新加审计日志。
多租户和数据权限
多租户插件通常会给 SQL 自动追加租户条件。
select * from asset where status = ?改写成:
select * from asset where tenant_id = ? and status = ?这类插件很方便,但风险也大:
- 哪些表需要加租户条件必须明确。
- 公共字典表不能误加租户条件。
- 手写 SQL、复杂子查询、union 可能改写不符合预期。
- 管理员跨租户查询要有明确绕过机制和审计。
数据权限同理,不能只依赖插件“自动加条件”,还要有权限模型设计和测试覆盖。
商业 Demo:资产台账 CRUD
实体:
@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:
public interface AssetMapper extends BaseMapper<AssetDO> {
}Service:
@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 说明:
- 简单分页列表可以用 Wrapper。
- 更新要放在事务里。
- 乐观锁更新失败要检查影响行数。
- 复杂报表不要硬塞 Wrapper。
线上排查流程
分页慢
flowchart TD
A["分页接口慢"] --> B["打印最终 SQL 和 count SQL"]
B --> C{"count 是否很慢?"}
C -->|"是"| D["优化 count、取消精确总数或改统计方案"]
C -->|"否"| E{"分页 SQL 是否深分页?"}
E -->|"是"| F["改游标分页或限制最大页"]
E -->|"否"| G{"排序和过滤是否走索引?"}
G -->|"否"| H["调整索引和查询条件"]
G -->|"是"| I["检查返回字段、回表、锁等待和数据库资源"]自动填充不生效
排查:
- 实体字段是否加了
@TableField(fill = ...)。 MetaObjectHandler是否是 Spring Bean。- 当前操作是 insert 还是 update。
- 是否传入实体对象。
- 字段是否已有值导致严格填充不覆盖。
逻辑删除后新增失败
排查:
- 唯一索引是否仍然只包含业务字段。
- 旧逻辑删除数据是否仍占用唯一值。
- 是否需要删除时改写唯一字段。
- 是否应该拆历史表或审计表。
面试标准回答
MyBatis-Plus 和 MyBatis 的关系
MyBatis-Plus 是 MyBatis 的增强工具,不改变 MyBatis 的执行本质。它根据实体元数据和 Wrapper 生成通用 CRUD SQL,通过插件实现分页、逻辑删除、乐观锁等能力,最后仍然交给 MyBatis 的 Executor 和 JDBC 执行。复杂 SQL、性能敏感查询仍然要回到 MyBatis XML 和数据库执行计划。Wrapper 原理是什么
Wrapper 把链式条件记录成 SQL 片段和参数,LambdaWrapper 会把方法引用解析成实体字段,再转换成数据库列名,最后拼入 MyBatis-Plus 的通用 SQL 中。它适合简单单表条件,不适合复杂 join、统计和强 SQL 可读性场景。分页插件原理是什么
分页插件通过 MyBatis 插件机制拦截 SQL,根据数据库方言生成 count SQL 和分页 SQL,例如 MySQL 会追加 limit。分页慢通常不是插件问题,而是 count 慢、深分页、排序字段无索引、返回列过多或回表成本高。逻辑删除有什么坑
逻辑删除是把 delete 改成 update deleted 标记,并在查询时自动追加未删除条件。它会影响唯一索引、历史数据查询、表体积和统计逻辑。不能只加 @TableLogic,还要设计唯一索引、历史保留和清理策略。关联知识点
本章小结
MyBatis-Plus 的正确学习方式不是背 API,而是看它生成了什么 SQL、插件改写了什么 SQL、字段策略追加了什么条件。简单 CRUD 用它提高效率,复杂 SQL 回到 MyBatis 和数据库本身,这才是商业项目里稳定可控的用法。
