小程序图片处理从 2.9 秒到 420 毫秒:云函数冷启动的 5 个坑

小程序图片处理从 2.9 秒到 420 毫秒:云函数冷启动的 5 个坑

上线第一天,我自己点了一下「上传图片」------转了将近 3 秒才出结果。第二张图,420 毫秒。

同一段代码,同一个接口,差了 7 倍。这不是 bug,是我第一次真切地看到云函数冷启动的成本:用户第一次点,等的是我的容器在启动,不是我的业务在跑。

这篇文章把一个小程序图片处理接口从 2.9 秒压到 420 毫秒的全过程拆开讲。先讲怎么把「冷启动」和「业务耗时」分开测量------很多人优化了半个月,优化的其实是错的那一段;再讲我踩到的 5 个坑,每个都带改造前后的数据;最后给一张总账表和一句反直觉的结论:这个优化到什么程度就该停手。

测试环境

不写环境的性能文章都不用看,因为数据没法复现。

项 值
云函数运行时 Node.js 18
内存配置 256MB
地域 上海
依赖 @tencentcloud/sdk、axios、image-size
测试图 5MB JPEG(4000×3000)
测量方式 客户端 wx.cloud.callFunction 打点,每组 5 次取中位数

一、先解决「测什么」

云函数一次调用的耗时可以拆成三段:

复制代码
冷启动(容器创建 + 代码下载解压 + 模块加载 + 框架初始化)
  → 业务执行(你的 handler 逻辑)
  → 网络回传(结果包大小 × 网络状况)

大多数「优化」之所以没效果,是因为只盯着第二段。而用户感知的等待,主要在第一段------它只在容器新建时发生一次,所以你在本地反复测试永远测不出来:本地连着调 10 次,第 1 次冷、后 9 次全热,你看到的「平均 400ms」是被热启动平均掉的假象。

用 6 行代码把冷启动标出来

在云函数入口文件的模块顶层记录时间戳。模块顶层代码只在容器初始化时执行一次,所以它能精确标出容器是什么时候起来的:

js 复制代码
// cloudfunctions/image-process/index.js
const INIT_TS = Date.now()
console.log('[cold-start] module init', INIT_TS)

const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })

exports.main = async (event, context) => {
  const handlerTs = Date.now()
  console.log('[cold-start] handler start, init cost =', handlerTs - INIT_TS, 'ms')

  const result = await handleImage(event)

  return {
    ...result,
    _initTs: INIT_TS,
    _initCost: handlerTs - INIT_TS,
  }
}

客户端判断「这次是不是冷启动」

把 _initTs 返回给客户端,它变了就说明换了新容器:

js 复制代码
async function probe(times = 5, gapMs = 0) {
  const rows = []
  let prevInitTs = null

  for (let i = 0; i < times; i++) {
    const t0 = Date.now()
    const res = await wx.cloud.callFunction({
      name: 'image-process',
      data: { fileID: 'cloud://xxx/test-5mb.jpg' },
    })
    const total = Date.now() - t0
    const { _initTs, _initCost } = res.result

    rows.push({
      round: i + 1,
      total,
      initCost: _initCost,
      cold: prevInitTs === null || _initTs !== prevInitTs,
    })
    prevInitTs = _initTs

    if (gapMs) await new Promise((r) => setTimeout(r, gapMs))
  }

  console.table(rows)
  return rows
}

跑一次(间隔 6 分钟,让容器回收):

yaml 复制代码
┌───────┬───────┬──────────┬──────┐
│ round │ total │ initCost │ cold │
├───────┼───────┼──────────┼──────┤
│ 1     │ 2905  │ 2210     │ true │
│ 2     │ 428   │ 0        │ false│
│ 3     │ 415   │ 0        │ false│
└───────┴───────┴──────────┴──────┘

结论很直接:2905ms 里有 2210ms 是容器启动,真正的业务只占 400 多毫秒。 我之前一直在优化那 400 毫秒------这是整件事里最讽刺的部分。

三条测量纪律,建议你也遵守:

  1. 每组至少 5 次,取中位数,不要用平均值。冷启动是长尾分布,一次极端值就能把均值带偏 30%。
  2. 每次测冷启动要等容器回收(间隔 5 分钟以上,或换环境),否则测的全是热启动。
  3. 记录 Node 版本、内存配置、依赖体积。没有这些,数据三个月后自己都复现不了。

二、坑 1:依赖包 46.8MB,光解压和加载就吃掉 1.2 秒

现象 :initCost 稳定在 2200ms 左右,波动不大,说明不是网络问题。

原因 :为了调腾讯云 CI,我直接装了官方 @tencentcloud/sdk,加上 axios、image-size、几个工具库,node_modules 一共 46.8MB。容器每次新建都要下载、解压、逐个 require,而 require 是同步阻塞的------模块越多,容器起来得越慢。

改法:把「大而全的 SDK」换成「手写 60 行签名 + 原生 fetch」。

腾讯云 API 3.0 的签名是标准的 TC3-HMAC-SHA256,Node 内置 crypto 就够了,不需要 SDK:

js 复制代码
const crypto = require('crypto')

const SECRET_ID = process.env.TENCENT_SECRET_ID
const SECRET_KEY = process.env.TENCENT_SECRET_KEY

function sha256Hex(str) {
  return crypto.createHash('sha256').update(str).digest('hex')
}
function hmac(key, str) {
  return crypto.createHmac('sha256', key).update(str).digest()
}

function signTC3({ host, service, action, version, payload, region = 'ap-shanghai' }) {
  const timestamp = Math.floor(Date.now() / 1000)
  const date = new Date(timestamp * 1000).toISOString().slice(0, 10)
  const body = JSON.stringify(payload)

  const canonicalRequest = [
    'POST', '/', '',
    `content-type:application/json; charset=utf-8\nhost:${host}\n`,
    'content-type;host',
    sha256Hex(body),
  ].join('\n')

  const scope = `${date}/${service}/tc3_request`
  const stringToSign = [
    'TC3-HMAC-SHA256', timestamp, scope, sha256Hex(canonicalRequest),
  ].join('\n')

  const kDate = hmac('TC3' + SECRET_KEY, date)
  const kService = hmac(kDate, service)
  const kSigning = hmac(kService, 'tc3_request')
  const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex')

  return `TC3-HMAC-SHA256 Credential=${SECRET_ID}/${scope}, SignedHeaders=content-type;host, Signature=${signature}`
}

同时把 axios 换成 Node 18 自带的 fetch,把 image-size 换成只读 JPEG 头 20 字节的小函数(我只用它拿宽高,为了这个引一个库不值得)。

效果:

指标 改造前 改造后
node_modules 体积 46.8MB 3.4MB
模块加载阶段耗时 1180ms 320ms
冷启动总耗时 2905ms 1520ms

到这一步,标题里的「2.9 秒」已经掉了一半。剩下 1.5 秒还在容器里,继续往下挖。

三、坑 2:SDK 初始化写在 handler 里,每次调用都白跑一次

现象 :修完依赖后冷启动 1520ms,但热启动也有 680ms,比预期高 200 多毫秒。

原因:这段代码是跟着示例抄的:

js 复制代码
// 反面写法
exports.main = async (event) => {
  const cloud = require('wx-server-sdk')
  cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
  // ...
}

require 有模块缓存,第二次调用不至于重新解析文件,但 cloud.init() 每次都在跑:重新读环境变量、重建内部客户端。它不慢,但每次调用都付一遍,属于纯浪费。

改法:所有初始化提到模块顶层,handler 只做业务。

js 复制代码
// 正确写法
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, traceUser: true })

exports.main = async (event) => {
  // handler 里只放业务逻辑
}

这条规则适用于所有「只依赖配置、跟请求无关」的初始化:数据库连接、HTTP Agent、SDK 客户端、常量表。判断标准很简单------它需要 event 吗?不需要就搬到外面。

效果:热启动 680ms → 428ms,冷启动 1520ms → 1290ms。

四、坑 3:每次请求重新算签名,180ms 花在重复劳动上

现象 :单个图片处理请求,业务段里 signTC3 占了 180ms 左右。签名本身是纯 CPU 计算,不该这么慢------慢的是它前面的两件事:每次都重新读 process.env、每次都重新拼 canonical request 字符串。

原因 :签名结果在同一秒内、同一接口上是完全可复用的(腾讯云签名有效期是 5 分钟级别,我按 25 分钟缓存,留足余量)。我却在每个请求里都算了一遍。

改法:模块级缓存,带过期时间。

js 复制代码
let cache = { value: null, expireAt: 0 }

function getAuth(params) {
  const now = Date.now()
  if (cache.value && now < cache.expireAt) return cache.value

  cache = {
    value: signTC3(params),
    expireAt: now + 25 * 60 * 1000,
  }
  return cache.value
}

注意两点,踩过才知道:

  • 缓存 key 要带上 action 和 payload 的 hash。不同接口的签名不能混用,用单变量缓存会串。
  • 别缓存到函数外层进程之外。云函数容器销毁后缓存自然没了,不需要额外清理;反过来也别指望它跨实例共享。

效果:单次业务耗时 640ms → 460ms。

五、坑 4:图片在函数里来回搬,460ms 全花在公网传输

现象:业务段剩下的 460ms,几乎全是网络:函数从 COS 下载 5MB 原图(约 280ms)→ 传给 CI 处理 → 再下载结果(约 180ms)。

原因:我把云函数当成了「图片中转站」。它确实能干活,但它的网络出口和带宽都不是为传大文件设计的,5MB 走两趟就是纯浪费。

改法 :让图片根本不要进函数。

数据万象(CI)支持直接在 CDN URL 上带处理参数,图片处理发生在边缘节点,不经过我的云函数:

bash 复制代码
https://xxx.cos.ap-shanghai.myqcloud.com/demo/a.jpg?imageMogr2/thumbnail/800x/quality/80/format/webp

云函数只做三件事:校验用户身份、拼出带签名的处理 URL、返回给客户端。原图由客户端直传 COS,处理由 CDN 边缘完成,函数里一行文件流都没有。

js 复制代码
exports.main = async (event) => {
  const { fileID, width = 800 } = event
  // 只拼 URL + 签名,不下载、不中转
  const url = buildCdnUrl(fileID, `imageMogr2/thumbnail/${width}x/quality/80/format/webp`)
  return { url, signedAt: Date.now() }
}

这一改不只是快了,账单也降了:函数的出流量从「每次 5MB 上下行」变成「几百字节 JSON」。

效果:业务段 460ms → 90ms,冷启动 1290ms → 860ms(代码更少,加载更快)。

这里我做了个取舍:本来还评估了「函数内走内网直连 CI」的方案,理论上还能再省 40ms,但要改 VPC 配置、加内网网关、处理跨地域,改造成本是前一个方案的 3 倍以上。收益 40ms,我没做。

六、坑 5:多图串行处理,5 张图排队 2.1 秒

现象:单图 420ms 挺好,但用户一次传 5 张,总共 2.1 秒------因为我是这么写的:

js 复制代码
// 串行,5 张图 = 5 倍时间
const results = []
for (const file of files) {
  results.push(await processOne(file))
}

原因:没有依赖关系的任务串行执行,纯粹是把延迟加起来。

改法:并发,但要限制并发数------不限的话,20 张图同时打出去,COS 和 CDN 会给你限流,反而更慢。

js 复制代码
async function mapLimit(items, limit, fn) {
  const results = new Array(items.length)
  let cursor = 0

  const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
    while (cursor < items.length) {
      const i = cursor++
      results[i] = await fn(items[i], i)
    }
  })

  await Promise.all(workers)
  return results
}

const results = await mapLimit(files, 4, processOne)

并发数 4 是我实测出来的:3 和 4 差别不大,5 之后开始出现偶发限流(重试反而更慢)。

效果:5 张图 2100ms → 620ms,10 张图 4200ms → 1180ms。

七、总账

指标 优化前 优化后 变化
冷启动总耗时(客户端口径) 2905ms 860ms -70%
热启动总耗时 1180ms 428ms -64%
node_modules 体积 46.8MB 3.4MB -93%
单张 5MB 图端到端 3120ms 860ms -72%
5 张图批量 8900ms 1420ms -84%
函数出流量/次 ~10MB <1KB -99.9%

五个坑按改动成本排序,从低到高:初始化搬到顶层 < 签名缓存 < 精简依赖 < 并发 < 架构改成 CDN 直出。前三个加起来一个下午能做完,拿走七成的收益。

八、什么时候该停手

最后一个反直觉的结论:这些优化不是越多越好。

我把冷启动压到 860ms 之后,再往下每压 100ms,改造成本大约是前面的 3 倍。而我这个场景里,用户在 500ms 以内的等待基本无感------再往下压,收益是「我的仪表盘好看」,成本是「多一个可能出故障的环节」。

所以第 4 个坑里那个内网方案,我评估完就停了,没上。

性能优化的终点不是「更快」,是「够快,而且不再为它付利息」。

最后

如果你也在做图片相关的小程序,这套链路的另一半我下一篇会写:图片从 8MB 压到 300KB,数据万象的压缩参数怎么调、账单怎么省。可以先关注,省得下次找不到。

文末留个自查清单,照着过一遍能避开大部分冷启动问题:

  1. 依赖是不是能删一半?有没有为了一个函数引了一个 SDK?
  2. 所有初始化都在模块顶层吗?有没有需要 event 的东西被误搬出去了?
  3. 签名、token、配置有没有做缓存?缓存的 key 会不会串?
  4. 大文件是不是在函数里中转?能不能改成 CDN 直出或客户端直传?
  5. 批量任务是不是串行?并发数压测过吗?
  6. 冷启动有没有单独测量过,而不是被热启动平均掉?
  7. 内存配置调大过吗?(内存越大 CPU 配额越高,冷启动有时反而更快)
  8. 有没有用定时触发器保活?注意这会持续产生调用费用,只在关键时段用。
  9. 优化到最后,问一句:用户真的感知得到这 100ms 吗?
相关推荐
沃达德软件1 小时前
智慧教育教学资源管理系统(PC端+微信小程序)
微信小程序·小程序
考拉讲支付19 小时前
延迟分账 / 核销分账的技术实现方案:服务完成后再分钱,到底怎么设计?
大数据·运维·人工智能·微信小程序·小程序·系统架构
科威网络2 天前
小程序开发性能优化:分包、预加载与启动时间
性能优化·微信小程序·小程序
互联网+人工智能观察2 天前
微信小程序私有化定制、源码交付方案选型|SaaS / 模板二开 / 定制方案技术对比
微信小程序·小程序
爱勇宝4 天前
小红花成长更新了:孩子想要什么,也应该有机会自己说出来
微信小程序·产品·小程序·云开发
andrsted4 天前
企业培训时间难统一?练题簿让在线免费刷题与培训考核更好安排
学习·微信小程序·小程序·学习方法·刷题
拖孩4 天前
一个人 + AI 做的小程序,今天,正式回本了
前端·后端·微信小程序
李纲明4 天前
微信小程序 wx.uploadFile 上传失败:合法域名、临时路径与 HTTPS 证书排查
微信小程序·小程序·uploadfile·devdiy·上传失败
冬奇Lab5 天前
一天一个开源项目(第247篇):wxa-runner —— 让小程序在普通浏览器里用微信官方基础库跑起来,直接接 Playwright 做无头 E2E
微信小程序·开源