NextAuth 的 linkAccount() 为何只在 Vercel 上卡住?
目前能确定什么?
现有材料还不能确定根因,也不足以认定这是 @prisma/adapter-pg 的已知缺陷,或 pg Pool 与 Vercel cold starts 不兼容。下一步应定位 prisma.account.create() 究竟停在获取连接、驱动执行还是数据库处理阶段,再决定修复方式。
同一生产数据库可以被本地应用写入,Vercel 的普通 API routes 也能写入 Account 表,但 OAuth callback 中的绑定操作仍会卡住。因此,应优先排查 callback 的调用顺序、连接占用情况和实际运行环境。
Prisma Accelerate 和 PgBouncer 可以列为后续方案,但现有证据还不足以说明必须引入它们。
如何理解现有日志?
原示例直接调用了:
const createPromise = prisma.account.create({
data: account
});
随后通过 Promise.race() 与一个 10 秒计时器竞争。这个包装只能说明调用没有在期限内返回,不能说明等待发生在哪一层。
解读现有日志时,需要注意:
- TCP 连接成功不能排除所有连接问题。已有连接正常,不代表后续连接申请、认证或数据传输都正常。
- Account INSERT 未出现在日志中是重要线索,但需要确认日志覆盖范围。应先确认查看的是实际目标实例,并且日志配置能够记录该语句。
events.linkAccount未触发与绑定流程未完成一致,无法区分连接池等待和查询执行等待,也就不能进一步缩小范围。$executeRaw仍然经过 Prisma 和所配置的 driver adapter。它同样卡住,可以降低模型字段处理异常的排查优先级,但它没有绕过整条数据库访问链。- 登录成功不一定证明 Account 创建正常,需要区分是否进入了新的账号绑定流程。配置使用
session: { strategy: "jwt" },也不能仅凭会话可用就推断数据库 Session 表写入成功。
原日志中的 timeout after 5s 与代码中的 10000、实际耗时 10006ms 也不一致。应先确认这些记录来自同一个 deployment 和同一次请求,避免用不同部署的日志推断故障。
第一步:观察实际使用的连接池
把传给 PrismaPg 的 Pool 提取出来,供诊断代码读取状态。不要另外创建一个 Pool 来观察,否则看到的可能不是发生等待的那个实例。
以下示例沿用问题中已有的 PrismaPg(new Pool(...)) 构造方式,前提是当前依赖组合支持该接口。示例中的 5 秒连接超时用于诊断,并非已验证的修复参数。
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { Pool } from "pg";
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
pgPool?: Pool;
};
export const pgPool =
globalForPrisma.pgPool ??
new Pool({
connectionString: process.env.DATABASE_URL,
connectionTimeoutMillis: 5000,
});
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
adapter: new PrismaPg(pgPool),
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.pgPool = pgPool;
globalForPrisma.prisma = prisma;
}
在 linkAccount() 开始、执行期间及失败时,记录:
console.info("[linkAccount] pool", {
total: pgPool.totalCount,
idle: pgPool.idleCount,
waiting: pgPool.waitingCount,
});
重点看等待期间的变化:
| 观察结果 | 下一步检查 |
|---|---|
waitingCount 持续大于 0,连接数达到上限 | 是否有连接未释放、长事务或嵌套数据库调用 |
| 正在建立连接但迟迟没有可用连接 | 连接建立、认证及连接超时是否生效 |
| 连接已被借出,但对应 SQL 未见记录 | 驱动执行、Prisma 调用路径及数据库日志覆盖范围 |
这些计数反映的是整个 Pool 的状态。存在并发请求时,需要结合请求标识判断,不能把所有连接都归因于当前 callback。
第二步:在同一个 callback 中分段探测
普通 API route 写入成功,并不足以反映 OAuth callback 中的执行条件。可以在受控测试中,在 Account 写入前加入一次直接使用同一个 Pool 的探测:
import { pgPool } from "./prisma";
async function probeDatabase() {
const started = Date.now();
console.info("[db probe] before connect");
const client = await pgPool.connect();
console.info("[db probe] connected", {
elapsedMs: Date.now() - started,
});
try {
const result = await client.query(
"SELECT pg_backend_pid() AS pid"
);
console.info("[db probe] query completed", {
elapsedMs: Date.now() - started,
pid: result.rows[0]?.pid,
});
} finally {
client.release();
}
}
这段代码会增加一次数据库往返,仅用于定位。它也可能改变连接预热和时序,因此需要与未加探测的情况对照。
根据停止位置继续处理:
- 停在
before connect之后:优先查连接获取和建立过程。结合 Pool 状态区分等待空闲连接与新建连接。 - 已经输出
connected,查询没有完成:用记录下来的 PostgreSQL PID 关联数据库活动、等待事件和日志,检查该连接实际在等待什么。 - 直接
pg查询成功,Prisma 调用仍卡住:再比较同一位置的 Prisma 简单查询、$executeRaw和account.create(),逐步缩小到 Prisma/driver adapter 路径或具体写入。
探测连接必须在后续 Prisma 操作开始前释放。否则,在连接数很小的配置下,诊断代码自己也可能占住后续操作需要的连接。
第三步:检查 callback 上下文和部署产物
沿着已经出现差异的路径排查,暂时不必反复调整连接池大小:
- 确认 Route Handler 的实际 runtime。对使用 Node.js
pg的路由,检查是否按预期运行在 Node.js runtime;如需明确指定,可在实际的route.ts中设置export const runtime = "nodejs"。 - 检查连接生命周期。是否有代码调用了共享 Pool 的
end()、提前执行$disconnect(),或手动借出连接后漏掉release()? - 检查数据库调用顺序。是否有 callback、包装器、Prisma 扩展或事务持有连接,同时等待另一个需要连接的操作?这只是待验证方向,当前材料没有证明存在这种情况。
- 确认数据库目标一致。比较 callback 与普通 API route 使用的环境变量来源、数据库、schema、连接参数和部署环境,记录时不要输出完整连接字符串或密码。
- 检查依赖与生成步骤。当前材料没有给出
prisma、@prisma/client和pg的完整版本。核对锁文件、构建日志及自定义生成目录,确认 Vercel 使用的是本次构建对应的 Prisma Client。
可以先查看安装关系:
npm ls next-auth @auth/prisma-adapter prisma @prisma/client @prisma/adapter-pg pg
这些检查只能确认配置和依赖事实,不能单凭“版本看起来一致”就判定运行时没有问题。若要判断是否属于某个已知缺陷,还需要能对应到具体版本、触发条件和最小复现的依据;当前材料没有提供这些证据。
是否需要 PgBouncer 或 Prisma Accelerate?
只有定位到连接数量或连接管理问题后,才能判断引入外部连接管理方案是否合适。
如果观测表明多实例并发导致 PostgreSQL 连接压力过大,可以评估外部连接池。如果应用没有释放连接、callback 中存在相互等待,或部署产物不一致,增加连接管理层未必能解决问题。
pg Pool 的存在本身也不能证明它与 cold starts 不兼容。应分别比较新实例和已运行实例的连接耗时与失败位置,确认故障是否真的只随冷启动出现。
超时和日志还需要注意什么?
Promise.race() 超时后,底层数据库操作不会因此自动取消。应用可能已经返回错误,而查询仍在继续等待或稍后完成。排查时不要紧接着重试相同的 Account INSERT,以免混淆多次请求的结果。
超时包装还应在 finally 中清理计时器;连接获取超时、客户端查询超时和数据库端语句超时需要分别处理,不能用一个外层计时器代替。
原代码会输出整个 account 对象,其中可能包含 OAuth token。诊断日志只保留请求标识、阶段、耗时和必要的连接池状态即可。
选择连接管理、依赖调整或 callback 代码方面的修复,需要同一次 OAuth callback 的三项证据:连接是否成功借出、直接 pg 查询是否返回,以及 Prisma 写入开始后对应数据库连接的状态。
备注:内容仅供参考。
