选 PgBouncer 的池化模式,实际上是在回答一个问题:一个 PostgreSQL 后端进程的会话状态,允许被几个客户端共享? session 模式的答案是「一个,直到客户端断开」,transaction 模式是「每笔事务换一个」,statement 模式是「每条语句换一个」。真正咬人的不是吞吐差异,而是你的 ORM、驱动、甚至某一行 SET search_path,有没有悄悄依赖了跨越事务边界的会话状态。PgBouncer 官方的 SQL feature map 已经把 transaction 模式下标记为 Never 的特性一条条列了出来——上线前把那张表读一遍,比读十篇教程有用。
撰稿时的当前版本是 1.25.2,发布于 2026 年 5 月 8 日,修复了 CVE-2026-6664 至 CVE-2026-6667 四个安全问题,其中包括未认证客户端可通过畸形 SCRAM 报文触发的整数溢出,以及 KILL_CLIENT 管理命令缺少授权检查。如果你还停在这之前的版本,先升级再谈调优。
三种模式的切分点
官方文档对三种模式的定义只有三十来个词,短到容易被跳过语义:
- session:客户端断开后,服务端连接才归还池子。这是默认值,所有 PostgreSQL 特性都可用。
- transaction:事务结束后归还。
- statement:单条语句结束后归还,并且跨多条语句的事务在这个模式下被直接禁止。
最后那半句常被误读。statement 模式不是「更激进的 transaction 模式」,它会主动报错拒绝多语句事务。文档写明它的目标场景是 PL/Proxy,本质是强制客户端处于 autocommit。除非你在做那类东西,否则这个模式不该出现在业务库前面。
被低估的逃生口:按库、按用户覆盖 pool_mode
pool_mode 不是一个全局二选一的决定。它可以在 [databases] 段里按库覆盖,也可以在 [users] 段里按用户覆盖。所以「transaction 还是 session」的正确答案通常是「都要」。
我推荐的默认形态:主流量指向一个 transaction 模式的池;同时定义第二个 [databases] 条目——同一台主机、同一个真实库、不同的别名——跑 session 模式,专供真正需要会话状态的组件使用:持有 advisory lock 的后台 worker、任何做 LISTEN 的进程、迁移工具、长分析会话。给它一个很小且显式限定的 pool_size,主流量那边继续吃多路复用的收益。这比两种常见的失败姿势都好:要么全量退回 session 模式,池化的意义基本没了;要么全量压进 transaction 模式,然后带着偶发的正确性 bug 上线。
transaction 模式下会坏的六类东西
SET / RESET 与会话级 GUC
标记为 Never。原因是机械性的:SET statement_timeout = '5s' 落在执行那条语句的后端上,下一笔事务可能换了条连接,设置就凭空消失。
但 PgBouncer 并非完全不管 GUC。它默认逐客户端追踪 client_encoding、DateStyle、TimeZone、standard_conforming_strings、application_name,外加 IntervalStyle(这是 track_extra_parameters 的默认值),并在客户端变活跃时把这些值恢复到它拿到的服务端连接上。追踪列表可以扩展。
这里有个硬限制:只有 PostgreSQL 会主动向客户端上报的参数(GUC_REPORT 那一组)才可能被追踪。PostgreSQL 17 及更早版本不上报 search_path,所以流传很广的那条建议——「把 search_path 加进 track_extra_parameters 就行了」——在那些版本上根本不成立,除非你装了会改变上报集合的扩展;PgBouncer 文档点名 Citus 12.0+ 会让 PostgreSQL 上报 search_path。PostgreSQL 18 把 search_path 标记成了 GUC_REPORT,所以这条追踪在 18 上确实开始生效(主版本跳跃本身带来的破坏性变更清单见PostgreSQL 19 Beta 迁移指南)——注意 PgBouncer 的 config 文档还停在只提 Citus 的旧措辞,而 1.25.1 的 changelog 已经把 PostgreSQL 18 和 Citus 并列为会出现该配置的场景。
同一条路径正是 CVE-2025-12819 的成因,修复于 1.25.1(2025 年 12 月 3 日):当 track_extra_parameters 含 search_path、配置了 auth_user、且 auth_query 没有做 schema 限定时,未认证的攻击者可以在认证阶段执行任意 SQL。如果你当初是照着某篇博客加上这项追踪的,那么现在有两个理由把它删掉。
多租户 schema 路由的正解不是追踪,而是事务内的 SET LOCAL search_path——它随事务一起消失,天然对齐归还边界——或者干脆全限定对象名。statement_timeout、work_mem、role 同理:用 SET LOCAL。
协议级 prepared statement:现在能用,但有前提
这是过去几年变化最大的一条,也是过期建议伤害最大的一条。协议级 prepared plan 在 transaction 模式下是 Yes,前提是 max_prepared_statements 非零。
机制值得了解,因为它解释了失效方式。PgBouncer 检查客户端 prepare 的每个查询串,给每个唯一的字符串分配一个形如 PGBOUNCER_{unique_id} 的内部名,真实后端上只 prepare 这个内部名;它维护「客户端起的名字 → 内部名」的映射,并在转发时改写命令。如果客户端拿到的服务端连接上还没有这条语句,PgBouncer 会先透明 prepare 再执行。两个客户端发出相同查询串时共享同一个内部名和同一份服务端计划。
版本时间线要记准:
- 1.21.0(2023 年 10 月 16 日) 引入该能力,
max_prepared_statements默认为 0,即关闭。 - 1.24.0(2025 年 1 月 10 日) 把默认值改为 200。
也就是说,如果你的 pgbouncer.ini 是 2025 年之前写的并一路抄下来,哪怕二进制已经很新,配置里那行显式的 max_prepared_statements = 0 依然会赢。用 SHOW CONFIG 确认,别靠猜。
SQL 级的 PREPARE、EXECUTE、DEALLOCATE 仍然是 Never。 它们被原样转发给 PostgreSQL,不改写也不追踪,坏法和以前一模一样。唯二的例外是 DEALLOCATE ALL 和 DISCARD ALL——PgBouncer 会识别这两条,用来清空它为该客户端追踪的语句。
max_prepared_statements 的数值语义是「单条服务端连接上 LRU 缓存保留的语句数」,不是全局上限。调大它在两处花内存:PostgreSQL 侧每条后端保留更多计划,PgBouncer 侧要留存查询串。设成略高于应用稳态使用的不同语句数即可,再往上没有收益。
客户端侧仍然有效的坑:
- PHP/PDO 长期与该特性不兼容(PgBouncer issue #991)。FAQ 的口径是只有 PHP 8.4+ 且 libpq 17 同时满足才兼容;达不到就把
PDO::ATTR_EMULATE_PREPARES设为 true。 - JDBC 想关掉服务端 prepare,连接串加
prepareThreshold=0。 - Npgsql 如果在 PgBouncer 前面还保留自己的连接池,且 PgBouncer 跑 transaction 或 statement 模式,官方文档要求设
No Reset On Close=true,因为 Npgsql 归还连接时那套 reset 逻辑在这些模式下毫无意义;直接Pooling=false也是文档给出的选项。
规律很简单:发协议级 Parse/Bind/Execute 的驱动没问题;发 SQL 文本 PREPARE 的不行;归还连接时自己跑 reset query 的,要把那个行为关掉。
临时表:取决于 ON COMMIT
feature map 把这条拆开了,而这个拆分就是全部答案:ON COMMIT DROP 的临时表是 Yes,PRESERVE ROWS / DELETE ROWS 的是 Never。
所以「transaction 模式不能用临时表」是错的。CREATE TEMP TABLE ... ON COMMIT DROP 的生命周期正好终止在 transaction 模式归还连接的位置,安全,而且在单事务内暂存数据时确实好用。
默认的 ON COMMIT PRESERVE ROWS 才是陷阱:表活过了事务,但连接已经还回池子,下一笔事务跑在别处、看不到它——更糟的是这张表会一直挂在原来那条后端上,直到 server_lifetime(默认 3600 秒)或 server_idle_timeout(默认 600 秒)回收该连接。你同时拿到一个正确性 bug 和一个横跨所有后端的慢性泄漏。
会话级 advisory lock
Never,而且我认为这是整张表里最危险的一条,因为它静默失败。
pg_advisory_lock() 一族持有到显式解锁或会话结束。在 transaction 模式下,事务一提交连接就归还,锁却留在那条后端上。应用之后那句 pg_advisory_unlock() 几乎必然跑在另一条连接上,返回 false,而原锁要等到几分钟后连接被回收才释放。全程没有任何报错。你观察到的现象是:某个任务偶尔跑两遍,或者某个 worker 偶尔无缘无故卡十分钟。
修法不是改配置,是改代码,换成事务级变体:pg_advisory_xact_lock()、pg_try_advisory_xact_lock() 及其 _shared 形式。它们随事务结束自动释放,而且根本不提供手动解锁接口——这恰恰是它们在这里正确的原因:API 让边界错误无法被表达。如果实在无法把工作压进一笔事务,那这个组件就该挂到 session 模式的别名上。
LISTEN、WITH HOLD 游标、LOAD
三条都是 Never,理由相同:它们建立的状态本就打算活过事务。
注意一个经常被传错的不对称:NOTIFY 是 Yes,LISTEN 是 Never。 发通知在事务内就完成了,没问题;收通知需要一个持续存在的会话。同理,WITHOUT HOLD 游标是 Yes(定义上就是事务作用域),WITH HOLD 游标是 Never。
实际后果是:任何建立在 LISTEN/NOTIFY 上的任务队列或缓存失效总线,其监听端必须走 session 模式,而发布端待在 transaction 池里完全没问题。这正是前面那个双别名模式的典型用武之地。(这类队列随之而来的重复投递怎么兜底,见Idempotency-Key 实现指南。)
server_reset_query 救不了你
一个常见误解是:把 server_reset_query = DISCARD ALL 保持默认,transaction 模式就安全了。它在 transaction 模式下根本不执行。 server_reset_query_always 默认为 0,意味着重置查询只在 session 模式的池子里跑;文档给的理由是——transaction 模式的客户端本来就不该使用会话特性。
把 server_reset_query_always 打开也不是修复。文档对这个开关的定位相当坦率:它是给「在 transaction 池上跑会话特性」的坏应用擦屁股用的,作用是把不确定的损坏变成确定的损坏,客户端每笔事务后必然丢状态。这是个调试工具——让 bug 在第一个请求就复现,而不是第一百个——不是治疗方案。
顺带记住 DISCARD ALL 的真实成本,因为 session 模式下常有人想换更轻的。PostgreSQL 文档说它等价于 CLOSE ALL; SET SESSION AUTHORIZATION DEFAULT; RESET ALL; DEALLOCATE ALL; UNLISTEN *; SELECT pg_advisory_unlock_all(); DISCARD PLANS; DISCARD TEMP; DISCARD SEQUENCES;。如果 session 模式的客户端只需要清掉 prepared statement,换成 DEALLOCATE ALL 是合理且更便宜的选择——它保留了计划缓存给下一个客户端。另外 DISCARD ALL 不能在事务块内执行。PgBouncer 文档对 reset query 的那条要求则是另一回事:连接释放时本来就没有事务在进行,所以这条查询里不该出现 ABORT 或 ROLLBACK。
连接数怎么算
分两个方向独立计算,别混在一起。
后端方向:PgBouncer → PostgreSQL
这个数由数据库真正能并发执行多少来决定,不是由你有多少用户决定。 PostgreSQL wiki 给的活跃连接起点是 (核心数 × 2) + 有效磁头数,核心数不含超线程兄弟线程,工作集全在缓存里时有效磁头数趋近 0。wiki 自己也标注:这个公式经受了多年基准测试,但在 SSD 上没有做过系统分析,它是增量调优的起点而不是答案。把它当作搜索的出发点:从这里起步压测往上加,直到吞吐不再涨、延迟开始爬。一台 16 核、缓存命中良好的机器大概落在 32 条活跃后端附近——通常比人们预期的小得多,这也正是连接池有价值的原因。
然后不要把这个数字直接填进 default_pool_size 就收工。default_pool_size(默认 20)是 per user/database pair 的,可被 [databases]、[users] 段的 pool_size 覆盖。10 个逻辑库 × 5 个用户 × 20,理论上就是 1000 条后端连接,会在够到你调好的目标之前先撞上 max_connections。所以:
max_db_connections限制每个库的服务端连接数(不分用户);max_user_connections限制每个用户的服务端连接数(不分库)。
两者默认都是 0(无限制)。生产环境必须显式设置。 不设的话,PostgreSQL 的 max_connections 就是你唯一的护栏,而撞上去的表现是新连接被直接拒绝——包括值班工程师用来排查的那一条。记得给 superuser_reserved_connections 和绕过池子的运维直连留出余量。
前端方向:客户端 → PgBouncer
max_client_conn 默认是 100,对几乎任何真实部署都太小,也是首次上线时报 "no more connections allowed" 的头号原因。按真实扇入来定:应用实例数 × 每实例本地池上限,再为发布期间新旧实例并存留出余量。
调大它就要同步调操作系统的文件描述符上限。文档给出的理论上界是:
max_client_conn + (max pool_size × 库数 × 用户数)
如果所有客户端都用同一个用户名连接,则是 max_client_conn + (max pool_size × 库数)。实际到不了这个值,但 fd 上限应该显著高于它。
两个配套旋钮:min_pool_size(默认 0)保留一批热连接,避免长时间空闲后流量突回时付建连成本——注意它只对「[databases] 条目里配了 forced user」或「当前至少有一个客户端连着」的池子生效。reserve_pool_size 配合 reserve_pool_timeout 在客户端排队过久时临时放行额外连接;reserve_pool_timeout 默认 5 秒,但 reserve_pool_size 默认为 0,所以整套机制在你显式配置之前是关着的。
PgBouncer 自身
PgBouncer 是单线程的,一个实例吃满一个核。 单实例把核打满时,文档给的做法是 so_reuseport:在同一端口上起多个实例,由内核分发新连接(在较新的 Linux、FreeBSD(用 SO_REUSEPORT_LB)、DragonFlyBSD 上有效)。有一条算术后果很容易漏:每个实例维护自己独立的池,所以真实的后端连接上限是「单实例限额 × 实例数」。要提前把 default_pool_size 除下去,而不是在撞上 max_connections 时才发现。
怎么区分「池子太小」和「数据库太慢」
SHOW POOLS 的两列就能回答。cl_waiting 是已发出查询、仍在等服务端连接的客户端数;maxwait 是队首客户端已经等了多久。maxwait 持续大于 0 并且在上涨,说明客户端在排队等服务端连接——文档给的两个可能原因是后端过载或池子设得太小,要靠别的指标把它们分开:查询本身很快、队列却在长,那就是池子太小。SHOW STATS 给出累积视角的 total_wait_time 和 avg_wait_time,适合做告警指标;旁边的 avg_query_time 正好把两个假设干净地分开。
1.25.0(2025 年 11 月 9 日) 加的两个设置值得采纳。query_wait_notify(默认 5 秒)在客户端排队超过该时长后向它发一条通知,把无形的停顿变成应用日志里看得见的东西,远早于 query_wait_timeout(默认 120 秒)把查询干掉。同版本还加了 transaction_timeout(默认 0,关闭),断开长时间挂在事务中的客户端。第二项在 transaction 模式下价值格外高:一个开了事务然后跑去调 HTTP 接口的客户端,会在整个往返期间独占一条后端连接、把它从池子里拿走。设上它,这类 bug 会以错误的形式暴露,而不是表现为池子耗尽。
我会怎么部署
主应用池走 transaction 模式,max_prepared_statements 保持 200 的默认值,并确认驱动发的是协议级 prepare 而不是 SQL PREPARE。另起一个 session 模式的 [databases] 别名,pool_size 显式设小,供监听端、advisory lock worker 和迁移工具使用。全面用 SET LOCAL 替代 SET。所有临时表加 ON COMMIT DROP。max_db_connections 和 max_user_connections 设成真实数字而不是留着无限制。max_client_conn 按实例数推算,fd 上限同步抬高。对 maxwait 和 avg_wait_time 配告警。至于 statement 模式,除非你在跑 PL/Proxy(文档写明的目标场景),否则完全不要碰——它拒绝多语句事务是设计如此,不是缺陷。