我第一次把 MongoDB 接到 Vercel 的 Serverless 函数上时,压测还没跑完数据库连接数就满了。原因不复杂:每个函数实例都在自己的进程里新建 MongoClient,而平台的扩缩容会在短时间内拉起很多实例。连接池本身没问题,问题是池子被建了太多次。
为什么要在 globalThis 上缓存
Serverless 的冷启动会创建新的执行环境,热调用则复用同一个进程。模块级变量在热调用之间是保留的,但不同实例之间不共享。所以我需要一个进程内单例,而不是模块导入时就连接。
- 模块顶层只声明变量,不发起连接
- 把 client 和连接中的 Promise 一起缓存,避免并发请求重复建连
- 缓存挂在 globalThis 上,防止打包或热更新导致的模块重复求值
import { MongoClient } from 'mongodb'
const KEY = Symbol.for('app.mongo')
const uri = process.env.MONGODB_URI
async function getDb() {
const cache = globalThis[KEY] ?? (globalThis[KEY] = { client: null, promise: null })
if (cache.client) return cache.client.db('blog')
if (!cache.promise) {
const client = new MongoClient(uri, {
maxPoolSize: 5,
minPoolSize: 0,
serverSelectionTimeoutMS: 5000,
connectTimeoutMS: 5000,
socketTimeoutMS: 20000,
maxIdleTimeMS: 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.db('blog')
}
这里的 catch 很关键。如果不把 promise 清空,一次网络抖动会让这个实例永远记住失败的连接,后续请求全部报错,只能等平台回收实例。
连接池大小要按实例算
很多人把 maxPoolSize 设成 100,觉得越大越好。在 Serverless 里这个数字要乘以并发实例数。假设数据库允许 500 个连接,平台峰值能开到 100 个实例,那每个实例的池子上限就不该超过 5。我一般先按 3 到 5 起步,再用监控里的连接数曲线调整。
minPoolSize 保持 0 更稳。实例大部分时间在空闲,预热连接只会占着数据库的名额,还会让冷启动变慢。
超时和失败退避
Serverless 函数的执行时间有硬上限,所以每个网络等待都要有明确的超时。serverSelectionTimeoutMS 我设 5 秒,比函数超时短一截,让错误尽早暴露,而不是拖到平台强杀。
- 连接失败后不要在同一个请求里无限重试
- 让错误向上抛,交给调用方决定是否降级
- 对写操作做好幂等,重试才不会产生重复数据
冷启动还能做什么
冷启动是这类架构的固有成本。我能做的只是别让它更糟:依赖精简、避免在模块顶层做重活、把连接放在第一次真正需要数据的时候建立。还有一个容易忽略的点,本地开发时用同一个 getDb 入口,行为和生产一致,很多连接泄漏的问题在本地就能暴露。
另外别在函数里开定时任务或者常驻的监听,实例被冻结之后这些逻辑不会按预期执行,日志里也看不出异常。我还会在日志里带上实例标识,排查连接问题时能对上号。
上线前我一般会做一次并发压测,观察数据库的连接数峰值和 P99 延迟。如果连接数曲线是锯齿状反复冲高,说明实例在频繁重启或者池子没复用,值得回头检查缓存那一段。