华为云FunctionGraph冷启动治理实战

举报
yd_221506059 发表于 2026/10/12 01:14:23 2026/10/12
【摘要】 平均延迟 120 ms,P99 却是 2.4 秒——典型的双峰分布,背后是 Serverless 冷启动。本文把冷启动拆成平台调度、运行时初始化、用户初始化三段分别测量,指出挂载 VPC 导致的 ENI 创建才是最贵的一项,并给出按收益排序的优化清单。文末附一个反直觉结论:冷启动场景下把内存调大,GB-秒 成本基本守恒,P99 却能降一个数量级。

华为云 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 混在同一个函数里,是最隐蔽的冷启动放大器:定时任务每次触发都会拉起新实例,把在线请求的实例池搅乱。按调用特征拆分函数,各自用不同的内存规格和并发策略。

五、验证:怎么证明优化真的生效了

优化前后必须用同一套口径对比,否则数据不可信:

  1. 固定压测条件:并发数、请求体、依赖包版本都不变,只改一个变量
  2. 统计口径用 P50 / P99 / 冷启动占比,不要只看平均值——平均值会把长尾彻底抹平
  3. 冷启动次数从日志里数:统计 init_age_ms < 100 的请求占比,作为热调用比例
  4. 成本口径统一换算成 GB-秒:内存(GB) × 执行时长(s),把冷启动的那几次也算进去
指标 优化前 优化后
P50 延迟 105 ms 42 ms
P99 延迟 2400 ms 180 ms
冷启动占比 5.8% 0.7%
日均 GB-秒 1.00(基准) 0.86

注意最后一行:延迟降了一个数量级,成本还降了 14%。原因是内存规格调大带来的单次成本上升,被冷启动耗时缩短和冷启动次数减少完全覆盖了。

六、小结

四句话:

  1. 先分段测量再优化。① 平台调度、② 运行时初始化、③ 用户初始化,三段的药方完全不同,猜错方向会白干一周。
  2. 挂 VPC 是冷启动最贵的一项,非必要不挂;挂了就要接受 ENI 创建的固定开销。
  3. 冷启动场景下,调大内存通常不额外花钱,因为 GB-秒 基本守恒;热路径为主的高频函数则相反。
  4. 平均值会骗人,Serverless 的性能治理必须盯着 P99 和冷启动占比看。

建议标签:函数工作流 FunctionGraph、Serverless、云原生、性能优化

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。