Postgres 高负载下连接池耗尽

Postgres 流量上来就报 'remaining connection slots reserved'。修复方法:合理化池子大小、前面放 PgBouncer 走 transaction 模式、清掉 idle-in-transaction 连接。

应用扛 200 RPS 稳稳的,流量一翻倍就开始报 FATAL: remaining connection slots are reserved for non-replication superuser connectionsFATAL: sorry, too many clients already。Postgres 撞到了 max_connections。新请求要么失败,要么挂着等空 slot;已有请求拿到的连接其实是死的,也会卡。

最快的修法: 在 Postgres 前面放一个连接池(PgBouncer 走 transaction 模式,或者你云厂商的托管版),让少量真实后端连接服务成千上万个应用连接;然后把每个应用实例的池子调小,让 实例数 * 池大小 控制在 max_connections 的 70% 左右以内。后面的内容帮你定位到底是哪一种情况,并防止复发。

你是哪一种情况?

跑一次下面这条,结果基本就指向原因了:

SELECT state, count(*)
FROM pg_stat_activity
GROUP BY state
ORDER BY count(*) DESC;
你看到的现象最可能的原因跳到
总数接近 max_connections,大部分是 idle应用池子太大 / 没有连接池Step 1、Step 2
idle in transaction 很多漏 commit/rollback(连接泄漏)Step 3
几个 active 跑了好几分钟长报表查询占着 slotStep 4
很多短命连接,源端口随机Serverless 没接连接池Step 6
active 里读重查询多,副本几乎闲着读流量都打到主库Step 5

常见原因

按踩坑频率排序。

1. 每个应用实例自己开大池子

10 个应用实例、每个池子 20,就是 200 连接。Postgres 默认 max_connections 是 100,所有实例同时起压就溢出。

怎么判断:对比 SELECT count(*) FROM pg_stat_activitySHOW max_connections。接近 max 就是要溢出了。

2. 漏提交事务导致连接泄漏

代码开了事务、抛了异常、既没 commit 也没 rollback。连接永远停在 idle in transaction,把池子占住。

怎么判断:SELECT state, count(*) FROM pg_stat_activity GROUP BY stateidle in transaction 数量高就是泄漏。

3. 长查询占着连接

报表查询跑 5 分钟,整段时间都占着一个连接。20 个这种查询就把池子塞满。

怎么判断:SELECT pid, query_start, query FROM pg_stat_activity WHERE state = 'active' ORDER BY query_start LIMIT 10。跑超过 30 秒的就是长尾查询。

4. Postgres 前面没有连接池

应用池子直连 Postgres,每个应用实例都占着真实后端连接,没有任何复用。

怎么判断:pg_stat_activity 里连接来源(client_addr)是你的应用服务器,而不是连接池主机。

5. Serverless 函数每次调用都开新连接

Vercel / Lambda 没用支持池化的驱动时,每次冷启动开一条新连接。突发流量就变成突发新连接。

怎么判断:pg_stat_activity 里很多短命连接,来自不同的随机源端口。

6. 读流量都打到主库

所有读查询都走主库,但其实可以走副本,把主库的连接腾给写。

怎么判断:pg_stat_activity 里读重查询占大头,副本利用率几乎为零。

动手前先确认

  • 确认具体症状字符串:是 too many clients 还是 remaining connection slots reserved
  • 记下当前 max_connections 和实际占用数。
  • 找最近一次可能改了池子大小或查询模式的应用发布。
  • 看 Postgres 本体是否健康:CPU、内存、复制延迟。
  • 计划一次安全变更:连接 drain 策略 + 一个短窗口,以防需要 terminate 后端。

需要收集的信息

  • SHOW max_connectionsSHOW shared_buffers,以及 Postgres 类型(RDS / Aurora、Cloud SQL、Supabase、自建)。
  • SELECT state, count(*) FROM pg_stat_activity GROUP BY state
  • 应用端池子配置:池大小、idle 超时、最大寿命。
  • 连接池配置(如果有):PgBouncer、Supavisor、RDS Proxy。
  • 过去一小时的慢查询日志。

分步修复

Step 1:池子大小合理化

要满足的约束:应用实例数 * 每实例池大小 <= 0.7 * max_connections。留余量,因为 max_connections 会给超级用户和复制保留几个 slot,操作系统维护也要占一两个。

// pg Pool 配置(node-postgres)
import { Pool } from 'pg';

const pool = new Pool({
  max: 10,                       // 每个应用实例
  idleTimeoutMillis: 30000,      // 30 秒后释放 idle 客户端
  connectionTimeoutMillis: 2000, // 饱和时快速失败,而不是一直挂着
});

10 个应用实例 + max_connections = 100,池子设 7 就留出了余量。多数应用需要的连接远比想象中少:每个后端能高吞吐地顺序服务很多请求,所以只在确实有争用证据时才加大,别先验性地堆大。

Step 2:Postgres 前面放连接池

自建 PgBouncer:

# pgbouncer.ini
[databases]
mydb = host=postgres-primary port=5432 dbname=app

[pgbouncer]
listen_port = 6432
listen_addr = *
auth_type = scram-sha-256
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
reserve_pool_size = 5
server_idle_timeout = 600
max_prepared_statements = 200

应用连 PgBouncer 的 6432 端口。PgBouncer 持有 25 条真实 Postgres 连接,把最多 1000 个应用连接复用到这 25 条上。

transaction 模式是正确的默认值:每个事务结束后服务端连接就还回池子,这正是复用收益的来源。除非你确实需要 session 级状态(advisory locks、LISTEN/NOTIFY、跨语句的临时表),否则别用 session 模式——它会给每个客户端钉住一条真实连接,基本放弃了复用收益。

关于认证:截至 2026 年 6 月,auth_type = scram-sha-256 是推荐设置。旧的 md5 还能用,但各 Postgres 部署里正在逐步淘汰。

托管版(不用自己跑 PgBouncer):

  • Supabase(Supavisor): 连 transaction 模式连接池走 6543 端口;5432 是 session 模式/直连路由。自 2025 年 2 月起,6543 端口只服务 transaction 模式。
  • AWS RDS / Aurora: 在实例前面开 RDS Proxy。注意连接 pinning——当客户端改了代理无法安全共享的 session 状态时,它会给该客户端钉住一条后端连接,复用率就掉下来。在 PostgreSQL 上,客户端一条 SET 就足以触发 pinning。盯一下代理的 DatabaseConnectionsCurrentlySessionPinned CloudWatch 指标;如果偏高,就把那些 SET 的值挪到代理的 initialization query(每条新后端连接只执行一次)里,或者用 ALTER ROLE/ALTER DATABASE 放到数据库端,然后把应用代码里那条按 session 的 SET 删掉。
  • Cloud SQL: 用 Cloud SQL Auth Proxy 配合应用端池子,或者旁挂一个 PgBouncer sidecar。

Step 3:清掉 idle-in-transaction 连接,并阻止新的产生

-- 看一下作恶的
SELECT pid, now() - state_change AS idle_for, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND state_change < now() - interval '5 minutes'
ORDER BY state_change;

-- 干掉它们
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND state_change < now() - interval '5 minutes';

然后在服务端加个保护,让泄漏能自愈:

ALTER SYSTEM SET idle_in_transaction_session_timeout = '60s';
SELECT pg_reload_conf();

terminate 后端只是争取时间。要从根上修泄漏:把每个事务包进 try/finally(或用 ORM 的托管事务辅助),保证连接无论成功还是走异常路径,都一定会 commit 或 rollback。

Step 4:加超时,让任何查询都不会永远占着 slot

-- 按数据库,限制单条语句
ALTER DATABASE app SET statement_timeout = '30s';

-- 给报表角色更长的额度
ALTER ROLE reporting SET statement_timeout = '5min';

应用代码里按会话来,在已知开销大的查询前设:

await pool.query("SET statement_timeout = '10s'");

Postgres 17 及以上(Postgres 18 自 2025 年 9 月起就是当前稳定大版本;截至 2026 年 6 月补丁线为 18.4)还可以考虑 transaction_timeout,它是 Postgres 17 引入的。它限制整个事务的总时长,不管里面单条语句多短,从而堵上了 statement_timeoutidle_in_transaction_session_timeout 留下的口子(一个由许多短语句加短停顿组成的事务,否则可以无限期地开着)。一个要注意的点:如果 transaction_timeout 设得小于等于 statement_timeoutidle_in_transaction_session_timeout,那两者里较长的那个会被忽略——所以把 transaction_timeout 当作最外层上限,其余两个设得更紧。

ALTER DATABASE app SET transaction_timeout = '2min';

Step 5:读分流到副本

import { Pool } from 'pg';

const primary = new Pool({ host: 'postgres-primary' });
const replica = new Pool({ host: 'postgres-replica' });

function getPool(query: string) {
  if (/^\s*(SELECT|EXPLAIN)\b/i.test(query) && !/FOR UPDATE/i.test(query)) {
    return replica;
  }
  return primary;
}

路由不明确时显式打标(比如要读自己刚写入那一行的 SELECT,应该留在主库,避免副本延迟读到旧数据)。把读移到副本,主库的 slot 就能腾给写。

Step 6:Serverless 用合适的驱动

Vercel / Lambda 每次调用都可能是全新运行时,别每次都开原生 Postgres 连接:

// 基于 HTTP 的驱动——不会每次调用都建一条常驻 TCP 连接
import { neon } from '@neondatabase/serverless';
const sql = neon(process.env.DATABASE_URL);

// 或 Supabase 客户端,它走 transaction 端口连连接池
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(url, key);

如果 serverless 里非用原生驱动不可,就把它指向连接池端点(Supavisor 的 transaction 端口 6543、RDS Proxy 或 PgBouncer),不要直连数据库,并且把每个函数的池子大小设成 1。

Step 7:监控和告警

-- 连接状态快照,每 30 秒跑一次
SELECT
  count(*) FILTER (WHERE state = 'active') AS active,
  count(*) FILTER (WHERE state = 'idle') AS idle,
  count(*) FILTER (WHERE state = 'idle in transaction') AS idle_tx,
  count(*) FILTER (WHERE wait_event_type = 'Lock') AS waiting
FROM pg_stat_activity;

总连接到 max_connections 的 80% 就告警,idle in transaction 超过 5 也告警。这两个都是早期预警,能在请求开始失败之前抓住问题。

怎么确认修好了

  • 跑 2 倍峰值的压力测试;pg_stat_activity 计数应该稳在 max_connections 的 70% 以下。
  • 在连接池侧确认高复用比:很多客户端连接由少得多的服务端连接来服务(PgBouncer 在 admin 控制台 SHOW POOLS; 里看 cl_activesv_active)。
  • 正常流量 10 分钟后,idle in transaction 数应该接近 0。
  • 副本接到了相当份额的读流量(通过 pg_stat_statements 验证)。

长期预防

  • 所有应用实例的池子配置标准化,把 实例数 * 池大小 的推导过程写在旁边。
  • 把连接池(PgBouncer、Supavisor 或 RDS Proxy)作为生产的默认配置,而不是事后补丁。
  • 在数据库层面就把 idle_in_transaction_session_timeoutstatement_timeout 以及(Postgres 17 及以上的)transaction_timeout 设好。
  • 上线第一天就准备只读副本,报表和分析全走它。
  • 每月复盘连接池告警,把最常触发的收紧。

容易踩的坑

  • max_connections 提到 500 来”解决”问题。实测一条 idle 后端连接大约耗 10 MB 内存(按共享内存折算的真实开销更低,但 ~10 MB 是个稳妥的估算),跑查询或临时表的活跃连接还会涨上去,这等于把连接上限换成了内存悬崖。超过约 200 连接,基本都该上连接池而不是加大上限。
  • PgBouncer 用 session 模式,其实 transaction 就够——白白放弃了大部分复用收益。
  • 在 transaction 模式下开了 prepared statements,却把 max_prepared_statements 留在默认的 0,结果悄悄禁用了 prepared statement 支持,报出 prepared statement "..." does not exist 这类错误。把它设成大于应用用到的不同 prepared statement 数量的值。
  • statement_timeout 设得太激进,把合法的长迁移给打断了——把长额度限定给迁移角色就好。
  • 杀连接但不找泄漏源——还会复发。

FAQ

每个应用实例池子该设多少? 5 到 10 起步。多数应用需要的连接比想象中少,因为每个后端能高吞吐地顺序服务请求。只在确有争用证据(请求在排队等空连接)时才加大。

PgBouncer 跟 prepared statements 能配合吗? transaction 模式下 PgBouncer 1.21 及以上可以,但前提是把 max_prepared_statements 设成非零值。它默认是 0,会禁用 transaction/statement 池化下的 prepared statement 支持。设成大于应用用到的不同 prepared statement 数量的值,命中率最好。

serverless 要不要做连接池? 要。用 serverless 友好或基于 HTTP 的驱动(Neon、Supabase 客户端,或让原生驱动指向 RDS Proxy / Supavisor),并把每个函数的池子大小设成 1。每次调用都开原生池子,正是突发流量下把数据库打爆的元凶。

statement_timeouttransaction_timeout 有什么区别? statement_timeout 限制单条查询;transaction_timeout(Postgres 17 引入)限制整个事务,所以由许多短语句组成的长事务也会被终止。两者会相互影响:如果 transaction_timeout 设得小于等于 statement_timeout(或 idle_in_transaction_session_timeout),那个较长的会被忽略。把 transaction_timeout 当作最外层上限,按语句和 idle 的限制设得更紧。

加大 max_connections 需要重启吗? 需要。max_connections 不能热加载,改它要重启 Postgres——这也是为什么在加大上限之前更应该先上连接池。

相关阅读

外部参考:PostgreSQL pg_stat_activity 视图文档客户端连接默认值statement_timeoutidle_in_transaction_session_timeouttransaction_timeout 的定义都在这里)、PgBouncer 配置参考RDS Proxy pinning 指南 是上面那些字段和设置项的权威来源。

标签: #后端 #排查 #postgres