同一个云函数第一次调用要等几秒、之后很快,这个现象通常说明耗时集中在实例新建后的初始化阶段,而不是 handler 里的业务逻辑。处理方向是先用日志把一次冷调用的时间切成「初始化」和「handler 执行」两段,看哪一段占主导,再决定是改代码结构、改打包方式,还是转去查下游服务的首次连接。如果日志显示两段都不长、总耗时却仍然高,问题可能不在函数内部(触发链路、网关转发、依赖服务等待),此时继续改函数代码多半没有收益。
不要先动业务代码。用同一个实例标识把平台日志的耗时字段和自己打的初始化打点对齐,确认慢只发生在实例新建后的第一次调用;确认后再把客户端创建和配置读取提到模块顶层,让复用的实例不再重复付这笔开销,最后用连续两次调用对比初始化耗时是否真的降下来。若两段耗时都不高,应转向打包体积和首次网络握手排查,而不是继续改 handler。
在云函数日志里分出初始化阶段和 handler 执行阶段
第一步不是改代码,是确认现象。在控制台的函数测试里连续触发两次调用,间隔不要太长(几分钟以上实例可能已被回收,就看不出复用效果)。云函数日志里通常能看到每次调用的耗时、内存占用以及是否属于冷启动的标记,字段名各运行时和版本可能不同,以控制台实际展示的为准,不要凭记忆写死字段。
平台给的耗时是整次调用的总量,要进一步拆分就得自己打点。可用下面这个骨架,靠进程号和请求标识把「模块加载完成」与「某一次 handler 执行」串起来:
// 模块顶层
const t0 = Date.now();
// ... 这里的 require 和初始化代码都属于初始化阶段
console.log(`[init-done] pid=${process.pid} load=${Date.now() - t0}ms`);
exports.main = async (event, context) => {
const t1 = Date.now();
const reqId = context.request_id || context.requestId; // 先打印 context 确认字段名
console.log(`[handler-start] pid=${process.pid} reqId=${reqId}`);
try {
return await handle(event);
} finally {
console.log(`[handler-done] pid=${process.pid} reqId=${reqId} cost=${Date.now() - t1}ms`);
}
};
要点在于 pid:模块顶层代码只在一个实例里执行一次,如果把 t0 写在顶层、在 handler 里算 t1-t0,热调用时这个差值会一次比一次大,因为它算的是「实例存活时长」,不是初始化耗时。正确做法是只在模块加载结束那一刻打一条 init-done,再用 pid 去对应同一实例下第一次 handler-start 的记录,两段才可比。把平台的整次耗时减去自测的 handler 耗时,剩下的部分就是初始化加框架开销,用来判断方向足够。
把客户端初始化和配置读取提到 handler 外
确认初始化占大头后,最直接的动作是把「每个请求都要用、且可以跨请求复用」的对象提到模块顶层创建。下面的骨架是通用写法,SDK 名称和初始化参数按你实际使用的替换:
const tcb = require('@cloudbase/node-sdk');
const app = tcb.init({ env: process.env.TCB_ENV });
const db = app.database(); // 复用实例,不在 handler 里新建
const PAGE_SIZE = 20; // 静态配置直接常量化
let configPromise = null;
function getConfig() {
if (!configPromise) {
configPromise = db.collection('config').doc('app').get()
.catch((err) => { configPromise = null; throw err; }); // 失败不缓存
}
return configPromise;
}
exports.main = async (event, context) => {
const cfg = await getConfig();
const { data } = await db.collection('orders')
.where({ uid: event.uid })
.limit(cfg.data.pageSize || PAGE_SIZE)
.get();
return { code: 0, data };
};
可以安全复用的是无状态、按环境初始化的对象:数据库与存储客户端、HTTP 客户端及其长连接 agent、只读常量和字典、已经校验过的环境变量。不建议跨请求共享的是带请求上下文的东西:登录态、用户身份、事务对象、以及自己写的可变缓存——并发进来时很容易互相串数据。上面 getConfig 用的是「首次用到才发起的 Promise,成功后一直复用」,代价是第一次调用仍然要等这次读取,好处是之后的调用不再重复。如果配置完全不随环境变化,直接写死或构建时注入,连这一次都省掉。
压缩打包体积与去掉首次同步 IO
初始化耗时降不下来时,看依赖。常见的拖慢项有三类:一是整包引入的库,只用一个函数却把 entire 包都 require 进来;二是模块顶层的同步读文件,例如在顶层 fs.readFileSync 读 JSON 规则表;三是首次网络握手,包括下游服务的 DNS 解析、TLS 握手、数据库连接建立,这部分不在你的代码里,但会体现在第一次调用的总耗时上。
给出一个常见的前后对照写法:
// 改造前:顶层同步 IO + 整包引入
const fs = require('fs');
const _ = require('lodash');
const rules = JSON.parse(fs.readFileSync('./rules.json', 'utf8'));
// 改造后:按需引入,且只在用到的分支里加载
const get = require('lodash/get');
let rules = null;
function getRules() {
if (!rules) rules = JSON.parse(require('fs').readFileSync('./rules.json', 'utf8'));
return rules;
}
判断标准要说清楚:懒加载只对「大部分请求用不到」的依赖有意义,比如后台管理分支、图片处理分支才会用的库。如果每次请求都要用,把它挪进 handler 只是把耗时从初始化段搬到 handler 段,首次调用的总时长并不会变,这时应该改打包(按需引入、构建期生成常量、拆成多个函数)。至于同步读文件,如果读的是小体积、启动即需要的配置,更稳妥的做法是构建时内联成常量或在部署配置里注入,避免运行时磁盘 IO。
连续触发两次调用做改造前后对比
验证方式要可重复。在控制台的函数测试面板连点两次,或使用 CLI 调用,命令名与参数以你本地安装版本的 help 为准,大致形式是:
tcb fn invoke <函数名> `--params` '{"uid":"test-user"}'
两次间隔控制在几秒内,确保落在同一个实例上(间隔过长实例被回收,测出来的仍是冷启动)。把两次的记录填进同一张表,改造前后各做一遍:
| 调用序号 | 总耗时(平台日志) | 初始化耗时(init-done 打点) | handler 耗时(自测) | 备注 |
|---|---|---|---|---|
| 1(冷) | 实例新建,pid 记为 A | |||
| 2(热) | 同 pid A,无 init 记录属正常 |
看表的方式:如果第 2 次的总耗时和 handler 耗时接近,说明请求路径里已经不包含重复的初始化动作;如果第 1 次仍明显偏慢、第 2 次正常、而 handler 耗时稳定,说明首次开销还在实例创建与依赖加载阶段,回到体积和网络握手那一节继续查。改造前后两张表的 handler 耗时不应有数量级变化,若有,多半是懒加载把成本挪过来了,而不是真的省掉了。