CoreRanch
返回文章列表

PgBouncer 容量数学:max_client_conn、文件描述符与 pool_size 的正确算法

2026-11-09 #数据库

一句话结论:PostgreSQL 每个连接是一个独立的操作系统进程,连接数本质是内存与调度成本;PgBouncer 的价值不是"支持更多客户端",而是把服务端连接数从"客户端数"解耦成"并发事务数"。算错的地方几乎总在文件描述符预算,而不是池大小。

0. 官方默认值:先看清出厂设置

下表来自 PgBouncer 官方配置文档,检索时间 2026-11-09[1]。

配置项默认值含义
pool_modesession客户端断开才归还服务端连接
max_client_conn100允许的客户端连接上限
default_pool_size20每个 user/database 对的服务端连接上限
min_pool_size0池低于该值时补连接(默认关闭)
reserve_pool_size0额外预留连接(默认关闭)
reserve_pool_timeout5.0 秒超过该等待时间才动用预留池
listen_backlog128未完成握手的连接队列长度
max_prepared_statements200每条服务端连接上 LRU 缓存的 prepared statement 数
stats_period60 秒SHOW 命令里平均值的刷新周期
peer_id0(禁用)对等实例编号,上限 16383

两个默认值会直接坑到第一次上线:

  1. max_client_conn = 100。应用侧连接池配置成 500 时,PgBouncer 会直接拒绝,报 no more connections allowed (max_client_conn)。这不是数据库问题,是 PgBouncer 自己的上限。
  2. 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/APIsession 级特性不可用
statement单条语句结束纯自动提交的简单查询跨语句事务被禁止

transaction 模式是生产主流,但它改变了"连接"的语义,以下三类用法会出问题:

  1. 会话级临时表 / SET 变量:下一个事务可能落在另一条服务端连接上,状态丢失。
  2. LISTEN / NOTIFY:需要长连接,transaction 模式下不可靠。
  3. 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)
PgBouncer1.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_modetps平均延迟失败事务
transaction1558.664.16 ms0
session1544.964.73 ms0

差 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_modetps平均延迟
transaction9865.050.68 ms
session394.21268.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 后不 RECONNECTcached plan must not change result type迁移脚本末尾加 RECONNECT

附录:数据来源与检索时间

检索时间统一为 2026-11-09(Asia/Shanghai)。

编号来源URL本文引用的具体数据
[1]PgBouncer 官方文档《pgbouncer.ini》https://www.pgbouncer.org/config.htmlpool_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 节的配置取值属于推导与建议,不是官方推荐值。