PostgreSQL VPS 连接池:避免 4 GB 内存耗尽
每个 PostgreSQL 连接都是独立进程,4 GB VPS 往往在达到 max_connections 前就触发 OOM。了解连接池如何减少真实后端进程,以及它会带来的会话与查询行为变化。
为什么小型 VPS 会先耗尽 RAM,而不是先达到 max_connections
在 VPS 上,Postgres 连接池不是提速技巧。它用于维持 4 GB 服务器的稳定运行,因为每个 PostgreSQL 连接都是一个独立的操作系统进程,并占用自己的私有内存。连接池在大量廉价的客户端连接后面,只保留少量固定数量的真实后端进程。
默认的 max_connections 是 100。这是上限,不是内存预算。PostgreSQL 不会检查您的计算机是否确实能够同时运行 100 个执行实际查询的后端进程,因此计算机通常会先失败。内核的内存不足(OOM)终止程序会选择一个进程;如果选中后端进程,PostgreSQL 会重启整个集群,以确保共享内存恢复安全。日志中会显示 server process (PID 1234) was terminated by signal 9: Killed,然后显示 terminating any other active server processes。所有打开的连接都会断开,包括原本正常的连接。
服务器会耗尽内存,是因为每个连接都是一个进程,并且 work_mem 是按每次排序或哈希操作分配的,而不是按连接分配的。这两种内存占用都会叠加。
每个连接都是一个进程,每个进程都会占用内存
PostgreSQL 为每个连接使用一个进程。客户端连接时,postmaster 会派生一个后端进程;客户端断开连接后,该后端进程才会退出。它不是线程。它有自己的页表、目录缓存和缓存的查询计划。连接访问的表越多、执行的不同查询越多,这些缓存就会增长。因此,在繁忙的 ORM 应用中,长连接的内存成本高于新建连接。
共享内存确实是共享的。shared_buffers 是整个集群的一次分配,并映射到每个后端进程。私有内存不会共享,因此 top 在这里会误导你:后端进程的常驻集大小(RSS)包含该进程访问过的共享页面,所以将 50 个后端进程的 RSS 相加,会把 shared_buffers 计算 50 次。
应改为测量私有部分。比例集大小(PSS)会将每个共享页面按映射它的进程数进行分摊;唯一集大小(USS)只统计完全属于该进程的页面。
sudo apt update
sudo apt install -y smem
sudo smem -k -P '^postgres'USS 列表示该后端进程退出后可以释放的内存。这才是每个连接的实际成本。公开资料通常显示,空闲后端进程占用的内存为个位数 MB,而执行过大型 ORM 查询的后端进程可能达到数倍。应将这些数据视为公开资料中的典型值,而不是你的实际值。唯一值得用于规划的数值,是你的服务器在实际工作负载下测得的数值。
有一项每会话分配很容易被忽略。temp_buffers 默认值为 8MB,并且会在会话首次访问临时表时按会话分配。在会话结束前,这部分内存不会归还。
work_mem 按操作分配,而不是按连接分配
这正是很多人算错的地方。work_mem 的默认值为 4MB,PostgreSQL 文档明确说明了其含义:“复杂查询可能会同时执行多个排序和哈希操作。在开始将数据写入临时文件之前,通常允许每个操作使用不超过此值的内存。”包含 3 个排序节点的执行计划,可以在同一个后端进程中同时使用 3 倍的 work_mem。
哈希操作允许使用更多内存。hash_mem_multiplier 的默认值为 2.0,因此哈希连接或哈希聚合可以使用 work_mem 的 2 倍内存;在默认设置下就是 8MB。并行查询还会进一步放大内存使用量,因为每个并行工作进程都是拥有独立内存限额的另一个进程。
以 4 GB VPS 为例进行计算。将 shared_buffers 设置为 1 GB,保持 work_mem 为 4MB,并让 100 个连接各执行一个包含 2 个哈希节点的查询。这样就是 100 乘以 16MB,即在 1 GB 共享缓冲区之外额外使用 1.6 GB 私有内存;这还没有计算页缓存和服务器上的其他内存使用。现在,由于服务器还有空闲 RAM,将 work_mem 提高到 64MB。同样的 100 个连接就意味着 100 乘以 256MB。系统不会发出警告。你会在 OOM killer 执行时才发现问题。
你可以检查 work_mem 是否过小,而不是凭猜测判断。将 log_temp_files = 0 设置到 postgresql.conf 中,然后重新加载配置。此后,每次向磁盘溢出数据时,都会写入一行日志,列出文件名和文件大小,例如 temporary file: path "base/pgsql_tmp/pgsql_tmp1234.0", size 20971520。频繁发生溢出,说明提高 work_mem 可能有帮助。没有溢出时,提高该值不会带来收益,只会占用你没有的内存。
真正造成问题的连接池计算
没人一开始就打算打开 240 个连接。他们配置了一个包含 20 个连接的连接池,然后在多个位置运行应用程序。
The data behind this chart
[
{
"config": "1 worker",
"backends": 20
},
{
"config": "4 web workers",
"backends": 80
},
{
"config": "4 web + 2 background",
"backends": 120
},
{
"config": "2 hosts x 4 workers",
"backends": 160
},
{
"config": "3 hosts x 4 workers",
"backends": 240
}
]4 个 Gunicorn worker,每个都持有一个包含 20 个连接的连接池,共会请求 80 个后端连接。再增加 2 个后台任务 worker 后,这个数量就是 120。扩展到 3 hosts x 4 workers 后,应用程序会针对容量为 100 的 max_connections 请求 240 个后端连接。上述 5 种配置中,没有任何一种在单个位置上存在错误。连接池按进程分配,应用程序的任何部分都看不到总连接数。
库的默认值也会造成同样的问题。SQLAlchemy 的 QueuePool 默认值为 pool_size=5,并配合 max_overflow=10,因此每个进程会使用 15 个连接。HikariCP 的默认值为 10。Django 在 5.1 之前没有内置连接池,每个 worker 进程使用一个连接。这就是为什么 Django 应用通常较晚遇到这个问题,并且在有人设置 CONN_MAX_AGE 或启用较新的 "pool": True 选项后,连接数会一次性增加。如果您在 Gunicorn 和 nginx 后运行 Django 应用,应乘以 Gunicorn worker 数量,而不是服务器数量。
VPS 上的 Postgres 连接池实际改变了什么
连接池程序一侧使用 PostgreSQL 线协议与应用通信,另一侧维护少量真实的服务器连接。它不会让任何查询更快,而是改变连接成本由谁承担,以及实际后端进程的数量。
有两点会得到改善。建立连接不再需要执行 fork,也不再需要进行填充空后端缓存所需的系统目录查询,因为连接池程序会直接响应客户端的连接请求。更重要的是,真实后端进程的数量不再与应用连接数同步增长,因此 500 个客户端可以共享 20 个后端进程。
等待本身就是这项功能,也是人们最难接受的部分。没有连接池时,500 个并发查询都会获得一个后端,并在两个 CPU 核心上同时运行。因此每个查询都会变慢,内存也会在同一时间被全部占用。使用连接池后,20 个查询运行,其余查询等待几毫秒。这样每个正在运行的查询都能获得实际的 CPU 资源份额,并更快完成。在小型连接池前设置队列,效果优于在大型连接池前不设置队列。
连接池不会限制机器上的其他资源。如果 Postgres 与应用服务器或同一 VPS 上的向量数据库共享 VPS,连接池只能保护 Postgres 不受应用影响,除此之外没有其他作用。还应为其他服务设置硬上限:可以使用systemd 限制服务可使用的内存和 CPU,这样单个失控进程就不会连带使数据库停止运行。数据库本身所在的位置会影响这些限制的设置方式。这也是在 Docker 中运行 Postgres 与直接在主机上运行 Postgres之间的实际差异。
会话池与事务池
一个设置决定其他所有行为,它就是 pool_mode。
在会话池模式下,服务器连接会在客户端连接的整个生命周期内分配给该客户端,并在客户端断开连接时释放。所有功能都能正常工作,因为池化器只是一个普通代理。你只节省了建立连接的开销,没有其他收益。如果应用打开 200 个连接,仍然需要 200 个后端连接。
在事务池模式下,服务器连接只会在一个事务的持续期间分配给客户端。在 COMMIT 或 ROLLBACK 时,连接会返回池中,随后由下一个等待的客户端获取。这就是将 500 个客户端转换为 20 个后端连接的机制。它也会导致一些功能失效,而且这是设计使然:下一条语句可能在与上一条语句不同的后端连接上执行。
PgBouncer 的默认模式是 pool_mode = session。安装后不做任何修改,你只能获得低成本这一半,而得不到池化的收益。第三种模式 statement 会在每条语句执行后立即返回连接,并拒绝多语句事务。除非你明确知道需要它的原因,否则不要使用此模式。
事务模式会破坏什么,以及原因
以下功能都会失败,原因相同:状态保存在单个后端中,而事务池化不保证两次使用同一个后端。
- 会话级别的
SET和RESET。SET search_path、SET statement_timeout、SET TIME ZONE和SET ROLE会在执行语句的后端上生效,并在下一次事务开始时消失。请在显式事务中使用SET LOCAL。它的作用域限定在该事务内,因此是安全的。 LISTEN。通知由执行LISTEN的后端负责发送。事务结束后,该后端会立即分配给其他客户端。NOTIFY在事务模式下仍可正常工作,因此这种故障很容易混淆:发送成功,但永远收不到通知。如果需要LISTEN,请额外建立一个直连 5432 端口的连接,绕过连接池。- 会话级 advisory lock。
pg_advisory_lock()由会话持有,并在会话结束时释放。在事务池化下,解锁调用会在另一个后端上执行,因此该锁会一直持有,直到 PgBouncer 回收对应的服务器连接。默认情况下,这发生在server_lifetime后,即 1 小时后。请使用pg_advisory_xact_lock()。它会由获取锁的同一个后端在事务结束时释放。 PREPARE和DEALLOCATE这两个 SQL 语句。在事务模式下始终不可用。WITH HOLD游标,以及任何预期跨事务继续存在的服务器端游标。- 需要在提交后继续存在的临时表。
CREATE TEMP TABLE ... ON COMMIT PRESERVE ROWS会将表放入某个后端的临时架构中,而下一次事务可能不会使用该后端。 LOAD。
协议级预处理语句是唯一发生变化的项目。PgBouncer 1.21.0 增加了事务模式下对它们的支持,1.24.0 通过将 max_prepared_statements 设置为 200,默认启用了该功能。旧版本将其设为 0,也就是关闭。Ubuntu 24.04 附带 PgBouncer 1.22.0,因此功能已存在,但必须自行设置 max_prepared_statements。如果不确定当前构建版本的行为,最安全的做法是在客户端进行设置:将 prepare_threshold 设为 None 后,psycopg 3 会停止使用服务器端预处理语句。
Django 对此有自己的表述。文档指出,“在事务池化模式下使用连接池(例如 PgBouncer)时,必须为该连接禁用服务器端游标”,因为“服务器端游标只能在创建它的连接中访问”。请在该数据库条目中将 DISABLE_SERVER_SIDE_CURSORS 设置为 True,否则每次 .iterator() 调用都可能间歇性失败,并且只会在负载较高时出现。
事务模式值得采用,但它也是一项约定。阅读上述列表,确认 ORM 和后台任务库符合这些限制,然后再切换。
安装 PgBouncer 并让应用连接到它
以下配置需要在您自己的服务器上执行:Ubuntu 24.04,且 PostgreSQL 已监听 127.0.0.1 的 5432 端口。
sudo apt update
sudo apt install -y pgbouncer
pgbouncer --versionUbuntu 24.04 提供 PgBouncer 1.22.0。2026 年 8 月,上游版本为 1.25.2。请检查您安装的版本,因为上文所述的预处理语句行为取决于该版本。
创建一个仅用于登录 PgBouncer 管理控制台的角色,然后生成密码文件。PgBouncer 需要从 pg_authid 中获取 SCRAM(加盐质询响应身份验证机制)密钥,且只有超级用户才能读取该表。
sudo -u postgres psql -c "CREATE ROLE pgb_admin LOGIN PASSWORD 'change-this'"
sudo -u postgres psql -At -c \
'SELECT format($$"%s" "%s"$$, rolname, rolpassword) FROM pg_authid WHERE rolpassword IS NOT NULL' \
> /tmp/userlist.txt
sudo install -o postgres -g postgres -m 640 /tmp/userlist.txt /etc/pgbouncer/userlist.txt
rm /tmp/userlist.txt直接复制密钥而不是重新输入密码,是此配置能够工作的原因。只有同时满足以下条件时,PgBouncer 才能使用 SCRAM 密钥登录 PostgreSQL:客户端也使用 SCRAM 完成身份验证;密码文件中的密钥与 pg_authid 中的密钥逐字节一致(盐值和迭代次数都相同,而不只是密码相同);并且 [databases] 行没有固定 user=。在该行中添加 user=appuser 后,PgBouncer 将需要明文密码。使用 systemctl show pgbouncer -p User 确认文件所有者与服务运行所用的账户一致。在 PostgreSQL 中轮换密码后,必须重新生成此文件,否则下一次连接将返回 password authentication failed。
现在写入 /etc/pgbouncer/pgbouncer.ini。
[databases]
appdb = host=127.0.0.1 port=5432 dbname=appdb
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
admin_users = pgb_admin
pool_mode = transaction
max_client_conn = 500
default_pool_size = 20
min_pool_size = 5
max_db_connections = 80
max_prepared_statements = 200
ignore_startup_parameters = extra_float_digitslisten_addr = 127.0.0.1 使连接池保持在公网之外。这一点很重要,因为可从外部访问的连接池就是一个您原本不打算公开的身份验证端点。max_client_conn 是 PgBouncer 接受的应用连接数,开销很小,因此可以设置得较大。default_pool_size 是单个数据库和用户组合可以持有的实际后端连接数,这是开销较大的数值。max_db_connections 将整个数据库限制为 80 个连接,并在 max_connections 以内为 psql、备份和监控留出空间。ignore_startup_parameters = extra_float_digits 可避免 PgBouncer 拒绝在连接时发送该参数的驱动程序,包括 JDBC 驱动程序。
sudo systemctl restart pgbouncer
sudo systemctl status pgbouncer --no-pager
sudo journalctl -u pgbouncer -n 20 --no-pager正常启动时,日志会记录 PgBouncer 正在监听 127.0.0.1:6432。因密码文件启动失败时,日志会记录无法读取的路径。这几乎总是权限模式或所有权问题,而不是语法问题。然后将应用的连接字符串中的端口从 5432 改为 6432,并重启应用。应用中的其他配置无需更改。
如何检查连接池是否正常工作
PgBouncer 通过名为 pgbouncer 的虚拟数据库提供管理控制台。
psql -h 127.0.0.1 -p 6432 -U pgb_admin pgbouncerSHOW POOLS;
SHOW STATS;
SHOW CLIENTS;需要重点关注 SHOW POOLS。cl_active 表示当前已连接到服务器连接的客户端,cl_waiting 表示正在等待服务器连接的客户端,sv_active 和 sv_idle 表示当前正在使用和空闲的实际后端连接,maxwait 表示队列最前端客户端的等待时间,单位为秒。正常负载下,健康状态意味着 cl_waiting 为 0 且 maxwait 为 0。如果 maxwait 超过 1 或 2 秒并持续上升,说明连接池过小,或查询速度过慢。这两种情况需要采用不同的修复方法。
提高 default_pool_size 之前,先确认是哪种情况。
SELECT state, count(*) FROM pg_stat_activity
WHERE backend_type = 'client backend' GROUP BY state;如果大多数后端连接处于 idle in transaction,问题不在连接池大小。应用正在开启事务,然后在事务中执行耗时操作,例如 HTTP 调用,因此每个后端连接都被占用,却没有运行查询。idle_in_transaction_session_timeout 可以终止这些连接,但根本解决方案在应用代码中。反之,如果所有后端连接都处于 active,说明连接池确实已饱和。在增加连接数之前,应先对查询执行 EXPLAIN (ANALYZE, BUFFERS)。
关于容量设置,最常引用的起始值是 HikariCP 启发式公式:核心数的约 2 倍加 1。对于 2 核 VPS,该值为 5。将其作为公开的起始参考值,把 default_pool_size 设在接近该值的位置,再根据 maxwait 进行调整。小型连接池可能感觉不够用,但通常测得的性能更好,因为排队中的后端连接不会消耗资源,而运行中的后端连接会消耗 CPU、内存,并增加锁竞争。
在 PgBouncer、PgDog 和 Pgpool-II 之间进行选择
对于常见场景,PgBouncer 是合适的选择:一台 PostgreSQL 服务器、一台 VPS,以及一个创建连接数超过主机承载能力的应用。它只负责一项工作,配置是单个 ini 文件,并且 Debian 和 Ubuntu 都提供软件包。它在单个线程中处理连接,对于 VPS 规模的负载已经足够;只有在更大型的机器上,单线程才可能成为上限。
当路由决策需要与连接池位于同一个网络跳点时,可以考虑 PgDog。PgDog 将自身定位为用于扩展 PostgreSQL 的代理,使用 Rust 编写。它支持事务池和会话池,还能通过解析查询实现读写分离,并支持带多分片路由和两阶段提交的分片。若您有一个主库和一个或多个副本,并希望将读取请求发送到副本,而不让应用了解副本的存在,可以选择它。需要注意两点。它采用 AGPLv3 许可证,因此在投入生产前,应先与公司中负责该决策的人员确认网络使用条款;项目自身的立场是,内部使用和私有修改不会产生源代码公开义务。它也相对较新,每周发布版本,版本号仍为 0.x,因此应固定到发布标签,而不要跟随 main。
git clone https://github.com/pgdogdev/pgdog
cd pgdog
cargo build --release
./target/release/pgdog --config pgdog.toml --users users.toml从源代码构建需要当前的稳定版 Rust 工具链、CMake 和 C/C++ 编译器。发布页面还提供预构建的 Linux 二进制文件和 Debian 软件包,并在 ghcr.io/pgdogdev/pgdog 提供容器镜像。配置分为两个文件。第一个文件包含常规设置,以及每个数据库对应的一项配置。这里使用内联表组成的 TOML 数组,以便清楚区分这两种形式。
databases = [
{ name = "appdb", host = "127.0.0.1" },
]
[general]
port = 6432
default_pool_size = 10第二个文件使用相同的数组形式,每个用户对应一项配置。
users = [
{ name = "appuser", database = "appdb", password = "change-this" },
]PgDog 默认监听 6432 端口,与 PgBouncer 使用相同的端口,因此两者不能在同一台主机上同时占用默认端口。
截至 2026 年 6 月,Pgpool-II 的版本为 4.7.2。它提供连接池和负载均衡,并通过 watchdog 实现自动故障转移。额外功能也会带来额外的故障模式,因此在选择前必须理解其连接池模型。Pgpool-II 会预先派生 num_init_children 个子进程,每个子进程最多缓存 max_pool 个服务器连接,因此后端连接上限为 num_init_children 乘以 max_pool。每个子进程一次只能服务一个客户端,因此可接受的客户端数量等于 num_init_children,并且在启动时固定;空闲客户端仍会占用一个子进程。将 num_init_children 设为 100、将 max_pool 设为 4,就相当于允许使用 400 个后端连接,而这正是安装连接池要解决的问题。若需要故障转移和查询路由,可以选择 Pgpool-II,然后仔细完成上述乘法计算。如果只想减少后端连接数,它提供的机制就超出了实际需求。
托管代理与自托管方案
托管平台会将此功能作为独立产品销售。AWS 在 RDS 前面部署 RDS Proxy,Supabase 在 Supabase Postgres 前面部署自有连接池 Supavisor。两者都能完成本文所述的工作:低成本地保持客户端连接,并将这些连接分配给数量更少的真实后端连接。Supavisor 是开源软件,也支持自托管,因此这不是专有方案与免费方案之间的选择。
托管代理的自托管等价方案并不是另一种理念,而是同一理念,只是配置文件由您掌控:在与数据库相同的 VPS 上以事务模式运行 PgBouncer,并监听 127.0.0.1。两者确实存在两个差异。托管代理位于另一个网络跃点,因此会增加延迟;即使数据库在其后方重启,它仍会继续保持客户端连接。部署在数据库主机上的 PgBouncer 只增加一次回环跃点,开销几乎可以忽略;但主机停止时,PgBouncer 也会停止。如果您希望实现重启后继续工作的行为,还需要故障转移机制。这时,Pgpool-II 的 watchdog 或 PgDog 的健康检查才开始体现其复杂性的价值。
还应考虑另一种方案。如果连接数是让部署变复杂的主要因素,嵌入式数据库不需要连接池模型,因为它是进程内的库,而不是监听端口的服务器。对于写入量适中的单应用服务器,在 VPS 上的生产环境中运行 SQLite 可以直接消除整个问题,而不必管理连接池。需要真正的数据库服务器时,请先确定连接池大小,再确定机器规格。
FAQ
如果应用已经有连接池,还需要 PgBouncer 吗?
通常仍然需要,因为应用连接池按进程独立存在,无法看到其他进程的连接。4 个 Gunicorn worker 各自持有一个包含 20 个请求 80 后端连接的连接池,再加上 2 个后台 worker 后,总数为 120。只有 PgBouncer 能看到总连接数并对其设置上限。合适的配置是同时使用两者:每个 worker 内部保留一个较小的连接池,使请求无需为 TCP 连接付出开销;再让处于 transaction 模式的 PgBouncer 限制其后的实际后端连接数。
切换 PgBouncer 到 transaction 模式后,具体哪些功能会失效?
任何在事务之间依赖同一后端保存状态的功能都会失效。这包括会话级 SET 和 RESET、LISTEN、WITH HOLD 游标,SQL PREPARE 和 DEALLOCATE 语句,会话级 advisory lock,需要在 commit 后继续存在的临时表,以及 LOAD。NOTIFY 仍可正常工作,因此损坏的 LISTEN 看起来更像传递错误,而不是连接池错误。在 Django 中,将 DISABLE_SERVER_SIDE_CURSORS 设置为 True。在 psycopg 3 中,将 prepare_threshold 设置为 None,或者使用 PgBouncer 1.22 或更高版本,并将 max_prepared_statements 设置为大于 0 的值。将 pg_advisory_lock() 替换为 pg_advisory_xact_lock()。
在 2 核 VPS 上,default_pool_size 应设置多大?
应比直觉认为的值更小。广泛使用的 HikariCP 经验公式约为核心数的 2 倍加 1,因此 2 核系统约为 5;但这只是起点,不是最终答案。设置后,在实际负载下读取 SHOW POOLS 中的 maxwait 和 cl_waiting。两者均为 0 表示连接池大小足够。不断上升的 maxwait 表示客户端正在排队。在提高数值前,先检查 pg_stat_activity:卡在 idle in transaction 的后端通常表示应用存在错误,增加连接数只会掩盖问题。
应使用 PgBouncer 还是 PgDog?
如果是在一台 VPS 上运行一个 PostgreSQL 服务器,应使用 PgBouncer;大多数部署都属于这种情况。Ubuntu 提供了 PgBouncer 软件包,其行为有完善文档说明,完整配置只需一个 ini 文件。
如果需要在同一层同时处理副本间的读写分离或分片,应使用 PgDog 进行连接池管理,这样应用无需了解拓扑结构。在确定使用 PgDog 前,应与负责所在组织许可事务的人员确认 AGPLv3 的适用问题,并固定使用某个具体版本,因为该项目目前仍使用 0.x 版本号,并且每周发布新版本。