说实话,我对 Spring Boot 的版本升级一直是“谨慎乐观”。乐观是因为官方每次迭代确实在解决老问题,谨慎是因为哪怕只是 minor 版本,底层框架一换,存量代码里那些“看似能用”的写法就会集中暴雷。这次把我们项目从 3.3.5 升到 3.4.2,前前后后折腾了三个晚上,遇到的问题从 springdoc 起不来、分页失效,到路径 404、日志字段丢失,能复现的我全都记录在这里,后面如果继续踩到新坑我会持续往这篇里更。
先说个总印象:3.4.x 不是那种“无脑升”的版本,它对第三方生态的适配要求比 3.2、3.3 高。主要原因倒不是 Boot 本身改了多少,而是它脚下的 Spring Framework 升到了 6.2,很多第三库是按 6.1 编译的,一混用就出 NoSuchMethodError。下面我把踩过的坑按类别拆开写,每个坑都尽量说清楚原因和最终解决方式,方便你对照排查。
1. 升级之前的整体盘点和心理建设
1.1 3.4.x 的底层变化不能只看 Release Notes
很多人升级前只瞄一眼 Release Notes,看到“新增结构化日志”“依赖版本升级”就草草动手,结果改完配置重启就懵了。其实 3.4 真正的变化集中在三个底层点:
第一是 Spring Framework 6.2 对路径匹配的调整。从 Spring Framework 6.0 开始,Spring MVC 默认就切到了PathPattern,但 6.2 又把不少边界情况收紧了,尤其是“可选路径变量”和“斜杠匹配”的行为。原来用AntPathMatcher写的老接口,可能在升级后突然 404,这个我在 2.3 节会展开说。
第二是配置属性绑定的严格化。@ConfigurationProperties在 3.4 里对类型转换和集合绑定做了更强的校验。以前配置写错,比如 list 里混进一个空字符串、数字写成了字符串,可能最多打条 warning,项目照样启动;现在很多场景下直接启动失败,报ConfigurationPropertyName或ConversionFailedException错误。这其实是好事,但升级时会把存量配置里欠的“债”全翻出来,得有点心里准备。
第三是 Jackson 模块的加载顺序。3.4 对ObjectMapper的自动配置调整了不少,自定义Jackson2ObjectMapperBuilderCustomizer时如果和 Boot 内置的JavaTimeModule等模块产生重复注册,很容易出现“时间格式没变”“序列化结果和以前不一样”这类诡异问题。这个问题排查起来最费时间,因为代码没报错,只是输出变了。
1.2 升级前先给自己做一张兼容清单
我们在动手升级前做了一次盘点,强烈建议你也照着走一遍:
先把项目里直接依赖和间接依赖拉出来看一遍。重点看 springdoc-openapi、mybatis-spring-boot-starter、mybatis-plus、redisson-spring-boot-starter、hutool 这类使用频率高、又与 Spring 底层耦合深的库。用 IDE 的 Maven Dependency Hierarchy 逐个查它们是否已经发布针对 Spring Boot 3.4 的适配版本。这一步没做,后面 90% 的坑都跟它有关。
再把配置文件里所有带server.、spring.、management.前缀的配置项过一遍。3.4 对配置项的前缀命名做了一些收敛,不少老配置虽然还兼容,但已经标了 deprecated,控制台启动时会刷一堆 warn。最好开一次--debug启动,把自动配置报告和 warn 日志留底,方便升级后对比。
最后就是评估项目里用了哪些“深层 API”。如果你们用了WebMvcConfigurer做拦截器、用了RequestContextHolder、自定义了HandlerMethodArgumentResolver,升级前一定要在测试环境把相关接口全部回归一遍。这些扩展点最容易受到底层 API 签名变化的影响。
总之,别急着改版本号,先花一个小时做上面三件事,比踩完坑再回头查要划算得多。
2. 高频踩坑现场还原
2.1 坑一:springdoc-openapi 一启动就报错
我们第一个遇到的就是 springdoc。项目里用的是springdoc-openapi-starter-webmvc-ui2.6.0,升级完 Spring Boot 3.4.2 后一启动直接报NoSuchMethodError: org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping.getHandlerMethod之类的问题,当时差点以为是两个配置类冲突。
原因其实很简单:Spring Framework 6.2 改动了部分 Web MVC 内部 API,而 springdoc 2.6.x 是基于 6.1 编译的,运行期找不到旧方法,自然就炸了。这不是配置问题,是编译期字节码和运行期类库不一致。
解决方式是把 springdoc 升到 2.8.x。我们最终用的 2.8.9,这个版本明确支持 Spring Boot 3.4.x 和 Spring Framework 6.2:
<dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.8.9</version> </dependency>如果你用的是 webflux 版本,对应的springdoc-openapi-starter-webflux-ui也要同步升到 2.8.x。升级后注意/v3/api-docs和/swagger-ui.html两个路径是否正常,如果 404,多半是 Spring MVC 的静态资源映射或路径匹配策略变了,去检查有没有自定义WebMvcConfigurer对这两个路径做过 rewrite。
补充一个经验:springdoc 升级后,原先自定义的OpenAPIBean 里如果写了过时的springdoc.swagger-ui.*配置,也会被 strict 模式拦住,建议把springdoc开头的配置全部整理一遍,删掉无用的旧项。
2.2 坑二:MyBatis-Plus 分页插件失效
第二个坑比 springdoc 更难发现,因为它不报错,只是分页不生效。项目用了 mybatis-plus 的PaginationInnerInterceptor,升级后所有分页查询接口突然全部返回全量数据,一开始还怀疑是 SQL 写错了,后来才定位到是 mybatis-plus 与 Spring Boot 3.4 的自动配置顺序不兼容。
具体表现是:MybatisPlusInterceptorBean 明明在配置类里定义了,但 SQL 解析拦截器没生效。底层原因是 mybatis 相关 starter 在版本较低时,其自动配置类在 3.4 的自动配置加载链中被后置,导致拦截器在 SqlSessionFactory 创建完成后才注册。
解决方式比较直接:把 mybatis-plus starter 升到 3.5.7 以上,我们用的是mybatis-plus-spring-boot3-starter3.5.7,分页恢复。如果你是原生 mybatis,用mybatis-spring-boot-starter的话建议升到 3.0.4+。
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.7</version> </dependency>同时提醒一句,分页插件如果原来是用@Bean方式注册的,升级后最好保持原样,别改成手动 new 到 SqlSessionFactory 里,那样反而容易和 Boot 的自动配置打架。检查分页是否生效,最简单的方法是把PaginationInnerInterceptor的maxLimit设置为 100,然后调用一个本来要查全表的接口,如果结果被限制到 100 条,说明插件真正起作用了。
2.3 坑三:路径匹配规则变了,接口突然 404
这个坑对我们老项目杀伤力最大。项目里有个接口,Controller 方法签名是:
@GetMapping({"/user", "/user/{id}"}) public Result getUser(@PathVariable(required = false) Long id) { ... }在 3.3.x 上跑得好好的,升级到 3.4.2 后,访问/user/末尾带斜杠时直接 404,访问/user/123没问题。后来查了 Spring Framework 6.2 的 path matching 变更,发现PathPattern对“多模式映射 + 可选路径变量”的组合处理更严格了。以前 AntPathMatcher 对/user和/user/的处理比较宽容,现在 PathPattern 在语义上把带斜杠和不带斜杠当作不同路径,required = false的变量也就覆盖不到这种边界。
有两种处理方式。最省事的是恢复 AntPathMatcher:
spring: mvc: pathmatch: matching-strategy: ant_path_matcher但我不推荐这个方案。一是因为它只是暂时绕过,下一版本可能直接把配置项废弃;二是因为 AntPathMatcher 在性能上确实比 PathPattern 差,尤其在大量请求匹配时。
推荐的做法是改接口设计,不要让一个方法同时处理“有无变量”两种形态,干脆分成两个方法:
@GetMapping("/user") public Result getUserList() { ... } @GetMapping("/user/{id}") public Result getUser(@PathVariable Long id) { ... }如果项目里有很多类似接口,逐个改工作量太大,也可以折中一下,单独给这些旧接口用@RequestMapping(path = {"/user", "/user/{id}"}, produces = ...),但务必在测试环境把带斜杠和不带斜杠两种请求都回归一遍。这次踩坑后的体会是:接口定义越“含糊”,升级风险越高,路径匹配这种底层语义变化靠经验是防不住的。
2.4 坑四:Jackson 时间格式静默改变
第四个坑发生在接口返回值层。项目里全局配置过:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8升级到 3.4 后,LocalDateTime字段在部分接口返回值里变成了数组形式,比如[2025,1,10,15,30,0],前端直接就渲染不了了。查了半天代码,发现没有任何一处改过 JavaTimeModule,最后在自动配置报告里看到JacksonAutoConfiguration的优先级变化,才知道是 Boot 对spring.jackson.*的绑定时机做了调整,自定义定制器和内置模块的执行顺序和以前不一致。
最稳妥的修法是不再依赖全局配置,而是显式注册一个Jackson2ObjectMapperBuilderCustomizer:
@Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder -> { builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss"); builder.timeZone(TimeZone.getTimeZone("GMT+8")); builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); builder.deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }; }这样做的好处是明确控制序列化行为,不受 Boot 内部 Bean 顺序影响。如果你的项目里已经有自定义ObjectMapper的 Bean,要特别小心别和这个 Customizer 同时存在,否则会出现 Customizer 不执行的问题。踩坑之后我建议团队里定一个规范:全局时间格式一律用LocalDateTimeSerializer处理,不要依赖spring.jackson.date-format这种“看起来改了但实际管不到 JSR310”的配置。
3. 结构化日志与可观测性:新功能背后的坑
3.4 把结构化日志定成了标配功能,这个方向是好的,但引入新功能的同时也带来了一些和旧体系冲突的坑。
3.1 启用 ECS 格式后的第一个坑
项目接入日志平台时,我看到 3.4 支持原生结构化日志,想着能少引一个 logstash-logback-encoder,就把配置改成了:
logging: structured: format: console: ecs启动后控制台确实变成了 JSON 格式,输出也符合 Elastic Common Schema,但日志采集 agent 在服务端却收不到任何新日志。排查后才发现,生产环境采集器读的是日志文件,不是控制台,而我只配了console,文件输出还是纯文本。
正确的做法是同时配置 file:
logging: structured: format: console: ecs file: ecs这里有个细节:这个配置的值可以是ecs、logstash、gelf,分别对应 Elastic Common Schema、Logstash JSON 格式和 Graylog Extended Log Format。如果你的日志平台用的不是 ES,可以按需换成logstash或gelf。
另外,如果项目里原本就有自定义的logback-spring.xml,里面定义了自己的 ConsoleAppender 和 pattern,结构化日志的 console 配置会失效。因为 Boot 的结构化日志接管的是它自己创建的 appender,不是你自定义的那个。我们后来把自定义的 appender 停掉,统一交给 Boot 管理才恢复正常。这个取舍要想清楚,用了原生结构化日志,就别再叠一层 logstash-encoder,两套体系很容易冲突。
3.2 traceId 等了半天,日志里却没有
微服务项目都习惯在日志里加 traceId。升级到 3.4 后,我遇到一个问题:通过X-Trace-Id传入的 traceId,在同步接口里能正常打到日志,但一走到异步线程、@Async、或者消息队列消费端,traceId 就丢了。
原因在于 3.4 中日志关联 traceId 依赖 Micrometer Tracing 的Observation上下文,而 MDC 里的 traceId 需要手动从 TraceContext 传递。异步场景下,子线程的 MDC 默认是空的,自然就没了。
解决方式是在跨线程时手动把 traceId 塞进子线程的 MDC。我们用了一个简单的包装器,在执行异步任务前做了设置:
public class TraceIdWrapper { public static <T> Callable<T> wrap(Callable<T> task) { Map<String, String> context = MDC.getCopyOfContextMap(); return () -> { if (context != null) { MDC.setContextMap(context); } try { return task.call(); } finally { MDC.clear(); } }; } }然后在ThreadPoolTaskExecutor提交任务时,或者@Async自定义执行器里调用这个 wrapper。如果你用的是 Spring 的TaskDecorator,那是更标准的做法,直接在Executors配置里塞这个装饰器就行。这个坑不深,但排查起来很迷惑,因为同步日志正常、异步日志丢失,看起来就像代码没生效。
3.3 日志脱敏和自定义 filter 的兼容问题
我们项目里有很多手机号、身份证号要脱敏,以前用 Logback 的replace规则:
<pattern>%d{yyyy-MM-dd HH:mm:ss} %replace(%msg){'(\d{3})\d{4}(\d{4})', '$1****$2'}%n</pattern>升级后这个 replace 规则在某些情况下直接不执行了。后来才明白,结构化日志格式下,日志输出不是走 pattern 拼字符串,而是由 JSON encoder 处理,自然也不会去解析你写在 pattern 里的 replace 标签。
解决方式是改成自定义转换器,或者在 JSON 字段输出前,用程序里的脱敏工具统一处理日志内容。我们最终采用的是后者,在写入日志前先调用一个DesensitizedUtils.maskJson()方法,把敏感的 JSON 字段值做脱敏再打日志。虽然麻烦一点,但比依赖日志框架的 replace 规则更可靠,而且结构化日志下也能稳定生效。
这里给个建议:如果你已经上了结构化日志,尽量把“脱敏”从日志框架层面挪到业务代码或切面层面,越早处理越稳妥。日志框架的这些文本替换功能更适合本地调试,不适合生产结构化采集。
4. 测试与构建链路上的细碎问题
升级过程中还有一类问题躲不开:测试跑不起来,构建老失败。这些问题虽然不直接影响线上运行,但特别耗时间。
4.1 @SpringBootTest 上下文加载慢、配置类不生效
升级后第一次跑集成测试,整体耗时从原来的 40 秒涨到了将近两分钟,而且有些@TestConfiguration里定义的 Bean 在某些测试类里不生效。一开始以为是机器问题,后来发现和 3.4 的上下文缓存机制有关。
Spring Boot 3.4 对上下文缓存的 key 计算变得更细了,比如@MockBean、@ActiveProfiles、@DynamicPropertySource的 static 方法,任何一个属性变化都会导致缓存失效,从而重新加载上下文。所以测试类之间如果配置差异大,整体耗时就会明显上升。
排查下来,最影响的是@DynamicPropertySource。我们有个测试类漏写了static关键字,在 3.3 上还能跑,3.4 直接给你抛异常,提示这个方法必须是 static。这个错误不是升级后才有的,但 3.4 明确把它变成强制要求。改法就是老老实实加 static:
@DynamicPropertySource static void props(DynamicPropertyRegistry registry) { registry.add("server.port", () -> "0"); }要降低测试耗时,可以尽量复用同一个 Spring 上下文:把相同配置的测试类放在同包同 properties 下,少用@MockBean,多在src/test/resources里放统一的application-test.yml。我们把每个测试类上那些冗余注解清理了一下,耗时就降回正常水平了。
4.2 MockMvc 测试拿不到预期 JSON
单元测试里用MockMvc调用接口,明明接口返回正常,但andExpect(jsonPath("$.code").value(200))一直失败,响应体打印出来却是空。这个坑也是升级后出现的,最后发现是ObjectMapper的序列化行为变了,某些场景下MappingJackson2HttpMessageConverter没有正确注册进 MockMvc。
我是这样排查的:先把响应体打印出来:
mockMvc.perform(get("/api/user")) .andDo(print()) .andExpect(status().isOk());打印结果里能看到 HTTP 状态正常,但 body 为空。这说明问题出在 message converter 上,而不是业务代码。解决方案是在测试类里显式注入 ObjectMapper 并配置 MockMvc:
@Autowired private ObjectMapper objectMapper; mockMvc = MockMvcBuilders .webAppContextSetup(context) .addConverter(new MappingJackson2HttpMessageConverter(objectMapper)) .build();如果你是直接用@SpringBootTest加@AutoConfigureMockMvc,还有另一个小坑:3.4 中 Jackson 的FAIL_ON_EMPTY_BEANS默认值有变化,某些只有一个 getter 的 DTO 在序列化时直接抛异常,导致响应体为空。解决办法是检查 DTO,把所有没有字段的 getter 去掉,或者在配置里重新允许空 Bean 序列化。我个人倾向于修 DTO,别为了测试开空序列化,线上容易漏数据。
4.3 Maven 插件、JDK 版本、mainClass 的三角关系
构建链路是升级时最容易忽视又最容易报错的部分。我们的 CI 机器上同时装了 JDK 8、11、17,升级到 3.4 后,maven 打包时一直报“无效的目标发行版”,因为默认的maven.compiler.source/target还是 1.8。Spring Boot 3.4 最低要求 Java 17,这个问题必须清楚根因:不是 Spring Boot 不能跑在 JDK 17 上,是你的 Maven 编译参数还在用老版本 JDK 级别。
建议直接在 pom 里统一配置:
<properties> <java.version>17</java.version> <maven.compiler.release>17</maven.compiler.release> </properties>注意用maven.compiler.release而不是 source/target,它更能保证编译和运行期 JDK API 一致,避免出现“编译过了、跑起来 NoClassDefFoundError”的情况。
另一个坑是 spring-boot-maven-plugin 在 3.4.x 下重新打包时,偶尔找不到启动类,报Unable to find main class。一般情况下它会从Start-Class属性推断,但如果你用了多模块且 parent 配置比较复杂,最好显式指定 mainClass:
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <mainClass>com.example.Application</mainClass> </configuration> </plugin>这个配置在开发阶段看着多余,但到了 CI 上能省很多时间。如果你们用 gradle 构建,思路也一样,bootJar时显式指定主类:
tasks.named('bootJar') { mainClass = 'com.example.Application' }5. 常见问题排查速查表与验收清单
5.1 升级适配版本速查表
下面这个表是这次升级后我们沉淀下来的适配版本对照,直接照着填依赖基本不会踩大坑:
| 组件 | 推荐版本 | 备注 |
|---|---|---|
| Spring Boot | 3.4.2+ | 3.4.0 刚发布时有些小问题,建议直接用 3.4.2 以上 |
| springdoc-openapi | 2.8.x | 2.6.x 在 Spring Framework 6.2 下会报 NoSuchMethodError |
| mybatis-spring-boot-starter | 3.0.4+ | 兼容 3.4 自动配置链 |
| mybatis-plus-spring-boot3-starter | 3.5.7+ | 分页插件失效会发生在低版本 |
| redisson-spring-boot-starter | 3.28+ | 3.27 之前版本在 6.2 下可能遇到兼容问题 |
| hutool | 5.8.27+ | 注意旧版反射工具方法签名问题 |
| spring-kafka | 3.2.x | 和 3.4 的 Observability 集成更顺 |
这个表只是参考,最终版本建议以官方 release 页面为准。团队升级时建议先开一个分支,把依赖统一升到这些版本后跑一遍集成测试,不要一个一个试,否则容易像我们一样,把时间耗在“依赖之间互相不匹配”的连环坑里。
5.2 启动排查手段速查表
遇到启动环节的各种问题,按这张表做基本能快速定位:
| 现象 | 排查手段 | 常见根因 |
|---|---|---|
| 启动报 NoSuchMethodError | mvn dependency:tree先看间接依赖版本 | 第三方库版本基于旧 Spring Framework 编译 |
| 启动失败绑定异常 | 看--debug自动配置报告 | 配置项类型或 list 值非法 |
| 部分接口 404 | 检查是否开启spring.mvc.pathmatch | 路径匹配策略差异 |
| JSON 序列化异常 | 打印响应体或开启 Jackson debug | 模块加载顺序/空 Bean 序列化 |
| 日志格式不对 | 检查结构化日志同时配置 file 和 console | 只配置了 console 导致采集不到线上日志 |
这些手段里最推荐的就是启动加--debug。不是让它跑更慢,而是让自动配置报告把所有“条件不成立、配置被忽略”的原因全打出来,比翻源码高效太多。
5.3 升级后的验收清单
最后建议按下面这个清单做一遍回归,别等到上线前才手忙脚乱:
- 所有分页查询接口,确认没有出现“全量返回”情况,尤其是用了分页插件的老接口。
- 时间格式相关的接口,特别是含 LocalDateTime/LocalDate 字段的,逐一比对升级前后的返回值。
- 路径不规范的接口,比如末尾斜杠、双斜杠、可选变量的场景,全部请求一次。
- 带有自定义拦截器的接口,确认拦截器注册成功,不出现 404 或 500。
- 日志采集链路,确认结构化日志的字段名在日志平台能正确检索,traceId 在异步链路中正常。
- 测试环境跑一次完整集成测试,观察上下文加载耗时是否有异常增长。
- CI 构建产物的启动日志,确认 mainClass 正确、JDK 版本无误。
我个人的习惯是,每次升级完把项目里所有带 3.x 版本的第三方依赖全部搜一遍,用 dependency hierarchy 做一次体检,然后再看启动过程中的 warn 日志。很多时候坑在警告里已经写了一半答案了。这篇我会持续更新,后面如果再碰到其他值得记录的 3.4.x 问题,我会直接补充进来,希望能帮你少走点弯路。