Spring Boot 3.x 全面升级指南——从 2.x 迁移到 3.x 的完整避坑手册
Spring Boot 3.x 全面升级指南——从 2.x 迁移到 3.x 的完整避坑手册
适读人群:有 Spring Boot 2.x 项目经验的 Java 工程师 | 阅读时长:约18分钟 | 核心价值:掌握 2.x 到 3.x 迁移全流程,避开高频坑点,让升级少走弯路
一、老李的那次升级事故
去年年底,我一个老朋友老李找我喝酒,一进门就说"老张你帮我看看,我这次快被开了"。
老李在一家中型互联网公司做架构师,带着三个人负责公司核心交易系统。年底公司要做技术升级,他拍板把主力项目从 Spring Boot 2.6 升到 3.1。升级理由充分:Jakarta EE 9、虚拟线程支持、原生编译,听起来都挺香。
结果就一个字:惨。
第一天,项目直接启动不了。javax.* 的包全部找不到,一堆 ClassNotFoundException。老李让组里小王去查,小王查了一小时,说"好像 Spring Boot 3 把 javax 改成 jakarta 了"。老李一听,好,那就全局替换,结果替换完更惨,连 Spring 自己的扩展包都炸了,因为有些第三方库内部还是用 javax,改了反而引入了不一致。
第二天,数据库相关的全挂了。Hibernate 6 的行为跟 Hibernate 5 不一样,原来能跑的 HQL 现在报错,原来隐式的 JOIN 现在要求显式写,还有几个自定义 UserType 的实现接口签名变了。
第三天,安全模块挂。Spring Security 6 的 WebSecurityConfigurerAdapter 被彻底删了,连 deprecate 都没有,就是没了。老李的安全配置全基于继承这个类,重写 configure 方法,一删,全部失效,而且不报错,静默跑通,到测试时才发现接口全部放开了……
这三天下来,老李的头发掉了一把,项目 deadline 差点崩。
我听完问他:升级前有没有看 Spring Boot 3.x 的 Migration Guide?他说看了,但没仔细看。我说那就对了,升级 Spring Boot 3 不是换个版本号这么简单,这是一次跨大版本的架构性迁移,坑点集中、影响面广,不系统梳理就是在赌。
这篇文章,我把自己经历的、帮别人排查的所有坑整理出来,给你一份完整的避坑手册。
二、升级前必须搞清楚的三个前置条件
2.1 Java 版本必须是 17+
Spring Boot 3.x 强制要求 Java 17。不是推荐,是强制。如果你的生产环境还跑在 Java 8 或 Java 11,必须先完成 JDK 升级,才能谈 Spring Boot 3 的事。
我的建议是:直接上 Java 21。Java 17 是 Spring Boot 3 的最低要求,但 Java 21 是当前 LTS,有虚拟线程(Virtual Threads)正式版,跟 Spring Boot 3.2+ 结合才能发挥最大价值。如果你现在升 Java 17,过一年大概率还要再升一次。
2.2 理解 Jakarta EE 9 命名空间变更
这是最大的变更,没有之一。
所有 javax.* 包改成了 jakarta.*:
| 旧包名 | 新包名 |
|---|---|
| javax.servlet.* | jakarta.servlet.* |
| javax.persistence.* | jakarta.persistence.* |
| javax.validation.* | jakarta.validation.* |
| javax.transaction.* | jakarta.transaction.* |
| javax.annotation.* | jakarta.annotation.* |
这个变更看起来简单,但影响是全方位的。你自己的代码改起来容易,但第三方依赖呢?如果你用了某个不再维护的库,它内部用的还是 javax,那这个库就不能和 Spring Boot 3 同时运行。
升级前必须做的事:用 Maven Dependency 插件全量扫描依赖树,找出所有还依赖 javax 的第三方库,逐一确认是否有兼容 jakarta 的新版本。
2.3 依赖版本全面对齐
Spring Boot 3 对应的关键依赖版本:
| 组件 | Spring Boot 2.7.x | Spring Boot 3.1.x |
|---|---|---|
| Spring Framework | 5.3.x | 6.0.x |
| Hibernate | 5.6.x | 6.2.x |
| Spring Security | 5.8.x | 6.1.x |
| Spring Data | 2021.x | 2023.x |
| Tomcat | 9.x | 10.x |
这些版本之间全部有 breaking change,我后面会逐一展开。
三、迁移步骤详解
3.1 第一步:升级 pom.xml
<!-- 修改 parent 版本 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.5</version>
<relativePath/>
</parent>
<!-- Java 版本改为 17 -->
<properties>
<java.version>17</java.version>
</properties>做完这一步,大概率项目直接编译不过。不要慌,这是正常的,后面一步一步修。
3.2 第二步:批量替换 javax → jakarta
用 IDE 的全局替换(注意要区分大小写、全词匹配):
javax.servlet → jakarta.servlet
javax.persistence → jakarta.persistence
javax.validation → jakarta.validation
javax.transaction → jakarta.transaction
javax.annotation.PostConstruct → jakarta.annotation.PostConstruct
javax.annotation.PreDestroy → jakarta.annotation.PreDestroy注意:javax.annotation.Resource 替换为 jakarta.annotation.Resource,但 javax.annotation.processing.*(注解处理器相关)不需要替换,它不属于 Jakarta EE 范畴。
3.3 第三步:处理 Spring Security 6 的重大变更
这个是老李踩的最大坑。Spring Security 6 完全废弃了 WebSecurityConfigurerAdapter。
旧写法(Spring Boot 2.x):
// 旧写法 - Spring Boot 2.x,3.x 直接编译报错
@Configuration
@EnableWebSecurity
public class OldSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin();
}
}新写法(Spring Boot 3.x):
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
/**
* Spring Security 6 采用函数式 Bean 定义,废弃了继承 WebSecurityConfigurerAdapter 的方式。
* 注意:antMatchers 已被废弃,改为 requestMatchers。
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
// antMatchers 已废弃,使用 requestMatchers
.requestMatchers("/public/**", "/actuator/health").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
.permitAll()
);
return http.build();
}
}3.4 第四步:处理 Hibernate 6 的变更
Hibernate 6 是个大版本,有几个高频坑:
坑1:隐式 JOIN 变成了显式要求
// Hibernate 5 允许这样写(隐式关联)
@Query("SELECT o FROM Order o WHERE o.user.name = :name")
List<Order> findByUserName(@Param("name") String name);
// Hibernate 6 需要显式 JOIN
@Query("SELECT o FROM Order o JOIN o.user u WHERE u.name = :name")
List<Order> findByUserName(@Param("name") String name);坑2:自定义 UserType 接口变了
Hibernate 5 的 UserType 接口在 Hibernate 6 中签名改了,原来实现了 nullSafeGet/nullSafeSet 方法的自定义类型全部需要重写。
package com.example.type;
import org.hibernate.engine.spi.SharedSessionContractImplementor;
import org.hibernate.usertype.UserType;
import java.io.Serializable;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.Objects;
/**
* Hibernate 6 的 UserType 接口变化较大,returnedClass() 改为泛型参数。
* 这里演示一个 JSON 字段类型的简单实现骨架。
*/
public class JsonUserType implements UserType<String> {
@Override
public int getSqlType() {
return Types.VARCHAR;
}
@Override
public Class<String> returnedClass() {
return String.class;
}
@Override
public boolean equals(String x, String y) {
return Objects.equals(x, y);
}
@Override
public int hashCode(String x) {
return Objects.hashCode(x);
}
@Override
public String nullSafeGet(ResultSet rs, int position,
SharedSessionContractImplementor session,
Object owner) throws SQLException {
return rs.getString(position);
}
@Override
public void nullSafeSet(PreparedStatement st, String value, int index,
SharedSessionContractImplementor session) throws SQLException {
if (value == null) {
st.setNull(index, Types.VARCHAR);
} else {
st.setString(index, value);
}
}
@Override
public String deepCopy(String value) {
return value;
}
@Override
public boolean isMutable() {
return false;
}
@Override
public Serializable disassemble(String value) {
return value;
}
@Override
public String assemble(Serializable cached, Object owner) {
return (String) cached;
}
}四、踩坑实录
坑1:@Autowired 循环依赖默认禁止了
现象:启动报错 The dependencies of some of the beans in the application context form a cycle,但明明 Spring Boot 2.x 跑得好好的。
原因:Spring Boot 2.6 就已经把循环依赖默认设为禁止,Spring Boot 3.x 继续延续这个策略。Spring Boot 2.5 及以前默认允许循环依赖,所以很多老项目里有循环依赖但一直没暴露。
解法:这个坑我也踩过,最省事的临时方案是在 application.yml 里加:
spring:
main:
allow-circular-references: true但这只是临时方案。我的建议是:借这次升级机会,彻底解掉循环依赖。循环依赖本身就是设计问题,通常是服务层职责划分不清。把公共逻辑抽成 Component,让 Service A 和 Service B 都依赖它,而不是互相依赖。
坑2:actuator 端点路径变了
现象:原来 /actuator/health 能访问,升级后返回 404。
原因:这不是路径变了,而是 Spring Security 6 的 requestMatchers 默认行为变了——如果你没有显式放行 actuator 端点,它会被拦截。另外部分公司会配置 management.server.port,要确认安全配置里有没有排除管理端口。
解法:
# application.yml
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when_authorized同时在 SecurityConfig 里显式放行:
.requestMatchers("/actuator/**").permitAll()坑3:Spring MVC 的 PathVariable 空值处理变了
现象:某些接口在 Spring Boot 2.x 能正常处理空字符串路径变量,3.x 里直接 404。
原因:Spring 6 对 URI 模板变量的校验更严格,不再接受空字符串匹配 {var} 模板。
解法:检查所有 @PathVariable 的接口,确保路径变量不会传入空字符串。如果业务确实需要,改用 @RequestParam 并设置 required = false。
五、依赖库兼容性速查表
我自己整理的常用库兼容情况,供参考:
| 库名 | 支持 Spring Boot 3 的最低版本 | 备注 |
|---|---|---|
| Mybatis-Spring-Boot-Starter | 3.0.0+ | groupId 未变,版本跟随 |
| MyBatis-Plus | 3.5.3.1+ | 需升级到支持 jakarta 的版本 |
| Swagger / SpringDoc | springdoc-openapi 2.x | Springfox 不兼容,必须换 SpringDoc |
| Knife4j | 4.0+ | 4.0 以下不支持 |
| Hutool | 5.8.11+ | 部分工具类需确认 |
| Sa-Token | 1.34.0+ | 官方已适配 |
| Redisson | 3.19.0+ | 支持 |
特别说明:如果你用的是 Springfox(io.springfox),这个库已经停止维护,不支持 Spring Boot 3,必须迁移到 SpringDoc(org.springdoc)。这是一次不可避免的替换。
六、升级验收清单
完成代码改造之后,按这个清单逐一验收:
七、我的迁移策略建议
对于大型存量项目,我建议采用"先升非核心模块,验证可行再推核心"的渐进式策略。
具体来说:
- 找一个低风险的边缘模块(比如消息推送服务、报表服务),先做升级试点
- 把这个模块的升级过程完整记录下来,整理出项目内的特有坑点
- 核心交易、支付、库存等模块,待试点稳定后再统一升级
- 升级过程中保持双版本并行运行,不要一次性全切
这个方法看起来慢,实际上是最快的——因为你不会在关键时刻踩到预料之外的坑。
老李后来就是用这个方法,花了三周把升级做完,没有出现生产事故。
