简介:一份面向Java后端开发者的Spring Boot与MyBatis-Plus整合实践资源。资源围绕企业级应用快速开发场景,展示如何利用Spring Boot的自动配置与起步依赖,以及MyBatis-Plus的CRUD接口、代码生成、分页与逻辑删除等特性,帮助读者摆脱繁琐配置和重复的持久层代码,专注业务逻辑实现。
包体共2000个文件、60.87MB,以1689个Java源文件为主体,同时包含146个XML映射或配置、12个YAML环境配置、99个Shell辅助脚本等,覆盖了从项目结构搭建、数据访问层编写到环境部署的完整链路。目录中可见common、dev等多环境模块划分,适合对照学习分层设计与多环境配置。
目前已有436人浏览学习,适合初识Spring Boot整合MyBatis-Plus的开发者,也可作为团队内部工程脚手架参考,直接用于快速搭建具备数据库操作能力的基础项目。
1. Spring Boot + MyBatis-Plus:先看它到底替你省了什么
做过几个接口就得手动写一条 SQL 的老项目,再回头看 Spring Boot 配 MyBatis-Plus,最直接的体感是「原来写增删改查也能这么快」。这套组合现在是 Java 服务端最主流的落地姿势之一:Spring Boot 负责把应用跑起来,MyBatis-Plus 负责把数据访问层的重复劳动砍掉大半。它不是什么新框架,而是在 MyBatis 基础上做的增强工具,内置通用 Mapper、通用 Service、分页插件、代码生成器,能覆盖日常开发里八成的单表 CRUD 场景。如果你正在搭新项目、接手老项目要改造数据层,或者单纯想找一个既保留 MyBatis 灵活性、又不用天天写 XML 的折中方案,这套组合值得你花一个下午完整跑一遍。本文用的是某电商后台的模拟项目X,集成 Spring Boot 2.x 与 MyBatis-Plus 3.5.x,把从建表、配置、CRUD 到分页、条件构造器、自动填充、乐观锁、代码生成器的全链路都走一遍,顺带把最容易翻车的地方指出来。
2. 从零集成:依赖、配置与第一张表的成功映射
2.1 依赖引入的版本坑
很多初学者第一步就栽在版本上。Spring Boot 2.x 和 MyBatis-Plus 3.x 的兼容性整体稳定,但如果你直接用最新 Spring Boot 3.x,MyBatis-Plus 就必须上 3.5.3 以上的版本,否则启动直接报ClassNotFoundException。我一般习惯用 Spring Boot 2.7.x 搭配 MyBatis-Plus 3.5.3.x,这套组合在绝大多数生产环境里跑得最稳。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>逻辑说明:这里用mybatis-plus-boot-starter而不是mybatis-spring-boot-starter,因为前者已经内置了对 MyBatis 的自动配置,不需要你额外引入 MyBatis 本体。Lombok 是可选的,但配合实体类能少写一堆 getter/setter。参数说明:mybatis-plus的版本号建议锁定具体版本,不要用RELEASE或LATEST,否则后期升级 Spring Boot 时容易连带出问题。
2.2 核心配置:逻辑删除、驼峰映射与 id 策略
集成 MyBatis-Plus 之后,配置文件里最值得花心思的是mybatis-plus这一节。很多人默认不关驼峰映射,但如果你数据库字段用的是下划线风格,而实体类是驼峰,必须让map-underscore-to-camel-case处于开启状态,MyBatis-Plus 默认是开的,但如果你同时手动配了mybatis的配置文件,就有可能在某个角落把它覆盖掉。
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/simulate_mall?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password 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: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0逻辑说明:id-type: auto表示主键用数据库自增,配合 MySQL 的AUTO_INCREMENT最直观。logic-delete-field是逻辑删除字段的全局配置,声明之后,所有实体里如果有deleted字段,删除操作会自动变成UPDATE ... SET deleted = 1,查询条件里也会自动加AND deleted = 0。参数说明:log-impl建议开发期打开StdOutImpl,你能在控制台看到每次执行的 SQL 和参数,排查问题比盲猜快得多。注意:全局逻辑删除字段一旦配置,所有表都要求有deleted列,如果某张表确实不需要,得在该实体字段上用@TableLogic(value = "0", delval = "1")单独设计,或者干脆在全局配置里留空、只在需要的实体上注解。
2.3 实体类映射:@TableName 与 @TableId 的边界
实体类映射看似简单,但有几个边界经常让人困惑。@TableName用于把实体映射到指定表名,但如果你表名和实体名正好是驼峰转下划线的一致关系(比如UserInfo->user_info),这个注解可以不写。@TableId也有类似规则,默认按id字段去找主键列。
@Data @EqualsAndHashCode(callSuper = false) @TableName("user") public class User { @TableId(value = "id", type = IdType.AUTO) private Long id; private String username; private String email; private Integer age; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; @TableLogic private Integer deleted; }逻辑说明:@TableId的type只影响插入时的主键生成策略,AUTO表示依赖数据库自增,插入后 MyBatis-Plus 会把回填的主键值赋回实体。@TableField(fill = FieldFill.INSERT)表示插入时自动填充该字段。参数说明:deleted字段加了@TableLogic后,如果全局配置里没有指定逻辑删除字段名,就以注解为准,两者同时存在时注意别冲突。这里最容易踩的坑是:实体类里出现了deleted但数据库表里没有列,启动时可能不会报错,但一旦执行查询,SQL 里会自动带上deleted = 0,数据库直接报字段不存在。
2.4 第一张表的 CRUD:BaseMapper 如何做到零 SQL
映射没问题之后,写一个继承BaseMapper<User>的接口就可以跑 CRUD 了。这是 MyBatis-Plus 最直观的价值点:不需要写 Mapper XML,不需要写@Select注解,通用方法直接可用。
@Mapper public interface UserMapper extends BaseMapper<User> { }使用示例:
@Service public class UserService { private final UserMapper userMapper; public UserService(UserMapper userMapper) { this.userMapper = userMapper; } public void demo() { // 插入 User user = new User(); user.setUsername("test_user"); user.setEmail("test@example.com"); user.setAge(28); userMapper.insert(user); System.out.println("insert id: " + user.getId()); // 按主键查询 User queried = userMapper.selectById(user.getId()); // 按条件更新 User update = new User(); update.setId(user.getId()); update.setAge(29); userMapper.updateById(update); // 删除 userMapper.deleteById(user.getId()); } }逻辑说明:insert之后主键被自动回填,selectById按主键查回实体,updateById只更新非 null 字段,deleteById如果配置了逻辑删除会自动变成逻辑删。参数说明:updateById是「按主键更新非 null 字段」,如果你某个字段想更新为 null,默认做不到,需要额外用UpdateWrapper的set("field", null)或者在实体字段上加@TableField(updateStrategy = FieldStrategy.IGNORED)。实际开发中,单表 CRUD 到这里已经能满足大部分需求,但复杂查询就要请出条件构造器了。
3. 条件构造器的正确姿势:QueryWrapper 与 LambdaWrapper 的选择
3.1 为什么推荐 LambdaWrapper
QueryWrapper用字符串列名,LambdaWrapper用方法引用。两者功能几乎一样,但 Lambda 写法在编译期就能发现字段名错误,而字符串写错了只能等运行期报错。对于团队协作项目,我强制自己用LambdaQueryWrapper,不是为了花哨,是为了减少低级的字段名拼写错误。
public List<User> findUsers(String keyword, Integer minAge, Integer maxAge) { LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery(); wrapper.like(StringUtils.hasText(keyword), User::getUsername, keyword) .ge(minAge != null, User::getAge, minAge) .le(maxAge != null, User::getAge, maxAge) .orderByDesc(User::getCreateTime); return userMapper.selectList(wrapper); }逻辑说明:like的第一个参数是 boolean 条件,只有满足时才拼接该条件,这是条件构造器最实用的能力——再也不用写if判断然后wrapper.and(...)。ge和le分别对应>=和<=。参数说明:orderByDesc直接接受方法引用,排序字段清晰可读。这里有个隐含的边界:like默认在首尾都加%,如果你的业务要求前缀匹配或后缀匹配,要用likeRight或likeLeft,否则一旦数据量大,这种全模糊查询很容易吃不到索引。
3.2 复杂查询:select 列裁剪与 group by
很多人在条件构造器上只用到 where 条件,忽略了select方法。当表字段很多而我们只需要两三个字段时,select能显著减少传输量和实体映射开销。
public List<User> getUserStatistics() { QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.select("age", "count(*) as cnt") .groupBy("age") .having("count(*) > {0}", 1) .orderByDesc("cnt"); return userMapper.selectMaps(wrapper).stream() .map(map -> { User user = new User(); user.setAge((Integer) map.get("age")); return user; }).collect(Collectors.toList()); }逻辑说明:selectMaps返回的是List<Map<String, Object>>,因为聚合查询结果无法映射到实体,MyBatis-Plus 约定用 Map 承接。having的写法要注意,虽然它接受字符串,但建议用占位符{0}传参,避免 SQL 注入风险。参数说明:selectMaps返回的 Map 里 key 是你写的别名,比如这里的cnt,取值时注意类型。如果非要把聚合结果映射成实体,也可以在实体里加一个@TableField(exist = false)的cnt字段,然后select("age", "count(*) as cnt")配selectList就能用实体接收。
3.3 分页查询的正确接线方式
分页是 MyBatis-Plus 的招牌功能,但很多人配置完发现分页失效,查出来还是全量数据。原因基本只有一个:没有注册分页插件。MyBatis-Plus 3.x 的分页是通过拦截器实现的,不配置拦截器的话,Page对象只会被当作查询参数传递,SQL 不会被改写成分页语句。
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }public Page<User> pageUsers(int current, int size, String keyword) { Page<User> page = new Page<>(current, size); LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery(); wrapper.like(StringUtils.hasText(keyword), User::getUsername, keyword); return userMapper.selectPage(page, wrapper); }逻辑说明:PaginationInnerInterceptor指定数据库类型,它会根据Page的页码和条数自动生成LIMIT语句。selectPage返回的Page对象里包含records、total、current、size等信息。参数说明:current从 1 开始,不是 0。前端传页码时注意转换。另一个容易被忽略的点:分页插件对多表 join 查询也起作用,但total的计算方式可能和你预期不一致,它默认执行一条COUNT(*)查询,如果你 join 的表有关联字段,效率会低一些。
3.4 一个容易踩的坑:UpdateWrapper 与实体混用
更新操作最容易出问题的场景是「既要更新某些字段,又要带条件更新某些字段」。很多人会用updateBatch或者循环单条更新,性能很差。正确做法是用UpdateWrapper。
public void updateEmailByAge(String newEmail, Integer minAge) { LambdaUpdateWrapper<User> wrapper = Wrappers.lambdaUpdate(); wrapper.set(User::getEmail, newEmail) .ge(User::getAge, minAge); userMapper.update(null, wrapper); }逻辑说明:第一个参数传 null,表示不依赖实体,所有字段都由wrapper控制。这种方式生成的 SQL 只更新email字段,并且带WHERE age >= ?。参数说明:set既可以传固定值,也可以传 SQL 表达式,比如set("views_count", "views_count + 1"),这种场景下用QueryWrapper反而更直观,因为 Lambda 写法在字段上做自增时没有字符串灵活。注意:混用实体非 null 字段和 wrapper 条件时要特别小心——实体的非 null 字段会被全部拼接进 SET 子句,容易多更新字段而不自知。
4. 自动填充与乐观锁:消除重复代码的常规手段
4.1 自动填充:创建时间与更新时间
在很多项目里,create_time和update_time是每张表都有的字段。MyBatis-Plus 提供了自动填充机制,让你不用在每个insert和update前手动 set。
@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()); } }逻辑说明:strictInsertFill只会填充实体中标记了FieldFill.INSERT且当前值为 null 的字段。strictUpdateFill填充FieldFill.INSERT_UPDATE或FieldFill.UPDATE的字段。参数说明:注意一个细节,如果实体字段穿了自定义类型,比如LocalDateTime,这里必须和实体字段类型一致,否则填充失败且不会报错,非常隐蔽。实际投入中,我遇到过有人把实体字段定义成String类型,存时间字符串,然后这里用LocalDateTime.class,结果每次插入都拿不到填充值。
4.2 乐观锁:注解与插件的配合
并发更新场景下,乐观锁是防止「丢失更新」的常见手段。MyBatis-Plus 的实现方式是:更新时自动带上版本号字段校验,如果版本号不对则更新失败。
@Data @TableName("product") public class Product { @TableId(type = IdType.AUTO) private Long id; private String name; private Integer stock; @Version private Integer version; }@Bean public MybatisPlusInterceptor optimisticLockerInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; }public boolean deductStock(Long id, Integer delta) { Product product = productMapper.selectById(id); if (product == null || product.getStock() < delta) { return false; } Product update = new Product(); update.setId(product.getId()); update.setStock(product.getStock() - delta); update.setVersion(product.getVersion()); return productMapper.updateById(update) > 0; }逻辑说明:@Version标记版本字段。更新时 MyBatis-Plus 会自动生成UPDATE ... SET stock = ?, version = version + 1 WHERE id = ? AND version = ?。updateById返回值是 0 说明版本冲突或者记录不存在。参数说明:OptimisticLockerInnerInterceptor必须和分页拦截器一起配置在同一个MybatisPlusInterceptor里。这里有一个容易踩的坑:如果实体里的version是 null,updateById不会启用乐观锁,而是直接按 id 更新,等于乐观锁失效。所以插入时一定要给版本号设初值,比如 0。
4.3 常见问题:为什么自动填充没生效、乐观锁失效
现象一:create_time插入后是 null。
原因:实体字段的@TableField(fill = FieldFill.INSERT)没写,或者MetaObjectHandler里填的字段名和实体属性名不一致。
解决:检查注解是否挂在正确的字段上,且strictInsertFill的字符串名必须和实体属性名完全一致。
现象二:乐观锁更新总是不成功。
原因:更新前 select 出来的version和数据库不一致,或者 service 里手动设置了version,导致 where 条件对不上。
解决:确保更新对象里的version是从数据库查出来的,不要自己 new 一个再往里塞。如果需要重试,可以用循环包一层,最多重试三次。
现象三:自动填充的时间比系统时间慢了 8 小时。
原因:数据库连接的serverTimezone没配,或者配成了UTC。
解决:数据源 URL 里用serverTimezone=Asia/Shanghai,同时确认应用服务器时区也是东八区。
5. 代码生成器与自定义 SQL:把最后一点重复劳动也干掉
5.1 代码生成器:不再手写实体、Mapper、Service
当表多起来之后,手写实体和 Mapper 就变成纯体力活。MyBatis-Plus 的代码生成器可以一次性生成实体、Mapper、Service、ServiceImpl、Controller。基于 3.5.x 版本的生成器写法比较简洁。
public class CodeGenerator { public static void main(String[] args) { FastAutoGenerator.create("jdbc:mysql://localhost:3306/simulate_mall", "root", "password") .globalConfig(builder -> builder.author("dev").outputDir("src/main/java")) .packageConfig(builder -> builder.parent("com.example.mall")) .strategyConfig(builder -> builder.addInclude("user", "product", "order_item")) .execute(); } }逻辑说明:FastAutoGenerator是 3.5.x 的新 API,链式配置比旧版简洁。addInclude指定要生成的表,不指定就生成全库。生成结果会覆盖同名文件,所以在已经改过代码的类上重新生成时要小心。参数说明:outputDir是生成文件的根目录,parent是包名基础路径。默认会生成 entity、mapper、service、serviceImpl、controller 五层。实际使用时,我倾向于关闭 controller 层的生成,因为接口设计往往跟表结构不完全一致,如果业务复杂,controller 生成出来还得大量手改,不如只生成到 service。
5.2 自定义 SQL:保留 MyBatis 的灵活姿势
MyBatis-Plus 覆盖了单表 CRUD,但多表 join、复杂子查询、动态报表还是要写手写 SQL。这块和原生 MyBatis 的用法完全一样,在 Mapper 接口里加方法,在 XML 里写语句即可。
public interface OrderMapper extends BaseMapper<Order> { List<OrderItemVO> selectOrderDetail(@Param("orderId") Long orderId); }<select id="selectOrderDetail" resultType="com.example.mall.vo.OrderItemVO"> SELECT o.id AS order_id, o.order_no, o.total_amount, i.product_id, i.quantity, i.price FROM order o LEFT JOIN order_item i ON o.id = i.order_id WHERE o.id = #{orderId} </select>逻辑说明:手写 SQL 时,返回类型可以是 VO 类,只要 VO 属性名和列别名对得上。#{orderId}是预编译参数,没有 SQL 注入风险。参数说明:如果返回多行,方法返回List<OrderItemVO>;如果返回单行,直接返回OrderItemVO,但是要保证 SQL 只查出一条,否则会报TooManyResultsException。这里要注意命名空间和接口路径一致,否则启动时会报绑定错误。
5.3 代码生成器的常见翻车点
现象一:生成的实体没有@TableName注解,当成表名不一致。
原因:默认情况下,生成的实体类名是按表名直接转换的,user生成User,表名和类名一致时可以不用注解。
解决:如果表名不清楚规则,直接在生成策略里加tablePrefix,比如t_开头,生成时会自动去掉前缀并转驼峰。
现象二:生成的ServiceImpl里有if (true) { throw new ServiceException("...") }这种占位逻辑。
原因:代码生成器默认生成的保存方法有重复校验逻辑,但只是占位。
解决:删掉那几行无用代码,按业务重写。
现象三:重新生成时把手动改过的代码覆盖。
原因:生成器不会询问,直接覆盖同名文件。
解决:给生成器加自定义模板,或者只在新建表时执行生成,老表不重新生成。
6. 从 demo 到实践:验证你的 MyBatis-Plus 真的没配错
6.1 一个完整的自检清单
每次搭完一个集成环境,我一般会跑下面这五步快速验证,而不是直接写业务代码。
第一步,启动应用,观察控制台有没有出现MybatisPlusAutoConfiguration相关的日志,确认 Starter 被加载。
第二步,打开debug或者trace日志级别,检查 SQL 打印。确保每一条通用 CRUD 语句都带上了逻辑删除条件(如果有deleted字段)。
第三步,写一个最简单的selectList(null)测试,看能否返回数据。这一步能验证数据源、表映射、驼峰转换是否都正常。
第四步,跑一次分页查询,重点看LIMIT语句是否生成,以及total是否正确。
第五步,检查控制台输出的 UPDATE 语句,确认乐观锁的 SQL 里带了version = ?条件。
@SpringBootTest public class MybatisPlusSmokeTest { @Autowired private UserMapper userMapper; @Test void testPage() { Page<User> page = userMapper.selectPage(new Page<>(1, 2), Wrappers.lambdaQuery().ge(User::getAge, 18)); Assertions.assertTrue(page.getTotal() > 0); Assertions.assertEquals(2, page.getRecords().size()); } }逻辑说明:SpringBootTest会拉起完整 Spring 容器,如果你的配置有问题,这步就会炸出来。page.getRecords().size()如果是 0,说明 SQL 有问题或者数据没插进去。参数说明:new Page<>(1, 2)第一参数是当前页,第二参数是每页条数。
6.2 几个值得收藏的调参习惯
MyBatis-Plus 的配置项不少,但真正值得在生产环境认真调的就那几个。
db-config.id-type如果项目是分库分表,建议改成ASSIGN_ID(雪花算法)。一旦表的主键不是自增,用auto就是灾难。
configuration.map-underscore-to-camel-case在 MyBatis-Plus 3.x 里默认是 true,但如果你的 Mapper XML 使用了resultType而不写resultMap,注意列别名一定要和实体属性一致。
global-config.db-config.logic-delete-value用的值,我见过有人配成true或字符串,但数据库字段是tinyint(1),这没问题;如果字段是int,1和0最稳妥,不要用true和false。
分页插件的maxLimit可以设置单页最大条数,防止有人传入一次查 10 万条把数据库打崩:
PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination);参数说明:maxLimit是单页最大上限,超过就抛异常,属于保护性质设置。实际项目里我通常会配这个参数,尤其接口对公网开放时,这个设置能拦住一部分恶意请求。
6.3 真正的最后一步:检视 SQL 与索引
MyBatis-Plus 帮你生成了 SQL,但没帮你生成索引。条件构造器写得很爽,但一旦数据量上来,就会发现所有查询都全表扫。常见做法是,每次写完一个wrapper,就去数据库执行EXPLAIN看是不是走了索引。比如like查询开头不写%,才能使用索引。
EXPLAIN SELECT id, username, email FROM user WHERE age >= 18;如果看到type = ALL或者rows特别大,就需要检查age字段上有没有建索引。条件构造器的ge、le、eq都尽量用上索引字段,like如果是%xxx%那基本只能全扫,这种情况下要么改业务,要么用全文索引。
从那以后,我每次接一个新模块,都强制自己走一遍这个流程:先搭好 Starter 并跑通增删改查,再配置逻辑删除和自动填充,然后写分页和条件构造器,最后用代码生成器把重复的表结构代码批量打出来。这套流程熟练之后,一个包含 5 张表的后台模块,从建表到能提交测试,基本控制在半天以内。真正花时间的,反而是那些不起眼的边界配置:分页插件没注册、逻辑删除字段没同步、乐观锁版本号没赋值。希望这些踩过的坑能帮你省掉几小时的排查时间。
本文还有配套的精品资源,点击获取