一、冷启动是怎么一回事

做过云函数开发的朋友,十有八九都遇到过一个让头疼的问题:冷启动延迟。什么叫冷启动呢?打个比方,你在小区门口开了一家深夜食堂,平时没人光顾,所以灯也不开、灶台也不热。突然有一天半夜来了一个客人,你得先开灯、点火、洗锅、切菜,这一套下来客人早就等得不耐烦了。云函数也是这样,正常情况下它不运行,代码和环境都躺在磁盘里。第一次有请求打过来的时候,系统要做一堆准备工作:把代码包从对象存储拉下来,把运行环境拉起来,再加载你的依赖,最后执行你的代码。这个过程就是冷启动。等请求处理完,函数环境并不会立刻销毁,还会留一小段时间给下一个请求用,这时候就叫热启动。热启动很快,因为环境还热着。冷启动就惨了,延迟可能从几百毫秒到好几秒不等,尤其是业务代码里还干了很多重活的时候。

冷启动延迟的高低,主要看三个层面:代码包大小、依赖复杂程度、启动时执行的初始化逻辑。这三个因素任何一个处理不好,都会让冷启动雪上加霜。腾讯云云函数也不例外,作为用户,我们没法改变底层虚拟化技术,但完全可以从自己代码和配置入手,把能省的时间都省下来。

二、从一次线上事故说起

我之前负责过一个用户画像的接口,业务逻辑就是根据用户ID从数据库里读几条记录,再调用另一个服务拼接一下。这个接口是搭在腾讯云函数上的,平时流量不大,高峰期也就几十QPS。看起来很简单对吧?结果上线后每次压测,超时率都高得吓人,尤其是一段时间没人访问后再去测试,第一次请求经常要等三秒以上,而调用方只给了两秒超时,于是直接报错。

当时我们第一反应是数据库连接太慢,但后来发现数据库连接明明很快。真正的问题出在函数冷启动上。因为这是一个公司内部工具,每天只有上午才有几个人用,中间有十几个小时完全空闲。云函数在没人调用的时候,环境被回收销毁,第二天早上第一个上班的人一点开,正好就踩中了冷启动的雷。加上我们当时把整个项目的依赖都塞进了函数包,里面还引入了很大体积的ORM框架和定时任务库,光解压代码包就花了不少时间。这种场景特别典型,并不是说写月过大,而是冷启动的影响被放大了。

三、排查思路逐步拆解

遇到这种问题,不要急着一上来就猛加并发或者换架构,先一步一步把原因找出来。

3.1 先看日志和监控

腾讯云控制台的函数服务页面,可以看到每一次调用的耗时和初始化时间。我们需要重点看一个指标:初始化时间。它往往被单独列出来,和函数执行时间分开。如果初始化时间占了总耗时的大头,那基本可以确定是冷启动问题。

为了看得更清楚,我们可以在代码里自己记一下时。下面是一段典型的Node.js云函数代码:

// 技术栈:Node.js
'use strict';

// 引入一个比较重的ORM框架,真实场景中可能就是它拖慢了启动
const { Sequelize } = require('sequelize');

// 在全局作用域做数据库连接,每次冷启动都会执行
const sequelize = new Sequelize('database', 'username', 'password', {
  host: '10.0.0.1',
  dialect: 'mysql'
});

exports.main = async (event, context) => {
  // 打印一下从环境创建到执行完的时间
  console.log('冷启动耗时:', Date.now() - global.__startTime);
  return { result: 'ok' };
};

// 记录一下模块加载完成的时间
global.__startTime = Date.now();

这段代码里的global.__startTime是模块加载时记录的时间,等函数真正执行时去减,虽然不够精确,但也能看出大概。如果你看到这个差值每次都是几百毫秒甚至几秒,说明冷启动确实很慢。

3.2 检查依赖包是不是太肥了

冷启动的时候,云函数平台要做的事之一就是把代码包下载下来并解压。代码包越大,这个过程越久。有些朋友习惯把整个项目的node_modules一股脑全打进去,甚至一些只在开发期用的工具也放进来了,这会让代码包体积爆炸。

我们可以在本地检查一下node_modules的目录大小,同时审视一下package.json里到底装了哪些东西。举个例子,如果只是为了发一个HTTP请求,完全不需要引request或者axios,Node.js自带的http模块就能做。下面是一个精简前后的对比:

// 技术栈:Node.js

// 精简前:引入axios,连带引入一堆依赖
const axios = require('axios');
exports.main = async (event) => {
  const res = await axios.get('https://api.example.com/user');
  return res.data;
};

// 精简后:使用Node.js内置的http模块
const http = require('http');
exports.main = async (event) => {
  const data = await new Promise((resolve, reject) => {
    http.get('http://api.example.com/user', (res) => {
      let body = '';
      res.on('data', (chunk) => body += chunk);
      res.on('end', () => resolve(body));
    }).on('error', reject);
  });
  return JSON.parse(data);
};

注意,上面这个例子只是演示,真正生产环境里也可以使用node-fetch这类轻量库,或者在运行环境支持的情况下直接用原生fetch。重点是把那些可要可不要的依赖删掉。

3.3 检查有没有过早的初始化操作

很多人在写云函数时,喜欢把数据库连接、Redis连接、配置拉取都放在全局作用域,觉得这样能复用。但问题是,如果函数环境被回收后重建,这些初始化代码每次冷启动都会跑一遍。冷启动时如果还要去连数据库、拉配置,那时间自然就蹭蹭上去了。

更好的做法是延迟初始化,等函数真正需要用到连接时才去创建。下面这段代码,第一次请求时会创建连接,后续请求复用同一个连接:

// 技术栈:Node.js
'use strict';

let connection = null; // 全局缓存这个连接

async function getConnection() {
  if (connection) {
    return connection;
  }
  // 模拟创建连接的过程,实际可能是 mysql.createConnection(...)
  console.log('开始创建数据库连接');
  connection = await new Promise((resolve, reject) => {
    setTimeout(() => resolve({ query: async () => [1, 2, 3] }), 300);
  });
  return connection;
}

exports.main = async (event, context) => {
  const db = await getConnection();
  const rows = await db.query('SELECT * FROM user');
  return { rows };
};

但要注意,这种复用只适用于函数实例存活期间。如果实例被回收,下次冷启动依然需要重新创建连接。不过至少你不会在冷启动时白白多花一次创建连接的等待时间,你可以把这个动作留到业务真正需要的时候,让调用方感受到的延迟至少前移了。

3.4 看看内存配置是不是太小了

云函数的内存大小会影响分配到的CPU性能。内存配得越小,同样一段初始化代码执行起来就越慢。比如把内存从128MB提升到512MB,冷启动时间可能会有明显下降。很多同学怕花钱,舍不得提内存,但有时候为了性能,适当的配置是值得的。而且在腾讯云函数上,内存大小也会影响并发能力的评估。

四、架构优化方案

排查清楚之后,就可以对症下药了。下面这几个方案是我实际用过觉得有效的,同时也需要权衡代价。

4.1 精简代码和依赖

这是成本最低、最应该先做的事。把不需要的依赖删掉,用更轻量的库替换重的库,减小代码包体积。同时把初始化逻辑从全局挪到函数内部,或者改成懒加载。这一步能让冷启动时间降低不少,也不影响正常运行。

4.2 配置预置并发

如果精简之后还是满足不了延迟要求,那就得上点"热武器"了。腾讯云函数有一个功能叫预置并发,通俗说就是提前把函数实例跑起来,让它保持"热"的状态。你提前设置几个实例,它们会一直待命,请求来了直接用热实例,完全避开冷启动。这个功能非常有效,但需要额外计费,就像你雇了几个人在店里干等,不管有没有客人都得开工资。

下面是一段通过云函数SDK开启预置并发的示例代码:

// 技术栈:Node.js
const tencentcloud = require('tencentcloud-sdk-nodejs');
const ScfClient = tencentcloud.scf.v20180416.Client;

// 这里需要替换成你的腾讯云密钥
const client = new ScfClient({
  credential: {
    secretId: 'your-secret-id',
    secretKey: 'your-secret-key'
  },
  region: 'ap-guangzhou',
});

const params = {
  FunctionName: 'my-function',
  Qualifier: '$LATEST',
  VersionProvisionedConcurrencyNum: 5
};

client.PutProvisionedConcurrencyConfig(params).then(res => {
  console.log('预置并发配置成功', res);
}).catch(err => {
  console.error('配置失败', err);
});

注释里已经说清楚了,这个配置能让5个实例保持热状态。不过在实际操作中,我建议用控制台或者terraform来管理,避免密钥泄漏风险。

4.3 用定时触发做"预热"

如果你不想预置并发那么贵,也可以搞一个定时触发器,每隔几分钟调用一次函数。比如用腾讯云定时触发器,每分钟调一次你的函数,让实例一直活着。这个方法成本很低,但有两个问题:一是你的函数必须有足够的并发余量,因为定时调用会占用实例;二是如果你有多个版本,预热可能只会触发其中一个版本。所以它更适合保证"至少有一个热实例"的场景。

下面是一个简单的定时触发函数示例,它本身什么都不做,只是来"暖场":

// 技术栈:Node.js
'use strict';

exports.main = async (event, context) => {
  // 这个函数会被定时触发器每分钟调用一次
  // 它的任务就是让云函数实例保持活跃,避免被回收
  console.log('预热请求来了,保持心跳');
  return { warmed: true };
};

注意,这种预热方式有时并不完全可靠,因为云函数平台可能会在两次调用之间回收实例,尤其是低流量函数。所以如果你对延迟有硬性要求,还是预置并发最稳。

4.4 把重活拆到独立函数

如果你的冷启动慢是因为加载了太多复杂模块,比如机器学习模型、浏览器渲染库,那可以考虑把这些重逻辑拆成单独的云函数,或者放到常驻的容器服务里。云函数只负责接收请求,然后调用内部接口。这样至少主函数的冷启动不会反复被拖累。不过这个方案会增加架构复杂度,需要你有足够的运维能力去维护。

4.5 合理设置内存大小

前面提到过,内存大小影响CPU性能。可以根据压测结果,试着调整函数内存大小,找到一个性价比最高的点。比如256MB不够,512MB刚好,那就可以接受。这个方法操作最方便,在控制台上拉一拉配置就行。

五、技术优缺点对比

上面几个优化方案,各有优缺点,我简单对比一下。

精简依赖是最简单、免费的,但是效果有上限,特别是当你的业务逻辑本身就复杂,依赖无法再压缩的时候。预置并发效果最明显,几乎是立竿见影,但需要持续花钱,而且还要注意并发上限和配额。定时预热成本低,但可靠性一般,只适合对延迟要求不极端的场景,而且定时任务本身也要消耗一部分调用次数。拆分函数可以把冷启动隔离起来,但增加了网络调用和运维成本。调整内存操作简单,但并不是所有函数都能靠加内存解决问题,内存大小对IO密集型任务的影响其实有限。

所以我的建议是:先做精简依赖和代码优化,把能省的冷启动时间都省掉。如果还不够,再看业务能不能承受一点延迟,能承受就做定时预热,不能承受就上预置并发。

六、注意事项

在实际优化过程中,有几个坑值得提醒大家。

第一,不要盲目追求极致的冷启动而牺牲代码可维护性。比如把所有依赖都手写,那项目后期会很痛苦。第二,预置并发不是设置了就一劳永逸,它也有扩容和缩容的过程,流量突增时依然可能触发新的冷启动。所以要对预置数量做合理评估。第三,监控告警一定要配好。别等到线上用户投诉了才发现问题。腾讯云控制台可以设置调用耗时和错误率告警,建议把所有函数都加上。第四,不同地域的冷启动特性可能不一样,优化策略也要跟着测试结果来。第五,注意云函数的代码包大小限制,如果超过限制,即使冷启动再快也没用,得换一种打包方式,比如使用层或者拆函数。

七、文章总结

冷启动延迟是Serverless绕不开的话题,但并不是无解的难题。通过排查日志、精简依赖、检查初始化逻辑、调整内存配置,再到选择合适的预置并发策略,我们完全可以把冷启动的影响控制在一个可以接受的范围内。当然,没有银弹,每一种方案都有代价,最终还是要根据业务特点来做取舍。我希望大家以后再遇到云函数变慢的情况,脑子里能立刻有一个清晰的排查步骤,而不是急得原地转圈。毕竟,云函数本身只是个工具,怎么用好它,才是我们技术人的本事。