HPA 扩容跟不上流量,问题出在启动要 8 秒
10 月初做了一轮容量压测,暴露了个之前没重视的问题。我们的 AI 网关在 K8s 上配了 HPA,CPU 超过 60% 就扩容。压测时流量从 500 QPS 拉到 3000 QPS,HPA 在 15 秒内触发扩容,但新 Pod 从调度到 Ready 花了 23 秒,这期间旧 Pod 已经被打爆,502 数量从 0 冲到 1400 多个。
# kubectl get pod -w 观察到的时间线
09:14:02 hpa scaled up replica set ai-gateway to 8
09:14:02 pod/ai-gateway-7d9f-abc Pending
09:14:05 pod/ai-gateway-7d9f-abc Running # 调度+拉镜像 3s
09:14:05 pod/ai-gateway-7d9f-abc Ready=false # 开始启动
09:14:13 pod/ai-gateway-7d9f-abc Ready=false # 8s 了还没好
09:14:28 pod/ai-gateway-7d9f-abc Ready=true # 23s
23 秒里有 8.2 秒是 JVM 启动加 Spring 容器初始化,剩下的是调度、镜像拉取和就绪探针间隔。前两项我动不了,能压的就是这 8.2 秒。
先把 8.2 秒拆开看
我用 ApplicationStartup 加了启动埋点,配合 JDK 的 class 加载日志,分了三段:
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(GatewayApplication.class)
.applicationStartup(new FlightRecorderApplicationStartup())
.run(args);
}
}
$ java -XX:StartFlightRecording:filename=boot.jfr,settings=profile \
-Xlog:class+load:file=classload.log \
-Dspring.context.exit-on-refresh=true -jar ai-gateway.jar
| 阶段 | 耗时 | 占比 | 说明 |
|---|---|---|---|
| JVM 启动 + 类加载 | 2210 ms | 27% | 加载 21463 个类 |
| Bean 定义扫描与注册 | 1980 ms | 24% | ComponentScan 扫 47 个包 |
| Bean 实例化 + 自动配置 | 2650 ms | 32% | 含 Redis、MyBatis、WebClient 初始化 |
| 内嵌 Tomcat 启动 + 端口绑定 | 780 ms | 10% | |
| 应用自定义初始化 | 580 ms | 7% | 词典加载、连接池预热 |
能优化的部分清楚了:类加载 2.2 秒可以用 CDS 砍,Bean 定义扫描 2 秒可以用 AOT 砍。
AppCDS:2.2 秒的类加载降到 0.6 秒
先看自带的默认 CDS 有多少用
JDK 从 11 开始默认开启了 JDK 类库的 CDS(-Xshare:auto),这个不用配,已经在生效了。它的归档只包含 JDK 自己的类,我们应用那 1.8 万个类不在里面。
$ java -Xshare:on -XX:+PrintSharedArchiveAndExit -version 2>&1 | head -5
archive : /Library/Java/JavaVirtualMachines/jdk-21.jdk/Contents/Home/lib/server/classes.jsa
version : 68.0
sharing : on
classes : 1304 # 只有 JDK 的类
静态归档 vs 动态归档
AppCDS 有两种做法。传统静态归档要手工维护 class list 文件,维护成本高;动态归档是训练运行一次,用 -XX:ArchiveClassesAtExit 把实际加载的类全 dump 出来,简单得多。我只推荐动态归档。
第一步,训练运行。关键参数是 -Dspring.context.exit-on-refresh=true,让 Spring 容器初始化完就退出,不启动 Tomcat:
$ java -Dspring.context.exit-on-refresh=true \
-XX:ArchiveClassesAtExit=ai-gateway.jsa \
-jar ai-gateway.jar
第二步,正式运行:
$ java -XX:SharedArchiveFile=ai-gateway.jsa -jar ai-gateway.jar
验证归档有没有用上:
$ java -XX:SharedArchiveFile=ai-gateway.jsa \
-Xlog:class+load:file=/dev/stdout \
-Dspring.context.exit-on-refresh=true -jar ai-gateway.jar 2>&1 \
| grep -c "source: shared objects file"
17842
21463 个类里有 17842 个走了共享归档。剩下的主要是运行时动态生成的(CGLIB 代理、Lambda 的 $$Lambda$ 类、以及反射加载的类)。
Spring Boot 3.3 把这个过程封装进了构建插件,配一下就行:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<cds>
<enabled>true</enabled>
</cds>
</configuration>
</plugin>
动态归档的一个大坑:classpath 必须一致
CDS 归档记录的是类的元数据,包括它从哪个 jar 来的。如果你的 jar 文件名带了版本号(Spring Boot 默认就是这样,比如 ai-gateway-1.4.2.jar),那归档跟这个文件名是绑定的。
我们的 CI 每次构建都带 Git commit hash 做版本号,导致归档每次都失效。解决办法是构建时用固定文件名:
<build>
<finalName>${project.artifactId}</finalName> <!-- 不要带版本号 -->
</build>
还有一点,归档必须和 JDK 版本匹配。JDK 升级了,归档得重生成。我们在 CI 里把 JDK 版本号写进归档文件名:ai-gateway-jdk21.jsa。
AOT:把 2 秒的 Bean 扫描挪到构建期
AOT(Ahead-of-Time)的思路不一样。CDS 优化的是"类加载"这个 JVM 层面的动作,AOT 优化的是"Spring 容器初始化"这个框架层面的动作——它在构建期就把 @ComponentScan 扫完,生成一段普通的 Java 代码代替运行时的反射扫描。
构建:
$ mvn clean spring-boot:process-aot package
生成的代码在 target/spring-aot/main/sources 和 target/spring-aot/main/classes 里,长这样:
// 自动生成的 GatewayApplication__BeanDefinitions.java 节选
public class GatewayApplication__BeanDefinitions {
public static BeanDefinition getChatServiceBeanDefinition() {
RootBeanDefinition beanDefinition = new RootBeanDefinition(ChatService.class);
beanDefinition.setInstanceSupplier(ChatService::new); // 直接调构造器,不走反射
return beanDefinition;
}
public static void registerBeans(DefaultListableBeanFactory beanFactory) {
beanFactory.registerBeanDefinition("chatService", getChatServiceBeanDefinition());
}
}
运行时要打开开关才会用上:
$ java -Dspring.aot.enabled=true -jar ai-gateway.jar
AOT 的三个限制,踩了两个
限制一:条件装配必须在构建期能确定。 我们有个 Bean 的判断条件是 @ConditionalOnProperty(name="llm.provider", havingValue="openai"),而 AOT 构建时这个值来自 application.yml 的默认配置。也就是说运行时再改环境变量是不生效的。这个坑让我在生产环境排查了半小时——环境变量配的 llm.provider=qwen,但运行时用的还是构建期的 openai 配置。
解决:AOT 构建时就传好最终的环境变量,或者接受某些 Bean 必须在运行时决定(那就别指望 AOT 优化它)。
限制二:反射配置要自己声明。 AOT 会用 @RegisterReflectionForBinding 处理一部分,但 MyBatis 的 XML 映射、Jackson 的多态反序列化这类还是得手工加:
@RegisterReflectionForBinding({
ChatRequest.class, ChatResponse.class,
SkuResult.class, PageResult.class
})
@Configuration
public class ReflectionHintsConfig {
}
限制三:启动参数和构建参数要保持对齐。 profile、环境变量、jvm 参数不一致会导致运行时报 AotDetector 相关的错。我们在 Dockerfile 里把构建和运行用同一份 env 文件。
CDS + AOT 组合,以及 Native Image 的对比
两者是正交的,可以叠加。实测数据(4C8G 容器,各跑 10 次取中位数):
| 方案 | 启动耗时 | 类加载 | Bean 初始化 | 构建耗时 | 内存占用(RSS) |
|---|---|---|---|---|---|
| 基线(JVM) | 8210 ms | 2210 ms | 4630 ms | 72 s | 480 MB |
| + CDS | 6520 ms | 620 ms | 4650 ms | 78 s | 465 MB |
| + AOT | 5940 ms | 2180 ms | 2510 ms | 96 s | 440 MB |
| + CDS + AOT | 4310 ms | 610 ms | 2480 ms | 104 s | 425 MB |
| GraalVM Native Image | 186 ms | — | — | 6 min 40 s | 128 MB |
Native Image 为什么没上
186 毫秒的启动确实诱人,但我们最后没用它,理由是三个:
- 构建耗时 6 分 40 秒,内存峰值到过 11 GB。我们的 CI 机器是 8C16G,构建时 OOM 过两次。CDS+AOT 只增加了 32 秒构建时间。
- 大模型 SDK 里有大量反射和动态代理。我们用的一个 SDK 内部用
java.lang.reflect.Proxy生成客户端,Native Image 需要一份完整的proxy-config.json,写起来费劲,而且 SDK 一升级就可能失效。 - 峰值吞吐反而下降。Native Image 没有 JIT 的运行时优化,我们这个服务有大量 JSON 解析和字符串处理,压测下来同样的 CPU 下 QPS 比 JVM 低 18%。
Native Image 适合的场景是 Serverless 和命令行工具——启动频繁、运行时间短。我们这种长驻服务,牺牲峰值性能换启动速度不划算。
容器里跑 CDS 要注意的事
本地验证通过不等于容器里能跑。我们把归档塞进镜像之后,第一次启动直接失败:
[error] An error has occurred while processing the shared archive file.
[error] Unable to map shared archive file: /app/ai-gateway.jsa
原因是 CDS 归档要求内存映射地址空间可用,容器里如果开了 ASLR(地址空间随机化),映射可能失败。JVM 会重试,但在某些内核参数下重试也会失败。解决办法有两个,我们都用上了:
# 运行时加 -Xshare:auto,失败就退化成不共享,不至于起不来
ENTRYPOINT ["java", "-Xshare:auto",
"-XX:SharedArchiveFile=/app/ai-gateway.jsa",
"-Dspring.aot.enabled=true", "-jar", "/app/ai-gateway.jar"]
# 或者固定映射基址(0x800000000 是 HotSpot 的默认值)
-XX:SharedBaseAddress=0x800000000
第二个要注意的是 Dockerfile 的分层。CDS 归档有 200 多 MB,如果和应用 jar 放在同一层,每次代码变更都会让这一层失效,镜像推送要重传 200 MB。我们把归档单独放在一层,并且放在 jar 之前:
FROM eclipse-temurin:21-jre-alpine
COPY target/ai-gateway.jsa /app/ai-gateway.jsa # 归档变动少,放前面
COPY target/ai-gateway.jar /app/ai-gateway.jar # jar 每次都变,放后面
ENTRYPOINT [...]
这样日常构建只需要推最后一层(约 90 MB),CI 的镜像推送时间从 4 分 10 秒降到 1 分 20 秒。
验证归档在容器里真的生效了
别只看启动时间,要确认归档确实被用上了:
$ kubectl exec -it ai-gateway-xxx -- \
java -XX:+PrintSharedArchiveAndExit -version 2>&1 | grep -i sharing
sharing : on # 只有 on 才说明生效,off 说明归档没用上
我们第一次部署时这里显示 off,查下来是归档文件和 jar 的构建时间差了一天,jar 里的依赖树变了。后来在 CI 里加了一步校验:构建完归档之后立刻跑一次 -XX:+PrintSharedArchiveAndExit,不是 on 就让流水线失败。
还有什么别的启动优化手段
CDS 和 AOT 之外还有几个可选项,我们评估过:
| 手段 | 收益 | 我们是否采用 | 原因 |
|---|---|---|---|
懒加载 Bean(spring.main.lazy-initialization=true) | -1.1 s | 否 | 把启动耗时转移到了首次请求,我们的健康检查会失败 |
精简 ComponentScan 路径 | -0.8 s | 是 | 纯收益,把 47 个包收敛到 12 个 |
| 去掉不必要的 Starter | -0.4 s | 是 | 去掉了没在用的 Actuator 的部分端点和 JDBC |
| CRaC(检查点恢复) | 理论 -90% | 否 | 只支持特定 JVM 发行版,且要求预热后做快照,运维复杂度太高 |
| AppCDS 分层(多归档) | 额外 -0.2 s | 否 | 复杂度高收益低 |
其中"精简 ComponentScan"和"去掉多余 Starter"加起来 1.2 秒,改动量只有几行配置,是性价比仅次于 CDS 的手段。
上线效果
CDS + AOT 上线后,Pod 从 Running 到 Ready 从 23 秒降到 16 秒,压测的 502 从 1400 多个降到 190 个。剩下的 190 个靠调整就绪探针间隔和 HPA 的 stabilizationWindowSeconds 解决了。
# 探针间隔从 10s 改成 2s,失败阈值从 3 改成 2
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 2
failureThreshold: 2
successThreshold: 1
就写到这。如果哪天你也被《Spring Boot 应用启动速度优化:CDS 与 AOT 实践》里同一个坑绊住,回来翻这篇,能省半小时。