PgBouncer 容量数学:max_client_conn、文件描述符与 pool_size 的正确算法
一句话结论:PostgreSQL 每个连接是一个独立的操作系统进程,连接数本质是内存与调度成本;PgBouncer 的价值不是"支持更多客户端",而是把服务端连接数从"客户端数"解耦成"并发事务数"。算错的地方几乎总在文件描述符预算,而不是池大小。
0. 官方默认值:先看清出厂设置
下表来自 PgBouncer 官方配置文档,检索时间 2026-11-09[1]。
| 配置项 | 默认值 | 含义 |
|---|---|---|
pool_mode | session | 客户端断开才归还服务端连接 |
max_client_conn | 100 | 允许的客户端连接上限 |
default_pool_size | 20 | 每个 user/database 对的服务端连接上限 |
min_pool_size | 0 | 池低于该值时补连接(默认关闭) |
reserve_pool_size | 0 | 额外预留连接(默认关闭) |
reserve_pool_timeout | 5.0 秒 | 超过该等待时间才动用预留池 |
listen_backlog | 128 | 未完成握手的连接队列长度 |
max_prepared_statements | 200 | 每条服务端连接上 LRU 缓存的 prepared statement 数 |
stats_period | 60 秒 | SHOW 命令里平均值的刷新周期 |
peer_id | 0(禁用) | 对等实例编号,上限 16383 |
两个默认值会直接坑到第一次上线:
max_client_conn = 100。应用侧连接池配置成 500 时,PgBouncer 会直接拒绝,报no more connections allowed (max_client_conn)。这不是数据库问题,是 PgBouncer 自己的上限。pool_mode = session。这是最"安全"的模式——它把连接持有到客户端断开,因此不会破坏任何会话状态;代价是池化收益完全取决于客户端空闲比例。客户端几乎不空闲时它和transaction一样快(实测差 0.9%),客户端有思考时间时它慢 25 倍(实测见第 8 节)。
1. 文件描述符预算:官方公式
这是全文最核心的一节。PgBouncer 官方给出的理论最大值[1]:
# 每个用户用自己的用户名连服务端(默认行为,一个 user 一个池)
max_client_conn + (max pool_size × total databases × total users)
# 连接串里写死了 user=(强制单用户,一个库只有一个池)
max_client_conn + (max pool_size × total databases)
官方同时明确:这个理论最大值几乎不可能被真正触达,除非有人刻意构造特殊负载;但正因为如此,文件描述符上限应该设得足够高,而不是"够用就好"。
算一遍
场景:单库单用户,max_client_conn = 5000,default_pool_size = 50。
理论 fd 上限 = 5000 + (50 × 1 × 1) = 5050
再叠加 PgBouncer 自身的日志文件、监听 socket、管理控制台连接,把 ulimit -n 设到 8192 是稳妥的起点;如果开 so_reuseport 多实例,每个实例都要单独算。
一个容易忽略的放大项:如果应用用多用户连接(每个租户一个数据库用户),公式里 total users 会乘上去。10 个用户、每用户 50 连接、单库:
5000 + (50 × 1 × 10) = 5500 # 只是 fd
服务端连接上限 = 50 × 10 = 500 # 这才是打向 PostgreSQL 的真实连接数
500 个 PostgreSQL 后端进程,每个进程的私有内存(排序、哈希、catalog cache)都要计入。池化的第一原则是让 total users 尽量等于 1,把租户区分放到 schema 或 application_name 里,而不是数据库用户。
2. 为什么 default_pool_size = 20 往往够用
因为池大小的分母是并发事务数,不是客户端数。用 Little 定律粗算:
需要的服务端连接 ≈ 峰值 TPS × 平均事务耗时(秒)
例:峰值 2000 TPS、事务平均 8ms:
2000 × 0.008 = 16 个并发事务
默认 20 就够。如果算出来远大于 20,先怀疑长事务——一个跑 30 秒的报表查询会独占一个服务端连接 30 秒,把池迅速抽干。此时正确的动作是给报表加 statement_timeout,而不是把 pool_size 加到 200。
同理,reserve_pool_size 与 reserve_pool_timeout 是"应急阀"而不是"扩容":默认 reserve_pool_timeout = 5.0 秒意味着一个客户端要等满 5 秒,才会借用预留连接。想让它更早介入,就得调小这个值,同时接受"瞬时打爆后端"的代价。
3. pool_mode 三种语义与对应的坑
| 模式 | 归还时机 | 适用 | 主要限制 |
|---|---|---|---|
session(默认) | 客户端断开 | 老应用、依赖 session 状态 | 收益取决于空闲比例,见第 8 节实测 |
transaction | 事务结束 | 绝大多数 Web/API | session 级特性不可用 |
statement | 单条语句结束 | 纯自动提交的简单查询 | 跨语句事务被禁止 |
transaction 模式是生产主流,但它改变了"连接"的语义,以下三类用法会出问题:
- 会话级临时表 /
SET变量:下一个事务可能落在另一条服务端连接上,状态丢失。 LISTEN/NOTIFY:需要长连接,transaction模式下不可靠。- advisory lock:会话级 advisory lock(
pg_advisory_lock)在归还连接后仍被持有,会污染其他客户端;应改用pg_advisory_xact_lock。
search_path 的版本差异(这个坑很新)
PgBouncer 默认跟踪的客户端参数包括 application_name、client_encoding、DateStyle、default_transaction_read_only、IntervalStyle、scram_iterations、search_path、session_authorization、standard_conforming_strings、TimeZone[1]。
关键在于:search_path 只有从 PostgreSQL 18 起才会被服务端主动报告给客户端。文档写得很清楚——对于服务端不上报的参数(例如 PG 18 之前的 search_path),PgBouncer 只能拿到客户端在启动包里指定的值;连接建立后用 SET 改的值不会被跟踪,会泄漏给共用同一服务端连接的其他客户端,除非启用 server_reset_query_always[1]。
工程结论:
- 后端是 PG 18+:
search_path自动被跟踪,风险显著降低。 - 后端是 PG 17 及更早:任何
SET search_path都必须视为泄漏风险。要么在连接串/启动参数里声明,要么开server_reset_query_always(代价是每次归还都重置,吃掉部分性能)。
4. prepared statement:max_prepared_statements 的真实成本
transaction 模式下,客户端 prepare 的语句在下一次事务时可能已经不在同一条服务端连接上。PgBouncer 的解法是自己维护一份协议级 prepared statement 缓存:给每条唯一查询串分配内部名 PGBOUNCER_{unique_id},在真正需要的服务端连接上透明地重新 prepare[1]。
默认 max_prepared_statements = 200(每条服务端连接)。官方给出的内存估算例子[1]:
1000 个活跃客户端
200 条唯一查询
平均查询 5kB
pkt_buf 默认 4096(4kB)
内存上限 = 200 × 5kB + 1000 × 4 × 4kB ≈ 17MB
也就是说这套机制的额外内存开销是可控的十几 MB 级别,不是想象中的灾难。真正的风险在别处:
如果同一条 prepared statement 在不同执行中返回值/参数类型发生变化,PostgreSQL 会报
cached plan must not change result type。
最常见触发场景是 DDL 迁移(加列、改列类型)。官方给出的处理方式是:迁移完成后在 PgBouncer 控制台执行 RECONNECT,强制重新 prepare[1]。
psql -h 127.0.0.1 -p 6432 -U admin -d pgbouncer -c "RECONNECT;"
5. 单核瓶颈与 so_reuseport
PgBouncer 是单线程的,每个实例只用一个 CPU 核[1]。当 QPS 高到单核成为瓶颈时,官方方案是 so_reuseport = 1:同一台机器上起多个实例监听同一端口,由内核分发连接。
代价与配套要求(都来自官方文档[1]):
- 每个实例必须有不同的
unix_socket_dir和pidfile(以及logfile)。 - 开启后无法再通过 TCP 连到某个特定实例,监控采集方式要相应调整。
- 必须配置 peering(
peer_id+[peers]段)才能保证查询取消(cancel)请求被转发到正确的实例——取消请求走的是另一条 TCP 连接,负载均衡可能把它送到错误的进程。peer_id上限是 16383。
6. 一份可直接用的配置
[databases]
; 不写 user=,即为每个客户端用户名各建一个池(total users 会进入 fd 公式)
app = host=10.0.0.11 port=5432 dbname=app
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
listen_backlog = 512
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
; 核心:事务级池化
pool_mode = transaction
max_client_conn = 5000
default_pool_size = 50
min_pool_size = 10
reserve_pool_size = 10
reserve_pool_timeout = 2.0
; prepared statement 缓存
max_prepared_statements = 200
; 超时(秒)——防止长事务抽干池
server_idle_timeout = 60
query_wait_timeout = 10
idle_transaction_timeout = 60
; 监控与管理
stats_period = 30
admin_users = pgbouncer_admin
stats_users = pgbouncer_stats
; 单核瓶颈时再打开(需配套 peers)
; so_reuseport = 1
; peer_id = 1
对应的 fd 上限(systemd):
# /etc/systemd/system/pgbouncer.service.d/limits.conf
[Service]
LimitNOFILE=8192
7. 用控制台验证,而不是靠猜
psql -h 127.0.0.1 -p 6432 -U pgbouncer_admin -d pgbouncer
-- 观察"客户端在等、服务端却闲着"的错配
SHOW POOLS;
-- 看每个客户端的连接归属与状态
SHOW CLIENTS;
-- 看服务端连接被谁占用、闲置多久
SHOW SERVERS;
-- 命中率与平均等待
SHOW STATS;
判读要点:
- 客户端在排队、服务端连接却全 idle:不是池太小,是
pool_mode用错了(大概率还在session)。 - 服务端连接长期被少量客户端占满:找长事务,
SHOW SERVERS里的连接年龄直接暴露问题。 - 等待时间持续接近
reserve_pool_timeout:说明常态并发已经超过default_pool_size,此时才该扩容。
8. 本机实测记录(2026-11-09)
前面全是算术和文档。这一节是我在本机真跑出来的数字。
8.1 环境
| 项 | 值 |
|---|---|
| 宿主机 | Windows 11 + Docker Desktop 29.4.3(WSL2) |
| PgBouncer | 1.26.1(libevent 2.1.13-stable,OpenSSL 3.5.8) |
| 后端 | PostgreSQL 18.6(shared_buffers=256MB,max_connections=300) |
| 数据集 | pgbench -i -s 10(pgbench 标准表) |
| 测量时间 | 2026-11-09 23:00 前后(Asia/Shanghai) |
局限同前:单机容器 + WSL2,绝对数值不可外推;这里要验证的是行为与数量级关系。
8.2 默认 max_client_conn = 100 的真实报错
用 150 个并发客户端压一个默认配置的 PgBouncer:
$ pgbench -h pb -p 5432 -U postgres -c 150 -j 4 -T 5 postgres
pgbench: error: connection to server at "pb" (...) failed:
FATAL: no more connections allowed (max_client_conn)
pgbench: error: could not create connection for client 137
注意两个细节:
- 报错文本就是
FATAL: no more connections allowed (max_client_conn),不是 PostgreSQL 的错误——排查时不要往数据库侧找。 - 失败发生在 client 137 而不是 101:pgbench 是分批建连的,前面的客户端跑完释放了槽位。所以"默认 100 够不够"不能靠看这个数字,而要看峰值并发连接数。
8.3 池模式对照一:客户端不空闲时,两种模式几乎一样
固定 100 个客户端、default_pool_size = 20、标准 TPC-B(每客户端背靠背发事务,无思考时间):
pool_mode | tps | 平均延迟 | 失败事务 |
|---|---|---|---|
transaction | 1558.6 | 64.16 ms | 0 |
session | 1544.9 | 64.73 ms | 0 |
差 0.9%。没有思考时间时,session 并不吃亏——因为服务端连接从来没闲着,池化没有可回收的空隙。这个结果提醒我们:不要用"背靠背压测"去证明池模式的价值。
8.4 池模式对照二:加上 50ms 思考时间,差距变成 25 倍
同一个 PgBouncer、同样的 default_pool_size = 20,换成 500 个客户端 + 每条事务后 \sleep 50 ms(模拟真实 Web 请求的思考时间):
-- pgbench 自定义脚本 /tmp/think.sql
\set aid random(1,1000000)
SELECT abalance FROM pgbench_accounts WHERE aid=:aid;
\sleep 50 ms
pgbench -n -c 500 -j 8 -T 15 -f /tmp/think.sql
pool_mode | tps | 平均延迟 |
|---|---|---|
transaction | 9865.0 | 50.68 ms |
session | 394.2 | 1268.38 ms |
25 倍吞吐差、25 倍延迟差。
而且这个数字可以被第 2 节的 Little 定律精确解释:
session 模式的吞吐天花板 = 服务端连接数 / 单连接占用时长
= 20 / (0.0507 s) ≈ 394 tps ← 实测 394.2
transaction 模式 = 连接在每个事务间隙被归还
≈ 20 / 0.001 s 量级 → 上万 tps ← 实测 9865
session 的 1268ms 平均延迟也解释得通:500 个客户端抢 20 条连接,排队长度约 25,25 × 50ms ≈ 1250ms。这就是"连接池不是并发放大器"的量化版本——它只是把客户端的空闲时间从服务端连接里剥离出来。
8.5 控制台实测:SHOW POOLS 里该看哪两列
database | user | cl_active | cl_waiting | sv_active | sv_idle | maxwait | pool_mode
----------+----------+-----------+------------+-----------+---------+---------+-----------
postgres | postgres | 0 | 0 | 0 | 20 | 0 | session
- 压测结束后
sv_idle = 20:default_pool_size = 20被真实建立成了 20 条服务端连接,与公式一致。 cl_waiting与maxwait是判断"池不够"还是"模式选错"的关键:cl_waiting高而sv_idle = 20同时存在,几乎必然是session模式导致的(8.4 节的 1268ms 就是这么来的)。
9. 五个反模式
| 反模式 | 后果 | 修正 |
|---|---|---|
沿用默认 max_client_conn = 100 | 高峰期直接拒连 | 按应用连接池总和设置,并同步抬 LimitNOFILE |
沿用默认 pool_mode = session 服务有思考时间的应用 | 实测 500 客户端下吞吐降到 1/25(394 tps) | 改 transaction,逐个排查 session 依赖 |
| 每个租户一个数据库用户 | 服务端连接数 × 租户数 | 单用户 + schema 区分 |
为报表把 pool_size 调到 200 | 后端进程内存暴涨 | 给报表设 statement_timeout,单独开只读池 |
DDL 后不 RECONNECT | cached plan must not change result type | 迁移脚本末尾加 RECONNECT |
附录:数据来源与检索时间
检索时间统一为 2026-11-09(Asia/Shanghai)。
| 编号 | 来源 | URL | 本文引用的具体数据 |
|---|---|---|---|
| [1] | PgBouncer 官方文档《pgbouncer.ini》 | https://www.pgbouncer.org/config.html | pool_mode 默认 session;max_client_conn 默认 100;default_pool_size 默认 20;min_pool_size 0;reserve_pool_size 0;reserve_pool_timeout 5.0;listen_backlog 128;max_prepared_statements 200;stats_period 60;peer_id 上限 16383;fd 公式 max_client_conn + (max pool_size × total databases × total users) 及其冻结用户变体;默认跟踪参数清单(含 search_path 自 PG 18 起由服务端上报、scram_iterations 自 PG 16、default_transaction_read_only 自 PG 14);未上报参数会泄漏给其他客户端、server_reset_query_always 的作用;prepared statement 内存估算例(1000 客户端 / 200 唯一查询 / 5kB / pkt_buf 4096 ≈ 17MB);cached plan must not change result type 与 RECONNECT;so_reuseport 单线程单核、需 peering、需不同 unix_socket_dir/pidfile/logfile、无法再按 TCP 定向连接实例 |
核验说明:所有默认值、公式、行为约束均逐条比对官方文档(2026-11-09)。
第 8 节为本机实测(PgBouncer 1.26.1 + PostgreSQL 18.6,Docker/WSL2),其中:
FATAL: no more connections allowed (max_client_conn)为实测原始报错文本;- 两张吞吐对照表为实测,绝对数值只代表本机容器环境,但 8.4 节的 25 倍差距与 Little 定律理论值(20/0.0507 ≈ 394)高度吻合,说明结论在数量级上可信;
- 8.3 节"无思考时间时两种模式几乎一样(差 0.9%)"是一个反直觉但重要的实测结论,已在文中据此修正了对
session模式的评价。
第 2 节的 Little 定律估算、第 6 节的配置取值属于推导与建议,不是官方推荐值。