文章目录
- [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))
- [12.6.1 依赖文件 `go.mod`](#12.6.1 依赖文件
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 ---------| 连接归还池中,干净状态 |
关键机制:
- PgBouncer 自动对客户端 Prepared Statement 名称加前缀(
pgb_),避免不同客户端之间的命名冲突 - 事务结束时,PgBouncer 自动
DEALLOCATE该连接上所有残留的 Prepared Statement - 确保连接归还池时处于"干净"状态
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,无论PreferSimpleProtocol取true还是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 文本行不一定落盘(本环境只稳定记录 execute 与 statement),但这两类已足以判定协议。
方案 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 源码才知另有分支:
gorm把配置映射成DefaultQueryExecMode(gorm.io/driver/postgres@v1.6.0/postgres.go):
go
// gorm postgres.go
if dialector.Config.PreferSimpleProtocol {
config.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol
}
PrepareContext会无条件向 PG 发Parse,并把 statement 注册进连接级缓存preparedStatements(github.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
...
}
c.conn.Prepare把 SQL 写入缓存(conn.go):
go
// conn.go:333
if psKey != "" {
c.preparedStatements[psKey] = sd // 关键:后续 Exec 会命中它
}
- 真正执行时,
exec()先查缓存,命中就直接走execPrepared(Extended),完全绕过DefaultQueryExecMode(conn.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) // ← 没机会走到
}
}
- 唯一的"后门"是第 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 |
行动建议:
- 若想真正走 Simple Protocol :必须改业务代码去掉
PrepareContext(方案 C)。仅靠PreferSimpleProtocol配置无法生效------前提是代码里没有任何显式database/sql的Prepare。 - 若保留
PrepareContext(如为语句复用/批量执行) :PreferSimpleProtocol无论true/false都走 Extended,该配置在带 Prepare 的代码下是无效配置,建议设回false避免误导。 - 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 问题一的兜底手段。
- Extended Protocol(
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.0将PreferSimpleProtocol映射为pgx.QueryExecModeSimpleProtocol;github.com/jackc/pgx/v5 v5.6.0的conn.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、端口、库名请按实际环境调整。