每次发版后的头几秒,接口都会超时
滚动发布时,每替换一个实例,监控上就出现一小簇超时,几秒后自己消失。新实例的健康检查是通过的,CPU 和内存都正常,代码也没有性能问题:同一个实例跑上一分钟之后,同样的请求只要几毫秒。
一个刚启动的 Java 进程和一个运行了一分钟的 Java 进程,执行同一段代码的速度可以差几个数量级。第一次执行要加载类、初始化静态状态,接着是解释执行,然后才由即时编译器一级一级地编译成机器码。健康检查通过只说明进程能响应,不说明它已经热了。流量在这个时候按正常的权重打进来,头几批请求就慢。
本文把这个过程量出来,再看三种缓解办法各自改变了什么。实验见文末,在 JDK 25.0.4.1、限 2 个 CPU 的容器里运行。
一、同一个函数,第几次调用
实验的处理函数接近一个小接口的工作量:拼字符串、正则提取、放进有序的 Map、流式拼接、格式化时间、算 SHA-256。每次启动一个全新的 JVM,连续调用一万次:
| 第 1 次 | 第 2—10 次 | 第 91—100 次 | 第 901—1000 次 | 第 9901—10000 次 | |
|---|---|---|---|---|---|
| 单次耗时 | 28.1 ms | 308 µs | 85 µs | 23 µs | 9.8 µs |
| 相对稳定后 | 约 2900 倍 | 31 倍 | 9 倍 | 2.3 倍 | 1 |
三个阶段:
第 1 次:28 毫秒。 这一次的时间几乎都不在业务逻辑上。处理函数用到的每个类(正则、时间格式化、摘要算法、流、字符串格式化)都要从磁盘读入、校验、链接、执行静态初始化;每个 lambda 第一次用到时要生成并链接。这些工作每个进程只做一次。
第 2 次到几百次:几百到几十微秒。 类已经加载完,代码在解释器里执行。解释器一边执行一边统计每个方法被调用了多少次、每个分支走了哪边。调用次数过了门槛的方法先由 C1 编译成机器码,速度提高一截。
一千次之后:十微秒上下。 最热的方法由 C2 根据前面收集的统计信息再编译一次,做内联、逃逸分析等优化,达到稳定的速度。
解释器对稳定速度的贡献可以单独看:
| 参数 | 第 1 次 | 第 91—100 次 | 第 9901—10000 次 |
|---|---|---|---|
| 默认 | 28.1 ms | 85 µs | 9.8 µs |
-Xint(只用解释器) | 28.4 ms | 392 µs | 392 µs |
-XX:TieredStopAtLevel=1(只用 C1) | 26.9 ms | 68 µs | 10.5 µs |
只用解释器时,一万次之后仍然是 392 微秒,是默认参数下的 40 倍。稳定后的速度几乎全部来自即时编译,而编译需要先执行足够多次。 只用 C1 时前期更快一些(少了 C2 的编译开销),这个小函数的稳定速度也接近;计算密集的代码在 C2 下会明显更快。即时编译做了哪些优化,见 JIT 与逃逸分析。
二、放到一个真实的服务里
实验的函数很小,没有框架。真实的服务里同样的三个阶段都在,只是每一段都更长:
- 第一次请求要走过 Web 框架、序列化、数据访问层的整条路径,沿途初始化的类有几千个。
- 连接池是懒创建的话,第一批请求还要等数据库连接和 TLS 握手建立。
- 各级缓存是空的。
- 即时编译占用 CPU。容器的 CPU 限额越小,编译线程与业务线程争抢越明显,热起来越慢。这时如果因为「启动时 CPU 高」触发了扩容,新扩出来的实例同样是冷的。
所以刚启动的实例处理请求的能力是逐渐爬升的。负载均衡按「每个就绪的实例能力相同」来分配流量,一个冷实例拿到与热实例同样多的请求,处理不过来的在它那里排队。这与一个实例变慢是同一种情况,见十个实例里一个变慢,十分之一的请求都超时了。
三、缓解办法
先预热,再报告就绪
让进程在接流量之前,自己把主要的代码路径走几遍:
| 第一个真实请求 | 从启动到第一个响应 | 前 100 个请求合计 | |
|---|---|---|---|
| 默认 | 28.1 ms | 46.8 ms | 44.3 ms |
| 先调用 3000 次再接流量 | 0.1 ms | 119 ms | 2.9 ms |
第一个真实请求从 28 毫秒降到 0.1 毫秒,代价是晚 72 毫秒才能开始服务。预热 3000 次之后还没有到最终的稳定速度(之后的第 901—1000 次是 20 微秒,稳定后是 9 微秒),但最慢的那一段已经过去了。
做法上有几点要注意:
- 预热要走真实的代码路径:经过 HTTP 层、序列化、数据访问,而不是只调用某个内部方法。预热没有覆盖到的分支,第一次走到时仍然是慢的。
- 预热请求不能有副作用。用只读接口,或者带上明确的标记让写操作被跳过,见压测流量写进了正式表。
- 预热完成之后才让就绪探针通过。Kubernetes 里用启动探针给预热留出时间,见 Running 不等于可用。
让流量逐渐进来
在负载均衡一侧,给新实例一个从低到高的权重爬升期(慢启动)。实例用真实流量把自己热起来,同时冷的时候只承担一小部分请求。它不需要改应用,也不存在「预热覆盖不到」的问题;需要负载均衡器或服务网格支持。
AOT 缓存
JDK 24 引入、25 继续扩展的 AOT 缓存,把一次训练运行里加载和链接好的类、以及收集到的方法执行统计存成一个文件,之后启动时直接使用:
java -XX:AOTCacheOutput=app.aot -cp app.jar com.example.Main # 训练运行,退出时生成缓存
java -XX:AOTCache=app.aot -cp app.jar com.example.Main # 之后带着缓存启动| 第 1 次 | 第 2—10 次 | 第 901—1000 次 | 从启动到第一个响应 | |
|---|---|---|---|---|
| 默认 | 28.1 ms | 308 µs | 23 µs | 46.8 ms |
| 带 AOT 缓存 | 13.9 ms | 299 µs | 11.5 µs | 32.0 ms |
第 1 次调用的耗时减半,因为类已经是加载、链接好的状态。第 901—1000 次从 23 微秒降到 11.5 微秒,因为缓存里带着训练运行的统计信息,即时编译不用从头收集,更早开始。解释执行的那一段(第 2—10 次)没有变化。
使用上的限制:
- 缓存与 jar、类路径、JDK 版本绑定,任何一个变了就要重新生成,所以它是构建流水线里的一步。
- 只收录从 jar 加载的类。
- 训练运行要像生产一样使用应用。训练时没有走到的代码,缓存里没有它的信息。
它与预热不冲突:AOT 缓存缩短的是每个进程都要重复做的那部分工作,预热和慢启动处理的是剩下的爬升过程。
四、检查清单
- 评估接口性能时区分冷和热。压测要运行足够长,并且单独看启动后头几十秒的延迟分位。
- 发布过程中的超时与错误单独统计。它们每次发布都出现,平均到全天看不出来。
- 就绪探针在预热之后才通过;或者负载均衡对新实例做慢启动。
- 连接池、关键缓存在启动时建立,不留给第一批请求。
- 滚动发布一次替换的实例数,按「剩下的热实例能扛住全部流量」来定。
- 容器的 CPU 限额给启动阶段留余量。
- 扩容用的指标避开启动阶段,否则冷实例的高 CPU 会触发更多的冷实例。
配套实验
- codesphere-labs/java/jvm-warmup-first-requests:JDK 25.0.4.1、限 2 个 CPU:同一个处理函数在新 JVM 里连续调用一万次,默认参数、
-Xint、只用 C1、带 AOT 缓存、先预热 3000 次五种情况下各阶段的单次耗时,每种 5 个进程取中位数;脚本只断言倍数关系(验证记录)
参考资料