Skip to content

连接池配置(PgBouncer) ​

PostgreSQL 的进程模型决定了它对「连接数」非常敏感:每条客户端连接都对应一个独立的后端进程,而不是一个线程。连接开得越多,内存与进程切换开销越大,性能反而下降。因此在生产环境里,「加连接池」几乎和「调 shared_buffers」一样,属于必做项。

本节从进程模型的代价讲起,说明为什么盲目调大 max_connections 是反模式,然后以 PgBouncer 为主线讲清三种池模式的取舍、事务模式下的各种限制,以及 PHP 应用最容易踩的预处理语句(prepared statement)陷阱。最后给出其他池化方案的横向对比与监控建议。

前置知识

阅读本节前,建议先了解:统计信息对执行计划的影响、postgresql.conf 配置

基础概念 ​

为什么 PostgreSQL 需要连接池 ​

PostgreSQL 采用多进程模型:Postmaster 收到连接请求后 fork() 出一个后端进程(backend process),此后这条连接的所有 SQL 都由这个进程处理,直到连接断开。

text
客户端 A ──┐
客户端 B ──┤            ┌──────────────┐
客户端 C ──┼── 连接 ──▶ │  Postmaster  │ ── fork ──▶ 后端进程 A
客户端 D ──┤            └──────────────┘            后端进程 B
客户端 E ──┘                                        后端进程 C
                                                    后端进程 D
                                                    后端进程 E

每个后端进程都要占据:

组成典型开销说明
进程本身约 2~5 MB页表、栈、可执行代码映射(大半为共享的只读页)
PgBackend 私有内存约 1~3 MB会话上下文、catalog cache、解析结果缓存
work_mem按需,每条排序/哈希最多一份单条复杂查询可能同时用到多份
temp_buffers默认 8MB 上限访问临时表才实际分配

一个空闲连接的基础内存开销通常在 5~10 MB 量级。也就是说 1000 条连接光是「挂在池外」,就可能吃掉 5~10 GB 内存——而这部分内存并没有用于缓存数据。

连接不是免费的

很多人把数据库连接当成「HTTP 长连接的廉价变体」。实际上在后端进程模型里,一个连接 = 一个操作系统进程 = 一份独立的内存与调度实体。连接数从 100 涨到 1000,吞吐量往往不涨反跌。

连接建立的真实成本 ​

fork() 一个后端进程远比建立一个线程昂贵。开一条新连接大致要经历:

text
客户端发起连接
   │
   ├─ 1. TCP 三次握手 + TLS 握手(若启用 SSL)        ← 网络往返
   ├─ 2. Postmaster 鉴权(pg_hba 匹配、SCRAM 计算)    ← CPU
   ├─ 3. fork() 后端进程 + 复制页表(COW)             ← 系统调用
   ├─ 4. 后端初始化:读 catalog、加载配置、建内存上下文  ← 磁盘 I/O + CPU
   └─ 5. 完成启动包交换,连接可用

在本地 Unix socket 上,这条链路约 5~20 ms;跨网络或启用 TLS、SCRAM 迭代次数较高时,30~100 ms 也很常见。

「短连接 + 高并发」的业务(典型如 PHP-FPM 的每个请求建一条连接)会把大量时间浪费在这条链路上。这正是连接池的核心价值:把连接的建立成本摊薄到多次请求上。

连接数与 CPU 的关系 ​

数据库能并行处理的「活跃」连接数,最终受限于 CPU 核数,而不是 max_connections。

text
最优并发数 ≈ CPU 核数 × 2 ~ 4
             (超线程算物理核数的一半;SSD 优于 HDD 时可取高值)

原因在于:连接一旦真正执行 CPU 密集的查询,就必然占用一个核。连接数远超核数时,只会带来:

现象原因
上下文切换(context switch)暴增太多进程争抢少数核
锁等待、LWLock 争用变多并发事务数上升
缓存命中率下降每个后端的 catalog cache 等私有内存互相挤占
尾延迟(p99)恶化队列变长,请求排队时间不可控

所以正确的做法是:保持活跃并发数接近 CPU 核数,用连接池把多余请求排队,而不是让它们都变成后端进程。

PostgreSQL 18 没有内置连接池 ​

需要明确一点:PostgreSQL 18 仍然没有内置连接池。官方一直把这件事交给外部组件(PgBouncer、pgpool-II、odyssey)或应用层连接池。

可能出现混淆的是协议层的 max_prepared_statements(PG14 引入)——它不是连接池,只是让服务端支持协议级命名预处理语句,从而让 PgBouncer 在事务模式下也能复用预处理语句。两者不要混为一谈。

语法与用法 ​

PgBouncer 简介 ​

PgBouncer 是最常用的 PostgreSQL 连接池,特点是:

特性说明
单进程、事件驱动基于 libevent,一个进程用较少内存支撑上千客户端
协议感知讲 PostgreSQL 前端/后端协议,客户端无需改代码(只改端口)
无需特权限不需要超级用户,普通连接即可
支持 TLS客户端到 PgBouncer、PgBouncer 到 PostgreSQL 均可加密
支持 auth_query可用一条查询动态获取用户密码,避免重复维护名单

三种池模式 ​

PgBouncer 通过 pool_mode 决定「服务端连接在多长的时间粒度上被复用」:

text
session 模式      客户端 ──连接──▶ [独占服务端连接] ──连接──▶ PostgreSQL
                  连接存续期间服务端连接不释放

transaction 模式  客户端 ──事务──▶ [占用服务端连接] ──提交/回滚──▶ 归还到池
                  只有事务期间独占,事务间可被别人复用

statement 模式    客户端 ──语句──▶ [占用服务端连接] ──语句结束──▶ 归还到池
                  每条语句独占(多语句事务不可用)
模式服务端连接复用粒度并发收益兼容性适用场景
session整个会话低完全兼容有会话状态需求(SET、临时表、LISTEN)
transaction每个事务高有约束(见下)短事务 OLTP,绝大多数场景的推荐值
statement每条语句最高只支持单语句、无事务极简只读/自动提交场景,较少使用

默认值是 session。要获得连接池的主要收益,需要改成 transaction。

关键配置项 ​

PgBouncer 的配置文件(pgbouncer.ini)中最需要理解的几个参数:

参数默认值含义
pool_modesession池模式,OLTP 建议 transaction
max_client_conn100允许的客户端连接总数上限
default_pool_size20每个 user+database 组合的服务端连接数
min_pool_size0常驻保底的服务端连接数
reserve_pool_size0超出 default_pool_size 时额外可借的连接数
reserve_pool_timeout5等待多久后启用 reserve pool(秒)
server_idle_timeout600空闲服务端连接被关闭的时间(秒)
server_lifetime3600服务端连接的最长存活时间(秒)
server_connect_timeout15建立服务端连接的超时(秒)
query_wait_timeout120客户端在池中等待连接的超时(秒),超时后报错

几个关键理解点:

1. max_client_conn 与 default_pool_size 是两层限制

text
客户端连接(最多 max_client_conn 条)
        │  排队等待
        ▼
服务端连接(每 user+db 最多 default_pool_size [+ reserve_pool_size] 条)
        │
        ▼
    PostgreSQL(后端进程数 ≈ 各池之和)

max_client_conn 可以设得很大(如 10000),因为空闲的客户端连接在 PgBouncer 里只是内存中的状态,不占后端进程。真正决定 PostgreSQL 负载的是所有池的 default_pool_size 之和。

2. 服务端连接总数必须低于 max_connections

text
Σ (每个 user+db 的 default_pool_size + reserve_pool_size)
  + 管理与监控连接
  应 < PostgreSQL 的 max_connections - superuser_reserved_connections

3. reserve_pool_size 是「临时扩容」而非「常驻容量」

只有当某池的连接全忙、且客户端等待超过 reserve_pool_timeout 时,才从 reserve pool 借连接。用完归还。

详细说明 ​

连接池的架构位置 ​

text
  应用层                                   数据库层
┌─────────────┐                       ┌──────────────────────┐
│ PHP-FPM     │──┐                    │  PostgreSQL          │
│ worker 1..N │  │  max_client_conn   │  ┌────────────────┐  │
├─────────────┤  ├──▶ ┌───────────┐ ──┼─▶│ 后端进程 1..M  │  │
│ 其他服务     │──┘    │ PgBouncer │   │  └────────────────┘  │
└─────────────┘       │ (transaction)│  │  M = Σ default_pool │
                      └───────────┘   └──────────────────────┘
                       池内排队            连接数稳定、可控

关键收益:PostgreSQL 侧看到的连接数变少且稳定,max_connections 可以保持在合理范围(如 200~500),内存与上下文切换压力大幅下降。

事务模式下的限制 ​

这是选择 transaction 模式必须理解的代价。事务模式下,服务端连接会在事务边界被回收,因此任何跨越事务的会话状态都会失效。

限制原因应对
会话级 SET 不生效下个事务可能拿到另一条服务端连接用 SET LOCAL(事务级)或连接串参数
命名预处理语句不可直接复用服务端连接变化后语句不存在PG14+ 配合 max_prepared_statements(见下节)
WITH HOLD 游标不可用游标跨事务存活,本质是会话状态改用普通游标,或把结果集一次取出
LISTEN / NOTIFY 不可靠监听绑定在具体服务端连接上用独立连接(session 池或直连)
会话级 advisory lock 有风险锁绑定在会话上,会话被复用后语义混乱用事务级 pg_advisory_xact_lock()
临时表、PREPARE、SET search_path均为会话状态改为事务内使用或应用层维护
pg_stat_activity 对应关系变化一个客户端会对应多条服务端连接记录用 application_name 关联

典型报错:prepared statement "S_1" does not exist

在 transaction 模式下,PHP PDO / Java JDBC 使用协议级命名预处理语句时,很容易遇到:

text
ERROR: prepared statement "S_1" already exists
ERROR: prepared statement "S_1" does not exist

原因是客户端在连接 A 上 PREPARE,下个事务却被分配到连接 B。解决办法见下一节。

预处理语句与事务池 ​

这是 PgBouncer + transaction 模式最经典的坑,方案有两代演进:

方案一:客户端不用命名预处理语句(旧办法)

让驱动使用「简单查询协议」或匿名预处理语句。PHP PDO 通过 ATTR_EMULATE_PREPARES 控制:

php
// 推荐用于 PgBouncer + transaction 模式:由 PDO 在客户端做参数转义与拼接
$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_EMULATE_PREPARES => true,
]);
ATTR_EMULATE_PREPARES行为与 PgBouncer 事务模式
truePDO 在客户端模拟,把参数值内联进 SQL兼容(不发送命名语句)
false(默认)走服务端协议级预处理语句可能报错,除非配合方案二

代价是丢掉了服务端预处理语句的「解析一次、执行多次」收益,且需信任驱动的参数转义。

方案二:服务端支持协议级预处理语句复用(PG14+ 推荐)

PostgreSQL 14 引入了 max_prepared_statements(默认 0,即关闭)。它会为每条协议级预处理语句按名称建立缓存,PgBouncer 1.21+ 在 transaction 模式下可以跟踪并重放这些语句,使命名预处理语句在跨服务端连接时依然有效。

sql
-- postgresql.conf 或 ALTER SYSTEM
max_prepared_statements = 200   -- 0 表示禁用;正数表示每会话可缓存的语句数
ini
# pgbouncer.ini
pool_mode = transaction
max_prepared_statements = 200   # PgBouncer ≥ 1.21,需与 PG14+ 服务端配合

这样就能保留 ATTR_EMULATE_PREPARES => false,同时兼容事务池。

判断该用哪种方案

  • PostgreSQL 版本 < 14:只能用方案一(客户端模拟)。
  • PostgreSQL ≥ 14 且 PgBouncer ≥ 1.21:优先方案二,兼顾兼容性与性能。
  • 无论哪种,都要在生产同配置的测试环境里跑一遍业务 SQL,别只看「连接能建立」。

其他池化方案对比 ​

方案类型优点缺点适用
PgBouncer独立中间件轻量、稳定、事务模式成熟、无状态依赖事务模式有会话状态限制绝大多数 OLTP,首选
pgpool-II独立中间件除连接池外还带读写分离、自动故障转移、负载均衡更重、配置复杂、自身也可能成为瓶颈需要读写分离/故障转移的一体化方案
odyssey独立中间件Yandex 出品,多线程、性能高、支持路由生态相对小高吞吐、愿意尝试新组件
应用层连接池进程内无额外中间件、可精细控制每个应用实例各持一批连接,总量难控;PHP-FPM 模型下效果差Java(HikariCP)、Go、常驻进程语言
云托管内置池云服务免运维、与应用一体能力有限(多为事务模式)、绑定厂商使用 RDS/Aurora/Cloud SQL 且无自运维需求

各方案的一句话定位:

  • PgBouncer:把「连接复用」这件事做到极致,其它什么都不做。
  • pgpool-II:连接池只是它的功能之一,主打读写分离与高可用。
  • odyssey:PgBouncer 的多线程替代品,追求更高并发。
  • 应用层池(HikariCP 等):适合常驻进程(Java/Go);PHP-FPM 每请求一个进程的模型下,进程结束池就没了,收益很有限。
  • Swoole 等常驻 PHP:在常驻模式下可以做应用层池,但要配合协程与连接生命周期管理,复杂度不低。

PHP-FPM 场景的常见误区

PHP-FPM 是「每请求一个进程、请求结束进程可能被回收」的模型。用 PHP 代码里的静态变量做「连接池」,在 FPM 下基本无效(请求结束即释放)。PHP 场景要真正池化,要么用 PgBouncer/pgpool-II 这类外部中间件,要么把应用改成 Swoole/Swoole 协程常驻模型再做应用层池。

max_connections 的调整建议 ​

max_connections 决定了后端进程数上限:

参数默认值说明
max_connections100所有连接的硬上限(含复制、后台连接)
superuser_reserved_connections3为超级用户保留的连接数,普通用户无法占用
reserved_connections0PG17+ 新增,为特定角色保留(配合 pg_use_reserved_connections)

调整原则:

text
1. 先上连接池,把 PostgreSQL 实际连接数压下来
2. 再按「所有池的 default_pool_size 之和 + 复制/管理连接 + 余量」定 max_connections
3. 不要为了「让应用不用改」就把 max_connections 调到 2000、5000

superuser_reserved_connections 存在的原因很实际:连接数打满时,普通用户连不进去,但你必须还能用超级用户登录进去「救火」。

ini
# 一个相对保守的 OLTP 配置示例(16 核 / 64GB 机器)
max_connections = 300
superuser_reserved_connections = 3

实战示例 ​

场景一:一个典型的 PgBouncer 配置 ​

需求:PostgreSQL 16 核机器,PHP-FPM 有 800 个 worker,要求活跃后端连接数不超过 100。

ini
; /etc/pgbouncer/pgbouncer.ini
[databases]
; 把 appdb 映射到真实 PostgreSQL
appdb = host=10.0.0.10 port=5432 dbname=appdb

[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 = 80            ; 每个 user+db 的服务端连接数
reserve_pool_size = 20            ; 突发时可临时多借 20 条
reserve_pool_timeout = 3          ; 等待 3 秒后启用 reserve pool
query_wait_timeout = 30           ; 客户端排队超过 30 秒报错,避免无限等待

; —— 服务端连接生命周期 ——
server_idle_timeout = 300
server_lifetime = 1800
server_connect_timeout = 5

; —— PG14+ 预处理语句支持 ——
max_prepared_statements = 200

; —— 日志与监控 ——
log_connections = 0
log_disconnections = 0
stats_period = 60

容量核算:

text
default_pool_size(80) + reserve_pool_size(20) = 100
max_connections(300) - superuser_reserved_connections(3) = 297
100 << 297    ✅ 有充足余量,且 100 个活跃连接 ≈ 16 核 × 6,仍在合理区间

场景二:验证连接池是否生效 ​

1. 在 PostgreSQL 侧确认实际连接数

sql
SELECT usename,
       application_name,
       state,
       count(*) AS conns
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY usename, application_name, state
ORDER BY conns DESC;
text
 usename | application_name |        state        | conns
---------+------------------+---------------------+-------
 appuser | pgbouncer        | active              |    78
 appuser | pgbouncer        | idle                |    19
 appuser | psql             | idle                |     1
(3 rows)

只要 application_name 显示为 pgbouncer,且总数远低于应用 worker 数,说明池在工作。

2. 在 PgBouncer 控制台查看池状态

bash
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer
sql
SHOW POOLS;
text
 database |   user   | cl_active | cl_waiting | sv_active | sv_idle | sv_used | pool_mode
----------+----------+-----------+------------+-----------+---------+---------+-----------
 appdb    | appuser  |        12 |          0 |        80 |       0 |      20 | transaction

关键列含义:

列含义关注点
cl_active正在执行事务的客户端连接与 sv_active 一起看
cl_waiting正在排队等连接的客户端长期 > 0 说明池太小
sv_active正在被使用的服务端连接逼近 default_pool_size 说明接近饱和
sv_idle空闲的服务端连接长期为 0 且 cl_waiting > 0 要扩池
sv_used刚被归还、待清理的连接偶发正常

3. 判断是否需要扩池

text
若 cl_waiting 长期 > 0 且 sv_active == default_pool_size:
   → 要么扩 default_pool_size(确认 CPU 有余量)
   → 要么优化 SQL(单个事务太慢,占着连接不放)

扩池不是万能解

cl_waiting > 0 时先别急着加 default_pool_size。如果根因是某条慢 SQL 占用连接数秒,扩池只是把「排队」变成「一起变慢」,还可能把 CPU 打满。先看 sv_active 上跑的是什么。

场景三:实测连接建立成本 ​

要判断「到底需不需要池」,最直接的依据是实测连接开销。可以用 pgbench 对比「每次建连接」与「连接复用」:

bash
# 1) 每事务新建连接(-C 表示 connection per transaction)
pgbench -h 10.0.0.10 -p 6432 -U appuser -c 32 -j 8 -T 60 -C appdb

# 2) 复用连接
pgbench -h 10.0.0.10 -p 6432 -U appuser -c 32 -j 8 -T 60 appdb

典型结果形态(数值随硬件变化,仅示意差异量级):

text
-C(每次建连接):  tps = 3800   latency average = 8.4 ms
   复用连接     :  tps = 14500  latency average = 2.2 ms

两者的 tps 差距,就是连接建立成本在真实负载下的体现。若差距显著,说明池化收益明确。

也可以用 PgBouncer 自身的 SHOW STATS 观察平均查询等待时间:

sql
SHOW STATS;
text
 database | total_xact_count | total_query_count | avg_query_time | avg_wait_time
----------+------------------+-------------------+----------------+--------------
 appdb    |          8420311 |          25130422 |            312 |             4

avg_wait_time(单位微秒)是客户端在池中排队等待的平均时间。它一旦升高,说明池容量开始成为瓶颈。

注意事项 ​

1. pool_mode = transaction 是默认要改的第一件事

PgBouncer 默认 session 模式,此时服务端连接被客户端独占,池化收益极小。上线前务必确认改成了 transaction(除非确实有会话状态需求)。

2. 别把 max_client_conn 当成「数据库能承受的连接数」

它只是 PgBouncer 内存中维护的客户端数量上限。真正打到 PostgreSQL 的是 default_pool_size 之和。

3. 所有池容量之和必须小于 max_connections

text
Σ (default_pool_size + reserve_pool_size) < max_connections - superuser_reserved_connections

否则 PostgreSQL 会直接报 FATAL: sorry, too many clients already。

4. transaction 模式下不要用会话级 SET

sql
-- 错误:下个事务可能落到另一条服务端连接上,设置丢失
SET search_path = app, public;

-- 推荐:事务级设置,随事务结束自动失效
SET LOCAL search_path = app, public;

同理,时区、statement_timeout 等会话参数应通过连接串或 SET LOCAL 设置。

5. 长事务会「霸占」服务端连接

事务模式下,服务端连接只在事务结束才归还。一个挂起的长事务会让该连接无法服务其他客户端,从而放大 cl_waiting。务必排查 idle in transaction:

sql
SELECT pid, state, now() - state_change AS idle_for, left(query, 60)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY state_change;

6. LISTEN / NOTIFY 与通知类功能不要走事务池

通知机制依赖固定的会话连接。需要时给这类客户端单独配一个 session 模式的池或直连端口。

7. 事务级 advisory lock 才是池化环境的正确选择

sql
-- 风险:会话级锁,连接复用后语义混乱
SELECT pg_advisory_lock(12345);

-- 推荐:事务级锁,提交/回滚自动释放
SELECT pg_advisory_xact_lock(12345);

8. 连接池掩盖不了 SQL 问题

连接池解决的是「连接开销」,不是「查询慢」。如果单条 SQL 要跑 5 秒,池化只会把等待从「建连接」转移到「排队」。慢 SQL 仍要靠索引与执行计划优化解决(见 查询重写技巧)。

9. server_lifetime 与 server_idle_timeout 都别设太大

服务端连接长期不重建,会固定占用后端内存,也不利于负载分散。默认的 3600s / 600s 通常够用。反之设得过小会导致频繁重建连接,收益下降。

10. 云托管服务的「连接池」能力有限

RDS/Aurora/Cloud SQL 提供的连接池(如 RDS Proxy、Cloud SQL Auth Proxy 的连接池)多为事务模式且行为受限,仍建议在应用与数据库之间自建 PgBouncer 来获得可控性。

11. pgbouncer 数据库必须用管理员账号连接

SHOW POOLS、SHOW STATS 等管理命令要求在 [pgbouncer] 段配置 admin_users(或 stats_users),且用 psql -U pgbouncer pgbouncer 的方式连接,不能走业务库名。

12. 别忘了给 PgBouncer 本身做高可用

PgBouncer 是单进程。它挂掉后所有应用连接中断。生产环境至少部署两个实例,用虚拟 IP 或服务发现做故障转移。

最佳实践 ​

1. OLTP 场景起步配置

ini
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25        ; 起步值,按 CPU 核数 × 2~4 调整
reserve_pool_size = 10
query_wait_timeout = 30
server_idle_timeout = 300
server_lifetime = 1800

2. 池大小的计算口径

text
单池 default_pool_size ≈ min(
    CPU 物理核数 × 2~4,          -- CPU 限制
    max_connections × 0.6 / 池数  -- 总容量限制
)

3. 给应用连接加上 application_name

便于在 pg_stat_activity 里区分来源,池化后这一点尤其重要:

ini
appdb = host=10.0.0.10 dbname=appdb application_name=order-svc

4. 监控三条线:连接数、等待时间、池饱和度

sql
-- PostgreSQL 侧:总连接数与状态分布
SELECT state, count(*)
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY state
ORDER BY count(*) DESC;
sql
-- PgBouncer 侧:等待队列与池饱和度(cl_waiting、sv_active)
SHOW POOLS;
sql
-- PgBouncer 侧:平均等待时间趋势
SHOW STATS;

三类指标触发的动作:

指标阈值建议动作
PostgreSQL 连接数 / max_connections> 80%检查是否有实例绕过池直连
cl_waiting长期 > 0排查慢事务;确认 CPU 有余量后扩池
avg_wait_time持续上升池容量或后端性能不足
idle in transaction 会话存在且持续 > 数分钟立即排查应用事务管理

5. 上线前做一次「容量推演」

text
已知:应用实例 4 个 × worker 200 = 800 潜在连接
设定:PgBouncer default_pool_size = 60,reserve_pool_size = 20
核算:80 × 1(单库单用户) = 80 条服务端连接
      80 < max_connections(300) - 3  ✅
      80 ≈ 16 核 × 5,需实测确认 p99 可接受

6. 灰度切换,保留回退通道

切换应用连接串到 PgBouncer 时,先在非核心业务验证,确认预处理语句、SET、事务行为都正常后再全量。回退只需改回直连端口。

参考链接 ​

下一节

继续学习:WAL 与检查点优化