# 深入理解云函数

云函数是按需执行, 函数结束后会停止运行, 其中 Node.js 运行时的运行机制和普通 Node.js 本地运行的行为有些差异.

# 云函数启动

冷启动, 热启动是云函数中比较重要的概念, 理解这几种启动的关系可以解释云函数的一些性能问题, 更好的优化代码. 基于云函数按需执行的特点, 函数在不被触发的时候 (大概几十分钟没有任何调用), 计算资源是不被激活的. 调用一个函数的完整过程一般需要实例化计算实例, 加载函数代码, 启动 node, 执行代码等步骤. 函数被调用时执行这些完整步骤的过程一般称作冷启动, 冷启动的耗时往往比较长. 而如果函数实例和执行进程都被复用的情况下一般被定义为热启动, 热启动的性能较冷启动要好很多.

考虑下面这个云函数的 index.js:

let i = 0
exports.main = async (event = {}) => {
  i++
  console.log(i)
  return i
}

在第一次调用该云函数的时候函数返回值为 1, 这是正常符合预期的. 但如果连续调用这个云函数, 其返回值有可能是从 2 递增, 也有可能变成 1. 返回值位递增的我们可以理解为函数热启动, 执行函数的 Node.js 进程会被复用, main 函数外的代码也不会执行. 返回值为 1 的时候可以理解函数是冷启动或者温启动, Node.js 执行进程是全新的, 代码会完整的执行一遍. 从这个角度来说, 云函数每次调用都有可以有自己的上下文, 并不是完全的无状态.

# 云函数的运行机制

运行环境

云函数运行在云端 Linux 环境中,一个云函数在处理并发请求的时候会创建多个云函数实例,每个云函数实例之间相互隔离,没有公用的内存或硬盘空间。云函数实例的创建、管理、销毁等操作由平台自动完成。每个云函数实例都在 /tmp 目录下提供了一块 512MB 的临时磁盘空间用于处理单次云函数执行过程中的临时文件读写需求,需特别注意的是,这块临时磁盘空间在函数执行完毕后可能被销毁,不应依赖和假设在磁盘空间存储的临时文件会一直存在。如果需要持久化的存储,请使用云存储功能。

无状态函数

云函数应是无状态的,幂等的,即一次云函数的执行不依赖上一次云函数执行过程中在运行环境中残留的信息。

为了保证负载均衡,云函数平台会根据当前负载情况控制云函数实例的数量,并且会在一些情况下重用云函数实例,这使得连续两次云函数调用如果都由同一个云函数实例运行,那么两者会共享同一个临时磁盘空间,但因为云函数实例随时可能被销毁,并且连续的请求不一定会落在同一个实例,因此云函数不应依赖之前云函数调用中在临时磁盘空间遗留的数据。总的原则即是云函数代码应是无状态的。

事件模型

云函数的调用采用事件触发模型,每一次调用即触发了一次云函数调用事件,云函数平台会新建或复用已有的云函数实例来处理这次调用。同理,因为云函数间也可以相互调用,因此云函数间相互调用也是触发了一次调用事件。

自动扩缩容

开发者无需关心云函数扩容和缩容的问题,平台会根据负载自动进行扩缩容。

# node8 云函数中的异步行为

考虑下面这样一段代码 index.js, 在本地调用 main 函数的时候可以马上拿到返回值 ok, 并且稍后可以看到 requestId 的打印.

exports.main = async (event = {}, context) => {
  setTimeout(() => {
    console.log('rid: ', context.request_id)
  }, 0)
  return 'ok'
}

如果把上面的代码上传成 nodejs8 的云函数, 通过 SDK 的 callFunction 或者其他方式进行触发调用, 也会马上拿到返回值 ok, 但是 setTimeout 中异步函数打印的内容确不会在这次调用日志里看到.

screenshot0

如果连续调用第二次, 您会在调用日志里看到上次 setTimeout 中打印. 这一点是很多云函数使用者感到困惑的地方. 于对于异步函数, 主流程执行完成(示例中类似 await main(event, context) 执行完成), 拿到返回值后, 函数实例进程会冻结, 程序中剩下的所有的异步任务会暂停执行, 直到这个进程被唤起. 另一方面如果函数实例由于某些原因没被复用(比如更新函数代码), 这个异步流程中的代码就永远不会被执行.

screenshot1

所以在 node8 的云函数中不要在异步分支流程中执行关键任务. 如果想稳定执行上面示例中 setTimeout 的代码, 请使用 node10 及以上版本的云函数或者将其放到函数主流程:

exports.main = async (event = {}, context) => {
  await new Promise(() => {
    setTimeout(() => {
      console.log('rid: ', context.request_id)
      resolve()
    }, 0)
  })
  return 'ok'
}

基于 node8 云函数的这种异步特性, 会有些和常规的本地开发不太一样的地方需要注意下:

考虑以下这样的一段代码, 我们希望向 test 集合中添加一条数据

exports.main = async (event) => {
  db.collection('test').add({ data: { a:1 } })
}

上面这段代码在实际运行过程中, 数据往往不会添加成功, 因为添加数据逻辑 db.collection('test').add({ data: { a:1 } }) 是一个异步过程, 数据添加的请求没发出去的时候函数就已经执行完成并进入冻结状态. 在单次函数调用过程中数据并不会添加成功, 在函数实例下次被复用的某个时候, 异步请求会被执行到并且将网络请求发送出去. 如果上例中添加数据的操作是个关键的操作, 需要添加 await 关键字来保证异步操作稳定完成:

exports.main = async (event) => {
  await db.collection('test').add({ data: { a:1 } })
}

# 小程序 getWXContext 方法在 node8 云函数中的问题

小程序 wx-server-sdk 提供了 getWXContext 方法来获取函数的一些上下文信息(appId, openId, unionId) 来方便开发者, 该方法本质是从环境变量中读取若干参数. 如果在异步流程中使用该方法获取 openIdunionId 的时候会产生身份漂移的异常情况.

比如存在这样的一段代码:

const cloud = require('wx-server-sdk')

exports.main = async (event) => {
  console.log('openid a: ', cloud.getWXContext().OPENID)  
  setTimeout(() => {
      const { OPENID } = cloud.getWXContext()
      console.log('openid b: ', OPENID)
  }, 0)
}

假如两个不同的用户通过小程序访问该函数, 用户 A 的 openid 为 1111, 用户 A 的 openid 为 2222. 他们两次调用的日志将分别为:

screenshot2

screenshot3

可以看到第一次调用的 requestId 前缀为 f5d39966, 打印出了用户 A 的 openid, 异步流程外的用户 openid 打印符合预期, 异步流程的逻辑当次调用并没有执行.

第二次调用的 requestId 前缀为 f6bda2d0, 在函数调用刚开始的时候触发了第一次调用的异步逻辑, 打印出了用户 B 的 openid, 而这次调用其实是用户 A 调用函数的异步流程.

所以如果需要在函数的异步流程里使用 _openid 或者 unionId 这些 c 端用户身份做些操作, 我们建议使用 node10 版本的云函数来实现这些异步能力. 在 node10 云函数中的异步行为和本地的行为类似, 在函数返回后异步流程还是会继续执行, 而不是等到后面某个调用周期里执行.

# 其他

云函数中的时区默认是 UTC+0, 在函数中使用 date.getHours 等方法获取本地时间信息的时候会和北京时间有 8 个小时的差异. 定义 TZ 环境变量可以定义函数运行时的时区, 比如设置 TZAsia/Shanghai 可以指定函数的时区为北京时间, 有效的时区可以参考 https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. 事实上, 在服务端处理时间 (包括云数据库和云函数) 使用日期或者小时字符串是个坏的实践, 建议使用时间戳或者时间对象这样的绝对值, 这样可以避免服务端和客户端时区差异带来的众多问题.