我第一次把数据库接到无服务器函数上时,压测还没跑完连接数就满了。原因不复杂:每个函数实例都在自己的进程里新建客户端,而平台的扩缩容会在短时间内拉起很多实例。连接池本身没问题,问题是池子被建了太多次。
为什么要在 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 延迟。如果连接数曲线是锯齿状反复冲高,说明实例在频繁重启或者池子没复用,值得回头检查缓存那一段。