02-MyBatis-Plus:把重复的 CRUD 收进框架
📝 笔记摘要:MyBatis-Plus 替掉了哪一段,又留下了哪一段
MP 是个增强框架,不重写 MyBatis,而是在它上面加一层:单表 CRUD 不用写了、条件构造器不用拼字符串了、主键不用管了。但动态 SQL、复杂映射、两级缓存这些底层机制一个没少,用的时候该知道的还是要知道。
这篇按 2026 年的现状写:mybatis-plus-boot-starter 当前是 3.5.17。3.5.9 是道分水岭——分页插件从主包里拆了出去,单独用要多引一个 mybatis-plus-jsqlparser;老的 PaginationInterceptor、OptimisticLockerInterceptor 这类顶层拦截器已经不存在了,统一换成 MybatisPlusInterceptor 加内部拦截器。主键策略那一套枚举也在 3.3.0 改过名,ID_WORKER 变成了 ASSIGN_ID。
这篇的结构
1. MP 解决什么问题,没解决什么
2. 依赖:3.5.9 之后多出来的那一个
3. BaseMapper 与 @MapperScan
4. 主键策略:ASSIGN_ID 和雪花 ID
5. 自动填充:MetaObjectHandler
6. 乐观锁:版本号怎么把并发挡在门外
7. 条件构造器 Wrapper
8. 插件链:顺序决定行为
9. MP 和原生 MyBatis 的默认差异
10. 代码生成器
11. 常见报错的根因
1. MP 解决什么问题,没解决什么
用原生 MyBatis 写一张表的增删改查,Mapper 接口加 XML 大概要这样:
<!-- 接口 -->
int insert(User user);
int deleteById(Long id);
int updateById(User user);
User selectById(Long id);
List<User> selectByName(String name);
<!-- XML -->
<insert id="insert" parameterType="User">
INSERT INTO user (user_name, age) VALUES (#{userName}, #{age})
</insert>
<delete id="deleteById">DELETE FROM user WHERE id = #{id}</delete>
<update id="updateById">
UPDATE user SET user_name = #{userName}, age = #{age} WHERE id = #{id}
</update>
<select id="selectById" resultType="User">
SELECT id, user_name, age FROM user WHERE id = #{id}
</select>
接口五个方法、XML 四段 SQL,全是机械劳动,而且改一个字段要动两个文件。MP 的做法是:
接口 继承
BaseMapper<User>,这五个方法直接有了,不用写一行
XML 一张单表的 CRUD 不用写了;复杂 SQL 该写还是写,MP 不拦着你
所以 MP 的边界很清楚:
MP 替掉 单表 CRUD、条件拼装、主键生成、字段自动填充、逻辑删除
MP 没替掉 多表连接、动态 SQL、复杂结果映射、事务管理、缓存策略
MP 不会替你写 索引、SQL 怎么写才快、怎么避免 N+1 查询——这些还是看 MySQL 优化与版本细节 和 MyBatis 基础
底层机制在 MP 里一个都没变。配置、映射、动态 SQL、缓存那些还是 MyBatis 那套,MP 只是往 Configuration 里塞了一个 MybatisSqlSessionFactoryBean,并注册了自己的插件和 sqlInjector。没读过原生 MyBatis 的话,直接上 MP 会在很多地方抓瞎——比如为什么 selectById 生成的 SQL 里字段名是驼峰、为什么改了数据库数据第二次查还是旧的。
2. 依赖:3.5.9 之后多出来的那一个
Spring Boot 2 用这个:
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.17</version>
</dependency>
Spring Boot 3 要换另一个 artifactId,坐标里的版本号一样是 3.5.17:
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.17</version>
</dependency>
用错 starter 的症状很典型:Spring Boot 3 的项目引了 mybatis-plus-boot-starter,启动就报 NoSuchMethodError 或者自动装配不生效,@Autowired 注入 BaseMapper 报找不到 bean。
提示:不要同时显式引
org.mybatis:mybatis。starter 已经把 MyBatis 拉进来了,版本由 starter 决定;手动再引一份,版本对不上时会出现「明明用新 API 却报 NoSuchMethodError」这种难查的问题。依赖版本怎么统一管理,Maven 入门与私有仓库搭建 里有dependencyManagement的写法。
还有一个 3.5.9 之后的坑:分页插件被拆出去了。
<!-- 用分页插件就必须额外引这个,3.5.9 起才有 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>3.5.17</version>
</dependency>
不引的话,代码里写 new PaginationInnerInterceptor() 直接编译不过,报 找不到符号。这个拆分是为了让不写 SQL 解析的轻量用户不用背 JSqlParser 的体积。
3. BaseMapper 与 @MapperScan
Mapper 接口继承 BaseMapper,方法就有了:
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
注意 @Mapper 和 @MapperScan 二选一。两个都写不冲突,但通常只用一个:
@Mapper 标在单个接口上,MyBatis-Spring 启动时扫到这个注解就注册
@MapperScan("com.example.demo.dao") 标在启动类或配置类上,扫整个包
包一多,@MapperScan 更省事。但它的包路径必须和接口的真实包路径一致,写错了不会报「扫描不到」这种直白的错,而是报 bean 找不到:
org.springframework.beans.factory.UnsatisfiedDependencyException:
Error creating bean with name 'demo.DemoApplicationTests':
Unsatisfied dependency expressed through field 'userDao';
nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException:
No qualifying bean of type 'com.example.demo.dao.UserDao' available:
expected at least 1 bean which qualifies as autowire candidate.
这条报错链有三层,从后往前读最省事:最外层是测试类创建失败,往里一层是 userDao 字段注入失败,最里面才是真正的原因——没有一个 UserDao 类型的 bean。绝大多数情况是 @MapperScan 的包路径写错,或者接口根本不在那个包下。
提示:
BaseMapper里现成的方法够用就别自己写 XML。真要写,方法名别和BaseMapper的重名,否则注入的到底是哪个实现很难说。
4. 主键策略:ASSIGN_ID 和雪花 ID
用 MP 插入数据时,不给主键字段赋值,MP 会自动生成一个。这是 IdType 决定的。
当前的枚举值一共五个,默认值是 ASSIGN_ID:
AUTO 数据库自增。插入前手动设的 ID 无效,插入后由数据库把自增值回写
NONE 没有策略,跟随全局配置
INPUT 插入前必须自己设主键值
ASSIGN_ID 默认值,自动分配,适用于 Long / Integer / String,用雪花算法实现
ASSIGN_UUID 自动分配 UUID,适用于 String
这里有个版本断点:ASSIGN_ID 和 ASSIGN_UUID 是 3.3.0 引入的新名字,老教程里的 ID_WORKER、ID_WORKER_STR、UUID 全部改名了。
// 2.x 的写法,3.3.0 之后已经不存在
@TableId(type = IdType.ID_WORKER)
// 现在的写法
@TableId(type = IdType.ASSIGN_ID)
照着老代码抄 IdType.ID_WORKER 会编译不过。这是个纯改名,行为没变——都是雪花算法。
雪花算法生成的是一个 64 位 long,结构上是三段:

为什么不用数据库自增 自增 ID 在分库分表时会撞号,而且暴露了业务量。雪花 ID 不用连数据库就能生成,插入前就拿到了,天然适合分库分表
为什么不用 UUID UUID 是 36 位字符串,索引里占空间大、随机写入容易让 B+ 树页分裂。雪花 ID 是 long,递增,索引友好
要注意的 单机默认时钟回拨容差是 0,服务器时间被往回调会抛异常。容器环境里 NTP 校时频繁的话要留意这点
实体类上这样标:
@TableName("sys_user")
public class User {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String userName;
}
@TableName 不写的话,MP 按驼峰转下划线推表名:sysUser → sys_user。表名不规则就显式写。
5. 自动填充:MetaObjectHandler
创建时间和更新时间这两个字段,每条记录都要填,手写就是两行废话。MP 的做法是声明填充策略,再实现一个处理器:
@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) {
LocalDateTime now = LocalDateTime.now();
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, now);
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}
提示:用
strictInsertFill/strictUpdateFill,不要用setFieldValByName。后者不管字段有没有声明fill策略都会硬塞值,字段名写错的时候不报错,只是值填不进去。
另一个坑:updateFill只在updateById/update这类实体更新时触发。用UpdateWrapper直接改字段,或者在 XML 里写UPDATE,填充策略不会生效。
数据库那一侧也要配合,两种做法:
交给数据库 字段设
DEFAULT CURRENT_TIMESTAMP,更新时间设ON UPDATE CURRENT_TIMESTAMP。这样即使不走 MP 也有值,最稳
交给应用 就是上面这段代码。好处是时间戳和应用服务器一致,不受数据库服务器时区影响
两种一起用不冲突,但要知道谁先谁后:应用层填的值会覆盖数据库的默认值。
6. 乐观锁:版本号怎么把并发挡在门外
先说清楚两种锁的区别:
悲观锁 假设一定会冲突,操作前先锁住那行,别人只能等。数据库里的
SELECT ... FOR UPDATE就是它
乐观锁 假设很少冲突,先放行,提交时再检查有没有被改过。冲突了就重试或报错
乐观锁的实现靠一个 version 字段,流程是四步:
1 表里加
version字段,默认值 0
2 实体类加@Version注解
3 注册OptimisticLockerInnerInterceptor插件
4 更新时把当前版本号带进 WHERE 条件
第 4 步是精髓。假设初始 version = 1,两个线程同时读到这条记录:
-- 线程 A 执行
UPDATE user SET age = 1000, version = version + 1 WHERE id = 1 AND version = 1
-- 影响 1 行,version 变成 2
-- 线程 B 执行(手里拿的还是 version = 1)
UPDATE user SET age = 10011, version = version + 1 WHERE id = 1 AND version = 1
-- 影响 0 行,version 对不上,更新失败
线程 B 的更新悄悄失败——不抛异常,就是影响行数为 0。这是乐观锁最需要小心的地方:
提示:
updateById返回值是影响行数,返回 0 就是更新失败,不是成功。业务代码里不判断返回值,并发覆盖就会静悄悄发生。
实体类和配置:
@TableName("user")
public class User {
private Long id;
private String name;
private Integer age;
@Version
private Integer version;
}
@Configuration
@MapperScan("com.example.demo.dao")
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}
插件在 Spring Boot 项目里不用手写注册,MP 的 starter 会自动装配一个 MybatisPlusInterceptor。手写这段配置是为了往里加自己的内部拦截器——比如分页。
7. 条件构造器 Wrapper
查「名字不为空且年龄大于 18 的用户」,原生 MyBatis 要么在 XML 里写动态 SQL,要么在 Java 里拼字符串再 ${} 拼进去——后者就是注入入口。
MP 的 Wrapper 把条件构造和值绑定分开了:
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.isNotNull("name")
.isNotNull("update_time")
.gt("age", 18);
List<User> users = userMapper.selectList(wrapper);
生成的 SQL 里 age > ?,值走占位符,注入不了。
但 QueryWrapper 用字符串列名,写错了编译期发现不了,运行时才报「字段不存在」。Lambda 版用方法引用,编译期就能查出来:
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.isNotNull(User::getName)
.isNotNull(User::getUpdateTime)
.gt(User::getAge, 18);
常用方法按用途分几类:
条件
eq、ne、gt、ge、lt、le、like、in、between、isNull、isNotNull
逻辑and、or、not
结果orderByAsc、orderByDesc、last
分页page、size
用 last 要特别小心——它把字符串原样拼到 SQL 末尾,不转义。写 last("limit 10") 没问题,写 last(userInput) 就是给自己开了个注入口子。
8. 插件链:顺序决定行为
MP 3.5.3 之后,所有插件统一进一个 MybatisPlusInterceptor,内部按添加顺序依次执行:

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
分页插件必须最后加 这是官方文档里明确标出来的一条。分页拦截器要做 count 查询和 SQL 改写,放前面会被后面的拦截器改坏
为什么不是三个独立 Bean 多个顶层拦截器时执行顺序不确定,而且各自intercept的调用时机和异常处理都不一样,合并成一条链更好控制
BlockAttackInnerInterceptor 是个防误操作的插件,它拦住没有 WHERE 条件的 UPDATE 和 DELETE:
-- 被拦截,抛异常
UPDATE user SET age = 18;
-- 正常执行
UPDATE user SET age = 18 WHERE id = 1;
一条手滑的 SQL 就能清空整张表,这个插件值得加上。注意它的判断依据是「有没有 WHERE 条件」,不是「影响多少行」,所以 WHERE 1=1 这种写法骗得过它。
9. MP 和原生 MyBatis 的默认差异
MP 改了一部分默认值,这些差异是「在 MyBatis 里好好的,上了 MP 就变了」的来源:
mapUnderscoreToCamelCase 原生默认
false,MP 默认true。所以同一个实体类,MP 里不用写@TableField也能映射上,原生里必须写
全局配置里的 db-config 表前缀、主键策略、逻辑删除字段这些是 MP 独有的,原生 MyBatis 没有这一层
二级缓存 MP 的cache-enabled默认仍是true,但很多团队会在配置里显式关掉,原因见 MyBatis 基础 里讲二级缓存作用域的那一节
MP 的配置项在 application.yml 里长这样:
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
banner: false
db-config:
id-type: ASSIGN_ID
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
mapper-locations: classpath*:/mapper/**/*.xml
要打开 SQL 日志,log-impl 指向 StdOutImpl 就够,但它直接把 SQL 打到标准输出,生产环境别开。正式项目用 SLF4J 接到日志文件里。
10. 代码生成器
MP 自带代码生成器,能把 Entity、Mapper、Service、Controller 一次生成出来。两种用法:
IDEA 插件 图形界面填连接和表名,勾选要生成的文件,生成到指定目录
Maven 插件 写一份配置,跑mvn generate。好处是能进 CI,表结构变了重新生成一次就行
Maven 插件的坐标是 com.baomidou:mybatis-plus-generator,配置里最关键的是数据源和表名:
<plugin>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.5.17</version>
<configuration>
<dataSource>
<driverClassName>com.mysql.cj.jdbc.Driver</driverClassName>
<url>jdbc:mysql://localhost:3306/demo?characterEncoding=UTF-8&serverTimezone=Asia/Shanghai</url>
<username>root</username>
<password>password</password>
</dataSource>
<javaModelGenerators>
<generator>
<name>DefaultModelGenerator</name>
<params>
<tableName>%s</tableName>
</params>
</generator>
</javaModelGenerators>
</configuration>
</plugin>
提示:生成器会覆盖同名文件。已经有业务逻辑的实体类别放进生成目录,或者生成后记得先备份再覆盖。生成出来的代码是骨架,不是成品——字段注释、校验注解、逻辑删除的语义都要自己补。
11. 常见报错的根因
找不到符号: PaginationInnerInterceptor 3.5.9 起分页插件拆出去了,引
com.baomidou:mybatis-plus-jsqlparser
找不到符号: IdType.ID_WORKER 3.3.0 改名了,改成ASSIGN_ID
找不到符号: OptimisticLockerInterceptor 顶层拦截器已经没有了,改OptimisticLockerInnerInterceptor,并挂到MybatisPlusInterceptor上
No qualifying bean of type 'xxxMapper'@MapperScan的包路径和接口真实路径对不上
NoSuchMethodError(Spring Boot 3 项目) 引的是mybatis-plus-boot-starter,应该用mybatis-plus-spring-boot3-starter
乐观锁没生效,更新被覆盖 实体类漏了@Version,或者没注册内部拦截器
更新时间没填上 用了UpdateWrapper或 XML 的 UPDATE,实体填充策略不生效
字段全是 null 原生 MyBatis 没开mapUnderscoreToCamelCase,MP 里是默认开的,混用时容易对不上
💡 补充:MP 省掉的是「怎么把单表 CRUD 写出来」,没省掉「怎么把 SQL 写快」。selectList 一行代码背后生成的还是那条全表扫描的 SQL,行数一多,Wrapper 拼出来的条件没有索引覆盖,和手写 SQL 慢得一模一样。ORM 省的是打字,不是索引。
🔥 一句话总结:MP 替掉单表 CRUD、条件拼装、主键和字段填充,替不掉动态 SQL、复杂映射和索引;3.5.9 之后多引一个 jsqlparser,插件统一挂到 MybatisPlusInterceptor 上、分页放最后。
