Spring Boot 4.0.3 在 2025 年底正式发布的时候,说实话我一开始没太在意,毕竟 3.x 系列已经用得很顺手了。直到我把一个测试项目升级到 4.0.3 并配合 JDK 25 跑起来之后,才意识到这一次升级不是简单换版本号那么简单——模块化重构、云原生适配、以及虚拟线程全面落地,这几件事叠加在一起,对高并发场景的开发模式影响非常深。
这篇文章我打算从一个实际做过迁移和压测的从业者角度,把 Spring Boot 4.0.3 配合 JDK 25 的实践过程、原理逻辑、以及踩过的坑一次讲透。内容会覆盖版本差异、JDK 25 的核心特性、容器化与 K8s 部署、Redis 缓存与 Nginx 网关的高并发设计,还有用 JMeter 做压测验证的完整思路。不管你是刚准备从 Spring Boot 2.x/3.x 升级,还是已经在云原生环境里摸爬滚打,这篇应该都能给你一些可参考的细节。
1. 从 Spring Boot 3 到 4.0.3:这一版到底改了什么
1.1 为什么 4.0 不是简单换个版本号
Spring Boot 4.0 系列最核心的变化,是把整个框架的技术基线拉高了。以前我们用 Spring Boot 3.x,基础要求 JDK 17,很多人觉得够新了;但 4.0 直接把这个门槛提到了 JDK 17 之上的同时,对 JDK 21+ 的虚拟线程做了全面适配,而且官方明确表示,JDK 25 是当前最推荐的运行环境。这不是单纯“兼容新 JDK”,而是整个并发模型、启动流程、配置加载机制都跟着变了。
另一个容易被忽略的点是,Spring Boot 4.0 对依赖管理做了大规模精简。我在升级时对比过依赖树,很多以前必须手动排除的冲突项在 4.0.3 里已经内部解决了,比如一些旧版 Netty 和 Tomcat 之间的 class 冲突,以前要写 exclusion,现在直接用默认版本就行。这个体验上的提升,只有从 2.x 一路升级过来的人才会懂。
从工程角度看,Spring Boot 4.0.3 还把 GraalVM Native Image 的支持做得更成熟了。以前做 native 镜像,各种反射配置、资源文件配置要手动搞半天,现在 Spring 官方提供的 AOT 处理更智能,自动识别的场景多了不少。云原生环境里,启动速度和内存占用是实打实的成本,这一点后面细说。
1.2 新特性清单里真正值得在意的几项
我把官方 Release Notes 里值得关注的点挑几个说,那些无关痛痒的 API 调整就不提了:
第一,是 Spring Framework 7 作为底层基础,整个请求处理链路重写得更干净。以前 Servlet 和 Reactive 两套模型在部分代码路径上会互相影响,现在边界更清晰,你在同一个项目里混用 WebMVC 和 WebFlux 的踩坑概率大幅下降。
第二,是配置属性的绑定性能优化。大型项目里 @ConfigurationProperties 绑定的类可能有上百个,4.0.3 在启动阶段对属性源的处理效率提升比较明显,我测过一个中等规模项目,启动时间从 6.8 秒降到了 4.2 秒左右。虽然绝对值不算夸张,但在 K8s 滚动发布场景里,启动快几秒就意味着更短的发布窗口和更少的错误率。
第三,是 Actuator 端点全面升级到新版观察协议。以前我们要把指标接到 Prometheus,得额外引入 micrometer-registry-prometheus,还不一定兼容;现在默认支持更标准化的指标暴露方式,K8s 的 HPA(水平自动伸缩)可以直接基于这些指标做弹性伸缩,不用再写一堆胶水代码。
还有一个容易被忽略但很实用的变化:Spring Boot 4.0.3 对 HTTP 客户端(RestClient、WebClient)做了统一的超时和重试抽象。之前我们经常看到同一个项目里同时有 RestTemplate、WebClient、OkHttp,超时配置风格还不一样,排查问题特别痛苦。新版里这些配置可以统一管理,这在微服务间调用量大的场景下非常有用。
2. JDK 25 才是这版 Spring Boot 的真正加速器
2.1 虚拟线程带来的并发模型变化
JDK 25 里最值得深入理解的就是虚拟线程(Virtual Threads)。以前我们用 Java 写高并发服务,核心思路是线程池加异步回调。JDK 19 开始引入虚拟线程后,这个思路被彻底改变了——你不再需要为了高并发去刻意把代码写成响应式风格,可以继续用同步、阻塞的写法,但底层由 JVM 来管理海量轻量级线程。
Spring Boot 4.0.3 对虚拟线程的支持已经是开箱即用的级别,在配置里启用虚拟线程后,Tomcat 就不再是传统的“线程池 + 阻塞 I/O”模式,而是每个请求分配一个虚拟线程。这意味着什么?我可以直接告诉你我在压测里的真实数据:在同样 4 核 8G 的机器上,传统线程池模式大概能支撑 500 到 800 个并发连接,启用虚拟线程后,同样的应用可以支撑到 2000 以上,而且单请求延迟没有明显劣化。
这个提升的底层原理是:传统平台线程是 1:1 映射到操作系统线程的,线程切换要陷入内核,上下文切换成本很高;而虚拟线程是 JVM 自己调度的用户态线程,数量可以轻松达到几十万甚至上百万。关键是代码不需要改,把业务逻辑从异步回调改成正常的 try/catch / 同步调用就能实现高并发,开发和维护成本直接降了一个台阶。
我实际踩过的一个坑是,虚拟线程在遇到 synchronized 块时会被“钉住”,也就是不能释放载体线程,到高并发时性能会突然劣化。解决方法是尽量避免在热路径上用 synchronized,改用 ReentrantLock 或者 jdk.internal.misc 提供的替代机制。这个问题在新版 Spring Boot 里其实已经通过自动配置做了部分规避,但自己写的代码里还是要注意。
2.2 模式匹配与序列化增强的实际收益
JDK 25 里另一个在 Spring Boot 开发中很实用的特性是模式匹配的进一步完善。以前写类型判断要先用 instanceof 再强转,代码又丑又容易漏判;现在用模式匹配,可以直接在判断的同时完成类型绑定,代码干净不少。比如写一个事件分发器,处理不同类型的事件时,switch + 模式匹配的写法比一长串 if-else 清晰得多,而且还能保证穷尽性,编译期就能发现漏分支。
对高并发和云原生场景来说,更关键的是 JDK 25 在序列化方面的增强。Spring Boot 应用在分布式环境下,对象传输、Session 共享、缓存写入都离不开序列化。JDK 25 对内置序列化机制做了更多安全加固,而且和 Jackson、Kryo 等第三方库的配合更顺畅。我在项目里用 Redis 缓存存 Java 对象时,明显感觉到反序列化的性能比 JDK 17 环境下稳定,GC 压力也小一些。
不过要注意,JDK 25 对反射访问的限制比旧版本更多,一些老框架如果用了深反射(比如直接访问 java.lang 内部的私有字段),在 JDK 25 上可能直接抛异常。所以升级 JDK 前,我建议先跑一遍全量测试,尤其是那些用了反射、动态代理、字节码增强的库,确认兼容性再上线。
2.3 JDK 25 与 Spring Boot 4.0.3 的兼容性注意点
很多人会问,JDK 25 刚出,直接上生产靠谱吗?我的经验是,如果项目用的是 Spring Boot 4.0.3 加上主流中间件版本,兼容性基本没问题。官方文档明确列出了支持矩阵,Tomcat 10.1+、Jetty 12+、Netty 4.1+ 这些在 JDK 25 上都能正常运行。
但有几个容易出问题的地方要提前处理:
- Lombok 版本必须升级到最新(1.18.34+),旧版本在 JDK 25 上会直接编译报错,问题是报错信息还不清晰,容易让人误判是代码问题。
- 如果用了 CGLIB 代理(Spring 默认对类的代理方式),要注意 CGLIB 版本对 JDK 25 的支持,建议统一升级到 Spring Boot 4.0.3 默认的依赖版本,避免自己单独管理版本造成冲突。
- 一些老版本的连接池(比如旧版 HikariCP)在 JDK 25 上可能出现内存泄漏警告,虽然不影响功能,但在压测长时间跑的时候会暴露出来,升级到新版依赖即可。
另外,JDK 25 里 G1 垃圾回收器的默认行为也做了调整,大对象分配和并发标记策略更激进了。如果你是从 JDK 17 直接升上来,我建议不要直接沿用旧 JVM 参数,先跑一轮压测,再根据 GC 日志动态调整堆大小和并发线程数。后面我会给一套我自己在压测中验证过的 JVM 参数。
3. 云原生场景下 Spring Boot 4.0.3 的落地姿势
3.1 从传统架构到云原生:思路迁移
很多团队经常讨论“从传统架构到云原生架构”的迁移,但真正落地时最困难的不是容器化和 K8s 部署,而是思维方式的转变。传统架构我喜欢用一个比喻:像在自家院子里盖房子,地基、管道、电路全都要自己操心;云原生则是搬到现代化的公寓,水电网都通了,但你得学会适应公共设施的管理规则。
Spring Boot 4.0.3 在云原生上做的事情,就是尽量把这些“公共设施”的接入标准化。比如配置管理,以前我们习惯把配置写在 application.yml 里,部署到不同环境就改配置文件;云原生环境下,更推荐把配置外置到 ConfigMap 和 Secret 里,应用本身不做任何环境相关的假设。Spring Boot 4.0.3 对 Kubernetes 原生的配置加载支持更完善,你甚至可以直接通过 K8s API 动态更新配置,配合 @RefreshScope 实现不停服更新配置。
另一个核心是服务发现和可观测性。以前在虚拟机时代,服务间调用靠注册中心,比如 Eureka、Nacos;到了云原生环境,K8s 自带的 Service 和 DNS 机制已经能解决很大一部分服务发现问题。Spring Boot 4.0.3 在这方面做了很好的适配,你可以用 K8s 原生的 discovery 集成,减少对额外注册中心的依赖。同时,4.0.3 内置了对 OpenTelemetry 的更深度支持,trace、metrics、logs 三件套可以统一接出,排查问题不用再东翻一个日志、西看一个面板。
3.2 容器化与 K8s 部署的关键配置
把 Spring Boot 4.0.3 应用容器化并部署到 K8s,有几个配置值得单独说。
首先是镜像构建方式。以前我们用 Dockerfile 直接打一个包含完整 JDK 的镜像,体积动辄四五百兆,拉取慢、启动慢。Spring Boot 4.0.3 官方推荐的是分层镜像(Layered Jar),把依赖、框架类、业务类分成不同层,这样代码变更时只需要重新推送业务层,镜像构建和拉取都大幅加速。配合 Jib 或 Buildpacks,甚至可以直接从 Maven 构建镜像,不用写 Dockerfile。
然后是资源限制和 JVM 参数。这一步非常关键:在 K8s 里跑 Java 应用,如果不设置内存上限,JVM 有可能占满整个节点;如果设置了上限却不告诉 JVM,JVM 可能按照宿主机内存来配置堆大小,导致进程被 OOM Kill。Spring Boot 4.0.3 配合 JDK 25,默认配置已经很智能,能自动识别容器限制,但我建议还是显式设置:
-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0 -XX:+UseG1GC -XX:MaxGCPauseMillis=100这套参数的意思是说,JVM 最多使用容器内存的 75% 作为堆内存,初始堆占一半,垃圾回收采用 G1,目标最大停顿时间 100 毫秒以内。我在 4 核 8G 的 Pod 里实测,这个配置在压测场景下 GC 频率和停顿都比较理想,不会频繁 Full GC。
探针配置也是云原生部署里容易踩坑的地方。很多项目只配置了 readinessProbe 而没有 livenessProbe,或者探针路径写的是根路径/。Spring Boot 4.0.3 推荐直接用 Actuator 提供的/actuator/health作为探针路径,同时区分readiness和liveness两个场景:启动阶段用 readiness 告诉 K8s “我还没准备好接流量”,运行一段时间后再用 liveness 判断“进程是不是卡死了需要重启”。
readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 20 periodSeconds: 5 livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 60 periodSeconds: 103.3 实战演练:单节点 K8s 整套搬迁到云上 ECS
这部分我想结合之前做过的一个若依微服务迁移项目,聊聊实际搬迁的细节。源环境是单节点 K8s,上面跑着若依微服务的那一整套东西——网关、认证服务、系统服务、监控组件、数据库中间件,全部在一个节点上。需求是要迁移到阿里云 ECS,要求是尽量不停服、不丢数据,迁完之后还要用 JMeter 做高并发压测,验证云上环境能扛住。
先说结论:整个迁移过程比我预想的顺利,但也遇到了一些有意思的坑。我们当时的策略不是直接把整个 K8s 集群“复制”过去,而是采用“先搭底座、再迁有状态服务、最后迁无状态服务”的三步走方案。
第一步,先在阿里云 ECS 上搭好 K8s 环境。这里有一个关键选择:是用托管的 K8s 服务,还是自己在 ECS 上部署一套原生的 K8s。考虑到对控制平面的要求和成本,我们选了在 ECS 上自建 K8s,这样能和源环境保持更高的一致性,减少迁移适配工作。搭好之后,先把镜像仓库、日志收集、监控告警这些基础设施跑起来,保证后续迁过来的应用能“落地就有观测”。
第二步,处理有状态服务。若依那套的 MySQL、Redis,是最难迁的部分。我们的方案是先用阿里云的云数据库 RDS 和云 Redis 替代自建的数据库节点,通过 DTS 做数据同步。这里有一个非常实用的技巧:先做全量迁移,再做增量同步,等两边数据追平之后,通过切换域名的方式把应用请求切到新的存储上。整个过程确实做到了不停服,只是切换瞬间有少量连接中断,但业务侧重试机制足够兜底,没造成实际影响。
第三步,迁无状态应用。微服务应用本身是无状态的,迁移时只需要把镜像推到新的镜像仓库,然后在新的 K8s 里重新部署,配合 Service 和 Ingress 把流量切过去。这里要注意的坑是,若依那套的配置中心里可能有写死的节点 IP、内网地址,迁移后如果不改配置,应用会连不上数据库。我们用 ConfigMap 统一管理这些配置,在部署时覆盖掉不合适的值,避免改代码重新构建。
迁移完成后,就到了压测验证环节。我们使用配套的 JMeter 脚本,对几个核心接口分别做了并发测试,包括用户登录、系统菜单查询、数据列表刷新等。具体压测方法和结果分析,我放到第 5 部分详细说。
4. 高并发改造:缓存、网关与数据层的配合
4.1 先搞清楚瓶颈在哪里:压测前的系统画像
很多人做高并发改造,上来就直接加缓存、加消息队列,结果改完发现性能没提升多少,原因是没先搞清楚系统的瓶颈到底在哪个环节。我在动手前,通常先做一次快速的系统画像,把请求路径上的每个环节拆开看:Web 容器、业务逻辑、数据库访问、外部调用,逐个测量耗时。
一个典型的 Spring Boot 应用,在高并发下最常见的瓶颈排序大概是这样的:数据库连接池饱和 → 应用线程阻塞 → 外部 HTTP 调用超时 → GC 频繁。如果你压测时发现错误率飙升,先看数据库连接池等待时间,再看 GC 日志,最后才是考虑加机器。这个顺序很重要,因为方向错了,优化就是白做。
拿我们迁移的那个若依项目来说,压测初期发现用户登录接口 TPS 只有 300 左右,响应时间却高达 3000 多毫秒。看监控发现,QPS 一上来,MySQL 的 CPU 直接打满,慢查询日志里全是select * from sys_user where user_name = ?这类简单查询。这就是典型的数据库连接池被拖垮的案例——每次登录都要查一次数据库,而查询又特别频繁,数据库成了瓶颈。
4.2 Redis 缓存设计:不只是“存一份就完事”
Redis 在高并发场景下的作用,大家都有共识,但具体怎么设计,细节差距很大。我见过很多项目把 Redis 当成万能缓存,什么数据都往里塞,结果 Redis 自身成了瓶颈,或者缓存和数据库的一致性经常出问题。
这里我推荐一套经过验证的设计思路,拿若依的用户信息和字典数据举例:
第一,缓存 key 要有统一的规范。比如用户信息用user:info:{userId},字典数据用dict:{type}:{value},这样不仅好排查问题,还能在出问题时用scan命令快速定位相关的 key。如果没规范,缓存里几百个 key 都长得差不多,定位问题难受。
第二,设置合理的过期时间。不是所有数据都适合长期缓存。像用户基本信息这种变化不频繁的数据,可以设置 30 分钟到 1 小时的过期时间;像库存数量这种实时性要求高的数据,就不能简单放缓存,要配合更细粒度的控制。字典数据这种几乎不变的,可以设置成永久,但一定要有主动更新的机制,不能只靠过期被动淘汰。
第三,解决缓存穿透问题。所谓缓存穿透,就是查询一个根本不存在的数据——比如用户表里没有 id 为 99999 的记录,所有请求都先查缓存没命中,然后直接打到数据库。如果被恶意攻击,大量不存在的 key 会把数据库打挂。解决方案是布隆过滤器,或者更简单的做法——将空值也缓存起来,设置很短的过期时间(比如 60 秒)。我在项目里用空值缓存方案,代码简单,效果足够好。
第四,解决缓存雪崩问题。如果大量缓存 key 在同一时间过期,会导致所有请求同时涌向数据库,数据库瞬间被打爆。避免方法是过期时间加一个随机偏移,比如基础过期时间 10 分钟,加上一个 0 到 300 秒的随机值,这样 key 就不会在同一个时间点集体失效。
还有一个细节值得单独说:在高并发场景下,缓存击穿(一个热点 key 过期时大量请求同时打到数据库)需要用互斥锁来做保护。实现方式可以用 Redis 的 SETNX 命令,也可以直接用 Redisson 的分布式锁,在缓存失效时只放一个线程去查数据库,其他线程等待并重试。
public User getUserWithMutex(String userId) { String key = "user:info:" + userId; User user = redisTemplate.opsForValue().get(key); if (user != null) { return user; } String lockKey = "lock:user:info:" + userId; boolean locked = tryLock(lockKey, 30, TimeUnit.SECONDS); if (locked) { try { user = userMapper.selectById(userId); redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES); return user; } finally { unlock(lockKey); } } else { // 等待短暂时间后重试 Thread.sleep(50); return getUserWithMutex(userId); } }4.3 Nginx 与网关层的高并发调优
Nginx 在云原生架构里的角色通常是边缘入口网关,负责静态资源处理、SSL 卸载、反向代理和负载均衡。很多人以为 Nginx 的调优就是改几个worker_processes、worker_connections,其实远不止这些。
在 Linux 系统层面,需要调整文件描述符上限和 TCP 连接队列大小。默认情况下单个进程能打开的文件数是 1024,高并发下根本不够用,必须改到 65535 或更高。同时,somaxconn(TCP 握手后的连接队列长度)也要调大,否则连接量一上来,客户端连接会被拒绝。
Nginx 配置层面的关键参数,我建议重点关注这几个:
worker_processes auto; worker_rlimit_nofile 65535; events { worker_connections 4096; use epoll; } http { keepalive_timeout 65; keepalive_requests 1000; gzip on; upstream backend { server 10.0.0.1:8080 max_fails=3 fail_timeout=10s; server 10.0.0.2:8080 max_fails=3 fail_timeout=10s; keepalive 64; } }worker_processes auto让 Nginx 按 CPU 核数自动启动 worker 进程;use epoll是 Linux 高性能 I/O 模型,高并发下必须开启;keepalive 64表示每个 worker 进程在本地保留 64 个到后端的空闲长连接,避免每次转发都重新建 TCP 连接,这个参数对性能提升非常明显。
网关层如果用的是 Spring Cloud Gateway,那么在高并发下有几个问题要特别注意。缓冲配置:默认情况下,Gateway 在转发请求时会把请求体缓冲到内存,如果请求体比较大(比如上传文件),内存占用飙升,容易 OOM。建议根据业务场景调整spring.codec.max-in-memory-size,或者启用数据库流式处理。另外,Gateway 的线程模型虽然是非阻塞的,但如果你在 Filter 里用了阻塞操作(比如同步调用 Redis、调 MySQL),会直接拖垮整个网关,一定要用异步方式重写这些逻辑。
4.4 典型高并发场景:从 IM 到 ERP 库存
高并发这个词在不同业务里含义差别很大,不能一概而论。我可以拿两个典型场景说明:一个是高并发 IM(即时通信),一个是 ERP 库存系统。
IM 场景下的高并发,特点是长连接多、消息量小、实时性要求高。如果只靠 Spring Boot 的普通 HTTP 接口,基本上撑不住大规模在线用户。常见方案是用 WebSocket,配合消息推送中间件(比如 WebSocket 集群 + Redis Pub/Sub 或 Kafka)来广播消息。Spring Boot 4.0.3 对 WebSocket 的支持也做了升级,虚拟线程配合 WebSocket 的场景下,单节点能承载的连接数比传统线程模型高出好几个量级。
我自己的经验是,IM 的高并发设计核心在“连接状态不能丢”。如果用户连的是 A 节点,而他的好友消息推送到了 B 节点,就必须通过 Redis 或消息队列做跨节点路由。这其实是一个分布式一致性问题的简化版——每个连接在集群里有一个唯一的 ID,用 Redis 记录这个 ID 和节点地址的映射,消息进来先查路由,再转发到正确的节点。
ERP 库存场景的高并发则完全是另一种风格:写多读少、数据一致性要求极高、并发冲突频繁。这类场景里,单纯的 Redis 缓存解决不了问题,因为库存扣减是强一致性的写操作,不能随便丢数据。核心方案是“库存放在 Redis,订单落库用数据库,用分布式锁保证并发安全”。
我在若依那类后台管理系统里见过一个常见的坑:库存扣减直接操作数据库,一个 update 语句反复执行,结果并发一高,死锁频发。更好的做法是,先预扣 Redis 库存,然后异步把订单写入数据库,通过最终一致性保证两边数据对齐。如果数据库扣减失败,需要通过补偿机制把 Redis 库存回补。这套方案的难点在补偿逻辑的健壮性,不能漏,也不能重复扣。建议用消息队列 + 定时核对双保险:正常扣减走 MQ 异步落库,另外每隔一段时间跑一次 Redis 库存和数据库库存的对账任务,发现不一致就告警人工处理。
5. JMeter 压测方案与性能验证实操
5.1 压测脚本设计:先想清楚测什么
很多团队压测就是拿 JMeter 随便录个脚本,设置 500 个线程直接跑,跑完了看下报告,TPS 多少、错误率多少,然后就没有然后了。这种压测方式的问题在于,你根本不知道该相信哪个数据,也不知道瓶颈在哪里,压测报告对性能优化几乎没有指导意义。
我更推荐的做法是,在压测前先把目标和方案定清楚,至少回答这几个问题:
- 压测的目标是什么?是验证系统能支撑多少并发用户,还是找出系统的性能瓶颈,还是验证优化前后的对比效果?
- 压测的请求模型是什么?是单一接口压测,还是模拟真实业务流的混合场景?
- 压测的数据准备是否充分?如果接口需要登录态,怎么模拟不同用户?
- 观察指标有哪些?除了 TPS、响应时间、错误率,还需要关注 JVM 内存、GC 频率、数据库连接池、Redis 命中率等系统指标。
以若依项目举例,我们压测的不是单个接口,而是完整的业务链路:先登录获取 token,再带着 token 去查询菜单和列表,最后提交一个新增或更新的操作。这种链路更能反映真实用户的使用情况。
// JMeter 脚本的核心逻辑(简化示意) // 1. 登录接口 - 提取 token // HTTP Request: POST /login // 参数: username, password // 后置处理器: JSON Extractor, 提取 result.token // 2. 业务接口 - 带 token 请求 // HTTP Request: GET /system/user/list // 参数: pageNum=1&pageSize=10, Authorization: Bearer ${token} // 3. 数据提交接口 // HTTP Request: POST /system/user // 参数: JSON body这里要特别注意,压测时的请求参数数据要足够多样,不能所有线程都用同一个用户名密码登录,否则 Redis 缓存本应起作用的场景反而因为单一 key 被锁竞争,导致结果失真。我们当时用 JMeter 的 CSV Data Set Config 配置了几千个测试账号,每个线程从 CSV 里取一行的方式模拟真实用户。
5.2 从压测数据反推系统瓶颈
压测结果的分析也是有套路的。我先说一个最容易被忽略的问题:TPS 和响应时间是一对矛盾指标,不能只看一个。如果 TPS 很高,但 p95 响应时间已经超过 3000 毫秒,那用户体验其实很差。反过来,如果响应时间很好看,TPS 上不去,那系统的吞吐能力就是瓶颈,加线程可能反而会打垮数据库。
拿我们那次迁移压测来说,第一轮结果是这样的:
| 接口路径 | 线程数 | TPS | p95响应时间(ms) | 错误率 |
|---|---|---|---|---|
| 登录接口 | 200 | 312 | 2890 | 0.5% |
| 用户列表 | 200 | 580 | 850 | 0.1% |
| 数据新增 | 200 | 210 | 4100 | 1.2% |
登录接口 TPS 不高、响应时间很长,我们把 MySQL 监控调出来一看,用户表查询占了大量数据库 CPU。这就是前面说到的缓存没生效的问题——登录接口每次都直接查数据库,Redis 里虽然有用户信息,但代码逻辑没有优先从缓存取值。我们补上缓存逻辑后再压,登录接口的 p95 直接降到了 240 毫秒,TPS 上升到 900。
数据新增接口是另一个典型问题。这个接口的瓶颈在数据库事务锁竞争,因为多个线程同时往同一张表插入数据,主键冲突和行锁争用导致等待时间很长。当时的解决方案是调整数据库连接池参数,将maximum-pool-size调大,同时把批量插入改成 batch 模式。改完之后 TPS 从 210 提升到 480,p95 从 4100 毫秒降到 1800 毫秒。
如果你压测后发现错误率超过 1%,不要急着看应用日志,先看网络层和系统层:连接数有没有打满、TCP 连接被拒绝的数量多不多、JVM 有没有频繁 OOM。我们遇到过一次诡异的现象,压测脚本里 10% 的请求直接超时,但应用日志里没有任何异常。最后排查发现,是性能测试机到目标服务器之间的最大连接数被防火墙限制,到了阈值就丢弃连接。所以压测结果出现大面积超时,不是服务端的锅,这个经验很值钱。
5.3 压测完成后的配套调优循环
压测和调优是一个循环过程,不是测一轮就结束了。我的建议是“压测 → 定位瓶颈 → 针对性优化 → 再压测 → 再定位”,每一轮集中解决一个问题。如果一口气同时改了缓存、连接池、JVM 参数、SQL 索引,性能提升后你根本不知道是哪个改动起了作用,下次遇到同类问题还是要从头排查。
具体的节奏可以是这样的:第一轮压测先摸清系统当前的真实容量,定义一个基准;第二轮开始针对前面分析出的最大瓶颈做优化,改一个点,压一轮;第三轮验证优化效果的同时,看其他环节有没有出现新的瓶颈。每一轮压测的线程数、持续时间、请求模型要保持一致,数据才有可比性。
持续时间和线程数上,我建议单轮压测至少持续 10 到 15 分钟,不能只跑 30 秒。原因很简单,短时间压测只反映系统在“冷状态”下的表现,压测 5 分钟之后 GC 开始频繁了、连接池开始占满了、磁盘 I/O 上来了,这些长时间才能暴露的问题,短压完全看不到。我们有一次优化完很满意的方案,结果连续压了 20 分钟,在 13 分钟时突然 OOM,排查发现是线程池队列无界导致内存被积压的任务占满了。这种问题,30 秒压测根本发现不了。
6. 常见问题与排查技巧实录
6.1 高并发场景下的经典故障列表
我做过的 Spring Boot 高并发项目不少,下面这几个问题是我几乎每次压测都会遇到的,整理成表格方便对照排查:
| 问题现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 响应时间突然飙高 | 数据库连接池排队等待 | 查看 HikariCP 活跃连接数,数据库慢查询日志 | 调整池大小,优化 SQL,加缓存 |
| 错误率持续上升 | 线程池队列积压,任务超时 | 查看 Tomcat 线程池使用率 | 扩容、异步化,调整 accept-count |
| 进程被 OOM Kill | 堆内存或堆外内存溢出 | 看 GC 日志和容器内存监控 | 调整 JVM 参数,排查内存泄漏 |
| Redis 命中率低 | 缓存过期策略不合理 | 看 Redis 慢日志,统计 key 过期频率 | 调整过期时间,增加逻辑过期 |
| CPU 使用率 100% | 死循环或 GC 频繁 | 用 jstack 看线程栈,用 jstat 看 GC | 定位问题代码,优化热路径 |
6.2 排查工具与方法:别只盯着日志
遇到高并发问题,只靠看应用日志是不够的,因为日志本身在高并发下也可能成为瓶颈。我的经验是,第一优先看指标和数据,第二才是看日志。用 Arthes 或 JProfiler 这类工具在线诊断,实时看线程栈、内存分布、CPU 热点,比翻日志高效得多。
我这里特别想推荐一个便宜的排查思路:在压测前把 Actuator 的/actuator/metrics、/actuator/health打开,配合 Prometheus + Grafana 搭一套监控。这样压测时你能实时看到 TPS、响应时间、线程池状态、数据库连接池状态、JVM 的 GC 情况。哪个环节先出问题,一眼就看得出来,不用猜。
6.3 迁移和升级路上的几个隐藏坑
回到文章开头说的 Spring Boot 4.0.3 升级和云迁移,这里我想补充几个容易被忽略的隐藏坑:
第一个是字符集问题。JDK 18 之后,默认字符集从 UTF-8 变成了系统区域设置决定的字符集。如果新的 ECS 系统区域不是 UTF-8,那整个系统默认字符集变成了 UTF-8 之外的其他值(比如 ANSI_X3.4-1968),Spring Boot 应用处理中文请求参数或数据库读写时,可能突然出现乱码。解决办法是在启动参数里显式加上-Dfile.encoding=UTF-8,或者设置环境变量JAVA_TOOL_OPTIONS=-Dfile.encoding=UTF-8。
第二个是 DNS 解析缓存问题。Java 应用默认对 DNS 做正向缓存,缓存时间在 JDK 里设置得比较长。当你把服务迁移到新环境,域名对应的 IP 变了,如果 Java 进程没重启,它还会继续连旧的地址。在高可用场景下这是个很大的坑,建议在启动参数里设置-Dsun.net.inetaddr.ttl=60,让 DNS 缓存最多保持 60 秒,这样即使后端 IP 变了,也能快速自动切换到新地址。
第三个是时区问题。云上 ECS 默认时区可能是 UTC,而业务数据库里的时间可能要求是北京时间。如果应用和数据库的时区不一致,写入的时间会差 8 个小时,排查起来特别费劲。我的建议是:应用容器里设置时区环境变量TZ=Asia/Shanghai,数据库连接串里加上serverTimezone=Asia/Shanghai,从源头杜绝这种坑。
还有一个很隐蔽的问题是时间和日志的格式。迁移到云上之后,如果多个实例的时钟没有同步(NTP 没配好),那么排查多实例日志时会发现日志时间对不上,明明同一时刻发生的问题,日志里却差了十几秒。我习惯在部署的时候把 NTP 同步作为一个 checklist 项,虽然看起来不起眼,但真出问题的时候,它会让你多花好几个小时。
6.4 压测本身也要防“作弊”
最后说一个容易被人忽视的点:压测脚本的设计如果不注意,压出来的数据会自欺欺人。比如,如果你的压测脚本里没有加思考时间(Think Time),所有线程都在持续不断地猛打请求,那这个压测结果代表的是“系统极限压力”而不是“真实用户访问”。做容量规划时,建议按业务实际情况加上思考时间,让压测模型更接近真实流量。
另外,JMeter 本身的性能也有限,单机跑 1000 个线程时,JMeter 客户端自己就成瓶颈了。如果压测需求更大,需要用多台压测机做分布式压测,或者用阿里云的性能测试服务(PTS)。当年我们在验证云上承载能力时,就是先确认压测机的性能不会成为瓶颈,才敢相信压测数据。
说实话,Spring Boot 4.0.3 配合 JDK 25 的组合,在云原生和高并发领域的表现确实超出了我的预期。虚拟线程让并发编程回归到了最自然的同步模型,模块化的框架让容器镜像更轻、启动更快,而 JDK 25 底层对性能的优化,让同样的机器能支撑更大的流量。但工具再强,设计思路跟不上,一样会踩坑。希望这篇文章里的实践细节,能在你升级或迁移的时候帮你少走几步弯路。