Skip to content

每次发版后的头几秒,接口都会超时 ​

滚动发布时,每替换一个实例,监控上就出现一小簇超时,几秒后自己消失。新实例的健康检查是通过的,CPU 和内存都正常,代码也没有性能问题:同一个实例跑上一分钟之后,同样的请求只要几毫秒。

一个刚启动的 Java 进程和一个运行了一分钟的 Java 进程,执行同一段代码的速度可以差几个数量级。第一次执行要加载类、初始化静态状态,接着是解释执行,然后才由即时编译器一级一级地编译成机器码。健康检查通过只说明进程能响应,不说明它已经热了。流量在这个时候按正常的权重打进来,头几批请求就慢。

本文把这个过程量出来,再看三种缓解办法各自改变了什么。实验见文末,在 JDK 25.0.4.1、限 2 个 CPU 的容器里运行。

默认参数:第几次调用第 1 次28.1 ms第 2—10 次308 µs第 91—100 次85 µs第 901—1000 次23 µs第 9901—10000 次9.8 µs第一个请求,三种启动方式默认28.1 ms带 AOT 缓存启动13.9 ms先预热 3000 次再接流量0.1 ms只用解释器(-Xint)时一万次之后仍是 392 µs:稳定后的速度几乎全部来自即时编译
图 1 · 实测(JDK 25.0.4.1,2 个 CPU;横轴是对数刻度):同一个处理函数,第 1 次调用要加载类、初始化静态字段、解释执行,是稳定后的两千多倍;之后随着即时编译逐级介入才降下来,第 100 次仍是稳定后的 9 倍。AOT 缓存把第 1 次的耗时减半;让进程先自己调用 3000 次再接流量,第一个真实请求是 0.1 毫秒。

一、同一个函数,第几次调用 ​

实验的处理函数接近一个小接口的工作量:拼字符串、正则提取、放进有序的 Map、流式拼接、格式化时间、算 SHA-256。每次启动一个全新的 JVM,连续调用一万次:

第 1 次第 2—10 次第 91—100 次第 901—1000 次第 9901—10000 次
单次耗时28.1 ms308 µs85 µs23 µs9.8 µs
相对稳定后约 2900 倍31 倍9 倍2.3 倍1

三个阶段:

第 1 次:28 毫秒。 这一次的时间几乎都不在业务逻辑上。处理函数用到的每个类(正则、时间格式化、摘要算法、流、字符串格式化)都要从磁盘读入、校验、链接、执行静态初始化;每个 lambda 第一次用到时要生成并链接。这些工作每个进程只做一次。

第 2 次到几百次:几百到几十微秒。 类已经加载完,代码在解释器里执行。解释器一边执行一边统计每个方法被调用了多少次、每个分支走了哪边。调用次数过了门槛的方法先由 C1 编译成机器码,速度提高一截。

一千次之后:十微秒上下。 最热的方法由 C2 根据前面收集的统计信息再编译一次,做内联、逃逸分析等优化,达到稳定的速度。

解释器对稳定速度的贡献可以单独看:

参数第 1 次第 91—100 次第 9901—10000 次
默认28.1 ms85 µs9.8 µs
-Xint(只用解释器)28.4 ms392 µs392 µs
-XX:TieredStopAtLevel=1(只用 C1)26.9 ms68 µs10.5 µs

只用解释器时,一万次之后仍然是 392 微秒,是默认参数下的 40 倍。稳定后的速度几乎全部来自即时编译,而编译需要先执行足够多次。 只用 C1 时前期更快一些(少了 C2 的编译开销),这个小函数的稳定速度也接近;计算密集的代码在 C2 下会明显更快。即时编译做了哪些优化,见 JIT 与逃逸分析。

二、放到一个真实的服务里 ​

实验的函数很小,没有框架。真实的服务里同样的三个阶段都在,只是每一段都更长:

  • 第一次请求要走过 Web 框架、序列化、数据访问层的整条路径,沿途初始化的类有几千个。
  • 连接池是懒创建的话,第一批请求还要等数据库连接和 TLS 握手建立。
  • 各级缓存是空的。
  • 即时编译占用 CPU。容器的 CPU 限额越小,编译线程与业务线程争抢越明显,热起来越慢。这时如果因为「启动时 CPU 高」触发了扩容,新扩出来的实例同样是冷的。

所以刚启动的实例处理请求的能力是逐渐爬升的。负载均衡按「每个就绪的实例能力相同」来分配流量,一个冷实例拿到与热实例同样多的请求,处理不过来的在它那里排队。这与一个实例变慢是同一种情况,见十个实例里一个变慢,十分之一的请求都超时了。

三、缓解办法 ​

先预热,再报告就绪 ​

让进程在接流量之前,自己把主要的代码路径走几遍:

第一个真实请求从启动到第一个响应前 100 个请求合计
默认28.1 ms46.8 ms44.3 ms
先调用 3000 次再接流量0.1 ms119 ms2.9 ms

第一个真实请求从 28 毫秒降到 0.1 毫秒,代价是晚 72 毫秒才能开始服务。预热 3000 次之后还没有到最终的稳定速度(之后的第 901—1000 次是 20 微秒,稳定后是 9 微秒),但最慢的那一段已经过去了。

做法上有几点要注意:

  • 预热要走真实的代码路径:经过 HTTP 层、序列化、数据访问,而不是只调用某个内部方法。预热没有覆盖到的分支,第一次走到时仍然是慢的。
  • 预热请求不能有副作用。用只读接口,或者带上明确的标记让写操作被跳过,见压测流量写进了正式表。
  • 预热完成之后才让就绪探针通过。Kubernetes 里用启动探针给预热留出时间,见 Running 不等于可用。

让流量逐渐进来 ​

在负载均衡一侧,给新实例一个从低到高的权重爬升期(慢启动)。实例用真实流量把自己热起来,同时冷的时候只承担一小部分请求。它不需要改应用,也不存在「预热覆盖不到」的问题;需要负载均衡器或服务网格支持。

AOT 缓存 ​

JDK 24 引入、25 继续扩展的 AOT 缓存,把一次训练运行里加载和链接好的类、以及收集到的方法执行统计存成一个文件,之后启动时直接使用:

bash
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 ms308 µs23 µs46.8 ms
带 AOT 缓存13.9 ms299 µs11.5 µs32.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 个进程取中位数;脚本只断言倍数关系(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权