Administrator
发布于 2024-10-12 / 11555 阅读
226

Spring Boot 应用启动速度优化:CDS 与 AOT 实践

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 ms27%加载 21463 个类
Bean 定义扫描与注册1980 ms24%ComponentScan 扫 47 个包
Bean 实例化 + 自动配置2650 ms32%含 Redis、MyBatis、WebClient 初始化
内嵌 Tomcat 启动 + 端口绑定780 ms10%
应用自定义初始化580 ms7%词典加载、连接池预热

能优化的部分清楚了:类加载 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/sourcestarget/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 ms2210 ms4630 ms72 s480 MB
+ CDS6520 ms620 ms4650 ms78 s465 MB
+ AOT5940 ms2180 ms2510 ms96 s440 MB
+ CDS + AOT4310 ms610 ms2480 ms104 s425 MB
GraalVM Native Image186 ms6 min 40 s128 MB

Native Image 为什么没上

186 毫秒的启动确实诱人,但我们最后没用它,理由是三个:

  1. 构建耗时 6 分 40 秒,内存峰值到过 11 GB。我们的 CI 机器是 8C16G,构建时 OOM 过两次。CDS+AOT 只增加了 32 秒构建时间。
  2. 大模型 SDK 里有大量反射和动态代理。我们用的一个 SDK 内部用 java.lang.reflect.Proxy 生成客户端,Native Image 需要一份完整的 proxy-config.json,写起来费劲,而且 SDK 一升级就可能失效。
  3. 峰值吞吐反而下降。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 实践》里同一个坑绊住,回来翻这篇,能省半小时。

参考