编程 PgBouncer 事务池下的 prepared statement:42P05、26000、08P01 与 1.21–1.24 的版本分界

2026-10-22 00:05:01

PgBouncer 事务池下的 prepared statement:42P05、26000、08P01 与 1.21–1.24 的版本分界

站内 #8060 讲 SET/RESET 在复用连接上的会话状态污染,#8100 讲 advisory lock 悬挂,这篇只谈第三类被事务池打断的东西:prepared statement 的命名空间。

事务池打断的到底是什么

pool_mode 有三档:session(客户端断开才归还连接,全部 PG 特性可用)、transaction(事务结束即归还)、statement(更激进,禁止多语句事务)。在 transaction 模式下,一条 server connection 在一个生命周期里服务多个客户端的不同事务;反过来,同一个客户端的下一条事务可能落到另一条 server connection 上。

PostgreSQL 里 prepared statement 是 session(backend 进程)级对象,名字在同一 session 内唯一,生命周期到 session 结束。扩展查询协议里由 Parse 消息创建,同名必须先用 Close 关闭才可重新定义;未命名语句会被下一次 Parse 覆盖。

所以 transaction 模式一开,「客户端连接」和「server session」的 1:1 关系就断了,驱动按客户端连接维护的本地缓存全部失真。

三种报错,三种根因

1) 名字撞车

42P05 prepared statement "s0" already exists

客户端本地缓存是空的,于是在一条已经由别的客户端(或自己更早的连接)Parse 过 s0 的 server connection 上再建同名语句。asyncpg FAQ 和 Prisma Migrate 常见的就是这个 s0。

2) 语句不在这个后端

26000 / invalid_sql_statement_name: prepared statement "foo" does not exist

在 A 后端建的语句,下一条事务落到 B 后端执行。

3) 同名不同语句,最危险

08P01 protocol_violation: bind message supplies 4 parameters, but prepared statement "ecto_323" requires 2

Ecto 的 ecto_323 在不同模块间重名,客户端拿到的是别人的语句,却按自己的参数绑定,个数对不上。它不报「不存在」,而是静默用错语句。

PgBouncer 和 Postgres 在这里还有一处行为差异:客户端在同一 client connection 上重用已存在的 PS 名字时,Postgres 抛普通 ERROR(事务还能继续或回滚),PgBouncer 直接关掉连接,前端表现为 08P01(issue #1118,prepared statement name is already in use (SQLSTATE 08P01))。排查时去 pgbouncer 日志里找被重用的名字。

max_prepared_statements:三个版本分界

  • 1.21.0(2023-10):新增 transaction/statement 模式下对 protocol-level named prepared statement 的跟踪。实现方式是对每条客户端 PS 按「查询字符串 + 参数类型」做全局去重,得到唯一 id,在服务端统一以 PGBOUNCER_{unique_id} 命名(prepare.c 里的全局 hashmap)。客户端发来的名字被改写成内部名再转发;目标 server connection 上还没准备过,就透明地先准备再执行。两个客户端各用 s0 指向不同 SQL 也不会串(discussion #973)。
  • 这个值是该 server connection 上 PS 的 LRU 容量;0 表示关闭,也是 1.21~1.23 的默认值。
  • 1.22.0 起处理 DEALLOCATE ALL / DISCARD ALL,会清掉 PgBouncer 为该客户端跟踪的条目。1.21 不处理,于是 Prisma 历史上的 workaround(每个命名语句前先 DEALLOCATE ALL)在 1.21 上反而报 prepared statement "PGBOUNCER_3" does not exist(issue #974,PR #972)。
  • 1.24.0 起默认值改为 200。

内存代价分三层:每条后端连接各有一份 LRU;PgBouncer 侧的全局 query cache 每条唯一查询存一次;每个客户端连接还有重写缓冲区,最坏 4×pkt_buf,且只有当语句长度落在 2~4 倍 pkt_buf 之间才会触顶。文档的建议是把这个值设成大于应用常用语句的数量。

开了跟踪之后仍有的三个限制

  1. 只覆盖协议级。 SQL 级 PREPARE / EXECUTE / DEALLOCATE 被直接转发,PgBouncer 不认识,transaction 模式下依旧会随机落到没有该语句的后端。
  2. 迁移改了列或类型后,复用到的语句报 ERROR: cached plan must not change result type。需要在 admin console 执行 RECONNECT 强制重新准备。
  3. 每条被跟踪的语句在 PgBouncer 和后端连接上都占内存。

server_reset_query 为什么救不了

config.html 写得很明确:transaction 模式下 server_reset_query 不会被使用,因为该模式在设计上就要求客户端不使用任何 session 级特性。默认的 DISCARD ALL 只在 session 模式生效。也就是说 1.21 之前 transaction 模式完全没法用 PS,这不是哪里漏配了一项。

驱动侧怎么关掉命名

驱动参数 / 做法
asyncpg(Python)statement_cache_size=0,且不调用 Connection.prepare
Bun SQLprepare: false
node-postgres pg默认只有查询带 name 才命名,走池时不传 name
pgJDBC(Java)prepareThreshold=0(Spark JDBC 那条 PSQLException: prepared statement "S_1" already exists 就是没设)
pgx(Go)default_query_exec_mode=exec / QueryExecModeExec;pgx 文档自己写明,两轮往返的执行模式在连接池切换底层连接时可能出问题(SpiceDB issue #1217 即因此判定与 PgBouncer 不兼容)。另外,pgx 5.5.1 之前用 SQL 级 DEALLOCATE 而不是协议级 Close(commit e5015e2),与 1.21 之前的 PgBouncer 跟踪冲突,5.5.1 起改用 Close
postgres.jsprepare: false
psycopg 3prepare_threshold=None;若要开 PgBouncer 侧跟踪,需 psycopg 3.2+、PgBouncer 1.22+、libpq 17+
Prisma ORM 7 + @prisma/adapter-pg默认未命名,只有设置 statementNameGenerator 才会命名并缓存,走池就别设

运维排查顺序

  1. SHOW VERSION; SHOW CONFIG; 确认版本与 max_prepared_statements。1.20 及以下没有这项。
  2. 按报错码分型:42P05 是名字撞车,26000 是语句不在这个后端,08P01 参数个数不符——最可能是同名不同语句。
  3. 确认失败进程连的是 pooled 端口还是 direct 端口。迁移、pg_dump、pg_restore、schema introspection 一律走直连。
  4. 如果报错出现在 DDL 之后,先 RECONNECT。
  5. 关掉驱动命名是最稳的兼容做法,代价是每次都要 parse + plan;让 PgBouncer 跟踪则要接受内存与 cached plan 两个副作用。

附:session 级其他不可用特性

以下按官方 features 表列出,用于区分范围。

  • SET/RESET:仅 tracked 参数支持——application_name、client_encoding、DateStyle、default_transaction_read_only(PG14+)、IntervalStyle、scram_iterations(PG16+)、search_path(PG18+)、session_authorization、standard_conforming_strings、TimeZone
  • LISTEN:从不支持
  • NOTIFY:支持
  • WITHOUT HOLD CURSOR:支持
  • WITH HOLD CURSOR:不支持
  • SQL 级 PREPARE / DEALLOCATE:不支持
  • ON COMMIT DROP 临时表:支持
  • PRESERVE / DELETE ROWS 临时表:不支持
  • LOAD:不支持
  • session 级 advisory lock:不支持

SQL 级 PREPARE 那条至今没有解。协议级能被 PgBouncer 改写,SQL 级只是一段普通文本,PgBouncer 没有立场去解析它、也没有地方存这套映射,只能原样转发。谁在事务池后面用 SQL 级 PREPARE,谁就得自己保证后续 EXECUTE 落在同一条 backend 上。

参考:PgBouncer features、PgBouncer config、issue #655、dba.stackexchange 274307。

推荐文章

程序员茄子在线接单