冷启动迷思:为事件处理优化 JVM

SchemaBridge Team · 2026-01-20 · Serverless, Performance, JVM, Java

Java 冷启动的坏名声源于 FaaS,而非 JVM。线程池、JIT 预热与长期运行的 worker 模型。

现代云的延迟税

"Serverless"本应拯救我们。它承诺无限扩展和零运维。但在面向用户的延迟方面,通用的 Serverless 函数(FaaS)有一个不光彩的秘密:冷启动。

当请求打到一个已经有一段时间没运行的 Lambda 上时,提供商必须拉起一个容器、启动运行时并加载你的代码。对于复杂的应用,这可能需要数秒。在现代 Web 应用的世界里,2 秒就是永恒。这就是"流畅"体验与"坏掉"体验之间的差别。

多年来,业界的普遍看法是:"Java 对 Serverless 来说太重了。用 Go 或 Node.js 吧。"

在 SchemaBridge,我们挑战这一看法。我们依赖 JVM(Java Virtual Machine)的稳健性,并且绕开冷启动问题的方式不是放弃 Java,而是从一开始就不把它当作短生命周期的函数来运行。

JVM:负重的耕牛,而非短跑选手

JVM 是为长期运行的服务器进程设计的,而非临时性函数。它在最初的时间里都在"预热"——加载类、解释执行字节码,并优化热点路径(JIT 编译)。

如果你把 Java 应用当作一个 100 毫秒后就消亡的"函数",那就是在与运行时的物理规律作对。你在每个请求上都支付启动成本,却收获不到 JIT 优化带来的好处。

实践:长期运行的应用上下文

SchemaBridge 不会把你的工作流步骤作为隔离的、临时性的函数来运行。相反,这些步骤运行在部署于 Kubernetes 上的长期运行 JVM worker 中。

1. 线程池化:每个 worker 维护一个固定大小的预热工作线程池,从 Valkey Streams 中消费。当事件到达时,由一个已存在的线程接手处理。每个事件既不需要创建操作系统进程,也不需要启动 JVM。

2. JIT 优化:由于 worker 是长期运行的进程,JVM 的 C2 编译器有时间把热点路径优化为原生机器码。worker 会随着预热变得越来越快——而为了比较公平起见,一个刚部署的 worker 是冷启动的,它只需支付一次预热成本,而不是每个事件都支付。

3. 连接池化:管理数据库连接代价高昂。在 FaaS 模型中,连接只能存活于一个热执行环境的生命周期内;每次扩容或环境被回收后都必须重新建立。我们的长期运行 worker 维持着到 PostgreSQL 和 Valkey 的池化连接,并复用访问外部服务的 HTTP 客户端,因此没有任何步骤需要为从零建立这些连接付出代价。

预热的 worker 无法消除的,是作为持久化(durable)工作流引擎的成本。在你的逻辑运行之前,每个步骤都要经过一次 stream 跳转、在 PostgreSQL 中认领自己的状态并获取输入。这是在步骤执行中途崩溃后仍能存活的代价,它使步骤延迟处于网络和数据库往返的量级——而非微秒级。

GraalVM 的定位

GraalVM 已经是平台的一部分,但并不是用来让 Java 更快启动的:它是一个多语言引擎,负责运行作者在工作流中编写的 JavaScript 和 Python 代码片段,并运行在一个禁止其创建进程、禁止文件或网络 I/O 的沙箱中。

GraalVM Native Image——将 Java 服务提前(ahead-of-time)编译为独立二进制文件——是另一项不同的功能,我们并未使用。据广泛报道,它能大幅缩短启动时间并降低内存占用,代价是构建复杂度增加,以及对反射和动态类加载的限制。对于一组长期运行的 worker 而言,启动时间的收益远不如对短生命周期函数那样重要,这恰恰就是我们选择长期运行 worker 的原因。

示例场景:一条对延迟敏感的流水线

设想一个假想的数据分析团队,使用 FaaS 上的 Java 函数处理持续不断的事件流。

问题

每当平台扩容或回收空闲函数时,周期性的冷启动都会造成秒级的延迟尖峰,而尾延迟主要由启动过程决定,而非团队自己的代码。

长期运行 worker 方案

把这部分处理迁移到预热的、长期运行的 worker 上,将会改变延迟曲线的形状:

1. 预热线程:事件运行在已经存在的线程上,处于一个已被 JIT 预热的进程中。

2. 更平稳的尾部:没有了按事件进行的容器供给,由启动引起的尖峰随之消失。剩余的延迟主要由工作本身以及每个步骤所经过的持久化跳转决定。

3. 权衡:无论是否有事件到达,你都要为运行中的 worker 付费;而以个位数毫秒计的硬实时预算,仍然不适合交给持久化工作流引擎——它应当属于你自己应用的请求路径。

对比:临时性 FaaS vs. 长期运行 worker

| 特性 | 标准 FaaS(Lambda) | SchemaBridge 预热 worker |

| :--- | :--- | :--- |

| 冷启动 | 扩容时启动进程/运行时 | 无按事件的进程启动 |

| 运行时 | 扩容时/空闲后启动 | 长期运行进程 |

| 优化 | 有限(执行环境会被回收) | 完整的 JIT 编译 |

| 连接 | 环境回收时丢失 | 池化并复用 |

| 每步开销 | 函数调用 | Stream 跳转 + 持久化状态认领 |

Java 性能专家清单

1. 复用资源:永远不要在处理函数内部创建数据库客户端。只创建一次(静态/全局),然后复用。

2. 调优堆内存:确保 JVM 堆大小与容器相匹配,以避免过于激进的垃圾回收。

3. 让 GC 与 CPU 限制相匹配:CPU 限制不足一个整核的容器可能无法从并行收集器中获益;请检查 JVM 在你的限制下实际选择了哪种 GC。

4. 避免反射:重度依赖反射的库(例如某些较老的 JSON 解析器)预热很慢。尽可能优先采用编译期生成。

5. 监控 GC 停顿:借助工具确保垃圾回收不会给你的工作流带来延迟尖峰。

// 昂贵的客户端每个进程创建一次,而不是每个事件创建一次。
private static final HttpClient CLIENT = HttpClient.newHttpClient();

结论:运行时很重要

"Java 意味着缓慢的冷启动"这句话说的是 Java 的部署方式,而不是 Java 本身。只要理解运行时底层的物理规律,并选择尊重这些规律的架构,你就能保留 Java 生态的生产力,而无需在每个事件上支付一次启动成本。只是要坦诚面对剩下的部分:持久化引擎用一部分延迟,换取了步骤在崩溃后依然能够存活的保证。

"Building the Bridge"系列文章之一。另请参阅:合规与审计:证据作为副产品 和 构建通用连接器。

深入了解