先说明一个现实问题:五分钟只是一个理想化目标,真正卡时间的地方往往不是创建项目,而是版本不对、依赖冲突、扫描路径漏配这三个深坑。我自己是从 SpringBoot2 升级上来的,当时被javax换jakarta的改动坑了一整个下午;后来在新项目里直接上 SpringBoot3 + MyBatis-Plus,顺手把遇到的坑、合理的配置、以及实际跑通的步骤全整理了一遍。这篇文章就是那套方案的完整沉淀,适合刚接触 SpringBoot3 的新手,也适合正在做版本升级的老手——照着走一遍,五分钟搭出一个能跑 CRUD 的基础工程,是完全可以做到的。
1. 搭建前的准备:SpringBoot3 到底改了什么
1.1 版本硬门槛:Java 17 起步,Jakarta 命名空间
SpringBoot3 第一个要命的改动就是把基线提升到了 Java 17。这意味着如果你本机还在用 JDK8 或者 JDK11,IDE 里直接新建 SpringBoot3 项目都会报错,更别说编译运行了。我在升级时遇到的第一问题就是本机同时装了好几个 JDK,而 IDE 默认用的是老版本,结果启动类一跑就提示UnsupportedClassVersionError。解决办法很简单:确认 IDE 里 Project SDK 选的是 17+,File -> Project Structure -> SDK里不能只看 Platform Settings,还要看 Modules 的 Language Level。
第二个改动是javax.*全面迁移到jakarta.*。这是 Java EE 改名 Jakarta EE 之后的必然结果,SpringBoot3 底层完全切换到 Jakarta EE 9 规范。实际影响就是你代码里凡是 importjavax.servlet、javax.annotation这类包的地方,都要改成jakarta.servlet、jakarta.annotation。如果是新项目问题不大,毕竟不会主动引这些老包;但如果是从 SpringBoot2 升上来的老项目,这一步会牵扯到很多第三方库的兼容性,比如老的 Shiro、老的 Redis 客户端、某些内部封装框架,都得逐个验证是否支持 Jakarta。
1.2 MyBatis-Plus 的“3”后缀为什么这么关键
MyBatis-Plus 对 SpringBoot3 的支持是单独发的 starter,artifact 是mybatis-plus-spring-boot3-starter,不是以前那个mybatis-plus-boot-starter。这个区分很重要,因为 3.5.3 之前 MyBatis-Plus 只基于 SpringBoot2 的自动配置机制编写,直接硬搬进 SpringBoot3 会出现两个典型症状:一是启动时提示找不到SqlSessionFactory,二是@MapperScan扫描到了接口但注入时提示没有 Bean。我当时在网上搜到一堆答案,大部分都在说加@MapperScan注解或者检查 Mapper 接口路径,其实根子上就是 starter 版本不对。
我建议新项目直接选 MyBatis-Plus 3.5.5 或更高版本,比如 3.5.7。3.5.3.x 虽然也支持 SpringBoot3,但有些边缘配置还需要手工处理;3.5.5 开始整体稳定了很多,分页插件、乐观锁插件、逻辑删除这些常用功能都没再出过幺蛾子。另外要提醒一句:MyBatis-Plus 的版本号和 MyBatis 的版本号不是强绑定的,但如果你单独引了mybatis或者mybatis-spring,一定要和 MyBatis-Plus 内部依赖的版本保持兼容,最稳妥的做法就是只引 MyBatis-Plus 全家桶,不要自己额外加 MyBatis 依赖。
2. 实操核心:从空目录到能跑通 CRUD 的完整流程
2.1 创建项目与依赖选型
我习惯直接用 Spring Initializr 生成骨架。打开 start.spring.io ,Project 选 Maven,Language 选 Java,Spring Boot 版本选 3.x 的最新稳定版(写这篇文章时 3.3.x 已经是 mainstream,3.4.x 也出了,不过我会选 3.3.x 做示范,因为它经过了大半年生产验证)。
| 关键项 | 推荐值 | 说明 |
|---|---|---|
| JDK | 17 或 21 | 17 是底线,21 是 LTS 但有些内网环境没装 |
| 依赖 | Spring Web | 提供 REST 接口能力,CRUD 演示必备 |
| 依赖 | Lombok | 简化实体类样板代码,注意 JDK17 要配新版本 Lombok |
| 依赖 | MySQL Driver | 连数据库用的驱动,版本由 SpringBoot 统一管理 |
| 手动引入 | mybatis-plus-spring-boot3-starter | Initializr 里没有,需要自己写进 pom.xml |
我一般会在 Initializr 里先勾上 Spring Web 和 MySQL Driver,Lombok 也顺手勾上,然后生成项目压缩包下载解压。打开pom.xml加入核心依赖:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.7</version> </dependency>这里有个细节:为什么要单独手动引?因为 Spring Initializr 只收录官方和部分知名第三方依赖,MyBatis-Plus 不在初始列表里。版本号建议去 Maven Central 查最新稳定版,不要用RELEASE这种写法,构建才可复现。
2.2 数据源与 MyBatis-Plus 配置
配置这块我踩过一次比较隐蔽的坑:以为 SpringBoot3 用了新的配置机制,就把spring.datasource的配置项也改了,结果数据源初始化直接失败。实际上spring.datasource.url、username、password这些根本没变,变化的是连接池的默认选型。SpringBoot3 默认使用 HikariCP,这个池子性能好、出错少,我不建议新手为了“统一技术栈”特意换成 Druid——除非你确实需要 Druid 的监控面板,否则 Hikari 够用且少一个维护点。
在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&useSSL=false&allowPublicKeyRetrieval=true username: root password: your_password mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0逐行解读一下:
driver-class-name必须是com.mysql.cj.jdbc.Driver,如果是旧项目残留的com.mysql.jdbc.Driver,在 MySQL 8+ 驱动下直接报 ClassNotFound。serverTimezone=Asia/Shanghai解决时区差 8 小时的问题,allowPublicKeyRetrieval=true解决 MySQL 8 的 caching_sha2_password 认证报错。map-underscore-to-camel-case默认值其实已经是 true,但我喜欢写出来,让后来接手的人一眼看得懂。log-impl配成StdOutImpl是演示阶段最好的选择,每个 SQL 都会打印在控制台;生产环境务必删掉或改成Slf4jImpl,否则日志噪音太大。
有人说type-aliases-package不配置也行,确实可以,但配了之后,Mapper XML 里写resultType和parameterType就不再需要写全限定类名,能省很多事。我建议项目一创建就配好,别等 XML 多了再补。
2.3 实体类与 Mapper:MyBatis-Plus 的省力核心
建一张最简单的用户表:
CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `name` VARCHAR(50) DEFAULT NULL, `age` INT DEFAULT NULL, `email` VARCHAR(100) DEFAULT NULL, `deleted` TINYINT NOT NULL DEFAULT 0, `create_time` DATETIME DEFAULT NULL, `update_time` DATETIME DEFAULT NULL, PRIMARY KEY (`id`) );实体类写法:
@Data @TableName("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("user")不加的话默认会映射到user表,但加上的好处是表名如果带前缀,比如t_user、sys_user,你就不用去全局配置里改映射规则。@TableId(type = IdType.ASSIGN_ID)是 MyBatis-Plus 推荐的雪花算法主键,它不是数据库自增,而是应用层生成分布式 ID,在多库分表场景下不会撞号。如果表是单表单库、没有分库分表需求,用数据库自增配合IdType.AUTO更自然,可以按需选择,但要保证代码和表定义一致。
@TableLogic是逻辑删除注解,加了之后 MyBatis-Plus 的deleteById不会执行物理 DELETE,而是自动改成 UPDATE 把deleted置为 1。所有查询语句也会自动追加deleted = 0条件。前提是 yml 里logic-delete-field配好了,或者注解写在字段上,两者配一个就行。
Mapper 接口极简:
@Mapper public interface UserMapper extends BaseMapper<User> { }BaseMapper是 MyBatis-Plus 打包好的万能实现,里面有selectById、selectList、insert、updateById、deleteById等十几个常用方法,你几乎不用写一行 SQL。如果条件查询复杂,可以用它内置的QueryWrapper/LambdaQueryWrapper,也可以用自定义 XML,两种都在这个结构里走得通。
这里有个注解选择的细节:可以在启动类上加@MapperScan("com.example.demo.mapper"),也可以在每个 Mapper 接口上单独加@Mapper。我习惯两种都写上:启动类扫描保证多模块项目不会漏,Mapper 上写@Mapper是为了让 IDE 和代码阅读者一眼识别这是 MyBatis 的 Mapper,而不是普通接口。
2.4 Service 与 Controller:标准三层直出
MyBatis-Plus 也提供了 Service 层的封装,继承ServiceImpl后可以少写大量模板代码:
public interface UserService extends IService<User> { } @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { }这样list()、getById()、save()、updateById()、removeById()这些方法直接就有,不需要自己在 Service 里再包一层 Mapper 调用。有的团队认为 Service 必须自己写业务逻辑,不能完全依赖IService封装,这个观点我部分认同——复杂业务当然要写定制方法,但基础的 CRUD 入口没必要重复造轮子,留出鉴权、参数校验、业务规则校验的扩展点就够了。
Controller 示例:
@RestController @RequestMapping("/user") @RequiredArgsConstructor public class UserController { private final UserService userService; @GetMapping("/{id}") public User getById(@PathVariable Long id) { return userService.getById(id); } @GetMapping("/list") public List<User> list() { return userService.list(); } @PostMapping public User save(@RequestBody User user) { userService.save(user); return user; } @PutMapping public User update(@RequestBody User user) { userService.updateById(user); return user; } @DeleteMapping("/{id}") public boolean delete(@PathVariable Long id) { return userService.removeById(id); } }@RequiredArgsConstructor来自 Lombok,配合private final做构造器注入。Spring 官方推荐构造器注入是有道理的:字段多了以后能避免@Autowired的循环依赖隐患,也方便写单元测试的时候手工 new 出来传 mock。
到这里,一个最简的 SpringBoot3 + MyBatis-Plus 工程已经能启动了。启动类默认长这样:
@SpringBootApplication @MapperScan("com.example.demo.mapper") public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }我在本地测试时用 Postman 模拟请求,访问GET /user/list会返回空数组(因为表里没数据),先 POST 一条数据,再 GET 一次,数据就出来了。整个链路:Controller -> Service -> Mapper -> MySQL,跑通即代表工程骨架正常。
3. 让工程真正“能上线”:分页、自动填充与逻辑删除
3.1 分页插件:不配置等于白搭
MyBatis-Plus 的分页能力不是内置默认开启的。很多新手在 SpringBoot2 时代可能用过PageHelper,切到 MyBatis-Plus 后发现selectPage方法返回的数据不分页、把全表都查回来了,原因就是没注册分页拦截器。
配置方式如下:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }DbType.MYSQL必须写对。因为分页插件要生成对应数据库的方言 SQL,比如 MySQL 用LIMIT ? OFFSET ?,PostgreSQL 用LIMIT ? OFFSET ?但语法细节不同,Oracle 还要包一层ROWNUM。写错数据库类型,分页 SQL 在特定场景下可能会报错。
分页方法示例:
@GetMapping("/page") public IPage<User> page(@RequestParam(defaultValue = "1") long current, @RequestParam(defaultValue = "10") long size) { return userService.page(new Page<>(current, size)); }IPage里带了records、total、current、size几个字段,前端做表格分页组件时直接绑数据就行,非常省事。我实际项目中还会把分页结果统一封装成 ApiResponse,把records和total拆出来,这样前端拿数据结构的成本更低。
3.2 字段自动填充:创建时间和更新时间别再手动 set
我见过太多项目里每写一条 insert 就要手动setCreateTime(new Date()),漏一个就出现脏数据。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)或FieldFill.INSERT_UPDATE。这套组合的好处是:插入时自动写两个时间,更新时只更新updateTime,代码里彻底删掉手动 set 时间的逻辑。
这里注意一个容易搞混的点:strictInsertFill的第二个参数是实体字段名,不是数据库列名。如果你的实体字段叫createTime,数据库列叫create_time,这里照样写createTime。MyBatis-Plus 会按照驼峰映射规则转成列名,写错了就填充不进去,但不报错,非常隐蔽。
3.3 逻辑删除:不只删数据,查数据也要过滤
前文提到的@TableLogic已经在实体类里写了,这里再补充一个实际业务里的坑:逻辑删除字段如果参与查询,ID 也要考虑唯一性。比如订单表里逻辑删除了订单,但主键 ID 又复用了物理 ID 的生成策略,那历史数据和新数据就会出现主键冲突。我一般建议逻辑删除表的主键使用雪花 ID,或者业务上接受这种冲突,否则就要引入“唯一业务号”字段。
global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0只要配置了logic-delete-field,且实体中 naming 能对上,MyBatis-Plus 会自动对查询语句加过滤条件。我特意反复提醒:如果你在 XML 里写了自定义 SQL,比如<select id="selectUserList">SELECT * FROM user WHERE name = #{name}</select>,这个 SQL 不会自动追加逻辑删除条件。要么在 XML 里手动写AND deleted = 0,要么使用 MyBatis-Plus 的@Select注解并套用其条件构造器。这是一个很容易被忽略的“半自动”特性。
3.4 代码生成器:值不值得用
MyBatis-Plus 有一个代码生成器,可以一键生成实体、Mapper、Service、Controller。如果你看过官方文档,会发现配置它甚至比手写还累——需要写一大堆自定义模板策略。我的态度是:单表 CRUD 不值得为它搭一套环境,但如果你的项目里有二十张以上的表,并且团队里所有新人都按同一套模板生成代码,它能保证风格统一,这时候收益就起来了。
如果你真要用,注意生成器依赖版本的兼容性,尽可能与我们使用的 MyBatis-Plus 核心版本保持一致,避免生成的代码里引用了错的@TableId包名。我遇到过一次生成出来的实体 import 是com.baomidou.mybatisplus.annotation.IdType,但项目里依赖的版本把这个类移到别的包,编译直接报错。
4. 高频问题与排查思路速查
4.1 启动失败类
| 报错关键字 | 常见原因 | 解决方向 |
|---|---|---|
UnsupportedClassVersionError | JDK 版本低于 17 | 项目 SDK 和 Language Level 切到 17+ |
ClassNotFoundException: javax.servlet.* | 依赖还停留在 javax 时代 | 找出旧包升级或替换为 jakarta 版本 |
Failed to configure a DataSource | 没配数据源或配置项拼错 | 检查 spring.datasource 下四个核心配置 |
Invalid bound statement (not found) | Mapper XML 没被扫描或 namespace 不对 | 检查 mapper-locations 路径和 namespace |
Error creating bean with name 'sqlSessionFactory' | starter 版本不对 | 换成 mybatis-plus-spring-boot3-starter |
Cause: java.lang.IllegalArgumentException: jdbcUrl is required | SpringBoot 数据源属性名写错 | 确认是 url 不是 jdbcUrl,是 username 不是 user |
这里单独拎出Invalid bound statement说一下。SpringBoot2 老项目里常见的是 mapper.xml 放在src/main/java目录下但没在 pom.xml 里加<resources>配置,导致编译后 XML 没有复制到 classpath。SpringBoot3 下我建议直接把 XML 放在src/main/resources/mapper/下,这样默认就打包进去了,省掉额外配置。
4.2 运行期逻辑类
| 现象 | 常见原因 | 解决方向 |
|---|---|---|
selectPage不过滤,全表返回 | 没注册分页插件 | 配置 MybatisPlusInterceptor + PaginationInnerInterceptor |
insert报主键重复 | IdType.AUTO和数据表自增不一致 | 统一主键策略 |
| 时间字段为空或都是当前时间 | 自动填充注解和 Handler 没配全 | 检查 fill 属性和 MetaObjectHandler 是否被扫描 |
| JSON 返回的 LocalDateTime 格式怪异 | Jackson 默认序列化不是业务想要的格式 | 配置spring.jackson.date-format或定义全局 ObjectMapper |
| 逻辑删除后 count 计数不对 | XML 自定义 SQL 没加过滤条件 | 手动补 deleted 条件或用 Wrapper |
LocalDateTime序列化问题我多讲一句。SpringBoot 默认用 Jackson 序列化 Java 时间类型,输出是数组格式,比如[2025,1,10,14,30,0],前端拿到完全没法直接显示。最简单的处理是在 yml 里加:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8但实测发现date-format只对java.util.Date生效,对LocalDateTime无效。这里要么为LocalDateTime单独配jackson-datatype-jsr310和格式注解,要么在字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),我推荐字段注解方案,因为它是显式且局部可控的,不会影响全局其他类型。后来项目里统一封装了LocalDateTimeSerializer/LocalDateTimeDeserializer并注册到 Jackson,但前期快速搭建阶段,字段注解已经够用。
4.3 我踩过的几个进阶坑
先说 Lombok 和 JDK17 的兼容性。Lombok 老版本比如 1.18.20 在 JDK17 下会直接报 “java.lang.ExceptionInInitializerError”,卡在编译阶段。SpringBoot3 的依赖管理里如果带了 Lombok 版本,基本是给 JDK17 适配过的 1.18.30 以上,一般不会有问题;但老项目升级时如果 pom 里锁了 Lombok 老版本,记得升级。我自己在新工程里显式写:
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.32</version> <scope>provided</scope> </dependency>scope=provided很重要,Lombok 是编译期注解处理器,不需要打包进最终 Jar。
再说 Mapper XML 的 namespace。MyBatis-Plus 的@Mapper注解已经帮忙注册了 Mapper 接口,但 XML 文件里的namespace必须和接口全限定名完全一致,否则运行时会报BindingException。这个错误经常在拷代码时发生,接口改名了 XML 忘记同步,排错 20 分钟是常有的事。
还有一个小众但典型的坑:SpringBoot3 + MyBatis-Plus 使用Page对象时,getTotal()有时候返回 0,原因是分页对象没在 Interceptor 里被正确拦截。排查方法很简单——看控制台日志里分页拦截器有没有打印==> Preparing: SELECT COUNT(*) FROM user。如果没有这行,说明项目里同名类冲突了,比如你自己从别的包 import 了一个Page,而不是com.baomidou.mybatisplus.extension.plugins.pagination.Page。IDE 自动 import 非常容易选错,这个问题我在团队里帮人看过不止三四次。
5. 后续还能怎么扩展
工程搭建只是第一步,实际项目里你可能马上要面对认证授权、统一异常处理、参数校验、多数据源这些问题。我个人的扩展顺序建议:先补统一返回体和全局异常,再上参数校验;然后根据需要接 Redis 做缓存,最后才是复杂权限框架。这些不是搭建阶段的事,但提前知道方向,搭数据库表结构时就能少走弯路。
把工具链做个收束:IDEA 装 MyBatisX 插件,它能从数据库表反向生成实体、Mapper 和 XML,效率比代码生成器高不少;再装一个 RestfulTool 或者直接用 Postman 测试接口。这两个辅助工具对新手非常友好,可以大大压缩调试时间。
提示:如果你用的是 IDEA 社区版,需要注意它没有自带 Spring Initializr 集成,可以手动去 start.spring.io 下载工程包再导入,也可以用 Maven 的
archetype生成。两种方式效果一样,不用强求 IDE 版本。
最后分享一个我个人操作上的习惯:项目刚创建完,第一件事不是急着写业务代码,而是先启动一次空工程,确认 Boot 本身能起、数据库能连。这个“先冒烟再开发”的做法能帮你把环境问题聚焦在最早期,而不是等写完几十行代码后再在层层堆叠里找根因。我每次开新项目都这么做,团队里新人也沿用这个流程,被环境问题卡住的概率明显低很多。这套 SpringBoot3 + MyBatis-Plus 的架子,我在多个外包项目、公司内部中台项目中都完整跑过,稳定性和后续维护性都可以放心。