logo科技微讯

腾讯云函数连接 mongoDB 的正确方法:保持数据库连接而不超时

作者:科技微讯
日期:2021-06-06
📜 文章

这篇文章分享运行在 Node.js 环境中的腾讯云函数、小程序云开发下的云函数连接 mongoDB 数据库的最佳实践,回答如何让数据库连接保持复用,同时又避免云函数超时出错的问题,这个问题有助于我们更深入地理解云函数的运行机制。

文章会提及阿里云、华为云、AWS 的云函数,为方便理解,请参考它们的简称:

  • 腾讯云云函数:scf
  • 阿里云云函数:fc
  • 华为云云函数:fg
  • AWS 云函数:lambda

遇到的问题

在腾讯云函数、小程序云开发中的云函数连接 mongoDB 数据库,如果在返回之前不手动 close 数据库连接会导致云函数超时,但是如果每次返回都 close,意味着每次执行都要重新 connect,这就增加了云函数的运行时间,不仅提高了费用,还降低了云函数处理大量请求的能力。

我们希望数据库连接之后就让它保持连接状态,之后的数据库读写操作都在这个连接上进行。需要注意,这样做可能会导致数据库同时连接数量超过所允许的最大连接数量,比如 MongoDB Atlas M0 最多允许同时建立 500 个 connection,如果想避免该问题,stackoverflow 网友建议使用 RESTHeart,对于 atlas 还可以使用它的 HTTP API

云函数的机制

在云函数下保持数据库连接并不容易,这受制于云函数的运行机制:云函数运行在云端容器化的 Unix 环境中,在处理并发请求的时候会创建多个云函数实例,每个实例之间相互隔离,没有公用的内存或硬盘空间。

这意味着 mongoDB 连接无法在不同的云函数实例之间共享,也正因如此,创建大量实例可能会出现刚刚所说的超过 mongoDB 所允许的最大同时连接数的限制。

2022-09-14 补充:腾讯云 scf 不支持单实例多并发,阿里云 fc、华为 fg 都支持,即一个函数实例可以同时处理多个请求,不一定需要创建新的函数实例。对于 scf,如果同一时间有多个请求,scf 会创建多个函数实例。

虽然不同的云函数实例无法共用一个数据库连接,但数据库连接可以在同一个实例的多次调用之间实现复用。云函数有冷启动、热启动两种启动方式,冷启动会创建新的容器环境,代码会从头到尾完整的执行一遍,耗时比热启动慢,而热启动不仅速度更快一些,其函数实例、执行进程都能被复用。云函数的调用总是先调用那些还没有进入冷启动状态的实例。

但是一个云函数实例是冷启动还是热启动,开发者无法干预,无法预测。

云开发文档显示,一个云函数实例第一次冷启动执行完毕之后,接下来几十分钟内如果再次被调用,这个云函数实例会被复用。换言之,当一个云函数在沉寂较长的一段时间后,突然涌入大量请求,那大部分云函数实例都将是冷启动,scf 文档建议

开发者在编写云函数时,应注意保证云函数是无状态的、幂等的,即当次云函数的执行不依赖上一次云函数执行过程中在运行环境中残留的信息。

正因如此,我们不应该写这种代码:

let i = 0;
exports.main = async (event = {}) => {
  i++;
  console.log(i); //i 不一定是 1
  return i;
};

那我们的需求,让这一次云函数的执行依赖上一次云函数残留下的数据库连接,是不是和腾讯云官方的建议矛盾了呢?看起来好像是的,但是腾讯云的建议不止一条,至少数据库连接是一个例外

用户代码中位于“执行方法”外部的任何声明保持已初始化的状态,再次调用函数时可以直接重用。例如,如果您的函数代码中建立了数据库连接,容器重用时可以直接使用原始连接。您可以在代码中添加逻辑,在创建新连接之前检查是否已存在连接。

所以数据库连接需要放在执行方法之外,那具体怎么写呢?

2022-09-14 补充:阿里云 fc 支持编写函数生命周期函数,其中 Initializer 函数表示在函数实例创建时执行,可以把数据库连接写在 Initializer 函数中,当函数实例被销毁前,会进入 PreStop 生命周期,开发者可以在 PreStop 生命周期函数中断开数据库连接,这样数据库连接就可以在同一个函数实例的多次调用间保持复用,结合 fc 的单实例多并发设置,可有效降低重复断开、连接数据库带来的延迟。华为云 fg 支持设置初始化函数,但没有 PreStop 这种实例被销毁前执行的函数。

云函数的代码结构

先了解 scf 的代码结构,包括执行方法、返回方式。

执行方法

在云函数后台,或者小程序开发者工具的云开发后台,可以看到每个云函数都有一个叫“执行方法”的信息,格式通常是 index.main,其中 index 表示云函数的入口文件 index.js,而 main 表示在 index.js 文件中通过 module.exports 导出的 main 入口函数,即:

module.exports.main = async () => {};
//或
exports.main = async () => {};

数据库连接可以保存在执行方法之外的变量中,当函数实例被复用时,执行方法可以直接使用这个变量。

下面是本文的重点,了解函数的返回方式,我们就知道如何在保持数据库连接的前提下,避免函数超时。

返回方式

腾讯云 scf、华为云 fg、AWS lambda 的返回方式是一样的:

  • 对于 non-async 入口函数,需要用 callback 返回,这种情况函数会等待 Event Loop 清空,如果没清空,不会返回,直至超时出错,可以把 callbackWaitsForEmptyEventLoop 设置为 false 避免等待;
  • 对于 async 入口函数,需要用 return 返回,这种情况不会等待 Event Loop 清空,这意味着 return 后,函数中还有一些未执行完毕的异步操作会停止执行(因为 return 后函数会进入冻结状态),下一次函数被复用时,这些操作会继续执行,这很可能会产生一些奇怪现象,需要特别避免。

值得一提的事,阿里云 fc 的返回方式有点不一样,fc 的事件函数只支持 callback 返回,无论入口函数有没有 async 都是 callback,http 函数则采用 response.send() 这种更接近 http server 的返回方式。

有趣的是,scf 的 Node.js 12、Node.js 10 有一个与众不同的特性:本来呢,return 返回后函数会冻结,callback 则会等待 Event Loop 清空才返回,而 scf 的 Node.js 12、Node.js 10 支持将函数的同步执行返回和异步事件处理分开进行的能力,无论是 return 还是 callback 返回,表现都一样,即:异步操作、同步操作是互相独立的,同步操作完成后直接返回,未完成的异步操作继续执行,函数不会马上被冻结。

cloud-function-excute-time

以 scf 的 Node.js 12.16 为例子,连接 mongoDB 数据库和断开数据库,通常这样写(async 入口函数,用 return 返回):

//连接逻辑定义在 ./connectDb.js 文件中
const connectDb = require("./connectDb");
exports.main = async (event, context) => {
  const mongoClient = await connectDb();
  //对数据库进行读写操作
  //....
  //在返回前关闭数据库连接
  mongoClient.close();
  return "ok";
};

这样写很容易理解:连接数据库 -> 执行相关操作 -> 断开数据库 -> 通过 return 把结果返回调用这个函数的那一方 -> 云函数正常终止运行。

如果把 mongoClient.close(); 这一行删除,那执行流程就不一样了:连接数据库 -> 执行相关操作 -> 通过 return 把结果返回给调用方 -> 函数没有被冻结,数据库连接保持 -> 数据库连接保持 -> 数据库连接保持.... -> 超时,云函数出错 -> 云函数被迫终止运行。

云函数超时后,数据库连接为什么不能复用?根据 aws lambda 的文档,如果因为超时或者 crash 等被迫停止运行,云函数会重置执行环境,下一次该云函数实例被复用时,会重新对执行方法外部的变量进行初始化,所以因为超时出错的云函数,其数据库连接不能被复用。

Overview-Invoke-with-Error

如果通过 callback 返回,需要删除入口函数的 async 关键词,效果同上:

//连接逻辑定义在 ./connectDb.js 文件中
const connectDb = require("./connectDb");
exports.main = (event, context, callback) => {
  connectDb().then((client) => {
    //对数据库进行读写操作
    //....
    //在返回前关闭数据库连接
    mongoClient.close();
    callback(null, "ok");
  });
};

通常来说,async 函数需要用 return 返回,但 scf 的 Node.js 8.9 这个 runtime 有点例外,无论是不是 async,既可以用 return 返回也可以用 callback 返回。不过 Node.js 8.9 是一个老版本,如果你不用它,可以忽略这种特殊情况。

//连接逻辑定义在 ./connectDb.js 文件中
const connectDb = require("./connectDb");
exports.main = async (event, context, callback) => {
  const mongoClient = await connectDb();
  //对数据库进行读写操作
  //....
  //在返回前关闭数据库连接
  mongoClient.close();
  callback(null, "ok");
};

被冻结的函数,被冻结的异步操作

前面提到 scf 的 Node.js 10.15 及 12.16 通过 return 或 callback 返回后,异步操作会继续执行,这是一种特殊情况。

而一般情况是,在 async 入口函数中,通过 return 返回后,异步操作(包括数据库连接)会马上进入冻结状态,函数内的异步可能执行到一半就被冻结了。scf 的其他 Node.js 版本就属于这个一般情况,这意味着如果你的 mongoDB 连接运行在 scf Node.js 14、Node.js 16 的云函数中,你不需要 close 也不会因为超时出错,并且当你的函数实例被复用,数据库连接也能被复用。

但这样也可能会导致一些奇怪的现象,因为本应该在这一次云函数运行中完成的操作,会出现在下一次函数被调用时执行,不了解这个现象的开发者可能会感到迷惑。尤其是习惯了在 scf 的 Node.js 12 环境中部署云函数的同学,如果使用了它的同步返回和异步操作分开进行的能力,后续需要把函数迁移到其他平台或 Node.js 版本时,需要额外注意这个问题。

总之,为了方便理解,建议把 scf Node.js 12、Node.js 10 返回后依然能执行异步操作的能力看作特殊情况,记住一般情况都是 return 返回后,未执行完成的异步操作会被冻结,无法执行。据我不完全了解,scf Node.js 14、Node.js 16,fc、fg、aws lambda 都属于一般情况。

如果你不想某些异步操作被冻结,只需要把它写成 promise 形式,并返回这个 promise,又或者 await 这个 promise,拿到结果了再返回。下面这种情况即使没有关闭数据库连接,但 timeout 这个异步操作可以在冻结前完成,一般情况下,函数进入冻结状态后,数据库连接也被冻结了,当函数再次被调用时,连接可复用。

exports.main = async (event, context) => {
  const mongoClient = await connectDb();
  return timeout(1000);
};

function timeout(tm) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("ok");
    }, tm);
  });
}

情况总结

总结一下上面提到的 3 种情况,注意说的都是腾讯云 scf,之所以强调是 scf,是因为 scf 的 Node.js 12、Node.js 10 有一个特殊能力。

Node.js 12、Node.js 10

  • 不 close:callback 或 return 返回后,函数不会马上冻结,数据库保持连接最终导致函数超时,超时出错导致数据库连接无法复用;
  • close:不会因为数据库连接而导致超时,既然 close 了,当然也无法复用;
  • callbackWaitsForEmptyEventLoop 设置为 false:不会超时,函数马上冻结,数据库连接会得到复用。

Node.js 8、14、16(return 返回)

  • 不 close:return 后函数会冻结,所以不会导致超时,未完成的异步操作也会冻结,数据库连接会被复用;
  • close:不会因为数据库连接没断开而超时,连接不会复用;
  • callbackWaitsForEmptyEventLoop 设置为 false:同不 close,即没影响,它其实主要用在 callback 返回。

Node.js 8、14、16(callback 返回)

  • 不 close:会因为数据库连接没断开导致函数超时,一直无法进入冻结状态,无法返回结果给调用方,超时出错导致数据库连接无法复用;
  • close:不会因为数据库连接导致超时,无法复用连接;
  • callbackWaitsForEmptyEventLoop 设置为 false:不会等待异步操作执行完毕再返回,所以也不会因为数据库连接导致函数超时,数据库连接会复用。

连接数据库的代码

mongoDB 官方文档有一篇文章介绍了如何在云函数中连接 mongoDB 数据库,虽然是针对 AWS Lambda,但也适用于腾讯云 scf。文章提到 callbackWaitsForEmptyEventLoop 适用于 non-async 入口函数,不适用(not applicable)于 async 函数。

你看 callbackWaitsForEmptyEventLoop 的名称中就带有 callback 这个单词,所以它适用于 callback 返回的情况,这样记是不是简单很多呢。

下面是 mongoDB 官方文档给出的代码,通过 return 返回,基本可以直接复制到 scf 使用。但是,由于 scf Node.js 12 的特殊能力,在 scf 中使用时需要额外把 callbackWaitsForEmptyEventLoop 设置为 false。

入口文件 index.js:

const { connectToDatabase } = require("./connect-to-mongodb");
module.exports.handler = async function (event, context) {
  const client = await connectToDatabase();
  // Use the connection to return the name of the connected database.
  return client.db().databaseName;
};

连接数据库逻辑的文件 ./connect-to-mongodb.js:

"use strict";
const { MongoClient } = require("mongodb");
const uri = process.env.MONGODB_URI;
if (!uri) {
  throw new Error(
    "The MONGODB_URI environment variable must be configured with the connection string " +
      "to the database."
  );
}
let cachedPromise = null;

module.exports.connectToDatabase = async function connectToDatabase() {
  if (!cachedPromise) {
    // If no connection promise is cached, create a new one. We cache the promise instead
    // of the connection itself to prevent race conditions where connect is called more than
    // once. The promise will resolve only once.
    // Node.js driver docs can be found at http://mongodb.github.io/node-mongodb-native/.
    cachedPromise = MongoClient.connect(uri, {
      useNewUrlParser: true,
      useUnifiedTopology: true,
    });
  }
  // await on the promise. This resolves only once.
  const client = await cachedPromise;
  return client;
};

总结

在腾讯云函数或者小程序云开发中的云函数中连接 mongoDB 数据库,为了节省开支,不应该每运行一次云函数都关闭数据库连接。要做到这一点,首先要把数据库连接写在执行方法之外,接着需要考虑两种情况:

  • 对于 scf 的 Node.js 10、Node.js 12 运行时,无论是 return 返回还是 callback 返回,都要在返回前把 callbackWaitsForEmptyEventLoop 设置为 false,设置为 false 之后,函数不会等待异步操作都执行完毕,返回后函数即被冻结,异步操作也会被冻结,数据库连接会在下次调用函数时被复用。

  • 对于其他云函数平台,或者 scf 的其他 Node.js 版本,通过 return 返回后函数会被冻结,所以数据库连接不关闭也不会导致函数超时,并会在下次函数调用时得到复用,对于 callback 返回,需要把 callbackWaitsForEmptyEventLoop 设置为 false。

把 callbackWaitsForEmptyEventLoop 设置为 false 后,未完成的异步操作会在函数下一次调用时继续执行,这可能会产生一些奇怪现象,需要特别留意。


延伸阅读:

donation赞赏
thumbsup0
thumbsdown0
暂无评论