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

日期:2021-06-06

作者:科技微讯

这篇文章分享运行在 Node.js 环境中的腾讯云函数、小程序云开发函数连接 mongoDB 数据库的最佳实践,回答如何让数据库连接保持复用同时又避免云函数超时出错的问题。

遇到的问题

在腾讯云函数、小程序云开发函数连接 mongoDB 数据库,如果在返回之前不手动 close 数据库连接,会导致云函数超时。为了不超时,云函数每次运行都要 close 数据库连接,下一次运行时,再重新 connect 数据库,这样做逻辑上没什么问题,只是有点慢,数据库连接所用的时间可能比数据库的读写操作还长,反应在前端,会让用户觉得你的服务不够流畅。

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

云函数的机制

问题是,云函数有以下运行机制:云函数运行在云端容器化的 Unix 环境中,在处理并发请求的时候会创建多个云函数实例,每个实例之间相互隔离,没有公用的内存或硬盘空间。这意味着 mongoDB 连接无法在不同的云函数实例之间共享。

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

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

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

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

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

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

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

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

所以代码怎么写?

云函数的代码结构

执行方法

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

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

返回方式

云函数的返回方式和 Node.js 的版本有关,云函数可以运行在四个版本的 Node.js 环境中,它们的返回方式分别如下:

  • Node.js 6.10:只能通过 callback 返回;
  • Node.js 8.9:既可以用 callback 返回,也可以用 return 返回;
  • Node.js 10.15:如果 main 函数是 async 函数,则只能用 return 返回,如果是非 async 函数,只能用 callback 返回;
  • Node.js 12.16:同 Node.js 10.15。

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

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

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

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

如果写成 callback 形式,因为 Node.js 12.16 的 callback 返回只能写在非 async 函数,所以需要这样写:

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

表现和使用 return 一样,即 callback 不会等待异步操作完成之后才向调用方返回数据,而异步操作会在返回后正常运行。和 return 返回一样,如果删除 mongoClient.close();,数据库连接会导致函数超时。

而 Node.js 8.9 的 callback 返回可以写在 async 函数中:

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

Node.js 8.9 的这种写法和前面的效果是一样的,但是,如果把 mongoClient.close(); 删除结果就不太一样了:连接数据库 -> 执行相关操作 -> 等待关闭数据库连接 -> 等待关闭数据库连接 -> 等待关闭数据库连接... -> 超时,云函数出错 -> 云函数被迫终止运行。没错,直到超时出错,函数也不会返回任何信息给调用方。

为什么会这样子呢?

被冻结的异步执行

这是因为在 Node.js 10.15 及 12.16 的 runtime 中,云函数会把函数的同步执行返回、异步事件执行,进行分开处理。同步执行完成之后返回,但是异步执行会不受影响地运行,直到运行结束,而数据库连接如果你不 close 它的话,它永远不会结束,直到超时。

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

如下图所示,最左边的 INIT 表示新开一个云函数实例时对实例进行初始化,包括三个阶段,其中 FUNCTION INIT 表示对执行方法外的变量进行初始化。下一步是正常的 INVOKE,即没有出错,则不会重置执行环境。再下一步,云函数如果被热启动,则会沿用该函数实例,直接 INVOKE,但这一次 INVOKE 因为各种原因出错了,比如超时了,比如内存超额了,这时候它会 reset runtime,并且终止执行。当下一次再热启动该函数实例,函数会先完成 3 个阶段的 INIT 才 INVOKE,所以不可能复用上一次的数据库连接。最后,如果该云函数实例长时间得不到复用,则会被销毁。

话题重新回到腾讯云的异步执行。前面提到 Node.js 10.15 及 12.16 返回后异步操作会继续执行,但是对于 Node.js 8.9,如果是 return 返回,当 return 之后,异步操作以及数据库连接会马上进入冻结状态,无论有没有执行完毕都不会继续执行下去。这意味着如果你的 mongoDB 连接运行在 Node.js 8.9 的云函数中,你不需要 close 也不会因为超时出错,并且当你的函数实例被复用,数据库连接也能被复用。但因为异步操作会被冻结,于是本应该在这一次云函数运行中完成的操作,会出现在下一次云函数运行中才完成,这可能会导致一些预料之外的问题。

如果你不希望异步操作被冻结,可以把这个异步操作写在一个 promise 中,最后 retrun 这个 promise,这样可以保证异步操作会在当次函数调用中执行完毕,当函数被复用时,数据库连接也能被复用。其实这样做和把异步操作通过 await 变成同步操作结果是一样的,如果异步操作是一个耗时很长的操作,那云函数也耗时很长,有时候我们并不希望如此。

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

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

前面提到 Node.js 8.9 的云函数还可以用 callback 返回,callback 返回和 return 返回的表现大不相同。callback 返回会等待异步函数都执行完毕才会返回,就像在异步操作自动前面加了 await 变成了同步操作,这意味着如果你不手动 close 数据库,不仅会导致函数超时,还会导致无法返回数据给调用方。

解决方法

我们的需求是不 close 数据库,并且在函数实例被复用时,可以继续使用这个数据库连接,在以上三种情况,只有第二种情况满足这个需求。要避开第二种情况可能出现的预料之外的情况,我们返回一个 promise,或者都写同步操作就可以了,包括用 await 把异步变成同步。

可是第二种情况要求我们使用 Node.js 8.9,虽然是一个长期维护的版本,但这是一个有点老的版本,发布于 2017 年 10 月,js 的有些新功能它不支持,比如我最近经常用的 Array.flat() 从 Node.js 11 才开始支持

如果云函数运行在 Node.js 12.16 环境下,可以实现一样的功能吗?答案是可以的。只要在 return 返回前加上一行代码即可:

context.callbackWaitsForEmptyEventLoop = false;

其中 context 是云函数的第二个入参,用于获取或传递运行时信息。这一行代码的作用,顾名思义,就是无须再等待 Event Loop 清空之后再返回,文档是这样说的:

可以使云函数后台在 callback 回调被调用后立刻冻结进程,不再等待事件循环内的事件,而在同步过程完成后立刻返回。

也就是说加了这样代码,Node.js 12.16 下的云函数表现,和 Node.js 8.9 下通过 return 返回的云函数表现是一样的。不仅如此,这行代码对 Node.js 8.9 下通过 callback 返回的云函数也有一样的作用。

情况总结

总结一下上面提到的 3 种情况。

Node.js 8.9(return 返回)

  • 不 close:return 后函数会马上终止,所以不会超时,另外异步操作会被冻结,如果函数实例被复用,异步操作会重新执行,意味着数据库连接可以复用,如果不希望异步操作被冻结,可以把异步操作写成 promise 并 return 这个 promise,或者通过 await 把异步操作变成同步操作;
  • close:除了数据库连接不能复用(因为被关闭了),其他同上;
  • callbackWaitsForEmptyEventLoop false:同不 close,即有没有这一行代码效果一样。

Node.js 8.9(callback 返回)

  • 不 close:会超时,因为 callback 返回这种方式会等待所有异步函数执行完成之后才会返回,就像自动地在异步操作前加了 await,另外因为是超时出错退出,所以数据库连接不能复用;
  • close:因为被关闭了,自然不会因为数据库连接问题而导致超时,其他异步操作执行完毕之后函数才能返回;
  • callbackWaitsForEmptyEventLoop false:同 Node.js 8.9(return 返回)。

Node.js 12.6

  • 不 close:return 后函数能返回,但不会终止,会超时,因为返回后异步操作不会冻结,会正常执行,数据库连接无法复用;
  • close:不会超时,异步操作会在返回后继续执行,直到执行完毕;
  • callbackWaitsForEmptyEventLoop false:同 Node.js 8.9(return 返回)。

连接数据库的代码

铺垫了这么多,是为了方便理解代码。其实 mongoDB 的官方文档有一篇专门讲如何在云函数中连接 mongoDB 数据库的,标题是 Best Practices Connecting from AWS Lambda。虽然是针对 AWS 的云函数,但也适用于腾讯云的云函数,这篇文章就提到 callbackWaitsForEmptyEventLoop,但文章说 callbackWaitsForEmptyEventLoop 建议用在第三个参数是 callback 的 handler 中,不适用(not applicable)于 async handler,矛盾的是 mongodb 官方博客在一篇文章中建议在 async handler 中加入:

context.callbackWaitsForEmptyEventLoop = false;

下面是 mongoDB 官方文档给出的代码,基本上可以直接复制黏贴到腾讯云函数使用,除了你还要根据 Node.js 的版本决定使用 return 返回或者 callback 返回,以及是否需要等待 Event Loop 清空。

入口文件 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 数据库,为了节省开支,不应该每运行一次云函数都关闭数据库连接。但是有一些 Node.js 版本如果不关闭连接,会导致云函数超时出错,对于这种情况,可以把 callbackWaitsForEmptyEventLoop 设置为 false,设置为 false 之后,需要注意避免异步操作被提前冻结。


延伸阅读: