做Java后端这几年,我几乎每个项目都要跟MyBatis打交道。用得越久越觉得,单表CRUD这部分的开发量是真的冗余:写XML、配ResultMap、写通用增删改查SQL,翻来覆去就是那几句,既枯燥又容易出低级错误。后来团队里引入了MyBatis-Plus,说白了就是MyBatis的一个增强工具,不改动MyBatis本身,但把单表CRUD、分页、逻辑删除这些高频操作全部封装好了,开发效率直接提了一个档次。这篇文章就围绕 MyBatis-Plus快速上手 这个主题,把我实际项目里的配置方式、常用API、容易踩的坑一次讲清楚。如果你是刚接触Java后端、想搭一个能跑起来的MyBatis-Plus项目,或者已经在用但有些功能没吃透,这篇内容应该能给你省下不少折腾时间。
1. 为什么我建议先搞清楚MyBatis-Plus解决了什么问题
1.1 它不是替代MyBatis,而是把重复劳动接过去
很多人第一次听MyBatis-Plus,容易误以为它是一个全新的ORM框架。实际上,它的底层还是MyBatis,只是在你和MyBatis之间多了一层封装。核心思路很简单:把单表操作的通用逻辑抽取成BaseMapper,把SQL条件构建抽取成Wrapper条件构造器,你只需要定义实体类和方法签名,框架自动帮你生成SQL并执行。
举个例子,以前用MyBatis做一个用户表的列表查询,你得写Mapper接口、写XML文件、写resultMap、写动态SQL,一个功能少说也要几十行配置。用MyBatis-Plus之后,Mapper接口继承BaseMapper,直接调用selectList、selectById、insert、updateById,零SQL就能完成绝大部分操作。对于中小型项目来说,80%的数据库操作都是单表CRUD,这部分一旦机械化,整个后端的开发速度提升会非常明显。
当然,多表联查、复杂报表、需要数据库方言优化的SQL,MyBatis-Plus也不会拦着你继续手写XML。它和原生的MyBatis可以共存,你完全可以在同一个项目里,一部分用通用Mapper,一部分保留自定义XML,互不干扰。
1.2 与JPA、原生MyBatis的取舍逻辑
我在选型时经常被问到,为什么不用Spring Data JPA?我的看法是:JPA的学习曲线更陡,关联映射、懒加载、持久化上下文这些东西,新手很难在短时间内用对,用错了还会产生性能隐患。MyBatis-Plus则更贴合国内团队的开发习惯——SQL是显式的,IO是可控的,出了问题也容易排查。它不像JPA那样试图帮你屏蔽SQL,而是帮你生成那些你本来就会写的SQL,让代码量变少,SQL依然透明。
和原生MyBatis相比,MyBatis-Plus牺牲了一点点定制空间,换取了极大的开发效率。比如分页,原生MyBatis要引入PageHelper或者手写拦截器;MyBatis-Plus自带分页插件,一行配置就能用。再比如逻辑删除,原生MyBatis要在每一条SQL后面手动加deleted = 0条件,漏一个地方就会查出脏数据;MyBatis-Plus直接注解声明,全局自动拼接条件。
用一句话总结我的取舍标准:如果你的项目以单表操作为主、追求快速交付,MyBatis-Plus是首选;如果你们的SQL极其复杂且团队对原生MyBatis已经很熟,那也不必强行迁移,但至少值得在后续新模块里试点。
2. 快速搭建与第一个可运行的CRUD
2.1 依赖和基础配置
我用的是Spring Boot 3.x + MyBatis-Plus 3.5.x的组合,Java版本17。先在pom.xml里引入核心依赖:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>接下来在application.yml里配置数据源和MyBatis-Plus的参数。这里有几个关键配置项值得解释一下:
spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 id-type: assign_id mapper-locations: classpath*:/mapper/**/*.xmlmap-underscore-to-camel-case控制下划线字段到驼峰属性的自动映射,一般建议打开,省去大量ResultMap配置。logic-delete-field声明逻辑删除字段后,删除操作会自动变成update,查询会自动过滤已删除记录。id-type我习惯用assign_id,也就是雪花ID,避免依赖数据库自增在分库分表场景下的局限。
2.2 实体类与通用Mapper的最小实现
假设要建一个用户表,实体类这么写:
@Data @TableName("sys_user") public class User { @TableId(type = IdType.ASSIGN_ID) private Long id; private String name; private Integer age; private String email; @TableLogic private Integer deleted; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }@TableName指定表名,如果实体类名和表名不一致就必须加。@TableId声明主键生成策略。@TableLogic配合前面配置的逻辑删除字段,让实体类里的deleted属性自动参与所有SQL的拼接。@TableField(fill = ...)配合后面的MetaObjectHandler实现自动填充。
Mapper接口就极简了,直接继承BaseMapper:
@Mapper public interface UserMapper extends BaseMapper<User> { }没有XML,没有方法。这样一个Mapper已经拥有了insert、deleteById、updateById、selectById、selectList、selectPage等20多个方法。我第一次用的时候都不太习惯,心里总嘀咕“这就完了?”,实际跑一次测试才发现,框架真的把这些方法都实现好了。
2.3 写一个Controller验证链路
为了确认配置没问题,我习惯先写一个最简Controller跑通整个链路:
@RestController @RequestMapping("/user") public class UserController { @Resource private UserMapper userMapper; @PostMapping public User create(@RequestBody User user) { userMapper.insert(user); return user; } @GetMapping("/{id}") public User get(@PathVariable Long id) { return userMapper.selectById(id); } @GetMapping("/list") public List<User> list() { return userMapper.selectList(null); } @PutMapping public User update(@RequestBody User user) { userMapper.updateById(user); return user; } @DeleteMapping("/{id}") public boolean delete(@PathVariable Long id) { return userMapper.deleteById(id) > 0; } }启动项目后用接口调试工具测试一遍,插入一条记录,再查出来看看字段映射是否正确。这里我建议一定要把log-impl配成StdOutImpl,可以在控制台看到MyBatis-Plus自动生成的SQL。第一次跑的时候我看着日志里自动生成的INSERT INTO sys_user (id, name, age, email, create_time, update_time) VALUES (...),突然就理解了“代码生成器式开发”是什么意思——再往后写新模块,新增表只需要实体类+Mapper两个文件,CRUD接口几乎不需要动脑。
3. 条件构造器是MyBatis-Plus的核心武器
3.1 QueryWrapper和LambdaQueryWrapper怎么选
单表CRUD只是开胃菜,真正让MyBatis-Plus好用的是条件构造器Wrapper。它是一个链式调用的查询条件封装类,把where条件变成了Java代码。需要明确区分QueryWrapper和LambdaQueryWrapper:
QueryWrapper用字符串指定字段名,写法简单,但字段名一旦修改不会编译报错,重构容易漏。LambdaQueryWrapper用方法引用指定实体属性,类型安全,IDE重构友好,我强烈推荐在生产代码里用它。
先看一段实际查询示例:
LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery(); wrapper.eq(User::getName, "张三") .like(User::getEmail, "@qq.com") .between(User::getAge, 18, 30) .orderByDesc(User::getCreateTime); List<User> users = userMapper.selectList(wrapper);这段代码生成的SQL大致是:
SELECT id,name,age,email,deleted,create_time,update_time FROM sys_user WHERE deleted=0 AND name = '张三' AND email LIKE '%@qq.com%' AND age BETWEEN 18 AND 30 ORDER BY create_time DESC注意,逻辑删除条件被自动加上了。Wrapper会自动感知实体类上的@TableLogic注解,不需要你在where里手动带deleted字段,这比手写SQL可靠得多。
3.2 动态条件的经典写法
实际业务里最常见的场景是,查询条件可能是空的,比如用户传入name为空就不查name,传入age为空就不查age。如果手写SQL,通常要拼一堆if标签。用Wrapper可以这样处理:
public List<User> search(String name, Integer minAge, Integer maxAge) { LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery(); wrapper.eq(StringUtils.hasText(name), User::getName, name); wrapper.ge(minAge != null, User::getAge, minAge); wrapper.le(maxAge != null, User::getAge, maxAge); return userMapper.selectList(wrapper); }eq方法第一个参数是boolean条件,只有条件为true时才会拼接这个条件。这样就彻底告别了一堆if判断,代码干净多了。另外还有几个高频方法我提一下:in用于集合查询,like和likeRight控制模糊匹配的方向,last可以直接追加SQL片段但要注意SQL注入风险,apply可以写原生条件片段。
3.3 分页查询的完整配置
MyBatis-Plus的分页不是开箱即用的,必须配置分页插件拦截器。如果不配,调用selectPage会发现分页参数无效,返回的结果没有自动加limit。配置代码如下:
@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 = Wrappers.lambdaQuery(); wrapper.orderByDesc(User::getCreateTime); Page<User> result = userMapper.selectPage(page, wrapper); long total = result.getTotal(); List<User> records = result.getRecords();这里有一个特别容易被坑的地方:如果分页查询需要连表,用selectPage可能不会自动处理JOIN。我当时的处理方案是自写分页SQL,在XML里自己写查询语句,然后用IPage作为参数传入,拦截器会解析SQL帮我们追加limit。这种模式下你需要自己确保SQL里有正确的COUNT查询条件,或者直接设置optimizeCountSql来让框架优化count语句。
分页插件的maxLimit建议设置一个上限,防止有人恶意传超大pageSize把数据库打崩。这个限制防的不是攻击者,而是防止前端误传一个pageSize=100000导致数据库全表扫描。
4. 这些常用功能配置好了能省很多事
4.1 自动填充:让创建时间和更新时间自己填
很多表都有create_time和update_time字段,以前每次insert都要手动set一遍。MyBatis-Plus的自动填充机制可以解决这个问题。第一步在实体字段上加注解:
@TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime;第二步实现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()); } }这样之后,任何insert操作都不用手动设置创建时间了。需要注意的是,strictInsertFill在字段为null时才会填充,如果你的业务里需要强制覆盖已有值,要用setFieldValByName。
4.2 乐观锁:并发更新不丢数据
电商库存、任务状态这类场景,并发更新很容易出现丢失更新问题。MyBatis-Plus的乐观锁插件用起来非常简单。先配置插件,在之前的拦截器里加一行:
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());实体类加版本字段:
@Version private Integer version;数据库表也要有version字段,默认值0。更新时,前端需要传version值,MyBatis-Plus生成的update SQL会是:
UPDATE sys_user SET name='新名字', version=version+1 WHERE id=1 AND version=0如果执行期间version被其他事务改过,影响行数为0,说明更新失败,程序里可以捕获这个结果做重试或提示用户。这个机制理解起来就像抢锁:谁先更新成功,版本号就变了,后来的人拿着旧版本号自然抢不到。
4.3 代码生成器:新模块半小时起步
对于那种“建一张表就要写一个模块”的重复工作,MyBatis-Plus官方提供了代码生成器,可以根据数据库表直接生成实体、Mapper、Service、Controller、XML。生成的代码风格统一,节省了机械劳动。
我以前带项目时,最开心的就是让新人用代码生成器生成模块代码,然后专注去写业务逻辑,而不是在那里敲setter和getter。代码生成器的核心配置大概是:
FastAutoGenerator.create("jdbc:mysql://localhost:3306/demo", "root", "123456") .globalConfig(builder -> builder.author("开发者").outputDir("项目路径")) .packageConfig(builder -> builder.parent("com.example.demo")) .strategyConfig(builder -> builder.addInclude("sys_user")) .execute();需要说明的是,生成的代码只是基础骨架,Service层默认可能只提供一些通用方法,具体业务规则还得自己实现。如果你项目里业务简单,生成的代码基本够用;业务复杂的话,建议把生成器当作脚手架,不要完全依赖它。
4.4 逻辑删除到底怎么用才安全
逻辑删除是MyBatis-Plus最常用的功能之一,但用不好也有隐患。我的建议是:
- 全局配置逻辑删除字段后,所有实体的删除都会变成update deleted=1,查询自动加deleted=0。
- 不要在实体里同时手写
deleted=0条件,容易和自动拼接重复,虽然结果不错但SQL看着很乱。 - 唯一索引要特别注意。逻辑删除后数据还在表里,如果业务字段有唯一约束,比如用户名唯一,删除一个用户后再插入同名用户就会触发唯一约束冲突。这时候常见方案有:用户名加后缀(如删除时间)、或者放弃逻辑删除直接物理删除,看业务对恢复数据的需求。
5. 实操中高频踩坑与排查技巧实录
5.1 分页不生效,排查三步走
selectPage返回的记录是全表,total也不对,这一般是分页插件没生效。排查步骤我整理成了流程:
- 确认pom里有
mybatis-plus-boot-starter依赖,版本3.5以上。 - 确认配置类里注册了MybatisPlusInterceptor并且包括PaginationInnerInterceptor。
- 确认没有在配置类上漏掉
@Configuration注解。
最隐蔽的问题是,项目里可能同时存在多个拦截器Bean,导致注册顺序异常。我在一个老项目里就遇到过:项目原来有自定义MyBatis拦截器做数据权限过滤,它先执行了,分页插件后执行,结果count SQL被数据权限条件污染。解决办法是调整拦截器顺序,或者在分页插件里配置setOverflow等参数规避。
5.2 字段映射失败,属性全是null
如果查询结果里部分字段是null,先在控制台看生成的SQL,确认查询列名。如果SQL列名是user_name而实体属性是userName,那就需要检查map-underscore-to-camel-case是否开启。还要注意:@TableField注解里也可以显式指定列名:
@TableField("user_name") private String userName;这种方式更直接,不容易受全局配置影响。
5.3 SQL注入与特殊查询条件的安全性
MyBatis-Plus的条件构造器本身做了参数预编译,但有两个地方容易出问题:
last()和apply()方法如果直接拼接外部传入的SQL片段,存在注入风险。- 排序字段如果直接用字符串传入
orderByColumn,可能被拼成ORDER BY 某个SQL。
我处理排序需求时,一般会维护一个白名单Map,把前端传的排序字段映射到实体属性,不在白名单的直接用默认排序。这样既安全又能保持灵活性。
5.4 逻辑删除字段也参与唯一约束的坑
前面提到逻辑删除和唯一约束的冲突,这里展开讲一个实际案例。某次做账号管理,表结构里username有唯一索引,用户注销走的是逻辑删除。测试人员发现,删除一个账号后立刻重新注册同一个用户名,系统报Duplicate entry。排查后发现,逻辑删除只是把deleted置1,username的记录还在表里,唯一索引同样生效。
最终方案是把唯一索引改成复合唯一索引(username, deleted)。但这样也有个问题:如果同一个用户名被多次逻辑删除,第二次删除时还是会冲突,因为存在(username, 1)和新的(username, 1)两条记录。更稳妥的做法是,在逻辑删除时更新username为原用户名_deleted_主键ID,保证每次删除后username都是唯一值。这个经验是踩过坑之后总结出来的,分享给遇到相同问题的人参考。
5.5 批量操作性能优化
MyBatis-Plus自带insertBatchSomeColumn之类的方法需要通过注入SQL注入器实现,常规的saveBatch方法在服务层其实是一条条insert的,数据量大时性能并不理想。我建议在批量导入场景下,自己拼批插入SQL,或者在XML里写foreach的批量插入。
另外,大批量查询时注意selectList会把所有记录加载到内存,如果数据量几万条以上,建议使用分页或selectObjs流式处理。在项目中我见过因为一次selectList查了十万条数据导致老年代内存飙高的情况,排查到后来发现就是这种无意识的滥用。
6. 上手过程中的心得总结
用MyBatis-Plus做了三个项目之后,我的一个明显感受是:新人的培训成本降低了很多。以前带新人用原生MyBatis,光讲动态SQL的标签、resultMap映射就要半天,现在用MyBatis-Plus,只要他会写SQL、看得懂Lambda表达式,基本看一遍代码就能上手。团队里代码风格也变得统一,CRUD代码不在因为个人写法不同而产生差异。
不过我还是要强调一点:MyBatis-Plus解决的是单表CRUD问题,它不能替代你理解SQL和数据库设计的能力。如果你连SQL都写不利索,用再多的增强工具也写不出高性能的多表查询。建议在实际项目中这样组合使用:
- 新建项目优先集成MyBatis-Plus,节省基础开发时间。
- 复杂查询依旧放到XML里手写SQL,把MyBatis-Plus的分页能力用于查询封装。
- 认真维护实体类注解,尤其是逻辑删除、乐观锁、自动填充这些全局能力,它们一旦配置错影响范围极大。
按照我上面的方式,半天之内你就能有一个完整的、可以跑起来的MyBatis-Plus模块。后面遇到具体问题,再根据报错日志去调整。工具永远是工具,用顺手了,效率自然就上来了。