1
0
0

02-MyBatis-Plus:把重复的 CRUD 收进框架

2026-10-20
2026-10-21

📝 笔记摘要: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 的 64 位结构:1 位符号位固定为 0,41 位时间戳毫秒差,10 位机器号,12 位序列号,同一毫秒内同一台机器最多生成 4096 个 ID

为什么不用数据库自增 自增 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,内部按添加顺序依次执行:

MyBatis-Plus 插件链的执行顺序:SQL 依次经过内部拦截器,分页拦截器必须最后添加,SQL 改写完才交给 StatementHandler 执行,再由 ResultSetHandler 映射结果

@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&amp;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 上、分页放最后。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!