PgBouncer 从原理到实践

文章目录

  • 第一部分:核心原理
    • [1.1 PgBouncer 是什么](#1.1 PgBouncer 是什么)
    • [1.2 为什么需要连接池](#1.2 为什么需要连接池)
    • [1.3 为什么有了应用层连接池,还需要 PgBouncer?](#1.3 为什么有了应用层连接池,还需要 PgBouncer?)
      • 先搞懂一个类比
        • [场景一:只有应用层连接池(没有 PgBouncer)](#场景一:只有应用层连接池(没有 PgBouncer))
        • [场景二:加了 PgBouncer](#场景二:加了 PgBouncer)
        • 对应到技术世界
    • [1.4 池模型:按 (database, user) 分池](#1.4 池模型:按 (database, user) 分池)
      • [pool_size 是预分配还是按需增长?](#pool_size 是预分配还是按需增长?)
        • [如果想"预热":用 `min_pool_size`](#如果想"预热":用 min_pool_size)
  • [第二部分:三种 Pooling 模式](#第二部分:三种 Pooling 模式)
  • [第三部分:编译安装与 systemd 管理](#第三部分:编译安装与 systemd 管理)
    • [3.1 编译安装](#3.1 编译安装)
    • [3.2 systemd 启停管理](#3.2 systemd 启停管理)
      • [systemd Type 选择(重要)](#systemd Type 选择(重要))
      • [Service 文件(推荐 Type=notify)](#Service 文件(推荐 Type=notify))
      • 创建运行用户
      • 启停命令
    • [3.3 一键安装脚本](#3.3 一键安装脚本)
  • 第四部分:配置详解
    • [4.1 完整配置示例1](#4.1 完整配置示例1)
      • 示例2
      • [通配符 `*` 的用法](#通配符 * 的用法)
      • [事务模式下 Extended Protocol 配置示例](#事务模式下 Extended Protocol 配置示例)
    • [4.2 认证文件 `userlist.txt`](#4.2 认证文件 userlist.txt)
    • [4.3 PostgreSQL 侧配套配置](#4.3 PostgreSQL 侧配套配置)
    • [4.4 系统级配置](#4.4 系统级配置)
    • [4.5 不确定哪些库需要单独调优时的简化策略](#4.5 不确定哪些库需要单独调优时的简化策略)
  • [第五部分:Transaction 模式的限制(重点)](#第五部分:Transaction 模式的限制(重点))
    • [5.1 不可用的功能](#5.1 不可用的功能)
    • [5.2 仍可用的功能](#5.2 仍可用的功能)
    • [5.3 会话级状态的处理建议](#5.3 会话级状态的处理建议)
    • [5.4 `server_reset_query` 在 transaction 模式下的行为](#5.4 server_reset_query 在 transaction 模式下的行为)
    • [5.5 PgBouncer 在 事务模式下, Extended Protocol 的已知限制](#5.5 PgBouncer 在 事务模式下, Extended Protocol 的已知限制)
      • [5.6 PgBouncer 事务模式下,用扩展协议会陷入两条路径都报错的死局:](#5.6 PgBouncer 事务模式下,用扩展协议会陷入两条路径都报错的死局:)
      • [路径 A:PgBouncer 归还时执行 DISCARD ALL](#路径 A:PgBouncer 归还时执行 DISCARD ALL)
      • [路径 B:PgBouncer 归还时不执行 DISCARD ALL](#路径 B:PgBouncer 归还时不执行 DISCARD ALL)
    • 根因
  • 第六部分:参数详解与配置经验
    • [6.1 池大小参数](#6.1 池大小参数)
    • [6.2 max_client_conn 的设计逻辑](#6.2 max_client_conn 的设计逻辑)
    • [6.3 超时参数](#6.3 超时参数)
    • [6.4 参数取值优先级](#6.4 参数取值优先级)
  • [第七部分:TCP Keepalive 参数详解](#第七部分:TCP Keepalive 参数详解)
  • 第八部分:认证机制详解
    • [8.1 两种用户,两套认证模型](#8.1 两种用户,两套认证模型)
    • [8.2 权限区别](#8.2 权限区别)
    • [8.3 admin/stats 用户的密码怎么来](#8.3 admin/stats 用户的密码怎么来)
      • [方法 1:明文密码(最简单)](#方法 1:明文密码(最简单))
      • [方法 2:SCRAM-SHA-256 哈希(推荐生产用)](#方法 2:SCRAM-SHA-256 哈希(推荐生产用))
      • 完整操作流程
    • [8.4 `auth_user` / `auth_query` 机制](#8.4 auth_user / auth_query 机制)
  • [第九部分:Extended Protocol 与 Prepared Statement 详解](#第九部分:Extended Protocol 与 Prepared Statement 详解)
    • [9.1 两种预处理语句的本质区别](#9.1 两种预处理语句的本质区别)
    • [9.2 `max_prepared_statements` 的作用与工作机制](#9.2 max_prepared_statements 的作用与工作机制)
    • [9.3 常见驱动兼容性](#9.3 常见驱动兼容性)
    • [9.4 如何判断 pgx 是否发送 Named Prepared Statement](#9.4 如何判断 pgx 是否发送 Named Prepared Statement)
      • [方法1:开启 PostgreSQL 日志(推荐)](#方法1:开启 PostgreSQL 日志(推荐))
      • [方法2:查看 pg_prepared_statements](#方法2:查看 pg_prepared_statements)
      • [方法3:抓 PostgreSQL 协议包(最准确)](#方法3:抓 PostgreSQL 协议包(最准确))
    • [9.5 验证 Extended Protocol 是否生效](#9.5 验证 Extended Protocol 是否生效)
  • [第十部分:Prepared Statement 排查流程](#第十部分:Prepared Statement 排查流程)
    • [10.1 报错 "prepared statement does not exist" 的五大原因](#10.1 报错 "prepared statement does not exist" 的五大原因)
      • [原因 1:版本 < 1.21](#原因 1:版本 < 1.21)
      • [原因 2:`max_prepared_statements = 0`](#原因 2:max_prepared_statements = 0)
      • [原因 3:SQL 级 PREPARE](#原因 3:SQL 级 PREPARE)
      • [原因 4:值配小了](#原因 4:值配小了)
      • [原因 5:驱动兼容性问题](#原因 5:驱动兼容性问题)
    • [10.2 完整排查流程](#10.2 完整排查流程)
    • [10.3 快速止血方案](#10.3 快速止血方案)
      • [方案 A:禁用服务端 prepared statement(最快)](#方案 A:禁用服务端 prepared statement(最快))
      • [方案 B:临时切 session 模式](#方案 B:临时切 session 模式)
      • [方案 C:升级 PgBouncer + 启用跟踪(根治)](#方案 C:升级 PgBouncer + 启用跟踪(根治))
  • [第十一部分:Go(pgx/GORM) 与 PgBouncer 的兼容方案](#第十一部分:Go(pgx/GORM) 与 PgBouncer 的兼容方案)
    • [11.1 方案 A:稳定优先(不使用 Prepared Statement)](#11.1 方案 A:稳定优先(不使用 Prepared Statement))
      • [PgBouncer 配置](#PgBouncer 配置)
      • Go代码
    • [11.2 方案 B:性能优先(pgx statement cache + PgBouncer tracking)](#11.2 方案 B:性能优先(pgx statement cache + PgBouncer tracking))
      • [PgBouncer 配置](#PgBouncer 配置)
      • Go代码
    • [11.3 两个方案对比](#11.3 两个方案对比)
    • [11.4 PrepareContext 与方案 A/B 的关系](#11.4 PrepareContext 与方案 A/B 的关系)
  • [第十二部分:Go(pgx/GORM) 走 Simple Protocol 的实测与坑](#第十二部分:Go(pgx/GORM) 走 Simple Protocol 的实测与坑)
    • [12.1 三种方案](#12.1 三种方案)
      • [方案 A:`PreferSimpleProtocol:false` + `PrepareContext` → 全部 `execute`(Extended)](#方案 A:PreferSimpleProtocol:false + PrepareContext → 全部 execute(Extended))
      • [方案 B:`PreferSimpleProtocol:true` + `PrepareContext` → 业务 SQL 仍为 `execute`(Extended),仅无参查询 `statement`](#方案 B:PreferSimpleProtocol:true + PrepareContext → 业务 SQL 仍为 execute(Extended),仅无参查询 statement)
      • [方案 C:`PreferSimpleProtocol:true` + 去掉 `PrepareContext` → 全部 `statement:`(真正 Simple)](#方案 C:PreferSimpleProtocol:true + 去掉 PrepareContext → 全部 statement:(真正 Simple))
    • [12.2 实测 PG 日志对比](#12.2 实测 PG 日志对比)
    • [12.3 为什么"半简单协议"会失效](#12.3 为什么"半简单协议"会失效)
    • [12.4 结论](#12.4 结论)
    • [12.5 完整可复现代码](#12.5 完整可复现代码)
      • go.mod
      • [方案 A 完整 main.go](#方案 A 完整 main.go)
      • [方案 B](#方案 B)
      • [方案 C 完整 main.go](#方案 C 完整 main.go)
      • 编译运行命令
  • 第十三部分:运维与监控
    • [13.1 启动与停止](#13.1 启动与停止)
    • [13.2 管理控制台](#13.2 管理控制台)
    • [13.3 核心监控命令](#13.3 核心监控命令)
    • [13.4 `SHOW POOLS` 关键指标解读](#13.4 SHOW POOLS 关键指标解读)
    • [13.5 常见运维场景](#13.5 常见运维场景)
    • [13.6 信号速查](#13.6 信号速查)
  • 第十四部分:生产最佳实践
    • [14.1 安全最佳实践](#14.1 安全最佳实践)
    • [14.2 性能调优清单](#14.2 性能调优清单)
    • [14.3 监控告警](#14.3 监控告警)
    • [14.4 常见踩坑清单](#14.4 常见踩坑清单)
  • 第十五部分:核心配置速查表
  • 一句话总结

第一部分:核心原理

1.1 PgBouncer 是什么

PgBouncer 是一个 PostgreSQL 的轻量级连接池中间件,用 C 语言编写,单进程事件驱动。

核心价值

plaintext 复制代码
                    前端(便宜)              后端(昂贵)
                ┌──────────────┐      ┌──────────────────┐
  N 个客户端 ──►│  PgBouncer   │─────►│  PostgreSQL      │
  (max_client   │  连接池复用   │ M 个  │  max_connections │
   _conn)       └──────────────┘连接   └──────────────────┘
                N >> M (复用)

|| 指标 | 客户端到 PgBouncer | PgBouncer 到 PostgreSQL | ||------|-------------------|----------------------| || 内存开销 | 2 KB/连接 | 5~10 MB/连接 | || 建连成本 | 极低 | 高(PG fork 进程) | || 数量级 | 可达数千 | 通常数十到数百 |

1.2 为什么需要连接池

PostgreSQL 的连接模型是每个连接一个进程(backend process),这与 MySQL 的线程模型不同:

plaintext 复制代码
客户端连接 → PG 主进程 fork → backend 进程 (5~10MB)

连接数过多的代价

  • 内存浪费:1000 个连接 × 10MB = 10GB 仅用于连接

  • 上下文切换开销:CPU 在大量进程间切换

  • max_connections 升高会降低整体性能

PgBouncer 的解决方案 :让 1000 个客户端复用 100 个后端连接。

1.3 为什么有了应用层连接池,还需要 PgBouncer?

先搞懂一个类比

想象一家有 40 个包间的酒店,包间就是你的数据库。

场景一:只有应用层连接池(没有 PgBouncer)

假设你有 3 个服务员 (应用实例),每个服务员身上挂着 10 串钥匙(连接池大小=10),每串钥匙能开任何一个包间。

plaintext 复制代码
服务员A(身上挂10串钥匙)
服务员B(身上挂10串钥匙)  
服务员C(身上挂10串钥匙)

总钥匙数 = 3 × 10 = 30 串钥匙,全部挂在服务员身上。

问题 1:钥匙浪费 ------服务员A 手里 10 串钥匙,某一时刻只有 2 个客人需要服务,剩下 8 串白挂。服务员B 那边突然来了 8 个客人,但他只有 10 串也快不够。能借吗?不能。 每个服务员各管各的。

问题 2:钥匙数量随服务员数量线性增长------10 个服务员 × 10 串 = 100 串钥匙,但实际同时服务客人可能 20-30 个。

问题 3:客人走后钥匙不马上还 ------客人点完菜在聊天(会话还没结束),钥匙一直被占着。这就是"会话级共享"。

场景二:加了 PgBouncer

在酒店大堂放一个 钥匙柜(PgBouncer),所有钥匙统一存在柜子里,只放 5 串。

好处 1:钥匙可以共享 ------跨服务员共享。 好处 2:用完马上还 ------客人点完菜(一个事务结束),服务员马上把钥匙还回柜子。这就是"事务级共享"。好处 3:加服务员不增加钥匙------10 个服务员共用柜子里的 5 串钥匙就够了。

对应到技术世界

|| 酒店类比 | 技术对应 | ||---------|---------| || 服务员 | 应用实例(Pod/进程) | || 钥匙串 | 数据库连接 | || 钥匙挂在服务员身上 | 应用层连接池(HikariCP/GORM) | || 钥匙柜 | PgBouncer | || 点菜动作 | 一个事务 | || 点完菜马上还钥匙 | 事务结束立即归还连接 |

应用层连接池 解决的是"单个实例内部复用",PgBouncer 解决的是"所有实例统一复用 + 更细粒度的复用"。

1.4 池模型:按 (database, user) 分池

PgBouncer 不是 一个全局大池,而是按 (database, user) 二元组切分成多个独立小池:

plaintext 复制代码
PgBouncer 进程
├── pool(db_A, user_X)  ← 独立池, 互不共享
├── pool(db_A, user_Y)
├── pool(db_B, user_X)
└── pool(db_B, user_Y)

关键特性

  • 每个池有独立的 pool_size 上限

  • 池是按需创建的:没有客户端连接时,池不存在

  • 连接是逐个按需建立的,不是预分配

  • 空闲连接通过 server_idle_timeout 逐步回收

pool_size 是预分配还是按需增长?

pool_size上限 ,不是预分配数量。答案是:一个个按需增长,上限 N 个

pool_size=2 为例:

plaintext 复制代码
时间线                后端连接数    说明
─────────────────────────────────────────────────────────
PgBouncer 启动        0           池不存在, 不预分配
首个客户端连接进来     0→1         按需新建 1 个后端连接
客户端查询完成        1           连接保持(空闲), 不关闭
第二个客户端同时访问   1→2         再新建 1 个, 达到上限 2
第三个客户端访问       2(排队)     达上限, 客户端等待
所有客户端断开+空闲10分钟  2→1→0   server_idle_timeout 逐步关闭
如果想"预热":用 min_pool_size
ini 复制代码
min_pool_size = 1            ; ★ 维持至少 1 个连接

重要前提min_pool_size 仅在以下情况生效:

  1. 该 database 配置了 forced user(在 [databases] 段指定了 user=),

  2. 至少有一个客户端已连接到该池

plaintext 复制代码
无 min_pool_size:     0 → 1 → 2 → (空闲) → 1 → 0
有 min_pool_size=1:   0 → 1 → 2 → (空闲) → 1 → 1 (维持不下于 1)

第二部分:三种 Pooling 模式

|| 模式 | 连接分配时机 | 连接归还时机 | 复用率 | 兼容性 | ||------|------------|------------|--------|--------| || session | 客户端连接时 | 客户端断开时 | 1:1(最低) | 100% 兼容 | || transaction | 事务开始时 | 事务结束时 | 5~50 倍 | 破坏会话级功能 | || statement | 语句执行时 | 语句执行后 | 最高 | 强制 autocommit |

模式选择决策树

plaintext 复制代码
应用是否使用会话级功能?
├── 是 (临时表/SET/LISTEN/会话锁)
│   └── session pooling
└── 否
    └── 是否需要多语句事务?
        ├── 是 → transaction pooling  ★ 最常用
        └── 否 (纯 autocommit) → statement pooling

第三部分:编译安装与 systemd 管理

3.1 编译安装

环境 : openEuler 22.03 (aarch64) | 日期: 2026-07-18

bash 复制代码
# 安装编译依赖
dnf install -y libevent-devel openssl-devel

# 下载源码
mkdir -p /tmp/pgbouncer_build && cd /tmp/pgbouncer_build
curl -sL -o pgbouncer-1.25.2.tar.gz \
  'https://github.com/pgbouncer/pgbouncer/releases/download/pgbouncer\_1\_25\_2/pgbouncer-1.25.2.tar.gz'
tar xzf pgbouncer-1.25.2.tar.gz && cd pgbouncer-1.25.2

# 配置编译
./configure --prefix=/postgresql/pgbouncer

# 编译(跳过 man 文档,避免缺 pandoc 报错)
make -j$(nproc) pgbouncer

# 安装
mkdir -p /postgresql/pgbouncer && make install

安装目录结构

plaintext 复制代码
/postgresql/pgbouncer/
├── bin/pgbouncer                  # 主程序 (2.5MB)
├── conf/                          # 配置文件目录
├── run/                           # 运行时 PID 目录
├── log/                           # 日志目录
└── share/doc/pgbouncer/           # 示例和文档

验证安装

bash 复制代码
/postgresql/pgbouncer/bin/pgbouncer --version
# PgBouncer 1.25.2 / libevent 2.1.12-stable / adns: evdns2 / tls: OpenSSL 1.1.1wa

3.2 systemd 启停管理

systemd Type 选择(重要)

PgBouncer 的 systemd Type 选择取决于启动方式,必须匹配

|| 方案 | Type | ExecStart | 说明 | ||------|------|-----------|------| || 推荐 | notify | /.../pgbouncer /.../pgbouncer.ini | 1.23+ 支持 sd_notify,精确感知就绪 | || 可用 | forking | /.../pgbouncer -d /.../pgbouncer.ini | 加 -d 参数 fork 到后台 | || 可用 | simple | /.../pgbouncer /.../pgbouncer.ini | 不加 -d,前台运行 |

常见错误Type=forking 但 ExecStart 不加 -d------PgBouncer 不会 fork,systemd 认为启动失败。

Service 文件(推荐 Type=notify)

ini 复制代码
[Unit]
Description=PgBouncer - PostgreSQL Connection Pooler
After=network-online.target
Wants=network-online.target

[Service]
Type=notify
User=pgbouncer
Group=pgbouncer
ExecStart=/postgresql/pgbouncer/bin/pgbouncer /postgresql/pgbouncer/conf/pgbouncer.ini
ExecReload=/bin/kill -HUP $MAINPID
ExecStop=/bin/kill -INT $MAINPID
PIDFile=/postgresql/pgbouncer/run/pgbouncer.pid
Restart=on-failure
RestartSec=3
LimitNOFILE=65536
LimitNPROC=4096

[Install]
WantedBy=multi-user.target

注意 :使用 Type=notify 需要编译时启用 systemd 支持。如果编译时 systemd = no,改用 Type=simpleType=forking + -d

  • 如果 Type 使用 simple
bash 复制代码
[Unit]
Description=PgBouncer - PostgreSQL Connection Pooler
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=pgbouncer
Group=pgbouncer
# simple 模式:进程必须前台运行,去掉 -d
ExecStart=/postgresql/pgbouncer/bin/pgbouncer /postgresql/pgbouncer/conf/pgbouncer.ini
ExecReload=/bin/kill -HUP $MAINPID
ExecStop=/bin/kill -INT $MAINPID
# simple 模式下 PIDFile 不影响服务状态判断,但保留可用于 ExecReload/ExecStop 定位
PIDFile=/postgresql/pgbouncer/run/pgbouncer.pid
Restart=on-failure
RestartSec=3
LimitNOFILE=65536
LimitNPROC=4096

[Install]
WantedBy=multi-user.target

创建运行用户

bash 复制代码
useradd -r -s /sbin/nologin -d /postgresql/pgbouncer/run pgbouncer
mkdir -p /postgresql/pgbouncer/{conf,run,log}
chown -R pgbouncer:pgbouncer /postgresql/pgbouncer

启停命令

bash 复制代码
systemctl daemon-reload
systemctl start pgbouncer
systemctl status pgbouncer
systemctl reload pgbouncer     # 热重载(不中断连接)
systemctl enable pgbouncer     # 开机自启
journalctl -u pgbouncer -f     # 查看日志

3.3 一键安装脚本

bash 复制代码
#!/bin/bash
set -e
VERSION="1.25.2"
INSTALL_PREFIX="/postgresql/pgbouncer"

dnf install -y libevent-devel openssl-devel
mkdir -p /tmp/pgbouncer_build && cd /tmp/pgbouncer_build
curl -sL -o "pgbouncer-${VERSION}.tar.gz" \
  "https://github.com/pgbouncer/pgbouncer/releases/download/pgbouncer\_${VERSION//./_}/pgbouncer-${VERSION}.tar.gz"
tar xzf "pgbouncer-${VERSION}.tar.gz" && cd "pgbouncer-${VERSION}"
./configure --prefix="$INSTALL_PREFIX"
make -j$(nproc) pgbouncer
mkdir -p "$INSTALL_PREFIX" && make install
mkdir -p "$INSTALL_PREFIX"/{conf,run,log}
cp "$INSTALL_PREFIX"/share/doc/pgbouncer/pgbouncer.ini "$INSTALL_PREFIX"/conf/
cp "$INSTALL_PREFIX"/share/doc/pgbouncer/userlist.txt "$INSTALL_PREFIX"/conf/
"$INSTALL_PREFIX/bin/pgbouncer" --version

第四部分:配置详解

4.1 完整配置示例1

以"40 个 database、1 个 user、常态 1-2 连接/库、峰值 5/库"的场景为例:

ini 复制代码
[databases]
;; 方案 A: 所有库在同一台 PG (用通配符)
* = host=127.0.0.1 port=5432

;; 方案 B: 库分布在不同 PG (逐条列出, 不配 pool_size)
; db_app_main    = host=10.0.0.1 port=5432 dbname=db_app_main

;; 方案 C: 某库需要单独调优 (覆盖通配符)
; db_hot = host=127.0.0.1 port=5432 pool_size=5

[pgbouncer]
;; ---- 基础监听 ----
listen_addr = 0.0.0.0
listen_port = 6432
unix_socket_dir = /var/run/postgresql

;; ---- 池化模式 ----
pool_mode = transaction          ;; ★ 生产首选

;; ---- 池大小 ----
default_pool_size = 3            ;; 常态每池上限
min_pool_size = 0                ;; 不预热
reserve_pool_size = 1            ;; 突发额外加 1
reserve_pool_timeout = 3

;; ---- 前端连接 ----
max_client_conn = 1000

;; ---- 跨池上限 ----
max_db_connections = 5           ;; ★ 单库后端连接硬上限

;; ---- 认证 ----
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

;; ---- 连接健康检查与生命周期 ----
server_reset_query = DISCARD ALL
server_reset_query_always = 0       ;; transaction 模式下默认不自动执行
server_check_delay = 30
server_check_query = select 1
server_lifetime = 3600
server_idle_timeout = 600
server_connect_timeout = 15
server_login_retry = 15
client_login_timeout = 60

;; ---- 危险超时 ----
query_timeout = 0
query_wait_timeout = 120
client_idle_timeout = 0
idle_transaction_timeout = 60       ;; ★ 事务空闲60秒强制断开
transaction_timeout = 0
cancel_wait_timeout = 10

;; ---- 日志 ----
logfile = /var/log/pgbouncer/pgbouncer.log
pidfile = /var/run/postgresql/pgbouncer.pid
log_connections = 1
log_disconnections = 1
log_pooler_errors = 1
log_stats = 1
stats_period = 60

;; ---- 管理控制台 ----
admin_users = dbadmin
stats_users = dbmonitor

;; ---- 预处理语句 (transaction 模式, 1.21+) ----
;; 默认值为 0(禁用),设为 > 0 时启用协议级 prepared statement 跟踪
;; 注意:这是每连接独立跟踪的上限,不是全局共享
max_prepared_statements = 200

;; ---- 底层网络 ----
pkt_buf = 4096
listen_backlog = 128
tcp_keepalive = 1
;; tcp_keepidle = 60
;; tcp_keepintvl = 10
;; tcp_keepcnt = 6

示例2

bash 复制代码
;; ============================================================
;; pgbouncer.ini - 生产环境配置模板
;; ============================================================

[databases]
;; 方案 A: 所有库在同一台 PG (用通配符)
* = host=127.0.0.1 port=54321

;; 方案 B: 库分布在不同 PG (逐条列出, 不配 pool_size)
; db_app_main    = host=10.0.0.1 port=5432 dbname=db_app_main
; db_app_order   = host=10.0.0.1 port=5432 dbname=db_app_order
; db_tenant_01   = host=10.0.0.2 port=5432 dbname=db_tenant_01

;; 方案 C: 某库需要单独调优 (覆盖通配符)
; db_hot = host=127.0.0.1 port=5432 pool_size=5

;; 高并发库:覆盖通配符的 default_pool_size
foundation    = host=127.0.0.1 port=54321 pool_size=4
efc           = host=127.0.0.1 port=54321 pool_size=3
is            = host=127.0.0.1 port=54321 pool_size=3
imos          = host=127.0.0.1 port=54321 pool_size=2
sw            = host=127.0.0.1 port=54321 pool_size=2
ras           = host=127.0.0.1 port=54321 pool_size=2
accesscontrol = host=127.0.0.1 port=54321 pool_size=2
cds           = host=127.0.0.1 port=54321 pool_size=2


[pgbouncer]
;; ---- 基础监听 ----
listen_addr = 0.0.0.0
listen_port = 5432
unix_socket_dir = /tmp


;; ---- 池化模式 ----
;; ★ 生产首选
pool_mode = transaction

;; ---- 池大小 ----
;; 常态每池上限
default_pool_size = 1
;; 不预热 (0=禁用)
min_pool_size = 0
;; 突发额外加 1
reserve_pool_size = 1
;; 3秒拿不到才动用 reserve
reserve_pool_timeout = 3

;; ---- 前端连接 ----
;; 客户端总连接上限
max_client_conn = 1000

;; ---- 跨池上限 ----
;; ★ 单库后端连接硬上限(0 表示不限制)
;; max_db_connections = 0
;; max_user_connections = 100     ;; 单用户后端连接上限 (可选)


;; ============================================================
;; 认证配置
;; ============================================================
;; 推荐使用 SCRAM
auth_type = scram-sha-256
auth_file = /usr/local/pgbouncer/conf/userlist.txt

;; 进阶: 从 PG 动态查询用户密码 (免维护 userlist.txt)
;; auth_user = pgbouncer_auth
;; auth_query = SELECT rolname, rolpassword FROM pg_authid WHERE rolname = $1 AND rolcanlogin
;; auth_dbname = postgres


;; ============================================================
;; 连接健康检查与生命周期
;; ============================================================
;; session 模式清理 (transaction 模式不执行)
server_reset_query = DISCARD ALL
;; 不强制在 transaction 模式执行
server_reset_query_always = 0
;; 30秒内复用不检查
server_check_delay = 30
;; 健康检查语句
server_check_query = select 1
;; 后端连接最多活 1 小时
server_lifetime = 1800
;; 空闲 10 分钟关闭
server_idle_timeout = 120
;; 连 PG 超时
server_connect_timeout = 15
;; 登录失败重试间隔
server_login_retry = 15
;; 客户端登录超时
client_login_timeout = 60


;; ============================================================
;; 危险超时 (谨慎使用, 按需开启)
;; ============================================================
;; 查询执行超时 (0=禁用)
query_timeout = 0
;; 客户端等连接超时, 防止无限排队
query_wait_timeout = 30
;; 客户端空闲超时 (0=禁用)
client_idle_timeout = 0
;; ★ 事务空闲 60秒强制断开
idle_transaction_timeout = 60
;; 取消请求超时
cancel_wait_timeout = 10


;; ============================================================
;; 日志
;; ============================================================
logfile = /usr/local/pgbouncer/conf/pgbouncer.log
pidfile = /usr/local/pgbouncer/conf/pgbouncer.pid

; log_connections = 1
; log_disconnections = 1
log_pooler_errors = 1
log_stats = 1
;; 每 60 秒输出统计
stats_period = 60

;; 可选: syslog
;; syslog = 1
;; syslog_ident = pgbouncer
;; syslog_facility = daemon


;; ============================================================
;; 管理控制台
;; ============================================================
admin_users = postgres
stats_users = postgres


;; ============================================================
;; 预处理语句 (transaction 模式)
;; ============================================================
;; 跟踪的预处理语句数量, 设置为 0 禁用 扩展查询(Extended Query) , 使用 简单查询(Simple Query)
#max_prepared_statements = 400


;; ============================================================
;; 底层网络 (通常不需要改)
;; ============================================================
tcp_keepalive = 1
tcp_keepidle = 60
tcp_keepintvl = 10
tcp_keepcnt = 6


;; 忽略JDBC等驱动发送的extra_float_digits参数
ignore_startup_parameters = extra_float_digits

通配符 * 的用法

* = host=127.0.0.1 port=5432 含义:任何客户端请求的 database 名自动映射到 127.0.0.1:5432 上的同名 database。通配符和具体条目可共存,具体条目优先于通配符

事务模式下 Extended Protocol 配置示例

ini 复制代码
pool_mode = transaction

;; 正确的参数名是 max_prepared_statements(不是 max_prepare_statements)
max_prepared_statements = 200

;; 某些 ORM(如 pgx、rust-postgres)需要在启动时设置此参数
ignore_startup_parameters = extra_float_digits

4.2 认证文件 userlist.txt

text 复制代码
;; 格式: "username" "password"
;; 密码支持三种格式: 明文 / MD5 / SCRAM-SHA-256

"postgres" "SCRAM-SHA-256$4096:base64salt$base64storedkey:base64serverkey"
"app_user" "SCRAM-SHA-256$4096:base64salt$base64storedkey:base64serverkey"
"dbadmin"  "SCRAM-SHA-256$4096:base64salt$base64storedkey:base64serverkey"

从 PG 导出:SELECT rolname, rolpassword FROM pg_authid WHERE rolcanlogin;

4.3 PostgreSQL 侧配套配置

ini 复制代码
# postgresql.conf
max_connections = 150
superuser_reserved_connections = 5

# pg_hba.conf
host    all       all       127.0.0.1/32  scram-sha-256

4.4 系统级配置

bash 复制代码
ulimit -n 65535   # max_client_conn=1000 至少需要 >= 3000
# /etc/security/limits.conf: * soft nofile 65535 / * hard nofile 65535
# systemd: LimitNOFILE=65536

4.5 不确定哪些库需要单独调优时的简化策略

用全局 default_pool_size + max_db_connections 兜底,不要提前猜

ini 复制代码
[databases]
* = host=127.0.0.1 port=5432

[pgbouncer]
pool_mode = transaction
default_pool_size = 3           ; 常态 1-2, 给点余量设 3
max_db_connections = 5          ; ★ 任何库都不会超过 5

跑一周后用 SHOW POOLS 观察哪个库频繁排队,再单独给它配 pool_size=5


第五部分:Transaction 模式的限制(重点)

5.1 不可用的功能

|| 功能 | 原因 | ||------|------| || **SET** / **RESET** | 会话级参数不跨事务保留 | || **LISTEN** | 通知通道绑定到特定后端连接 | || **WITH HOLD CURSOR** | 可保持游标跨事务, 连接已归还 | || SQL 级 **PREPARE**/ **DEALLOCATE** | 预处理语句绑定到连接 | || 临时表( **PRESERVE/DELETE ROWS** | 行数据跨事务不保证 | || **LOAD** 语句 | 加载的库绑定到连接 | || 会话级咨询锁 | pg_advisory_lock() 绑定到连接 |

5.2 仍可用的功能

|| 功能 | 说明 | ||------|------| || 启动参数 | client_encodingDateStyleTimezone 等(仅通过连接串/启动参数设置生效,事务内 **SET** 不保留 ) | || NOTIFY | 单次通知可用 | || 普通游标 | WITHOUT HOLD 在事务内可用 | || 协议级预处理语句 | 需配 max_prepared_statements > 0(1.21+) | || ON COMMIT DROP 临时表 | 事务结束时自动删除 |

5.3 会话级状态的处理建议

plaintext 复制代码
方案 1: 改用 session pooling (牺牲复用率)
方案 2: 应用改造, 将会话状态存到应用层或 Redis
方案 3: 混合模式 (部分库 session, 部分库 transaction)

混合模式配置:

ini 复制代码
[databases]
db_app_main = host=... pool_mode=transaction
db_report   = host=... pool_mode=session

5.4 server_reset_query 在 transaction 模式下的行为

  • server_reset_query_always = 0(默认):transaction 模式下默认不自动执行 DISCARD ALL

  • server_reset_query_always=1 会每次事务结束都执行 DISCARD ALL会破坏应用状态,不推荐

5.5 PgBouncer 在 事务模式下, Extended Protocol 的已知限制

|| 限制 | 替代方案 | ||------|----------| || 不支持命名 Portal / DECLARE CURSOR | WITH HOLDpool_mode=session | || 不支持 LISTEN/NOTIFY | pool_mode=session | || 不支持 SET/RESET 跨事务 | 连接串参数或 server_reset_query | || 不支持临时表跨事务 | pool_mode=session | || 不支持 PREPARE TRANSACTION(两阶段提交) | 直连 PG 或 pool_mode=session |

5.6 PgBouncer 事务模式下,用扩展协议会陷入两条路径都报错的死局:

正确做法

PgBouncer 事务模式 + 扩展协议 = 不可用,必须配简单协议(涉及业务层修改):

go 复制代码
config.PreferSimpleProtocol = true

简单协议不走 Parse/Bind/Execute,没有 prepared statement,连接归还后状态完全干净,零冲突风险。

路径 A:PgBouncer 归还时执行 DISCARD ALL

plaintext 复制代码
1. 客户端 pgx 在连接 C1 上:Parse("S_1", SQL) → 缓存 prepared statement 描述
2. 事务结束 → PgBouncer DISCARD ALL → S_1 从 PostgreSQL 服务端删除
3. 客户端下次拿到连接 C2,pgx 客户端缓存仍认为 S_1 存在
4. pgx 直接发 Bind/Execute("S_1") → ❌ ERROR: prepared statement "S_1" does not exist

路径 B:PgBouncer 归还时不执行 DISCARD ALL

plaintext 复制代码
1. 客户端 A 在连接 C1 上:Parse("S_1", SQL_A) → COMMIT
2. 连接 C1 归还池,S_1 还留在 C1 上(没清理)
3. 客户端 B 拿到 C1:尝试 Parse("S_1", SQL_B) → ❌ ERROR: prepared statement "S_1" already exists
   (同名但不同 SQL,冲突)

根因

prepared statement 是 session 级对象 ,它的生命周期绑定在后端连接上,而不是客户端事务上。PgBouncer 事务模式复用连接时:

状态 问题
服务端有 statement,客户端缓存匹配 碰巧正常(同一连接、同一 SQL)
服务端有 statement,客户端缓存不匹配(换连接了) ❌ SQL 不对
服务端无 statement(被 DISCARD),客户端缓存以为有 ❌ does not exist
服务端有 statement,新客户端想创建同名 ❌ already exists

只有"碰巧"场景正常,其他全部报错。


第六部分:参数详解与配置经验

6.1 池大小参数

|| 参数 | 作用 | 配置经验 | ||------|------|---------| || default_pool_size | 每池默认上限 | 常态连接数 × 1.5~2 | || min_pool_size | 每池最小连接数 | 0(默认);设 1 可抗突发但有前提 | || reserve_pool_size | 突发额外连接 | 1~3 | || reserve_pool_timeout | 何时动用 reserve | 3~5 秒 | || max_db_connections | 单库硬上限 | 锁死峰值 | || max_user_connections | 单用户硬上限 | 多应用共用时使用 |

核心公式:Σ pool_size ≤ PG max_connections × 0.8

6.2 max_client_conn 的设计逻辑

从 5 个视角交叉验证 max_client_conn = 1000

|| 推算视角 | 计算逻辑 | 得出值 | ||---------|---------|--------| || 原直连峰值放大 | 200 × 5 倍 | 1000 | || transaction 复用率 | 60 × 15 倍 | 900 | || 应用部署规模 | 20 台 × 50 | 1000 | || 突发容灾 | 500 × 2 | 1000 | || 资源开销验证 | 1000 × 2KB = 2MB | 无瓶颈 |

6.3 超时参数

|| 参数 | 默认值 | 推荐值 | 说明 | ||------|--------|--------|------| || server_idle_timeout | 600s | 600s | 空闲连接回收 | || server_lifetime | 3600s | 3600s | 连接最大生命周期 | || query_wait_timeout | 120s | 60s | 客户端排队超时 | || idle_transaction_timeout | 0 | 60s | 事务空闲超时(推荐开启) | || client_idle_timeout | 0 | 0 | 客户端空闲超时 |

三个超时容易混淆:

plaintext 复制代码
客户端连接进来, 不做事      → client_idle_timeout
客户端 BEGIN 后不做事       → idle_transaction_timeout
客户端 BEGIN 后一直执行慢查询 → transaction_timeout

6.4 参数取值优先级

plaintext 复制代码
1. [databases] 段的 pool_size=      (最高优先级)
2. [users] 段的 pool_size=
3. default_pool_size                  (兜底)

第七部分:TCP Keepalive 参数详解

ini 复制代码
tcp_keepalive = 1       # 总开关: 启用 TCP keepalive
tcp_keepidle = 60       # 连接空闲 60 秒后, 开始发送探测包
tcp_keepintvl = 10      # 每次探测间隔 10 秒
tcp_keepcnt = 6         # 连续 6 次探测无响应, 判定连接已死

总死亡检测时间 = tcp_keepidle + tcp_keepintvl × tcp_keepcnt = 60 + 10 × 6 = 120 秒

如果不设这几个参数,Linux 默认要 2 小时 + 11 分钟 才发现死连接,太慢!生产环境建议显式设置。


第八部分:认证机制详解

8.1 两种用户,两套认证模型

|| 用户类型 | 密码从哪来 | 是否要在 PG 中存在 | ||---------|-----------|-----------------| || 业务用户 (app_user) | 从 PG 复制过来 ,必须一致 | ✅ 必须 | || admin/stats 用户 | 你自己定义 | ❌ 不需要 |

admin_users / stats_users 只需要在 PgBouncer 的 userlist.txt 中配置即可,不需要在 PG 实例中创建 。连接到 pgbouncer 虚拟库时,请求不会转发到 PostgreSQL。

8.2 权限区别

|| 角色 | 能做什么 | 不能做什么 | ||------|---------|-----------| || admin_users | 所有 SHOW + PAUSE/RESUME/RELOAD/KILL/RECONNECT/SHUTDOWN | - | || stats_users | 所有 SHOW 命令(只读) | 不能执行管理命令 |

8.3 admin/stats 用户的密码怎么来

你自己定。三种方式:

方法 1:明文密码(最简单)

text 复制代码
"dbadmin"  "MyAdminPass2024!"
"dbmonitor" "MyMonitorPass2024!"

方法 2:SCRAM-SHA-256 哈希(推荐生产用)

方式 A:临时在 PG 中创建用户,拷贝哈希,再删除

sql 复制代码
CREATE USER tmp_dbadmin PASSWORD 'MyAdminPass2024!';
SELECT rolpassword FROM pg_authid WHERE rolname = 'tmp_dbadmin';
DROP USER tmp_dbadmin;

方式 B:用 PgBouncer 自带的命令(1.23+)

bash 复制代码
pgbouncer --scram "MyAdminPass2024!"

完整操作流程

bash 复制代码
ADMIN_PASS="MyAdminPass2024!"
psql -p 5432 -U postgres -c "CREATE USER tmp_gen PASSWORD '${ADMIN_PASS}';"
ADMIN_HASH=$(psql -p 5432 -U postgres -tAc "SELECT rolpassword FROM pg_authid WHERE rolname='tmp_gen'")
psql -p 5432 -U postgres -c "DROP USER tmp_gen;"

cat >> /etc/pgbouncer/userlist.txt << EOF
"dbadmin" "${ADMIN_HASH}"
EOF

chown pgbouncer:pgbouncer /etc/pgbouncer/userlist.txt
chmod 600 /etc/pgbouncer/userlist.txt
psql -p 6432 pgbouncer -c "RELOAD;"

8.4 auth_user / auth_query 机制

只适用于业务用户,admin/stats 用户必须userlist.txt 中手动配置。

ini 复制代码
auth_user = pgbouncer_auth
auth_query = SELECT rolname, rolpassword FROM pg_authid WHERE rolname = $1
auth_dbname = postgres

第九部分:Extended Protocol 与 Prepared Statement 详解

这是 PgBouncer 最容易混淆的概念 。PgBouncer 1.25 transaction 模式支持 Extended Protocol,也支持协议级 named Prepared Statement(通过 max_prepared_statements),但它不是完整的 PostgreSQL session prepared statement 语义,因此不能保证所有驱动、所有 prepared statement 使用方式都兼容。

9.1 两种预处理语句的本质区别

plaintext 复制代码
┌─────────────────────────────────────────────────────────────┐
│  类型 1: SQL 级预处理语句 (SQL-level PREPARE)              │
│                                                             │
│  客户端发 SQL 命令:                                         │
│    PREPARE stmt1 AS SELECT * FROM users WHERE id = $1;     │
│    EXECUTE stmt1(1);                                        │
│    DEALLOCATE stmt1;                                        │
│                                                             │
│  transaction 模式: ❌ 不支持 (连接归还后语句丢失)           │
│  配置参数: 无                                               │
│  PgBouncer 角色: 直接转发(不跟踪)                         │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│  类型 2: 协议级预处理语句 (Protocol-level prepared stmt)   │
│                                                             │
│  客户端用扩展查询协议 (Extended Query Protocol):           │
│    Parse 消息 + Bind 消息 + Execute 消息                   │
│                                                             │
│  transaction 模式: ✅ 支持 (需配 max_prepared_statements>0) │
│  配置参数: max_prepared_statements                          │
│  PgBouncer 角色: 内部跟踪+重写+透明准备                     │
└─────────────────────────────────────────────────────────────┘

|| 维度 | SQL 级 PREPARE | 协议级 prepared statement | ||------|---------------|--------------------------| || 客户端怎么发 | PREPARE stmt AS ... SQL 文本 | 驱动用扩展查询协议(Parse/Bind/Execute 消息) | || transaction 模式 | ❌ 不支持 | ✅ 支持 | || 跨连接复用 | 不能 | 能(PgBouncer 透明处理) | || 典型使用者 | 手动写 PREPARE SQL | JDBC/psycopg3/pgx 等驱动 |

9.2 max_prepared_statements 的作用与工作机制

参数说明

ini 复制代码
max_prepared_statements = 200
  • 默认值为 0(禁用)。设为 > 0 时启用协议级 prepared statement 跟踪。

  • 这是每个后端连接独立跟踪的上限,不是全局共享的。

  • 不是"每个客户端 200 个",也不是"全局共享 200 个"

工作机制

plaintext 复制代码
1. 客户端用扩展协议发 Parse "SELECT * FROM users WHERE id=$1"
2. PgBouncer 内部记录查询字符串, 分配内部名称: pgb_xxx
3. PgBouncer 转发给 PG: Parse pgb_xxx "SELECT * FROM users WHERE id=$1"
4. 事务结束, 后端连接归还到池
5. 下个事务可能用不同的后端连接
6. 客户端再次执行同一预处理语句: Bind/Execute pgb_xxx
7. PgBouncer 发现当前后端连接上没有准备 pgb_xxx → 透明地先 Parse 再 Execute

关键机制

  1. PgBouncer 自动对客户端 Prepared Statement 名称加前缀(pgb_),避免命名冲突

  2. 事务结束时,自动 DEALLOCATE 该连接上所有残留的 Prepared Statement

  3. 确保连接归还池时处于"干净"状态

关于命名前缀 :PgBouncer 源码中使用小写前缀 pgb_。在 PostgreSQL 日志中显示为大写 PGBOUNCER_xxx,这是 PG 日志格式化的结果。

9.3 常见驱动兼容性

|| 驱动/语言 | 默认协议 | 是否默认用协议级 prepared statement | 注意事项 | ||-----------|----------|----------------------------------|----------| || libpq © | Simple | 否 | PQexecParams 使用 Extended Protocol | || pgx (Go) | Extended | 是(CacheStatement 模式) | 需 max_prepared_statements > 0 | || psycopg2 (Python) | Simple | | psycopg2 没有协议级 prepared statement 支持 | || psycopg3 (Python) | Extended | 是 | prepare_threshold 控制何时自动 Prepare | || JDBC (Java) | Extended | 是(prepareThreshold=5) | 连接串加 prepareThreshold=0 可禁用 | || node-postgres | Extended | 是 | 默认用扩展协议 | || rust-postgres | Extended | 是 | 需 ignore_startup_parameters = extra_float_digits | || psql 命令行 | Simple | 否 | 用简单查询 |

重要 :psycopg2 默认使用简单查询协议 ,不使用 Extended Protocol 的 prepared statement。prepare_thresholdpsycopg3 的参数,psycopg2 没有此参数。

9.4 如何判断 pgx 是否发送 Named Prepared Statement

pgx v5 有多种 QueryExecMode:

|| 模式 | 是否创建 Named Prepared Statement | 协议 | ||------|----------------------------------|------| || QueryExecModeSimpleProtocol | ❌ | Simple Query | || QueryExecModeExec | ❌(每次 Parse unnamed) | Extended | || QueryExecModeCacheStatement | ✅ | Extended + Named Prepared | || QueryExecModeDescribeExec | ❌ | Extended | || QueryExecModeCacheDescribe | ❌(缓存 Describe) | Extended |

方法1:开启 PostgreSQL 日志(推荐)

conf 复制代码
# postgresql.conf
log_statement = all
log_min_duration_statement = 0
  • Named Statement:LOG: execute stmtcache_1: SELECT ... → ✅

  • Unnamed:LOG: execute <unnamed>: SELECT ... → Extended 但 ❌ unnamed

  • Simple Protocol:LOG: statement: SELECT ... WHERE id=10 → 无 execute 关键字

方法2:查看 pg_prepared_statements

sql 复制代码
select * from pg_prepared_statements;
-- 通过 PgBouncer transaction 模式时,必须连接到同一个 backend 才能看到

方法3:抓 PostgreSQL 协议包(最准确)

bash 复制代码
tcpdump -i eth0 port 5432 -w pg.pcap
# 用 Wireshark 分析 Parse 消息中的 Statement 字段

9.5 验证 Extended Protocol 是否生效

sql 复制代码
SELECT name, statement, prepare_time
FROM pg_prepared_statements
WHERE name LIKE 'pgb_%';
-- 看到 pgb_ 前缀 → Extended Protocol 正在工作

第十部分:Prepared Statement 排查流程

10.1 报错 "prepared statement does not exist" 的五大原因

plaintext 复制代码
原因 1: PgBouncer 版本 < 1.21 (功能根本不存在)     ← 最常见
原因 2: max_prepared_statements = 0 (功能被禁用)    ← 很常见(默认就是 0)
原因 3: 应用用了 SQL 级 PREPARE (而非协议级)        ← 常见
原因 4: 值确实配小了 (唯一查询数超过上限)            ← 偶发
原因 5: 客户端驱动兼容性问题                        ← 特定场景

原因 1:版本 < 1.21

max_prepared_statements 功能从 1.21.0 才引入。检查:pgbouncer -V

原因 2:max_prepared_statements = 0

默认值是 0 (不是 200),即默认禁用。修复:max_prepared_statements = 200 + RELOAD;

原因 3:SQL 级 PREPARE

max_prepared_statements 只跟踪协议级预处理语句,不跟踪 SQL 级的。

解决方案:改用协议级或普通 SQL,或切换到 session pooling。

原因 4:值配小了

plaintext 复制代码
max_prepared_statements = 唯一查询数 × 1.5 (留余量)

原因 5:驱动兼容性问题

|| 驱动 | 问题 | 解决方案 | ||------|------|---------| || PHP/PDO | 需 PHP 8.4+ 且 libpq 17 | PDO::ATTR_EMULATE_PREPARES => true | || JDBC | prepareThreshold 间歇报错 | prepareThreshold=0 | || PgBouncer 1.22.0 | 已知 bug | 升级到 1.22.1+ |

10.2 完整排查流程

plaintext 复制代码
步骤 1: pgbouncer -V → 版本 < 1.21? → 升级
步骤 2: SHOW CONFIG → max_prepared_statements = 0? → 改为 200
步骤 3: 查应用用 SQL 级还是协议级 → SQL 级? → 改用协议级或普通 SQL
步骤 4: 统计唯一查询数 > 配置值? → 调大
步骤 5: 查驱动兼容性 → 不兼容? → 升级或禁用服务端 prepared
步骤 6: verbose = 2 查看详细日志

10.3 快速止血方案

方案 A:禁用服务端 prepared statement(最快)

|| 驱动 | 禁用方法 | ||------|---------| || JDBC | 连接串加 prepareThreshold=0 | || PHP PDO | PDO::ATTR_EMULATE_PREPARES => true | || psycopg3 | prepare=False | || Go pgx | prefer_simple_protocol=true |

方案 B:临时切 session 模式

ini 复制代码
pool_mode = session  # 牺牲复用率

方案 C:升级 PgBouncer + 启用跟踪(根治)

bash 复制代码
apt install --only-upgrade pgbouncer
# max_prepared_statements = 200 (在配置文件中显式设置)
systemctl restart pgbouncer

第十一部分:Go(pgx/GORM) 与 PgBouncer 的兼容方案

11.1 方案 A:稳定优先(不使用 Prepared Statement)

目标:GORM + pgx + PgBouncer transaction,不要使用 Prepared Statement

架构:GORM → database/sql → pgx → Extended Protocol (QueryExecModeExec) → PgBouncer → PostgreSQL

重要PreferSimpleProtocol: false不等于 QueryExecModeExec。pgx v5 的 DefaultQueryExecMode 默认值是 QueryExecModeCacheStatement。要确保走 Exec 模式,需要显式设置 config.DefaultQueryExecMode = pgx.QueryExecModeExec

PgBouncer 配置

ini 复制代码
pool_mode = transaction
max_prepared_statements = 0      ;; 关闭 tracking
max_client_conn = 1000
default_pool_size = 50

Go代码

go 复制代码
package main

import (
	"fmt"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
)

type User struct {
	ID   uint `gorm:"primaryKey"`
	Name string
	Age  int
}

func main() {
	dsn := "host=127.0.0.1 user=testuser password=123456 dbname=testdb port=6432"

	db, err := gorm.Open(
		postgres.New(postgres.Config{
			DSN:                   dsn,
			PreferSimpleProtocol: false,
			// 注意: 仅 PreferSimpleProtocol:false 不够,
			// 需显式创建 pgx config 并设 DefaultQueryExecMode = QueryExecModeExec
		}),
		&gorm.Config{
			PrepareStmt: false,  // GORM层关闭缓存
		},
	)
	if err != nil {
		panic(err)
	}

	db.AutoMigrate(&User{})
	user := User{Name: "Tom", Age: 20}
	db.Create(&user)
	db.Model(&User{}).Where("id=?", user.ID).Update("age", 30)

	var result User
	db.Where("id=?", user.ID).First(&result)
	fmt.Println(result)
}

11.2 方案 B:性能优先(pgx statement cache + PgBouncer tracking)

PgBouncer 配置

ini 复制代码
pool_mode = transaction
max_prepared_statements = 500
max_client_conn = 1000
default_pool_size = 50

不要server_reset_query_always=1

Go代码

GORM 的 PreferSimpleProtocol 不够表达 CacheStatement,推荐显式创建 pgx config:

go 复制代码
package main

import (
	"fmt"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/stdlib"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
)

type User struct {
	ID   uint `gorm:"primaryKey"`
	Name string
	Age  int
}

func main() {
	dsn := "postgres://testuser:123456@127.0.0.1:6432/testdb"

	config, err := pgx.ParseConfig(dsn)
	if err != nil {
		panic(err)
	}

	// pgx层: 显式设 CacheStatement
	config.DefaultQueryExecMode = pgx.QueryExecModeCacheStatement

	sqlDB := stdlib.OpenDB(*config)

	db, err := gorm.Open(
		postgres.New(postgres.Config{Conn: sqlDB}),
		&gorm.Config{PrepareStmt: false},
	)
	if err != nil {
		panic(err)
	}

	db.AutoMigrate(&User{})
	user := User{Name: "Tom", Age: 20}
	db.Create(&user)
	db.Model(&User{}).Where("id=?", user.ID).Update("age", 40)

	var result User
	db.Where("id=?", user.ID).First(&result)
	fmt.Println(result)
}

11.3 两个方案对比

|| | 方案A | 方案B | ||------|------|------| || GORM PrepareStmt | false | false | || 手工 PrepareContext | ❌ 不允许 | ⚠️ 可以但增加风险 | || pgx 模式 | Exec | CacheStatement | || max_prepared_statements | 0 | 500 | || Extended Protocol | ✅ | ✅ | || Named Prepared | ❌ | ✅ | || 性能 | 中 | 高 | || 稳定性 | 最高 | 较高 |

方案A/B 的默认前提 :应用不显式调用 PrepareContext,也不启用 GORM PrepareStmt

11.4 PrepareContext 与方案 A/B 的关系

如果代码中显式使用 **PrepareContext()**,那么:

  • 方案 A:不成立 ------PrepareContext 会无条件向 PG 发 Parse,绕过 pgx 的 DefaultQueryExecMode

  • 方案 B:也不成立 ------不再是 pgx CacheStatement 的控制路径

pgx 源码机制

PrepareContext 把 statement 注册进连接级缓存 preparedStatements,后续执行时 exec() 先查缓存,命中就直接走 **execPrepared**(Extended),完全绕过 **DefaultQueryExecMode**

唯一的"后门":无参数的查询强制 Simple Protocol。

go 复制代码
// conn.go:481
// Always use simple protocol when there are no arguments.
if len(arguments) == 0 {
    mode = QueryExecModeSimpleProtocol
}

GORM 的 PrepareStmt 也一样

go 复制代码
db, _ := gorm.Open(postgres.New(...), &gorm.Config{PrepareStmt: true})

它内部等价于 conn.PrepareContext(ctx, sql)因此对于 PgBouncer transaction,建议 **PrepareStmt: false**

完整修正后的方案定义

|| | 方案A | 方案B | ||------|------|------| || GORM PrepareStmt | false | false | || 手工 PrepareContext | ❌ 不允许 | ⚠️ 可以但增加风险 | || pgx CacheStatement | ❌ | ✅ | || max_prepared_statements | 0 | 500 | || transaction pool | 稳定 | 性能更高 |


第十二部分:Go(pgx/GORM) 走 Simple Protocol 的实测与坑

环境:gorm.io/driver/postgres v1.6.0 + github.com/jackc/pgx/v5 v5.6.0,通过 PgBouncer 6432 端口连接 PostgreSQL 15。 结论:仅靠改配置无法做到 ------只要代码里显式调用了 sqlDB.PrepareContext,无论 PreferSimpleProtocoltrue 还是 false,实际都走 Extended Protocol。

12.1 三种方案

方案 A:PreferSimpleProtocol:false + PrepareContext → 全部 execute(Extended)

go 复制代码
postgres.New(postgres.Config{
    DSN: "postgres://test:123456@192.168.139.12:6432/test",
    PreferSimpleProtocol: false,
})
selStmt, _ := sqlDB.PrepareContext(ctx, "SELECT id, name FROM users WHERE id = $1")
selStmt.QueryRowContext(ctx, id).Scan(&u.ID, &u.Name)

方案 B:PreferSimpleProtocol:true + PrepareContext → 业务 SQL 仍为 execute(Extended),仅无参查询 statement

仅改一行 PreferSimpleProtocol: true,业务代码不动。凡是经过 **PrepareContext** 的 SQL,两种配置毫无区别,都锁死在 Extended。

方案 C:PreferSimpleProtocol:true + 去掉 PrepareContext → 全部 statement:(真正 Simple)

go 复制代码
PreferSimpleProtocol: true,
// 不再 PrepareContext,直接执行
sqlDB.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id).Scan(&u.ID, &u.Name)
sqlDB.ExecContext(ctx, "INSERT INTO users (name) VALUES ($1)", name)

12.2 实测 PG 日志对比

方案 A :全 execute PGBOUNCER_xxx: ...

方案 B :业务 SQL 全 execute PGBOUNCER_xxx: ...;仅无参的 QueryContextstatement:

方案 C :全 statement: SELECT ... WHERE id = '1'(参数内联,无 parse/bind/execute)

12.3 为什么"半简单协议"会失效

PreferSimpleProtocol 只在 SQL 不在 preparedStatements 缓存时才生效;一旦显式 PrepareContext,该 SQL 就被锁死为 Extended Protocol。源码关键路径:

go 复制代码
// conn.go:486
if sd, ok := c.preparedStatements[sql]; ok {  // PrepareContext 已写入,必命中
    return c.execPrepared(ctx, sd, arguments)  // 直接发 Bind+Execute,mode 被忽略!
}

12.4 结论

|| 方案 | 配置 | 业务代码 | 是否真正 Simple | ||------|------|---------|----------------| || A | false + PrepareContext | 保留 | ❌ Extended | || B | true + PrepareContext | 保留 | ❌ 实际仍 Extended | || C | true + 无 PrepareContext | 改用 ExecContext | ✅ 真正 Simple |

行动建议

  1. 若想真正走 Simple Protocol:必须去掉 PrepareContext(方案 C)

  2. 若保留 PrepareContextPreferSimpleProtocol 是无效配置,建议设回 false

  3. Simple Protocol 在 PgBouncer transaction 模式下天然规避 prepared statement 问题

12.5 完整可复现代码

go.mod

go 复制代码
module go-demo

go 1.26.1

require (
	gorm.io/driver/postgres v1.6.0
	gorm.io/gorm v1.31.2
)

require (
	github.com/jackc/pgx/v5 v5.6.0 // indirect
	github.com/jackc/puddle/v2 v2.2.2 // indirect
	github.com/jinzhu/inflection v1.0.0 // indirect
	github.com/jinzhu/now v1.1.5 // indirect
	golang.org/x/crypto v0.31.0 // indirect
	golang.org/x/sync v0.10.0 // indirect
	golang.org/x/text v0.21.0 // indirect
)

方案 A 完整 main.go

go 复制代码
package main

import (
	"context"
	"fmt"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
)

type User struct {
	ID   int
	Name string
}

func main() {
	db, err := gorm.Open(
		postgres.New(postgres.Config{
			DSN:                   "postgres://test:123456@192.168.139.12:6432/test",
			PreferSimpleProtocol: false, // 带 PrepareContext 时 true/false 均走 Extended
		}),
		&gorm.Config{PrepareStmt: false},
	)
	if err != nil {
		panic(err)
	}

	sqlDB, _ := db.DB()
	ctx := context.Background()

	// STEP 1: PrepareContext 一次,QueryRowContext 多次
	selStmt, _ := sqlDB.PrepareContext(ctx, "SELECT id, name FROM users WHERE id = $1")
	defer selStmt.Close()

	for _, id := range []int{1, 2, 3} {

		var u User
		selStmt.QueryRowContext(ctx, id).Scan(&u.ID, &u.Name)
		fmt.Printf("  [id=%d] -> %+v\n", id, u)
	}

	// STEP 2: PrepareContext 一次,ExecContext 三次
	insStmt, _ := sqlDB.PrepareContext(ctx, "INSERT INTO users (name) VALUES ($1)")
	defer insStmt.Close()

	for _, name := range []string{"Alice_PrepStmt", "Bob_PrepStmt", "Charlie_PrepStmt"} {

		insStmt.ExecContext(ctx, name)
	}

	// STEP 3: PrepareContext 一次,ExecContext 两次
	updStmt, _ := sqlDB.PrepareContext(ctx, "UPDATE users SET name = $1 WHERE name = $2")
	defer updStmt.Close()

	for _, u := range []struct{ newName, oldName string }{

		{"Alice_Renamed", "Alice_PrepStmt"},
		{"Bob_Renamed", "Bob_PrepStmt"},
	} {
		updStmt.ExecContext(ctx, u.newName, u.oldName)
	}

	// STEP 4: PrepareContext 一次,ExecContext 三次
	delStmt, _ := sqlDB.PrepareContext(ctx, "DELETE FROM users WHERE name = $1")
	defer delStmt.Close()

	for _, name := range []string{"Alice_Renamed", "Bob_Renamed", "Charlie_PrepStmt"} {

		delStmt.ExecContext(ctx, name)
	}

	// STEP 5: 验证最终数据
	rows, _ := sqlDB.QueryContext(ctx, "SELECT id, name FROM users ORDER BY id")
	defer rows.Close()
	for rows.Next() {
		var u User
		rows.Scan(&u.ID, &u.Name)
		fmt.Printf("  id=%d  name=%s\n", u.ID, u.Name)
	}
}

方案 B

与方案 A 完全相同,仅第 20 行改为 PreferSimpleProtocol: true

方案 C 完整 main.go

go 复制代码
package main

import (
	"context"
	"fmt"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
)

type User struct {
	ID   int
	Name string
}

func main() {
	db, err := gorm.Open(
		postgres.New(postgres.Config{
			DSN:                   "postgres://test:123456@192.168.139.12:6432/test",
			PreferSimpleProtocol: true, // 去掉 PrepareContext 后此配置才真正生效
		}),
		&gorm.Config{PrepareStmt: false},
	)
	if err != nil {
		panic(err)
	}

	sqlDB, _ := db.DB()
	ctx := context.Background()

	// 不再 PrepareContext,直接 ExecContext/QueryRowContext

	for _, id := range []int{1, 2, 3} {

		var u User
		sqlDB.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id).Scan(&u.ID, &u.Name)
		fmt.Printf("  [id=%d] -> %+v\n", id, u)
	}


	for _, name := range []string{"Alice_PrepStmt", "Bob_PrepStmt", "Charlie_PrepStmt"} {

		sqlDB.ExecContext(ctx, "INSERT INTO users (name) VALUES ($1)", name)
	}


	for _, u := range []struct{ newName, oldName string }{

		{"Alice_Renamed", "Alice_PrepStmt"},
		{"Bob_Renamed", "Bob_PrepStmt"},
	} {
		sqlDB.ExecContext(ctx, "UPDATE users SET name = $1 WHERE name = $2", u.newName, u.oldName)
	}


	for _, name := range []string{"Alice_Renamed", "Bob_Renamed", "Charlie_PrepStmt"} {

		sqlDB.ExecContext(ctx, "DELETE FROM users WHERE name = $1", name)
	}

	rows, _ := sqlDB.QueryContext(ctx, "SELECT id, name FROM users ORDER BY id")
	defer rows.Close()
	for rows.Next() {
		var u User
		rows.Scan(&u.ID, &u.Name)
		fmt.Printf("  id=%d  name=%s\n", u.ID, u.Name)
	}
}

编译运行命令

bash 复制代码
# 交叉编译
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o demo-extended .   # 方案A
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o demo-semisimple . # 方案B
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o demo-simple .     # 方案C

# 抓 PG 日志
ssh root@192.168.139.12 "wc -c < /postgresql/pg15.18/data/log/postgresql-2026-07-19_000000.log > /tmp/logsize.txt"
ssh root@192.168.139.12 "timeout 60 /tmp/demo-extended"
ssh root@192.168.139.12 "LOG=/postgresql/pg15.18/data/log/postgresql-2026-07-19_000000.log; SIZE=\$(cat /tmp/logsize.txt); tail -c +\$((SIZE+1)) \$LOG | grep -E 'LOG:  (parse |bind |execute |statement:)'"

第十三部分:运维与监控

13.1 启动与停止

bash 复制代码
pgbouncer -d /etc/pgbouncer/pgbouncer.ini    # 启动(后台)
pgbouncer -v /etc/pgbouncer/pgbouncer.ini     # 前台启动(调试)
kill -HUP $(cat /var/run/postgresql/pgbouncer.pid)  # 热重载配置
kill -TERM $(cat /var/run/postgresql/pgbouncer.pid) # 安全停止
kill -QUIT $(cat /var/run/postgresql/pgbouncer.pid) # 立即停止

13.2 管理控制台

bash 复制代码
psql -p 6432 -U dbadmin pgbouncer

13.3 核心监控命令

sql 复制代码
SHOW POOLS;    -- ★ 最常用: cl_active, cl_waiting, sv_active, sv_idle, maxwait
SHOW STATS;    -- avg_query_time, avg_wait_time, total_query_count
SHOW TOTALS;   -- 汇总统计
SHOW SERVERS;  -- 后端连接详情
SHOW CLIENTS;  -- 客户端连接详情
SHOW DATABASES;
SHOW CONFIG;
SHOW LISTS;
SHOW MEM;
SHOW STATE;

13.4 SHOW POOLS 关键指标解读

|| 指标 | 健康值 | 异常信号 | ||------|--------|---------| || cl_waiting | 0 | > 0 说明池不够用 | || maxwait | 0 | 持续增长说明严重不足 | || sv_idle | ≤ pool_size | 长期等于 pool_size 说明池过大 | || sv_active | < pool_size | 长期等于 pool_size 说明池过小 |

13.5 常见运维场景

数据库重启

sql 复制代码
PAUSE db_main;     -- 暂停
-- PG 侧: systemctl restart postgresql
RESUME db_main;    -- 恢复

修改配置后生效

sql 复制代码
RELOAD;
WAIT_CLOSE db_main;  -- 如修改了 pool_size 等参数

PG 故障转移

sql 复制代码
RECONNECT db_main;   -- 渐进式
WAIT_CLOSE db_main;
-- 或紧急: KILL db_main; → 修改配置 → RELOAD; → RESUME db_main;

13.6 信号速查

|| 信号 | 等价命令 | 说明 | ||------|---------|------| || SIGHUP | RELOAD | 重新加载配置 | || SIGTERM | SHUTDOWN WAIT_FOR_CLIENTS | 等待客户端断开后关闭 | || SIGINT | SHUTDOWN WAIT_FOR_SERVERS | 等待后端连接释放后关闭 | || SIGQUIT | SHUTDOWN | 立即关闭 | || SIGUSR1 | PAUSE | 暂停 | || SIGUSR2 | RESUME | 恢复 |


第十四部分:生产最佳实践

14.1 安全最佳实践

ini 复制代码
auth_type = scram-sha-256           # 使用 SCRAM 认证
user = pgbouncer                     # 非 root 用户运行
admin_users = dbadmin               # 管理权限分离
stats_users = monitor
listen_addr = 10.0.0.5              # 只监听内网
# client_tls_sslmode = require      # 跨网络时启用 TLS

14.2 性能调优清单

ini 复制代码
idle_transaction_timeout = 60       # 防漏 COMMIT 卡连接
query_wait_timeout = 60             # 防客户端无限排队
max_prepared_statements = 200       # transaction 模式 (1.21+)
server_lifetime = 3600              # 定期重建连接

14.3 监控告警

sql 复制代码
-- 告警 1: cl_waiting > 0 持续 → 池不够
-- 告警 2: cur_client / max_client_conn > 80% → 前端接近上限
-- 告警 3: pg_stat_activity count / max_connections > 80% → 后端接近上限
-- 告警 4: idle in transaction 过多 → 应用漏 COMMIT

14.4 常见踩坑清单

|| 坑 | 症状 | 解决 | ||----|------|------| || max_client_connmax_connections | 复用价值丧失 | 前者远大于后者 | || Σ pool_size > max_connections | 部分池拿不到连接 | 前者 ≤ 后者 × 0.8 | || 应用连接池 > PgBouncer pool_size | 延迟尖峰 | 应用侧 ≤ PgBouncer | || transaction 模式用临时表 | 报错或数据丢失 | 改 session 或改造应用 | || 忘记设 max_db_connections | 单库吃光 PG 连接 | 锁死单库上限 | || ulimit -n 太小 | 大量连接时报错 | 调到 65535 | || server_reset_query_always=1 | 破坏应用状态 | 保持 0 | || DDL 后预处理语句报错 | cached plan must not change result type | 执行 RECONNECT | || 忘记留给 superuser 的连接 | DBA 连不上 | superuser_reserved_connections=5 | || PrepareContext + PreferSimpleProtocol:true | 仍走 Extended Protocol | 必须去掉 PrepareContext |


第十五部分:核心配置速查表

|| 参数 | 默认值 | 生产推荐 | 说明 | ||------|--------|---------|------| || pool_mode | session | transaction | 池化模式 | || max_client_conn | 100 | 1000~5000 | 前端连接上限 | || default_pool_size | 20 | 2~5 | 常态每池上限 | || max_db_connections | 0(无限) | 峰值 | 单库硬上限 | || reserve_pool_size | 0 | 1~3 | 突发缓冲 | || reserve_pool_timeout | 5 | 3 | 动用 reserve 的等待 | || server_idle_timeout | 600 | 600 | 空闲回收 | || server_lifetime | 3600 | 3600 | 连接最大生命周期 | || query_wait_timeout | 120 | 60 | 客户端排队超时 | || idle_transaction_timeout | 0 | 60 | 事务空闲超时 | || max_prepared_statements | 0 | 200 | 预处理语句跟踪(1.21+,默认禁用) | || auth_type | md5 | scram-sha-256 | 认证方式 | || server_reset_query | DISCARD ALL | DISCARD ALL | session 模式清理 |


一句话总结

PgBouncer 的核心价值是"压缩后端、释放前端 ":用少量昂贵的 PG 后端连接,支撑大量便宜的客户端连接。生产环境首选 transaction pooling 模式,配合 default_pool_size(管常态)、max_db_connections(锁峰值)、idle_transaction_timeout(防漏 COMMIT)、max_prepared_statements(支持协议级预处理语句,默认为 0 需显式开启 ),并确保 Σ pool_size ≤ PG max_connections × 0.8用 SHOW POOLS 的 **maxwait** **cl_waiting** 做调优依据,而非拍脑袋

相关推荐
IvorySQL12 小时前
PostgreSQL 日报 | JOIN 与外键优化(8 月 9 日)
数据库·postgresql
IvorySQL18 小时前
PostgreSQL 日报| PostgreSQL 19 默认 WAL 压缩算法(8 月 8 日)
数据库·postgresql·区块链
切糕师学AI19 小时前
从压缩到查询:PostgreSQL中JSON数据的存储与处理实践
数据库·postgresql·json
瀚高PG实验室1 天前
几种因网络波动导致应用与数据库操作异常的现象
运维·网络·数据库·postgresql·瀚高数据库
PGCCC3 天前
Ubuntu 安装 PostgreSQL 16 完整教程(2026):从仓库配置到远程连接
linux·ubuntu·postgresql
jnrjian3 天前
postgres database 更换用户
数据库·postgresql
李昊哲小课4 天前
Ubuntu24.04 PostgreSQL 18 完整安装与配置教程
ubuntu·postgresql
IvorySQL4 天前
PostgreSQL 日报|复制槽泄漏修复(8 月 6 日)
数据库·postgresql
喜欢的名字被抢了4 天前
FastAPI 接入异步 PostgreSQL 完成任务 CRUD 与数据库迁移
数据库·postgresql·fastapi