ITPUB

团队冒死升级 Spring Boot 3.5,云账单惊现 45% 降幅!

兄弟们,凌晨三点,运维小哥的监控大屏突然炸开了锅 —— 不是服务器挂了,而是云账单预警短信像过年的鞭炮似的疯狂轰炸手机。看着当月同比暴涨 60% 的云服务器费用,技术总监老王的保温杯 "咣当" 摔在地上:"上个月刚被财务小姐姐指着鼻子骂,这个月怕不是要卷铺盖走人?"

就是在这种生死存亡的压力下,我们团队咬着牙开启了 Spring Boot 3.5 的升级冒险。本以为会像以往版本升级那样踩满坑,没想到三个月后拉账单时,全组人都惊掉了下巴 —— 云账单直接砍了 45%!更惊喜的是,系统吞吐量提升了 30%,接口平均响应时间从 800ms 降到了 500ms 以下。这波操作堪称技术人用代码省出年终奖的教科书级案例,今天就把我们淌过的河、踩过的坑,还有挖到的宝藏统统抖出来。

一、升级前的灵魂三问:为什么非升不可?

其实年初做技术规划时,我们就盯上了 Spring Boot 3.5 的新特性,但一直被 "生产环境稳定第一" 的魔咒按在地上摩擦。直到云账单爆炸式增长,我们才痛定思痛,把三个核心痛点摆到台面上:

1. 老版本 Tomcat 像头吞资源的笨象

我们还在用 Spring Boot 2.7,配套的 Tomcat 9 简直就是资源黑洞。每个 HTTP 请求都要新建一个线程,高峰期线程数轻松破千,光 JVM 线程栈就吃掉 2GB 内存。有次做压测,300 个并发直接把 4 核 8G 的服务器压到 CPU 飙红,监控图活像心电图。

2. 微服务调用在玩 "俄罗斯套娃"

公司搞微服务化后,一个简单的查询请求要穿越 5、6 个服务,每个服务都用 RestTemplate 同步调用,层层阻塞像极了俄罗斯套娃。某次大促时,下游服务稍微卡顿,上游直接被拖成 "慢羊羊",整个调用链的吞吐量惨不忍睹。

3. 云原生时代的 "恐龙级" 配置

看着隔壁团队用 K8s 玩得风生水起,我们却还在用传统的 YAML 配置文件管理资源。手动配置的线程池参数永远跟不上流量变化,高峰期只能靠堆服务器硬扛,账单能不涨吗?用运维小哥的话说:"我们这是在用拖拉机跑高速公路。"

带着这些痛点,我们翻开了 Spring Boot 3.5 的官方文档,一眼就相中了几个能救命的新特性:HTTP/2 支持、Tomcat 线程池优化、反应式编程增强,还有和 K8s 更丝滑的集成。但升级之路从来不是一帆风顺,光兼容性问题就差点让我们折戟沉沙。

二、开门红?不,是开门 "坑"!

第一个坑就埋在 Spring Boot 3.5 的最低 JDK 版本要求上 —— 必须 JDK 17+。我们老项目还在用 JDK 11,本以为升级 JDK 是小事,结果启动时就报错:"java.lang.UnsupportedClassVersionError: xxx has been compiled by a more recent version of the Java Runtime (class file version 61.0), this version of the Java Runtime only recognizes class file versions up to 55.0"。没办法,只能先花两周时间把整个项目的 JDK 环境升级到 JDK 17,期间还解决了不少老旧依赖不兼容的问题,比如 Fastjson 1.x 在 JDK 17 下的序列化漏洞。

第二个坑是 Tomcat 容器的变化。Spring Boot 3.5 默认启用了 Tomcat 的 Maven 依赖管理,结果我们自定义的 Tomcat 配置文件突然失效了。原来新版本对配置文件的加载路径做了调整,我们在 application.properties 里配置的 server.tomcat.max-threads 参数怎么都不生效,最后翻遍官方文档才发现,需要在 application.yml 里用 server.thread-pool.max-threads 来配置。这种细节变化真是防不胜防。

不过真正让我们冷汗直冒的,是数据库连接池的兼容性问题。我们用的 HikariCP 版本太低,在 Spring Boot 3.5 里和新的数据库驱动包冲突,启动时直接报 ClassNotFoundException。没办法,只能硬着头皮升级 HikariCP 到最新版本,顺便把数据库驱动从 mysql-connector-java 8.0 升级到 8.1,这期间还修复了几个因驱动版本差异导致的 SQL 语法错误。

避坑指南:

  1. 先用jdeps --list-dependencies命令扫描老项目依赖,提前发现 JDK 版本不兼容的类库

  2. 准备一个干净的测试环境,用 Docker 容器模拟生产环境的 JDK、中间件版本

  3. 建立兼容性问题清单,按 "阻塞升级 > 影响功能 > 性能损耗" 优先级逐个攻克

三、省钱第一弹:HTTP/2 让流量跑成 "高铁"

熬过了痛苦的兼容性测试,我们迎来了第一个大招 ——HTTP/2 协议。之前用 HTTP/1.1 时,每个接口请求都要单独建立 TCP 连接,光三次握手就浪费不少时间,赶上复杂页面,光加载静态资源就要发起几十次请求,浏览器的并发连接数还被限制在 6 个。用抓包工具分析,发现每次请求的 RTT(往返时间)平均有 300ms,光网络延迟就占了响应时间的 40%。

Spring Boot 3.5 对 HTTP/2 的支持简直是丝滑般顺畅,只需要在 application.yml 里加两行配置:

server:
  ssl:
    enabled: true
    key-store: classpath:keystore.p12
    key-store-password: password
    key-store-type: PKCS12
  port: 443
  http2:
    enabled: true

没错,HTTP/2 需要 HTTPS 加持,这也倒逼我们把所有服务都升级到了 HTTPS。刚开始还担心 SSL 加密会增加 CPU 开销,结果压测发现,虽然 CPU 使用率上升了 5%,但整体吞吐量提升了 20%,因为 HTTP/2 的多路复用特性太香了 —— 同一个 TCP 连接可以同时处理多个请求,再也不用像 HTTP/1.1 那样排队等待了。最直观的变化是静态资源加载速度,原来加载一个页面需要 2 秒,现在 1 秒内就能完成,用户体验直接起飞。更让我们惊喜的是头部压缩功能。

HTTP/1.1 的请求头每个都要完整传输,像 Cookie 这种大个头每次都要几百字节。HTTP/2 用 HPACK 算法对头部进行压缩,相同的请求头只会传输一次,后续请求用索引代替。我们统计了一下,平均每个请求的头部大小从 400 字节降到了 80 字节,光这一项就节省了 30% 的网络流量。按我们每天 1000 万次请求计算,一个月就能省下几十 GB 的流量,云服务商的流量计费账单直接砍了一刀。

实战技巧:

  1. 用 Chrome 的开发者工具查看 "Network" 面板,确认请求协议是否显示 "h2"

  2. 定期清理无效的 Cookie,减少头部数据量

  3. 对图片、视频等大文件启用服务器推送(Server Push),提前把相关资源推送给客户端

四、Tomcat 线程池:从 "人海战术" 到 "精英部队"

解决了网络层的问题,我们把矛头对准了 Tomcat 这个吞资源的大户。老版本的 Tomcat 用的是 BIO 模型,每个请求都要占用一个线程,高峰期线程数暴增,上下文切换频繁,CPU 大部分时间都花在了线程调度上。Spring Boot 3.5 引入了新的 Tomcat 线程池配置,基于 NIO 的 APR 模式,简直就是为高并发场景量身定制。

先来看看核心配置参数:

server:
  tomcat:
    thread-pool:
      max-threads: 200
      min-spare-threads: 20
      max-connections: 10000
      accept-count: 1000

这里的 max-threads 不再是传统的最大线程数,而是 Tomcat 处理业务的最大工作线程数。配合 NIO 的非阻塞 IO,一个线程可以处理多个连接,原来需要 1000 个线程才能处理的并发量,现在 200 个线程就能轻松搞定。我们做了个对比测试,在 500 并发下,老版本 Tomcat 的线程数达到 800+,CPU 使用率 80%;新版本线程数稳定在 200 左右,CPU 使用率降到 50%,内存占用更是减少了 40%。

这里还有个小插曲:刚开始我们照搬官方文档的配置,结果发现吞吐量上不去。仔细分析才知道,max-connections 参数没调好。这个参数表示 Tomcat 在同一时间能处理的最大连接数,默认值是 10000,但我们的服务器带宽只有 1Gbps,峰值连接数根本达不到这个量,过高的配置反而会占用过多的文件描述符。后来我们根据压测结果,把 max-connections 调到 5000,吞吐量立马提升了 15%。

性能调优公式:

合理的max-threads = (CPU核心数 * 2) + 1
max-connections = max-threads * 100 (根据实际带宽调整)

五、反应式编程:让阻塞式调用原地起飞

要说这次升级最颠覆认知的,当属反应式编程的应用。我们有个核心的订单查询服务,需要调用库存、价格、物流三个下游服务,原来用 RestTemplate 同步调用,每个调用都要等待结果返回,整个流程耗时 800ms 以上。用 Postman 测试时,经常能看到 "Pending" 状态卡在那里,像极了等外卖时的焦急心情。

Spring Boot 3.5 对 Reactor 框架的支持更加成熟,我们试着把同步调用改成反应式的 WebClient:

Mono<StockResponse> stockMono = webClient.get()
  .uri("/stock/{id}", order.getId())
  .retrieve()
  .bodyToMono(StockResponse.class);
Mono<PriceResponse> priceMono = webClient.get()
  .uri("/price/{id}", order.getId())
  .retrieve()
  .bodyToMono(PriceResponse.class);
Mono<LogisticsResponse> logisticsMono = webClient.get()
  .uri("/logistics/{id}", order.getId())
  .retrieve()
  .bodyToMono(LogisticsResponse.class);
Mono.zip(stockMono, priceMono, logisticsMono)
  .map(tuple3 -> {
    // 合并结果
    return new OrderResponse(tuple3.getT1(), tuple3.getT2(), tuple3.getT3());
  })
  .block();

这波操作简直打开了新世界的大门!三个下游调用变成了并行执行,通过 Mono.zip 合并结果,整个流程耗时直接降到 300ms,相当于把原来的串行执行变成了并行处理,效率提升了近 3 倍。而且反应式编程天生支持背压(Backpressure),当下游服务处理不过来时,会自动减缓请求发送速度,避免上游服务被压垮,这在微服务调用链中简直就是防雪崩的神器。

不过刚开始用反应式编程时,团队里不少老程序员都犯了难,毕竟习惯了命令式编程,对这种声明式的写法很不适应。为此我们专门搞了几次内部培训,用 "超市购物" 来比喻:同步调用就像排队结账,必须等前面的人结完账才能轮到自己;反应式编程就像多个收银台同时工作,你把购物车交给收银员后可以去干别的事,等通知来取就行。这样一比喻,大家很快就理解了异步非阻塞的概念。

最佳实践:

  1. 对 IO 密集型接口优先使用反应式编程,CPU 密集型接口谨慎使用

  2. 利用 Spring Cloud Gateway 搭建反应式网关,统一处理跨服务调用

  3. 使用 Micrometer 监控反应式流的背压情况,及时发现瓶颈点

六、内存管理:让 JVM 学会 "断舍离"

升级到 JDK 17 后,我们顺便对 JVM 参数做了全面优化。原来的 JVM 配置还是几年前的老样子,用的是 Parallel GC,内存碎片多,Full GC 频繁,每次 Full GC 都要暂停好几百毫秒,用户明显能感觉到系统卡顿。Spring Boot 3.5 推荐使用 G1 垃圾收集器,我们果断启用,并做了针对性配置:

-XX:+UseG1GC
-XX:G1HeapRegionSize=4m
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=45
-XX:+ParallelRefProcEnabled
-XX:ConcGCThreads=8

这波操作下来,效果立竿见影:Young GC 频率降低了 30%,Full GC 几乎看不到了,内存使用率从 80% 降到了 60% 以下。更惊喜的是,系统的响应时间稳定性大幅提升,99% 的请求响应时间控制在了 600ms 以内,再也不会出现偶尔的 "卡顿毛刺" 了。

这里有个关键参数需要注意:InitiatingHeapOccupancyPercent,它表示当堆内存使用达到 45% 时,就开始准备并发标记,避免堆内存耗尽时才被迫进行 Full GC。我们刚开始设成 50%,结果发现并发标记还是有点滞后,调到 45% 后,GC 性能进一步提升。

内存泄漏排查三板斧:

  1. 用 JVisualVM 实时监控内存使用情况,重点关注 Survivor 区和老年代的变化

  2. 定期生成堆转储文件,用 MAT 工具分析大对象和引用链

  3. 启用 GC 日志分析,推荐使用 GCEasy 在线工具,一键生成分析报告

七、云原生集成:和 K8s 组个 "最佳拍档"

最后不得不提 Spring Boot 3.5 对云原生的深度集成,尤其是和 K8s 的配合简直天衣无缝。我们原来的 Pod 资源配置全靠手动估算,经常出现 "资源浪费" 和 "资源不足" 两种极端情况。现在利用 Spring Boot 的 K8s 探针(Liveness Probe、Readiness Probe),可以精准控制 Pod 的启动和销毁,配合 Horizontal Pod Autoscaler(HPA),自动根据 CPU 使用率调整 Pod 数量,高峰期自动扩容到 20 个 Pod,低谷期缩容到 5 个,资源利用率提升了 50%,账单自然就降下来了。

还有个隐藏技能:Spring Boot 3.5 支持 K8s 的 ConfigMap 和 Secret 动态加载,我们再也不用为了改一个配置而重启整个服务了。通过 K8s 的 API 实时监听配置变化,自动刷新应用内的配置,这个功能在灰度发布和 A/B 测试中简直不要太好用。

K8s 优化清单:

  1. 为每个微服务设置合理的 requests 和 limits,避免资源竞争

  2. 启用 Pod 优先级和抢占机制,保证核心服务的资源供给

  3. 利用 K8s 的网络策略(NetworkPolicy)隔离微服务,减少不必要的网络开销

八、升级后的 "意外之喜"

除了肉眼可见的账单下降,这次升级还给我们带来了不少意外收获:

1. 开发效率提升 30%

Spring Boot 3.5 的 DevTools 做了重大升级,自动重启速度提升了 50%,热部署支持的类库更多了。现在改完代码保存,不到 3 秒就能看到效果,再也不用像以前那样等 10 几秒了,光这一项就节省了大量开发时间。

2. 单元测试跑得更快了

新版的 Spring Test 框架优化了上下文加载机制,我们的集成测试平均耗时从 5 分钟降到了 3 分钟,CI/CD 流水线的整体耗时减少了 40%,每天能多跑几轮测试,质量保障更到位了。

3. 监控体系更完善了

Spring Boot 3.5 原生支持 Micrometer 1.10+,可以直接输出 Prometheus 格式的监控指标,配合 Grafana 做可视化监控,现在能实时看到每个接口的吞吐量、错误率、响应时间,定位问题比以前快了 10 倍。

九、给准备升级的同行的几点忠告

  1. 别想一口吃成胖子:分阶段升级,先升级核心服务,再逐步过渡边缘服务,我们就是先升级了订单、支付这些高流量服务,积累经验后再推到全链路。

  2. 压测一定要到位:用 JMeter、Gatling 等工具模拟真实流量,我们在压测时发现了 3 个隐藏的性能瓶颈,都是平时测试环境没暴露出来的。

  3. 团队培训不能少:反应式编程、HTTP/2 等新技术对开发人员有挑战,提前组织内部培训,避免升级后代码写得五花八门。

  4. 监控先行:升级前搭好全链路监控,包括 APM、日志、Metrics,我们靠 Prometheus+Grafana 实时监控升级后的各项指标,及时发现并解决了好几个性能问题。

尾声:技术升级不是冒险,是投资

回顾这次九死一生的升级之旅,我们最大的感悟是:技术升级从来不是为了追新,而是为了解决实际问题。当云账单像脱缰的野马狂奔时,Spring Boot 3.5 的新特性就像一套精准的刹车系统,不仅帮我们刹住了成本,还让系统性能实现了跨越式提升。

现在再看财务小姐姐的眼神,从原来的 "death stare" 变成了 "星星眼",就连隔壁组的同事都来取经。最爽的是上周例会上,老王把新账单往桌上一拍:"就这成本控制水平,年底奖金不涨都说不过去!"

当然,升级过程中踩过的坑、掉过的泪,只有我们自己知道。但当看到系统吞吐量飙升、用户投诉大减、账单数字狂降时,一切都是值得的。这也让我们更加坚信:在技术的世界里,没有白走的路,每一步都算数。

如果你所在的团队还在为高成本、低性能发愁,不妨试试 Spring Boot 3.5 的升级套餐,说不定下一个让财务小姐姐惊叹的,就是你!

直播预告
7月4日,ITPUB携手行业专家围绕“ 如何低成本训练企业级模型”这一主题开展线上直播分享,点击下方卡片预约直播:
Image