☰
Serverless冷启动延迟测试实战:从原理到优化
2026/10/7 12:34:01 网站建设 项目流程

做过 Serverless 的同学应该都有过这种体验:白天一切正常,半夜一条监控告警把你叫醒,某个接口的 P99 延迟从平时的 30ms 突然飙到了 4 秒,点开链路一看,平台给你弹出一条"函数实例冷启动耗时 3.8s"的记录。于是你意识到,无服务器架构的延迟账本里,冷启动才是那笔最不可控的支出。

这几年我在几个云平台上线过几十个函数,也帮团队处理过不少"超时告警"和"流量洪峰打崩函数"的线上事故,绝大部分问题的根源都能追溯到冷启动延迟。这篇文章不打算讲太多概念,我想把我自己实际做冷启动延迟测试的完整思路、脚本、踩坑记录和优化对比数据整理出来,给正在排查无服务器架构性能问题、或者准备在项目里引入函数计算的朋友一份可以直接参考的实操手册。

你不需要理解太多底层原理也能照做,但我会在关键步骤后面解释"为什么要这么做",因为只有理解了延迟从哪来,测试数据才有意义。

1. 冷启动延迟到底贵在哪儿——先把底层延迟账本摊开算

1.1 一次冷启动背后发生了什么

很多人以为冷启动就是"平台把函数跑起来",但真实过程比你想的复杂得多。一次完整的冷启动,大概要经过下面这些环节:

  • 请求到达函数计算平台的路由层,平台发现当前没有可用的函数实例
  • 平台从资源池里分配一个沙箱/容器,初始化运行环境
  • 把函数的代码包从对象存储拉取下来,解压到本地
  • 启动运行时进程,比如 Node.js、Python 解释器、Java 虚拟机会在这个阶段被拉起来
  • 执行模块加载和初始化代码,也就是你在函数文件顶层写的那些连接数据库、加载配置、实例化 SDK 的逻辑
  • 最后才进入你真正的 handler 逻辑,开始处理业务

你可以把它想象成一家没有预留食材的餐厅。热启动就像厨房里已经备好了洗好切好的菜,客人下单直接起锅;冷启动则是客人下单之后,厨师才从仓库拿菜、洗菜、切菜、烧锅,全部就绪才轮到炒菜。你说这中间要多花多少时间。

1.2 延迟账单:哪些环节占了时间大头

不同平台、不同运行时的冷启动时间构成差异非常大,但大体上可以分成四块:

延迟环节典型耗时主要决定因素
沙箱/容器分配几十毫秒到几百毫秒平台资源池的空闲程度、账号并发水位
代码包下载与解压几十毫秒到数秒部署包体积、依赖数量、存储类型
运行时进程启动几十毫秒到数秒语言运行时差异,Java 明显最重
用户初始化代码执行几毫秒到数秒你在代码顶层做了什么操作

这里有个很多人忽略的点:代码包下载解压这个环节,往往比你想象的更贵。我曾经把一个 Java 函数从 80MB 的依赖包瘦身到 25MB,冷启动时间直接降了一半以上。所以如果你测出来冷启动特别慢,第一步就该去看部署包大小。

1.3 为什么冷启动延迟测试不能只看平均值

这是一个老生常谈但你一定要刻在脑子里的原则:冷启动在请求总量里占比很低,平均值会把它稀释得毫无意义。

我举个具体例子。假设你发了 1000 次请求,990 次热启动,单次耗时 28ms;剩下 10 次是冷启动,单次耗时 5 秒。算一下平均值:

(990 × 0.028 + 10 × 5) ÷ 1000 ≈ 0.078 秒

也就是说平均值只有 78ms。如果只看均值,你会觉得系统性能好得很,根本没毛病。但实际有 1% 的用户,真实体感是等了整整 5 秒。尾延迟 P99 才是你要盯的那个数字。无服务器架构的冷启动延迟测试,本质上就是一次针对尾部延迟的专项体检,如果没有围绕 P95、P99 来设计测试,那测出来的数据基本是自欺欺人。

2. 测试目标和环境设计——没有对照组和标尺的测试都是自嗨

2.1 先回答四个问题:测哪个环节、用什么流量模型、在什么环境、测哪些指标

动手测之前,我建议你先明确四件事。不然很容易出现"数据测出来一堆,但根本不知道说明什么问题"的情况。

第一,测哪个环节。你是想测端到端延迟,也就是从发起 HTTPS 请求到收到响应的完整链路,包括 API 网关转发、函数调度、执行、返回;还是只想测函数运行时本身的冷启动耗时?这两个数据用途不一样。端到端数据用来评估用户真实体感,运行时数据用来定位优化方向。

第二,用什么流量模型。是单发一次请求看冷启动?还是先冷后热连续对比?还是模拟突发流量做并发冷启动压测?我建议三类都要测,因为线上事故往往发生在第三种场景。

第三,在什么环境测。冷启动延迟非常受平台整体水位影响,生产环境和测试环境的数据可能差出一倍以上。最好在专用测试账号、独立区域的测试环境里做,并且记录下环境指纹,避免跑了两轮数据来自完全不同的配置再拿来对比。

第四,测哪些指标。核心是冷启动耗时、P50/P95/P99 延迟、请求成功率、实例扩容数量和速率。有条件的话,把平台日志里的 InitializeDuration、Duration 也拿下来,方便拆分。

2.2 环境隔离与基线控制

测试环境设计最容易被忽略的是"混杂"。如果测试函数和其他业务函数混在同一个资源池,别的高负载函数突然扩容,可能把平台的资源抢走,你的冷启动延迟会莫名其妙变高,而且复现不出来。所以做冷启动测试之前,我通常会对环境做几项固定处理:

  • 使用独立测试账号或者独立区域,不跟生产环境混用
  • 测试函数尽量放在同一个平台账号下专门为测试开通的命名空间里
  • 压测机和服务端之间的网络路径保持固定,最好在同一个地域发起请求,避免公网抖动干扰
  • 关闭压测机上的代理、DNS 缓存、HTTP 连接复用机制,保证每次请求都是"干净"的新连接
  • 把所有环境参数记录下来:函数内存、运行时版本、部署包哈希、依赖层版本、VPC 配置、网关超时时间

有些朋友会图省事直接用浏览器 F5 刷一刷当测试,我不建议这么做。浏览器有连接复用、缓存、同源并发限制,测出来的数据基本不具备参考价值。后面我会给你一套脚本,直接命令行跑。

2.3 指标口径对不齐,后面全白做

定义指标时最容易打架的是"冷启动到底从哪一秒开始算"。我的建议是分开两个口径:

  • 端到端冷启动延迟:从客户端发起请求到收到完整响应的总时长,前提是你确认该请求命中的是冷实例
  • 运行时冷启动耗时:从运行时进程开始加载模块,到 handler 真正被调用的时间,加上平台日志里的 initDuration

确认"命中的是不是冷实例"很关键。我常用的办法是看响应头或者日志里的实例 ID。同一个实例 ID 第一次出现,基本就是冷启动;如果实例 ID 是之前出现过的,那只是热调用。你还可以在函数代码里打印一条带实例 ID 的日志,配合压测脚本一起分析。

3. 一套可以直接抄的冷启动延迟测试方案

3.1 工具选型:压测工具与观测工具怎么搭配

工具不需要很复杂,但要根据你的目的选对。我只拿自己用顺手的几款说:

  • 单发请求、冷热对比:curl 就够了,配合 time 指令或者 Python 的 perf_counter
  • 固定 QPS 压测:考虑 wrk、hey,它们轻量,适合短时间压测
  • 复杂场景编排:我用 Python 脚本 + requests 库,灵活控制并发、间隔、请求头,还能把数据落盘
  • 观测端:平台自带日志 + 链路追踪,另外在函数里埋点打印关键时间戳,两边的数据互相印证

这里有一个经验:压测工具负责制造负载,但延迟的分段明细一定要靠平台日志和服务端埋点,不能只信客户端测出来的总时长。客户端测到的总时长里包含网络 RTT 和网关排队时间,这些不是冷启动本身,混在一起会让你误判。

3.2 最简单的一套冷热交替测试脚本

下面这个 Python 脚本是我平时最常用的"探针"脚本,用来快速感知冷启动是否存在、以及冷热差距有多大。它不是压测工具,而是定位工具。

import time import statistics import requests URL = "https://your-function-endpoint.example.com/hello" def timed_request(): start = time.perf_counter() try: resp = requests.get(URL, timeout=30) elapsed_ms = (time.perf_counter() - start) * 1000 return elapsed_ms, resp.status_code, resp.headers.get("x-fc-request-id", "") except Exception as exc: elapsed_ms = (time.perf_counter() - start) * 1000 return elapsed_ms, 0, str(exc) # 第一轮:制造并观察冷启动 elapsed, code, rid = timed_request() print(f"first-cold: {elapsed:.1f} ms, status={code}, req_id={rid}") # 第二轮:立即请求,命中热实例 elapsed, code, rid = timed_request() print(f"second-hot: {elapsed:.1f} ms, status={code}, req_id={rid}") # 连续10次热请求,统计P50/P95 hot_times = [] for i in range(10): elapsed, code, rid = timed_request() hot_times.append(elapsed) hot_times.sort() p50 = hot_times[len(hot_times) // 2] p95 = hot_times[int(len(hot_times) * 0.95) - 1] print(f"hot p50={p50:.1f} ms, p95={p95:.1f} ms")

跑完这个脚本,你能立刻得到三个信息:冷启动大概是多少、热启动大概是多少、两者差了几个数量级。如果冷热差距在 3 到 5 倍以内,说明系统还算健康;如果冷启动比热启动慢了 50 倍以上,恭喜你,基本上确认了延迟大头在冷启动。

3.3 如何确保每次都能测到"真正的冷启动"

很多人测着测着发现"怎么全是热启动,根本测不到冷启动"。原因是平台有实例存活机制,你上一个请求刚结束,实例还在存活窗口里,下一个请求过来直接被复用了。

我总结了三种能稳定制造冷启动的方法,按可靠性排序:

  1. 等待实例完全回收后再测。大多数平台的空闲实例会在几分钟到十几分钟后被回收,你可以通过监控实例数曲线来确认回收完成,再发首个请求
  2. 通过平台的管理 API 或 CLI 主动释放/更新函数实例。比如更新函数配置或代码,平台一般会触发旧实例重建,但注意这样测出来的是"更新后的首启",和纯冷启动略有区别
  3. 直接修改测试函数的资源标签或环境变量触发滚动重建,再把测试请求打到新版本上

第一种方法最干净,但等待时间长。如果你要连续多轮采集冷启动样本,我建议写一个定时任务:发一个请求,然后睡眠 20 分钟等实例回收,再发下一个请求,收集 20 个样本。虽然慢,但是数据极干净。

3.4 在函数内部埋点,把运行时耗时单独拆出来

端到端数据拿到之后,我还习惯在函数代码里加一个最朴素的埋点,用来算"模块加载到 handler 被调用"的时长。以 Python 为例:

import time _init_start = time.perf_counter() # 这里是各种模块加载、连接池、配置初始化 # ... def handler(event, context): init_cost_ms = (time.perf_counter() - _init_start) * 1000 print(f"container_init_to_handler={init_cost_ms:.1f} ms") return {"statusCode": 200, "body": "ok"}

注意,我特意用 perf_counter 而不是 time.time(),因为 perf_counter 是单调时钟,不受系统时间调整影响,用来测间隔更可靠。这个日志会和你压测端到端的时间戳一起进入链路追踪,在后端把两者对照,你就能清楚地看到网络和网关花了多少、运行时初始化花了多少、业务执行又花了多少。

4. 实测数据拆解:谁在悄悄吃掉你的延迟预算

4.1 内存配置的影响:不是线性增长,但有明显拐点

函数计算平台通常以内存大小作为分配 CPU 和资源的依据。我在某个平台用同一个空函数,只改内存配置,测出来的冷启动中位数如下:

内存配置冷启动耗时中位数热启动耗时中位数
128MB1320ms25ms
256MB850ms24ms
512MB620ms23ms
1024MB480ms22ms
2048MB390ms21ms

可以看到,从 128MB 升到 512MB 时,冷启动时间下降非常明显;但到了 1GB 以上,收益就变得很平缓。这说明内存档位只要过了一个基本阈值,平台分配的基础资源就够用了,继续加内存对初始化速度帮助有限。所以如果你的函数冷启动偏高,我建议先把内存从 128MB 提到 512MB 试试,这个动作基本不花什么改造成本,却往往能拿到 40% 以上的收益。

4.2 运行时差异:Java 和 .NET 的初始化开销真的不小

很多无服务器项目选语言时只看写起来顺不顺手,到压测时才后悔。我同样用一个空函数,不同运行时在同一平台、同一内存档位下测过:

运行时冷启动耗时中位数备注
Go280ms编译后的二进制,启动极快
Python520ms依赖少的场景表现不错
Node.js460ms表现稳定,依赖多时会涨
Java (GraalVM)700ms比普通 JVM 好很多,但仍有成本
Java (普通 JVM)2100msJVM 启动 + 类加载开销大
.NET1300ms运行时初始化较重

这里我想强调两点。第一,不要只看语言本身,还要看你的依赖生态。一个带着 40MB 第三方包的 Python 函数,冷启动可能比轻量 Java 函数还慢。第二,Java 函数如果一定要用,优先考虑 GraalVM 原生镜像或者平台提供的快照启动方案,否则每一个新实例的诞生都要付一次 JVM 冷启动的罚款。

4.3 部署包体积和初始化代码:容易被忽视的大头

我做过一个对照实验。同一个 Node.js 函数,代码逻辑完全一样,只在部署包里多塞了一个 30MB 的纯静态模块。结果冷启动从 420ms 涨到了 1650ms,而热启动几乎没变。原因很直接:冷启动要拉包、解压、加载模块,包越大这个环节越慢;热启动因为是复用已加载好的进程,所以感知不到。

另外,初始化代码的质量也很关键。我见过一个函数在全局作用域里初始化了一个 Redis 客户端、一个消息队列生产者、还加载了一大堆配置 SDK,冷启动 3 秒多,但其实业务只是返回一句"hello"。把这些重初始化改成惰性加载——也就是首次真正用到时才创建连接,冷启动往往能降到原来的三分之一。

4.4 VPC 和突发并发:线上事故的两大主谋

如果你的函数要访问私有数据库或内部服务,一般要配置 VPC。但 VPC 模式的函数在新实例创建时经常需要挂载网络接口,这个动作挂得慢的话,会让冷启动显著增加。我测试的某个平台,无 VPC 函数冷启动约 500ms,接入 VPC 后变成 900ms 到 1200ms。优化办法通常是把网络接口的创建改成异步模式,或者让平台复用热网卡,但这需要你按平台能力去配置。

突发并发则是最考验冷启动的场景。我模拟过一次 50 并发打到同一个函数上,平台需要同时扩张 50 个新实例,这时单个实例的冷启动时间从平时的 600ms 被拉到了接近 1.8 秒。原因也不难理解:短时间新增实例太多,平台的调度、拉包、初始化都在抢资源,互相拖累。所以做无服务器容量评估时,单独测一个冷启动的数据是不够的,一定要做并发冷启动测试,看平台在突发流量下能撑到什么水位。

5. 降低冷启动延迟的优化手段与优化后的再验证

5.1 预留实例:最直接但最贵的选择

如果你已经确认冷启动对业务有实质影响,最简单的方案是购买预留实例/预置并发。用完这些配额后,请求直接命中热实例,延迟瞬间回到几十毫秒。代价是费用会明显上升,而且预留多了会造成资源浪费,预留少了在流量高峰依然会触发冷启动。

我的建议是:给核心链路、延迟敏感的函数做 20% 到 40% 的预留,剩下的流量还是走弹性伸缩。预留量可以结合你过去一个月的实际并发峰值以及 P99 目标延迟来反推。先跑一段时间,再根据成本报表调整比例。

5.2 代码层瘦身:代价最小、收益最稳的优化

这是我最推荐的优化方向,因为它既减小冷启动,又不增加费用,还提升安全性。具体操作思路如下:

  • 清理掉部署包里没有被实际引用的依赖,必要时用工具扫描依赖树
  • 把重型 SDK 的导入从顶层搬到 handler 内的分支里,牺牲少量可读性换取启动速度
  • 使用平台提供的依赖层能力,把不常变的依赖放在层里,让平台缓存层内容,减少请求时的拉包量
  • 对于自定义运行时,考虑裁剪基础镜像,去掉不必要的系统库
  • 数据库连接、消息客户端等重对象,在连接池里跨请求复用,不要在每次调用时重建

注意一个细节:模块懒加载不是所有场景都合适。如果你的函数会被频繁调用,懒加载收益很小,反而可能因为初始化逻辑在每次调用时都要判断一遍而增加一点点执行时间。适合懒加载的是那些"绝大多数请求用不到"的依赖,比如冷备用的告警上报 SDK、管理后台才触发的大计算库等。

5.3 快照启动和自定义运行时:平台级黑科技

现在主流平台基本都提供了快照启动或者类似的加速机制,原理是提前把一个函数实例初始化好,把它的内存状态打成快照,后面创建新实例时直接恢复快照,省掉代码加载和初始化阶段。我自己的实测里,快照启动能把一个 2 秒的 Java 函数冷启动压到 400ms 以内,效果非常明显。

用快照启动要特别注意两点:第一,快照里如果包含随机数种子、加密 key 生成器等状态,恢复出来的多个实例可能会有状态一致性问题,需要做专门的调用前重置;第二,初始化代码里如果有往外部系统写数据、发消息的副作用,打快照时会被执行一次,恢复后又会执行一次,存在重复计数的风险。所以你需要在代码里做一个"当前是否为快照恢复"的标记处理。

另外,如果你的平台支持自定义镜像,可以通过预装依赖来减少拉包和解压的时间。但镜像也不是越大越好,镜像文件超过一定体积之后,平台拉取镜像的开销反而会抵消掉依赖预装的收益。理想情况是用一个刚好包含所需系统库和运行时的精简镜像。

5.4 优化前后的回归测试:同一套脚本、同一套环境、同一个时间段

优化做完之后,最忌讳的就是换了个环境、换个脚本再测一轮,然后对比数字。冷启动延迟受平台当前水位影响很大,不同时间段的测试数据波动可能比优化收益还大。我的回归测试方法如下:

  1. 把测试环境固定下来,记录函数的内存、运行时、部署包哈希、关联层版本
  2. 在某个相对空闲的时间窗口,先跑一轮完整脚本作为"优化前基线"
  3. 应用优化后,在同一个时间窗口、用同一套脚本再跑一轮
  4. 每轮至少采集 20 到 30 个冷启动样本,取中位数和 P95,不要拿单次数据说话
  5. 最后把关键指标摆在一起对比,比如冷启动中位数、P95 端到端延迟、突发并发下实例创建速率

这里我放一个我实际优化某个 Python 函数前后的对比摘要,给你做个格式参考:

指标优化前优化后降幅
部署包体积52MB18MB-65%
冷启动中位数1680ms620ms-63%
冷启动 P952240ms890ms-60%
50 并发冷启动中位数4100ms1450ms-65%

这个函数的优化动作很简单:把两个几乎没用到的 SDK 从顶层导入改成了函数内惰性加载,同时把部署包里的静态资源挪到了一个独立的对象存储服务里。整个过程大约花了一个下午,收益却非常可观。

6. 我在实测中踩过的坑和给你留的几点建议

6.1 最容易让数据失效的五个坑

第一,测试过程中平台可能已经"养热"了实例。我试过等了一个小时再发请求,结果因为监控探针或者健康检查一直在打这个函数,实例根本没被回收,测出来的"冷启动"实际是热的。排查办法是看实例数曲线,确认降到 0 之后才开始。

第二,网关和客户端的超时设置太短,会把冷启动请求直接掐断。我见过一个人把 API 网关超时设成了 1 秒,冷启动需要 2 秒,结果所有冷启动都被网关 504 截断,他还以为是平台在报错。测冷启动之前,一定要把链路中所有超时阈值放宽到预期冷启动时间的 3 倍以上。

第三,请求客户端使用了长连接复用。Python 的 requests 库默认是每个请求新连接,但如果你用连接池,第二次请求会复用同一个 TCP 连接,省掉了 TLS 握手和连接建立的耗时,这会掩盖真实冷启动成本。测试脚本里要明确禁用连接复用,或者至少在同一批测试里保持一致的配置。

第四,只测一轮就下结论。同一平台的冷启动本身就是一个分布很宽的随机变量,单次测出来 600ms,下一次可能就变成 1.2 秒。必须多轮采样看中位数和 P95,否则你基于单次数据做的优化决策很可能是在追噪音。

第五,并发冷启动测试撞上平台限流。有些平台对新实例的创建速度有上限,突发并发过大时会直接返回 429 或者 503。这不代表你的函数有问题,而是平台的资源调度策略导致的。遇到这种情况,要把错误响应单独分类统计,不能一股脑算进延迟里。

6.2 给新手的实操建议

如果你刚接触无服务器架构冷启动测试,我个人建议按这个顺序起步:先用最简单的冷热交替脚本确认冷启动是否存在;然后打开平台日志,看看 initDuration 和 duration 分别是多少,判断瓶颈在初始化阶段还是在业务阶段;接着试着把内存从 128MB 调到 512MB,重新跑一轮,一般很快就能看到变化;最后再考虑代码瘦身和预留实例这些更高阶的手段。

我还会建议你把测试脚本和结果文件一起放进项目的仓库里,旁边写清楚环境指纹、测试时间、云平台版本信息。因为这类性能问题具有很强的时效性,平台底层一升级,旧数据就基本失效了。有留存的数据和脚本,下次再遇到延迟告警,你能很快复用这套流程去排查,而不是重新发明轮子。

6.3 最后再分享一个小技巧

如果你有定期巡检无服务器应用的习惯,我建议把冷启动探针集成到已有的定时健康检查里,比如每 5 分钟做一次带实例 ID 记录的热探测,每天在低峰期主动制造一次"纯冷启动"请求,并把耗时指标上报到监控系统。这样你看到的不是某一次测试的快照,而是一条连续的冷启动延迟趋势线。当平台升级、依赖调整或者基础镜像变化时,这条趋势线会在第一时间告诉你冷启动延迟有没有悄悄变差。

我现在团队里的无服务器服务监控看板上就长期挂着两个数字:一个是热启动 P95,一个是每日冷启动中位数。只要冷启动中位数出现连续两天的上升,我会主动去查部署包大小和平台配置变更,而不是等到用户投诉了才被动响应。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询