一个微服务启动要 110 秒,扩容时新实例两分钟才接流。压测显示"启动期 CPU 打满"——而查下来是三件事叠在一起:Spring 扫描 800 个 Bean、连接池预热 200 条连接、以及一次"顺手"的全量字典加载(60 万行)。
启动时间是个"被容忍的问题":它不在 SLA 里,但它在扩容、回滚、故障恢复里都会变成真实的等待。这类问题的排查有"帕累托"特征:前十行日志里的时间戳差,通常就能指出 70% 的耗时。而多数团队从没打过这些时间戳,于是只能"猜是框架慢"。
而它的成本分布很不均匀——多数时间花在三五处地方。
1. 启动耗时的六段
| 段 | 典型占比 |
|---|---|
| JVM 启动与类加载 | 5–10% |
| 框架初始化(扫描、装配) | 20–30% |
| Bean 创建(含数据源) | 20–30% |
| 连接池与外部预热 | 10–20% |
| 数据预加载(字典、缓存) | 10–30% |
| 健康检查就绪 | 秒级 |
第五项最容易被"顺手加":"启动时把字典全量加载到本地缓存"第五项的动机通常很正当:"避免首次查询慢"。但它把成本从"首次请求"移到了"启动",而两者的差是——首次请求只影响一个人,启动影响整个实例能不能接流量。
看起来是优化(避免首次查询慢),实际把启动时间从 40 秒推到了 110 秒。
2. 怎么量:先定位到段
| 手段 | 得到 |
|---|---|
| 启动日志时间戳差值 | 各段耗时(粗) |
-Xlog:class+load |
类加载量与耗时 |
-XX:+PrintCompilation |
编译情况 |
| JFR / async-profiler(启动期采样) | 火焰图 |
Spring 的 StartupStep |
Bean 级耗时 |
第一项的具体做法:在 main、上下文初始化完成、每个 @PostConstruct 前后各打一行。十行日志就能画出时间轴,而成本是十分钟。
第一项最便宜:在关键位置打时间戳日志,两分钟就知道哪段慢。而它常被跳过,因为"看起来都知道是 Spring 慢"。
第五项最精确:Spring Boot 2.4+ 的启动追踪能列出"哪个 Bean 花了多久",于是"800 个 Bean 里是哪 5 个慢"变成可回答的问题。
// 启动期采样(JFR,从进程启动就开始)
java -XX:StartFlightRecording=duration=120s,filename=startup.jfr -jar app.jar
// 然后用 jfr 工具看 "ExecutionSample" 与 "ClassLoad" 事件
jfr summary startup.jfr
3. 五处最常见的耗时
| 来源 | 典型耗时 |
|---|---|
| 组件扫描范围过大 | 10–30 秒 |
大量 @Configuration 条件装配 |
5–15 秒 |
| 数据源初始化 + 校验 | 5–10 秒 |
| 全量字典/缓存预热 | 10–60 秒 |
| 定时任务与 MQ 消费者启动 | 5–10 秒 |
第一项最容易改:把 @ComponentScan 从"整个基础包"缩到"本服务包"。它在"多模块共享基础包"的项目里尤其明显。
第四项要"分级预热":先加载"必须的少量",其余异步加载。第四项还有一个"分批"版本:按分区/租户分批预加载,第一批完成即"可服务",其余在后台继续。这需要"部分可用"的语义(可接受),而不是"全有或全无"。
这样启动快,而"未预热的数据"最多导致首次查询慢——比"两分钟不能接流"划算。
// 分级预热:关键数据同步,其余异步
@EventListener(ApplicationReadyEvent.class)
public void warmup() {
dict.loadCritical(); // 约 2000 行,200ms
CompletableFuture.runAsync(dict::loadAll); // 60 万行,后台跑
}
// 关键:ApplicationReadyEvent 之前不做重活(否则拖慢就绪)
4. 就绪与"能接流量"的差别
| 信号 | 含义 |
|---|---|
| 端口可连 | 进程起来了 |
/actuator/health 200 |
依赖可连 |
| 业务预热完成 | 能正确服务 |
| 连接池已建立 | 不会首批超时 |
第四项的量化:每条建连 5–20ms,50 条就是 0.25–1 秒,而这段时间的请求全部在等。所以"连接预热"是"就绪探针"应当覆盖的一项。
第四项常被忽略:连接池按需建立时,前几十个请求都要等建连(每次几十毫秒),于是"刚起来的实例特别慢"。预热连接(initialSize 或启动时执行一次校验查询)能消除这段。
第三项决定了"是否要等",而这取决于业务:字典必须等,推荐位可以不等。
启动时间的"版本对比"要固定口径,否则优化效果会误判:
// 测启动时间的三个约定
1 从进程 exec 到"就绪探针返回 200"(不是到"日志打印 Started")
2 冷启动:清页缓存或用全新实例(不是 restart 同一个进程)
3 至少 3 次取中位数(JIT/缓存会波动)
// 常见误判:用"日志出现 Started"当终点 → 忽略了就绪前的那几秒
5. 缩短启动的四种手段
| 手段 | 收益 |
|---|---|
| 缩小扫描与装配 | 高 |
延迟初始化(spring.main.lazy-initialization) |
中高(但会把耗时移到首次请求) |
| 应用 CDS / AOT | 高(JVM 层) |
| 减少启动期外部调用 | 高 |
第二项有代价:"启动快"换来"首次请求慢",而首次请求往往是用户在等。第二项在"滚动发布"时尤其危险:新实例起来后首批请求全慢,而负载均衡此时正把流量切过来,于是 P99 尖峰出现在"发布完成"之后。
所以它适合"有稳定流量、且能承受首请求延迟"的场景。
第三项(CDS/AOT)是"JVM 层"的收益:AppCDS 能把类加载时间减半,而 spring-aot 能在构建期生成装配代码,减少运行期反射。
// AppCDS 的三步(收益:类加载 5–10s → 2–4s)
1 训练运行:java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar
2 生产启动:java -XX:SharedArchiveFile=app.jsa -jar app.jar
3 验证:-Xlog:class+load 看 "shared" 比例
// 注意:训练运行的启动参数要与生产一致,否则归档失效
"启动期"还有一个常被忽略的部分:镜像拉取与容器创建。它不在应用日志里,却在"从调度到进程启动"之间——而在节点首次拉镜像时可能有几十秒。
// 端到端拆解(扩容时)
t0 调度成功 → t1 容器创建 → t2 镜像就绪 → t3 进程启动 → t4 就绪
// 应用日志只能看到 t3 之后;t0–t3 要在编排侧看(kubectl describe / events)
6. 排错:五种"启动慢查不出"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 都在 Spring 初始化 | 扫描范围大 | 看 StartupStep |
| 卡在某个 Bean | 该 Bean 在做外部调用 | 移出启动路径 |
| 启动后前几十请求慢 | 连接池未预热 | 预热连接 |
| 偶发很慢 | DNS/配置中心超时 | 加超时与本地缓存 |
| 扩容时特别慢 | 镜像冷启动 + 预热 | 分级预热 + 预热实例 |
第四项还有个"配置中心"版本:启动时同步拉取远端配置,网络抖动就卡住,而超时默认可能是"无限等"。所以启动路径上的每个外部调用都应当有超时。
第四项很隐蔽:启动时解析一个主机名,DNS 慢就卡住,而它看起来像"框架慢"。
最后一条"平衡":不要为了启动时间把初始化逻辑藏起来。常见的坏做法是"把重活挪到后台线程且没人管失败"——于是启动很快,而"某个字典没加载成功"静默发生,直到用户报错。
// 反例:后台预热失败无人知
CompletableFuture.runAsync(dict::loadAll); // 异常被丢掉
// 正例:记录状态与失败,并暴露到健康检查
CompletableFuture.runAsync(dict::loadAll)
.whenComplete((v, e) -> { if (e != null) log.error("warmup failed", e); ready.set(e == null); });
// 就绪探针可以反映"预热完成与否",而不是"进程活着"
7. 边界:什么情况不必优化启动
| 场景 | 为什么 | 做法 |
|---|---|---|
| 长期运行的固定实例 | 启动只发生一次 | 不必 |
| 批处理任务(跑完即退) | 启动占比小 | 不必 |
| 单体应用(不扩容) | 无扩容等待 | 不必 |
| 已用预热实例池 | 扩容不冷启动 | 不必 |
判断口径一句话:启动时间会不会变成"用户等待"或"容量缺口"。最后一条与"容量"有关的边界:启动时间直接决定"故障后多久能恢复容量"。一个 110 秒启动的服务,在"全实例重启"时要两分钟才能恢复;而另一个 20 秒启动的,只需要 20 秒——这个差别在故障里就是用户可感知的时长。
会(频繁扩容、滚动发布、故障恢复),就值得优化;不会(稳定长跑),它是运维体验问题而非技术债。
8. 自测
- 启动耗时六段里,哪一段最容易被"顺手加"?为什么"全量预热"反而是负担?
- 怎么用最便宜的方式定位到"哪段慢"?Spring 的启动追踪解决了什么具体问题?
- "分级预热"为什么比"全量预热"划算?它把延迟移到了哪里?
- 四种就绪信号里,哪一个常被忽略?它会造成什么现象?
- "延迟初始化"的代价是什么?它适合什么场景?
- 判断"是否需要优化启动"的那个问题是什么?