# 会话状态与连接池陷阱

LLMS 索引： [llms.txt](/llms.txt)

---

应用看到的“一个数据库连接”可能依次经过：

```text
request
  → application pgxpool connection
  → HAProxy service
  → PgBouncer client connection
  → one of many PostgreSQL server connections
```

当 PgBouncer 采用 transaction pooling 时，client connection 不是 PostgreSQL session 的永久所有者。应用必须把正确性限制在 transaction 边界，不能把上一个 transaction 留下的 session state 当作下一个 transaction 的前提。

## 12.4.1 session、transaction 与 statement pooling {#item-12-4-1}

### 三种“归还 backend”的时刻

| mode | server connection 何时归还 | 兼容性 | 复用率 |
|---|---|---|---|
| session | client 断开 | 最接近直连 | 最低 |
| transaction | transaction 结束 | app 必须 transaction-aware | 高 |
| statement | 每条 statement 后 | multi-statement transaction 不成立 | 最高、限制最多 |

session pooling 保留一个 client 对一个 backend 的 session 关系，许多 session feature 可用，但吸收连接峰值的能力有限。

transaction pooling：

```text
BEGIN
  all statements use one backend
COMMIT
backend returns to pool
next transaction may use another backend
```

这非常适合把业务状态装进显式 transaction 的服务，也意味着跨 transaction 的 session assumption 会破坏。

statement pooling 连一个多语句事务都不能正常表达，不适合作为本章业务主线。

### Transaction pooling 中哪些东西会坏

PgBouncer 官方 feature map 明确指出 transaction pooling 下不能依赖：

- 普通 `SET/RESET` 的跨事务效果；
- `LISTEN`；
- SQL-level `PREPARE/DEALLOCATE`；
- `WITH HOLD` cursor；
- preserve/delete rows temp tables；
- session-level advisory locks；
- `LOAD`。

例：

```sql
SET app.tenant_id = 'tenant-a';
COMMIT;

BEGIN;
SELECT ...;  -- 可能已经换 backend
```

第二个 transaction 不应假定 `app.tenant_id` 仍存在。更糟的是，拿到的 backend 可能曾服务另一个 client；可靠 pooler 会 reset/track 一部分参数，但应用不能把未知 session residue 当作隔离机制。

### 一个 transaction 内的状态仍然有意义

transaction pooling 在 `BEGIN` 到 `COMMIT/ROLLBACK` 期间固定 backend，所以：

```sql
BEGIN;
SET LOCAL app.tenant_id = 'tenant-a';
SELECT ...;  -- same transaction/backend
COMMIT;
```

语义成立。关键不是“永远不用 SET”，而是：

```text
state lifetime <= transaction lifetime
```

且每个 transaction 都重新建立需要的 context。

### Autocommit 是 transaction

一条没有显式 `BEGIN` 的 SQL 在 PostgreSQL 中也运行于一个 transaction；在 transaction pooling 中，statement 完成后 backend 就可能归还。

所以这种代码不成立：

```text
Exec("SET LOCAL ...")   // outside explicit transaction; warning/no effect
Query("SELECT ...")     // another transaction/backend
```

`SET LOCAL` 必须与受保护查询在同一个显式 transaction object 上执行。

### 不要用 session advisory lock 做跨请求 ownership

第 10 章已区分 transaction/session advisory lock。transaction pooling 下：

```text
pg_advisory_lock()
client transaction ends
backend returns, session lock may remain on backend
next client may inherit effect
original client cannot reliably unlock same backend
```

这既会泄漏锁，也会让 unlock 不可达。使用：

- `pg_advisory_xact_lock`，生命周期绑定当前 transaction；
- 或把 ownership 建模成持久 lease/row；
- 或为确需 session affinity 的任务使用单独 direct/session-pooled service。

不能为一个特殊 job 把所有在线业务都切到 session pooling；服务职责可以拆分。

## 12.4.2 预备语句行为必须绑定 PgBouncer 与驱动版本 {#item-12-4-2}

### “Prepared statement 与 PgBouncer 不兼容”过于粗糙

需要至少区分：

```text
SQL PREPARE name AS ...
protocol-level named prepared statement
unnamed statement/extended protocol
driver statement cache
description cache
simple protocol interpolation
PgBouncer prepared-statement tracking
```

PgBouncer 1.21 起可以在 transaction pooling 中跟踪 protocol-level named prepared statements，但必须：

```text
max_prepared_statements > 0
```

它会在 client/server name 之间重写，并确保目标 backend 已准备该 query。SQL-level `PREPARE/DEALLOCATE` 仍不受 transaction pooling 支持。

因此不能从“PgBouncer 版本够新”直接推导“所有 driver 默认都安全”。要验证：

```text
PgBouncer exact version
max_prepared_statements actual value
pool_mode at database/user level
driver exact version
driver query mode
query parameter/result types
DDL/cache invalidation behavior
reconnect procedure
```

### pgx 的默认行为

pgx v5.10.0 默认 `QueryExecModeCacheStatement`：

```text
extended protocol
automatically prepare and cache statements
single round trip after cached
```

如果 schema 或 `search_path` 在缓存后变化，第一次重新执行可能失败，例如 `SELECT *` 列数变化或 result type 变化。pgx 文档也提示默认 prepared statements 可能与 proxy/PgBouncer 不兼容，建议按环境选择 `QueryExecModeExec` 或在必要时 simple protocol。

本章保守固定：

```go
config.ConnConfig.DefaultQueryExecMode =
    pgx.QueryExecModeExec
```

该模式：

- 仍使用 extended protocol；
- 不使用 named prepared statement cache；
- 根据 Go argument type 推断 PostgreSQL parameter type；
- 使用 text-formatted parameters/results；
- 单 round trip；
- 比 simple protocol 更优先。

这减少了本章未验证 PgBouncer config 下的一个变量，不代表 statement cache 永远不该用。生产若确认 PgBouncer tracking 与 workload 收益，应单独 A/B 并保存版本/config/DDL 恢复证据。

### 不要误用 `SimpleProtocol`

simple protocol 不是“更安全的参数化”。pgx 会在 client 端对参数插值并转义，它适合某些不支持 extended protocol 的 proxy。对标准 PostgreSQL/PgBouncer，优先尝试 `QueryExecModeExec`。

simple/exec mode 还要求你认真处理 type mapping，尤其 `[]byte`、JSON、用户自定义类型。不要为躲开 prepared statement 问题，悄悄改变参数编码语义而不运行 contract suite。

### DDL 后的 cached plan

PgBouncer prepared-statement tracking 提升复用，但若相同 query 的 parameter/result types 在 DDL 后改变，PostgreSQL 可能报：

```text
cached plan must not change result type
```

PgBouncer 文档建议这类 migration 后通过 admin console `RECONNECT` 让 server connections 重建计划。发布设计应回答：

- DDL 是否改变返回列数/type；
- old/new app 是否使用相同 query text 却期待不同 shape；
- 是否使用 `SELECT *`；
- app pool 是否也有 cache；
- PgBouncer reconnect 如何执行、影响多少连接；
- reconnect storm 与 rollback；
- failure metric 与 smoke query。

不能把 `RECONNECT` 当作每次 DDL 的盲目万能命令；先证明目标、作用域和版本。

### 建议的组合矩阵

| pgx mode | PgBouncer transaction pool | 需要验证 |
|---|---|---|
| cache_statement | tracking on | exact versions/config, DDL invalidation |
| cache_statement | tracking off/unknown | 不应默认放行 |
| cache_describe | no named plan | result/arg type drift |
| describe_exec | two round trips | pooler round-trip backend affinity |
| exec | conservative mainline | type mapping, performance |
| simple_protocol | fallback only | client interpolation/type semantics |

“能跑一条 `SELECT 1`”不能覆盖这个矩阵。本章 promotion blocker 要求用完整下单/支付/DDL smoke 通过实际 primary service。

## 12.4.3 `SET LOCAL`、事务边界与 RLS 上下文 {#item-12-4-3}

### `SET` 与 `SET LOCAL`

PostgreSQL：

```text
SET / SET SESSION
  current session
  if transaction commits, value persists after transaction

SET LOCAL
  only current transaction
  COMMIT or ROLLBACK ends it
  outside transaction block warns and has no effect
```

transaction pooling 的主线应是：

```go
tx, err := conn.BeginTx(ctx, options)
...
_, err = tx.Exec(
    ctx,
    `SELECT set_config('app.tenant_id', $1, true)`,
    tenantID,
)
...
rows, err := tx.Query(ctx, tenantScopedSQL, ...)
```

`set_config(..., true)` 的第三个参数表示 transaction-local，便于参数化 value。不要拼：

```sql
SET LOCAL app.tenant_id = '<user input>';
```

### RLS context 要 fail closed

若 ch23 使用：

```sql
current_setting('app.tenant_id', true)
```

policy 要明确 missing context 是：

```text
zero rows / reject
```

而不是 fallback 到“全部租户”。还要验证：

- runtime role 不具 `BYPASSRLS`；
- object owner 是否绕过 RLS；
- `FORCE ROW LEVEL SECURITY` 是否需要；
- SECURITY DEFINER 是否重新建立 context；
- connection reset 后不存在可继承 tenant；
- transaction retry 每次重新 `SET LOCAL`；
- background jobs 使用什么身份。

把 tenant 放到 `application_name` 不安全也会产生高基数；它是 observability label，不是授权 context。

### 同一 transaction 才能相信 context

错误：

```go
pool.Exec(ctx, "SELECT set_config(..., true)")
pool.Query(ctx, tenantSQL)
```

两个 pool method 可能 acquire 不同连接，也一定是不同 autocommit transaction。正确：

```go
tx, _ := pool.Begin(ctx)
tx.Exec(ctx, setLocalSQL, tenant)
tx.Query(ctx, tenantSQL)
tx.Commit(ctx)
```

若 query 是单条并且能把 tenant 作为普通 `$1` predicate，就优先显式参数；RLS context 用于数据库必须统一执行的访问策略，不是减少一个参数的技巧。

### `search_path` 也不要依赖 session residue

本章所有对象 schema-qualified：

```sql
shop_ch12.sales_order
```

SECURITY DEFINER function 固定：

```sql
SET search_path = pg_catalog
```

然后引用 qualified object。这样：

- transaction pooling 不依赖前一 transaction 的 path；
- 恶意同名 object 更难劫持；
- query contract 明确；
- migration 与 app 看同一对象。

若通过 role/database startup parameter 固定 `search_path`，仍要把它作为 connection contract 验证。

## 12.4.4 在 ch22、ch23 分别深化池化与权限 {#item-12-4-4}

本节只建立应用必须知道的最小边界，不在这里展开两个独立大主题。

第 22 章将深入连接治理：

```text
max_connections budget
PgBouncer topology and auth
pool_size/reserve/connlimit
queueing and admission control
pause/resume/reconnect
failover and connection storm
per-user/per-database pools
SHOW POOLS/STATS evidence
```

第 23 章将深入安全与访问：

```text
roles and ownership
default privileges
RLS and FORCE RLS
tenant/session context
SECURITY DEFINER hardening
credential rotation
TLS/HBA
audit and break-glass
```

本章保留的 cross-chapter contract：

1. 在线服务默认可以在 transaction pooling 下正确运行；
2. 正确性不依赖跨 transaction session state；
3. runtime role 最小权限、不是 object owner、没有 BYPASSRLS；
4. connection/query mode 与 pooler 版本配置绑定；
5. 特殊 session workload 使用独立 service，不污染在线主线；
6. 权限或 pool 配置变化后重跑同一业务/failure matrix。

## 本节检查表

- [ ] 知道实际 pool_mode，不从端口名猜；
- [ ] transaction pooling 下没有跨事务 `SET` 前提；
- [ ] `LISTEN`、temp table、cursor、advisory lock 的 lifetime 已评审；
- [ ] transaction-local state 与业务 SQL 在同一 tx object；
- [ ] pgx 与 PgBouncer exact versions 已记录；
- [ ] `max_prepared_statements` 实际值已记录；
- [ ] 区分 protocol prepared、SQL PREPARE 与 driver cache；
- [ ] query mode 改变后重新验证 type mapping；
- [ ] DDL result-shape change 有 cache/reconnect 计划；
- [ ] `SET LOCAL` 或 `set_config(..., true)` fail closed；
- [ ] tenant context 不是 authorization 的唯一应用侧证据；
- [ ] SQL schema-qualified，SECURITY DEFINER path 固定；
- [ ] 特殊 session workload 使用独立连接路径。

## 参考资料

- [PgBouncer：SQL feature map](https://www.pgbouncer.org/features.html)
- [PgBouncer：max_prepared_statements 与 pool_mode](https://www.pgbouncer.org/config.html)
- [PgBouncer FAQ：transaction pooling prepared statements](https://www.pgbouncer.org/faq.html)
- [pgx v5.10.0：QueryExecMode](https://pkg.go.dev/github.com/jackc/pgx/v5@v5.10.0#QueryExecMode)
- [PostgreSQL 18：SET](https://www.postgresql.org/docs/18/sql-set.html)
- [Pigsty：PgBouncer Administration](https://pigsty.io/docs/pgsql/admin/pgbouncer/)

---

[上一节：Go 服务中的连接与事务](../03/) · [返回本章目录](../) · [下一节：服务级可观测性](../05/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
