同一份 jar,笔记本上 Started Application in 3.1s,进容器和镜像仓库后变成四十秒。不是 JVM 变慢,而是启动被拆成几段可分别计价的工作,部署形态决定每段的权重。启动时间在单体里是开发体验问题,在弹性伸缩下是容量问题——副本还没就绪就被判死,等于买了机器不产生吞吐。
判断顺序:先量清那几十秒落在哪一段,再决定改代码、改打包还是改探针。跳过测量直接开 lazy-initialization,最常见结局是把启动期错误推迟成运行期 500。
1. 把启动切成五段分别计时
Spring Boot 3 的 ApplicationStartup 提供了标准的阶段埋点,用 BufferingApplicationStartup 就能在日志里打出全部分段。
public static void main(String[] args) {
SpringApplication app = new SpringApplication(ClarityWebApplication.class);
app.setApplicationStartup(new BufferingApplicationStartup(2048));
app.run(args); // 启动后遍历 getStartupEvents,按 duration 倒序打日志
}
| 阶段 | 内容 | 典型量级 | 主要削减手段 | 代价 |
|---|---|---|---|---|
| 类加载与校验 | 解压 jar、读 class、链接 | 秒级,类数越多越长 | 精简依赖、CDS/AppCDS、分层 jar 缓存 | CDS 需随镜像重建 |
| 组件扫描 | 遍历 classpath 匹配注解 | 与包规模相关 | 收窄 scanBasePackages、去掉无用 starter |
改错一行就注入失败 |
| 自动配置 | 条件评估 + Bean 实例化 | 与 starter 数量相关 | 排除不需要的自动配置 | 排除后需要手写装配 |
| 连接池与外部资源 | 建连、认证、TLS、预热 | 网络 RTT × 连接数 | 懒建连、minimumIdle 调小 |
首请求延迟抬高 |
| 数据预热 | 缓存、字典、规则加载 | 取决于数据量 | 异步预热、分批 | 就绪判定要跟着改 |
先看分段再开药:如果 40 秒里有 25 秒在等建连超时,优化扫描包毫无用处。
2. 组件扫描与自动配置:先减依赖,再谈启动
多数"启动慢"的真实原因是 classpath 上躺着一堆用不到的 starter。判断方法很朴素:逐个注释掉 starter、启动一次、比对分段耗时。
// 收窄扫描面:默认从主类所在包向下扫,显式声明能省掉无关子树
@SpringBootApplication(scanBasePackages = "top.clxhxhhr.web")
public class ClarityWebApplication { }
// 排除确认不用的自动配置,比 scanBasePackages 更细粒度
@SpringBootApplication(exclude = {
DataSourceAutoConfiguration.class,
RedisAutoConfiguration.class })
这类改动收益确定、风险也确定:删 starter 与收窄扫描面改错就启动报错,不会潜伏到运行期,所以最该先做。
3. spring.main.lazy-initialization:把错误搬了个位置
全局开关:启动时只创建被依赖链强制需要的 Bean,其余延迟到首次使用。
spring:
main:
lazy-initialization: true
# 反例:全局懒加载 + 关键 Bean 也懒加载 = 第一个请求吃全部成本
| 维度 | 立即初始化 | 全局懒初始化 |
|---|---|---|
| 启动耗时 | 全部 Bean 建完 | 只建入口链路 |
| 配置错误暴露时机 | 启动期,进程起不来 | 运行期,第一个请求 500 |
| 首请求延迟 | 平稳 | 尖峰,可能叠加建连与预热 |
| 内存峰值 | 启动即达峰 | 渐进抬升,更难做容量判断 |
| 适合场景 | 发布频率低、要求"起来就是对的" | 短生命周期、Worker 型进程 |
反直觉的结论:懒加载几乎不减少总工作量,只把工作量挪到请求路径。对常驻服务,它把集中、可观测的故障换成分散、随机的故障。逐 Bean 挑"重且不急"的(报表聚合、字典同步)标 @Lazy,比全局开关可控得多。
4. AOT 与 native image:把启动收益和构建成本一起算
Spring Boot 3 的 AOT 发生在构建期,产出额外生成的 Java 源码与反射元数据;native image 再把这些静态编译成二进制。二者常被混称,解决的是不同问题。
# 构建期生成 AOT 产物(类路径模式,仍跑在 JVM 上)
mvn -Pnative spring-boot:process-aot
# 完整 native 构建,耗时为分钟到十几分钟量级,按机器与依赖规模核对
mvn -Pnative native:compile
| 维度 | JVM + fat jar | native image |
|---|---|---|
| 构建时长 | 分钟级 | 十几分钟到几十分钟量级,需单独流水线与缓存 |
| 启动耗时 | 秒级到数十秒 | 明显更低,具体倍数随应用构成变化 |
| 反射/动态代理 | 运行期自由使用 | 必须显式注册,缺一条就 ClassNotFound |
| 常驻内存下限 | 堆 + Metaspace,可调 | 无 JIT/Metaspace,但代码段与元数据占位固定 |
| 峰值吞吐 | JIT 优化后可高于静态编译 | 通常低于长跑 JIT |
| 调试难度 | 常规 attach、堆转储 | 堆转储与采样受限,排障工具链更窄 |
决策口径是进程寿命:跑几十秒的短生命周期进程,构建成本一次性付出、启动收益每次兑现;跑几十天的常驻服务,启动只发生一次,为它牺牲峰值吞吐和调试能力不划算。
5. 打包形态决定镜像层的缓存命中
fat jar 是不可分的大文件,改一行代码整层失效,推送量与拉取时间跟着走。分层 jar 把不变的依赖、loader 与变化的应用类拆开。
FROM eclipse-temurin:17-jre AS builder
WORKDIR /app
COPY target/clarity-web.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=builder /app/extracted/dependencies/ ./
COPY --from=builder /app/extracted/spring-boot-loader/ ./
COPY --from=builder /app/extracted/snapshot-dependencies/ ./
COPY --from=builder /app/extracted/application/ ./
ENTRYPOINT ["java", "-jar", "app.jar"]
| 形态 | 层缓存收益 | 启动影响 | 适用 |
|---|---|---|---|
| fat jar 单层 | 无,改一行全层失效 | 基线 | 本地开发、临时环境 |
| 分层 jar(layertools / tools extract) | 依赖层稳定命中,日常发布只推应用层 | 与基线接近 | 常规容器部署 |
| 依赖外置 + 瘦 jar | 依赖层完全不变,镜像可复用 | 类加载路径变多,略降 | 多服务共用同一基础依赖集 |
镜像内 -XX:SharedArchiveFile |
类加载段再缩短 | 需随 JDK 与依赖重建 CDS | 已确认启动是瓶颈 |
jarmode 在 3.x 小版本上有 layertools 与 tools 两种写法,按自己项目的版本核对。
6. 瘦 jar 与依赖外置:容器里值得做,但不是免费
瘦 jar 把 BOOT-INF/lib 挪出 jar,用 loader.path 或 classpath 目录加载。
java -Dloader.path=/app/lib -jar clarity-web-thin.jar
代价三条:类加载要扫更多目录项,启动段轻微回退;依赖与应用的版本关系从"jar 内自洽"变成"运维必须对齐",错配表现为 NoSuchMethodError 而不是启动失败;-Dloader.path 属 Spring Boot loader 行为,换打包插件需重新验证。收益在镜像复用与增量发布。
7. 探针:startupProbe 存在的理由
livenessProbe 的语义是"进程是否该被重启",readinessProbe 是"是否该接流量"。启动慢的服务如果只配这两个,会出现"应用还在建连、liveness 已经判死"的循环重启。
startupProbe: # 只负责"给它时间",成功前不跑 liveness
httpGet: { path: /actuator/health/liveness, port: 8080 }
failureThreshold: 30
periodSeconds: 2
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 10
readinessProbe: # 只负责"能不能接流量",失败只摘流量不重启
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 5
| 现象 | 机制原因 | 处理方向 |
|---|---|---|
| 副本反复重启,日志停在建连阶段 | failureThreshold × periodSeconds 小于真实启动时间 |
补 startupProbe,阈值按 P95 启动时间留余量 |
| 起来了但一直不接流量 | readiness 判据含预热完成,却从没完成 | 把预热做成可观测状态,别只靠时间猜 |
| 滚动发布卡住 | maxUnavailable 与启动时间叠加,新副本迟迟不可用 |
拆 readiness 与 liveness,缩短就绪路径 |
| 扩容时被杀 | 弹性伸缩的 scaleUp 与探针容忍不匹配 |
冷启动时间必须进扩容预算 |
冷启动与扩容的交互最易被忽略:扩容决策基于"当前 CPU 高",新副本却要几十秒才就绪,这段时间旧副本继续被压。启动时间大于流量增长的时间常数时,扩容即无效扩容;它也是容量模型的一部分。
8. 排错:五种"启动了但不像预期"
| 现象 | 最可能的原因 | 核对方式 |
|---|---|---|
| 本地快、容器慢 | CPU limit 过低导致 JIT 编译线程被节流 | 对比容器内外 jcmd <pid> VM.flags 与 nproc |
| 启动耗时忽长忽短 | 建连重试、DNS 解析、配置中心长轮询超时 | 看分段耗时里"外部资源"段的抖动 |
| 启动后第一批请求全超时 | 懒加载 + 连接池 minimumIdle 同时调小 |
抓首请求的线程栈与池指标 |
| 镜像拉取占了大半发布时长 | 单层 fat jar,每次全量推送 | docker history 看层大小分布 |
| native 镜像启动报缺反射配置 | 动态代理/序列化类未注册 | 启动日志里的 --enable-url-protocols 类提示与 trace 文件 |
9. 边界:什么时候优化启动就是浪费
| 场景 | 判断 | 理由 |
|---|---|---|
| 单体应用,一天发布一次 | 不优化 | 一年只多等几十分钟,改打包的验证成本更高 |
| 常驻不重启,靠滚动发布 | 优先降低单批不可用窗口 | 启动时间只在发布期兑现 |
| 已确认启动在 SLA 可接受区间 | 转向请求路径优化 | 用户感知的是 P99 而不是 Started in |
| 弹性伸缩依赖秒级扩容 | 值得投入 | 启动时间直接决定扩容有效性 |
| 短生命周期批处理/函数 | 值得投入 | 每次执行都付启动成本 |
| 团队没有分段埋点 | 先埋点 | 没有分段数据,任何改动都无法归因 |
最后一条是入口:启动优化需要基线。没有分段耗时与发布频率这两个数字,讨论 lazy-initialization 或 native image 只是交换立场。
10. 自测
- 容器里启动比本地慢十倍,你会先看哪两个数字?
lazy-initialization把哪一类故障从启动期挪到了运行期?- 分层 jar 的核心收益是什么,它和"启动更快"是同一件事吗?
startupProbe与livenessProbe的语义差别是什么,为什么不能合并?- 什么情况下扩容了却没有增加吞吐?
- 说出三个"优化启动时间属于浪费"的具体场景。