pgbouncer笔记 3

文章目录

  • [PgBouncer 1.25.2 编译安装指南](#PgBouncer 1.25.2 编译安装指南)
    • 一、环境信息
    • 二、安装编译依赖
    • 三、下载源码
    • 四、配置编译选项
    • 五、编译
    • 六、安装
    • 七、验证安装
    • 八、快速启动
    • [九、systemd 启停管理及开机自启](#九、systemd 启停管理及开机自启)
      • [9.1 创建 systemd Service 文件](#9.1 创建 systemd Service 文件)
      • [9.2 创建运行用户及目录权限](#9.2 创建运行用户及目录权限)
      • [9.3 pgbouncer.ini 中对应设置](#9.3 pgbouncer.ini 中对应设置)
      • [9.4 启停管理命令](#9.4 启停管理命令)
      • [9.5 设置开机自启](#9.5 设置开机自启)
      • [9.6 通过 Admin 控制台在线管理(连接不中断)](#9.6 通过 Admin 控制台在线管理(连接不中断))
    • [十、事务模式下启用 Extended Query Protocol](#十、事务模式下启用 Extended Query Protocol)
      • [10.1 背景](#10.1 背景)
      • [10.2 工作原理](#10.2 工作原理)
      • [10.3 配置示例](#10.3 配置示例)
      • [10.4 userlist.txt 示例](#10.4 userlist.txt 示例)
      • [10.5 常见驱动兼容性](#10.5 常见驱动兼容性)
      • [10.6 常见问题与排查](#10.6 常见问题与排查)
      • [10.7 事务模式下 Extended Protocol 的已知限制](#10.7 事务模式下 Extended Protocol 的已知限制)
    • 十一、一键脚本
    • [十二、经验总结:Go(pgx/gorm) 走 Simple Protocol 的实测与坑](#十二、经验总结:Go(pgx/gorm) 走 Simple Protocol 的实测与坑)
      • [12.1 三种方案与对应代码](#12.1 三种方案与对应代码)
        • [方案 A:原始代码(实测走 Extended Protocol)](#方案 A:原始代码(实测走 Extended Protocol))
        • [方案 B:半简单协议(实测仍是 Extended Protocol)](#方案 B:半简单协议(实测仍是 Extended Protocol))
        • [方案 C:纯简单协议(实测真正走 Simple Protocol)](#方案 C:纯简单协议(实测真正走 Simple Protocol))
      • [12.2 编译、上传与抓取日志命令](#12.2 编译、上传与抓取日志命令)
      • [12.3 实测 PG 日志对比](#12.3 实测 PG 日志对比)
      • [12.4 为什么"半简单协议"会失效(pgx 源码机制)](#12.4 为什么"半简单协议"会失效(pgx 源码机制))
      • [12.5 结论与 PgBouncer 配置建议](#12.5 结论与 PgBouncer 配置建议)
      • [12.6 完整可复现代码(文档自包含,无需另附 main.go)](#12.6 完整可复现代码(文档自包含,无需另附 main.go))
        • [12.6.1 依赖文件 `go.mod`](#12.6.1 依赖文件 go.mod)
        • [12.6.2 方案 A 完整 `main.go`(当前仓库版本,`false` + `PrepareContext`)](#12.6.2 方案 A 完整 main.go(当前仓库版本,false + PrepareContext))
        • [12.6.3 方案 B(`true` + 保留 `PrepareContext`)](#12.6.3 方案 B(true + 保留 PrepareContext))
        • [12.6.4 方案 C 完整 `main.go`(纯简单协议:去掉 `PrepareContext`)](#12.6.4 方案 C 完整 main.go(纯简单协议:去掉 PrepareContext))

PgBouncer 1.25.2 编译安装指南

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


一、环境信息

项目 详情
操作系统 openEuler 22.03
架构 aarch64 (ARM64)
内核 Linux 7.0.11
GCC 10.3.1
Make GNU Make
Libevent 2.1.12-stable
OpenSSL 1.1.1w (16 Nov 2023)
SSH 认证 密钥 /Users/lxm/.ssh/id_ed25519

二、安装编译依赖

bash 复制代码
dnf install -y libevent-devel openssl-devel
依赖 用途
libevent-devel 事件驱动库,PgBouncer 异步 I/O 核心
openssl-devel TLS/SSL 支持

三、下载源码

bash 复制代码
# 获取最新版本
curl -sL 'https://github.com/pgbouncer/pgbouncer/releases' \
  | grep -o 'tag/[^"]*' | head -1
# 输出: tag/pgbouncer_1_25_2

# 下载源码包
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

备注 : pgbouncer.org 下载页面返回 404,直接从 GitHub Releases 获取。


四、配置编译选项

bash 复制代码
./configure --prefix=/postgresql/pgbouncer

Configure 输出:

复制代码
Results:
  adns    = evdns2     # 使用 libevent 内置 DNS
  ldap    = no         # 未启用 LDAP 认证
  pam     = no         # 未启用 PAM 认证
  systemd = no         # 未启用 systemd 集成
  tls     = yes        # TLS/SSL 已启用

五、编译

bash 复制代码
make -j$(nproc)

遇到的问题及解决:

问题 原因 解决方式
make 在 man 文档生成时失败 缺少 pandoc 先编译二进制:make -j$(nproc) pgbouncer,再 make install(man 生成失败不影响安装)

完整操作步骤:

bash 复制代码
# 只编译二进制(跳过 man 文档)
make -j$(nproc) pgbouncer

# 安装(包含二进制 + 配置文件,man 自动跳过)
make install

六、安装

bash 复制代码
mkdir -p /postgresql/pgbouncer
make install

安装目录结构:

复制代码
/postgresql/pgbouncer/
├── bin/
│   └── pgbouncer                  # 主程序 (2.5MB)
├── conf/                          # 配置文件目录
│   ├── pgbouncer.ini              # 主配置
│   └── userlist.txt               # 用户认证
├── run/                           # 运行时 PID 目录
│   └── pgbouncer.pid
├── log/                           # 日志目录
│   └── pgbouncer.log
└── share/doc/pgbouncer/
    ├── pgbouncer.ini              # 完整配置示例 (11KB)
    ├── pgbouncer-minimal.ini      # 最小配置示例
    ├── userlist.txt               # 用户列表示例
    ├── pgbouncer.service          # systemd service 模板
    ├── pgbouncer.socket           # systemd socket 模板
    ├── README.md
    └── NEWS.md

统一路径设计原则 :所有 pgbouncer 相关文件(程序、配置、日志、运行时 PID)均收敛在 /postgresql/pgbouncer/ 下,便于备份、迁移和权限管理。打包整个目录即可完整搬迁。


七、验证安装

bash 复制代码
/postgresql/pgbouncer/bin/pgbouncer --version

输出:

复制代码
PgBouncer 1.25.2
libevent 2.1.12-stable
adns: evdns2
tls: OpenSSL 1.1.1wa  16 Nov 2023

八、快速启动

bash 复制代码
# 1. 创建配置、日志、运行时目录
mkdir -p /postgresql/pgbouncer/{conf,run,log}

# 2. 复制配置模板
cp /postgresql/pgbouncer/share/doc/pgbouncer/pgbouncer.ini /postgresql/pgbouncer/conf/

# 3. 按需修改配置
vi /postgresql/pgbouncer/conf/pgbouncer.ini

# 4. 创建用户列表
cp /postgresql/pgbouncer/share/doc/pgbouncer/userlist.txt /postgresql/pgbouncer/conf/

# 5. 启动
/postgresql/pgbouncer/bin/pgbouncer /postgresql/pgbouncer/conf/pgbouncer.ini

九、systemd 启停管理及开机自启

9.1 创建 systemd Service 文件

bash 复制代码
cat > /etc/systemd/system/pgbouncer.service << 'EOF'
[Unit]
Description=PgBouncer - PostgreSQL Connection Pooler
Documentation=https://www.pgbouncer.org/
After=network-online.target
Wants=network-online.target

[Service]
Type=forking
User=pgbouncer
Group=pgbouncer

# 二进制与配置文件路径(统一在 /postgresql/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

# 安全加固(可选)
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/postgresql/pgbouncer/run /postgresql/pgbouncer/log

# 资源限制
LimitNOFILE=65536
LimitNPROC=4096

[Install]
WantedBy=multi-user.target
EOF

9.2 创建运行用户及目录权限

bash 复制代码
# 创建专用用户(禁止登录,home 目录指向 /postgresql/pgbouncer/run)
useradd -r -s /sbin/nologin -d /postgresql/pgbouncer/run pgbouncer

# 创建 conf/run/log 子目录
mkdir -p /postgresql/pgbouncer/{conf,run,log}

# 整个 pgbouncer 目录授权给专用用户
chown -R pgbouncer:pgbouncer /postgresql/pgbouncer

9.3 pgbouncer.ini 中对应设置

ini 复制代码
[pgbouncer]
# PID 与日志路径(与 systemd unit 一致)
pidfile = /postgresql/pgbouncer/run/pgbouncer.pid
logfile = /postgresql/pgbouncer/log/pgbouncer.log

9.4 启停管理命令

bash 复制代码
# 重新加载 systemd 配置
systemctl daemon-reload

# 启动
systemctl start pgbouncer

# 查看状态
systemctl status pgbouncer

# 停止
systemctl stop pgbouncer

# 重启(连接会中断)
systemctl restart pgbouncer

# 热重载配置(不中断连接,发送 SIGHUP)
systemctl reload pgbouncer

# 查看日志
journalctl -u pgbouncer -f

9.5 设置开机自启

bash 复制代码
# 启用开机自启
systemctl enable pgbouncer

# 验证是否已启用
systemctl is-enabled pgbouncer
# 输出: enabled

# 禁用开机自启
# systemctl disable pgbouncer

9.6 通过 Admin 控制台在线管理(连接不中断)

bash 复制代码
# 连接到 Admin 控制台
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer

# 重载配置(无需重启进程)
RELOAD;

# 暂停接受新连接(维护窗口准备)
PAUSE;

# 恢复接受连接
RESUME;

# 优雅关闭(等待活跃事务结束)
SHUTDOWN;

# 查看连接池状态
SHOW POOLS;
SHOW STATS;
SHOW CLIENTS;
SHOW SERVERS;
SHOW DATABASES;
SHOW USERS;

注意 : SHUTDOWN 命令停止进程后,systemd 的 Restart=on-failure 会将其自动拉起。如需彻底停止,先执行 systemctl stop pgbouncer


十、事务模式下启用 Extended Query Protocol

10.1 背景

Extended Query Protocol(扩展查询协议)是 PostgreSQL 的核心协议,支持:

  • Prepared Statements(预编译语句):解析一次,多次执行,避免硬解析开销
  • 参数化查询:绑定参数而非拼接 SQL,防止 SQL 注入
  • 游标操作(Portal):支持分批获取大数据集

PgBouncer 1.21+ 版本在事务模式下支持协议级别的 Prepared Statement 跟踪,无需再因使用预编译语句而退化为 Session 模式。

10.2 工作原理

复制代码
客户端                    PgBouncer                  PostgreSQL
  |                          |                           |
  |-- Parse(name, sql, params) -->|                           |
  |                          |-- 根据 name=schema 重命名为 pgb_xxx -->|
  |                          |<-- ParseComplete ---------|
  |<-- ParseComplete ---------|                           |
  |                          |                           |
  |-- Bind(pgb_xxx, params) ->|                           |
  |                          |-- Bind(pgb_xxx, params) ->|
  |                          |<-- BindComplete ----------|
  |<-- BindComplete ----------|                           |
  |                          |                           |
  |-- Execute() ------------->|                           |
  |                          |-- Execute() ------------->|
  |                          |<-- DataRow(s) ------------|
  |<-- DataRow(s) -----------|                           |
  |                          |                           |
  |-- Sync ------------------>|      事务提交后:
  |                          |-- DEALLOCATE pgb_xxx ---->|  ← 清理服务端 Prepared Statement
  |                          |<-- CloseComplete ---------|
  |<-- CloseComplete ---------|      连接归还池中,干净状态 |

关键机制

  1. PgBouncer 自动对客户端 Prepared Statement 名称加前缀(pgb_),避免不同客户端之间的命名冲突
  2. 事务结束时,PgBouncer 自动 DEALLOCATE 该连接上所有残留的 Prepared Statement
  3. 确保连接归还池时处于"干净"状态

10.3 配置示例

ini 复制代码
[databases]
# 数据库连接定义
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
# ========== 监听配置 ==========
listen_addr = 0.0.0.0
listen_port = 6432

# ========== 认证 ==========
auth_type = scram-sha-256
auth_file = /postgresql/pgbouncer/conf/userlist.txt

# ========== 连接池模式 ==========
# 事务模式:连接在事务结束后归还池,支持 Extended Protocol (1.21+)
pool_mode = transaction

# ========== Extended Protocol 核心配置 ==========
# 启用服务端 Prepared Statement 跟踪(默认 on,1.21+ 必须)
prepare_statements = true

# 每连接最多缓存的 Prepared Statement 数量(默认 100)
# 如果应用大量使用预编译语句且跨越不同 SQL,适当调大
max_prepare_statements = 100

# 跟踪 extra_float_digits 参数(默认已忽略)
# 某些 ORM(如 pgx、rust-postgres)会在启动时设置此参数
ignore_startup_parameters = extra_float_digits

# ========== 连接池大小 ==========
default_pool_size = 25
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3.0
max_client_conn = 500
max_db_connections = 50

# ========== 超时设置 ==========
server_idle_timeout = 600        # 服务端空闲连接超时 (10min)
client_idle_timeout = 600        # 客户端空闲连接超时 (10min)
server_lifetime = 3600           # 服务端连接最大生命周期 (1h)
server_connect_timeout = 15      # 连接后端超时
query_timeout = 0                # 查询超时(0=不限制)
client_login_timeout = 60        # 客户端登录超时

# ========== 日志 ==========
log_connections = 1
log_disconnections = 1
stats_period = 60
verbose = 0

# ========== 进程管理 ==========
pidfile = /postgresql/pgbouncer/run/pgbouncer.pid
logfile = /postgresql/pgbouncer/log/pgbouncer.log

# Admin 控制台
admin_users = pgbouncer_admin
stats_users = pgbouncer_stats

10.4 userlist.txt 示例

复制代码
"pgbouncer_admin" "SCRAM-SHA-256$..."
"pgbouncer_stats" "SCRAM-SHA-256$..."
"app_user" "SCRAM-SHA-256$..."

用户密码哈希生成:

bash 复制代码
# 交互式输入密码
/postgresql/pgbouncer/bin/pgbouncer --auth-type=scram-sha-256 /dev/null <<< ""

# 或者直接使用 PG 的 scram 哈希
# SELECT rolpassword FROM pg_authid WHERE rolname = 'app_user';

10.5 常见驱动兼容性

驱动/语言 默认协议 兼容性 注意事项
libpq © Simple 完全兼容 PQexecParams 使用 Extended Protocol
pgx (Go) Extended 完全兼容 默认使用 Prepared Statement,需 pool_mode=transaction + prepare_statements=true
psycopg2 (Python) Extended 完全兼容 默认使用 Extended Protocol
Npgsql (.NET) Extended 完全兼容 默认自动 Prepare
JDBC (Java) Extended 完全兼容 prepareThreshold 控制何时自动 Prepare
rust-postgres Extended 完全兼容 ignore_startup_parameters = extra_float_digits

10.6 常见问题与排查

问题一:客户端报 "prepared statement does not exist"

原因:事务结束后 Prepared Statement 被清理,但客户端尝试复用。

解决:

ini 复制代码
# 确保已启用
prepare_statements = true
# 适当增大缓存
max_prepare_statements = 200

问题二:go-pg / pgx 连接报 "unknown startup parameter: extra_float_digits"

解决:

ini 复制代码
ignore_startup_parameters = extra_float_digits

问题三:Python psycopg2 自动 Prepare 失败

解决:psycopg2 默认 prepare_threshold=5(第 6 次执行同 SQL 时自动 Prepare),如不使用可关闭:

python 复制代码
import psycopg2
conn = psycopg2.connect(dsn, prepare_threshold=None)  # 禁用自动 Prepare

问题四:如何确认 Extended Protocol 生效?

sql 复制代码
-- 在 PG 端查询 Prepared Statements
SELECT name, statement, prepare_time
FROM pg_prepared_statements
WHERE name LIKE 'pgb_%';
-- 如果看到 pgb_ 前缀的条目,说明 Extended Protocol 正在工作

10.7 事务模式下 Extended Protocol 的已知限制

限制 说明 替代方案
不支持命名 Portal DECLARE CURSOR 需要 Session 模式 使用 WITH HOLD 或切换 pool_mode=session
不支持 LISTEN/NOTIFY 跨事务的异步通知 切换到 Session 模式
不支持 SET/RESET 跨事务 Session 级参数需 Session 模式 通过连接串参数或 server_reset_query 设置
不支持临时表跨事务 临时表在事务结束后自动删除 切换到 Session 模式
不支持 PREPARE TRANSACTION 两阶段提交 需直连 PG 或 Session 模式

十一、一键脚本

bash 复制代码
#!/bin/bash
# pgbouncer_install.sh - PgBouncer 一键编译安装
set -e

VERSION="1.25.2"
INSTALL_PREFIX="/postgresql/pgbouncer"
BUILD_DIR="/tmp/pgbouncer_build"

echo ">>> 安装依赖..."
dnf install -y libevent-devel openssl-devel

echo ">>> 下载源码..."
mkdir -p "$BUILD_DIR"
cd "$BUILD_DIR"
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}"

echo ">>> 配置..."
./configure --prefix="$INSTALL_PREFIX"

echo ">>> 编译..."
make -j$(nproc) pgbouncer

echo ">>> 安装..."
mkdir -p "$INSTALL_PREFIX"
make install

echo ">>> 创建 conf/run/log 子目录..."
mkdir -p "$INSTALL_PREFIX"/{conf,run,log}

echo ">>> 复制配置文件..."
cp "$INSTALL_PREFIX"/share/doc/pgbouncer/pgbouncer.ini "$INSTALL_PREFIX"/conf/
cp "$INSTALL_PREFIX"/share/doc/pgbouncer/userlist.txt "$INSTALL_PREFIX"/conf/

echo ">>> 验证..."
"$INSTALL_PREFIX/bin/pgbouncer" --version

echo ">>> 完成!安装路径: $INSTALL_PREFIX"
echo ""
echo ">>> 后续步骤:"
echo "    1. 创建运行用户:  useradd -r -s /sbin/nologin -d $INSTALL_PREFIX/run pgbouncer"
echo "    2. 授权:          chown -R pgbouncer:pgbouncer $INSTALL_PREFIX"
echo "    3. 修改配置:      vi $INSTALL_PREFIX/conf/pgbouncer.ini"
echo "    4. 部署 systemd:  参考文档第九节"
echo "    5. 启动:          systemctl start pgbouncer"

十二、经验总结:Go(pgx/gorm) 走 Simple Protocol 的实测与坑

本文基于一次真实排查过程整理。环境:应用侧 gorm.io/driver/postgres v1.6.0 + github.com/jackc/pgx/v5 v5.6.0,通过 PgBouncer 6432 端口pool_mode=transaction + prepare_statements=true)连接 PostgreSQL 15。

核心诉求:仅修改 Go 配置项 PreferSimpleProtocol不改任何业务代码 ,让程序走 Simple Query Protocol。

结论先行:仅靠改配置无法做到 ------只要代码里显式调用了 sqlDB.PrepareContext,无论 PreferSimpleProtocoltrue 还是 false,实际都走 Extended Protocol。要真正走 Simple,必须去掉 PrepareContext

12.1 三种方案与对应代码

被测 main.go 的业务逻辑一致:Prepare → 多参数执行(SELECT/INSERT/UPDATE/DELETE),最后一条无参 SELECT 验证。区别仅在于(1)PreferSimpleProtocol 取值;(2)是否保留 PrepareContext

方案 A:原始代码(实测走 Extended Protocol)

配置与调用方式(节选):

go 复制代码
// main.go 第 18-25 行
postgres.New(postgres.Config{
    DSN:                   "postgres://test:123456@192.168.139.12:6432/test",
    PreferSimpleProtocol: false, // Extended Query Protocol
})
// ...
selStmt, _ := sqlDB.PrepareContext(ctx, "SELECT id, name FROM users WHERE id = $1")
selStmt.QueryRowContext(ctx, id).Scan(&u.ID, &u.Name)   // 复用 prepared statement
// INSERT / UPDATE / DELETE 同理用 PrepareContext + ExecContext
rows, _ := sqlDB.QueryContext(ctx, "SELECT id, name FROM users ORDER BY id") // STEP5 无参
方案 B:半简单协议(实测仍是 Extended Protocol)

仅改一行配置 ,业务代码(含 PrepareContext)完全不动:

go 复制代码
// main.go 第 20 行由 false 改为 true
PreferSimpleProtocol: true, // 期望执行走 Simple,但 PrepareContext 仍在
方案 C:纯简单协议(实测真正走 Simple Protocol)

PreferSimpleProtocol: true 且去掉所有 PrepareContext ,改用 sqlDB.ExecContext/QueryRowContext 直接带参执行:

go 复制代码
// main.go 第 20 行
PreferSimpleProtocol: true,
// ...
// 不再 PrepareContext,直接执行;参数随调用传入
for _, id := range []int{1, 2, 3} {
    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)
sqlDB.ExecContext(ctx, "UPDATE users SET name = $1 WHERE name = $2", u.newName, u.oldName)
sqlDB.ExecContext(ctx, "DELETE FROM users WHERE name = $1", name)
rows, _ := sqlDB.QueryContext(ctx, "SELECT id, name FROM users ORDER BY id")

12.2 编译、上传与抓取日志命令

交叉编译(macOS → linux/arm64),上传到服务器后运行,并只截取本次运行新增的 PG 日志:

bash 复制代码
# 1. 交叉编译(三种方案分别产出不同二进制名)
cd go-demo
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

# 2. 上传二进制
scp -i ~/.ssh/id_ed25519 demo-extended root@192.168.139.12:/tmp/

# 3. 运行前记录当前日志字节数(用于隔离本次输出)
ssh -i ~/.ssh/id_ed25519 root@192.168.139.12 \
  "wc -c < /postgresql/pg15.18/data/log/postgresql-2026-07-19_000000.log > /tmp/logsize.txt"

# 4. 运行程序
ssh -i ~/.ssh/id_ed25519 root@192.168.139.12 "timeout 60 /tmp/demo-extended"

# 5. 只抽取本次新增日志中的协议行(parse / bind / execute / statement)
ssh -i ~/.ssh/id_ed25519 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:)'"

注意:PG 日志可能在整点轮转(如本例 00:00 切到 postgresql-2026-07-19_000000.log)。运行前后务必确认活动日志文件路径一致,否则抓不到新增内容。

12.3 实测 PG 日志对比

PG 端 log_min_messages/log_statement 配置下,parse/bind 文本行不一定落盘(本环境只稳定记录 executestatement),但这两类已足以判定协议。

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

复制代码
LOG:  statement: -- ping                       ← gorm 探活,无参,强制 Simple
LOG:  execute PGBOUNCER_6: SELECT id, name FROM users WHERE id = $1   ×3
LOG:  execute PGBOUNCER_7: INSERT INTO users (name) VALUES ($1)        ×3
LOG:  execute PGBOUNCER_8: UPDATE users SET name = $1 WHERE name = $2  ×2
LOG:  execute PGBOUNCER_9: DELETE FROM users WHERE name = $1           ×3
LOG:  execute PGBOUNCER_10: SELECT id, name FROM users ORDER BY id     ← STEP5 也走 execute

方案 B(true + PrepareContext)------ 仍为 execute,仅无参查询走 statement

复制代码
LOG:  statement: -- ping                       ← 无参,强制 Simple
LOG:  execute PGBOUNCER_1: SELECT id, name FROM users WHERE id = $1   ×3
LOG:  execute PGBOUNCER_2: INSERT INTO users (name) VALUES ($1)        ×3
LOG:  execute PGBOUNCER_3: UPDATE users SET name = $1 WHERE name = $2  ×2
LOG:  execute PGBOUNCER_4: DELETE FROM users WHERE name = $1           ×3
LOG:  statement: SELECT id, name FROM users ORDER BY id   ← STEP5 无参,受 SimpleProtocol 影响走 statement

方案 A 与 B 的 STEP1--4 日志完全一致(全是 execute 。唯一差异在 STEP5:因为 STEP5 用的是 sqlDB.QueryContext(没有 PrepareContext),才受 PreferSimpleProtocol 控制------方案 B 走 statement,方案 A 走 execute。这恰好反证:凡是经过 PrepareContext 的 SQL,两种配置毫无区别,都锁死在 Extended。

方案 C(true + 去掉 PrepareContext)------ 全部 statement: 且参数内联(真正 Simple):

复制代码
LOG:  statement: SELECT id, name FROM users WHERE id =  '1'
LOG:  statement: SELECT id, name FROM users WHERE id =  '2'
LOG:  statement: SELECT id, name FROM users WHERE id =  '3'
LOG:  statement: INSERT INTO users (name) VALUES ( 'Alice_PrepStmt' )
LOG:  statement: INSERT INTO users (name) VALUES ( 'Bob_PrepStmt' )
LOG:  statement: INSERT INTO users (name) VALUES ( 'Charlie_PrepStmt' )
LOG:  statement: UPDATE users SET name =  'Alice_Renamed'  WHERE name =  'Alice_PrepStmt'
LOG:  statement: UPDATE users SET name =  'Bob_Renamed'  WHERE name =  'Bob_PrepStmt'
LOG:  statement: DELETE FROM users WHERE name =  'Alice_Renamed'
LOG:  statement: DELETE FROM users WHERE name =  'Bob_Renamed'
LOG:  statement: DELETE FROM users WHERE name =  'Charlie_PrepStmt'
LOG:  statement: SELECT id, name FROM users ORDER BY id

关键特征:parse/bind/execute 任何一行 ,参数被 pgx 直接拼进 SQL 文本(WHERE id = '1',双空格是 sanitizeForSimpleQuery 产物)。这正是 Simple Query Protocol。

12.4 为什么"半简单协议"会失效(pgx 源码机制)

仅看配置直觉会以为 PreferSimpleProtocol: true 就让执行走 Simple。读 pgx/v5 源码才知另有分支:

  1. gorm 把配置映射成 DefaultQueryExecModegorm.io/driver/postgres@v1.6.0/postgres.go):
go 复制代码
// gorm postgres.go
if dialector.Config.PreferSimpleProtocol {
    config.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol
}
  1. PrepareContext 会无条件向 PG 发 Parse,并把 statement 注册进连接级缓存 preparedStatementsgithub.com/jackc/pgx/v5@v5.6.0/stdlib/sql.go):
go 复制代码
// stdlib/sql.go:422
func (c *Conn) PrepareContext(ctx context.Context, query string) (driver.Stmt, error) {
    sd, err := c.conn.Prepare(ctx, query, query) // 发 Parse,写入 preparedStatements
    ...
}
  1. c.conn.Prepare 把 SQL 写入缓存(conn.go):
go 复制代码
// conn.go:333
if psKey != "" {
    c.preparedStatements[psKey] = sd // 关键:后续 Exec 会命中它
}
  1. 真正执行时,exec() 先查缓存,命中就直接走 execPrepared(Extended),完全绕过 DefaultQueryExecModeconn.go):
go 复制代码
// conn.go:456
func (c *Conn) exec(ctx context.Context, sql string, arguments ...any) (commandTag, err) {
    mode := c.config.DefaultQueryExecMode // = SimpleProtocol(方案B设的)
    ...
    if sd, ok := c.preparedStatements[sql]; ok {  // ← 第486行:PrepareContext 已写入,必命中
        return c.execPrepared(ctx, sd, arguments) // ← 直接发 Bind+Execute,mode 被忽略!
    }
    switch mode {
    case QueryExecModeSimpleProtocol:
        return c.execSimpleProtocol(ctx, sql, arguments) // ← 没机会走到
    }
}
  1. 唯一的"后门"是第 482 行的硬规则------无参数的查询强制 Simple ,这解释了方案 B 里 STEP5(无参 SELECT)为何走 statement
go 复制代码
// conn.go:481
// Always use simple protocol when there are no arguments.
if len(arguments) == 0 {
    mode = QueryExecModeSimpleProtocol
}

一句话机制PreferSimpleProtocol 只在 SQL 不在 preparedStatements 缓存时(即没有先 PrepareContext)才生效;一旦显式 PrepareContext,该 SQL 就被锁死为 Extended Protocol。

12.5 结论与 PgBouncer 配置建议

方案 配置 业务代码 PG 日志 是否真正 Simple
A PreferSimpleProtocol:false 保留 PrepareContext execute ❌ Extended
B PreferSimpleProtocol:true 保留 PrepareContext 业务 SQL 全 execute;仅无参查询 statement ❌ 实际仍 Extended
C PreferSimpleProtocol:true 去掉 PrepareContext,用 ExecContext/QueryRowContext statement:(参数内联) ✅ 真正 Simple

行动建议:

  1. 若想真正走 Simple Protocol :必须改业务代码去掉 PrepareContext(方案 C)。仅靠 PreferSimpleProtocol 配置无法生效------前提是代码里没有任何显式 database/sqlPrepare
  2. 若保留 PrepareContext(如为语句复用/批量执行)PreferSimpleProtocol 无论 true/false 都走 Extended,该配置在带 Prepare 的代码下是无效配置,建议设回 false 避免误导
  3. PgBouncer 侧对应关系 (与本文第十章一致):
    • Extended Protocol(prepare/bind/execute)在 pool_mode=transaction 下可用,需 prepare_statements = true + 足够的 max_prepare_statements(默认 100/200,本文环境 200),Server 端日志会看到 pgb_ 前缀的 statement。
    • Simple Protocol 不依赖 prepared statement 跟踪 ,在 session/transaction 任意模式下都能透传,因此方案 C 在现有事务模式 PgBouncer 上直接可用,无需改 PgBouncer 配置。
    • 在 PgBouncer 前置场景下,Simple Protocol 的"参数内联进单条 Query"天然规避了跨连接 prepared statement 失效问题(如 prepared statement "pgb_xxx" does not exist),是规避第十章 10.6 问题一的兜底手段。

12.6 完整可复现代码(文档自包含,无需另附 main.go)

本节给出复现第十二章全部实验所需的完整源码。只需把代码放进 go-demo/ 目录(go.mod + main.go),即可编译运行 ,无需再单独发送 main.go 文件。

12.6.1 依赖文件 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/pgpassfile v1.0.0 // indirect
	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
	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
)

关键版本:gorm.io/driver/postgres v1.6.0PreferSimpleProtocol 映射为 pgx.QueryExecModeSimpleProtocolgithub.com/jackc/pgx/v5 v5.6.0conn.go 存在 12.4 节描述的 preparedStatements 缓存分支。版本不同结论可能变化,请以实际依赖为准。

12.6.2 方案 A 完整 main.go(当前仓库版本,false + PrepareContext
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, // Extended Query Protocol(实测:带 PrepareContext 时 true/false 均走 Extended)
		}),
		&gorm.Config{
			PrepareStmt: false,
		},
	)
	if err != nil {
		panic(err)
	}

	sqlDB, err := db.DB()
	if err != nil {
		panic(err)
	}

	ctx := context.Background()

	// ============================================================
	// STEP 1: PrepareContext 一次,QueryRowContext 多次(不同 ID)
	//         预期日志: 1×Parse + 3×(Bind+Execute), 三组不同参数
	// ============================================================
	fmt.Println("========== STEP 1: Multi-SELECT (1 Parse, 3 Bind+Execute) ==========")
	selStmt, err := sqlDB.PrepareContext(ctx, "SELECT id, name FROM users WHERE id = $1")
	if err != nil {
		panic(fmt.Sprintf("PrepareContext SELECT: %v", err))
	}
	defer selStmt.Close()

	for _, id := range []int{1, 2, 3} {
		var u User
		if err := selStmt.QueryRowContext(ctx, id).Scan(&u.ID, &u.Name); err != nil {
			panic(fmt.Sprintf("QueryRowContext id=%d: %v", id, err))
		}
		fmt.Printf("  [id=%d] -> %+v\n", id, u)
	}

	// ============================================================
	// STEP 2: PrepareContext 一次,ExecContext 三次(不同 name)
	//         预期日志: 1×Parse + 3×(Bind+Execute), 三组不同参数
	//         这是核心示范:同一个 prepared statement 被多次复用
	// ============================================================
	fmt.Println("\n========== STEP 2: Multi-INSERT (1 Parse, 3 Bind+Execute) ==========")
	insStmt, err := sqlDB.PrepareContext(ctx, "INSERT INTO users (name) VALUES ($1)")
	if err != nil {
		panic(fmt.Sprintf("PrepareContext INSERT: %v", err))
	}
	defer insStmt.Close()

	names := []string{"Alice_PrepStmt", "Bob_PrepStmt", "Charlie_PrepStmt"}
	for _, name := range names {
		result, err := insStmt.ExecContext(ctx, name)
		if err != nil {
			panic(fmt.Sprintf("ExecContext INSERT '%s': %v", name, err))
		}
		n, _ := result.RowsAffected()
		fmt.Printf("  INSERT '%s' -> rowsAffected=%d\n", name, n)
	}

	// ============================================================
	// STEP 3: PrepareContext 一次,ExecContext 两次(批量更新两行)
	//         预期日志: 1×Parse + 2×(Bind+Execute)
	// ============================================================
	fmt.Println("\n========== STEP 3: Multi-UPDATE (1 Parse, 2 Bind+Execute) ==========")
	updStmt, err := sqlDB.PrepareContext(ctx, "UPDATE users SET name = $1 WHERE name = $2")
	if err != nil {
		panic(fmt.Sprintf("PrepareContext UPDATE: %v", err))
	}
	defer updStmt.Close()

	updates := []struct{ newName, oldName string }{
		{"Alice_Renamed", "Alice_PrepStmt"},
		{"Bob_Renamed", "Bob_PrepStmt"},
	}
	for _, u := range updates {
		result, err := updStmt.ExecContext(ctx, u.newName, u.oldName)
		if err != nil {
			panic(fmt.Sprintf("ExecContext UPDATE '%s'->'%s': %v", u.oldName, u.newName, err))
		}
		n, _ := result.RowsAffected()
		fmt.Printf("  UPDATE '%s' -> '%s', rowsAffected=%d\n", u.oldName, u.newName, n)
	}

	// ============================================================
	// STEP 4: PrepareContext 一次,ExecContext 三次(清理本轮插入的行)
	//         预期日志: 1×Parse + 3×(Bind+Execute)
	// ============================================================
	fmt.Println("\n========== STEP 4: Multi-DELETE (1 Parse, 3 Bind+Execute) ==========")
	delStmt, err := sqlDB.PrepareContext(ctx, "DELETE FROM users WHERE name = $1")
	if err != nil {
		panic(fmt.Sprintf("PrepareContext DELETE: %v", err))
	}
	defer delStmt.Close()

	toDelete := []string{"Alice_Renamed", "Bob_Renamed", "Charlie_PrepStmt"}
	for _, name := range toDelete {
		result, err := delStmt.ExecContext(ctx, name)
		if err != nil {
			panic(fmt.Sprintf("ExecContext DELETE '%s': %v", name, err))
		}
		n, _ := result.RowsAffected()
		fmt.Printf("  DELETE '%s' -> rowsAffected=%d\n", name, n)
	}

	// ============================================================
	// STEP 5: 验证最终数据(应该只剩原始 3 条)
	// ============================================================
	fmt.Println("\n========== STEP 5: Final SELECT (verify cleanup) ==========")
	rows, err := sqlDB.QueryContext(ctx, "SELECT id, name FROM users ORDER BY id")
	if err != nil {
		panic(fmt.Sprintf("QueryContext: %v", err))
	}
	defer rows.Close()

	fmt.Println("Remaining users:")
	for rows.Next() {
		var u User
		if err := rows.Scan(&u.ID, &u.Name); err != nil {
			panic(err)
		}
		fmt.Printf("  id=%d  name=%s\n", u.ID, u.Name)
	}

	fmt.Println("\n========== ALL DONE ==========")
}
12.6.3 方案 B(true + 保留 PrepareContext

与 12.6.2 完全相同,仅第 20 行由 false 改为 true

go 复制代码
			PreferSimpleProtocol: true, // 半简单协议:期望执行走 Simple,但 PrepareContext 仍在

其余 143 行原样不变。改完编译运行后的 PG 日志见 12.3 节「方案 B」------业务 SQL 仍为 execute(Extended),仅无参的 STEP5 走 statement

12.6.4 方案 C 完整 main.go(纯简单协议:去掉 PrepareContext
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, err := db.DB()
	if err != nil {
		panic(err)
	}

	ctx := context.Background()

	// ============================================================
	// 方案C(纯简单协议):不再 PrepareContext,直接 ExecContext/QueryRowContext
	// 参数内联进 SQL 文本,PG 日志记为 statement:(无 parse/bind/execute)
	// ============================================================
	fmt.Println("========== STEP 1: Multi-SELECT (Simple Protocol) ==========")
	for _, id := range []int{1, 2, 3} {
		var u User
		if err := sqlDB.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id).Scan(&u.ID, &u.Name); err != nil {
			panic(fmt.Sprintf("QueryRowContext id=%d: %v", id, err))
		}
		fmt.Printf("  [id=%d] -> %+v\n", id, u)
	}

	fmt.Println("\n========== STEP 2: Multi-INSERT (Simple Protocol) ==========")
	names := []string{"Alice_PrepStmt", "Bob_PrepStmt", "Charlie_PrepStmt"}
	for _, name := range names {
		result, err := sqlDB.ExecContext(ctx, "INSERT INTO users (name) VALUES ($1)", name)
		if err != nil {
			panic(fmt.Sprintf("ExecContext INSERT '%s': %v", name, err))
		}
		n, _ := result.RowsAffected()
		fmt.Printf("  INSERT '%s' -> rowsAffected=%d\n", name, n)
	}

	fmt.Println("\n========== STEP 3: Multi-UPDATE (Simple Protocol) ==========")
	updates := []struct{ newName, oldName string }{
		{"Alice_Renamed", "Alice_PrepStmt"},
		{"Bob_Renamed", "Bob_PrepStmt"},
	}
	for _, u := range updates {
		result, err := sqlDB.ExecContext(ctx, "UPDATE users SET name = $1 WHERE name = $2", u.newName, u.oldName)
		if err != nil {
			panic(fmt.Sprintf("ExecContext UPDATE '%s'->'%s': %v", u.oldName, u.newName, err))
		}
		n, _ := result.RowsAffected()
		fmt.Printf("  UPDATE '%s' -> '%s', rowsAffected=%d\n", u.oldName, u.newName, n)
	}

	fmt.Println("\n========== STEP 4: Multi-DELETE (Simple Protocol) ==========")
	toDelete := []string{"Alice_Renamed", "Bob_Renamed", "Charlie_PrepStmt"}
	for _, name := range toDelete {
		result, err := sqlDB.ExecContext(ctx, "DELETE FROM users WHERE name = $1", name)
		if err != nil {
			panic(fmt.Sprintf("ExecContext DELETE '%s': %v", name, err))
		}
		n, _ := result.RowsAffected()
		fmt.Printf("  DELETE '%s' -> rowsAffected=%d\n", name, n)
	}

	fmt.Println("\n========== STEP 5: Final SELECT (verify cleanup) ==========")
	rows, err := sqlDB.QueryContext(ctx, "SELECT id, name FROM users ORDER BY id")
	if err != nil {
		panic(fmt.Sprintf("QueryContext: %v", err))
	}
	defer rows.Close()

	fmt.Println("Remaining users:")
	for rows.Next() {
		var u User
		if err := rows.Scan(&u.ID, &u.Name); err != nil {
			panic(err)
		}
		fmt.Printf("  id=%d  name=%s\n", u.ID, u.Name)
	}

	fmt.Println("\n========== ALL DONE ==========")
}

把以上任一 main.go 与 12.6.1 的 go.mod 放在同一目录,执行 12.2 节的编译命令即可复现对应协议的 PG 日志。DSN 中的密码、IP、端口、库名请按实际环境调整。

相关推荐
jnrjian14 小时前
\dx Postgres 查看EXTENSION
postgresql
Lihua奏15 小时前
高可用与扩展:一台 PostgreSQL 不够用之后怎么办?
postgresql
鸽芷咕19 小时前
MySQL/PostgreSQL 迁移金仓 KES:LEFT JOIN 丢数据排查与避坑指南
数据库·mysql·postgresql
Java面试题总结1 天前
PostgreSQL 数据库技术详解
数据库·postgresql
Lihua奏2 天前
PostgreSQL:数据还在内存里,为什么断电也不怕?
postgresql
周杰伦的稻香3 天前
PostgreSQL中的METHOD(认证方法)
数据库·postgresql
Zhu7583 天前
对docker环境的postgresql数据库做快速初始化
数据库·docker·postgresql
l1t4 天前
测试用rust重写的postgresql: pgrust
开发语言·postgresql·rust