1. 为什么都在用 MyBatis Plus:先看它解决了什么问题
做 Java 后端开发的人,对 MyBatis 肯定不陌生。Mapper 接口加 XML 文件,SQL 自己写,灵活是真灵活,但烦也是真烦。单表 CRUD 这类操作,几乎每个项目都要写一遍,而且写得大同小异:selectById、insert、updateById、deleteById,换个表就是换个字段名,纯体力活。更别说分页查询,每到一个项目就要重复配一次分页插件,遇到多表关联还得小心 SQL 别写错。
MyBatis Plus 的核心定位,就是把这些重复劳动砍掉。它在 MyBatis 之上做了一层增强,单表 CRUD 不用写 SQL,内置方法直接用;分页、逻辑删除、自动填充这些高频需求也内置了解决方案。它不是替代 MyBatis,而是在 MyBatis 的基础上帮你省事。
我当时上手这个框架,最直接的感受是:以前写一个用户模块,从建表到写完 Mapper 和 Service,怎么也要半天,用 MyBatis Plus 之后,建完表,实体类一写,继承几个基类,CRUD 接口基本就齐了。这也就是为什么现在很多新项目,尤其是中小规模项目,直接就用它来起步。
适合谁学?如果你是刚入门 Java 后端的学生、准备做毕设的开发者,或者公司项目里数据层还在靠手写大量重复 SQL 的 CRUD 场景,MyBatis Plus 是值得花几天时间掌握的。它的学习曲线比 MyBatis 原生要平缓得多,只要你掌握了 Spring Boot 的基本用法,剩下的就是照着文档调 API。
下面我不打算照着官方文档重新念一遍,而是按我实际用过之后的经验,把最关键的配置、最常用的功能、最容易踩的坑,从头到尾捋一遍。你跟着走一遍,基本就能在项目里正常使用了。
2. 快速上手前的准备:环境搭建与依赖引入
2.1 环境版本选择:Spring Boot 2 还是 3
这个选择现在影响挺大的。MyBatis Plus 从 3.5.4 之后,对 Spring Boot 3 的支持才算是比较稳定。如果你是新项目,直接用 Spring Boot 3.x 加 MyBatis Plus 3.5.x 以上版本,问题不大。如果项目还在用 Spring Boot 2.x,那么 MyBatis Plus 3.4.x 或 3.5.x 也都可以,但要注意 3.5.x 后续版本有些接口做了微调,升级时要留意兼容性。
以我常用的组合为例:Spring Boot 3.2.x,MyBatis Plus 3.5.7,JDK 17,MySQL 8.x。这个组合在现在的生产环境里很常见,也没有遇到过什么坑。如果你是为了学习,完全可以用这个组合起步。
2.2 Maven 依赖引入
用 Maven 管理依赖的话,核心只要加一个:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency>注意,如果你用的是 Spring Boot 3,官方推荐引入mybatis-plus-spring-boot3-starter,而不是原来的mybatis-plus-boot-starter。这是个容易踩的坑,我之前就见过有人用 Spring Boot 3 引入了旧 starter,结果启动直接报错。
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.7</version> </dependency>2.3 配置文件里的关键项
在application.yml里,最基础的就是配置数据源。然后是两个我个人强烈建议你一上来就打开的开关:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: yourpassword mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0log-impl这个配置,会把所有执行的 SQL 打印到控制台。调试阶段一定要开,不然你怎么死的都不知道。等上线前再关掉就行。
logic-delete-field是逻辑删除的全局配置,后面我会详细讲。这里先记住:如果你表里设计了一个deleted字段来标记是否删除,那这个配置能帮你省掉大量重复的 "where deleted = 0" 条件。
另外,实体类里字段的驼峰命名和下划线命名自动映射,MyBatis Plus 默认就是开启的,所以userName对应user_name这种场景,不需要额外配置。这一点相当省心,比原生 MyBatis 默认不开启要友好得多。
2.4 启动类注解
在 Spring Boot 启动类上,需要加一个注解:
@SpringBootApplication @MapperScan("com.example.demo.mapper") public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }@MapperScan指定 Mapper 接口所在的包路径,这样就不用每个 Mapper 接口上都加@Mapper注解了。两种方式都行,但@MapperScan更简洁,尤其是 Mapper 数量多的时候。
3. 核心功能逐个拆解:从实体类到条件构造器
3.1 实体类注解:别只会 @TableName 和 @TableId
实体类是 MyBatis Plus 操作的基础。最基本的两个注解,一个是@TableName,指定这个实体类对应哪张表;另一个是@TableId,标记主键字段。但如果只知道这两个,还远远不够,实际项目里还有几个字段场景非常常见。
先看一个完整的例子:
@Data @TableName("user") public class User { @TableId(type = IdType.ASSIGN_ID) private Long id; private String name; private Integer age; private String email; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; @TableLogic private Integer deleted; }这里面有几个点值得展开说:
@TableId(type = IdType.ASSIGN_ID):主键生成策略。ASSIGN_ID是雪花算法生成分布式 ID,适合分布式系统。如果用的是数据库自增主键,就用IdType.AUTO。我把这个选择放在优先级第一位,是因为很多新人在主键策略上选错,导致插入数据时主键冲突或者 ID 为空。@TableField(fill = FieldFill.INSERT):自动填充标记。createTime这个字段希望在插入时自动填入当前时间,updateTime希望在插入和更新时都自动填入。这个配合后面说的 MetaObjectHandler 一起用。@TableLogic:单字段逻辑删除标记。加上这个注解之后,MyBatis Plus 的删除操作会自动变成 update 语句,查询操作会自动加WHERE deleted = 0条件。
这里要提醒一句:如果你在配置文件里已经配了全局logic-delete-field,实体类上可以不用加@TableLogic。但如果你只想对某一张表做逻辑删除,其他表不做,那就在实体类字段上单独加注解,不配全局配置。两种方式按需选择。
还有一个比较容易忽略的问题:如果数据库表的字段名和 Java 字段名差别较大,比如表里叫user_name,类里叫name,那就需要@TableField("user_name")显式指定。但大多数情况下,只要遵循下划线转驼峰的命名规则,是可以省略这个注解的。
3.2 BaseMapper:零 SQL 完成单表 CRUD
实体类写完之后,Mapper 接口可以直接继承BaseMapper:
public interface UserMapper extends BaseMapper<User> { }就这么一个空接口,你就能用这些方法了:
selectById(id):按主键查询selectList(Wrapper):按条件查询列表selectOne(Wrapper):按条件查询单条insert(entity):插入一条deleteById(id):按主键删除updateById(entity):按主键更新
这些方法都是基于主键和实体类字段自动生成 SQL 的,不需要你写任何 XML。对于单表操作来说,覆盖了 80% 以上的场景。
3.3 条件构造器 Wrapper:动态 SQL 的核心
真正让 MyBatis Plus 变得好用,甚至可以说是灵魂的,是QueryWrapper和LambdaQueryWrapper。它解决了一个非常痛的问题:查询条件不确定,用户可能传 name,可能传 age,可能两个都传,也可能都不传。
以前写原生 MyBatis,这种场景要在 XML 里拼<if>标签,一不小心就 SQL 拼接错误。用条件构造器之后,直接在 Java 代码里动态组合条件:
public List<User> searchUsers(String name, Integer age) { LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(name), User::getName, name) .eq(age != null, User::getAge, age) .orderByDesc(User::getCreateTime); return userMapper.selectList(wrapper); }这里的关键点:
like()的第一个参数是布尔条件。条件为 true 才把这个条件加进去,这样动态 SQL 的拼装变得非常优雅。eq()和like()的组合,已经能覆盖绝大多数查询场景。- 用
LambdaQueryWrapper而不是QueryWrapper,好处是方法引用User::getName可以避免硬编码字段名字符串。我强烈建议你只用 Lambda 版本的,不然后期重构实体类字段名时,字符串字段名不会跟着变,很容易查不出来问题。
再补充几个常用的条件方法:
ge/le:大于等于 / 小于等于,常用于时间范围和数值范围between:闭区间范围in:IN 查询isNull/isNotNull:判空last("LIMIT 1"):拼接原生 SQL 片段,但这个方法能不用就不用,因为你无法控制拼接的位置,容易出问题。如果真的只查一条,用selectOne加条件即可。
3.4 分页查询:PaginationInnerInterceptor 的配置与使用
分页查询是 MyBatis Plus 使用率最高的功能之一。但注意,它并不是开箱即用的。你需要先配置一个分页插件拦截器,否则你调分页方法,会发现查出来的数据并没有真正分页,甚至可能直接把全表查出来。
配置方式如下:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }分页查询的使用方式:
Page<User> page = new Page<>(1, 10); LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.orderByDesc(User::getCreateTime); Page<User> result = userMapper.selectPage(page, wrapper); // result.getRecords() 当前页数据 // result.getTotal() 总记录数 // result.getPages() 总页数 // result.getCurrent() 当前页码参数new Page<>(1, 10)是第 1 页,每页 10 条。返回的result对象里,getRecords()拿当前页数据,getTotal()拿总条数,这就够前端渲染分页组件了。
这里有个非常隐蔽的性能问题值得注意:selectPage默认会执行一条 count 查询来统计总条数,如果你的表数据量很大,这个 count 查询可能比数据查询本身还慢。MyBatis Plus 3.5.x 之后支持了分页查询的COUNT优化,也支持通过Page的setSearchCount(false)跳过 count 查询。如果场景是"加载更多"类型的无限滚动,不需要总条数,建议直接把 searchCount 关掉。
3.5 Service 层:IService 和 ServiceImpl
很多项目里 Service 层代码就是简单调用 Mapper,自己手写的话,每张表都要写一遍,挺没意思的。MyBatis Plus 提供了 IService 接口和 ServiceImpl 基类,可以把这层也省掉。
public interface UserService extends IService<User> { } @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { }继承之后,你的 Service 层就有了getById、save、updateById、removeById、page这些现成方法。而且ServiceImpl内部还做了一件事:默认开启了批量插入的优化,用saveBatch批量插入时,内部会拼接多条 INSERT 语句,减少数据库交互次数。
我实际测过,插入 5000 条数据,循环单条插入可能要十几秒,用saveBatch两三秒就能完成。如果项目里有批量导入功能,这个提升是非常明显的。
4. 进阶功能深入:自动填充、逻辑删除与乐观锁
4.1 字段自动填充:别再把创建时间手动 set 了
很多表里都有create_time、update_time这类审计字段。以前的做法是在插入和更新前手动 set,代码多而且容易漏。MyBatis Plus 的自动填充功能,可以让你实现一次、全局生效。
先写一个 MetaObjectHandler 的实现类:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { 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()); } }然后实体类对应字段上要加上前面说过的@TableField(fill = FieldFill.INSERT)或@TableField(fill = FieldFill.INSERT_UPDATE)。
这个功能的核心价值在于统一。任何一个实体类,只要有这些注解,插入和更新时时间字段就自动维护了,不需要在业务代码里再写一行 set。尤其是团队协作时,这个能规避"有人忘了填 create_time"的隐患。
4.2 逻辑删除:删除不是真删,是打标记
逻辑删除的意思很直白:数据不物理删除,而是把某个字段置为 1,查询时默认排除掉。这在很多业务场景里是一个硬性需求,比如订单记录、用户审计数据,真正删除会导致后续追溯困难。
用 MyBatis Plus 实现逻辑删除非常简单。你只需要在字段上加上@TableLogic。
之后:
- 调用
deleteById(1L)时,实际执行的 SQL 是UPDATE user SET deleted=1 WHERE id=? AND deleted=0 - 调用
selectById(1L)时,实际执行的 SQL 是SELECT * FROM user WHERE id=? AND deleted=0 - 调用
selectList(wrapper)时,会在原有条件上自动追加AND deleted=0
你会发现,所有框架内置的方法,都自动处理了逻辑删除条件。但如果你自己写 XML SQL,那么抱歉,MyBatis Plus 是无法干预你的自定义 SQL 的,你需要在 XML 里手动加上deleted = 0条件。这一点很多人容易忽略,导致逻辑删除后通过自定义 SQL 查到了已经逻辑删除的数据。
4.3 乐观锁:解决并发更新覆盖问题
具体场景是这样的:一个后台编辑功能,A 和 B 同时打开同一条记录的编辑页面。A 先保存了修改,把数据从 version 1 改成了 version 2。B 没有刷新页面,保存的时候仍然是基于 version 1 的旧数据,如果不加控制,B 的修改会把 A 的修改覆盖掉。
乐观锁的思路是在更新时校验版本号。具体到 MyBatis Plus,需要三步配置。
第一步,实体类里加版本字段:
@Version private Integer version;第二步,配置乐观锁插件。这个插件要和分页插件一起注册:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }第三步,更新时通过updateById或update方法操作即可。
当 MyBatis Plus 执行更新时会自动拼接条件:
UPDATE user SET name='新的名字', version=2 WHERE id=1 AND version=1如果影响行数为 0,说明版本不对,更新失败,你可以在业务代码里做出提示。
这里要特别注意一个坑:乐观锁只对 MyBatis Plus 内置的updateById和update(entity, wrapper)生效。你如果有自定义 SQL 的更新方法,@Version是不起作用的,需要自己在 SQL 里处理。这种复合场景,我更推荐在乐观锁冲突失败后,给用户一个明确的提示,比如"数据已被他人修改,请刷新后重试",而不是静默失败。
5. 实战流程全记录:从建表到接口实现的一次完整走查
5.1 建表和初始化数据
假设我们要做一个简单的用户管理系统,包含查询用户、新增用户、修改用户、删除用户和分页列表。先建表:
CREATE TABLE user ( id BIGINT PRIMARY KEY COMMENT '主键ID', name VARCHAR(30) NOT NULL COMMENT '姓名', age INT DEFAULT NULL COMMENT '年龄', email VARCHAR(50) DEFAULT NULL COMMENT '邮箱', version INT DEFAULT 1 COMMENT '乐观锁版本号', deleted TINYINT DEFAULT 0 COMMENT '逻辑删除,0未删除,1已删除', create_time DATETIME DEFAULT NULL COMMENT '创建时间', update_time DATETIME DEFAULT NULL COMMENT '更新时间' ) COMMENT '用户表';插入几条测试数据:
INSERT INTO user (id, name, age, email, version, deleted, create_time, update_time) VALUES (1, '张三', 25, 'zhangsan@example.com', 1, 0, NOW(), NOW()), (2, '李四', 30, 'lisi@example.com', 1, 0, NOW(), NOW()), (3, '王五', 28, 'wangwu@example.com', 1, 0, NOW(), NOW());5.2 编写实体类、Mapper、Service
实体类和 Mapper 前面已经展示过,这里直接看 Service:
public interface UserService extends IService<User> { } @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { }这里不需要写任何具体实现,基础 CRUD 和分页已经由 IService 提供了。
5.3 开发 Controller 接口
Controller 层是直接面向前端和外部调用的。我们可以直接基于 MyBatis Plus 提供的分页能力来写接口:
@RestController @RequestMapping("/user") public class UserController { @Resource private UserService userService; @GetMapping("/page") public Result<Page<User>> page(@RequestParam(defaultValue = "1") Integer current, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String name) { LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(name), User::getName, name) .orderByDesc(User::getCreateTime); Page<User> pageResult = userService.page(new Page<>(current, size), wrapper); return Result.success(pageResult); } @GetMapping("/{id}") public Result<User> getById(@PathVariable Long id) { return Result.success(userService.getById(id)); } @PostMapping public Result<Boolean> save(@RequestBody User user) { return Result.success(userService.save(user)); } @PutMapping public Result<Boolean> update(@RequestBody User user) { return Result.success(userService.updateById(user)); } @DeleteMapping("/{id}") public Result<Boolean> delete(@PathVariable Long id) { return Result.success(userService.removeById(id)); } }这里的Result是统一返回类型,实际项目里一般会定义一个包装类,包含状态码、消息和数据。这个和 MyBatis Plus 无关,是你项目自己的设计。
当代码写完,启动项目之后,访问GET /user/page?current=1&size=10,你将看到控制台打印出两条 SQL:
SELECT COUNT(*) AS total FROM user WHERE deleted=0; SELECT * FROM user WHERE deleted=0 ORDER BY create_time DESC LIMIT 10;注意,每个查询都自动带上了deleted=0条件,这就是前面日志里配置的逻辑删除生效的表现。
接口调用也可以直接验证一下删除:
调用DELETE /user/1,控制台打印的 SQL 是:
UPDATE user SET deleted=1 WHERE id=1 AND deleted=0;数据还在表里,但从接口结果和后续查询来看,这条记录已经"不存在"了。审计场景下,你可以随时手动把deleted改回 0 来恢复数据。
6. 我踩过的一些坑和排查思路
6.1 常见异常速查表
| 异常现象 | 可能原因 | 解决办法 |
|---|---|---|
启动报Invalid bound statement (not found) | Mapper 接口没有扫描到,或者 XML 文件的 namespace 写错 | 检查启动类的@MapperScan包路径;检查 XML 的 namespace 是否指向正确接口 |
| 插入数据主键冲突 | 主键策略用的是ASSIGN_ID,但表主键设置了自增 | 统一主键策略:要么数据库自增 +IdType.AUTO,要么雪花 ID + 表不设自增 |
| 分页查询返回全表数据 | 没有配置PaginationInnerInterceptor | 注册分页插件,且注意数据库类型要一致 |
| 逻辑删除后,自定义 SQL 查到了已删除数据 | 自定义 SQL 没有处理删除标记条件 | 在 XML 或注解 SQL 里手动加deleted = 0条件 |
| 更新不生效且无报错,影响行数为 0 | 乐观锁版本号不对 | 即更新前先查一次最新版本,或者给用户刷新页面的提示 |
6.2 一个最容易困扰新人的问题:updateById 更新为 null 的问题
updateById的默认行为是:实体类里为 null 的字段不参与更新。也就是说,如果你只设置了name和age,其余字段为 null,那么生成的 SQL 只更新name和age,不会把其他字段置空。
大多数场景这个行为是合理的。但如果你就是想更新某个字段为 null,比如把用户的email清空,updateById是做不到的。这时候要改成 UpdateWrapper:
LambdaUpdateWrapper<User> updateWrapper = new LambdaUpdateWrapper<>(); updateWrapper.eq(User::getId, 1L) .set(User::getEmail, null); userMapper.update(null, updateWrapper);这类需求很冷门,但遇到了确实容易懵,因为你不看 SQL 根本不知道 updateById 悄悄跳过了 null 字段。
6.3 自定义 SQL 和 MyBatis Plus 内置方法混用时的边界
MyBatis Plus 虽然提供了大量内置方法,但复杂查询还是得自己写 SQL。比如多表关联、子查询、复杂统计,这些场景用 Wrapper 硬拼反而不如写 XML 清晰。
我相信这种方式:
- 单表常规操作走 MyBatis Plus 内置方法
- 多表关联和报表统计走自定义 XML SQL
- 自定义 SQL 的参数传递可以用
@Param("ew")结合 Wrapper 来动态拼接条件,但这个进阶玩法对新手不友好,建议先直接用普通参数
如果你在同一个 Mapper 接口里既继承BaseMapper,又自己定义方法,那么唯一的硬性要求是:自己的方法要有对应的 XML 实现。否则启动时虽然不报错(MyBatis Plus 对非内置方法默认会尝试从 XML 找映射),但你一调用就会抛异常。
7. 一些个人经验总结
我实际用下来的体感是:MyBatis Plus 不是让你就不写 SQL 了,而是把重复的单表 SQL 从你手里解放出去,让你把精力放在那些真正复杂的查询和业务逻辑上。它的核心价值是效率,不是万能。
有一点我觉得挺重要的:如果你是团队开发,一定要在项目初期约定好 MyBatis Plus 的使用边界。哪些地方用内置方法,哪些地方必须手写 SQL,逻辑删除是全局开启还是只针对部分表,主键策略是什么,统一了规则之后,协作时才不会一个项目里出现两种风格。
用配置类里注册插件的时候,分页插件和乐观锁插件都注册在同一个MybatisPlusInterceptor里,顺序一般没有严格限制,但保持"先乐观锁、后分页"是官方文档里的推荐写法,跟着做不会有问题。
最后说一个我在实际项目里养成的习惯:所有 MyBatis Plus 内置方法执行完之后,我都会看一眼日志里的 SQL 是否正确,特别是条件有没有生效、逻辑删除条件有没有自动带上。不要嫌麻烦,SQL 错了,数据就错了,数据错了,业务逻辑再对也是白搭。这个习惯帮我抓出过不少隐蔽的 bug,很值得养成。