从 3.3.5 升到 3.5.3,踩了四个坑
八月底我们把手上 11 个服务从 Spring Boot 3.3.5 升到了 3.5.3。原本计划两天搞定,实际花了九天,中间回滚了一次。这篇记录升级过程和四个值得说的坑。
先说结论:值得升,但别在业务高峰期前升。ServiceConnection 和结构化日志这两项对我们的日常开发效率提升是实打实的,SSL 热重载则解决了一个困扰很久的运维痛点。
为什么要升
不是追新。三个具体原因:一是 3.3.x 的 OSS 支持快到了,安全团队在催;二是我们想在测试环境用 Testcontainers 的 ServiceConnection 替代那套自研的 docker-compose 起测试库脚本(那个脚本每次改都要同步两份配置);三是运维同学抱怨证书轮换要重启服务,我们希望用上 3.5 的 SSL 热重载。
坑一:spring-boot-starter-parent 的插件版本冲突
升级第一步就卡住了。改完 <parent> 版本号,mvn clean package 直接报错:
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-checkstyle-plugin:3.4.0:check
[ERROR] Execution default of goal ...maven-checkstyle-plugin:3.4.0:check failed:
[ERROR] An API incompatibility was encountered while executing ...:
[ERROR] java.lang.NoSuchMethodError: 'java.lang.String
com.puppycrawl.tools.checkstyle.api.Violation.getSeverityName()'
[ERROR] realm = plugin>org.apache.maven.plugins:maven-checkstyle-plugin:3.4.0
原因是我们自己在 pluginManagement 里锁了 checkstyle 插件版本,而 3.5 的 parent 把 puppycrawl 的版本提上去了,两边对不上。解决方式很直接——删掉自己锁的版本,让 parent 管:
<!-- 删掉这段,交给 spring-boot-starter-parent 统一管理 -->
<!--
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.4.0</version>
</plugin>
-->
教训是:升级大版本前先看看自己锁了多少插件版本。我们 11 个项目里一共锁了 23 处,其中 6 处和 3.5 的 parent 冲突。用 mvn help:effective-pom 对比一下新旧 pom 能快速定位。
ServiceConnection:测试环境配置终于统一了
这是我最喜欢的一个特性。以前测试要用真实数据库,得在 application-test.yml 里写一套地址端口,还得保证 CI 机器上 docker-compose 起好了对应的容器。现在:
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfig {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgres() {
return new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("order_test")
.withReusable(true); // 复用容器,本地开发快很多
}
@Bean
@ServiceConnection("redis")
GenericContainer<?> redis() {
return new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
}
}
容器起来后,Spring Boot 自动把 spring.datasource.url、username、password 这些属性注入进去,测试代码里一行配置都不用写。我们删掉了 11 个项目里的 application-test.yml,一共 340 行配置。
withReusable(true) 这句很关键。不开的话本地每次跑测试都要重新拉起容器,我们的集成测试从 47 秒变成 12 秒。注意它需要在 ~/.testcontainers.properties 里加 testcontainers.reuse.enable=true。
踩到的小坑:Redis 那个 GenericContainer 必须显式指定 @ServiceConnection("redis") 的名字,自动推断对 GenericContainer 不生效。我们排查了半小时才发现连接串没注入。
SSL 热重载:证书轮换终于不用重启了
我们内部的 gRPC 和几个 webhook 回调都开了 mTLS,证书 90 天一换。以前每次轮换要挑凌晨重启 30 多个实例,还得协调业务方。
3.5 的做法:
server:
ssl:
bundle: web
# 热重载:证书文件变化时自动重新加载,无需重启
tomcat:
ssl: {}
spring:
ssl:
bundle:
pem:
web:
reload-on-update: true # 关键配置
keystore:
certificate: /etc/certs/server.crt
private-key: /etc/certs/server.key
private-key-password: ${KEY_PWD}
开了 reload-on-update: true 之后,Spring Boot 会监听证书文件的修改时间,变了就重新加载。我们把证书更新流程改成:CertManager 下发新证书到 /etc/certs → 文件变更 → 应用自动加载。
实测下来从文件更新到新证书生效,间隔在 1.2 秒以内(默认的文件监听轮询是 1 秒)。
这里有个必须注意的点:老的连接不会断开。已经建立的 TLS 连接继续用旧证书,直到连接关闭。对于长连接的 gRPC 这意味轮换不是瞬间完成的。我们的做法是把 gRPC 的 maxConnectionAge 设成 10 分钟,强制连接定期重建,这样 10 分钟内所有连接都会切到新证书。
结构化日志:从「配 ELK 解析规则」到「直接输出」
以前我们的日志是纯文本,靠 Logstash 的 grok 表达式解析成 JSON 打到 ES。每次加字段都要改 grok,而且多行堆栈的解析一直有 bug。
3.5 内置了结构化日志支持,开一行配置:
logging:
structured:
format:
console: ecs # 或 gelf、logstash;生产我们用 ecs
file: ecs
include:
- traceId
- spanId
- userId
- tenantId
输出变成:
{"@timestamp":"2025-08-28T14:22:31.117Z","log.level":"INFO",
"process.pid":72114,"process.thread.name":"http-nio-8080-exec-3",
"service.name":"order-service","traceId":"4bf92f3577b34da6a3ce929d0e0e4736",
"spanId":"00f067aa0ba902b7","userId":"u_88213","tenantId":"t_31",
"message":"订单创建成功","orderNo":"SO20250828142231"}
Logstash 那边直接 json { source => "message" },grok 规则全删了。我们的日志解析规则从 47 行 grok 降到 6 行。
自定义字段用 MDC 塞就行,结构化的输出会自动把它作为顶层字段:
MDC.put("userId", ctx.userId());
MDC.put("tenantId", ctx.tenantId());
性能上有一点代价。我们压测对比:同样 QPS 8000,纯文本日志 CPU 占用 42%,ECS 结构化日志 47%。多了 5 个百分点,主要是 JSON 序列化。可以接受,但日志量特别大的服务(我们有个网关日均 2 TB)建议只结构化部分关键日志,或者用异步 appender。
坑二和坑三:两个不起眼的行为变更
第二个坑是 @ConfigurationProperties 的构造函数绑定。3.5 对构造器绑定的校验更严格了,我们有一段代码用了 @Value 和构造器绑定混写,升级后启动报:
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 2 of constructor in com.example.OrderProperties required a bean
of type 'java.time.Duration' that could not be found.
Action:
Consider defining a bean of type 'java.time.Duration' in your configuration.
实际原因是配置值是 timeout: 30 而类型是 Duration,3.3 会默认按秒解析,3.5 要求显式写单位。改成 timeout: 30s 就好了。这个改动是对的,但错误信息完全没指向真正的问题,排查花了两小时。
第三个坑是 Actuator 的 /actuator/health 详情默认不再暴露(management.endpoint.health.show-details)。我们有个健康检查脚本依赖 details 里的 db 状态,升级后一直在报「UP 但没内容」。加上配置即可:
management:
endpoint:
health:
show-details: when_authorized
probes:
enabled: true
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
坑四:这次回滚的原因
前面三个坑都在测试环境解决了。真正让我们回滚的是上线后第二天早上 9 点,订单服务 P99 从 130ms 涨到 890ms。
排查了一上午,最后定位到 Micrometer 1.14 里的一个变化:3.5 默认给 HTTP 客户端指标加了 uri 标签的高基数保护,而我们自己的一个 MeterFilter 里做了标签重命名,两者叠加导致每次请求都要走一遍正则替换。我们的过滤器是这么写的:
// 问题代码:每次请求都执行正则
@Bean
MeterFilter renameUriTag() {
return MeterFilter.replaceTagValues("uri",
v -> v.replaceAll("/orders/\\d+", "/orders/{id}"), "");
}
3.3 时这个过滤器只在 meter 注册时执行一次,1.14 之后每次观测都会走。改成用 WebMvcTagsContributor 在源头就归一化 URI 之后,P99 回到 138ms。
这个坑的教训是:监控相关的自定义代码,在大版本升级后必须重新压测。它不报错、不影响功能,只是悄悄地把每次请求变慢 0.7ms。
升级后的收益
| 项目 | 升级前 | 升级后 |
|---|---|---|
| 测试配置文件行数 | 340 行(11 个项目) | 0 |
| 本地集成测试耗时 | 47s | 12s |
| 证书轮换 | 停机重启,30 实例,凌晨执行 | 无停机,1.2s 生效 |
| Logstash grok 规则 | 47 行 | 6 行 |
| 日志查询延迟(Kibana) | P95 2.3s | P95 0.8s |
小结
这轮升级的实际工作量分布大概是:改版本号和修复编译问题占 20%,解决那三个不起眼的行为变更占 30%,排查 Micrometer 性能问题占 40%,剩下 10% 是验收。
给准备升级的同学三条建议:一是先在非核心服务上试,我们是在内部管理后台跑了三周才动的订单服务;二是升级后一定要压测,尤其是有自定义 Micrometer、自定义 Filter 的项目;三是 ServiceConnection 值得单独先上,它不需要升 Spring Boot 版本就能用(3.1+ 就有),可以先享受收益再规划整体升级。
下一个版本 Spring Boot 4.0 我看到已经在计划中了,基于 Spring Framework 7。这次的经验是:大版本升级别急,让别人先踩一个月。