Supabase 连接池耗尽:修复 Serverless 直连反模式

`remaining connection slots are reserved` 与 `too many clients already`——把 serverless 正确切到 6543 端口的 pooler。

你的 Vercel / Cloudflare / Netlify 函数突然全线返回 500,日志里满是下面这两条之一:

error: remaining connection slots are reserved for non-replication
       superuser and rds_superuser connections

或者:

sorry, too many clients already

这就是 Supabase(本质是原生 Postgres)经典的连接池耗尽。

最快修复: 把 serverless 代码指向 6543 端口的 transaction 模式 pooler...pooler.supabase.com:6543),而不是 :5432 直连串。再确保每个 query 都正确释放连接。下面的 Step 1 + Step 2 几分钟就能把挂掉的服务救回来。

为什么会这样:一个 Postgres 连接是重资源(每个占用约 5-10 MB 服务器内存外加一个后台进程),完全不像一个廉价的 HTTP 请求。你的 Postgres 实例只接受固定数量的直连——Supabase Free 档是 60 个(随 compute 规格变化:Micro 60、Small 90、Medium 120,往上更多,截至 2026 年 6 月)。Serverless 平台在流量突增时会拉起 100 多个互相独立的实例,每个都开自己的直连,瞬间就把上限撑爆。修法不是”买更多连接”,而是在函数和 Postgres 之间放一个 connection pooler(Supabase 内置的 Supavisor),让成千上万的短连接共用一小撮真正的后端连接。

先判断你属于哪一类

日志里的症状最可能的原因跳到
流量高峰时 too many clients alreadyServerless 走直连 :5432,没过 pooler原因 1 / Step 2
连接数只涨不降连接泄漏(漏了 release()原因 2 / Step 5
prepared statement "s0" already exists / ...does not existtransaction pooler 下用了 prepared statements原因 4 / Step 4
pooler 报 MaxClientsInSessionMode / “max clients reached”pooler 客户端上限被打满,或误用 session mode原因 3
错误集中在整点cron / 批处理任务霸占连接池原因 5
pg_stat_activity 里出现 pgAdmin/Metabase/DBeaverGUI 工具挂在 prod 上原因 6
新机器根本连不上(ENETUNREACH、IPv6 地址)直连需要 IPv6,而你在纯 IPv4 网络见”IPv4 与 IPv6”

常见原因

按命中率从高到低排列。

1. Serverless 直连 :5432(不走 pooler)

最高频。代码用了 Dashboard 给的 Direct connection 串,每个 Lambda / Worker / Edge 实例各自开一个后端连接。突发流量拉起 100 个实例 → 开 100 个连接 → 撑爆上限。

如何判断: 看连接串里的 host 和端口。db.<ref>.supabase.co:5432直连aws-<region>.pooler.supabase.com:6543transaction pooler。Serverless 必须用后者。

2. 没释放连接 / 长事务

const client = await pool.connect();
const result = await client.query('SELECT ...');
// 漏了 client.release()

每个 request 漏 1 个连接,几小时就耗尽。如果你开了事务(BEGIN)后提前 return/抛错跳过了 COMMIT/ROLLBACK,结果一样——这个后端会永远卡在 idle in transaction

如何判断: active 连接数单调上涨、从不回落到基线,哪怕流量空闲时也不降。

3. pooler 客户端上限被打满 / 误用 session mode

Supavisor 暴露两个不同的串。6543 端口是 transaction mode——每个事务一结束连接就归还池子,所以少量后端连接能服务上千客户端。pooler.supabase.com host 上的 5432 端口是 session mode——每个客户端在整个会话期间独占一个连接,换回完整的 Postgres 兼容性,但失去了扩展优势。(注意:6543 端口上的 session mode 已于 2025 年 2 月 28 日下线——现在 6543 只支持 transaction,所以你是靠复制哪个端口/串来选模式,而不是靠 pool_mode 查询参数。)

如何判断: pooler 返回 MaxClientsInSessionMode 或 “max clients reached”,或者你看到 serverless 负载指向了 :5432 的 pooler 串。

4. transaction pooler 不支持的 statement

prepared statements、LISTEN/NOTIFY、advisory locks、会话级临时表等在 transaction mode pooler 下都会失效,因为下一个事务可能落到另一个后端连接上。最常见的触发源是 ORM:Prisma,以及 node-postgrespool.query 带 name 时,默认都会创建服务端 prepared statements。

如何判断:prepared statement "s0" already existsprepared statement "s1" does not exist

5. 后台 cron / 批处理跑慢查询

一个跑 30 秒的定时统计查询,全程占着一个连接。如果几个同时触发、正常流量又要连接,池子就被 cron 吃掉了。

如何判断: 错误紧贴 cron 触发时间(常在整点 :00)成簇出现。

6. 旁路工具(pgAdmin、Metabase、DBeaver)占着连接

你或同事用 GUI 直连 prod 库,每个空闲的 GUI 会话可能占着 5-10 个永不关闭的连接。

如何判断:SELECT application_name, count(*) FROM pg_stat_activity GROUP BY application_name;,看有没有你认得的工具名。

IPv4 与 IPv6(如果你”根本连不上”先看这段)

很多人在从直连串迁移时会被这个坑住。截至 2026 年 6 月:

  • 直连db.<ref>.supabase.co:5432)解析到的是 IPv6 地址。如果你的运行环境或网络是纯 IPv4(很多 CI runner、部分公司内网、较旧的 serverless 区域),它会在你撞到连接上限之前就先报 ENETUNREACH 或 “no route to host”。
  • Shared Pooler / Supavisor...pooler.supabase.com54326543 都算)在所有档位都兼容 IPv4——这也是 serverless 该走它的另一个理由。
  • 如果你确实需要在 IPv4 下用直连,可启用付费的 IPv4 add-on,或改用 Dedicated Pooler(与数据库同机部署,延迟比 shared pooler 更低,付费 compute 上可用)。

最短修复路径

Step 1:紧急止血——kill 卡死的连接

在 Supabase Dashboard 的 SQL Editor 里跑,立刻腾出连接:

-- 看清楚到底谁连着、谁在忙
SELECT pid, usename, application_name, state, query_start
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY query_start;

-- 终止卡 idle 超过 5 分钟的事务
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND query_start < now() - interval '5 minutes';

这能换来喘息空间让服务恢复,但只是止血带——Step 2-5 才是真正的修复。

Step 2:把 serverless 切到 transaction pooler

在 Supabase Dashboard 顶栏点 Connect,弹窗里列出全部三个串。(同样的串也在 Project Settings → Database → Connection string 下,那里还有一个 Connection pooling 标签页可以调池子设置。)

直连(IPv6,仅限长跑服务器):
postgresql://postgres:[PASSWORD]@db.<ref>.supabase.co:5432/postgres

Session pooler(5432 端口,IPv4,每客户端独占一个连接):
postgres://postgres.<ref>:[PASSWORD]@aws-<region>.pooler.supabase.com:5432/postgres

Transaction pooler(6543 端口,IPv4——serverless 用这个):
postgres://postgres.<ref>:[PASSWORD]@aws-<region>.pooler.supabase.com:6543/postgres

注意用户名不一样:直连用 postgres,两个 pooler 串都用 postgres.<ref>。把你的环境变量(DATABASE_URL)改成 :6543 那个串,然后重新部署。

Step 3:调对池子大小,别一上来就升 plan

现在不再用查询参数选”session 还是 transaction”——而是靠复制 :6543(transaction)还是 :5432 的 pooler 串(session)来选。Serverless 要的是 transaction mode。

如果单个函数实例可能开多于一个连接,给它封顶,免得一群实例各抓一把:

postgres://...@...:6543/postgres?connection_limit=1

对 serverless 来说,每实例 connection_limit=1 是合理默认——每个实例一次只处理一个请求。pooler 的 Pool Size(每租户默认 15,可在 Connection pooling 标签页调)是它向 Postgres 保持的真实后端连接数;Max Client Connections(Free 档默认 200,截至 2026 年 6 月)是同时能挂到 pooler 上的 serverless 客户端数。

Step 4:Prisma / ORM 特殊处理(prepared statements)

transaction mode 用不了服务端 prepared statements,所以 Prisma 需要加 pgbouncer=true(这会让 Prisma 关掉 prepared statements):

// .env
// 运行时查询 → transaction pooler,关掉 prepared statements:
DATABASE_URL="postgres://postgres.<ref>:[PASSWORD]@aws-<region>.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1"

// migration 需要一个不走池子、支持 session 的连接:
DIRECT_URL="postgres://postgres.<ref>:[PASSWORD]@aws-<region>.pooler.supabase.com:5432/postgres"
// schema.prisma
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")   // 应用查询 → :6543 pooler
  directUrl = env("DIRECT_URL")     // prisma migrate / db push → :5432
}

directUrl 指向 session 模式 pooler(:5432,而不是原始直连串——直连 host 需要 IPv6,很多 CI/migration 环境没有。用 node-postgres 时别用命名 prepared statements;用 Drizzle 的 postgres-js driver 时设 prepare: false

Step 5:query 必须释放

// 错——出错时会泄漏
const client = await pool.connect();
await client.query(/* ... */);
// 没有 finally → 连接永不归还

// 对——保证 release
const client = await pool.connect();
try {
  await client.query(/* ... */);
} finally {
  client.release();
}

// 最简单——pool.query 自动取出并归还连接
await pool.query('SELECT * FROM users WHERE id = $1', [id]);

只有需要多语句事务时才手动取 client,否则一律优先用 pool.query

Step 6:监控 + 告警

-- 当前连接按 state 分组
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;

-- 长时间 idle in transaction(漏 release 的信号)
SELECT pid, query, query_start, state
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND query_start < now() - interval '1 minute';

连接总数超过上限约 80% 时触发告警。Supabase Dashboard 也在 Reports → Database 里画了连接数曲线。

Step 7:升级 compute(最后手段,不是第一步)

直连上限随 compute 规格变化,而非 plan 名称:大致 60(Micro/Free)、90(Small)、120(Medium),更大实例更高,截至 2026 年 6 月。Pro 档($25/月)让你能抬高 compute 和 pooler 上限。升级能买到余量,但如果 serverless 还在走直连,它只是把下一次故障往后拖——先把 pooling 修对。

如何确认修好了

  1. 部署完 :6543 串后,做一次压测(或者直接在真实流量下刷新),盯着 SELECT state, count(*) FROM pg_stat_activity GROUP BY state;——active 数应该稳定在上限以下,而不是一路往上爬。
  2. 确认部署环境里的连接串 host 以 pooler.supabase.com 结尾、端口是 6543(去平台的环境变量面板查,别只看本地 .env)。
  3. 让它空闲 10 分钟,再跑一次计数查询——idle-in-transaction 应该回落到接近 0。如果没有,说明你还有 release() 泄漏(Step 5)。

预防建议

  • Serverless / edge:永远走 :6543 transaction pooler,绝不碰直连 :5432
  • 只有长跑后端(Fly.io、Railway、带稳定连接池的 VM/EC2 进程)才用直连——而且要么走 IPv6,要么开 IPv4 add-on。
  • serverless 里用 ORM 时,每实例设 connection_limit=1,Prisma 加 pgbouncer=true
  • 每个 query:awaittry/finally 释放,或直接用 pool.query
  • 监控连接数,超过上限 80% 告警。
  • 给 cron / 批处理独立连接或专用 worker,别和请求流量抢池子。
  • 别让笔记本或 GUI 工具挂在 prod 库上——用 Supabase Studio、read replica 或 Dedicated Pooler。
  • 大流量项目第一天就估算连接需求;Free 档那 60 个直连消失得很快。

常见问题

5432 和 6543 端口有什么区别?pooler.supabase.com host 上,5432session mode(每客户端独占一个连接,Postgres 功能全开),6543transaction mode(每个事务结束后连接回收,专为 serverless 设计)。而 db.<ref>.supabase.co:5432 是另一个完全绕过 pooler 的直连

serverless 里 connection_limit 该不该调到 1 以上? 很少需要。每个 serverless 实例一次只处理一个请求,一个连接就够。调高只会让流量突增更快打满 pooler。带内部并发的长跑服务器是例外。

为什么报 prepared statement "s0" already exists 你的 ORM 或 driver 在创建服务端 prepared statements,而它们在 transaction mode pooler 下活不下来。Prisma 在 DATABASE_URL?pgbouncer=true;其他 driver 关掉 prepared statements(如 postgres-js 里设 prepare: false)。

切到 pooler 后 migration 跑不了——为什么? transaction mode 不支持 migration 需要的会话级特性(advisory locks、prepared statements)。让 migration 走一条单独的 session 模式连接——把 directUrl 指向 :5432 的 pooler host(不是 IPv6 直连串)。

连接数明明正常,却还在报 “too many clients”? 你可能撞的是 pooler 的 Max Client Connections,而不是 Postgres 的直连上限;也可能是 GUI 工具或 cron 挂在库上。查 SELECT application_name, count(*) FROM pg_stat_activity GROUP BY application_name;,如果确实是应用流量,就在 Connection pooling 标签页把 pooler 客户端上限调高。

GitHub Actions / CI 里能用直连吗? 通常不行——直连 host 只有 IPv6,很多 CI runner 是纯 IPv4,会报 ENETUNREACH。CI/migration 用 :5432 的 session pooler 串,或开 IPv4 add-on。

相关阅读

标签: #独立开发 #排查 #排查