前言 大家好,我是Chrisco,现在提供一篇MyBatisPlus的新手从0入门教程 ,新手可以跟着上手操作,学过的老手也能用来复习,希望大家点个关注,我会持续更新优质内容
从 0 学 MyBatis-Plus:从建表到常用插件实战
这是一篇可以照着敲一遍的入门教程。它不假设你用过 MyBatis-Plus,甚至不假设你写过原生 MyBatis,只要求你会写 Java、知道 Spring Boot 大概在做什么、机器上有数据库可以连。
本文基于 MyBatis-Plus 3.5.17(官方文档当前展示版本)、JDK 17、Spring Boot 3.x、MySQL 8 编写。若你的项目是 JDK 8,绝大多数代码可以直接用,仅分页插件等可选模块的依赖坐标不同,文中已单独标注。
准备好开始
适合谁读
| 写得出 Java 类和方法,用过 Spring Boot 的基本注解 | 最合适。可以完整跟着做完,把 MyBatis-Plus 用进日常开发 |
| 写过原生 MyBatis,写过 XML 或注解 SQL | 可以重点看条件构造器、分页、自动填充这几节,体会"省掉了什么" |
| 还没写过 Java,或不了解关系型数据库和 SQL | 建议先补 Java 基础与 SQL,再回来读,否则第 3 章之后会吃力 |
学完你能做到什么
把下面这份清单当成验收标准。全部打勾,说明你已经具备在真实项目里使用 MyBatis-Plus 的能力。
-
能独立搭好一个 Spring Boot + MyBatis-Plus 的工程,知道该引哪个 starter
-
面对一张新表,不写任何 SQL 就能完成增删改查
-
能用 Wrapper 拼出带条件、排序、分页的查询,并知道哪些写法有注入风险
-
能让创建时间、更新时间这类公共字段自动填充
-
能用逻辑删除替代物理删除,并理解它带来的查询行为变化
-
能用乐观锁解决并发覆盖问题,用枚举映射处理状态字段
-
能用代码生成器一次性生成实体、Mapper、Service 和 Controller 骨架
整体学习路径
教程按下面的顺序推进,每一步都有可以自己验证的结果。建议边读边敲,不要只读不写。
认识 MyBatis-Plus 的定位与它增强的部分
准备一张带公共字段的用户表
建工程、引依赖、写最小配置
跑通第一个查询,建立信心
掌握通用 CRUD、Service 层与条件构造器
按需开启分页、自动填充、逻辑删除、乐观锁、枚举映射
用代码生成器把重复劳动一次做掉
认识 MyBatis-Plus 是什么
一句话:**MyBatis-Plus 是 MyBatis 的增强工具,只做增强、不做改变。**引入它之后,你原有的 MyBatis 写法(XML、注解 SQL、自定义 SQL)全部照常可用,同时多出来一整套"不用写 SQL"的能力。
它解决的是开发中最枯燥的那部分工作:单表的增删改查、条件查询、分页、公共字段填充、逻辑删除、乐观锁。这些代码结构高度重复,手写既慢又容易出错,而 MyBatis-Plus 把它们变成了内置方法和注解。
和原生 MyBatis 的差别
| 单表增删改查 | 每个方法写 SQL 或 XML 片段 | Mapper 继承 BaseMapper,内置方法直接调用 |
| 条件查询 | 在 XML 里堆 if 标签判断参数 | 条件构造器 Wrapper 链式拼装,字段可空时用重载参数跳过 |
| 分页 | 自己写 limit,再写一条 count 语句 | 配置分页插件后,一次调用拿到数据和总数 |
| 创建时间、更新时间 | 每个 insert 和 update 手动 set | 注解标记字段,处理器自动填充 |
| 删除数据 | 物理删除,数据无法找回 | 逻辑删除:删除变成更新标记位,查询自动过滤 |
| 并发更新覆盖 | 自己写 version 条件判断 | 乐观锁插件加 @Version 注解 |
| 生成模板代码 | 手抄或自己写脚本 | 代码生成器按表结构一键生成 |
它在系统里的位置
先建立这张整体图,后面每一节都只是往图上的某一段填内容。MyBatis-Plus 的增强集中在两处:一是 Mapper 层的通用方法,二是夹在 Mapper 与数据库之间的拦截器链。
跟着做:从建表到第一个查询
准备数据库
为了让后面的每个特性都有地方用,这张表一次性加上状态、版本号、逻辑删除标记和两个时间字段。它们现在看着多余,学到第 8、9 节就会全部用上。
CREATE DATABASE IF NOT EXISTS mp_demo DEFAULT CHARSET utf8mb4;
USE mp_demo;
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` VARCHAR(30) DEFAULT NULL COMMENT '姓名',
`age` INT DEFAULT NULL COMMENT '年龄',
`email` VARCHAR(50) DEFAULT NULL COMMENT '邮箱',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1 启用,0 禁用',
`version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0 未删除,1 已删除',
`create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
INSERT INTO `user` (`name`, `age`, `email`) VALUES
('张三', 18, 'zhangsan@example.com'),
('李四', 20, 'lisi@example.com'),
('王五', 28, 'wangwu@example.com');
执行完成后先确认一下数据:
SELECT id, name, age, email, status, deleted FROM `user`;
看到 3 条记录即准备完成。注意此时 create_time、update_time 都是 NULL,这是故意的:稍后要观察自动填充是否生效。
创建工程与引入依赖
用 IDEA 的 Spring Initializr 建一个空工程即可,需要勾选的只有 Spring Web、MySQL Driver 和 Lombok(Lombok 可选,不习惯就用 IDE 生成 getter/setter)。关键在依赖坐标:Spring Boot 版本不同,starter 名字也不同,引错了会在启动时报找不到类。
| Spring Boot 2.x | mybatis-plus-boot-starter |
| Spring Boot 3.x | mybatis-plus-spring-boot3-starter |
| Spring Boot 4.x(3.5.13 起支持) | mybatis-plus-spring-boot4-starter |
| 非 Spring Boot 的 Spring 项目 | mybatis-plus,并按需自行引入 mybatis-spring |
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.17</version>
</dependency>
<!– 分页、乐观锁、防全表更新等插件依赖它,3.5.9 起需要单独引入 –>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>3.5.17</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!– 可选:用 Lombok 省掉 getter/setter –>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
引入 MyBatis-Plus 后,不要再引入 mybatis、mybatis-spring-boot-starter 或 mybatis-spring,版本差异会导致难以排查的冲突。
3.5.9 起插件模块被拆成可选依赖。没引 mybatis-plus-jsqlparser,分页插件的类根本找不到,这是升级后最常见的报错。
如果你的项目还在 JDK 8,把 jsqlparser 那一项换成下面这个坐标,它对应不支持 JDK 8 的 jsqlparser 5.x 之前的版本:
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser-4.9</artifactId>
<version>3.5.17</version>
</dependency>
依赖变多之后建议用官方 BOM 统一版本:引入 mybatis-plus-bom(3.5.9 及以上版本)到 dependencyManagement,后面各模块就不用再写 version 了。
完成最小配置
Spring Boot 会自动装配 MyBatis-Plus,你只需要告诉它数据库在哪,以及在哪里找 Mapper 接口。
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/mp_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 123456
mybatis-plus:
configuration:
# 数据库的 user_name 自动映射到实体的 userName,强烈建议开启
map-underscore-to-camel-case: true
# 学习阶段把执行的 SQL 打到控制台,上线前记得关掉或换成日志框架
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
banner: false
db-config:
id-type: auto
@SpringBootApplication
@MapperScan("com.example.demo.mapper")
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
注意第二行的 @MapperScan,参数是 Mapper 接口所在的包名。忘了写它,启动时不会报错,但运行时调用 Mapper 会抛出 “Invalid bound statement (not found)”,这个坑后面还会在速查表里出现。
跑通第一个查询
先写实体类和 Mapper 接口。实体类用注解把 Java 字段和数据库列对应起来,Mapper 接口只需要继承 BaseMapper,一行方法都不用写。
@Data
@TableName("user")
public class User {
/** 主键,数据库自增 */
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private Integer age;
private String email;
/** 状态,用枚举映射数据库的 status 字段 */
@EnumValue
private StatusEnum status;
/** 乐观锁版本号 */
@Version
private Integer version;
/** 逻辑删除标记 */
@TableLogic
private Integer deleted;
/** 插入时自动填充 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/** 插入和更新时自动填充 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
public interface UserMapper extends BaseMapper<User> {
}
注解里已经提前埋了枚举、乐观锁、逻辑删除和自动填充的写法,暂时不影响查询,后面会逐个解释。现在先写测试验证链路是否打通:
@SpringBootTest
class UserMapperTest {
@Autowired
private UserMapper userMapper;
@Test
void selectAll() {
List<User> users = userMapper.selectList(null);
users.forEach(System.out::println);
Assertions.assertEquals(3, users.size());
}
}
selectList 的参数是条件构造器,传 null 表示查询全部。
**第一处检查点:**控制台打印出三条 User、断言通过。如果报 “Invalid bound statement”,先检查 @MapperScan 的包名写对没有。
核心能力逐个击破
不写 SQL 的通用 CRUD
BaseMapper 已经内置了单表所需的全部常用操作。下面这张表按使用频率排列,日常开发中八成以上的数据库操作都在这里。
| insert(entity) | 插入一条记录,返回影响行数;主键会回写到实体对象里 |
| deleteById(id) | 按主键删除。配了逻辑删除后,实际执行的是 update |
| delete(wrapper) | 按条件删除,条件由 Wrapper 提供 |
| updateById(entity) | 按主键更新,只更新实体中非 null 的字段 |
| update(entity, wrapper) | 按条件更新,实体设置新值,Wrapper 设置 where 条件 |
| selectById(id) | 按主键查询单条 |
| selectOne(wrapper) | 按条件查询单条,结果超过一条会抛异常 |
| exists(wrapper) | 判断满足条件的数据是否存在,比 count 更直观 |
| selectList(wrapper) | 按条件查询列表,参数为 null 即查全部 |
| selectCount(wrapper) | 按条件统计总数 |
| selectPage(page, wrapper) | 分页查询,需要先配置分页插件 |
| selectMaps / selectObjs | 直接返回 Map 或首个字段的值,适合只取部分列的场合 |
@Test
void crud() {
// 插入:主键回写到 user 对象
User user = new User();
user.setName("赵六");
user.setAge(25);
user.setEmail("zhaoliu@example.com");
userMapper.insert(user);
System.out.println("新生成的主键:" + user.getId());
// 查询单条
User found = userMapper.selectById(user.getId());
System.out.println(found);
// 更新:只更新 name,其他字段保持原值
User update = new User();
update.setId(user.getId());
update.setName("赵六六");
userMapper.updateById(update);
// 统计
Long total = userMapper.selectCount(null);
System.out.println("总记录数:" + total);
// 删除
userMapper.deleteById(user.getId());
}
主键策略怎么选
主键生成由 @TableId 的 type 属性控制,常用取值如下。不确定时,自增表用 AUTO,分布式或分库分表场景用 ASSIGN_ID。
| IdType.AUTO | 数据库自增 | 单库单表,主键列是 AUTO_INCREMENT |
| IdType.ASSIGN_ID | 由框架分配,默认雪花算法 | 分布式、需要提前拿到 id 的场景 |
| IdType.ASSIGN_UUID | 分配 UUID,适用于 String 主键 | 不需要有序主键的表 |
| IdType.INPUT | 插入前由自己设置主键 | 主键来自外部系统或业务规则 |
| IdType.NONE | 不指定,跟随全局配置 | 默认值,等价于交给全局 db-config 决定 |
旧版本里的 ID_WORKER、UUID、ID_WORKER_STR 已经废弃,新代码统一用 ASSIGN_ID 或 ASSIGN_UUID 代替。
Service 层与链式调用
Mapper 已经够省事,但业务代码通常写在 Service 层。让 Service 继承 IService,可以再省掉一层转发代码,并且多出批量操作和链式查询。
public interface UserService extends IService<User> {
}
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
}
Service 层方法名有清晰的前缀规律:get 查单条、list 查集合、page 分页、save 保存、remove 删除、update 更新、count 统计。
| save / saveBatch | 插入一条或批量插入,可指定批次大小控制单次 SQL 体量 |
| saveOrUpdate | 主键有值则更新,没有则插入 |
| getById / getOne | 按主键或条件查单条 |
| list / listByIds | 按条件或主键集合查列表 |
| page | 分页查询,返回含 records 与 total 的分页对象 |
| count | 按条件统计 |
| updateById / update | 按主键或条件更新 |
| removeById / remove | 按主键或条件删除 |
@Autowired
private UserService userService;
@Test
void serviceDemo() {
// 批量插入
List<User> list = List.of(buildUser("A"), buildUser("B"));
userService.saveBatch(list, 500);
// 链式查询:一行写完条件与结果
List<User> adults = userService.lambdaQuery()
.ge(User::getAge, 18)
.orderByDesc(User::getAge)
.list();
// 链式更新
userService.lambdaUpdate()
.set(User::getEmail, "new@example.com")
.eq(User::getName, "A")
.update();
// 单条查询
User one = userService.getById(1L);
System.out.println(one);
}
在工具类、静态方法里拿不到 Service 注入时,可以用官方提供的 Db Kit 静态工具类直接执行 CRUD,用法与 IService 基本一致,详细用法见官方《持久层接口》文档。另外从 3.5.9 起官方把 service 模块重构为 repository,提供了语义更清晰的 IRepository,用法与 IService 相同,新项目可以按团队规范二选一。
条件构造器 Wrapper
Wrapper 是这套框架里最需要花时间熟悉的部分。它把 where 条件变成链式方法调用,让你在 Java 里拼 SQL,而不是去 XML 里堆 if。
| QueryWrapper | 查询条件,字段用字符串写数据库列名 |
| LambdaQueryWrapper | 查询条件,字段用方法引用写,推荐优先使用 |
| UpdateWrapper | 更新条件,可以只设置 where 而不传实体 |
| LambdaUpdateWrapper | 更新条件的 lambda 版本 |
为什么要强调 lambda 版本?看下面两行的区别就明白了。字符串写错字段名,编译期不会报错,运行时才炸;方法引用写错,IDE 立刻标红。
// 普通写法:字段名写错要到运行时才发现
QueryWrapper<User> q1 = new QueryWrapper<>();
q1.eq("name", "张三").ge("age", 18);
// lambda 写法:字段名写错编译不过,推荐
LambdaQueryWrapper<User> q2 = Wrappers.lambdaQuery();
q2.eq(User::getName, "张三").ge(User::getAge, 18);
常用条件方法
| eq / ne | column = value / column <> value |
| gt / ge / lt / le | 大于 / 大于等于 / 小于 / 小于等于 |
| between / notBetween | column BETWEEN v1 AND v2 |
| like / notLike | column LIKE ‘%value%’ |
| likeLeft / likeRight | 以 value 结尾 / 以 value 开头 |
| isNull / isNotNull | column IS NULL / IS NOT NULL |
| in / notIn | column IN (…),集合为空时该条件自动跳过 |
| groupBy / having | 分组与分组后过滤 |
| orderByAsc / orderByDesc | 升序 / 降序排序 |
| select | 指定查询列,避免 select * |
| and / or / nested | 组合条件,nested 用于给一组条件加括号 |
| apply / last / exists | 拼接自定义 SQL 片段,能力最强也最危险 |
| set / setSql | 更新场景下指定要改的字段和新值 |
两个必须掌握的习惯
**习惯一:用 condition 参数让条件可选。**几乎所有方法都有带 boolean 的重载,第一个参数为 false 时整个条件不会拼进 SQL。这样就不用写一堆 if,也不会有动态 SQL 的拼接烦恼。
LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery();
wrapper.eq(StringUtils.isNotBlank(name), User::getName, name)
.ge(age != null, User::getAge, age)
.in(CollectionUtils.isNotEmpty(ids), User::getId, ids);
这里的语义是:只有 name 不为空、age 不为 null、ids 集合非空,对应条件才会生效。集合或 Map 参数为空时,相关条件也会被自动忽略。
**习惯二:or 一定要跟括号一起用。**不加 nested,or 会把前面的条件也一起"或"掉,这是业务查询结果诡异的常见原因。
// 错误:实际 SQL 是 status = 1 AND name = '张三' OR age = 30
wrapper.eq(User::getStatus, 1).eq(User::getName, "张三").or().eq(User::getAge, 30);
// 正确:用 nested 把"或者"的部分包起来
wrapper.eq(User::getStatus, 1)
.and(w -> w.eq(User::getName, "张三").or().eq(User::getAge, 30));
// 生成:status = 1 AND (name = '张三' OR age = 30)
**安全红线:**apply、last,以及排序字段、字段名这些位置,绝不要把前端传入的字符串直接拼进去。Wrapper 的参数绑定机制只保护"值",不保护"SQL 片段"。涉及字段名、排序字段时务必做白名单校验,否则就是标准的 SQL 注入入口。
Wrapper 是查询构造工具,不是数据传输对象。不要把它当作接口参数在服务之间传递,正确做法是定义 DTO 传业务参数,由被调用方自己构造 Wrapper。
分页插件
分页是本教程第一个真正需要"配置"的能力。它靠拦截器改写 SQL 实现:你在代码里写普通查询,插件帮你加上 limit,并自动生成 count 语句。
@Configuration
@MapperScan("com.example.demo.mapper")
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 配置多个插件时,分页插件要放在最后
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
@Test
void pageDemo() {
Page<User> page = new Page<>(1, 2); // 第 1 页,每页 2 条
LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery();
wrapper.ge(User::getAge, 18).orderByDesc(User::getId);
Page<User> result = userMapper.selectPage(page, wrapper);
System.out.println("总记录数:" + result.getTotal());
System.out.println("总页数:" + result.getPages());
System.out.println("本页数据:" + result.getRecords());
}
Page 对象的主要属性:
| current / size | 当前页与每页条数,size 默认 10 |
| total / pages | 总记录数与总页数 |
| records | 当前页的数据列表 |
| orders | 排序信息 |
| searchCount | 是否执行 count 查询,设为 false 可省一次查询 |
| optimizeCountSql | 是否自动优化 count 语句,默认开启 |
| maxLimit | 单页条数上限,防止前端传入超大 size |
分页最容易踩的三个坑
**分页完全不生效,返回全部数据。**先看有没有注册 PaginationInnerInterceptor;3.5.9 之后还要看有没有引入 mybatis-plus-jsqlparser。
**count 结果不准。**多个插件共存时,分页插件必须放在拦截器链的最后。另外带 left join 的 SQL 建议给表加别名,否则自动优化 count 语句时可能把参与条件的表优化掉。
**排序字段被前端控制导致注入。**Page 的 orders 和 Wrapper 的排序方法都不要直接使用外部传入的列名,必须做白名单校验。
如果需要在自定义 SQL 里分页,把 IPage 作为第一个参数传给 Mapper 方法即可,返回类型写 IPage 或 List 都可以,XML 里照常写你自己的查询语句。
自动填充与逻辑删除
这两个功能都属于"一旦用上就回不去"的类型,它们把最枯燥的重复代码消掉了。
自动填充公共字段
第一步,在实体字段上标记填充时机。第二步,实现 MetaObjectHandler 告诉框架填什么值。字段上的 fill 属性有三种常用取值:INSERT(仅插入)、UPDATE(仅更新)、INSERT_UPDATE(两者都填)。
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
// strictInsertFill 只在字段当前为 null 时填充,避免覆盖业务手动设置的值
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}
真实项目里这两个值常常还要填当前登录人,同样在这个处理器里通过上下文获取即可。注意 strict 系列方法只在字段为空时填充;如果你的业务需要每次更新都强制覆盖时间,用非 strict 的重载。
逻辑删除
逻辑删除把 delete 变成 update。配置方式有两种,选一种就好:在实体字段上加 @TableLogic 注解,或者用全局配置统一指定字段名。
mybatis-plus:
global-config:
db-config:
logic-delete-field: deleted # 实体属性名,不是数据库列名
logic-delete-value: 1 # 已删除,默认值就是 1
logic-not-delete-value: 0 # 未删除,默认值就是 0
配置生效后行为会发生变化,这是初学者最容易困惑的地方:
| deleteById(1) | UPDATE user SET deleted = 1 WHERE id = 1 AND deleted = 0 |
| selectById(1) | SELECT … WHERE id = 1 AND deleted = 0 |
| selectList(null) | SELECT … WHERE deleted = 0 |
**别忘了插入时的初始值。**新插入的数据必须带上 deleted = 0,否则这条数据在所有查询里都"不存在"。三种常见处理方式:给数据库列设默认值(本教程建表语句已经这么做)、插入前手动 set、用自动填充。另外,如果你希望删除时也填充"删除人、删除时间",直接用 deleteById 配合自动填充即可,老版本里那些额外的注入器已经废弃。
乐观锁与枚举映射
乐观锁解决并发覆盖
两个人同时打开同一条数据、先后保存,后保存的人会覆盖前一个人的修改。乐观锁用版本号解决这个问题:更新时带上版本条件,版本对不上就是 0 行受影响,你的代码自己决定是重试还是报错。
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
// 分页插件放在最后
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
// 1. 先查出来,拿到当前版本号
User user = userMapper.selectById(1L);
// 2. 改字段
user.setEmail("new@example.com");
// 3. 更新,SQL 会自动带上 version 条件
int rows = userMapper.updateById(user);
if (rows == 0) {
// 说明这条数据在本次操作期间被别处改过,按业务决定重试或提示
throw new IllegalStateException("数据已被修改,请刷新后重试");
}
// 更新成功后,新版本号会自动回写到 user 对象
要点:支持 int、Integer、long、Long、Date、Timestamp、LocalDateTime 类型;newVersion 会自动回写到实体;只对 updateById、update(entity, wrapper)、saveOrUpdate 这类内置更新生效;同一个 wrapper 不要在多次更新中复用。
枚举映射
数据库里的 status 是数字,代码里希望是枚举,此时用 @EnumValue 标出"哪个属性对应数据库里存的值"即可,不需要写 TypeHandler。
@Getter
@AllArgsConstructor
public enum StatusEnum {
ENABLED(1, "启用"),
DISABLED(0, "禁用");
/** 这个值会被写进数据库 */
@EnumValue
private final int code;
private final String desc;
}
实体里正常声明 StatusEnum status 字段即可,插入和查询都会自动在枚举与数字之间转换。如果枚举实现了 IEnum 接口,也是同样效果;官方目前推荐使用 @EnumValue 注解的方式。对于没有做任何声明的枚举,框架会退回 MyBatis 默认的按名称映射,结果往往不是你要的,因此状态类字段建议一律显式声明。
代码生成器
前面所有代码其实是高度模式化的。当项目里有几十张表时,应该用代码生成器一次性把实体、Mapper、Service、Controller 的骨架全部生成出来。
先加两个依赖,starter 之外还需要生成器本体和一个模板引擎。生成器用到了模板引擎,但官方不强制绑定某一个,Velocity、Freemarker、Beetl、Enjoy 任选其一,下面以 Velocity 为例。
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.5.17</version>
</dependency>
<dependency>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId>
<version>2.3</version>
</dependency>
public static void main(String[] args) {
FastAutoGenerator.create(
"jdbc:mysql://localhost:3306/mp_demo?serverTimezone=Asia/Shanghai",
"root",
"123456")
.globalConfig(builder -> builder
.author("your-name")
.outputDir("D://mp-demo/src/main/java")
.disableOpenDir())
.packageConfig(builder -> builder
.parent("com.example.demo")
.entity("entity")
.mapper("mapper")
.service("service")
.serviceImpl("service.impl")
.controller("controller"))
.strategyConfig(builder -> builder
.addInclude("user")
.addTablePrefix("t_")
.entityBuilder().enableLombok().enableTableFieldAnnotation()
.controllerBuilder().enableRestStyle())
.templateEngine(new VelocityTemplateEngine())
.execute();
}
这套全新的代码生成器在 3.5.1 版本加入,与更早版本的 API 不兼容。生成之后请把代码当作自己项目的一部分来维护,别每次改表都重新生成覆盖业务逻辑。如果想让生成的实体带注释,MySQL 连接串上可以加 remarks 相关参数,具体以官方文档说明为准。
常见坑速查
下面这些是入门阶段最常遇到的报错与困惑,遇到问题时先来这里对一眼。
| Invalid bound statement (not found) | Mapper 接口没有被扫描到 | 启动类加 @MapperScan 指向 Mapper 所在包,或给接口加 @Mapper |
| 分页不生效,查出全部数据 | 没有注册分页插件,或 3.5.9 之后没引 jsqlparser | 注册 PaginationInnerInterceptor,并补上 mybatis-plus-jsqlparser 依赖 |
| 找不到 PaginationInnerInterceptor 类 | 插件已从核心包拆出 | 引入 mybatis-plus-jsqlparser(JDK 8 用 mybatis-plus-jsqlparser-4.9) |
| 启动报 NoSuchMethodError 之类的方法缺失 | 同时引入了 mybatis 或 mybatis-spring 的旧版本 | 用依赖树排查并移除重复的 MyBatis 依赖 |
| 查询结果字段为 null | 下划线与驼峰没有自动映射 | 开启 map-underscore-to-camel-case,或用 @TableField 指定列名 |
| updateById 没有更新某些字段 | 实体中这些字段是 null,默认策略会忽略 null | 用 UpdateWrapper.set 显式设置,或调整字段的更新策略 |
| deleteById 之后数据还在表里 | 逻辑删除在起作用,这是预期行为 | 确认这就是你要的效果;确实需要物理删除时写自定义 SQL |
| 新插入的数据查不到 | 逻辑删除字段没有初始值 | 给数据库列设默认值、插入前赋值,或使用自动填充 |
| 更新误伤了整张表 | Wrapper 忘了加 where 条件 | 加 BlockAttackInnerInterceptor 插件,让全表更新直接抛异常 |
| or 条件把其他条件也带偏了 | 缺少括号分组 | 用 nested 或 and(w -> …) 包住"或"的部分 |
| 生产环境日志刷屏 | log-impl 打开了控制台输出 | 上线前移除该配置,改用日志框架按需打印 SQL |
上线前强烈建议加上 BlockAttackInnerInterceptor(防全表更新与删除插件)。它会在没有 where 条件的 update 或 delete 执行前直接抛异常,属于低成本、高收益的保险。生产环境还可以进一步考虑非法 SQL 拦截、多租户、数据权限等插件。
完成清单与下一步
回头看一遍开头的清单,逐条确认:
-
工程能启动,UserMapper 的查询返回了数据库里的真实数据
-
不写 SQL 完成了 insert、updateById、deleteById、selectList
-
能用 lambdaQuery() 或 Wrapper 组合条件,并知道 or 要加括号
-
分页查询能拿到正确的 total 和 records
-
新增数据时 createTime 与 updateTime 自动有值
-
删除数据后表里仍有记录,但查询查不到
-
并发更新时,旧版本号的更新返回 0 行
-
状态字段在数据库里是数字,在代码里是枚举
-
用代码生成器成功生成了一套 CRUD 骨架
全部打勾之后,可以按兴趣往下走:
-
多租户与数据权限插件:SaaS 场景下按租户或按角色自动给 SQL 加过滤条件
-
动态表名插件:按时间分表后,让同一套代码读写不同月份的表
-
流式查询与批量操作:数据量大时避免一次性把结果集读进内存
-
SQL 分析与执行日志:结合 p6spy 等工具观察真实执行的 SQL 与耗时
-
MyBatisX 插件:在 IDEA 里实现 Mapper 接口与 XML 之间的相互跳转
最后一句经验之谈:MyBatis-Plus 的价值在于省掉重复劳动,而不是让你彻底远离 SQL。遇到多表关联、复杂聚合、性能敏感的场景,直接写 SQL 往往更清晰、更好优化,框架也完全允许你这么做。
参考资料
-
MyBatis-Plus 官方文档 · 安装:starter 坐标、版本要求与 Maven BOM 说明
-
MyBatis-Plus 官方文档 · 快速开始:本文第 3 章的示例来源
-
官方文档 · 条件构造器:全部 Wrapper 方法与生成 SQL 对照
-
官方文档 · 持久层接口:IService、IRepository、链式调用与 Db Kit
-
官方文档 · 注解配置:@TableName、@TableId、@TableLogic 等全部属性
-
官方文档 · 使用配置:application.yml 中所有可配置项
-
官方文档 · 分页插件与乐观锁插件:插件配置与注意事项


