#DevOps

PostgreSQL 连接池与 PgBouncer

每个请求新建 DB 连接?连接数一上来 PG 就崩。本文讲清为什么 PG 连接贵(进程模型)、应用侧连接池、PgBouncer(中间件池化)、transaction 模式、以及连接数该怎么算,让你的数据库扛得住并发。

本文要点

  • 主题聚焦:DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年5月15日·更新于 2026年7月23日

我接手过一个线上事故:某天流量涨了三倍,数据库 CPU 直接拉满,大量请求卡在 authentication timeout,最后 PG 进程被 OOM killer 一批批干掉。排查下来根因特别"低级"——每个应用实例都开了 50 个连接,部署了 8 个实例,再加上 cron、迁移脚本、BI 报表,直连 PG 的连接数飙到 500+。PG 默认 max_connections 才 100。这篇文章把 PG 连接为什么贵、应用侧池化、PgBouncer 中间件池化一次讲透,让你不再因为"连接打爆"半夜爬起来。

一、PG 的连接为什么这么贵

很多新手以为数据库连接就是个 TCP socket,便宜得很。但 PostgreSQL 的架构决定了:每来一个连接,它就 fork 一个独立的 backend 进程。不是线程,是进程。

这意味着每条连接的代价是实打实的:

  • 内存:每个 backend 进程有独立的 work_mem、maintenance_work_mem、临时表空间、缓存上下文。一个空连接占 5~10MB,跑起来一个复杂查询轻松吃几十 MB。300 条连接,光进程元数据就几 GB 起步。
  • CPU:进程之间不共享缓存,TLB miss、context switch 的开销随连接数线性上升。
  • fork 成本:TCP 握手 + fork + 权限校验 + 内存初始化,新建一条连接少说也要几十毫秒。高并发下这个延迟会直接体现在 P99 上。

所以 PG 的连接是重资源,不是"开就完了"。官方默认 max_connections=100 不是抠门,是经验值——再大,单机性能反而下降。MySQL 的连接是线程模型,相对轻量;PG 的进程模型决定了它对连接数天然更敏感。

一句话:对 PG 而言,连接数就是吞吐的天花板,几百条就能让一台不错的机器疲于奔命。

二、应用侧连接池:别每次请求都新建连接

既然连接贵,就该复用。所有像样的驱动都内置了连接池,核心机制就两件事:预热一批连接放池子里,请求来了借一条用完还回去,而不是用完就关、下次再新建。

Node 生态里几个常见选择:

js
// node-pg(pg)——最底层,Pool 就是连接池
const { Pool } = require('pg');
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,                  // 池大小:最多 10 条连接
  idleTimeoutMillis: 30000, // 空闲超过 30s 就回收
  connectionTimeoutMillis: 2000,
});
// 用完一定 release,否则连接泄漏(见第七节)
pool.query('SELECT 1').then(() => pool.end());
text
// Drizzle —— 自带 pool,基于 pg / postgres.js
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
const pool = new Pool({ connectionString, max: 10 });
const db = drizzle(pool);
text
# Prisma —— 用 connection_limit 参数控制
DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=10&pool_timeout=10"

不管用哪个 ORM,池大小、超时、空闲回收这三个参数必须显式配,别吃默认值。ORM 帮你管生命周期,但参数是你要拍板的。

三、池大小:不是越大越好,多了反而慢

这是反直觉的一点:连接池开到几十就够了,开到几百只会更慢。

HikariCP(JDBC 生态最流行的池)作者有一篇经典推导,核心结论是池大小的最优解近似:

text
connections = (CPU 核心数 × 2) + 有效磁盘数

一台 4 核 + SSD 的机器,理论最优就是 9 条左右。为什么这么少?因为数据库的瓶颈永远是 CPU 和磁盘 IO,连接数远大于并行能力时,多出来的连接不会并行执行,只会排队。它们互相抢锁、抢缓冲池、做 context switch,反而拖慢真正在跑的那批查询。

实际工程上,单实例池大小设 10~25 是甜点区。要扛更高并发,正确做法是横向加实例(每个实例各持一个小池),而不是把单池开大。把池开到 100 的团队,通常是在掩盖另一个问题——慢查询。

记住:连接池是"复用",不是"扩容"。

四、PgBouncer:在 PG 前面加一层池

应用侧池化解决了"单实例重复新建连接"的问题,但解决不了"实例数 × 池大小 > max_connections"。你部署了 20 个 Pod,每个池 10 条,直连 PG 就是 200 条,已经把默认值撑爆。再加 BI 工具、临时脚本、迁移任务,炸是迟早的事。

PgBouncer 就是干这个的——一个轻量级的 PG 连接池中间件,放在应用和 PG 之间:

text
应用 A ─┐
应用 B ─┼─→ PgBouncer(:6432) ─→ PostgreSQL(:5432)
应用 C ─┘     收几百条客户端连接    只开几十条 server 连接

它对外暴露成"一个数据库",应用像连 PG 一样连它;对内用一小批 server 连接复用给所有客户端。最小配置(pgbouncer.ini):

ini
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction           # 关键:见第五节
max_client_conn = 1000            # 客户端连接上限(便宜,可以开大)
default_pool_size = 25            # 真正连到 PG 的 server 连接数(贵,要克制)
reserve_pool_size = 5             # 排队爆了时的应急池

max_client_conn 可以开到上千(它只是 TCP,便宜);default_pool_size 要克制——这才是 PG 上真正承担的连接数。这一层就是把"客户端连接数"和"PG 连接数"解耦,让 PG 永远只看到几十条。

五、三种池模式:session / transaction / statement

PgBouncer 最核心的开关是 pool_mode,决定"一条 server 连接被复用到什么粒度":

  • session(每会话):客户端连上 PgBouncer,就独占一条 server 连接,直到断开。相当于不做真正的池化,只是个代理。除非你的应用会话内有大量 SET/会话级状态,否则不要用这个模式——它帮不上你扛并发。

  • transaction(每事务,推荐):客户端在一个事务的执行期间借用一条 server 连接,事务一结束(COMMIT/ROLLBACK)立刻还回池。这是绝大多数 Web 应用的最佳选择,复用率最高,PG 上的连接数严格等于 default_pool_size。

  • statement(每语句):每条 SQL 执行完就归还。复用率拉满,但禁止多语句事务(因为 BEGIN 和后续语句可能落到不同 server 连接)。只在纯只读、无事务场景用。

text
session:      连接 ────────── 整个会话 ────────── 连接
transaction:  连接 ─[BEGIN..COMMIT]─ 归还 ─[下一个事务]─
statement:    连接 ─[SQL]─ 归还 ─[SQL]─ 归还 ─[SQL]─

transaction 模式是 PgBouncer 的灵魂,也是它能在几百客户端连接下让 PG 只看到几十条连接的根本原因。

transaction 模式的一个大坑: prepared statements

transaction 模式下,事务结束连接就还回池了,下一个事务可能拿到另一条 server 连接。如果你在客户端缓存了 named prepared statement(PREPARE 或驱动的语句缓存),下一条连接上没有这个 statement,查询直接报错 prepared statement "xxx" does not exist。

解决方式三选一:

  1. 驱动关闭 named prepared statement 缓存(Prisma 连接串加 pgbouncer=true)。
  2. 升级到 PgBouncer 1.21+,它能在协议层追踪 prepared statement,设 max_prepared_statements。
  3. 改用 statement 模式(仅当无事务时)。

Serverless 函数、短连接场景这个坑尤其常见,排查方向先往这想。

六、连接数到底怎么算

这是最该落到纸上的一步。先列清楚谁会连 PG:

text
直连 PG 的所有来源:
  应用实例数 × 每实例池大小
  + 迁移脚本(cron / CI)
  + BI / 报表工具
  + 开发者直连(调试)
  + 备份/监控连接

举个反例:20 个应用 Pod × 每池 10 = 200,加 BI 5、迁移 10、人工 5,合计 220。PG 默认 max_connections=100,直接超。

两种解法:

A. 直连(无 PgBouncer):严格满足 Σ(实例 × 池) + 杂项 ≤ max_connections × 0.7。预留 30% 给运维和突发。池大小按第三节的小而美原则设,实例数有硬上限。

B. 加 PgBouncer(推荐,尤其多实例 / Serverless):应用连 PgBouncer,PG 上看到的连接数等于 default_pool_size × 数据库数 × 用户数。比如单库单用户、default_pool_size=25,PG 就只有 25 条 server 连接,稳如老狗。前端可以挂几千个客户端连接随便玩。

经验公式:

text
PG 侧实际连接 ≈ default_pool_size × (db 数 × user 数) + reserve_pool_size
应用侧(连 PgBouncer)每实例池: 5~10 即可,因为后面还有 PgBouncer 兜底

两层池的关键认知:应用池管"复用",PgBouncer 管"封顶"。 应用侧池可以小一点,因为真正的并发上限由 PgBouncer 的 default_pool_size 卡死。

七、常见坑:泄漏、池太小、Serverless 爆连接

最后是四个生产事故高频来源:

1. 连接泄漏:借了不还。 用 pool.connect() 手动拿连接却忘了 release(),尤其在异常路径上。一段 try 里拿连接、catch 里 return、漏了 finally——连接就永久卡在"已借出"状态,池子被一点点掏空,最后全部请求等 pool_timeout。规范写法:

js
const client = await pool.connect();
try {
  await client.query('...');
} finally {
  client.release();   // 必须在 finally,异常也要还
}

ORM 帮你把这套生命周期封进 db.query(),能用高阶 API 就别手动 connect。

2. 池太小:请求排队。 池开 5,瞬时来了 20 个请求,15 个排队,pool_timeout 到点直接报 Timed out fetching connection。表现是流量稍大就 5xx,但 PG 本身 CPU 很闲——瓶颈在客户端池,不在数据库。监控指标盯 waiting clients,持续大于 0 就是池偏小或慢查询。

3. Serverless 冷启动爆连接。 Lambda / Vercel / Cloudflare Workers 这类 FaaS,每个实例是独立进程、独立池、生命周期不可控。并发一上来可能瞬间起几百个实例,每个开几条连接,直连 PG 瞬间打爆 max_connections。Serverless + PG 的标准答案是:必上 PgBouncer(或云厂商的等价物如 RDS Proxy、Supabase Pooler),且用 transaction 模式。 否则只是把事故推迟。

4. 迁移脚本把连接占满。 CI 跑迁移时开了连接不关,或迁移脚本自己开了大池,把生产应用挤得连不上。迁移用独立的、小的、短生命周期的连接,跑完即退;别和生产应用共用池,更别开着迁移脚本挂着不退。

小结

  1. PG 的连接是重资源:每连接一个独立进程,几百条就能吃光内存、拖垮 CPU,默认 max_connections=100 是经验值不是抠门。
  2. 应用侧连接池管"复用":池大小 10~25 是甜点区,横向扩容靠加实例而不是开大池;连接池是复用不是扩容。
  3. PgBouncer 管"封顶":用 transaction 模式把客户端连接数和 PG 连接数解耦,让 PG 永远只看到几十条 server 连接,这是多实例 / Serverless 的标准配置。
  4. 连接数必须落到纸上算:Σ(实例 × 池) + 杂项 要小于 max_connections × 0.7;上 PgBouncer 后 PG 侧约等于 default_pool_size × db × user。
  5. 连接泄漏、池太小、Serverless 冷启动、迁移占连接 是四大高频事故源——finally 释放、监控 waiting clients、Serverless 必上池化、迁移独立短连接,分别对应。

关于连接池有一句工程界的共识值得刻在墙上:数据库不是被流量打死的,是被连接数耗死的。 在你优化任何一条 SQL 之前,先确认你的连接模型是健康的——这是性价比最高、也最容易被忽视的一层。

Related

相关推荐