2503 字
约 8 分钟
23
S08-X11 · MyBatis-Plus 的能力与边界

MyBatis-Plus 的定位很清晰:把单表 CRUD 从手写 XML 里拿掉,其余一切照旧。问题也在这儿——它替你做掉的东西有隐含前提,越界使用时不会报错,只会给出一个"看起来对"的结果。

这篇按"它给你什么 / 你要付什么"过一遍六个自动能力,再讲它们各自失效的位置。


1. 替你做掉的事与代价

能力 用法 代价
通用 CRUD BaseMapper 的 17 个方法 SQL 不可见,出问题要看日志
条件构造器 QueryWrapper/LambdaQueryWrapper 复杂 join、子查询仍要手写
分页 PaginationInnerInterceptor 多一次 COUNT 往返
逻辑删除 @TableLogic 唯一索引语义被改变
自动填充 MetaObjectHandler 批量操作可能不触发
乐观锁 @Version + OptimisticLockerInnerInterceptor 只在特定方法上生效

三条隐含前提必须记住:条件构造器只覆盖单表;所有增强都依赖实体元数据(表名、列名、主键策略);拦截器链有顺序要求。


2. LambdaQueryWrapper 的列名解析

List<User> list = userMapper.selectList(
    new LambdaQueryWrapper<User>()
        .eq(User::getStatus, 1)
        .like(StringUtils.hasText(kw), User::getName, kw)
        .orderByDesc(User::getId));

User::getName 是 SFunction,MP 靠 LambdaUtils 解析出 name 列,再叠加 @TableField 的映射。解析依赖两件事:方法引用对应实体类的 getter、该实体的 TableInfo 已被初始化。

失效场景:

场景 表现
引用了非实体的方法引用 抛 MybatisPlusException: can not find lambda cache for this entity
实体没被 @TableName 或扫描到 TableInfo 不存在,解析失败
多模块下 TableInfo 缓存串了 解析到别的类的列名,SQL 里出现不存在的列
用了 @TableField(exist = false) 的字段 SQL 里出现该列,数据库报 unknown column

LambdaUtils 的缓存是静态的 Map<Class, Map<String, ColumnCache>>,在多 ClassLoader 环境(热部署、插件化)里会持有旧类引用,表现为"改了字段名还查旧列"。清理方式是重启或调 TableInfoHelper.remove(Class)——生产上按版本核对 API 名,不同小版本签名有差异。

字符串形式的 QueryWrapper 没有这个问题,但列名写错编译器不管,运行时才炸。复杂条件用 Lambda,简单条件用字符串,这是实践里的分界线。


3. 分页插件与 searchCount

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

Page<T> 的 searchCount 决定是否发那条 COUNT:

用法 行为 适用
new Page<>(1, 20) 默认 searchCount=true,查总数 需要分页条
new Page<>(1, 20, false) 不发 COUNT,getTotal() 为 0 下拉加载、滚动列表
page.setSearchCount(false) 同上,链式调用后置 条件分支判断

深翻页时 COUNT 与 LIMIT offset, size 各是一次全表扫描式的开销。Page 只解决"有没有总数",不解决"offset 太大"。真要做深分页,得用 id > lastId 的游标写法,这一段 MP 不提供。


4. 逻辑删除与唯一索引的冲突

@TableLogic 把 delete 变成 update set deleted=1,把 select 自动加上 deleted=0。副作用在唯一索引上:

-- 原表
UNIQUE KEY uk_phone (phone)
-- 逻辑删除后想再插同一 phone:冲突,因为旧行还在

三种解法:

方案 做法 代价
唯一键加删除位 UNIQUE KEY uk_phone (phone, deleted) 同一 phone 只能删一次(deleted 只有 0/1)
删除时改写字段 deleted 存时间戳或 id,唯一键带它 查询要处理 deleted=0 的特殊值
物理删除 + 归档表 删除搬去历史表 多一套表与同步逻辑

第一种是最常见的错误答案:deleted 只有两个值,删两次就撞。正确做法是让删除位"多值",例如 deleted_at 存毫秒时间戳,未删除为 0,唯一键 (phone, deleted_at)——同一号码只可能有一条 deleted_at=0。


5. 自动填充的时机

@Component
public class AuditMetaObjectHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
        this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, LocalDateTime.now());
        this.strictInsertFill(metaObject, "createdBy", Long.class, CurrentUser.id());
    }
    @Override
    public void updateFill(MetaObject metaObject) {
        this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
    }
}

触发点是 MybatisParameterHandler 处理参数时,只对"单个实体参数"生效。以下情况不填充:

  • update(null, wrapper)——没有实体对象可填;
  • 自定义 XML 里手写 insert 且参数是 Map(strictInsertFill 找不到属性);
  • saveBatch 走的是批量 SQL,实体被包装成集合,需按版本确认是否逐个填充。

结果就是"单条插入有创建时间、批量导入一片 null"。这类问题只能在数据里发现,代码看不出来。约定是:批量入口显式赋值,别依赖填充器。


6. 乐观锁的三个前提

@Version
private Integer version;

// 更新:必须先查后改,再 updateById
User u = userMapper.selectById(id);
u.setName("new");
int rows = userMapper.updateById(u);   // where id=? and version=?
前提 不满足时的表现
先查出带 version 的对象 version 为 null,SQL 不带版本条件,锁形同虚设
走 updateById 或带实体的 update update(null, wrapper) 不触发版本递增
装了 OptimisticLockerInnerInterceptor 语句里根本不出现 version 条件

返回 rows == 0 表示版本冲突,业务要自行重试或提示。别把 rows == 0 当成"记录不存在"——两者含义不同,却常被同一个 if 吞掉。


7. IService 与 BaseMapper 的层次

层 提供什么 什么时候用
BaseMapper<T> 17 个单表方法 只需要单表操作
IService<T> / ServiceImpl 在 Mapper 上包一层,含 saveBatch、lambdaUpdate、getOne 需要事务编排、批量封装

ServiceImpl 的方法默认不带 @Transactional,saveBatch 也是。批量写要事务就自己加,别假定它替你开了。getOne 在有多个结果时抛异常(TooManyResultsException),要比 list 更小心。

层次选择的原则:Mapper 只做数据访问,Service 有业务语义。把条件构造器直接写在 Controller 里,等于把表结构暴露到接口层,改字段要改三处。


8. 多数据源与 @DS 的边界

@DS("slave") 靠 DynamicDataSourceAnnotationInterceptor 在方法调用前后切 DataSource。边界很硬:

情况 结果
同类内部方法调用 注解不生效(没有代理)
方法内嵌套另一个 @DS 方法 内层覆盖外层,但事务已绑定连接则无效
@Transactional + @DS 同方法 事务开启时连接已确定,@DS 来不及切换
分页插件在多数据源下 插件顺序错了会拿错方言

第三条最致命:事务先于数据源切换发生,@DS 写在同一方法上常常不生效。正确做法是在事务外层切数据源:Controller 或外层 Service 方法加 @DS,事务方法在内层。


9. SQL 注入的残留面

MP 的条件构造器是参数化的,但它留了两个口子:

// 危险:字符串拼接
wrapper.apply("date_format(created_at,'%Y') = '" + year + "'");
wrapper.last("limit " + offset + "," + size);
wrapper.inSql("id", "select id from t where x = " + x);

apply、last、inSql、having、orderByAsc(String) 都接受原始 SQL 片段。凡是从请求参数来的值进了这些方法,就是注入。

安全写法是占位符形式:

wrapper.apply("date_format(created_at,'%Y') = {0}", year);
wrapper.last("limit {0},{1}", offset, size);   // 部分版本支持

{0} 会被转成 #{ew.paramNameValuePairs.MPGENVAL1},走预编译。排序字段这种结构性输入只能白名单映射,不能靠占位符。


10. 与手写 XML 共存的约定

MP 与原生 MyBatis 共用同一套 Configuration,冲突集中在 namespace:

冲突 现象 处理
手写 XML 的 namespace 与 Mapper 接口不一致 Invalid bound statement (not found) namespace 必须是接口全限定名
XML 与注解定义同 id 语句 启动时报重复定义 同一 id 只能有一处
mapper-locations 与自动扫描重叠 语句被加载两次 二选一
自定义 SQL 想复用 MP 的 BaseMapper 方法 XML 里查不到 直接调 Mapper 方法,别在 XML 引用

约定:MP 管单表、XML 管多表,按方法名分工,不互相引用。


11. 排错表

现象 原因 处理
updateById 部分字段没更新 实体里 null 字段被忽略(默认 NOT_NULL 策略) 用 LambdaUpdateWrapper.set 显式赋值
统计 SQL 数字偏大 逻辑删除条件没加 手写 SQL 补 deleted=0,或用 @TableLogic 的常量
apply 报语法错或注入告警 字符串拼接 改占位符或白名单
分页查不到总数 searchCount=false 需要时打开
@DS 不生效 同类调用 / 与事务同方法 移到事务外层
唯一键冲突删不掉 逻辑删除 + 单值删除位 唯一键带时间戳删除位

12. 边界

需求 MP 更合适
单表 CRUD、条件分页 是 —
多表 join、窗口函数 勉强 手写 XML 或视图
复杂报表 否 独立查询对象 + 专用 SQL
跨库事务 否 分布式事务框架
多租户数据隔离 可用 TenantLineInnerInterceptor 库级隔离更彻底

一句话:MP 省的是重复代码,不是设计能力——表结构、索引、事务边界、缓存一致性,一样也没替你想。


13. 自测

  1. LambdaQueryWrapper 解析列名依赖哪两样东西?
  2. Page 的 searchCount=false 会影响哪些字段?
  3. 逻辑删除后为什么"删除再插同值"会撞唯一键?两种正确解法是什么?
  4. MetaObjectHandler 在哪些情况下不触发?
  5. @Version 生效需要哪三个前提?
  6. @DS 与 @Transactional 写在同一方法上为什么常常失效?
S08-X11 · MyBatis-Plus 的能力与边界
https://www.clxhxhhr.top/posts/5958/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。