为什么 PHP 站点的数据库连接会成为瓶颈
很多个人站长从 MySQL 迁到 PostgreSQL 之后,会发现一个反直觉的现象:CPU 明明不忙、磁盘 IO 也很低,但网站一到晚高峰就开始报 FATAL: sorry, too many clients already。原因不在数据库算力,而在连接数。
PostgreSQL 是"每连接一个进程"的架构,不像 MySQL 只有一个线程。每个客户端连接都会 fork 出一个后端进程,每个后端进程最少占用几 MB 到十几 MB 内存,还要参与进程调度。当你的 PHP-FPM 开了 50 个 worker,每个 worker 又各自持有一个数据库连接,再叠加上定时任务、爬虫、后台管理,几百个连接瞬间把默认的 max_connections = 100 打满,数据库直接拒绝新连接。
PgBouncer 就是解决这个问题的:它是一个轻量的连接池中间件,位于应用和 PostgreSQL 之间,把成百上千个应用连接复用到几十个真实的数据库连接上。本文记录的是我在一台 2G 内存的小型 VPS 上,给一个日 PV 五万左右的站做 PgBouncer 落地的完整过程。
PgBouncer 的三种池模式,选错等于白装
这是最关键的一节。PgBouncer 有三种连接复用策略,理解它们才能选对:
- Session 模式:应用连上来后,独占到断开为止。这只是省了 TCP 握手和认证开销,连接数并没有真正减少。基本没用,不推荐。
- Transaction 模式:一个事务结束就归还连接。连接利用率最高,是绝大多数场景的首选。
- Statement 模式:每条语句结束就归还。更激进,但纯 SQL 才安全,任何依赖会话状态的用法都会出错。
⚠️ Transaction 模式的限制:因为连接会被换手,所以不能依赖会话级状态。这意味着 prepered statement(预处理语句)默认用不了,除非开启 server_reset_query_always 和对应的 max_prepared_statements;SET 语句、LISTEN/NOTIFY、advisory lock、临时表,都会在事务结束后失效。
对绝大多数 PHP 应用来说,只要框架不是深度依赖会话级 SET,Transaction 模式就能用。Laravel、ThinkPHP 这类框架走的都是"每次请求建立连接、请求结束释放"的短连接模型,天然适配。
安装与最小可用配置
Debian / Ubuntu:
apt install pgbouncer -yRHEL 系:
dnf install pgbouncer -y主配置文件通常在 /etc/pgbouncer/pgbouncer.ini。一份可以直接用的最小配置:
[databases]
# 应用连的库名 = 真实地址 数据库名
mydb = host=127.0.0.1 port=5432 dbname=mydb
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
; 关键:选择事务池模式
pool_mode = transaction
; 池大小:应用侧最多允许多少个"虚拟连接"
max_client_conn = 500
; 每个用户/库组合,对后端最多开多少真实连接
default_pool_size = 20
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3
max_db_connections = 25
; 超时
server_idle_timeout = 600
client_idle_timeout = 0
; 日志
logfile = /var/log/postgresql/pgbouncer.log
pidfile = /var/run/postgresql/pgbouncer.pid
admin_users = postgres
stats_users = postgres
; 安全:不用 unix socket 时,务必禁止非本机
listen_addr = 127.0.0.1几个参数的真实含义,很多人抄了但没搞懂:
max_client_conn是应用侧能同时连 PgBouncer 的数量上限,可以设得很大(它只是内存里的小对象,开销极低)。default_pool_size是每个 (user, database) 组合,PgBouncer 对真实 PostgreSQL 打开的最大连接数。这才是压制数据库连接数的关键旋钮。max_db_connections是整个数据库维度(跨所有用户)的硬上限。
经验值:default_pool_size 设成数据库 CPU 核数的 2~4 倍,让每个连接都有活干又不至于上下文切换过载。4 核机器给 16~25 比较合适。
配置认证文件
auth_type = md5 时,需要在 userlist.txt 里写用户和密码的 md5 哈希。最稳的做法是直接问 PostgreSQL 要:
# 取出 postgresql 存储的 md5 口令串
sudo -u postgres psql -c "SELECT '\"' || usename || '\" \"' || passwd || '\"' FROM pg_shadow WHERE usename='myapp';" -t -A > /etc/pgbouncer/userlist.txt
chmod 600 /etc/pgbouncer/userlist.txt如果你只想快速验证,也可以先在 pg_hba.conf 里对 127.0.0.1 允许 trust,或者用 auth_type = trust 本地调试,但那绝不能在暴露给应用的场景下使用。
启动、验证与"到底有没有生效"
systemctl enable --now pgbouncer
systemctl status pgbouncer
# 用 psql 连到 pgbouncer 的管理库(注意端口是 6432)
psql -h 127.0.0.1 -p 6432 -U postgres pgbouncer管理库里有几个视图是排查利器:
SHOW POOLS;
SHOW STATS;
SHOW CLIENTS;
SHOW SERVERS;
SHOW DATABASES;SHOW POOLS 输出里的 cl_active 是当前活跃的应用连接,sv_active 是真正占用的后端连接,sv_idle 是空闲后端。判断池有没有效果,就看 cl_active 远大于 sv_active + sv_idle 之和——那说明大量应用连接被成功复用了。如果两者接近,说明池没起作用,多半是 pool_mode 还是 session,或者应用用了长连接把连接占死了。
SHOW STATS 里的 avg_query_count、avg_wait_time 能看到等待情况。avg_wait_time 如果持续大于 0,说明池太小,应用在排队等连接,这时才需要考虑调大 default_pool_size。
应用侧改一行端口就能切换
PgBouncer 对应用是透明的——它假装自己是一个 PostgreSQL。所以 PHP 侧只需要把连接端口从 5432 改成 6432,主库地址改成本机 127.0.0.1:
// PDO 示例
$dsn = 'pgsql:host=127.0.0.1;port=6432;dbname=mydb';
$pdo = new PDO($dsn, 'myapp', 'password');⚠️ 一个非常常见的坑:应用不要再用连接池了。像 PHP 的 PDO 永连接(PDO::ATTR_PERSISTENT)或者框架自带的连接池,跟 PgBouncer 叠加会互相干扰,容易出现"连接被 PgBouncer 换手后状态错乱"。用 PgBouncer 时,应用侧保持最普通的一次性连接即可。
切换与回滚:别让网站挂在这半小时
最稳的切换顺序是:
- 先只改一个测试脚本连 6432,跑几条读写验证通过;
- 修改应用配置,但保留旧配置文件的备份;
- 滚动重启 PHP-FPM(
systemctl reload php8.2-fpm),让新 worker 用新配置; - 观察
SHOW POOLS和网站错误日志 15 分钟; - 确认无误后,把 5432 从应用配置里彻底移除,只留 PgBouncer。
回滚就是改回 5432 再 reload。因为 PgBouncer 不改数据库本身,回滚几乎零成本——这也是我喜欢把它放在应用和数据库之间的原因。
几个进阶细节
给 pg_dump 之类工具单独开一条路
备份和 schema 迁移这类工具重度依赖会话状态,走 Transaction 池会出错。正确做法是在 [databases] 里为它单独定义一个走 session 池的入口:
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb
mydb_session = host=127.0.0.1 port=5432 dbname=mydb pool_mode=session备份时连 mydb_session,应用连 mydb,互不影响。
监控告警
PgBouncer 自身不导出 Prometheus 指标,但可以写个 cron 定期抓 SHOW STATS,把 avg_wait_time、total_xact_count 上报。我的做法更简单:每分钟采样一次,若 avg_wait_time > 50(毫秒)就发一条告警,提示池可能太小。
安全边界
listen_addr 永远写 127.0.0.1,或者内网 IP,绝不监听 0.0.0.0。PgBouncer 是数据库的大门,一旦大门对着公网开着,等于把数据库直接摆到了互联网上。同理 admin_users 只给本机管理员账号。
和 PgCat、pgbouncer 之外的选择对比
PgBouncer 不是唯一方案,了解同类工具能帮你判断是否选对了:
- PgBouncer:C 语言写的单进程事件循环,极轻量,配置简单,最成熟。缺点是单进程无法利用多核,超高并发时可能成为瓶颈,但那个量级个人站十年也碰不到。
- PgCat:Rust 写的现代替代品,支持分片、读写分离、多线程。如果你的场景要同时做"池 + 读写分离",它一步到位。缺点是相对年轻,文档和踩坑经验少。
- Odyssey:Yandex 出品,多线程,性能强,但对事务池里的预处理语句支持更挑剔。
- 应用层连接池:像 Java 的 HikariCP,绕过中间件,但 PHP 的短生命周期模型根本用不起来,每次请求结束进程就销毁了。
对 PHP 站点,PgBouncer 依然是默认答案。只有当你的站已经大到单进程 PgBouncer 的 CPU 成为瓶颈,才考虑换 PgCat 或 Odyssey。
读写分离:PgBouncer 能顺带解决吗
很多人会问:既然有了连接池,能不能顺便让读请求走从库?答案是可以,但 PgBouncer 本身不做 SQL 解析,它无法判断一条语句是读还是写。所以常见的做法是定义两个入口,让应用自己决定连哪个:
[databases]
mydb_rw = host=127.0.0.1 port=6432 dbname=mydb
mydb_ro = host=10.0.0.12 port=6432 dbname=mydb pool_mode=transaction写操作连 mydb_rw(指向主库),明确只读的查询连 mydb_ro(指向从库)。这需要应用代码里显式区分,不能靠中间件自动完成。如果你的框架支持多个连接配置(如 Laravel 的读写连接),配置起来会顺很多。
⚠️ 注意主从延迟:写到主库后立刻去从库读,可能读不到刚写的数据。所以"写后立即读"的场景必须强行走主库,这点要写进业务逻辑。
容量规划:这台小机器到底能扛多少
PgBouncer 自身内存开销极小——每个应用连接约占几 KB 到几十 KB,一个进程总共几十 MB 就能管理上千个客户端连接。真正吃资源的是它背后的 PostgreSQL。所以规划时应该反过来算:
- 先确定 PostgreSQL 这台机器的内存和 CPU;
- 按"每个真实连接约 10MB 内存 + 一个进程调度开销"估算后端连接上限;
- 把
default_pool_size设在这个上限之内,留 20% 余量给管理连接和备份。
举个实例:2G 内存的 VPS 跑 PostgreSQL,shared_buffers 给了 512MB,剩下约 1.2G 给后端进程,按 10MB 算能开 120 个连接,但我只把 default_pool_size 设成 20——因为池的意义就是让少量连接高效复用,而不是把连接数顶满。
总结:什么情况下该上 PgBouncer
如果你的 PostgreSQL 报过连接数不够、或者 PHP-FPM 一扩容数据库连接就爆,PgBouncer 是最低成本的解法之一。它不占多少资源,一个进程几 MB 内存,却能把你几百个应用连接压到几十个真实连接。记住三条:池模式选 transaction、default_pool_size 按 CPU 核数的 2~4 倍给、listen_addr 只开本机。剩下的调优,等 avg_wait_time 真的上去了再说,不要一上来就把池开得老大,那等于没做池。