MyBatis-Plus 核心实战:条件构造器、逻辑删除、分页插件与代码生成器

每个 Spring Boot 项目几乎都要写 CRUD,而原生 MyBatis 的痛点是:每个表要手写 Mapper.xml、重复的 resultMap、分页要自己写 count(*) 再拼 LIMIT、逻辑删除靠 WHERE deleted=0 处处粘贴。MyBatis-Plus(下文简称 MP)定位很明确——只做增强,不做改变:你原来的 MyBatis 代码一行都不用动,只是在上面叠了一层开箱即用的能力。本文聚焦 MP 自身,不重复讲 MyBatis 的 SqlSession/动态 SQL 原理,也不碰并发、集合、JVM 那些独立主题。

一、为什么需要 MyBatis-Plus

先看一段原生 MyBatis 最常见的「样板代码」:

1
2
3
4
5
6
// 查询用户列表,按状态过滤 + 分页
List<User> selectByStatus(@Param("status") Integer status,
@Param("offset") int offset,
@Param("size") int size);
// 还要再写一条 count
Long countByStatus(@Param("status") Integer status);
1
2
3
4
5
6
7
<select id="selectByStatus" resultType="User">
SELECT id, name, age, status, deleted, create_time
FROM user
WHERE deleted = 0
AND status = #{status}
LIMIT #{offset}, #{size}
</select>

表一多,这种 CRUD 模板铺天盖地。MP 把这些全部收编成通用能力,你只写「不一样」的那部分。

痛点 原生 MyBatis MyBatis-Plus
单表 CRUD 手写 Mapper + XML BaseMapper 内置 17 个方法
分页 手写 count + limit PaginationInnerInterceptor 自动分页
逻辑删除 处处拼 deleted=0 @TableLogic 全局自动过滤
条件查询 XML 拼接 or ${} 拼串 QueryWrapper 类型安全
字段填充 手动 set MetaObjectHandler 自动填充
代码生成 无 FastAutoGenerator 一键生成

二、快速集成

引入 starter 即可,注意 MP 的 starter 已经排除了原生 MyBatis,不要再同时引 mybatis-spring-boot-starter,否则版本会打架:

1
2
3
4
5
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.7</version>
</dependency>
1
2
3
4
5
6
7
8
9
10
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
global-config:
db-config:
id-type: auto # 主键策略,默认 ASSIGN_ID(雪花)
logic-delete-field: deleted # 全局逻辑删除字段
logic-delete-value: 1
logic-not-delete-value: 0
configuration:
map-underscore-to-camel-case: true # 下划线转驼峰

启动类扫描 Mapper 接口:

1
2
3
4
@MapperScan("com.example.demo.mapper")
@SpringBootApplication
public class DemoApplication { public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args); }}

MP 用的是 mybatis-plus-spring-boot3-starter(适配 Spring Boot 3),老项目 Spring Boot 2 用 mybatis-plus-boot-starter。选错 starter 会启动失败。

三、实体与表映射

MP 通过注解把实体和表结构关联起来,无需 XML:

1
2
3
4
5
6
7
8
9
10
11
12
13
@TableName("user")                 // 表名,默认按类名驼峰转下划线
public class User {
@TableId(type = IdType.AUTO) // 自增主键;ASSIGN_ID 为雪花算法
private Long id;
private String name;
private Integer age;
@TableField("status") // 字段名不一致时显式指定
private Integer userStatus;
@TableLogic // 逻辑删除字段,自动在查询/更新中处理
private Integer deleted;
@TableField(fill = FieldFill.INSERT) // 插入时自动填充
private LocalDateTime createTime;
}
注解 作用 常用取值
@TableName 绑定表名 字符串表名
@TableId 主键 AUTO / ASSIGN_ID / INPUT
@TableField 字段映射 value、fill、exist=false
@TableLogic 逻辑删除 标注在删除标记字段

四、通用 Mapper:BaseMapper 内置 CRUD

让 Mapper 继承 BaseMapper<T>,17 个常用方法直接可用:

1
2
3
public interface UserMapper extends BaseMapper<User> {
// 你只需要写「自定义」的部分,通用的已经在父类里
}
1
2
3
4
5
6
User user = userMapper.selectById(1L);
List<User> list = userMapper.selectList(null); // 全查
userMapper.insert(user);
userMapper.updateById(user);
userMapper.deleteById(1L); // 逻辑删除时自动转 UPDATE
long count = userMapper.selectCount(null);

selectList(null) 的 null 是 QueryWrapper 参数,传 null 表示无条件。这也正是下一节条件构造器的入口。

五、条件构造器(核心)

条件构造器是 MP 最常用也最容易被误用的能力。三种主力:

构造器 用法 推荐度
QueryWrapper 用字符串写列名,如 eq("name", "Tom") 一般
LambdaQueryWrapper 用方法引用写列,如 eq(User::getName, "Tom") ⭐ 推荐
UpdateWrapper 带更新的 set 条件 更新场景

推荐 LambdaQueryWrapper,因为编译期就能发现字段拼错,重构安全:

1
2
3
4
5
6
7
LambdaQueryWrapper<User> w = new LambdaQueryWrapper<>();
w.eq(User::getUserStatus, 1)
.like(User::getName, "张")
.between(User::getAge, 18, 60)
.orderByDesc(User::getCreateTime)
.last("LIMIT 10"); // 慎用 last,直接拼 SQL 尾巴
List<User> users = userMapper.selectList(w);

更新用 LambdaUpdateWrapper(不用先查再 set):

1
2
3
LambdaUpdateWrapper<User> uw = new LambdaUpdateWrapper<>();
uw.eq(User::getId, 1L).set(User::getAge, 30);
userMapper.update(null, uw); // 第一个参数为 null:纯按 wrapper 更新

绝对不要用 QueryWrapper 拼接前端传来的字符串当列名或值,例如 eq(前端字段, 前端值)。列名必须白名单校验,值用 #{}(构造器内部已经是预编译参数),否则有注入风险。构造器生成的 SQL 全部走 ? 预编译,本身是安全的——危险的是你拿外部输入当「列名」。

六、自定义 SQL 与 Wrapper 共存

MP 不排斥你写 XML/注解 SQL,而且能让自定义 SQL 复用 Wrapper 条件:

1
2
@Select("SELECT * FROM user ${ew.customSqlSegment}")
List<User> selectCustom(@Param("ew") Wrapper<User> wrapper);
1
2
userMapper.selectCustom(
new LambdaQueryWrapper<User>().eq(User::getUserStatus, 1));

${ew.customSqlSegment} 会把 wrapper 编译成 WHERE ...,既保留手写 SQL 的自由,又享受条件构造器的便利。

七、分页插件

MP 的分页是拦截器式的——你只管传 Page,它自动帮你拼 count 和 limit:

1
2
3
4
5
6
7
8
9
10
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(
new PaginationInnerInterceptor(DbType.MYSQL)); // 指定数据库类型
return interceptor;
}
}
1
2
3
4
5
Page<User> page = new Page<>(1, 10);          // 第 1 页,每页 10 条
Page<User> result = userMapper.selectPage(page,
new LambdaQueryWrapper<User>().eq(User::getUserStatus, 1));
long total = result.getTotal(); // 自动查出的总数
List<User> records = result.getRecords(); // 当前页数据

联表分页(多表 JOIN)selectPage 的 count 会按主表 count,常算错。实践里有两条路:① 用 @Select 自定义 SQL 并自己写 count 方法;② 先分页查出主表 ID,再 IN 查询关联数据(避免深分页 JOIN)。

八、逻辑删除

逻辑删除让 deleteById 变成 UPDATE ... SET deleted=1,查询自动追加 deleted=0,业务无感知:

  • 全局配置(见第二节 yaml):logic-delete-field: deleted 后,所有实体有该字段即生效;
  • 或按字段单独标 @TableLogic 覆盖全局。
1
2
userMapper.deleteById(1L);
// 实际执行:UPDATE user SET deleted=1 WHERE id=1 AND deleted=0

逻辑删除的三个坑:① 被标记的字段不要建唯一索引(否则两条「已删除」数据会冲突,用「唯一列 + deleted」联合索引缓解);② selectCount 默认也带 deleted=0,统计已删除要手写 SQL;③ 逻辑删除字段不要和「是否禁用」等业务字段混用,语义会乱。

九、自动填充与乐观锁

自动填充:用 MetaObjectHandler 统一处理 create_time / update_time,告别每次手动 set:

1
2
3
4
5
6
7
8
9
10
11
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject mo) {
strictInsertFill(mo, "createTime", LocalDateTime.class, LocalDateTime.now());
}
@Override
public void updateFill(MetaObject mo) {
strictUpdateFill(mo, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}

乐观锁:高并发更新防覆盖,靠 @Version + 拦截器:

1
2
@Version
private Integer version; // 更新时自动 WHERE version=? 且 SET version=version+1
1
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());

更新失败(version 不匹配)返回影响行数 0,业务层据此重试或抛错。

十、代码生成器 FastAutoGenerator

不想手写 Entity/Mapper/Service/Controller?3.x 的 FastAutoGenerator 一条链搞定:

1
2
3
4
5
6
7
8
FastAutoGenerator.create("jdbc:mysql://localhost:3306/demo", "root", "pwd")
.globalConfig(g -> g.author("liuxin").outputDir("./src/main/java").openDir(false))
.dataSourceConfig(d -> d.dbQuery(new MySqlQuery()))
.packageConfig(p -> p.parent("com.example.demo").entity("entity").mapper("mapper"))
.strategyConfig(s -> s.addInclude("user", "order")
.entityStrategy(e -> e.enableLombok().enableTableFieldAnnotation())
.controllerBuilder(c -> c.enableRestStyle()))
.execute();

执行后直接产出:User.java / UserMapper.java(含 extends BaseMapper<User>)/ UserService + UserServiceImpl / UserController,并自动带 Swagger/RESTful 风格。

十一、与 MyBatis 原生共存 & 插件生态

MP 完全兼容原生 MyBatis:你的老 @Select、XML、<resultMap>、<foreach> 全部照常工作,MP 只是在 MyBatis 生命周期里插入了拦截器。常用内置拦截器还有:

  • TenantLineInnerInterceptor:多租户,自动拼 tenant_id;
  • DynamicTableNameInnerInterceptor:分表时按规则动态替换表名;
  • DataChangeRecorderInnerInterceptor:数据变更记录(审计)。

插件通过 MybatisPlusInterceptor.addInnerInterceptor(...) 顺序注册。分页插件建议放在最后,避免其他插件改写 SQL 后分页拦截器拿不到正确语句。

十二、MP vs MyBatis vs JPA 选型对照

维度 MyBatis(原生) MyBatis-Plus Spring Data JPA
单表 CRUD 效率 低(手写) 高(通用 Mapper) 高
复杂 SQL 可控性 最高 高(XML 共存) 低(JPQL 受限)
学习曲线 中 低 中
动态条件查询 XML 拼 Wrapper 类型安全 Specification 繁琐
数据库移植性 强 强 最强
适合场景 报表/复杂 SQL 业务后台(CRUD 多) 领域模型驱动

结论:后台管理系统、内部业务系统这种「表多 CRUD 多」的场景,MP 性价比最高;需要大量手写复杂报表 SQL 的,原生 MyBatis 或直接 JDBC 更顺手。

十三、10 条生产避坑清单

  1. starter 别重复引:引了 mybatis-plus-*-starter 就不要再引原生 mybatis-spring-boot-starter,版本冲突直接 NoClassDefFound。
  2. 列名用 Lambda 写法:LambdaQueryWrapper 比字符串 QueryWrapper 多一层编译期校验,重命名字段时 IDE 会提醒。
  3. last() 慎用:last("LIMIT 1") 是直接字符串拼接,参数不可来自外部输入,否则注入。
  4. 逻辑删除 + 唯一索引:把 deleted 纳入联合唯一索引,或避免对该字段建唯一索引。
  5. 分页拦截器放最后:多插件顺序注册时,分页放末尾,避免 SQL 被改写后分页失效。
  6. 乐观锁更新要取返回值:updateById 返回 0 说明 version 冲突,必须重试而非静默成功。
  7. selectList(null) 会全表扫:生产环境务必带条件或分页,避免一次拉全表。
  8. IdType.ASSIGN_ID 是 19 位 Long:前端 JS 精度只有 53 位,返回要转 String,否则末几位被截断。
  9. 多数据源别只配一个分页:每个数据源都要各自注册 MybatisPlusInterceptor,否则其中一个不分页。
  10. exist=false 标注非表字段:实体里的 private String keyword(查询条件字段)一定要标 @TableField(exist = false),否则 insert 报「未知列」。

MP 的哲学是「增强不替换」:它让 80% 的单表 CRUD 零代码,又把那 20% 复杂 SQL 完整留给你。掌握条件构造器、分页、逻辑删除、自动填充这四件套,再加上代码生成器,一个标准后台的持久层开发效率能翻好几倍。