如果你正在使用 Next.js(或任何 Serverless 架构)、Prisma 和 PostgreSQL 构建应用,你很可能在某个深夜,满怀期待地将应用部署到 Vercel 后,看到过这个让你心跳停止的错误:
Error: Timed out fetching a new connection from the connection pool. (P2024)
这个错误就像一个幽灵,它在本地开发环境(npm run dev)中从不出现,却在生产环境的流量高峰期(有时甚至只是几个并发用户)将你的应用炸得粉碎。
在过去的几天里,我完整地经历了从遇到这个错误,到层层排查,再到最终彻底解决的全过程。这篇文章就是我的复盘和总结,希望能帮助同样被此问题困扰的你,省下宝贵的时间和头发。
一切都始于那个熟悉的 P2024 错误。日志显示,无论是访问普通页面(prisma.post.findFirst()),还是用户通过 Next-Auth 登录(prisma.account.findUnique()),应用都会随机性地因为无法从连接池获取连接而超时。
我的第一反应是:“数据库连接数不够了?” 于是我检查了 PgBouncer 的配置:MAX_CLIENT_CONN=150,DEFAULT_POOL_SIZE=80。同时我的数据库 max_connections 是 100。从数字上看,绰绰有余。
显然,问题比表面看起来要复杂得多。
经过一系列的调试和研究,我发现导致问题的不是单一原因,而是三个层层叠加的“坑”。
为了避免在每次请求中都创建新的 PrismaClient 实例,我们都知道要使用“单例模式”。我最初的代码是这样的:
Generated javascript
// 错误的单例模式
let prisma: PrismaClient
if (process.env.NODE_ENV === "production") {
prisma = new PrismaClient()
} else {
if (!global.cachedPrisma) {
global.cachedPrisma = new PrismaClient()
}
prisma = global.cachedPrisma
}
export const db = prisma
这段代码在传统服务器(如 Express)上没有问题。但在 Serverless 环境中,它是完全错误的!
为什么? Serverless 平台(如 Vercel)为处理并发请求,会启动多个独立的函数实例。我这段代码的逻辑是“如果是生产环境,就直接 new PrismaClient()”。这意味着每一个冷启动的函数实例都会创建一个全新的、拥有独立连接池的 PrismaClient。
假设 Prisma 默认的 connection_limit 是 9。当 10 个用户同时访问,触发 10 个函数实例冷启动时,我的应用会瞬间向数据库请求 10 * 9 = 90 个连接!这足以压垮任何配置合理的 PgBouncer。
在修复了单例模式后,我观察到情况有所好转,但 P2024 错误依然在流量稍高时出现。为什么?
即便我们确保了全局只有一个 PrismaClient 实例,但这个实例本身也会维护一个内部连接池。默认情况下,这个池的大小是 num_of_cpus * 2 + 1。在 Serverless 环境中,这可能意味着 5 到 10 个连接。
当大量请求涌入,多个“温”函数实例同时向这一个共享的 Prisma 实例请求连接时,仍然可能在短时间内耗尽 PgBouncer 的可用连接。这就像虽然全公司共用一个咖啡机,但早晨 9 点所有人同时去接咖啡,咖啡机还是会不堪重负。
这是最隐蔽,也是最终极的问题。在我修复了前两个问题,并且在连接字符串里加上了 connection_limit=5 来限制每个实例的行为后,错误依然偶尔浮现。
我查看 PgBouncer 的日志,发现了大量奇怪的警告:
WARNING ... FIXME: query end, but query_start == 0
这个警告暗示 PgBouncer 对客户端(Prisma)发送的指令感到“困惑”。经过查阅资料,我发现元凶是 Prepared Statements (预编译语句)。
Prisma 为了性能,会大量使用预编译语句。
PgBouncer 在 transaction(事务)池模式下,对预编译语句的支持存在缺陷。它可能无法在事务结束后正确地释放一个连接,导致这个连接被“卡住”。
随着时间推移,被卡住的连接越来越多,连接池被缓慢耗尽,最终导致新的请求超时。
针对以上三个根本原因,我采用了下面这套组合方案,最终让应用在生产环境稳定如飞。
这是最基础,也是最重要的一步。我将创建 Prisma 实例的代码修改为以下唯一正确的版本:
Generated typescript
// lib/db.ts
import { PrismaClient } from '@prisma/client'
declare global {
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined
}
// 无论在什么环境下,都优先从全局缓存中获取实例
export const db = global.prisma || new PrismaClient()
// 在非生产环境中,将实例挂载到全局对象上,防止热重载导致实例泛滥
if (process.env.NODE_ENV !== 'production') {
global.prisma = db
}
这个版本确保了在任何情况下,一个函数实例的生命周期内,PrismaClient 只会被创建一次。
仅仅修复代码是不够的,我们还需要从配置层面“驯服”Prisma。我修改了 .env 文件中的 DATABASE_URL,为它加上了几个关键参数:
Generated env
DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DB?schema=public&pgbouncer=true&connection_limit=5&timeout=30"
pgbouncer=true:明确告知 Prisma 它正通过连接池工作,让它采取更兼容的模式。
connection_limit=5:这是安全带! 它强制规定每个 PrismaClient 实例(即每个函数实例)最多只能占用 5 个数据库连接。这能有效防止单个实例的连接风暴耗尽整个池。
timeout=30:将连接超时时间从默认的 10 秒延长到 30 秒。这为应用在网络波动或高负载下提供了更多的容错空间。
为了解决 Prisma 与 PgBouncer 的根本冲突,我加入了最后一个,也是最关键的参数:
Generated env
DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DB?schema=public&pgbouncer=true&connection_limit=5&timeout=30&pga_prepared_statements=false"
pga_prepared_statements=false:这个 Prisma 独有的参数,指示它不要使用会话级别的预编译语句。这彻底避免了与 PgBouncer transaction 模式的冲突,杜绝了连接被“卡住”的现象。
Serverless 数据库连接管理是一个复杂的领域。P2024 错误并非单一问题,而是一系列架构、代码和配置问题的综合体现。
回顾整个过程,解决问题的关键路径是:
实现真正安全的单例模式,杜绝 PrismaClient 滥建。
通过 connection_limit 为每个实例戴上“紧箍咒”,防止连接风暴。
通过 pga_prepared_statements=false 解决深层次的兼容性问题,确保连接被正确释放。
如果你也遇到了同样的问题,希望我的这段踩坑经历能为你提供一张清晰的路线图,让你能直达问题核心,快速恢复服务的稳定。
When building a modern web application with Next.js and Tailwind CSS, you'll often want to set a custom global background color that aligns with your brand identity. A common mistake is to hardcode the color directly onto the <body> tag, which can break theming capabilities like dark mode.
Published Jul 20, 2025
在网络世界中,一个看不见的威胁可能正潜伏在看似无害的按钮或链接之下。用户的一次无心点击,可能导致账户被盗、信息泄露,甚至造成财产损失。这个“隐形杀手”就是——点击劫持(Clickjacking)。
Published Jul 18, 2025
Published Jul 18, 2025
Comments (0)