函数计算冷启动优化:从几百毫秒降到几十毫秒的几个手段
冷启动的时间花在哪
函数计算按需拉起实例,这个「拉起」的过程就是冷启动。它大致分成四段:
- 调度与分配:找到可用的计算资源并分配——这部分平台侧完成,用户无法干预;
- 运行时启动:拉起语言运行时进程;
- 初始化代码:执行函数入口之外的初始化逻辑,比如加载依赖、建立连接、读取配置;
- 首次调用:真正执行业务逻辑,可能还包括 JIT 预热、首次磁盘读取等。
能优化的是第 3 段和第 4 段,第 3 段通常占大头。很多函数冷启动慢,不是平台慢,是初始化代码里做了一件本不该在那里做的事。
手段一:把重活挪出初始化路径
最常见的反模式是初始化时去连数据库、拉配置中心、下载大文件。这三件事每一项都能消耗几百毫秒到几秒。
# 反模式:初始化时同步建连
conn = create_db_connection() # 可能几百毫秒
def handler(event, context):
return query(conn)
更合理的做法是延迟初始化 + 按需复用:第一次调用时建连,之后复用;连接放在全局变量里,实例被复用期间一直有效。
_conn = None
def get_conn():
global _conn
if _conn is None:
_conn = create_db_connection()
return _conn
def handler(event, context):
return query(get_conn())
这样冷启动时省掉了建连时间,代价是第一个请求会慢一点。如果业务不能接受第一个请求慢,就在初始化里做「预热连接」但设置超时,失败也不阻塞函数启动。
手段二:砍掉依赖体积
运行时启动时间和代码包大小强相关。一个常见的情况是:函数只用了某个 SDK 的一两个接口,却把整个包连同它的全部传递依赖都打了进去。
可操作的做法:
- 用精简的运行时基础镜像,不要带完整的编译工具链;
- 依赖按需安装,编译型依赖尽量用预编译的 wheel,不要在生产包里现场编译;
- 把不需要的测试、文档、示例目录从包里剔除;
- 大文件(模型、字典)放到对象存储,运行时按需下载并缓存到
/tmp。
这些措施叠加起来,把包从几百 MB 压到几十 MB 是常见的。
手段三:保持实例预热
如果业务对延迟敏感,最直接的办法是不让它冷。函数计算一般支持预留实例或者最小实例数,让平台常驻一定数量的实例,请求直接打到热实例上。
这里要注意成本与收益的权衡:预留实例是要付费的,按「最少需要几个实例才能覆盖峰值 QPS × 单请求耗时」来算,不要按峰值 QPS 的全部来预留。比如峰值 100 QPS、单请求 20ms,理论上 2 个实例就够,多留几个做冗余即可。
手段四:并发模型的选择
单实例单并发时,每个并发请求都要独占一个实例,流量一涨就大量冷启动。开启单实例多并发之后,一个实例可以同时处理多个请求,冷启动次数会显著下降。
但多并发有前提:函数必须是无状态且线程安全的。如果代码里有全局可变状态(比如共享的连接对象没做线程安全处理、用了非线程安全的缓存),开启多并发会引入偶发的数据错乱,而且极难复现。开启之前先审一遍全局变量。
哪些成本是省不掉的
需要说清楚的是,调度和运行时启动这两段是平台的固定成本,用户侧优化不到。所以冷启动有一个下限,不可能压到零。如果业务要求稳定的个位数毫秒延迟,Serverless 就不是合适的选择,应该用常驻服务。
实践中的判断标准:如果冷启动的 P99 落在业务可接受的范围内(比如 500ms 以内,且只影响极少量请求),那优化到这一步就够了;如果冷启动直接导致超时或用户可感知的卡顿,那要么上预留实例,要么换架构。
怎么定位冷启动的耗时分布
不要靠猜。函数里打三个时间戳:进程启动时刻、初始化完成时刻、handler 开始执行的时刻。三者相减就能算出「运行时启动」和「初始化」各占多少。有了这个分布,才知道该优化哪一段——如果初始化占了 80%,去砍依赖体积收益很小,应该先挪走初始化里的重活。
小结
冷启动优化的优先级是:先把初始化路径里的重活挪走,再砍依赖体积,最后考虑预留实例。前两步是免费的,第三步要花钱。很多团队跳过前两步直接买预留实例,成本会高出好几倍。
- 点赞
- 收藏
- 关注作者
评论(0)