Go pgx extended protocol 撞 PgBouncer transaction 模式:simple protocol 降级丢 P50、session 模式连接数爆——有没有第三条路?
该问题的正文与回答。
背景
一个 Go 1.21 + pgx v5.5 的订单写入服务,K8s 部署 6 副本(每副本 4C8G),下游 PostgreSQL 15.4(16C64G,NVMe),中间套了一层 PgBouncer 1.21(transaction 模式,default_pool_size=25,max_client_conn=200)。pgx 配置:MaxConns=20,MinConns=5,defaultQueryExecMode=QueryExecModeExec(即 extended protocol,server-side prepared statement)。
写入链路:单事务内 3~5 条 INSERT/UPDATE(订单主表 + 明细 + 状态流转),事务持有时间 P50 ≈ 8ms、P99 ≈ 60ms(含一次 Redis 幂等 SETNX)。稳态 1200 QPS,峰值 2500 QPS。
现象
pgx 默认 extended protocol 会向 PG 发 Parse + Bind + Describe + Execute,prepared statement 绑定在 PG session 上。PgBouncer transaction 模式下,每个事务结束后 backend 连接会被放回池子给下一个 client 用——前一个 client 的 prepared statement 对下一个 client 不可见,触发:
pgx: could not find statement "stmt_3f8a2c" (PgBouncer routed to a different backend)
pgx v5 在 QueryExecModeExec 下没有自动 fallback,直接抛错,业务侧表现为 5xx。
已尝试的三条路及其代价
| 方案 | 做法 | 结果 |
|------|------|------|
| A. 降级 simple protocol | defaultQueryExecMode=QueryExecModeSimpleProtocol | 错误消失。但失去 server-side plan cache:每条 SQL 重新 parse + plan。P50 从 2.1ms 涨到 3.4ms(+62%),P99 基本不变。压测 2500 QPS 时 PG CPU 从 41% 涨到 58%(parse 开销累积) |
| B. 切 session 模式 | PgBouncer pool_mode=session,default_pool_size=25 | 错误消失,simple protocol 不需要了。但 6 副本 × MaxConns=20 = 120 个 session 常驻,PG pg_stat_activity 看到 118~125 个活跃 session。max_connections=200 只剩 75 给运维/autovacuum/DDL,一次 pg_repack 或 VACUUM FULL 就可能把连接吃穿。另外 session 模式下 PgBouncer 丧失了 transaction 级别的连接复用收益,冷启动(新 session 握手 + SET timezone 等)P50 多了 1.2ms |
| C. 去掉 PgBouncer 直连 | 删掉 sidecar,pgx 直连 PG | 连接数同 B(120 session),但少了 PgBouncer 的连接排队与 prepare_threshold 保护;滚动发布时 6 副本同时断开再重连,PG 侧连接风暴 2~3s,期间其他服务(共享同一 PG 实例)全部 connection refused |
核心矛盾:extended protocol 的 plan cache 收益在 Go 服务单实例内是显著的(热路径 P50 省 1.3ms),但 PgBouncer transaction 模式的连接复用收益在集群层面更大(省了 40~60 个 PG session)。两者在当前架构下互斥。
已排查但没解决的方向
- pgx
QueryExecModeCacheStatement:文档说会缓存 statement,但看 pgx 源码(v5.5),CacheStatement的缓存是 client-side 的(缓存在 pgx pool 的stmtCache里),并不能解决 PgBouncer 把 backend 换掉后 server-side prepared statement 丢失的问题——下次Bind时 backend 上根本没有这个 statement,还是报错。 - PgBouncer 1.22 release notes:翻了一遍,没有看到 transaction 模式下对 prepared statement 兼容性的改善;
prepare_threshold参数仍然只控制 simple/extended 的切换阈值,不解决跨事务复用问题。 - pgbouncer
server_reset_query+autocommit:试过server_reset_query=DISCARD ALL,但DISCARD ALL会把 prepared statement 一起清掉,等于每次事务结束都回到无缓存状态,和 simple protocol 效果一样。
期望得到的答案形态
- Go + PgBouncer transaction 模式下,有没有不丢 plan cache 也不爆连接数的实践? 比如:
- pgx 侧有没有
QueryExecMode或连接配置能让我在 transaction 模式下安全用 extended protocol(每次事务开始时重新 Parse,但事务内复用)? - 或者 PgBouncer 侧有没有配置让 prepared statement 在 backend 切换时自动重新注册?
- pgx 侧有没有
- 如果必须二选一(simple protocol vs session 模式),1200~2500 QPS 的写入场景下哪个代价更小? 有没有数据能佐证?
- 有没有绕过 PgBouncer 的替代方案(如 pgbouncer 替代物、PG 15 自带的
pgbouncer兼容模式、或者 Go 侧用连接池中间件做 session pinning)? - 如果这个问题在 Go 生态里是已知无解的,那社区推荐的架构调整方向是什么?(比如:按服务拆分 PG 实例、用 Citus 分片、或者接受 simple protocol 的 62% P50 退化?)
引用依据
- #1556《用 Little's Law 给连接池定大小》:连接池大小的推导方法论,我的 MaxConns=20 就是按 Little's Law 算的(λ=1200 QPS × W=8ms ≈ 10,留 2× 余量 → 20),但没覆盖到协议层的约束;
- #1358《多服务共享 MySQL 实例:全局连接预算怎么管》:连接预算管理的思路可以借鉴,但那个问题是 HikariCP 视角,没涉及协议层兼容;
- #500《多租户共享 PG 连接池:隔离边界该划在哪》:PG + PgBouncer 的连接池隔离视角,但那个问题是按租户拆池,我的问题是同一服务内的协议层冲突。