3、云函数进阶:异步调用、冷启动优化与并发扩缩容

好,咱们接着聊云函数。前面几章我们把云函数的基本用法过了一遍,能跑起来了,也能接 API 了。但说实话,那只是入门。真正到了生产环境,你会发现事情没那么简单。

我记得第一次把云函数部署到线上,用户一多,函数直接超时、报错、甚至卡死。我当时就懵了——明明本地测试好好的啊?后来才明白,云函数不是「写好了就能扛住流量」的。你得懂它的异步机制、冷启动的坑、还有并发扩缩容的脾气。

这一章,我就把这三个核心问题掰开揉碎讲清楚。你想想看,搞懂了这些,你才算真正入了 Serverless 的门。

3.1 云函数的异步调用

先说异步调用。说白了,就是函数调用方不等着结果返回,丢个请求就走。这在很多场景下非常有用,比如用户注册后发个欢迎邮件、上传图片后做压缩处理、或者定时跑个数据清洗任务。

我习惯把异步调用分成两种:事件驱动型异步任务型异步

3.1.1 事件驱动型异步

这种模式最常见。比如用户上传了一张图片到对象存储,存储桶触发一个事件,云函数自动被调用去处理图片。调用方(存储桶)不关心函数什么时候执行完,它只管「通知」一下。

代码示例(以腾讯云 SCF 为例):

// 处理图片上传事件
exports.main_handler = async (event, context) => {
    console.log('收到文件上传事件:', JSON.stringify(event));
    
    // 从事件中提取文件信息
    const fileInfo = event.Records[0].cos.object;
    const bucket = event.Records[0].cos.bucket;
    
    // 异步处理:压缩图片
    await compressImage(bucket, fileInfo.key);
    
    // 这里不需要返回任何东西给调用方
    return 'ok';
};

嗯,这里要注意:事件驱动的异步调用,函数本身不需要关心返回值。调用方(比如对象存储)也不会等你的结果。所以如果你的函数里有什么错误,调用方是不知道的——你得自己做好日志和告警。

我的经验: 事件驱动的异步调用,一定要加死信队列(DLQ)。我曾经有个图片处理函数,某天突然批量失败,因为没有 DLQ,所有失败事件都丢了,用户反馈图片没处理我才发现。后来我每个异步函数都配了 DLQ,再也没出过这种问题。

3.1.2 任务型异步

另一种异步是「我主动调用一个函数,但我不想等它执行完」。比如用户注册后,你希望发一封邮件,但发邮件可能要几秒钟,你不想让用户干等着。

这时候可以用云平台提供的异步调用接口:

// 使用 SDK 异步调用云函数
const { SCF } = require('tencentcloud-sdk-nodejs-scf');

async function sendWelcomeEmailAsync(userEmail) {
    const client = new SCF.Client({
        credential: { secretId, secretKey },
        region: 'ap-guangzhou'
    });
    
    // 关键:设置 InvokeType 为 'Event' 表示异步调用
    await client.Invoke({
        FunctionName: 'sendEmail',
        InvocationType: 'Event',  // 异步!
        Payload: JSON.stringify({ email: userEmail })
    });
    
    // 这里立即返回,不等待函数执行完
    return '邮件发送任务已提交';
}

为什么用异步?说白了,就是用户体验。用户注册完,你直接返回「注册成功」,后台慢慢发邮件。用户不用等那几秒钟,体验好得多。

注意: 异步调用有超时限制。大多数云平台对异步调用的最大执行时间有限制(比如 15 分钟或 30 分钟)。如果你的任务需要跑几个小时,异步调用就不合适了——得用工作流或批处理服务。

3.2 云函数的冷启动优化

冷启动,这是 Serverless 的老大难问题。你想想看,一个函数第一次被调用时,平台需要拉起一个容器、加载代码、初始化运行时——这个过程就是冷启动。它可能耗时几百毫秒甚至几秒。

我刚开始做 Serverless 时,有个 API 接口,用户反馈「第一次访问特别慢,后面就快了」。这就是典型的冷启动问题。那怎么优化呢?

3.2.1 减少代码包体积

代码包越大,冷启动越慢。这是最直接的优化点。

  • 只打包必要的依赖:别把整个 node_modules 都塞进去。用 npm install --production 只装生产依赖。
  • 使用层(Layer):把公共依赖(比如 SDK、工具库)放到层里,函数代码只包含业务逻辑。层会被缓存,冷启动时不用重新加载。
  • 代码拆分:一个函数只做一件事。别把整个项目塞到一个函数里。

代码示例:使用层优化后的目录结构

# 项目结构
my-function/
├── src/
│   └── index.js        # 业务代码,很小
├── layer/              # 层目录
│   └── nodejs/
│       └── node_modules/
│           ├── axios/
│           └── lodash/
└── serverless.yml

3.2.2 初始化代码延迟加载

很多人在函数外面做初始化,比如建立数据库连接。这会导致冷启动时所有初始化都跑一遍。

我建议:把初始化放到函数内部,按需加载

// ❌ 不好的做法:全局初始化
const db = new Database(process.env.DB_URL);  // 冷启动时一定会执行

exports.handler = async (event) => {
    return db.query('SELECT * FROM users');
};

// ✅ 好的做法:延迟初始化
let db;

exports.handler = async (event) => {
    if (!db) {
        db = new Database(process.env.DB_URL);  // 只在第一次调用时初始化
    }
    return db.query('SELECT * FROM users');
};

嗯,这里有个小技巧:用 lazy 模式初始化,冷启动时间能减少 30%-50%。我在一个高并发项目里试过,效果很明显。

3.2.3 预留并发实例

这是最「暴力」但最有效的方法。说白了,就是让平台一直保持几个函数实例是「热」的,永远不会被回收。

大多数云平台都支持预留并发(Provisioned Concurrency)。你设置一个最小值,比如 5 个实例,平台就会一直保持 5 个实例在线。冷启动?不存在的。

核心要点: 预留并发能彻底解决冷启动,但要花钱。你得在性能和成本之间找平衡。我一般对核心 API 接口用预留并发,对后台任务用普通模式。

3.3 云函数的并发与扩缩容

最后一个话题,也是生产环境最容易出问题的——并发和扩缩容。

你想想看,你的函数平时可能只有几十个请求,突然搞了个活动,流量暴涨到几万。云函数能扛住吗?

3.3.1 并发上限

每个云平台都有并发上限。比如腾讯云 SCF 默认单个函数的并发上限是 1000(可以提工单提升)。AWS Lambda 默认是 1000。

这个上限是什么意思?就是同一时刻最多只能有 1000 个函数实例在运行。如果第 1001 个请求来了,它会排队等待,或者直接报错。

云平台 默认并发上限 可提升至
腾讯云 SCF 1000 数万(需申请)
AWS Lambda 1000 数万(需申请)
阿里云 FC 100 数千(需申请)

我遇到过最惨的一次:双十一活动,并发直接冲到 3000,函数报错率飙升到 40%。后来紧急提工单提升并发上限才解决。所以,提前评估并发需求,提前申请上限,别等到出事了再处理。

3.3.2 扩缩容策略

云函数的扩缩容是自动的,但不同平台的策略不一样。

  • 快速扩容:流量突增时,平台会快速创建新实例。但创建需要时间(冷启动),所以会有短暂的延迟。
  • 慢速缩容:流量下降时,平台不会立刻销毁实例。它会保留一段时间(比如 5-15 分钟),以防流量再次上涨。

这里有个坑:缩容太慢会导致成本上升。我记得有一次,一个定时任务跑完后,函数实例还保留了 10 分钟,白白浪费了资源。后来我设置了更短的超时时间,让实例尽快释放。

我的建议: 如果你的函数有明确的流量波峰波谷,可以设置「最小实例数」和「最大实例数」。比如白天流量大,最小实例数设为 10;晚上流量小,最小实例数设为 1。这样既能保证性能,又能控制成本。

3.3.3 避免并发冲突

并发高了,容易出另一个问题——资源竞争。比如多个函数实例同时写同一个文件、同时更新数据库同一条记录。

解决方案很简单:避免共享状态

  • 用数据库的事务机制处理并发写
  • 用分布式锁(比如 Redis 锁)控制临界资源
  • 尽量用幂等操作,同一个请求执行多次和一次结果一样

代码示例:使用 Redis 分布式锁

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

async function processOrder(orderId) {
    const lockKey = `order:${orderId}:lock`;
    
    // 尝试获取锁,超时 5 秒
    const lock = await redis.set(lockKey, 'locked', 'NX', 'EX', 5);
    
    if (!lock) {
        throw new Error('订单正在处理中,请稍后重试');
    }
    
    try {
        // 处理订单逻辑
        await updateOrderStatus(orderId, 'processed');
    } finally {
        // 释放锁
        await redis.del(lockKey);
    }
}

嗯,这里要注意:锁的超时时间要设置合理。太短了可能业务没处理完锁就过期了,太长了会影响其他请求。我一般根据业务耗时设置,比如 5 秒或 10 秒。

小结

这一章我们聊了三个核心问题:异步调用怎么玩、冷启动怎么优化、并发扩缩容怎么应对。说白了,云函数不是「写代码」那么简单,你得理解它的运行机制,才能用好它。

我个人觉得,冷启动优化和并发管理是 Serverless 进阶的关键。搞懂了这两点,你就能写出既快又稳的云函数。下一章我们会聊更高级的话题——云函数与数据库的深度集成,到时候见。