返回文章列表

PostgreSQL Go、pgx/v5、pgxpool 与生产级数据库访问层

从 goroutine、pgxpool、PostgreSQL Backend、连接池排队、Rows、事务、SQLSTATE、PgBouncer、健康检查与优雅停机出发,理解 Go 生产级 PostgreSQL 访问层设计。

第 16 章:Go、pgx/v5、pgxpool 与生产级数据库访问层

结论核对,基准日期:2026 年 6 月 20 日

  • PostgreSQL 当前生产基线可写为 PostgreSQL 18,当前维护版本为 18.4;PostgreSQL 19 仍处于 Beta 1,不应作为生产基线。PostgreSQL 14 将于 2026 年 11 月 12 日停止维护。(PostgreSQL)
  • 当前稳定 Go 版本为 Go 1.26.4;本章代码只依赖稳定语言能力,不把 Go 补丁版本写入项目设计。(Go语言)
  • 当前 pgx/v5 API 已包含 MinIdleConnsPingTimeoutPrepareConnCanceledAcquireCountEmptyAcquireWaitTime 等能力。配置字段的准确名称是 MaxConnLifetimeJitter,不是泛化的 “Lifetime Jitter”。(Go Packages)
  • pgx.Conn 不支持并发调用;并发安全的是 pgxpool.Pool。(Go Packages)
  • “PgBouncer Transaction Pooling 不支持预编译语句”已经不是完整结论。当前 PgBouncer 可以在启用 max_prepared_statements 后跟踪协议级命名预编译语句,但 SQL 级 PREPARE/EXECUTE/DEALLOCATE、会话级 SETLISTEN、会话 Advisory Lock 等仍不兼容 Transaction Pooling。(PgBouncer)

1. 本章定位

本章解决的不是“如何执行一条 SQL”,而是以下生产问题:

  1. 如何在大量 goroutine 与有限 PostgreSQL Backend Process 之间建立稳定边界;
  2. 如何防止连接泄漏、连接风暴、连接池耗尽和长事务占池;
  3. 如何正确处理 RowsBatchResults、事务、超时和 SQLSTATE;
  4. 如何在数据库重启、网络中断和 Failover 后恢复连接;
  5. 如何监控连接池排队,而不是只监控数据库连接数;
  6. 如何在 PgBouncer、直接连接和读写分离之间选择协议模式;
  7. 如何设计 readiness、liveness 和优雅停机。

本章依赖:

  • 第 1 章的 PostgreSQL 每连接一个 Backend Process 模型;
  • 第 9~11 章的事务、MVCC、锁和死锁;
  • 第 13 章的提交路径及 Commit 结果不确定;
  • 第 15 章的应用版本与 Schema 兼容。

本章不深入:

  • 批量写入方法的极限优化,留到第 17 章;
  • PgBouncer 全部容量参数,留到第 18 章;
  • 分片连接池,留到第 19 章;
  • 复制读路由和 LSN 一致性,留到第 21 章;
  • Patroni Failover 编排,留到第 23 章。

2. 可验证的学习目标

完成本章后,应能够:

  • 解释 goroutine、连接池连接、PostgreSQL Backend、活跃查询和 TPS 的区别;
  • 根据数据库总连接预算、应用实例数和压测结果推导 MaxConns
  • 使用 pgxpool.ParseConfig 创建生产级连接池;
  • 正确使用 AcquireAcquireFuncQueryQueryRowRowsTxBatchCopyFrom
  • 通过 Pool.Stat() 判断池排队、连接抖动和连接池耗尽;
  • 使用 errors.As*pgconn.PgError 和 SQLSTATE 分类错误;
  • 解释为什么 Commit 返回错误不等于事务一定未提交;
  • 验证 Transaction Pooling 下哪些会话状态不可用;
  • 在数据库重启后观察旧连接失效和新连接建立;
  • 实现有超时、监控、预热、健康检查和优雅关闭的 Go 数据访问层。

3. 核心术语

中文名称英文名称准确定义容易混淆的概念所属层次
物理连接Physical Connection客户端到 PostgreSQL 或 PgBouncer 的 TCP/Unix Socket 与协议会话业务请求网络/协议
数据库会话Session物理连接存续期间的会话状态,包括 GUC、临时表、Prepared Statement 等事务PostgreSQL
事务Transaction一个原子提交或回滚的数据库工作单元连接SQL/MVCC
连接池Connection Pool复用有限物理连接并让调用方等待获取的组件PgBouncer应用进程
池租约Leasegoroutine 暂时独占一个池连接的时间段数据库锁应用并发
获取等待Acquire Wait因池中无可用连接而等待的时间SQL 执行时间应用排队
活跃连接Acquired Connection当前已从 pgxpool 借出但尚未归还的连接PostgreSQL active 状态应用池
空闲连接Idle Connection已建立、当前未被借出的池连接idle in transaction应用池
会话状态Session State绑定物理连接的 SET、临时表、LISTEN、会话锁等事务局部状态PostgreSQL
查询执行模式Query Execution Modepgx 选择缓存预编译、描述缓存、Extended 或 Simple Protocol 的策略PostgreSQL Planner 计划模式驱动
语句缓存Statement Cachepgx 按 SQL 文本缓存协议级预编译语句PostgreSQL Plan Cache驱动/服务端
描述缓存Description Cache缓存参数和结果类型描述,而不一定保留服务端命名语句业务结果缓存驱动
连接风暴Connection Storm大量实例同时建立或重建连接,压垮认证、TLS、Backend 创建或数据库上限高 SQL QPS故障行为
结果不确定Outcome Unknown客户端未能确认服务端是否完成 Commit 或语句明确回滚分布式故障
Bulkhead舱壁隔离用独立池或独立并发预算隔离不同工作负载读写分离架构
Backpressure背压下游饱和时让上游排队、降级或拒绝,而不是无限增加并发限流流量控制

4. 整体心智模型

4.1 goroutine、连接池与 PostgreSQL Backend

PostgreSQL Go、pgx/v5、pgxpool 与生产级数据库访问层 flow 1

关键边界:

  • 一个应用实例可以有数千个 goroutine;
  • 但其 pgxpool 可能只有 10~30 个连接;
  • 每个直连 PostgreSQL 的物理连接通常对应一个 Backend Process;
  • 一个连接同一时刻只能安全执行一条正常命令流;
  • goroutine 超过 MaxConns 后应在池或应用准入层排队;
  • 增加连接数只增加并发进入数据库的能力,不保证增加吞吐。

pgxpool.Pool 是并发安全的,而单个 pgx.Conn 不是;因此不应让多个 goroutine 同时使用同一个 pgx.Conn。(Go Packages)

4.2 一次查询的控制流

PostgreSQL Go、pgx/v5、pgxpool 与生产级数据库访问层 flow 2

Pool.Query 在返回 Rows 后仍占用连接,直到结果读完或 Rows.Close()QueryRow 则在调用 Scan() 时释放连接。忘记 ScanCloserows.Err() 都可能造成资源泄漏或错误丢失。(Go Packages)

4.3 加入 PgBouncer 后

PostgreSQL Go、pgx/v5、pgxpool 与生产级数据库访问层 flow 3

在 Transaction Pooling 中:

  • pgx 看到的“连接”只固定到 PgBouncer;
  • 不保证下一个事务仍使用同一个 PostgreSQL Backend;
  • 会话级 SET、持久临时表、LISTEN 和 Session Advisory Lock 不可依赖;
  • 事务内的 SET LOCAL、普通事务锁和事务级数据操作仍有明确边界;
  • Prepared Statement 是否可用取决于 PgBouncer 版本和 max_prepared_statements 配置。(PgBouncer)

4.4 故障路径

PostgreSQL Go、pgx/v5、pgxpool 与生产级数据库访问层 flow 4

5. 使用方式

5.1 pgx 原生接口与 database/sql

方案适用场景优点代价
pgx 原生接口PostgreSQL 专用服务类型系统、COPY、Batch、通知、协议模式和错误信息更直接database/sql 生态接口不同
pgxpool多 goroutine 生产服务并发安全、统计完整、与 pgx API 接近需要自行设计连接预算
pgx/stdlib第三方库要求 database/sql兼容 sql.DB 生态PostgreSQL 专属能力暴露较弱
原生 database/sql 抽象同一代码需要支持多个数据库驱动通用接口容易牺牲 PG 专属语义,且 sql.DB 本身也是池
单个 pgx.ConnCLI、迁移工具、专有会话、LISTEN Worker会话边界明确不能被多个 goroutine 并发使用

不要同时无预算地创建:

  • 一个大型 pgxpool.Pool
  • 一个独立的 sql.DB 池;
  • 一个后台任务池;
  • 一个读副本池。

它们最终都消耗 PostgreSQL 或 PgBouncer 的连接预算。

5.2 核心对象的资源边界

对象获得资源释放方式常见泄漏
pgxpool.Pool多个物理连接Pool.Close()应用退出未关闭
pgxpool.Conn一个池连接租约Release()Acquire 后提前返回
pgx.Tx一个连接和事务Commit()Rollback()错误路径未回滚
pgx.Rows查询结果流和连接读尽或 Close()循环提前退出
pgx.BatchResults批次结果和连接协议状态Close()只读取部分结果
Pipeline连接的 Pipeline 状态Close()错误后未恢复正常模式

BatchResults.Close() 不只是“释放内存”。它还负责消费剩余结果并使连接恢复到可复用状态;若协议状态无法确定,底层连接可能被关闭。Rows 同样必须关闭,并在循环后检查 rows.Err()

5.3 当前 pgxpool.Config 的关键字段

字段作用生产注意事项
MaxConns单实例池上限由全局连接预算和压测共同决定
MinConns池中的最少总连接数不等于始终存在可立即使用的空闲连接
MinIdleConns维持的最少空闲连接数对降低突发请求建连尾延迟更直接
MaxConnLifetime连接最大寿命用于轮换连接,不应用于掩盖泄漏
MaxConnLifetimeJitter在寿命上增加随机抖动防止同一时刻批量重建连接
MaxConnIdleTime空闲连接最长保留时间太短会产生连接抖动
HealthCheckPeriod空闲连接健康维护周期不是业务请求健康检查周期
PingTimeout池内部 Ping 最大等待时间避免健康检查无限卡住
AfterConnect新物理连接建成后初始化只做快速、确定、幂等操作
PrepareConn每次借出前验证或准备连接会影响每次 Acquire;避免无条件执行昂贵 SQL
ShouldPing决定 Acquire 时是否 Ping默认会检查长时间空闲的连接
BeforeClose连接关闭前回调只能做轻量清理或观测

当前文档明确说明:MinIdleConns 更适合维持可立即使用的空闲容量;PrepareConn 已取代旧的 BeforeAcquireShouldPing 默认会 Ping 空闲至少约一秒的连接。(Go Packages)

5.4 AcquireAcquireFunc

conn, err := pool.Acquire(ctx)
if err != nil {
	return err
}
defer conn.Release()

var now time.Time
err = conn.QueryRow(ctx, `SELECT clock_timestamp()`).Scan(&now)

适合必须连续操作同一会话的场景。

err := pool.AcquireFunc(ctx, func(conn *pgxpool.Conn) error {
	_, err := conn.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, lockID)
	return err
})

AcquireFunc 自动释放连接,但传给 AcquireFuncctx 只约束获取连接,不会自动为回调函数创建新的执行期限。业务 SQL 仍应使用带 deadline 的上下文。

不需要会话独占时,优先直接使用:

_, err := pool.Exec(ctx, sql, args...)
rows, err := pool.Query(ctx, sql, args...)
err := pool.QueryRow(ctx, sql, args...).Scan(&value)

5.5 Simple 与 Extended Protocol

pgx 模式协议与行为优点风险与适用边界
QueryExecModeCacheStatementExtended;自动缓存协议级 Prepared Statement重复查询往返少,默认首选Schema 或 search_path 改变后首个执行可能遇到缓存失效问题
QueryExecModeCacheDescribeExtended;缓存类型描述不依赖长期命名计划仍有描述失效边界
QueryExecModeDescribeExecExtended;先 Describe 后 Execute更能适应并发 Schema 变化通常需要额外往返;后端切换型代理可能在两轮之间换连接
QueryExecModeExecExtended;文本参数与结果一次往返,较适合严格 Pooler 兼容对未知或歧义类型需显式转换
QueryExecModeSimpleProtocolSimple;pgx 在客户端安全插值参数某些不完整代理的最后兼容方案功能和二进制编码受限,不应作为默认优化手段

即使使用 Simple Protocol,也必须继续使用 $1 参数调用 pgx API;不要自行拼接用户输入。

5.6 Session State

功能直连/Session PoolingTransaction Pooling
普通事务支持支持
SET LOCAL支持支持,作用限于事务
会话级 SET支持不可靠
临时表 ON COMMIT DROP支持通常可用,但需严格限制事务边界
持久临时表支持不可靠
LISTEN支持不支持
Session Advisory Lock支持不支持
Transaction Advisory Lock支持支持
协议级 Prepared Statement支持依赖 PgBouncer 配置
SQL PREPARE支持不支持

生产查询应尽量:

  • 使用 app.orders 之类的 Schema 限定名;
  • 不依赖请求之间保留的 search_path
  • 不依赖“上一个请求执行过 SET”;
  • 将事务级配置写成 SET LOCAL
  • LISTEN 使用独立、固定的直连或 Session Pooling 连接。

5.7 TLS、application_namesearch_path

DATABASE_URL 应由秘密管理系统提供,例如:

postgres://app_user:***@db.example.internal:5432/orders
?sslmode=verify-full
&sslrootcert=/run/secrets/ca.crt
&connect_timeout=5

生产注意事项:

  • 安全边界需要主机名校验时采用 verify-full
  • application_name 应包含服务、环境和角色,例如 orders-api.prod.writer
  • 不把租户、用户 ID 或高基数字段放进 application_name
  • 避免使用包含不可信可写 Schema 的 search_path
  • SQL 尽量使用 Schema 限定名;
  • 不在日志中记录完整 DSN、密码、证书私钥或 SQL 参数。

6. 底层原理

6.1 Pool 建立并不等于数据库已可用

pgxpool.NewWithConfig 可以在尚未建立任何连接时返回,因此:

ParseConfig
  → 创建 Pool 对象
  → 返回成功
  → 第一次 Acquire/Ping
  → DNS
  → TCP
  → TLS
  → Authentication
  → PostgreSQL Backend 创建
  → AfterConnect
  → 可用

启动时必须至少执行一次有期限的 PingAcquire,否则可能出现:

  • 服务进程启动成功;
  • readiness 提前变绿;
  • 第一批真实请求才发现 DNS、证书、密码或数据库不可用。

官方 pgxpool 文档也明确指出,创建池不会等待连接建立,应立即 Acquire 或 Ping 验证。(Go Packages)

6.2 Acquire 状态变化

Acquire(ctx)
  ├─ 有 IdleConn
  │    └─ PrepareConn / ShouldPing → 返回
  ├─ TotalConns < MaxConns
  │    └─ Constructing → AfterConnect → 返回
  └─ TotalConns == MaxConns
       └─ 进入等待队列
            ├─ 其他请求归还连接 → 成功
            └─ ctx 到期 → CanceledAcquireCount 增加

需要区分三段时间:

总请求时间
= 准入等待
+ Pool Acquire 等待
+ SQL/事务执行
+ 结果读取
+ 应用处理

只记录 SQL 执行耗时会漏掉连接池排队。

6.3 连接归还条件

以下操作会自动借出和归还连接:

  • Pool.Exec:函数返回时归还;
  • Pool.QueryRow:调用 Scan 时归还;
  • Pool.QueryRows 关闭或读尽时归还;
  • Pool.BeginTx:Commit 或 Rollback 时归还;
  • Pool.SendBatchBatchResults.Close 后归还。

因此下面的代码存在泄漏:

row := pool.QueryRow(ctx, sql, id)
// 忘记 row.Scan(...)
return nil

另一个常见问题:

rows, err := pool.Query(ctx, sql)
if err != nil {
	return err
}
defer rows.Close()

for rows.Next() {
	if shouldStop() {
		return nil // defer 会关闭,安全
	}
}
return nil // 仍遗漏 rows.Err()

正确结尾:

if err := rows.Err(); err != nil {
	return err
}

6.4 事务 Context 边界

BeginTx(ctx, ...)ctx 主要约束开始事务命令,不表示之后一旦 ctx 取消,pgx 会自动替你回滚整个事务。因此仍必须明确:

tx, err := pool.BeginTx(ctx, opts)
if err != nil {
	return err
}

defer func() {
	rollbackCtx, cancel := context.WithTimeout(
		context.Background(),
		2*time.Second,
	)
	defer cancel()
	_ = tx.Rollback(rollbackCtx)
}()

事务中每条 SQL 继续使用受控 Context;成功路径单独检查 Commit。

6.5 Commit 结果不确定

时间线:

客户端发送 COMMIT

服务端写入并刷出 Commit WAL

服务端事务已提交

网络断开 / Primary 故障 / 客户端超时

客户端收到 error

此时客户端不能推断“事务未提交”。

因此:

  • 不要看到 Commit 网络错误就直接重新创建订单;
  • 使用 Idempotency Key;
  • 重新查询业务唯一键或请求状态;
  • 对外部副作用采用 Outbox;
  • 08007 transaction_resolution_unknown40003 statement_completion_unknown 视为结果不确定,而不是普通可重试失败。PostgreSQL 官方建议应用依赖 SQLSTATE 而不是本地化错误文本。(PostgreSQL)

6.6 Pool.Reset()Pool.Close()

Pool.Reset()

  • 关闭当前池中的连接;
  • 池本身保持可用;
  • 已借出的连接在归还时关闭;
  • 适合确认发生全局网络中断、服务器状态切换或路由变化后使用;
  • 不应在每个单独查询错误上调用,否则会形成重连风暴。

Pool.Close()

  • 用于应用停止;
  • 会等待已借出连接归还;
  • 没有 Close(ctx) 形式;
  • 因此必须先停止接收新请求、取消 Worker、等待处理完成,再关闭池。(Go Packages)

7. 内部数据结构和状态

本章不涉及 Heap Page,但需要掌握以下状态。

7.1 应用侧状态

状态含义关键指标
Idle已建立、未借出IdleConns
Acquired已借出AcquiredConns
Constructing正在 DNS/TCP/TLS/认证ConstructingConns
Waiting调用方等待可用连接Acquire 延迟、EmptyAcquireWaitTime
Canceled等待期间 Context 取消CanceledAcquireCount
Destroyed因空闲、寿命或错误关闭Destroy Count
Broken协议或网络状态不可复用错误日志、新连接速率

7.2 PostgreSQL 侧状态

应用连接在 pg_stat_activity 中可能表现为:

state含义池视角
active正在执行查询通常已 Acquired
idle会话空闲可能是池中的 Idle,也可能已借出但应用未执行 SQL
idle in transaction事务已开启但无当前 SQL高风险,连接和事务均未释放
idle in transaction (aborted)事务失败后未回滚连接不能正常执行后续 SQL
disabled统计跟踪关闭不应据此判断业务状态

池中 AcquiredConns=20 不代表 PostgreSQL 有 20 条 active 查询:部分连接可能在应用代码、结果处理或慢外部调用中。

7.3 Pool.Stat() 指标

指标解释
AcquireCount成功获取连接累计次数
AcquireDuration成功 Acquire 的累计耗时
AcquiredConns当前借出连接数
CanceledAcquireCount因 Context 取消而失败的 Acquire 累计次数
EmptyAcquireCount池为空后等待并最终成功的 Acquire 次数
EmptyAcquireWaitTime上述成功等待的累计耗时
ConstructingConns当前正在建立的连接数
IdleConns当前空闲连接数
NewConnsCount累计新建连接数
TotalConnsConstructing、Acquired 和 Idle 之和

这些是累计计数或快照。告警应计算时间窗口增量,而不是直接对累计值设阈值。(Go Packages)


8. 场景和选型决策

业务场景推荐方案不推荐方案原因性能代价并发代价一致性代价高可用代价运维复杂度
普通 Go API一个共享 pgxpool.Pool每请求创建连接或 Pool复用认证和 Backend少量池管理开销有界排队无直接代价需处理旧连接
PostgreSQL 专属服务原生 pgx为抽象而强制 database/sql保留 COPY、Batch、协议与类型能力较低清晰较清晰错误信息完整
第三方库要求 sql.DBpgx/stdlib自己重写第三方库兼容生态多一层抽象需独立预算取决于库需核对重连
在线与后台任务并存独立 Pool 或准入预算共用一个满负载池防止后台任务挤占请求少量空闲连接更稳定无直接代价多池重连需控制
PgBouncer Transaction Pooling避免会话状态,核对 Prepared 配置LISTEN、Session Lock、持久临时表后端不固定多一跳后端复用更高会话语义变化增加代理故障域
LISTEN/NOTIFY Worker独立直连或 Session Pooling普通 Transaction PoolLISTEN 绑定会话占用固定连接少一个池名额通知本身非可靠队列需重连并重新 LISTEN
极高连接实例数PgBouncer + 小型应用池每实例大 MinIdleConns降低 Backend 数量代理开销避免连接风暴需遵守 Pooling 模式多一层恢复逻辑
Failover 写入口独立 Writer Pool + 可写验证读写共用模糊地址避免写到只读节点健康验证成本重建期间排队可减少错误路由仍需幂等与退避

9. 高性能分析

9.1 连接池大小推导

先定义:

B_total   = PostgreSQL max_connections
B_reserve = 超级用户、保留角色、复制、监控、迁移、应急连接
B_other   = 其他服务与工具的预算
B_app     = B_total - B_reserve - B_other
N_peak    = 发布、扩容、故障转移期间可能同时存在的应用实例数
B_inst    = floor(B_app / N_peak)
C_knee    = 压测中吞吐停止增长或P99明显恶化前的活跃查询并发

每实例初始上限:

MaxConns <= min(B_inst, C_knee)

不能仅使用:

MaxConns = CPU 核数 × 固定倍数

因为最终值还取决于:

  • 数据规模和行宽;
  • 查询 CPU/I/O 特征;
  • P95/P99 查询时长;
  • 事务锁持有时间;
  • 读写比例;
  • WAL 和存储延迟;
  • Primary 与 Replica 数量;
  • 发布时双倍实例;
  • Failover 后重连;
  • 运维保留连接。

PostgreSQL 会按 max_connections 分配部分资源,包括共享内存;简单提高数据库上限并非无成本。(PostgreSQL)

9.2 推导示例

假设,仅作为方法示例:

max_connections                      300
超级用户、保留角色和应急              20
复制、监控、迁移                      20
其他服务                              60
本服务总预算                         200

正常实例数                              8
滚动发布/故障峰值实例数                 12
按连接预算每实例 floor(200/12)          16

压测发现:
活跃查询从 12 增至 16 时吞吐基本不变,
但数据库 CPU Run Queue 和 P99 明显上升。

因此初始 MaxConns = 12,而不是 16。

这 12 个连接还应按工作负载拆分,例如:

前台写请求池:8
前台只读池:2
后台任务池:2

是否拆为三个物理 Pool,要综合最低连接数和运维复杂度;也可以一个 Pool 配三个应用层 Semaphore。

9.3 性能成本

CPU

连接过多会增加:

  • Backend Process 调度;
  • 上下文切换;
  • Planner/Executor 并发;
  • 锁管理竞争;
  • 缓存失效。

内存

每条连接可能持有:

  • Backend 私有内存;
  • Prepared Statement 与 Portal 状态;
  • Sort/Hash 工作内存;
  • 客户端缓冲区;
  • TLS 状态。

work_mem 是每个执行节点潜在消耗,不应以“连接数 × work_mem”简单等同实际使用,但连接和并行节点越多,峰值越危险。

shared_buffers 与 OS Page Cache

连接池大小不会改变 shared_buffers 容量。增加连接可能让更多互不相关的查询并发执行,造成:

  • Buffer 缓存稀释;
  • 随机 I/O 增加;
  • Page Cache 抖动。

随机和顺序 I/O

连接数过少可能无法利用存储并发;连接数过多则会提高 Queue Depth 和尾延迟。最佳点必须通过稳定态压测确认。

[PG18] AIO

PostgreSQL 18 AIO 可以改善部分数据 I/O 路径,但它不会消除:

  • CPU 上限;
  • 锁竞争;
  • WAL Flush;
  • Backend 调度;
  • Pool 排队;
  • 应用的网络往返。

因此不能因为启用 AIO 就成倍放大 MaxConns

网络往返

  • 单行高频请求受 RTT 影响明显;
  • Statement Cache、Batch 和 Pipeline 可减少往返;
  • 但 Batch/Pipeline 会增加错误处理和连接占用复杂度;
  • 大结果集长时间流式读取也会长期占用连接。

WAL、Checkpoint 和 Vacuum

连接池不直接生成 WAL,但更高写并发会影响:

  • WAL Insert/Flush 竞争;
  • Checkpoint 脏页;
  • Dead Tuple 生成;
  • Autovacuum 追赶速度;
  • 副本重放延迟。

P95/P99

平均 Acquire 时间可能正常,但少量长事务可让 P99 Acquire 激增。因此至少记录:

  • 请求总延迟;
  • Acquire 延迟 Histogram;
  • SQL 延迟;
  • 事务时长;
  • 结果读取时长;
  • AcquiredConns/MaxConns
  • CanceledAcquireCount 增量。

9.4 Pool 指标推导

时间窗口内:

池使用率 ≈ AcquiredConns / MaxConns

等待成功比例
= ΔEmptyAcquireCount / ΔAcquireCount

成功等待平均值
= ΔEmptyAcquireWaitTime / ΔEmptyAcquireCount

Acquire 取消率
= ΔCanceledAcquireCount / 请求数

连接抖动率
= ΔNewConnsCount / 时间

AcquireDuration/AcquireCount 只能得到累计平均,不能替代 P95/P99。生产中应在调用边界单独记录 Histogram。


10. 高并发分析

10.1 五个不能混淆的数字

指标含义
goroutine 数应用并发执行单元,可以远大于连接数
连接数到数据库或 PgBouncer 的物理会话数
活跃查询数当前正在 PostgreSQL 执行 SQL 的 Backend 数
TPS每秒成功完成的事务数
排队请求数等待准入、等待 Pool 或等待锁的请求数

例如:

2000 个 goroutine
→ 200 个通过应用准入
→ 20 个取得连接
→ 12 个正在执行 SQL
→ 8 个在应用处理结果或事务间隙
→ 180 个等待 Pool
→ 1800 个等待准入或已被拒绝

这不等于 2000 个数据库并发。

10.2 goroutine 与连接的关系

错误模式:

for _, item := range items {
	go func() {
		_, _ = pool.Exec(ctx, sql, item.ID)
	}()
}

即使 Pool 限制了数据库连接,也会创建无限等待 goroutine,导致:

  • 堆内存增长;
  • 调度开销;
  • Context 和请求对象长期存活;
  • 下游恢复后瞬时冲击;
  • 超时和重试风暴。

正确方向:

sem := make(chan struct{}, concurrency)

for _, item := range items {
	select {
	case sem <- struct{}{}:
	case <-ctx.Done():
		return ctx.Err()
	}

	go func(item Item) {
		defer func() { <-sem }()
		process(ctx, item)
	}(item)
}

还需使用 WaitGroup、错误收集和总任务取消。

10.3 长事务为什么耗尽池

BeginTx
→ UPDATE,持有行锁
→ 调用外部支付接口 5 秒
→ 再执行 SQL
→ Commit

这五秒内:

  • 连接不能归还;
  • 行锁不能释放;
  • Snapshot 可能继续存在;
  • 其他事务可能排队;
  • Pool 的可用连接减少;
  • 请求超时后可能触发重试。

应改为:

事务前:调用无需依赖数据库锁的外部服务
事务内:只执行必要数据库操作
事务后:通过 Outbox 驱动外部副作用

不能简单把所有外部调用移到事务前;必须重新分析业务原子性,必要时使用预授权、状态机或 Outbox。

10.4 热点和锁竞争

增加 Pool 连接数不能解决:

  • 单库存行更新;
  • 单账户余额行;
  • 单租户序列化锁;
  • 热门唯一键 Upsert;
  • 固定队列表头;
  • 同一 B-tree 右侧热点。

反而可能使更多 Backend 同时排队于同一锁,形成 Lock Convoy。

10.5 重试风暴

事务重试必须:

  • 仅重试明确可重试的 4000140P01
  • 重试完整事务;
  • 有最大次数;
  • 指数退避和随机抖动;
  • 受 Context 总预算约束;
  • 通过全局并发限制防止恢复后洪峰;
  • 不把 Commit 结果不确定当作普通事务冲突。

11. 高可用分析

本章与高可用的关系是间接但关键:连接池不决定 RPO,却直接影响故障发生后的应用 RTO 和恢复稳定性。

11.1 Failover 时发生什么

Primary A
  ↓ TCP连接
pgxpool 中已有连接

A 故障

Replica B Promote

VIP/DNS/代理切到 B

现有 TCP 连接不会迁移到 B:

  • 正在执行的请求可能收到 EOF、Connection Reset、Admin Shutdown;
  • 空闲旧连接可能直到下一次使用才被发现失效;
  • 新 Acquire 会尝试建立新连接;
  • Session State、Prepared Statement、临时表和 LISTEN 状态会丢失;
  • 所有应用实例可能同时重连。

11.2 RPO 和 RTO

项目pgxpool 的作用
RPO不决定复制是否丢数据
数据库 Failover RTO不决定 Promotion 速度
应用恢复 RTO决定旧连接清除、重连、退避和 readiness 恢复方式
脑裂不能代替 Fencing
提交结果不确定必须由业务幂等和对账解决
读写路由应由 Writer/Reader Endpoint 和 Pool 隔离
FailbackPool 仍需逐步重建连接

11.3 Connection Storm

危险配置:

100 个应用实例
每实例 MinIdleConns = 20
Failover 后理论重连目标 = 2000 个连接

即使数据库最终只允许 300 条连接,也可能先遭遇:

  • TLS 握手 CPU 峰值;
  • 认证压力;
  • Backend Fork/启动压力;
  • DNS 和代理排队;
  • 53300 too_many_connections
  • readiness 抖动;
  • 所有实例同步重试。

缓解:

  • MaxConnLifetimeJitter
  • 启动和重连随机抖动;
  • 小型 MinIdleConns
  • 有界实例启动;
  • PgBouncer;
  • 应用层 Circuit Breaker 和 Load Shedding;
  • 为运维保留连接槽位;
  • 不在每个错误上调用 Reset()

11.4 Readiness 与 Liveness

Liveness

只表示进程事件循环仍健康,不应因为数据库暂时不可用就让编排器无限重启应用。

Readiness

表示实例是否适合接收需要数据库的流量。检查应:

  • 有短期限;
  • 低频或后台缓存结果;
  • 使用真实权限;
  • 不执行重查询;
  • Failover 时及时转为不可用;
  • 恢复时避免所有实例同时高频 Ping。

11.5 SQLSTATE

生产分类至少识别:

SQLSTATE含义建议
40001Serialization Failure可重试完整事务
40P01Deadlock Detected可重试完整事务
23505Unique Violation通常为业务冲突,不自动重试
25006Read-only SQL Transaction可能路由到了只读节点
53300Too Many Connections背压、降载、检查连接预算
57014Query Canceled区分 Context、statement_timeout 和人工取消
57P01Admin Shutdown重连,常见于 Failover/维护
57P02Crash Shutdown等待数据库恢复
57P03Cannot Connect Now数据库恢复或 Promotion 期间
08007Transaction Resolution Unknown对账,不能盲重试
40003Statement Completion Unknown对账,不能盲重试

这些代码及其稳定分类来自 PostgreSQL 官方 SQLSTATE 表。(PostgreSQL)


12. 三维影响矩阵

维度相关度核心收益主要风险关键指标
高性能复用连接、减少握手、限制数据库并发、降低尾延迟池过大、连接抖动、长结果占池Acquire P95/P99、NewConns、SQL P99
高并发goroutine 与数据库并发解耦、提供排队和背压Pool 耗尽、无限 goroutine、长事务、重试风暴Acquired/Max、Empty Wait、Canceled Acquire
高可用自动剔除旧连接、故障后重建、支持健康检查Connection Storm、旧连接、Commit 不确定重连率、SQLSTATE、readiness、恢复时间

13. 实验

实验一:限制 MaxConns 并复现连接池耗尽

13.1 实验目标

验证:

  • goroutine 数不等于数据库连接数;
  • Pool 满时请求在客户端等待;
  • 等待 Acquire 的请求不会出现在 PostgreSQL 的 pg_stat_activity 中;
  • Context 可以取消 Acquire;
  • EmptyAcquireCountCanceledAcquireCount 含义不同。

13.2 环境

  • PostgreSQL 14~18;
  • 当前 pgx/v5;
  • 不需要扩展;
  • 仅在测试环境执行。

13.3 准备

CREATE SCHEMA IF NOT EXISTS lab16;

CREATE TABLE IF NOT EXISTS lab16.pool_test (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    note text NOT NULL,
    created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);

连接池配置:

cfg.MaxConns = 2
cfg.MinConns = 0
cfg.MinIdleConns = 0

13.4 Session A 与 B

两个 goroutine 各自执行:

SELECT pg_backend_pid(), pg_sleep(8);

时间线:

T0:A 获取连接 1,开始 pg_sleep
T0:B 获取连接 2,开始 pg_sleep
T1:Pool AcquiredConns = 2

13.5 Session C:等待后成功

C 使用 15 秒 Context:

ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()

var one int
err := pool.QueryRow(ctx, `SELECT 1`).Scan(&one)

C 会在 Pool 等待,直到 A 或 B 归还连接。预期:

  • EmptyAcquireCount 增加;
  • EmptyAcquireWaitTime 增加;
  • PostgreSQL 在 C 获得连接前看不到第三条查询。

13.6 Session C:等待后取消

重新启动 A、B,然后将 C 的 Context 改为一秒:

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

err := pool.QueryRow(ctx, `SELECT 1`).Scan(&one)

预期:

  • C 在客户端失败;
  • CanceledAcquireCount 增加;
  • 数据库没有执行 SELECT 1
  • 这不是 PostgreSQL statement_timeout

13.7 诊断 SQL

SELECT
    pid,
    application_name,
    state,
    wait_event_type,
    wait_event,
    query_start,
    state_change,
    left(query, 120) AS query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY backend_start;

重要字段:

  • application_name:映射服务;
  • state:Backend 当前状态;
  • wait_event_type/wait_event:数据库内等待;
  • query_start:当前或最近查询开始时间;
  • state_change:状态变化时间。

连接池等待不属于 PostgreSQL Wait Event,因此必须结合应用指标。

13.8 记录指标

禁止伪造固定性能结果。记录:

  • PostgreSQL 版本;
  • MaxConns
  • 并发 goroutine 数;
  • 每个请求的 Acquire P50/P95/P99;
  • AcquiredConns
  • EmptyAcquireCount
  • EmptyAcquireWaitTime
  • CanceledAcquireCount
  • PostgreSQL Backend 数;
  • CPU 和网络。

13.9 清理

DROP SCHEMA lab16 CASCADE;

13.10 生产警告

不要在生产实例使用大量 pg_sleep 占满全部连接。


实验二:事务中调用慢外部服务

13.11 实验目标

比较:

  1. 事务内等待外部服务;
  2. 外部调用移到事务外;
  3. 连接占用和行锁等待的变化。

13.12 准备

CREATE SCHEMA IF NOT EXISTS lab16;

CREATE TABLE lab16.accounts (
    id bigint PRIMARY KEY,
    balance_cents bigint NOT NULL CHECK (balance_cents >= 0)
);

INSERT INTO lab16.accounts(id, balance_cents)
VALUES (1, 100000)
ON CONFLICT (id) DO UPDATE
SET balance_cents = EXCLUDED.balance_cents;

13.13 Session A:错误模式

T0  BeginTx
T1  UPDATE accounts SET balance_cents = balance_cents - 100 WHERE id = 1
T2  在 Go 中 time.Sleep(10s),模拟慢外部服务
T12 Commit

Session A 的 SQL:

UPDATE lab16.accounts
SET balance_cents = balance_cents - 100
WHERE id = 1;

13.14 Session B

在 T2 执行:

UPDATE lab16.accounts
SET balance_cents = balance_cents + 50
WHERE id = 1;

B 会等待 A 结束。

13.15 Session C:诊断

SELECT
    a.pid,
    a.application_name,
    a.state,
    a.xact_start,
    clock_timestamp() - a.xact_start AS xact_age,
    a.wait_event_type,
    a.wait_event,
    pg_blocking_pids(a.pid) AS blockers,
    left(a.query, 120) AS query
FROM pg_stat_activity AS a
WHERE a.datname = current_database()
ORDER BY a.xact_start NULLS LAST;

预期:

  • A 可能显示 idle in transaction
  • A 仍占用 pgxpool 连接;
  • B 等待事务 ID 或行锁;
  • Pool 可用连接减少。

13.16 改进模式

T0  在事务外完成可安全前置的外部调用
T10 BeginTx
T11 UPDATE
T12 Commit

再次测量:

  • 事务时长;
  • AcquiredConns
  • B 的锁等待;
  • 请求 P95/P99;
  • Pool Acquire 延迟。

13.17 注意

外部调用不能机械地移到事务外。必须分析:

  • 调用是否可撤销;
  • 是否需要预授权;
  • 是否会重复;
  • 是否应采用 Outbox 或 Saga;
  • Commit 不确定时如何对账。

实验三:数据库重启或 Failover 后的连接池恢复

13.18 实验目标

观察:

  • 旧 TCP 连接不会迁移;
  • 空闲旧连接可能在再次使用时才暴露错误;
  • Pool 如何销毁旧连接并建立新连接;
  • NewConnsCount 和错误分类如何变化。

13.19 环境

仅使用可丢弃测试环境,任选:

  • 重启 PostgreSQL;
  • Patroni 测试集群执行 Switchover;
  • 切换测试代理上游;
  • 不要在未演练的生产系统直接操作。

13.20 Session A

每秒执行:

SELECT
    pg_backend_pid(),
    pg_is_in_recovery(),
    clock_timestamp();

同时记录:

  • Backend PID;
  • 错误;
  • SQLSTATE;
  • Pool.Stat()
  • readiness 状态。

13.21 Session B

在 T5 重启数据库或执行 Planned Switchover。

13.22 Session C

观察数据库恢复后:

SELECT
    application_name,
    backend_start,
    state,
    count(*)
FROM pg_stat_activity
GROUP BY application_name, backend_start, state
ORDER BY backend_start;

13.23 预期结果

  • 某些在途请求失败;
  • 已提交但客户端未收到响应的写事务可能结果不确定;
  • 旧 PID 消失,新连接得到新 PID;
  • NewConnsCount 增长;
  • 恢复时间取决于数据库、代理、DNS、Context 和重试退避;
  • 不应声称所有请求都会由 pgx 自动无损重试。

13.24 Reset() 对照

确认路由已切换后,可测试一次:

pool.Reset()

比较自然淘汰与主动 Reset 的恢复速度和连接风暴。禁止在每个错误上调用。


14. 完整 Go 示例

14.1 示例 Schema

CREATE SCHEMA IF NOT EXISTS app;

CREATE TABLE app.users (
    tenant_id bigint NOT NULL,
    id bigint NOT NULL,
    email text NOT NULL,
    created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
    PRIMARY KEY (tenant_id, id),
    UNIQUE (tenant_id, email)
);

CREATE TABLE app.accounts (
    tenant_id bigint NOT NULL,
    id bigint NOT NULL,
    balance_cents bigint NOT NULL CHECK (balance_cents >= 0),
    PRIMARY KEY (tenant_id, id)
);

CREATE TABLE app.audit_events (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_id bigint NOT NULL,
    event_type text NOT NULL,
    payload jsonb NOT NULL,
    created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);

14.2 生产级骨架

package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"sync"
	"sync/atomic"
	"syscall"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/jackc/pgx/v5/pgxpool"
)

type queryNameKey struct{}
type traceStateKey struct{}

type traceState struct {
	name    string
	started time.Time
}

func withQueryName(ctx context.Context, name string) context.Context {
	return context.WithValue(ctx, queryNameKey{}, name)
}

type dbTracer struct {
	log *slog.Logger
}

func (t *dbTracer) TraceQueryStart(
	ctx context.Context,
	_ *pgx.Conn,
	_ pgx.TraceQueryStartData,
) context.Context {
	name, _ := ctx.Value(queryNameKey{}).(string)
	if name == "" {
		name = "unnamed"
	}

	return context.WithValue(ctx, traceStateKey{}, traceState{
		name:    name,
		started: time.Now(),
	})
}

func (t *dbTracer) TraceQueryEnd(
	ctx context.Context,
	_ *pgx.Conn,
	data pgx.TraceQueryEndData,
) {
	state, _ := ctx.Value(traceStateKey{}).(traceState)

	attrs := []any{
		"query_name", state.name,
		"duration", time.Since(state.started),
		"command_tag", data.CommandTag.String(),
		// 对 INSERT/UPDATE/DELETE 有意义;
		// 不能解释为 SELECT 被应用消费的结果行数。
		"rows_affected", data.CommandTag.RowsAffected(),
	}

	if data.Err == nil {
		t.log.Info("database query completed", attrs...)
		return
	}

	attrs = append(attrs, "error_class", classifyDBError(data.Err))

	var pgErr *pgconn.PgError
	if errors.As(data.Err, &pgErr) {
		attrs = append(attrs,
			"sqlstate", pgErr.Code,
			"constraint", pgErr.ConstraintName,
			"table", pgErr.TableName,
		)
	}

	// 不记录 SQL 参数,避免凭据、PII 和业务数据泄漏。
	t.log.Error("database query failed",
		append(attrs, "error", data.Err)...,
	)
}

func classifyDBError(err error) string {
	if err == nil {
		return "none"
	}
	if errors.Is(err, context.Canceled) {
		return "context_canceled"
	}
	if errors.Is(err, context.DeadlineExceeded) {
		return "context_deadline"
	}

	var pgErr *pgconn.PgError
	if !errors.As(err, &pgErr) {
		return "network_or_client"
	}

	switch pgErr.Code {
	case "40001", "40P01":
		return "retryable_transaction"
	case "08007", "40003":
		return "outcome_unknown"
	case "23505":
		return "unique_violation"
	case "23503":
		return "foreign_key_violation"
	case "23514":
		return "check_violation"
	case "25006":
		return "read_only_route"
	case "53300":
		return "too_many_connections"
	case "55P03":
		return "lock_not_available"
	case "57014":
		return "query_canceled"
	case "57P01", "57P02", "57P03":
		return "server_unavailable"
	default:
		return "postgres_" + pgErr.Code[:2]
	}
}

func envInt32(name string, fallback int32) (int32, error) {
	value := os.Getenv(name)
	if value == "" {
		return fallback, nil
	}

	n, err := strconv.ParseInt(value, 10, 32)
	if err != nil || n < 0 {
		return 0, fmt.Errorf("%s must be a non-negative int32", name)
	}
	return int32(n), nil
}

func openPool(
	ctx context.Context,
	log *slog.Logger,
) (*pgxpool.Pool, error) {
	dsn := os.Getenv("DATABASE_URL")
	if dsn == "" {
		return nil, errors.New("DATABASE_URL is required")
	}

	cfg, err := pgxpool.ParseConfig(dsn)
	if err != nil {
		return nil, fmt.Errorf("parse DATABASE_URL: %w", err)
	}

	maxConns, err := envInt32("PGPOOL_MAX_CONNS", 12)
	if err != nil {
		return nil, err
	}
	minIdleConns, err := envInt32("PGPOOL_MIN_IDLE_CONNS", 2)
	if err != nil {
		return nil, err
	}
	if maxConns <= 0 {
		return nil, errors.New("PGPOOL_MAX_CONNS must be greater than zero")
	}
	if minIdleConns > maxConns {
		return nil, errors.New(
			"PGPOOL_MIN_IDLE_CONNS cannot exceed PGPOOL_MAX_CONNS",
		)
	}

	cfg.MaxConns = maxConns
	cfg.MinConns = 0
	cfg.MinIdleConns = minIdleConns
	cfg.MaxConnLifetime = 45 * time.Minute
	cfg.MaxConnLifetimeJitter = 5 * time.Minute
	cfg.MaxConnIdleTime = 10 * time.Minute
	cfg.HealthCheckPeriod = 30 * time.Second
	cfg.PingTimeout = 2 * time.Second

	cfg.ConnConfig.ConnectTimeout = 5 * time.Second
	cfg.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeCacheStatement

	if cfg.ConnConfig.RuntimeParams == nil {
		cfg.ConnConfig.RuntimeParams = make(map[string]string)
	}
	cfg.ConnConfig.RuntimeParams["application_name"] =
		"orders-api.prod.writer"

	cfg.ConnConfig.Tracer = &dbTracer{log: log}

	// 此示例假设直连 PostgreSQL 或使用 Session Pooling。
	// Transaction Pooling 下不要依赖请求之间保持该会话状态。
	cfg.AfterConnect = func(
		ctx context.Context,
		conn *pgx.Conn,
	) error {
		_, err := conn.Exec(ctx, `SET TIME ZONE 'UTC'`)
		return err
	}

	// 无额外网络往返地拒绝异常事务状态的连接。
	cfg.PrepareConn = func(
		_ context.Context,
		conn *pgx.Conn,
	) (bool, error) {
		return conn.PgConn().TxStatus() == 'I', nil
	}

	pool, err := pgxpool.NewWithConfig(ctx, cfg)
	if err != nil {
		return nil, fmt.Errorf("create pool: %w", err)
	}

	// NewWithConfig 成功不代表已建立连接。
	pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	if err := pool.Ping(pingCtx); err != nil {
		pool.Close()
		return nil, fmt.Errorf("initial database ping: %w", err)
	}

	warmCtx, warmCancel := context.WithTimeout(ctx, 10*time.Second)
	defer warmCancel()

	if err := warmPool(warmCtx, pool, minIdleConns); err != nil {
		pool.Close()
		return nil, fmt.Errorf("warm pool: %w", err)
	}

	return pool, nil
}

func warmPool(
	ctx context.Context,
	pool *pgxpool.Pool,
	count int32,
) error {
	conns := make([]*pgxpool.Conn, 0, count)
	defer func() {
		for i := len(conns) - 1; i >= 0; i-- {
			conns[i].Release()
		}
	}()

	for i := int32(0); i < count; i++ {
		conn, err := pool.Acquire(ctx)
		if err != nil {
			return err
		}
		conns = append(conns, conn)
	}
	return nil
}

type User struct {
	ID        int64
	Email     string
	CreatedAt time.Time
}

func getUser(
	ctx context.Context,
	pool *pgxpool.Pool,
	tenantID int64,
	userID int64,
) (User, error) {
	ctx = withQueryName(ctx, "user.get")

	var user User
	err := pool.QueryRow(ctx, `
		SELECT id, email, created_at
		FROM app.users
		WHERE tenant_id = $1
		  AND id = $2
	`, tenantID, userID).Scan(
		&user.ID,
		&user.Email,
		&user.CreatedAt,
	)
	if err != nil {
		return User{}, fmt.Errorf("get user: %w", err)
	}
	return user, nil
}

func listUsers(
	ctx context.Context,
	pool *pgxpool.Pool,
	tenantID int64,
	afterID int64,
	limit int32,
) ([]User, error) {
	ctx = withQueryName(ctx, "user.list")

	rows, err := pool.Query(ctx, `
		SELECT id, email, created_at
		FROM app.users
		WHERE tenant_id = $1
		  AND id > $2
		ORDER BY id
		LIMIT $3
	`, tenantID, afterID, limit)
	if err != nil {
		return nil, fmt.Errorf("query users: %w", err)
	}
	defer rows.Close()

	users := make([]User, 0, limit)
	for rows.Next() {
		var user User
		if err := rows.Scan(
			&user.ID,
			&user.Email,
			&user.CreatedAt,
		); err != nil {
			return nil, fmt.Errorf("scan user: %w", err)
		}
		users = append(users, user)
	}

	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate users: %w", err)
	}
	return users, nil
}

var ErrInsufficientBalance = errors.New("insufficient balance")

func transfer(
	ctx context.Context,
	pool *pgxpool.Pool,
	tenantID int64,
	fromID int64,
	toID int64,
	amountCents int64,
) error {
	if amountCents <= 0 {
		return errors.New("amount must be positive")
	}
	if fromID == toID {
		return errors.New("source and destination must differ")
	}

	tx, err := pool.BeginTx(ctx, pgx.TxOptions{
		IsoLevel: pgx.Serializable,
	})
	if err != nil {
		return fmt.Errorf("begin transfer: %w", err)
	}

	// 使用独立、短时清理 Context,避免请求 Context 已取消后无法回滚。
	defer func() {
		rollbackCtx, cancel := context.WithTimeout(
			context.Background(),
			2*time.Second,
		)
		defer cancel()
		_ = tx.Rollback(rollbackCtx)
	}()

	lockCtx := withQueryName(ctx, "account.lock_transfer_pair")
	rows, err := tx.Query(lockCtx, `
		SELECT id, balance_cents
		FROM app.accounts
		WHERE tenant_id = $1
		  AND id = ANY($2::bigint[])
		ORDER BY id
		FOR UPDATE
	`, tenantID, []int64{fromID, toID})
	if err != nil {
		return fmt.Errorf("lock accounts: %w", err)
	}

	balances := make(map[int64]int64, 2)
	for rows.Next() {
		var id, balance int64
		if err := rows.Scan(&id, &balance); err != nil {
			rows.Close()
			return fmt.Errorf("scan locked account: %w", err)
		}
		balances[id] = balance
	}
	rows.Close()

	if err := rows.Err(); err != nil {
		return fmt.Errorf("iterate locked accounts: %w", err)
	}
	if len(balances) != 2 {
		return errors.New("account not found")
	}
	if balances[fromID] < amountCents {
		return ErrInsufficientBalance
	}

	updateCtx := withQueryName(ctx, "account.transfer_update")

	tag, err := tx.Exec(updateCtx, `
		UPDATE app.accounts
		SET balance_cents = balance_cents - $1
		WHERE tenant_id = $2
		  AND id = $3
	`, amountCents, tenantID, fromID)
	if err != nil {
		return fmt.Errorf("debit account: %w", err)
	}
	if tag.RowsAffected() != 1 {
		return errors.New("debit affected unexpected row count")
	}

	tag, err = tx.Exec(updateCtx, `
		UPDATE app.accounts
		SET balance_cents = balance_cents + $1
		WHERE tenant_id = $2
		  AND id = $3
	`, amountCents, tenantID, toID)
	if err != nil {
		return fmt.Errorf("credit account: %w", err)
	}
	if tag.RowsAffected() != 1 {
		return errors.New("credit affected unexpected row count")
	}

	if err := tx.Commit(ctx); err != nil {
		// 这里不能断言事务一定未提交。
		return fmt.Errorf(
			"commit transfer; outcome may be unknown: %w",
			err,
		)
	}
	return nil
}

type AuditEvent struct {
	TenantID int64
	Type     string
	Payload  []byte
}

func insertAuditBatch(
	ctx context.Context,
	pool *pgxpool.Pool,
	events []AuditEvent,
) (err error) {
	if len(events) == 0 {
		return nil
	}

	var batch pgx.Batch
	for _, event := range events {
		batch.Queue(`
			INSERT INTO app.audit_events(
			    tenant_id,
			    event_type,
			    payload
			)
			VALUES ($1, $2, $3::jsonb)
		`, event.TenantID, event.Type, event.Payload)
	}

	results := pool.SendBatch(
		withQueryName(ctx, "audit.insert_batch"),
		&batch,
	)

	defer func() {
		if closeErr := results.Close(); closeErr != nil {
			err = errors.Join(err, closeErr)
		}
	}()

	for range events {
		tag, execErr := results.Exec()
		if execErr != nil {
			return fmt.Errorf("execute audit batch: %w", execErr)
		}
		if tag.RowsAffected() != 1 {
			return errors.New("unexpected audit batch row count")
		}
	}
	return nil
}

func copyAuditEvents(
	ctx context.Context,
	pool *pgxpool.Pool,
	events []AuditEvent,
) (int64, error) {
	rows := make([][]any, 0, len(events))
	for _, event := range events {
		rows = append(rows, []any{
			event.TenantID,
			event.Type,
			event.Payload,
		})
	}

	count, err := pool.CopyFrom(
		withQueryName(ctx, "audit.copy_from"),
		pgx.Identifier{"app", "audit_events"},
		[]string{"tenant_id", "event_type", "payload"},
		pgx.CopyFromRows(rows),
	)
	if err != nil {
		return 0, fmt.Errorf("copy audit events: %w", err)
	}
	return count, nil
}

func healthLoop(
	ctx context.Context,
	pool *pgxpool.Pool,
	ready *atomic.Bool,
) {
	check := func() {
		pingCtx, cancel := context.WithTimeout(ctx, time.Second)
		err := pool.Ping(pingCtx)
		cancel()
		ready.Store(err == nil)
	}

	check()

	ticker := time.NewTicker(5 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			ready.Store(false)
			return
		case <-ticker.C:
			check()
		}
	}
}

func poolMetricsLoop(
	ctx context.Context,
	log *slog.Logger,
	pool *pgxpool.Pool,
) {
	ticker := time.NewTicker(10 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			stat := pool.Stat()
			log.Info("pgxpool stats",
				"max_conns", stat.MaxConns(),
				"total_conns", stat.TotalConns(),
				"acquired_conns", stat.AcquiredConns(),
				"idle_conns", stat.IdleConns(),
				"constructing_conns", stat.ConstructingConns(),
				"acquire_count", stat.AcquireCount(),
				"acquire_duration", stat.AcquireDuration(),
				"empty_acquire_count", stat.EmptyAcquireCount(),
				"empty_acquire_wait_time", stat.EmptyAcquireWaitTime(),
				"canceled_acquire_count", stat.CanceledAcquireCount(),
				"new_conns_count", stat.NewConnsCount(),
			)
		}
	}
}

func main() {
	log := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	rootCtx, stop := signal.NotifyContext(
		context.Background(),
		syscall.SIGINT,
		syscall.SIGTERM,
	)
	defer stop()

	pool, err := openPool(rootCtx, log)
	if err != nil {
		log.Error("database startup failed", "error", err)
		os.Exit(1)
	}

	var ready atomic.Bool
	var wg sync.WaitGroup

	wg.Add(2)

	go func() {
		defer wg.Done()
		healthLoop(rootCtx, pool, &ready)
	}()

	go func() {
		defer wg.Done()
		poolMetricsLoop(rootCtx, log, pool)
	}()

	mux := http.NewServeMux()

	mux.HandleFunc("/livez", func(
		w http.ResponseWriter,
		_ *http.Request,
	) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ok\n"))
	})

	mux.HandleFunc("/readyz", func(
		w http.ResponseWriter,
		_ *http.Request,
	) {
		if !ready.Load() {
			http.Error(
				w,
				"database not ready",
				http.StatusServiceUnavailable,
			)
			return
		}
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ready\n"))
	})

	server := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}

	serverErr := make(chan error, 1)
	go func() {
		serverErr <- server.ListenAndServe()
	}()

	select {
	case <-rootCtx.Done():
	case err := <-serverErr:
		if !errors.Is(err, http.ErrServerClosed) {
			log.Error("http server failed", "error", err)
		}
		stop()
	}

	ready.Store(false)

	shutdownCtx, cancel := context.WithTimeout(
		context.Background(),
		15*time.Second,
	)
	defer cancel()

	if err := server.Shutdown(shutdownCtx); err != nil {
		log.Error("http shutdown failed", "error", err)
	}

	// 停止健康检查和后台 Worker,确保不再借出连接。
	stop()
	wg.Wait()

	// Close 会等待借出连接归还;应放在流量和 Worker 停止之后。
	pool.Close()
}

14.3 代码审查结论

上述示例体现了以下边界:

  • DATABASE_URL 来自环境;
  • 只使用 $1 参数,不拼接用户输入;
  • Pool 在启动时通过 Ping 验证;
  • Rows 显式关闭并检查 Err
  • BatchResults 的 Close 错误未丢弃;
  • 事务有 Rollback 兜底;
  • Commit 错误被标记为结果可能未知;
  • Tracer 不记录参数;
  • readiness 与 liveness 分离;
  • Pool 统计可观测;
  • 关闭顺序是停止流量、停止 Worker、等待 goroutine、关闭 Pool。

15. 生产排障 Runbook

15.1 首先确认

  1. 是所有实例、单个实例还是单个租户?
  2. 错误发生在 Acquire、SQL、结果读取还是 Commit?
  3. 是否伴随发布、扩容、Failover、证书轮换或网络变更?
  4. SQLSTATE 是什么?
  5. AcquiredConns 是否接近 MaxConns
  6. EmptyAcquireWaitTimeCanceledAcquireCount 是否增长?
  7. PostgreSQL 实际连接数是否达到上限?
  8. 是否存在长事务或 idle in transaction

15.2 应用指标

优先查看:

request_duration_seconds
db_acquire_duration_seconds
db_query_duration_seconds
db_transaction_duration_seconds
pgxpool_acquired_conns
pgxpool_idle_conns
pgxpool_constructing_conns
pgxpool_empty_acquire_total
pgxpool_empty_acquire_wait_seconds_total
pgxpool_canceled_acquire_total
pgxpool_new_connections_total
errors_by_sqlstate

判断:

  • Acquire P99 上升、SQL 时间稳定:Pool 或准入瓶颈;
  • SQL P99 上升、Acquire 稳定:数据库执行问题;
  • 两者都上升:长查询、锁或 I/O 正在占满连接;
  • NewConns 激增:连接轮换、网络抖动或 Failover;
  • Idle 很多但请求慢:不是 Pool 容量不足。

15.3 数据库连接分布

SELECT
    application_name,
    state,
    wait_event_type,
    wait_event,
    count(*) AS connections
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY
    application_name,
    state,
    wait_event_type,
    wait_event
ORDER BY connections DESC;

15.4 长事务

SELECT
    pid,
    application_name,
    usename,
    state,
    xact_start,
    clock_timestamp() - xact_start AS xact_age,
    query_start,
    wait_event_type,
    wait_event,
    left(query, 160) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;

关注:

  • xact_age
  • idle in transaction
  • 是否来自后台任务;
  • 是否长期持有同一个 Pool 连接。

15.5 blocker

SELECT
    blocked.pid AS blocked_pid,
    blocked.application_name AS blocked_application,
    blocked.wait_event_type,
    blocked.wait_event,
    pg_blocking_pids(blocked.pid) AS blocking_pids,
    clock_timestamp() - blocked.query_start AS blocked_for,
    left(blocked.query, 160) AS blocked_query
FROM pg_stat_activity AS blocked
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0
ORDER BY blocked.query_start;

随后检查 blocker:

SELECT
    pid,
    application_name,
    state,
    xact_start,
    clock_timestamp() - xact_start AS xact_age,
    left(query, 160) AS query
FROM pg_stat_activity
WHERE pid = ANY($1::integer[]);

诊断工具中必须使用参数数组传入 PID,不要拼接 SQL。

15.6 连接预算

SELECT name, setting
FROM pg_settings
WHERE name IN (
    'max_connections',
    'reserved_connections',
    'superuser_reserved_connections'
)
ORDER BY name;
SELECT count(*) AS client_backends
FROM pg_stat_activity
WHERE backend_type = 'client backend';

不能只看 max_connections,还需确认:

  • 每个应用实例数;
  • 每实例 MaxConns
  • 发布期间实例峰值;
  • PgBouncer Server Pool;
  • 监控、迁移、备份和 DBA 连接。

15.7 找到最早计划估算错误

本章主要是连接层,但 Pool 耗尽可能由慢计划引起。

步骤:

  1. 从 Trace 或 pg_stat_statements 找到占用总时间最高、P99 最差的 Query Name;
  2. 使用代表性参数在可控环境执行:
EXPLAIN (
    ANALYZE,
    BUFFERS,
    WAL,
    SETTINGS,
    VERBOSE,
    SUMMARY
)
SELECT ...;
  1. 从最内层节点向上找第一处 Actual RowsRows 出现数量级偏差的位置;
  2. 检查 loops
  3. 检查 Sort、Hash、Temporary File;
  4. 结合 Wait Event、CPU 和 I/O。

不要在普通请求路径自动执行 EXPLAIN ANALYZE。对写语句它会真正执行;即便放在 BEGIN/ROLLBACK 中,Sequence、外部系统和某些触发器副作用也未必完全回滚。

15.8 故障分类

现象优先判断
CPU 高、Pool 满查询并发超过性能拐点、坏计划、并行过多
I/O 高、Pool 满缓存未命中、扫描量上升、随机 I/O 饱和
锁等待高热点行、长事务、DDL、固定锁顺序问题
Pool 满、数据库 active 少应用在事务中做慢处理、Rows 泄漏
53300全局连接预算失控
NewConns 高网络抖动、寿命过短、连接风暴
WAL 高写吞吐、批量更新、索引维护
Vacuum 落后长事务和写放大间接占池
Replica Lag 高写负载、长事务、重放资源不足
25006Writer 地址指向只读节点
08007/40003结果不确定,启动对账流程

15.9 在线安全操作

通常可在线执行:

  • 查询 pg_stat_activity
  • 查询 pg_locks
  • 读取 Pool.Stat()
  • 暂停后台任务;
  • 降低应用并发;
  • 限制新流量;
  • 对已确认的异常 Pool 执行一次受控 Reset;
  • 取消明确的非关键长查询。

高风险操作:

  • 批量 pg_terminate_backend
  • 同时重启所有应用;
  • 无差别 Pool.Reset()
  • 临时放大所有实例 MaxConns
  • 取消未知业务事务;
  • Failover 后直接重放写请求;
  • 在事故期间修改多个超时和池参数而无基线。

15.10 临时止损

  • 暂停后台批处理;
  • 下调应用 Admission 并发;
  • 对非关键接口 Load Shedding;
  • 缩短新请求的队列预算,而不是强杀在途事务;
  • 隔离热点租户;
  • 启用只读降级;
  • 限制重试速率;
  • 保留 DBA 连接。

15.11 根本修复

  • 修复 Rows/Tx/Batch 泄漏;
  • 将慢外部服务移出事务或改为 Outbox;
  • 为在线和后台任务设置独立预算;
  • 重新推导 MaxConns
  • 修复慢计划和索引;
  • 减少热点竞争;
  • 为 Failover 增加抖动和幂等;
  • 按 Pooling 模式消除会话依赖。

15.12 验证

  • Acquire P95/P99 回到基线;
  • CanceledAcquireCount 增量归零或恢复正常;
  • AcquiredConns 不再长期贴近上限;
  • 长事务和 blocker 消失;
  • NewConns 速率稳定;
  • 错误率和业务不变量正常;
  • 进行相同负载的回归压测。

16. 常见错误与反模式

  1. 每个请求创建一个 Pool Pool 应是进程级长生命周期对象。

  2. MaxConns 设置成 goroutine 数 goroutine 是上游并发,不是数据库容量。

  3. 把 Pool 当成吞吐放大器 Pool 的首要作用是复用、限制和排队。

  4. 多个 goroutine 共用同一个 pgx.Conn pgx.Conn 不支持并发调用。

  5. 忘记 Rows.Close()rows.Err() 会泄漏连接或丢失流式读取阶段错误。

  6. 忘记关闭 BatchResults 可能让连接停留在不可复用的协议状态。

  7. 事务内调用慢外部服务 同时延长连接、Snapshot 和锁占用。

  8. Ping 放在每个业务请求前 增加额外往返,也不能消除 Commit 结果不确定。

  9. Transaction Pooling 下依赖 Session State 包括 SET、LISTEN、持久临时表和 Session Advisory Lock。

  10. 对所有错误自动重试或每次错误执行 Reset 会重复业务写入并制造重连风暴。


17. 模拟生产事故案例

案例一:发布导致连接风暴

模拟生产案例

系统背景

  • PostgreSQL max_connections=400
  • 20 个应用实例;
  • 每实例 MaxConns=30MinIdleConns=20
  • 滚动发布时新旧实例重叠;
  • 无 PgBouncer。

故障现象

  • 发布后 53300 too_many_connections
  • readiness 大量失败;
  • 新旧实例反复重启;
  • 数据库认证 CPU 升高;
  • 正常实例也开始失去连接。

错误假设

“Pool 最多才 30,不算多,而且连接会按需建立。”

实际最低空闲目标已造成:

40 个重叠实例 × 20 = 800 个预期空闲连接

排查过程

  1. application_name 聚合 pg_stat_activity
  2. 发现发布版本连接数同步增长;
  3. NewConnsCount 在所有实例激增;
  4. 数据库 SQL 本身并不慢;
  5. 连接主要处于 idle

根因

单实例配置未乘以发布峰值实例数,MinIdleConns 造成同步建连。

临时止损

  • 暂停发布;
  • 限制新实例并发启动;
  • 降低 MinIdle;
  • 保留旧健康实例;
  • 对非关键流量降载。

最终修复

  • 用峰值 40 个实例推导全局预算;
  • MaxConns 下调;
  • MinIdleConns 设置为经过测量的低值;
  • 加入启动 Jitter;
  • 引入 PgBouncer Transaction Pooling;
  • 保留运维连接槽位。

监控补充

  • 所有实例 MaxConns 配置总和;
  • NewConnsCount 速率;
  • 数据库按 application_name 的连接数;
  • 发布期间峰值实例数;
  • 53300 告警。

防止复发

把连接预算检查加入发布准入:

预计峰值实例数 × 单实例 MaxConns
<= 分配给该服务的全局连接预算

案例二:Rows 泄漏导致 Pool 耗尽

模拟生产案例

系统背景

订单导出接口读取大量行,Pool MaxConns=20

故障现象

  • 数据库 CPU 正常;
  • PostgreSQL active 查询不多;
  • 应用 Acquire P99 持续上升;
  • AcquiredConns=20
  • CanceledAcquireCount 增长。

错误假设

“查询已经返回,连接应该自动回池。”

排查过程

  1. 数据库 pg_stat_activity 多条连接为 idle
  2. 应用 Pool 显示全部 Acquired;
  3. Goroutine Profile 指向导出函数;
  4. 发现循环在达到业务限制后直接返回;
  5. 函数没有 defer rows.Close()

根因

结果集未关闭,连接仍被调用方持有。

临时止损

  • 下线导出接口;
  • 重启少量实例释放泄漏;
  • 限制导出并发;
  • 不扩大 MaxConns 掩盖问题。

最终修复

rows, err := pool.Query(ctx, sql, args...)
if err != nil {
	return err
}
defer rows.Close()

for rows.Next() {
	// ...
}
return rows.Err()

同时为导出任务设置独立 Pool 或 Semaphore。

监控补充

  • Pool Acquired;
  • Acquire P99;
  • 长时间持有租约的 Tracer;
  • 导出任务并发数;
  • Goroutine 数。

防止复发

  • Code Review 检查 Rows、Tx、Batch 生命周期;
  • 静态分析和集成测试;
  • 故意提前退出循环的测试;
  • Pool 耗尽回归实验。

18. 面试题

18.1 核心概念题

题 1:pgx.Connpgxpool.Pool 有什么区别?

30 秒回答: pgx.Conn 是单个 PostgreSQL 会话,不支持多个 goroutine 并发使用;pgxpool.Pool 管理多个连接并支持并发 Acquire。

深入回答: Conn 适合 LISTEN、迁移或专用会话;Pool 适合 API 服务。Pool 能限制并发,但不能修复慢 SQL。缺点是增加租约、排队和生命周期管理。替代方案是 database/sql 或 PgBouncer,但仍要计算总连接预算。

考察: 是否真正理解连接与池。

错误回答: “Conn 和 Pool 都是并发安全的。”

追问: 一个事务是否可以跨两个 Pool 连接?

答案: 不可以。事务绑定一个物理会话,必须通过同一个 Tx 执行。


题 2:goroutine 数为什么不应等于连接数?

30 秒回答: goroutine 是应用并发,连接是数据库并发预算;两者应通过 Pool 和准入队列解耦。

深入回答: 高于数据库最佳活跃并发后,更多连接只会增加 CPU 调度、锁和 I/O 排队。优点是 Pool 能提供背压;缺点是会增加排队延迟,因此还需超时和 Load Shedding。替代方案是 PgBouncer,但它也不能突破数据库执行容量。

错误回答: “连接越多 TPS 越高。”

追问: 如何找最佳值?

答案: 在代表性数据和硬件上增加活跃查询并发,找到吞吐停止增长、P99 或资源队列开始恶化的拐点。


题 3:MinConnsMinIdleConns 有何区别?

30 秒回答: MinConns 约束池总连接下限;MinIdleConns 目标是维持可立即借出的空闲连接,更直接用于降低突发请求的建连尾延迟。

深入回答: MinIdle 太高会引发启动或 Failover 连接风暴;太低则第一波请求承担建连成本。应结合实例峰值、TLS 成本和 SLO 测量。

错误回答: “两个参数完全相同。”

追问: 是否应该等于 MaxConns?

答案: 通常不应。除非有明确测量和数据库预算,否则会让所有实例常驻最大连接数。


题 4:连接、会话和事务有什么区别?

30 秒回答: 连接承载会话;会话可执行多个事务;事务只覆盖 Begin 到 Commit/Rollback。

深入回答: 会话状态可跨事务保留,因此在 Transaction Pooling 下会失效。事务内锁、Snapshot 和 SET LOCAL 只存在于当前事务。

错误回答: “一个连接只能执行一个事务。”

追问: 临时表属于哪个边界?

答案: 通常属于会话;ON COMMIT DROP 可把生命周期限制到事务,但仍需核对 Pooling 模式。


题 5:为什么必须检查 rows.Err()

30 秒回答: Query 成功只表示查询已启动,网络、解码或服务端错误可能在迭代过程中才出现。

深入回答: Rows.Close() 负责释放连接,rows.Err() 负责报告流式读取错误。替代方案 CollectRows 可自动关闭,但仍会返回错误并可能一次性占用更多内存。

错误回答: “Query 没有返回 error 就不会再错。”

追问: 提前退出循环怎么办?

答案: 立即 Close,通常通过 defer rows.Close() 保证。


18.2 原理与排障题

题 6:Pool 满时请求在哪里等待?

30 秒回答: 等待发生在应用 pgxpool,尚未发送到 PostgreSQL,因此数据库 Wait Event 看不到它。

深入回答: 需要同时监控 Acquire Histogram、EmptyAcquireWaitTime、CanceledAcquireCount 和数据库活动。优点是保护数据库;风险是队列过长导致超时堆积。

错误回答: “会显示为 PostgreSQL Lock 等待。”

追问: 如何证明?

答案: 将 MaxConns 设为 2,占满连接后发起第三个 Acquire,并对比 Pool Stats 与 pg_stat_activity


题 7:为什么数据库 active 少但 Pool 仍可能耗尽?

30 秒回答: 已借出连接可能在应用代码、结果读取、慢外部调用或 idle transaction 中,而非正在执行 SQL。

深入回答: 对比 AcquiredConnspg_stat_activity.state,再查 Goroutine Profile、事务时长和 Rows 生命周期。

错误回答: “数据库 active 少说明 Pool 一定有空闲。”

追问: 最常见代码问题是什么?

答案: Rows 未关闭、事务未结束、BatchResults 未关闭、事务内慢外部调用。


题 8:如何处理 Commit 返回网络错误?

30 秒回答: 将结果视为未知,不能直接断言回滚,也不能盲目重试业务写入。

深入回答: 使用 Idempotency Key、业务唯一约束、状态查询和 Outbox。0800740003 应进入对账路径。优点是避免重复订单;代价是业务模型更复杂。

错误回答: “Commit error 就重新执行整个请求。”

追问: 40001 呢?

答案: 它表示事务被回滚,可在满足幂等与重试预算时重新执行完整事务。


题 9:AcquireDuration 很高说明什么?

30 秒回答: 说明调用方获取连接耗时高,但还需判断是 Pool 空、建连慢还是健康检查慢。

深入回答: 查看 EmptyAcquire、Constructing、NewConns、DNS/TLS 指标和数据库连接数。累计平均不能代替 P99。

错误回答: “一定是 SQL 慢。”

追问: SQL 慢能否间接导致 Acquire 慢?

答案: 能。SQL 长时间占用连接,后续请求会在 Pool 排队。


题 10:PgBouncer Transaction Pooling 对 pgx Prepared Statement 有什么影响?

30 秒回答: 不能再笼统说完全不支持。现代 PgBouncer 可跟踪协议级命名 Prepared Statement,但需要核对配置;SQL PREPARE 和多数会话状态仍不兼容。

深入回答: 可选择 PgBouncer Prepared 支持、QueryExecModeExec,或在受限代理下退回 Simple Protocol。每种方案在 RTT、类型编码和兼容性上有取舍。

错误回答: “所有 Prepared Statement 都必须关闭。”

追问: DescribeExec 为什么也可能有代理问题?

答案: 它可能需要多个往返;若代理在两轮之间把客户端映射到不同 Backend,描述与执行状态可能不一致。


题 11:什么时候使用 Pool.Reset()

30 秒回答: 在确认全局连接状态已失效,例如网络中断或服务器状态切换后,受控关闭池中连接并让后续请求重建。

深入回答: 单条业务错误不应 Reset;否则所有实例可能同时重连。替代方案是让池自然剔除坏连接。

错误回答: “任何 SQL error 都 Reset。”

追问: 已借出连接怎么办?

答案: Reset 时可继续被持有,但归还时会关闭。


18.3 架构设计题

题 12:如何为 50 个实例设计连接预算?

30 秒回答: 从数据库总上限扣除保留和其他工作负载,再除以发布、扩容和故障峰值实例数,并与压测得到的性能拐点取较小值。

深入回答: 还要拆分 Writer、Reader、后台任务和运维连接。引入 PgBouncer 可降低 Backend 数,但应用 Pool 仍需有界。

错误回答: “每实例默认 100 个连接。”

追问: 发布时为什么不能只按正常 50 个实例算?

答案: 滚动发布可能同时存在新旧实例,Failover 或自动扩容还可能进一步放大实例数。


题 13:在线请求和后台任务是否应共用 Pool?

30 秒回答: 低负载时可共用,但必须有独立并发预算;高风险系统应使用独立 Pool 或 Bulkhead。

深入回答: 独立 Pool 提供强隔离,但增加常驻连接和重连复杂度;单 Pool 配 Semaphore 更节省连接,但隔离较弱。

错误回答: “只要 MaxConns 足够大就没问题。”

追问: 如何防止后台任务饿死?

答案: 为后台保留小型固定预算,并为在线请求设置更高优先级和独立超时。


题 14:如何设计 Failover 后的应用恢复?

30 秒回答: 使用稳定 Writer Endpoint、短 Connect Timeout、旧连接剔除、带 Jitter 的有界重连、readiness、幂等写入和 Load Shedding。

深入回答: TCP 连接不会迁移;Commit 结果可能未知;所有实例不能同时 Reset 和全速重试。Pool 只负责连接恢复,不能替代 Fencing 或复制一致性。

错误回答: “驱动会把所有事务自动切到新主库。”

追问: 是否重试所有失败 SELECT?

答案: 只读且无副作用的 SELECT 通常更适合有限重试,但仍需遵守 Context 和全局背压。


题 15:如何设计数据库健康检查?

30 秒回答: Liveness 不依赖数据库;Readiness 使用后台、低频、短超时的 Ping 或轻量查询缓存结果。

深入回答: 每请求 Ping 增加 RTT;高频探针会形成负载。Failover 时 readiness 应及时失败,恢复时要有抖动,避免所有实例同步建连。

错误回答: “数据库不通就让 Kubernetes 不断重启应用。”

追问: 为什么重启可能更糟?

答案: 它会销毁现有健康状态并触发更多 TLS、认证和 Backend 创建,形成 Connection Storm。


19. 练习和参考答案

19.1 理论题

1. MaxConns=20 是否表示数据库始终有 20 条连接?

答案: 不一定。它是上限;实际连接数受请求、MinConnsMinIdleConns、空闲回收和连接寿命影响。

2. AcquiredConns=20 是否表示有 20 条 SQL 正在执行?

答案: 不一定。连接可能处于应用处理、结果读取、事务间隙或慢外部调用。

3. 为什么 Pool.Close() 可能长期阻塞?

答案: 它等待借出的连接归还。应先停止新流量、取消 Worker 并等待请求退出。

4. 23505 是否应该自动重试?

答案: 通常不应。它多为业务唯一性冲突;只有在明确的并发分配算法中才可能有受控重试。

5. 为什么不能把 search_path 当成普通业务请求状态?

答案: 它属于会话状态;在连接复用和 Transaction Pooling 下可能由其他请求或不同 Backend 承载。应使用 Schema 限定 SQL。

19.2 实验题

1. 将 MaxConns 从 2 逐步增加到 32,记录吞吐和 P99。

参考方法:

  • 固定数据、查询和硬件;
  • 每档充分 Warm-Up;
  • 保持测试时长一致;
  • 记录 Acquire、SQL、CPU、I/O、Wait Event;
  • 找到吞吐增益消失而 P99 开始恶化的拐点;
  • 不以单次结果作为生产结论。

2. 故意在 Rows 循环第十行提前返回但不 Close。

预期:

  • Pool Acquired 持续增长;
  • 数据库连接可能显示 idle;
  • 最终 Acquire 超时;
  • 加上 defer rows.Close() 后恢复。

3. 在 Transaction Pooling 下测试 Session State。

依次测试:

SET application_name = 'changed';
CREATE TEMP TABLE temp_test(id integer);
LISTEN demo;
SELECT pg_advisory_lock(1);

参考答案: 根据 PgBouncer 当前 Feature Map 判断哪些状态不能跨事务依赖;协议级 Prepared Statement 还需核对 max_prepared_statements

19.3 排障题

1. Pool Acquired=Max,数据库 CPU 低,active 查询很少。

参考答案:

  1. idle in transaction
  2. 查 Rows、Tx、Batch 泄漏;
  3. 查事务内外部调用;
  4. 查 Goroutine Profile;
  5. 暂停相关接口;
  6. 修复资源生命周期,而不是先扩大 Pool。

2. Failover 后所有实例出现大量 53300

参考答案:

  • 检查所有实例同步重连;
  • 检查 MinIdleConns
  • 检查是否每个错误都调用 Reset;
  • 降低准入和重连速率;
  • 保留部分实例逐步恢复;
  • 从峰值实例重新计算预算;
  • 引入 PgBouncer 或分批重建。

19.4 系统设计题

设计一个订单服务数据库访问层

条件:

  • 正常 20 个实例,发布峰值 30 个;
  • Primary 总连接上限 500;
  • 需保留 100 个给复制、运维和其他服务;
  • 订单请求 P99 目标 200ms;
  • 后台对账任务吞吐高但优先级低;
  • 有一个只读副本。

参考设计:

Primary 可分配预算:400
订单服务分配:300
其他预留:100

峰值实例数:30
Primary 理论每实例预算:10
压测拐点:假设每实例 8
Writer Pool MaxConns:8

Replica 单独核算预算
Reader Pool:按副本容量设置,例如每实例 4,但必须重新核算副本总预算

后台任务:
优先选择独立 Semaphore,最多使用每实例 1~2 条 Writer 连接;
若隔离要求高,建立小型独立 Pool,但总预算不能重复计算。

设计还应包括:

  • Schema 限定 SQL;
  • Query Name 和 Tracer;
  • Acquire 与 Query 分段超时;
  • Idempotency Key;
  • Commit Unknown 对账;
  • Failover Jitter;
  • readiness/liveness;
  • 在线和后台 Bulkhead;
  • Pool Stats 告警;
  • Replica 过期读取策略。

20. 检查清单

  • 我能够解释 goroutine、连接、Backend、活跃查询和 TPS 的区别。
  • 我能够根据全局连接预算和峰值实例数推导 MaxConns
  • 我能够说明 MinConnsMinIdleConns 的差异。
  • 我能够正确关闭 Rows 并检查 rows.Err()
  • 我能够正确关闭 BatchResults
  • 我能够使用 BeginTx、Rollback 兜底并单独检查 Commit。
  • 我不会因为 Commit 返回错误就断言事务一定未提交。
  • 我能够使用 errors.As*pgconn.PgError 分类 SQLSTATE。
  • 我能够通过 Pool Stats 识别 Acquire 排队和连接风暴。
  • 我能够复现连接池耗尽。
  • 我能够定位长事务、blocker 和事务内慢外部调用。
  • 我能够解释 pgx.Conn 为什么不能由多个 goroutine 并发使用。
  • 我能够解释 Transaction Pooling 下的会话状态限制。
  • 我能够为 Failover 设计重连退避、Jitter 和 Load Shedding。
  • 我能够区分 readiness 与 liveness。
  • 我能够分析连接池对 CPU、内存、I/O、WAL 和 P99 的间接影响。
  • 我能够在 Go 中实现有界并发、Pool 预热和优雅关闭。
  • 我能够验证修复,而不是仅通过扩大连接数止损。

21. 原结论检查结果

你给出的第 16 章范围总体正确,但建议做以下精确修订:

  1. 技术基线结论成立:截至 2026 年 6 月 20 日,PostgreSQL 18 是稳定主版本,精确维护版本为 18.4;PostgreSQL 19 是 Beta 1。(PostgreSQL)
  2. 将配置项 “Lifetime Jitter” 精确写为 MaxConnLifetimeJitter
  3. MinConnsMinIdleConns 不能并列而不解释差异;维持请求可立即使用的空闲容量时,当前 pgxpool 文档明确更推荐 MinIdleConns。(Go Packages)
  4. PrepareConn 应作为当前 API 讲解重点;旧的 BeforeAcquire 已标记为 Deprecated。(Go Packages)
  5. PingTimeout 是当前 Pool 配置字段,但它不能替代业务 Context、Connect Timeout 或 statement_timeout
  6. AcquireDurationEmptyAcquireWaitTime 是累计值,不能直接当作 P95/P99;生产必须另建 Histogram。
  7. EmptyAcquireCount 只统计等待后成功获取的情况;Context 取消由 CanceledAcquireCount 统计。(Go Packages)
  8. Pool.Close() 会等待借出连接归还;优雅关闭顺序必须先停止流量和 Worker。Pool.Reset() 保持 Pool 可用,只关闭当前连接。(Go Packages)
  9. PgBouncer Prepared Statement 结论必须写成版本和配置相关,不能继续笼统地说 Transaction Pooling 完全不支持 Prepared Statement。(PgBouncer)
  10. “连接池是并发安全的”不能外推为“池里取得的 pgx.Conn 也是并发安全的”;后者不成立。(Go Packages)