我第一次把数据库接到无服务器函数上时,压测还没跑完连接数就满了。原因不复杂:每个函数实例都在自己的进程里新建客户端,而平台的扩缩容会在短时间内拉起很多实例。连接池本身没问题,问题是池子被建了太多次。

为什么要在 globalThis 上缓存

无服务器函数的冷启动会创建新的执行环境,热调用则复用同一个进程。模块级变量在热调用之间是保留的,但不同实例之间不共享。所以我需要一个进程内单例,而不是模块导入时就连接。

  • 模块顶层只声明变量,不发起连接
  • 把 client 和连接中的 Promise 一起缓存,避免并发请求重复建连
  • 缓存挂在 globalThis 上,防止打包或热更新导致的模块重复求值
// 不同数据库驱动的 API 不一样,这里用通用写法示意
const KEY = Symbol.for('app.db')
const uri = process.env.DATABASE_URL

async function getDb() {
  const cache = globalThis[KEY] ?? (globalThis[KEY] = { client: null, promise: null })
  if (cache.client) return cache.client.database('blog')
  if (!cache.promise) {
    const client = createClient(uri, {
      poolMax: 5,
      poolMin: 0,
      connectTimeout: 5000,
      socketTimeout: 20000,
      idleTimeout: 60000,
    })
    cache.promise = client.connect().then(() => {
      cache.client = client
      return client
    }).catch((err) => {
      cache.promise = null
      throw err
    })
  }
  const client = await cache.promise
  return client.database('blog')
}

这里的 catch 很关键。如果不把 promise 清空,一次网络抖动会让这个实例永远记住失败的连接,后续请求全部报错,只能等平台回收实例。

连接池大小要按实例算

很多人把池子上限设成 100,觉得越大越好。在无服务器环境里这个数字要乘以并发实例数。假设数据库允许 500 个连接,平台峰值能开到 100 个实例,那每个实例的池子上限就不该超过 5。我一般先按 3 到 5 起步,再用监控里的连接数曲线调整。

池子的最小连接数保持 0 更稳。实例大部分时间在空闲,预热连接只会占着数据库的名额,还会让冷启动变慢。

超时和失败退避

无服务器函数的执行时间有硬上限,所以每个网络等待都要有明确的超时。连接超时我设 5 秒,比函数超时短一截,让错误尽早暴露,而不是拖到平台强杀。

  • 连接失败后不要在同一个请求里无限重试
  • 让错误向上抛,交给调用方决定是否降级
  • 对写操作做好幂等,重试才不会产生重复数据

冷启动还能做什么

冷启动是这类架构的固有成本。我能做的只是别让它更糟:依赖精简、避免在模块顶层做重活、把连接放在第一次真正需要数据的时候建立。还有一个容易忽略的点,本地开发时用同一个 getDb 入口,行为和生产一致,很多连接泄漏的问题在本地就能暴露。

另外别在函数里开定时任务或者常驻的监听,实例被冻结之后这些逻辑不会按预期执行,日志里也看不出异常。我还会在日志里带上实例标识,排查连接问题时能对上号。

上线前我一般会做一次并发压测,观察数据库的连接数峰值和 P99 延迟。如果连接数曲线是锯齿状反复冲高,说明实例在频繁重启或者池子没复用,值得回头检查缓存那一段。