华为云FunctionGraph冷启动治理实战
华为云 FunctionGraph 冷启动治理:把 P99 从 2.4 秒压到 180 毫秒
平均延迟 120 ms,P99 却是 2.4 秒——这是典型的双峰分布,也是 Serverless 最容易被忽视的长尾。本文不讨论"要不要用 Serverless",而是把冷启动拆成三段逐个测,给出一份按收益排序的优化清单,以及一个反直觉的结论:把内存调大反而更省钱。
一、先确认"长尾"到底是不是冷启动
监控上看到的平均延迟几乎没用,因为冷启动是低频高幅事件。判断方法有两个,都很直接:
方法一:看延迟分布是不是双峰
把每次调用的耗时打到日志服务 LTS,做直方图统计:
[0, 200ms) ████████████████████████████ 94.2%
[200ms, 500ms) ██ 3.1%
[500ms, 1s) ▌ 1.4%
[1s, 3s) ██ 1.3% ← 第二座峰
两个峰之间有一段"真空区",这是冷启动的典型特征。如果是下游依赖慢,分布会是连续拖尾,不会出现真空。
方法二:在 handler 外面打时间戳
import time
# 模块级代码,只在冷启动时执行一次
COLD_START_AT = time.time()
def handler(event, context):
# 距进程创建的时间,近似等于冷启动代价
elapsed_since_init = time.time() - COLD_START_AT
print(f"init_age_ms={elapsed_since_init * 1000:.1f}")
...
init_age_ms 只有几十毫秒,说明是热调用;几百毫秒到几秒,就是冷启动。这行日志比任何监控都可靠。
二、把冷启动拆成三段
冷启动不是"一个"延迟,它是三段串起来的,优化手段完全不同:
| 阶段 | 主要开销 | 优化手段 |
|---|---|---|
| ① 平台调度:分配节点、创建沙箱、创建 ENI(挂了 VPC 时) | 通常最大 | 摘掉不必要的 VPC 挂载、预留实例 |
| ② 运行时初始化:拉起解释器、解压并加载依赖包 | 中等 | 依赖瘦身、减少顶层 import |
| ③ 用户初始化:handler 之外的代码 | 小但完全可控 | 代码结构调整、Initializer 预热 |
关键点:① 段和 ③ 段的优化手段没有交集。 很多团队一上来就拼命精简代码,结果发现 P99 纹丝不动——因为瓶颈在 ① 段的 ENI 创建上,代码再瘦也没用。所以第一步必须是分段测量,而不是直接开优化。
分段测量可以用平台的 Initializer 能力:把预热逻辑单独放进去,它会先于 handler 执行,你可以在里面打点,把 ② 段和 ③ 段切开。
三、实测:内存规格是最容易被低估的旋钮
FunctionGraph 的计费口径是 GB-秒(内存 GB × 执行时长秒),所以直觉上"内存越小越便宜"。实测下来恰恰相反。
同一份 Python 代码(依赖包约 40 MB,包含一个数据校验库),固定并发 20、请求负载相同,只改内存规格:
| 内存规格 | 冷启动耗时 | 热调用耗时 | 单次冷启动 GB-秒 | 单次热调用 GB-秒 |
|---|---|---|---|---|
| 128 MB | ~2400 ms | ~150 ms | 0.300 | 0.019 |
| 256 MB | ~1450 ms | ~105 ms | 0.363 | 0.026 |
| 512 MB | ~700 ms | ~65 ms | 0.350 | 0.033 |
| 1024 MB | ~330 ms | ~40 ms | 0.330 | 0.040 |
读这张表的正确姿势:
- 冷启动的 GB-秒 几乎不变(0.30 → 0.33)。因为内存涨了 8 倍,耗时掉了 7 倍多,两者基本对冲。冷启动场景下,调大内存不额外花钱,却换来了 7 倍的响应速度。
- 热调用的 GB-秒 是线性上升的(0.019 → 0.040)。热路径占比高的函数,内存调大才是真花钱。
原因是内存规格同时决定了可用的 CPU 配额(各平台的内存/CPU 配比不同,以 FunctionGraph 文档说明为准),而冷启动的 ② ③ 段主要是 CPU 密集型:解压依赖包、执行 import、建连接。CPU 一上来,这几段立刻缩短。
结论:冷启动占比高的函数,把内存往大调;热路径为主、延迟已达标的高频函数,保持小内存。 两者是相反的优化方向,混在一起讨论只会得出错误结论。
四、优化清单(按收益排序)
1. 摘掉不必要的 VPC 挂载 —— 收益最大,也最常被忽略
函数一旦挂载 VPC,平台就要为它创建 ENI(弹性网卡),这一步在冷启动里开销很重。
判断标准很简单:
- 函数只访问公网 API / OBS / 自己的其他函数 → 不要挂 VPC
- 函数要访问 RDS、CCE 内网服务、自建内网 API → 才挂 VPC,并把它当作"必要成本"接受
- 挂 VPC 后失去公网出口 → 需要公网就走 NAT 网关,不要为了图省事把函数挂到 VPC 再配 EIP
一个真实案例:某函数的调用量里有 70% 只是写日志到 OBS,却因为"顺手"挂了 VPC,冷启动 1.8 秒里超过 1.2 秒花在 ENI 创建上。摘掉 VPC 后 P99 直接从 1.8 秒掉到 400 毫秒,代码一行没改。
2. 依赖包瘦身,并把 import 下沉
依赖包体积直接决定 ② 段的解压和加载时间:
- 只装运行态依赖,把
pytest、black、mypy这类开发依赖彻底剔出构建产物 - 剔除
tests/、docs/、.pyc缓存、示例数据——很多包的实际体积有一半是这些 - 多个函数共用依赖时用层(Layer),避免每个函数重复打包
比瘦身更有效的是惰性导入:
# 不好:模块级导入,每次冷启动都要付出完整的 import 成本
import pandas as pd
import heavy_validation_lib
def handler(event, context):
if event.get("type") == "simple":
return simple_path(event)
return heavy_path(event)
# 好:只有真的走到那个分支才付出成本
def handler(event, context):
if event.get("type") == "simple":
return simple_path(event)
import pandas as pd
import heavy_validation_lib
return heavy_path(event, pd, heavy_validation_lib)
大部分线上流量走的是简单分支,那么复杂分支的 import 成本就只在那 1% 的请求上付出,而不是每一次冷启动。
3. 客户端初始化放 handler 外,但必须处理失效
这是最基础的写法,但很多人只做了前半句:
import os
_client = None
def get_client():
global _client
if _client is None:
_client = SomeClient(
endpoint=os.environ["ENDPOINT"],
timeout=2,
max_retries=1,
)
return _client
def handler(event, context):
client = get_client() # 热调用时直接复用
...
注意:复用连接意味着要处理"连接被对端关掉"的情况。函数可能被平台冻结几分钟再唤醒,此时旧连接已失效。正确做法是捕获连接类异常后重建一次,而不是无脑重试:
def call_with_reconnect(fn):
try:
return fn(get_client())
except (ConnectionError, TimeoutError):
global _client
_client = None # 丢弃失效连接
return fn(get_client()) # 只重建一次,避免雪崩
4. 用 Initializer 预热,而不是在 handler 里判断
把"建连接、加载配置、拉取小字典"这类事放进 Initializer:
def initializer(context):
get_client().ping() # 提前建连
load_whitelist() # 预热内存缓存
return "ok"
好处是预热逻辑和业务逻辑解耦,而且 Initializer 的执行时间在监控里是单独可见的,方便你判断"预热本身是不是变慢了"。
5. 预留实例兜住核心接口
对延迟最敏感的少数函数(比如对外 API 入口)配预留实例,从根上消除冷启动。两个注意点:
- 预留实例按预留时长计费,与按量调用是两套账,要算清楚边际收益
- 预留实例数量要跟着业务高峰调,配置过低等于没配,配置过高就是纯浪费
6. 别让异步任务和在线请求抢同一个函数
定时任务、消息消费、在线 API 混在同一个函数里,是最隐蔽的冷启动放大器:定时任务每次触发都会拉起新实例,把在线请求的实例池搅乱。按调用特征拆分函数,各自用不同的内存规格和并发策略。
五、验证:怎么证明优化真的生效了
优化前后必须用同一套口径对比,否则数据不可信:
- 固定压测条件:并发数、请求体、依赖包版本都不变,只改一个变量
- 统计口径用 P50 / P99 / 冷启动占比,不要只看平均值——平均值会把长尾彻底抹平
- 冷启动次数从日志里数:统计
init_age_ms < 100的请求占比,作为热调用比例 - 成本口径统一换算成 GB-秒:
内存(GB) × 执行时长(s),把冷启动的那几次也算进去
| 指标 | 优化前 | 优化后 |
|---|---|---|
| P50 延迟 | 105 ms | 42 ms |
| P99 延迟 | 2400 ms | 180 ms |
| 冷启动占比 | 5.8% | 0.7% |
| 日均 GB-秒 | 1.00(基准) | 0.86 |
注意最后一行:延迟降了一个数量级,成本还降了 14%。原因是内存规格调大带来的单次成本上升,被冷启动耗时缩短和冷启动次数减少完全覆盖了。
六、小结
四句话:
- 先分段测量再优化。① 平台调度、② 运行时初始化、③ 用户初始化,三段的药方完全不同,猜错方向会白干一周。
- 挂 VPC 是冷启动最贵的一项,非必要不挂;挂了就要接受 ENI 创建的固定开销。
- 冷启动场景下,调大内存通常不额外花钱,因为 GB-秒 基本守恒;热路径为主的高频函数则相反。
- 平均值会骗人,Serverless 的性能治理必须盯着 P99 和冷启动占比看。
建议标签:函数工作流 FunctionGraph、Serverless、云原生、性能优化
- 点赞
- 收藏
- 关注作者
评论(0)