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. 自测
LambdaQueryWrapper解析列名依赖哪两样东西?Page的searchCount=false会影响哪些字段?- 逻辑删除后为什么"删除再插同值"会撞唯一键?两种正确解法是什么?
MetaObjectHandler在哪些情况下不触发?@Version生效需要哪三个前提?@DS与@Transactional写在同一方法上为什么常常失效?