返回文章列表

PostgreSQL 性能工程、可观测性、准入控制与 PgBouncer

从 SLO、工作负载模型、pgxpool、PgBouncer、PostgreSQL 统计视图、压测方法、准入控制和 Runbook 出发,建立生产级 PostgreSQL 性能治理闭环。

第 18 章:性能工程、可观测性、准入控制与 PgBouncer

技术基线:PostgreSQL 18;兼容说明覆盖 PostgreSQL 14—18。Go 使用当前稳定版与 github.com/jackc/pgx/v5pgxpool。资料核验日期:2026-06-20。

本章原则:先定义 SLO、工作负载和容量边界,再观测、实验、归因和治理;不要从“复制一组调优参数”开始。

1. 本章定位

性能问题不是“某条 SQL 慢”这么简单。一次请求的端到端延迟可能由应用排队、连接池等待、PgBouncer 等待、锁等待、CPU 调度、缓存未命中、存储 I/O、WAL 刷盘、同步复制确认和网络共同组成。若只看数据库执行时间,就会漏掉最容易推高 P99 的排队时间;若只增加连接,又可能把可控的应用队列变成数据库内部的 CPU、锁和 I/O 争用。

本章建立完整闭环:

  1. 用 SLO 和工作负载模型定义“性能好”的可验证含义;
  2. 从应用、pgxpool、PgBouncer、PostgreSQL 和操作系统五层收集证据;
  3. 通过基线、预热、稳态、开放环压测和容量曲线寻找饱和点;
  4. 用有界并发、超时预算、限流、熔断、舱壁和负载丢弃保护数据库;
  5. 正确选择 PgBouncer 的池化模式,并识别会话状态与预处理语句边界;
  6. 把诊断过程固化为可值班执行的 Runbook。

本章依赖第 6—7 章的执行计划与统计信息、第 11—13 章的锁/WAL/Checkpoint、第 16 章的 pgxpool、第 17 章的高吞吐写入;为第 19 章容量规划与水平扩展、第 20—23 章恢复和高可用提供性能证据。本文不重复索引原理、MVCC、WAL 格式、复制协议或 Patroni/Fencing 的完整实现。

1.1 版本边界

能力PG14PG15PG16PG17PG18
pg_stat_wal✓,字段有所调整
pg_stat_io新增✓,覆盖 WAL 等更多对象
pg_stat_checkpointer新增✓,增加完成次数等信息
pg_wait_events新增
pg_aios 与内置异步 I/O 观测新增
pg_stat_get_backend_io(pid) / pg_stat_get_backend_wal(pid)新增
log_lock_failures新增;当前主要覆盖 NOWAIT 获取锁失败

在 PG14—15 上没有 pg_stat_io;应使用 pg_stat_databasepg_statio_*EXPLAIN (ANALYZE, BUFFERS)、日志和操作系统指标补齐。不要把 PG18 SQL 原样部署到旧版本而不做能力探测。

2. 可验证的学习目标

完成本章后,读者应能:

  1. 写出包含成功率、P50/P95/P99、吞吐量、时间窗口和适用工作负载的 SLO;
  2. 区分 QPS、TPS、连接数、数据库活动查询数、应用 goroutine 数和排队长度;
  3. 用 Little’s Law 检查吞吐量、端到端延迟与系统内请求数是否自洽,并说明其适用限制;
  4. pg_stat_statements 找到总资源消耗最大的语句,而不误称其能直接提供 P99;
  5. pg_stat_activitypg_lockspg_blocking_pids() 找出阻塞链;
  6. 在 PG18 中把活动后端与其 I/O、WAL 统计关联,并解释 pg_aios 状态;
  7. 设计包含预热、稳态、开放环到达率、冷/热缓存和重复试验的 pgbench 方案;
  8. 识别协调遗漏,解释为什么闭环压测可能低估过载时延迟;
  9. 为 API、后台任务和读流量设计独立的并发上限、连接池和超时预算;
  10. 根据会话状态需求选择 PgBouncer session/transaction/statement pooling;
  11. 判断协议级 prepared statement、SQL PREPARE、临时表、advisory lock、LISTEN/NOTIFY 在 transaction pooling 下的边界;
  12. 编译并运行一个有界并发 Go 压测器,输出 P50/P95/P99、错误分类和连接池统计;
  13. 按 Runbook 处理 CPU 高、I/O 饱和、P99 高、锁等待、连接风暴和 PgBouncer 排队等故障;
  14. 说明准入控制、连接复用和同步复制对 RPO、RTO、故障转移及 uncertain commit 的影响。

3. 核心术语

中文名称英文名称准确定义容易混淆的概念层次
服务级别目标SLO在明确窗口和工作负载下,对成功率、延迟、吞吐等指标的目标SLA 是对外承诺;SLI 是测量值业务/治理
每秒查询数QPS每秒执行的 SQL 语句或业务查询数;必须声明计数边界一次事务可含多条 SQL,不能等同 TPS应用/数据库
每秒事务数TPS每秒完成的事务数;应声明是否含回滚和自动提交pgbench 的“一次脚本”也是一个事务单位数据库/压测
分位延迟Percentile latency小于等于该值的请求比例,如 P99 覆盖 99% 请求平均值不能表达尾部;分位数不可直接相加端到端
尾延迟Tail latency延迟分布高分位区域,常受排队和少量慢路径支配“最慢一条”不是稳定统计量端到端
并发度Concurrency同时处于系统边界内的请求数连接数、线程数、goroutine 数不是同一概念全栈
排队时间Queueing time请求等待资源、连接或执行槽位的时间数据库执行时间通常不含上游队列全栈
Little 定律Little’s Law稳态下 L = λ × W:系统内平均请求数=平均到达率×平均停留时间不等于容量公式;非稳态、丢弃和边界不一致时会失真排队论
协调遗漏Coordinated omission负载发生器因等待慢响应而减少后续请求,漏记本应出现的等待延迟仅提高并发不一定消除;要按计划到达时间计时压测
预热Warm-up在正式采样前让连接、代码路径、缓存和统计进入代表状态预热不是删除冷缓存结果压测
稳态Steady state到达率、吞吐、队列和资源占用在可接受区间内近似稳定短暂峰值不满足稳态假设压测/容量
基线Baseline在可复现实验条件下的对照结果和配置快照单次“最好成绩”不是基线性能工程
准入控制Admission control在昂贵资源之前限制进入系统的工作量连接池只是资源池,不自动等同业务限流应用/平台
反压Backpressure下游饱和时把“无法及时处理”的信号传回上游无限排队会掩盖反压并放大尾延迟架构
负载丢弃Load shedding主动拒绝低优先级或已无望满足截止时间的请求与随机故障不同,应可分类和度量架构
熔断Circuit breaker下游持续失败时暂时阻止新调用,并通过探测恢复不能替代超时、重试预算和健康检查应用
舱壁Bulkhead为不同工作负载设置独立资源上限,限制故障扩散仅使用不同业务标签但共用同一池不算隔离架构
连接风暴Connection storm大量进程在启动、故障转移或重试时同时建连连接数高不一定是风暴,关键是突发速率和后端代价应用/数据库
池化模式Pooling modePgBouncer 把服务端连接绑定到客户端的生命周期边界session、transaction、statement 的兼容性不同代理
不确定提交Uncertain commit客户端在提交响应前断线,无法仅凭本次错误判断事务是否提交盲目重试可能产生重复副作用事务/HA

4. 整体心智模型

PostgreSQL 性能工程、可观测性、准入控制与 PgBouncer flow 1

4.1 数据流

请求先经过限流和并发闸门,再等待应用工作槽位、pgxpool 连接、PgBouncer 服务端连接,最终进入 PostgreSQL backend。读路径可能命中 shared_buffers,也可能从 OS page cache 或存储读取;写路径还会生成脏页和 WAL,提交可能等待本地 WAL 刷盘及同步副本确认。

4.2 控制流

控制面决定:允许多少请求进入、每一层最多等多久、哪些工作负载共享资源、故障时是否熔断、重试是否有预算。正确顺序通常是:先检查剩余截止时间,再准入,再获取连接,再执行 SQL。在池中无限等待后才设置 statement_timeout,无法保护端到端 SLO。

4.3 状态变化

一次请求可能经历:scheduled → application_queue → admitted → pool_wait → pgbouncer_wait → active/running → database_wait → completed/failed/rejected。每个状态都必须有时间戳或计数,否则只能看到“数据库似乎不慢,但用户很慢”。

4.4 故障路径

过载会形成正反馈:延迟增加 → 请求驻留更久 → 并发增加 → 连接池耗尽 → 重试增加 → 数据库争用更强。准入控制的目标不是让所有请求都成功,而是在容量不足时快速、可解释地拒绝一部分请求,保护仍可满足 SLO 的请求和恢复通道

4.5 Little’s Law 的正确用法

在边界一致、长期稳定、没有大量未计入丢弃的条件下:

L = λ × W

示例:稳定完成 2,000 req/s,端到端平均停留 40 ms:
L ≈ 2,000 × 0.040 = 80 个在途请求

若监控显示平均在途请求约 800,而测得吞吐与平均延迟只推导出 80,通常说明边界不一致:可能漏掉了上游队列、重试、超时后的“僵尸工作”,或吞吐采样窗口不同。Little’s Law 不直接给出 P99,也不适用于明显爬坡、故障转移、突发流量等非稳态窗口。

5. 使用方式

5.1 先写 SLO 与工作负载说明书

任何调优前先填下表;缺失的项就是实验风险。

维度必须记录的内容常见错误
SLO成功定义、统计窗口、P50/P95/P99、错误预算、超时边界只写“平均小于 50ms”
查询混合各模板权重、读写比、事务内语句数、返回行数只测一条主键查询
数据规模表/索引大小、活跃集、增长率、租户数用 1 万行推断 10 亿行
数据分布均匀/Zipf/时间相关、热点键、NULL 和极端值随机均匀数据掩盖热点
并发与到达峰值到达率、突发持续时间、开放环/闭环把客户端数当业务 QPS
缓存状态冷、温、热;连接和计划缓存状态只保留最佳热缓存结果
事务隔离级别、锁顺序、持锁时间、重试和幂等压测脚本与生产事务不同
HA是否同步复制、压测副本、故障转移期间目标单机结果直接当生产容量
环境PG/OS/Go/PgBouncer 版本、CPU、内存、存储、网络、参数不保存配置快照

SLO 示例应写成可判定形式,而不是通用参数建议:

在“API 读 70%、写 25%、批处理 5%,热点分布与生产一致”的 5 分钟稳态窗口内:
- 成功率 ≥ 业务目标;
- 端到端 P99 ≤ 业务目标;
- 数据库执行 P99、连接池等待 P99、PgBouncer 等待 P99 分别设预算;
- 复制延迟、WAL 归档和错误率不得越过恢复目标;
- 超载时低优先级请求按预定义错误码快速丢弃。

5.2 五层观测边界

核心指标关键问题
应用到达率、完成率、端到端直方图、排队、超时、重试、丢弃慢发生在进入数据库前吗?
pgxpoolAcquire 次数/总时长、空池等待、取消获取、当前连接是连接不足,还是 DB 处理能力不足?
PgBouncercl_waitingsv_activesv_idlemaxwait、xact/query/wait time客户端是否在代理排队?池按多少 user×database 展开?
PostgreSQLSQL 聚合、活动/等待、锁、I/O、WAL、Checkpoint、Vacuum、临时文件哪类资源和哪条语句贡献最大?
OS/基础设施CPU、run queue、上下文切换、内存/Swap、页缓存、IOPS/吞吐/延迟/QD、网络数据库看到的等待是否有主机证据?

5.3 pg_stat_statements:按总影响排序,而不是只看最慢单次

启用扩展需要把 pg_stat_statements 放入 shared_preload_libraries 并重启,然后创建扩展。查询文本和统计可能暴露敏感信息,应只授予受控的观测角色。

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

SELECT
    queryid,
    calls,
    round(total_exec_time::numeric, 2) AS total_exec_ms,
    round((total_exec_time / NULLIF(calls, 0))::numeric, 3) AS mean_exec_ms,
    rows,
    shared_blks_hit,
    shared_blks_read,
    temp_blks_written,
    wal_records,
    pg_size_pretty(wal_bytes::bigint) AS wal_bytes,
    left(query, 180) AS query_sample
FROM pg_stat_statements
WHERE calls > 0
ORDER BY total_exec_time DESC
LIMIT 20;

pg_stat_statements 是累计聚合,不保存每次执行的完整分布,因此不能直接给出 P95/P99。P99 应来自应用/Tracing 的直方图、结构化慢日志或压测逐请求记录。统计值还可能因 reset、查询规范化、计划变化和采样窗口而混合,生产分析应使用两个快照的增量。

查看缓存与 I/O 倾向时:

SELECT
    queryid,
    calls,
    shared_blks_read,
    shared_blks_hit,
    round(
      100.0 * shared_blks_hit /
      NULLIF(shared_blks_hit + shared_blks_read, 0), 2
    ) AS pg_buffer_hit_pct,
    temp_blks_read,
    temp_blks_written,
    blk_read_time,
    blk_write_time,
    left(query, 160) AS query_sample
FROM pg_stat_statements
ORDER BY shared_blks_read DESC
LIMIT 20;

这里的命中率只描述 PostgreSQL buffer cache,不区分操作系统页缓存命中;shared_blks_read 也不等于物理磁盘读。必须与 pg_stat_io 和 OS 指标交叉验证。

5.4 活动、等待和阻塞链

SELECT
    pid,
    usename,
    application_name,
    state,
    wait_event_type,
    wait_event,
    now() - xact_start AS xact_age,
    now() - query_start AS query_age,
    pg_blocking_pids(pid) AS blocking_pids,
    left(query, 180) AS query_sample
FROM pg_stat_activity
WHERE backend_type = 'client backend'
  AND pid <> pg_backend_pid()
ORDER BY query_start NULLS LAST;

state='active'wait_event IS NOT NULL 表示后端仍在处理语句,但当前被某类等待阻塞;不能仅凭 active 认定正在消耗 CPU。

SELECT
    blocked.pid AS blocked_pid,
    blocked.application_name AS blocked_app,
    blocker.pid AS blocker_pid,
    blocker.application_name AS blocker_app,
    blocked.wait_event_type,
    blocked.wait_event,
    now() - blocked.query_start AS blocked_for,
    now() - blocker.xact_start AS blocker_xact_age,
    left(blocked.query, 120) AS blocked_query,
    left(blocker.query, 120) AS blocker_query
FROM pg_stat_activity AS blocked
CROSS JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS p(blocker_pid)
JOIN pg_stat_activity AS blocker ON blocker.pid = p.blocker_pid
ORDER BY blocked_for DESC;

若需查看锁对象:

SELECT
    l.pid,
    l.locktype,
    l.mode,
    l.granted,
    l.relation::regclass AS relation,
    l.page,
    l.tuple,
    l.transactionid,
    l.virtualxid,
    l.waitstart
FROM pg_locks AS l
WHERE NOT l.granted
ORDER BY l.waitstart NULLS LAST;

5.5 I/O、WAL、Checkpoint 与 PG18 AIO

累计统计必须做同一 reset 周期内的增量,不要把启动以来的总数直接除以最近 5 分钟。

SELECT
    backend_type,
    object,
    context,
    reads,
    pg_size_pretty(read_bytes) AS read_bytes,
    read_time,
    writes,
    pg_size_pretty(write_bytes) AS write_bytes,
    write_time,
    writebacks,
    extends,
    hits,
    evictions,
    reuses,
    fsyncs
FROM pg_stat_io
ORDER BY read_bytes + write_bytes DESC;

read_time/write_time 依赖 track_io_timing;WAL I/O 计时依赖 track_wal_io_timing。两者可能带来时钟调用开销,应在目标主机用 pg_test_timing 评估,而不是无条件开启。

[PG18] 关联活动后端与其 I/O/WAL:

SELECT a.pid, a.application_name, a.wait_event_type, a.wait_event, io.*
FROM pg_stat_activity AS a
CROSS JOIN LATERAL pg_stat_get_backend_io(a.pid) AS io
WHERE a.backend_type = 'client backend';

SELECT a.pid, a.application_name, wal.*
FROM pg_stat_activity AS a
CROSS JOIN LATERAL pg_stat_get_backend_wal(a.pid) AS wal
WHERE a.backend_type = 'client backend';

[PG18] 查看正在使用的异步 I/O handle:

SELECT
    state,
    operation,
    f_sync,
    count(*) AS handles,
    pg_size_pretty(sum(length)::bigint) AS bytes
FROM pg_aios
GROUP BY state, operation, f_sync
ORDER BY state, operation;

pg_aios 的状态包括已交付、已定义、已暂存、已提交以及完成后的不同归属状态。它是瞬时视图:空并不证明 AIO 未生效,可能只是采样时没有在途操作。访问该视图需要超级用户或相应统计权限。

SELECT * FROM pg_stat_wal;
SELECT * FROM pg_stat_checkpointer;

SELECT
    datname,
    numbackends,
    xact_commit,
    xact_rollback,
    blks_read,
    blks_hit,
    temp_files,
    pg_size_pretty(temp_bytes) AS temp_bytes,
    deadlocks,
    blk_read_time,
    blk_write_time,
    sessions,
    sessions_abandoned,
    sessions_fatal,
    sessions_killed
FROM pg_stat_database
WHERE datname IS NOT NULL;

诊断思路:

  • wal_buffers_full 增速高:检查写入突发、full-page image、WAL buffer 与提交模式,不要只调大参数;
  • requested checkpoint 增速异常:检查 max_wal_size、写入峰值、手工 checkpoint 和磁盘能力;
  • checkpointer 的 write_time/sync_time 与 P99 同步尖峰:进一步对齐 OS 写延迟和队列深度;
  • client backend 出现大量写回或 fsync:检查 checkpointer 是否跟不上、shared_buffers 是否失衡以及存储写回行为;
  • 临时文件/字节增速高:按 SQL 找排序、Hash、Materialize 溢出,注意 work_mem 会按节点、并行 worker 和并发查询倍增。

5.6 表、索引、Vacuum 与进度

下列 last_seq_scanlast_idx_scan 字段为 [PG16+];PG14—15 执行时删除这两列。

SELECT
    relid::regclass AS table_name,
    seq_scan,
    last_seq_scan,
    seq_tup_read,
    idx_scan,
    last_idx_scan,
    n_live_tup,
    n_dead_tup,
    n_mod_since_analyze,
    n_tup_hot_upd,
    last_autovacuum,
    last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 30;

SELECT
    indexrelid::regclass AS index_name,
    relid::regclass AS table_name,
    idx_scan,
    last_idx_scan,
    idx_tup_read,
    idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC, pg_relation_size(indexrelid) DESC;

idx_scan 不等于可立即删除:索引可能用于约束、罕见但关键的请求、故障恢复或月末任务。先核对时间窗口、计划、依赖和回滚方案。

SELECT * FROM pg_stat_progress_vacuum;
SELECT * FROM pg_stat_progress_create_index;
SELECT * FROM pg_stat_progress_analyze;
SELECT * FROM pg_stat_progress_copy;
SELECT * FROM pg_stat_progress_cluster;
SELECT * FROM pg_stat_progress_basebackup;

不同版本拥有的 pg_stat_progress_* 视图略有差异,部署前用系统目录探测。PG18 还提供累计 vacuum/analyze 时间,可用于识别长期维护成本。

5.7 日志与 auto_explain

以下仅是“延迟 SLO 为数百毫秒、日志系统有容量”的示例,不是通用值:

shared_preload_libraries = 'pg_stat_statements,auto_explain'
compute_query_id = 'auto'
pg_stat_statements.track = 'all'

log_min_duration_statement = '500ms'
log_lock_waits = on
log_lock_failures = on              # [PG18] 当前主要记录 NOWAIT 锁失败
track_io_timing = on                # 先评估时钟开销
track_wal_io_timing = on

auto_explain.log_min_duration = '500ms'
auto_explain.sample_rate = 0.01
auto_explain.log_analyze = on
auto_explain.log_timing = off       # 保留行数,降低逐节点计时开销
auto_explain.log_buffers = on
auto_explain.log_wal = on
auto_explain.log_settings = on
auto_explain.log_format = 'json'

auto_explain.log_analyze=on 会对所有被执行的语句收集执行统计,即使最终没有达到日志阈值;在高 QPS 环境必须低比例灰度、测量开销并设撤回开关。日志可能包含参数或业务文本,需配合脱敏、权限、保留周期和磁盘配额。

5.8 角色级超时预算

超时应从端到端截止时间倒推。下面是示意,不是默认建议:

ALTER ROLE api_user SET statement_timeout = '700ms';
ALTER ROLE api_user SET lock_timeout = '150ms';
ALTER ROLE api_user SET idle_in_transaction_session_timeout = '15s';

ALTER ROLE batch_user SET statement_timeout = '20min';
ALTER ROLE batch_user SET idle_in_transaction_session_timeout = '60s';

应用还要设置:请求总截止时间、准入队列超时、连接获取/SQL 上下文超时。通常应满足:

上游总截止时间
> 应用排队预算 + 连接池预算 + PgBouncer 排队预算
  + SQL/锁预算 + 序列化/网络预算 + 返回余量

statement_timeout 从服务器收到命令开始计时,无法覆盖它之前的应用和 PgBouncer 等待;lock_timeout 只在等待锁时生效,且不应大于同一会话的 statement_timeout

5.9 操作系统证据

Linux 上可按需使用:

pidstat -u -w -p "$(head -1 "$PGDATA/postmaster.pid")" 1
vmstat 1
iostat -xz 1
sar -n DEV,TCP,ETCP 1
ss -s
cat /proc/pressure/cpu
cat /proc/pressure/io
cat /proc/pressure/memory

重点不是单个“红线”,而是相关性:

  • CPU:user/system/iowait、run queue、上下文切换;高连接数可增加调度成本;
  • 内存:RSS、page cache、Swap in/out、内存 PSI;数据库进程 RSS 相加不能简单代表独占物理内存;
  • 存储:IOPS、吞吐、读写延迟、队列深度、设备并行度;NVMe 上 %util=100 不能脱离延迟和 QD 单独解释;
  • 网络:重传、丢包、RTT、连接建立速率、TLS 开销;
  • 容器:同时检查 cgroup CPU throttling、memory limit、I/O 限额和宿主机压力。

5.10 pgbench:基线、开放环与协调遗漏

只在独立测试库初始化;pgbench -i 会删除同名标准表。

createdb bench
pgbench -i -s 100 bench

# 预热,不计入正式结果
pgbench -c 32 -j 8 -T 120 -P 10 bench

# 开放环目标到达率;从计划开始时间计延迟,能暴露 schedule lag
pgbench -c 64 -j 8 -T 300 -R 3000 -L 500 -P 10 -l bench

-R/--rate 按计划时间线发起事务;报告延迟包含 schedule lag。-L/--latency-limit 会把超时事务记为 late,并在已无望满足目标时跳过尚未发送的事务。应阶梯增加 -R,画出“到达率—完成率—P95/P99—schedule lag—错误—资源”曲线,而不是只记录最高 TPS。

正式报告至少包含:硬件/虚拟化、PG 和 OS 参数、数据规模与分布、客户端位置、连接协议、预热时间、稳态窗口、重复次数、P50/P95/P99、失败/重试/跳过、CPU/I/O/WAL/锁/复制指标。不要伪造固定延迟结果。

5.11 Go Benchmark 的适用边界

Go 微基准适合比较序列化、SQL 构造、扫描和业务函数的 CPU/分配成本:

func BenchmarkDecodeRow(b *testing.B) {
    payload := samplePayload()
    b.ReportAllocs()
    for b.Loop() {
        _ = decode(payload)
    }
}
go test -bench=. -benchmem -count=5 ./...

微基准不能替代数据库端到端容量测试:它通常没有真实网络、锁、池等待、缓存状态、WAL、Checkpoint 和副本确认。结果应配合稳定 CPU 频率、隔离噪声和多次运行;跨版本比较可使用统计工具,但必须保存原始输出。

5.12 PgBouncer 配置与观测

池化模式

模式服务端连接释放时点兼容性典型用途
session客户端断开时支持全部 PostgreSQL 会话能力强依赖会话状态、长会话管理工具
transaction每个事务结束时不保证跨事务会话状态无状态 OLTP,最常用的复用模式
statement每条语句结束时禁止多语句事务,限制最强极少数单语句工作负载;需严格验证

transaction pooling 的关键边界

功能结论生产解释
普通事务支持事务期间固定到一个服务端连接
SET/RESET 会话状态不保证下一事务可能换 backend;用启动参数、角色默认值或事务内 SET LOCAL
SQL PREPARE/EXECUTE/DEALLOCATE不支持跨事务保证PgBouncer 不解析这套 SQL 级状态
协议级 named prepared statement当前版本可支持max_prepared_statements 必须非零;DDL 后可能需 RECONNECT 清旧计划
临时表 ON COMMIT DROP可在事务内使用提交即删除,不遗留跨事务状态
临时表保留行/定义不保证表属于某个随机服务端会话
session advisory lock不支持锁可能遗留在服务端连接并影响别的客户端
transaction advisory lock支持事务边界内用法使用 pg_advisory_xact_lock,提交自动释放
LISTEN不支持监听依赖持久会话
NOTIFY可发送接收方仍须拥有稳定 LISTEN 会话
WITH HOLD cursor不支持跨事务保证cursor 是会话状态

下面配置仅对应一个示例容量背景:单个中心 PgBouncer、数据库为应用预留约 120 个 backend、主要是短事务 OLTP。数字必须按 user × database × PgBouncer 实例 展开后重新预算。

[databases]
app = host=db.service.internal port=5432 dbname=app

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
pool_mode = transaction

max_client_conn = 2000
default_pool_size = 80
reserve_pool_size = 10
reserve_pool_timeout = 2
query_wait_timeout = 2

# 当前 PgBouncer 可跟踪协议级 named prepared statements
max_prepared_statements = 200

server_connect_timeout = 5
server_login_retry = 2

max_client_conn 是客户端连接上限,不是数据库 backend 上限;它还受文件描述符约束。default_pool_size 是每个 user/database 池的服务端连接上限,多个用户和数据库会相乘。reserve pool 是短时救急,不应成为常态容量。

管理控制台常用命令:

SHOW POOLS;
SHOW STATS;
SHOW DATABASES;
SHOW CLIENTS;
SHOW SERVERS;
SHOW CONFIG;

重点字段:cl_waitingsv_activesv_idlemaxwait/maxwait_us、平均/累计 xact/query/wait time、连接数和每库限制。maxwait 持续增长表示最老客户端越等越久:可能是数据库变慢、池太小、事务太长或流量超过容量,不能只靠增大池解决。

6. 底层原理

6.1 一次请求的时间线

t0  请求按计划到达
 │  应用队列:等待 worker / token / semaphore
 t1 准入成功
 │  pgxpool Acquire:等待可复用连接或新建连接
 t2 获取客户端连接
 │  PgBouncer:等待 user×database 池中的 server connection
 t3 绑定 PostgreSQL backend
 │  Parse/Bind/Execute,进入 executor
 │  可能等待 Lock、LWLock、BufferPin、IPC、I/O、WAL、同步复制
 t4 SQL 完成
 │  COMMIT 可能写/刷 WAL 并等待副本
 t5 事务结束,transaction pooling 释放 server connection
 t6 pgxpool 归还连接
 t7 响应序列化和网络返回

端到端延迟近似为各阶段耗时之和,但P99 不能用各阶段 P99 相加,因为高延迟事件是否发生在同一请求上取决于相关性。应为单个请求传播 trace/span 或至少关联 request ID、backend PID、query ID 和时间戳。

6.2 为什么接近饱和时 P99 会突然恶化

资源利用率低时,请求大多直接获得 CPU、连接和 I/O 槽位;接近饱和后,轻微的服务时间抖动就会积累队列。队列增长使请求驻留时间增加,Little’s Law 又推高在途并发,进而导致更多上下文切换、缓存污染、锁重叠和超时重试。吞吐可能只增加少量,P99 却成倍上升。这是应在“拐点之前”保留容量余量的原因。

6.3 缓存路径与放大

读取可经历:索引访问 → heap page → shared_buffers 命中;未命中则向操作系统发起读,OS page cache 仍可能命中;最终才是存储。pg_stat_database.blks_hit 只统计前者。随机读、顺序读、预读合并和 PG18 AIO 会改变 IOPS、吞吐和等待形态。

写入可同时产生:新 tuple、索引 tuple、WAL record、full-page image、脏 buffer、后台写回、Checkpoint 同步、复制传输。一次业务写入的读放大、写放大、WAL 放大和空间放大必须一起衡量。

6.4 PG18 异步 I/O 状态机

PG18 可让后端把多个读取请求排队而不是逐个同步等待,顺序扫描、bitmap heap scan、VACUUM 等路径可受益。控制项包括 io_methodio_combine_limitio_max_combine_limiteffective_io_concurrencymaintenance_io_concurrency。是否改善取决于存储并行能力、数据访问模式、缓存状态和 CPU;不能把官方特定场景增益当作本机承诺。

一个 AIO handle 大致经历:

HANDED_OUT → DEFINED → STAGED → SUBMITTED

             COMPLETED_IO / COMPLETED_SHARED / COMPLETED_LOCAL

pg_aios 显示当前在用 handle,不是累计性能报表。累计字节、次数和时间仍要看 pg_stat_io,单后端归因看 PG18 的 backend I/O/WAL 函数。

6.5 PgBouncer 的复用状态机

client connected
  ├─ session mode: client ───────── server,直到断开
  ├─ transaction mode:
  │      BEGIN/首条隐式事务 → 绑定 server → COMMIT/ROLLBACK → 归还
  └─ statement mode:
         单条 statement → 绑定 server → 完成即归还

transaction pooling 增加复用率的代价是切断“客户端会话=固定 PostgreSQL backend”的假设。PgBouncer 当前可以跟踪协议级 named prepared statements:它重写名称并确保当前分配的服务端连接已准备该语句;但 SQL 文本中的 PREPARE 仍是普通会话状态。DDL 改变结果类型后,旧服务端连接可能保留不兼容计划,需要受控执行 RECONNECT

7. 内部数据结构和状态

对象/状态所在层本章相关行为观测与陷阱
统计累计器PostgreSQL 共享统计设施backend 在本地累计后周期性刷新;同一事务内统计快照可能缓存需要增量和一致窗口;必要时 pg_stat_clear_snapshot()
PGPROC/活动槽backend保存 PID、状态、等待事件、事务信息pg_stat_activity 是采样,不是完整历史
Lock/PROCLOCK锁管理器描述锁对象、持有者与等待者行锁常表现为 transaction ID 等待,不一定直接显示 tuple 锁
Buffer descriptorshared_buffers记录 page、脏状态、引用、pin 等buffer hit 不等于无物理 I/O 压力
OS page cache内核缓存数据库文件块并参与写回PostgreSQL 统计无法直接区分其命中
WAL buffer/insert statePostgreSQL并发生成 WAL、等待空间/刷盘pg_stat_walpg_stat_io、PG18 backend WAL 统计
Checkpointer 状态后台进程平滑写脏页并在 checkpoint 同步requested checkpoint 和 sync 尖峰需对齐 P99
AIO handle[PG18]保存操作、长度、状态、同步标志pg_aios 是瞬时在途状态
pgxpool connection stateGo 进程total/idle/acquired,Acquire 等待与取消goroutine 可远多于连接;Acquire 队列必须有截止时间
PgBouncer pool代理按 database/user 管理 client、server、等待队列default_pool_size 会按池实例展开
Memory Contextbackend计划、执行、排序/Hash 等分配的生命周期容器work_mem 是操作节点上限,不是会话总上限
Tuple/Page/Index Tuple存储读请求不改 tuple;写请求可能创建新版本、索引项和 WAL监控本身不改变 MVCC,但 EXPLAIN ANALYZE 的 DML 会真实执行

8. 场景和选型决策

业务场景推荐方案不推荐方案性能代价并发/一致性HA/运维影响
短事务无状态 API应用有界并发 + pgxpool + PgBouncer transaction pooling每请求新建连接;无限 goroutine多一跳代理,但显著降低 backend 数会话状态必须移除;事务内一致性不变故障转移时需强制旧连接重连
依赖 LISTEN、会话 advisory lock、跨事务临时表专用 session pool 或直连;与 API 隔离混入 transaction pool服务端连接长期占用保留会话语义容量更小,必须限制连接数
单语句、绝对无事务工作负载经严格验证后可考虑 statement pooling默认选择 statement mode复用最高但兼容风险最大不支持多语句事务排障复杂,通常收益不抵风险
在线 API 与批处理并存独立角色、队列、pgxpool、PgBouncer pool;不同 timeout共用一个大池和同一并发上限牺牲部分池利用率换隔离防止批处理占满 API 资源故障时可优先丢弃后台任务
读写分离写池指向 primary;读池按一致性需求选择 replica任意读都发副本多池与路由复杂度副本读可能陈旧;read-your-write 需路由/LSN 等策略故障转移与副本延迟需纳入路由
热点更新分片热点、缩短事务、顺序锁、队列化/聚合写单纯增加连接可能增加应用复杂度降低锁重叠;需保证幂等可减少 WAL/复制尖峰
大型分析查询专用副本/队列、资源和 timeout 隔离在 OLTP primary 高峰直接运行可能牺牲实时性副本有延迟;长快照影响清理防止影响 primary 与恢复链路
突发流量令牌桶/漏桶 + semaphore + 短有界队列 + load shedding无限内存队列峰值请求会被拒绝保护已准入请求的 P99提高故障恢复可控性
高频协议级 prepared statements当前 PgBouncer + max_prepared_statements>0;迁移后清理旧连接transaction pool 中使用 SQL PREPARE代理有跟踪内存/CPU成本当前 server 上自动准备DDL 后需 RECONNECT 计划
极低延迟、连接数已可控直接 pgxpool 或近端 PgBouncer,实测比较为“架构统一”强行多跳少一跳但 backend 占用更高会话能力更完整应用实例扩容时易连接风暴

9. 高性能分析

9.1 建立可重复的性能闭环

性能工程应按以下顺序循环:

SLO/容量目标
  → 工作负载与数据分布
  → 分层指标和基线
  → 假设(只能解释一个主要瓶颈)
  → 受控实验
  → 增量与分位结果
  → 正确性、HA、成本回归
  → 灰度、观察、回滚或固化

每次变更只回答三个问题:瓶颈是否移动、SLO 是否改善、代价是否可接受。吞吐增加但错误率、复制延迟或恢复时间恶化,不是完整成功。

9.2 CPU、run queue 与上下文切换

CPU 高时先判断“有用计算”还是调度/内核开销:

  • PostgreSQL 活动 backend 是否大幅超过有效 CPU 并行度;
  • run queue、上下文切换和系统态是否与连接数同时上升;
  • 是少数大查询、海量小查询、表达式/JIT、排序/Hash,还是锁自旋和网络协议开销;
  • pg_stat_statements 的 calls、总执行时间和总计划时间是否由某类语句主导;
  • 容器是否被 CPU quota throttling。

降低连接数有时会在 TPS 基本不变的同时显著改善 P99,因为减少了调度和缓存争用。相反,如果 CPU 尚有余量、队列来自 I/O 或锁,缩池只会把等待移到应用层而不提高容量;移动队列仍有价值,但不能误称消除了瓶颈。

9.3 内存、shared_buffers 与 OS page cache

PostgreSQL 同时依赖 shared_buffers 和操作系统页缓存。shared_buffers 过小可能增加 buffer 替换与重复读取,过大则会挤压 OS cache、后台写回和其他进程内存;不存在跨硬件通用的百分比。

内存预算至少包含:

常驻:shared_buffers + 后台进程 + 扩展 + PgBouncer/应用
按连接:backend 基础内存、catalog/plan、会话状态
按操作:work_mem × 同时活动节点 × 并行 worker × 并发查询
维护:maintenance_work_mem × 同时维护任务
OS:page cache、内核、文件系统与监控代理
安全余量:突发、故障转移、统计误差

work_mem=64MB 并不表示每连接最多 64MB;一个复杂计划可有多个 Sort/Hash,且并行 worker 各自分配。判断是否应提高时,应先用 EXPLAIN、临时文件增量和并发模型计算上界。

9.4 随机/顺序 I/O、PG18 AIO 与存储

  • 随机点查更关注单次延迟、缓存命中和索引局部性;
  • 大扫描更关注吞吐、预读、合并和并行能力;
  • PG18 AIO 能让部分读取路径保持多个请求在途,但收益受设备队列能力和缓存状态限制;
  • 存储延迟上升时,先区分读取、WAL 顺序写、data file 写回、fsync 和 checkpoint 同步;
  • 云盘还要检查 IOPS/吞吐额度、burst credits、网络存储限速与多租户抖动。

验证 AIO 或 I/O 参数必须同时记录:查询计划、读字节、I/O 次数、I/O 时间、CPU、P95/P99、缓存状态和存储队列深度。仅看到 TPS 上升无法证明是 AIO 导致。

9.5 网络与协议

高 QPS 小查询可能不是 SQL 本身慢,而是网络往返和协议开销占比高。可选手段包括:把多步逻辑放进一个事务/语句、使用 pgx.Batch 或 COPY、减少无用返回列、避免 N+1、让 PgBouncer 靠近应用或数据库。批处理会增加单次失败范围、内存和事务时长,必须限制 batch 大小并正确关闭 BatchResults

9.6 索引、WAL、Checkpoint、Vacuum、临时文件

现象可能根因验证证据不能直接采取的动作
查询读块多缺索引、选择性差、统计误差、缓存冷EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY)看到 Seq Scan 就建索引
写延迟/WAL 激增索引过多、FPI、批量写、热点更新、逻辑复制pg_stat_wal、backend WAL、WAL 归档、计划 WAL只调大 WAL buffer
周期性 P99 尖峰checkpoint、存储写回、批任务、自动维护checkpointer 增量、OS 延迟/QD、时间对齐关闭 checkpoint/autovacuum
dead tuple 增长长事务、autovacuum 不足、热点表阈值不合适pg_stat_user_tables、活动事务、vacuum progress生产直接 VACUUM FULL
临时文件激增Sort/Hash 溢出、并行放大、低选择性中间集temp_bytes、日志、执行计划全局大幅提高 work_mem

执行 DML 的 EXPLAIN ANALYZE 会真实修改数据;生产上应使用回滚事务且确认无外部副作用,或在可恢复副本/影子环境执行:

BEGIN;
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY)
UPDATE perf_account
SET balance = balance + $1
WHERE id = $2;
ROLLBACK;

即使回滚,锁、WAL、触发器、序列和外部扩展副作用仍可能发生;高风险语句不要在线执行。

9.7 读、写与空间放大

容量报告至少给出四种放大:

  • 读放大:为返回一行业务结果读取多少 index/heap block;
  • 写放大:一次逻辑写导致多少 heap、索引、写回与 fsync;
  • WAL 放大:逻辑 payload 与 wal_bytes 的比例;
  • 空间放大:逻辑数据与表、索引、dead tuple、TOAST、临时空间的比例。

这些指标比“数据库大小”和“TPS”更能解释增长趋势。索引可能降低读放大,却提高写/WAL/空间放大;分区可能降低维护范围,却增加规划和对象管理成本。

9.8 容量规划

对每类工作负载做阶梯压测,寻找以下三点:

  1. 线性区:提高到达率,吞吐近似同步增加,P99 和队列稳定;
  2. 拐点:吞吐增幅变小,P99、schedule lag 或等待开始加速;
  3. 崩溃区:完成率下降、错误/超时/复制延迟上升,恢复需要较长时间。

生产容量应落在拐点之前,并扣除故障场景余量:失去一个副本、单可用区带宽下降、Checkpoint、Vacuum、部署重连、数据增长和热点偏斜。不要以“压到 CPU 100% 的最高 TPS”作为可售容量。

一个连接预算框架是:

数据库应用 backend 预算
= min(CPU 可接受活动并发,
      存储可承受并发,
      每连接/每操作内存预算,
      max_connections 扣除运维/复制/紧急连接后的余额)

所有应用实例 pgxpool.MaxConns 的总和
以及所有 PgBouncer user×database server pool 的总和
都必须服从该预算,不能各自独立拍脑袋。

10. 高并发分析

10.1 先区分五种“并发”

10,000 goroutines
  ≠ 10,000 个 pgxpool 连接
  ≠ 10,000 个 PgBouncer client
  ≠ 10,000 个 PostgreSQL backend
  ≠ 10,000 条正在 CPU 上执行的 SQL

例如 2,000 个请求可在应用排队,64 个持有 pgxpool 连接,40 个绑定 PgBouncer server connection,其中 12 个在 CPU 上运行,其余等待锁/I/O。TPS 是单位时间完成量,不是同时执行量。监控面板必须分别展示这些层次。

10.2 推荐准入顺序

1. 校验请求和幂等键
2. 检查总 deadline 剩余时间
3. 限流:控制到达速率
4. 熔断:下游明显不可用时快速失败
5. 舱壁:按 API/后台/租户/读写选择资源组
6. semaphore:限制在途昂贵操作
7. 短有界队列:等待预算耗尽则 load shed
8. 获取 pgxpool 连接并执行参数化 SQL
9. 记录排队、Acquire、SQL 和总延迟

把 semaphore 放在获取连接之后,会让连接被“已准入但尚未执行”的请求占住;把超时放在 SQL 执行后,则无法取消池等待。

10.3 有界并发、连接池队列和反压

应用并发上限应依据数据库可接受的活动工作量,通常小于或等于池上限。pgxpool.MaxConns 是最后资源边界,不应承担全部准入职责。连接池队列持续增长时:

  • 如果数据库 CPU/I/O/锁已饱和,增大池会恶化争用;
  • 如果数据库有明显闲置、Acquire 等待来自池过小,才考虑小步增池;
  • 如果少数长事务占池,先缩短事务或隔离工作负载;
  • 如果连接创建慢,检查连接风暴、TLS/DNS、认证和 PgBouncer,而不是长期维持巨大 MinConns

10.4 限流、熔断、舱壁与负载丢弃

  • Rate limit 控制每秒进入量,适合保护容量和租户公平;
  • Concurrency limit 控制在途量,能适应服务时间变化;
  • Circuit breaker 针对连续基础设施失败,不能因业务校验错误而打开;恢复时应少量半开探测;
  • Bulkhead 为 API、写入、查询、批处理或租户设置独立池/闸门;
  • Load shedding 应优先丢弃低优先级、已过截止时间或可异步处理的请求,并返回可观察的明确错误。

PostgreSQL 没有通用的内置工作负载资源治理器。生产上常通过角色默认 timeout、独立应用队列、独立 pgxpool/PgBouncer pool、专用副本和必要的 cgroup 来实现优先级。不要依赖一个全局大池“自然公平”。

10.5 读写池与在线/后台池

api_write_pool  → primary,短事务,小队列,高优先级
api_read_pool   → primary 或满足一致性约束的 replica
background_pool → primary/专用 replica,低并发、长 timeout、可暂停
admin_pool      → 受控直连或专用入口,保留故障诊断容量

分池会降低连接的统计复用率,但能阻止大查询、迁移或批任务耗尽 API 连接。每个池必须独立计数、报警和容量预算。

10.6 热点、锁、死锁与重试风暴

热点行使服务时间随并发上升:锁等待越长,事务重叠越多,后续请求更容易超时。治理顺序通常是缩短持锁区、统一锁顺序、拆分热点、聚合/排队写入、使用 SKIP LOCKED 任务领取或重构业务不变量,而不是增加连接。

只对可重试 SQLSTATE(常见 4000140P01)做有界指数退避和抖动,并服从总 deadline、重试预算与幂等条件。连接中断后的 COMMIT 结果可能不确定,不能自动当作“未提交”重试;应通过业务幂等键、唯一约束和结果查询消歧。

10.7 防止连接风暴

应用部署、数据库重启和故障转移时:

  • 每实例 MaxConns 总和必须受全局预算约束;
  • 建连失败使用有上限的指数退避和 jitter;
  • MaxConnLifetimeJitter 避免连接同一时刻老化;
  • 谨慎设置 MinConns/MinIdleConns,避免所有实例同时预热;
  • readiness 不应因数据库短暂抖动触发无休止重启;
  • PgBouncer 也要限制建连速率和 server pool;
  • 保留 DBA/监控入口,不让业务连接吃光 max_connections

11. 高可用分析

11.1 性能治理与 RPO/RTO

决策对性能的影响对 RPO/RTO 的影响
异步复制primary 提交尾延迟较低故障时可能丢失尚未重放 WAL,RPO 非零
同步复制提交等待副本确认,P99 受网络/副本 I/O 影响可降低 RPO,但副本/网络故障可能影响可用性
更激进 load shedding保住数据库与复制链路通常缩短恢复时间,但业务拒绝率上升
大量重试短故障可能被掩盖可能形成重试风暴、扩大 RTO 和 uncertain commit 风险
过高连接池平时看似减少 Acquire 等待故障转移重连慢、旧连接多、主节点压力大
平滑 checkpoint/vacuum减少抖动和 WAL/膨胀风险提高崩溃恢复和备份链路可预测性

11.2 故障转移时的连接语义

PgBouncer 不是拓扑管理器,也不负责选主、Fencing 或判断旧 primary 是否仍可写。HA 层必须完成:

  1. 隔离旧 primary,防止 split-brain;
  2. 提升新 primary 并更新可靠入口;
  3. 让 PgBouncer 停止向旧地址分配新 server connection;
  4. 受控 PAUSE/RECONNECT/重载,清理旧连接;
  5. 应用取消或耗尽旧请求,按退避策略重连;
  6. 对提交响应丢失的事务做幂等消歧;
  7. 验证写入、复制、归档、备份和读路由后再恢复全部流量。

DNS 或服务发现更新不等于所有已有连接立即切换;活跃连接可能继续指向旧节点直到断开/归还。故障演练必须观察 PgBouncer SHOW SERVERS、应用 pool 连接代际和旧主连接数,而不是只检查新主可连接。

11.3 读副本、陈旧读与回退

读池可以降低 primary 读压力,但会引入:复制延迟、回放冲突、陈旧读、故障时读流量回灌 primary。业务应分类:

  • 强一致/read-your-write:读 primary,或等待/验证目标 LSN 后读副本;
  • 可陈旧读:允许在明确 lag 阈值内读副本;
  • 分析读:专用副本,允许更长延迟但限制资源;
  • 副本失效:优先降级/丢弃非关键读,不能无界回灌 primary。

11.4 恢复验证

性能事故恢复不只看 P99 回落,还需确认:WAL 归档恢复、复制 lag 收敛、autovacuum 追上、临时文件和队列下降、PgBouncer 等待清零、旧连接消失、备份/PITR 链仍可用。否则只是从用户可见故障转为数据保护隐患。

12. 三维影响矩阵

技术/动作高性能高并发高可用
增加应用/数据库连接可能降低短期 Acquire 等待,也可能增加 CPU/缓存争用增加活动竞争和锁重叠故障转移重连与连接风暴更严重
PgBouncer transaction pooling降低建连和 backend 常驻成本大量 client 复用少量 server需要配合 HA 入口和强制旧连接重连
有界并发在饱和前稳定 P99把争用变为可控队列/拒绝防止故障扩大,通常缩短 RTO
独立 API/后台池可能损失一点复用率限制故障域和资源饥饿可优先暂停后台任务保核心链路
较短 timeout快速释放资源降低在途量,但过短会制造取消/重试保护恢复;需处理 uncertain commit
提高 work_mem可能减少单查询临时 I/O并发下可能内存爆炸/SwapOOM 会扩大故障和恢复时间
增加索引降低部分读取延迟写时增加锁/WAL/缓存压力增大备份、复制和恢复数据量
PG18 AIO特定扫描/维护可提高 I/O 并行更多在途 I/O 需存储承受存储过载会影响 WAL/复制/恢复
同步复制增加提交尾延迟并发提交可能积累等待降低 RPO,网络/副本故障影响可用性
高采样 auto_explain提供计划证据但有观测开销高 QPS 下会放大 CPU/日志争用日志盘打满可能影响服务和恢复
负载丢弃保住剩余请求的尾延迟明确拒绝代替无限排队保护 primary、复制和运维入口
大 reserve pool短峰值可能更快容易掩盖持续过载故障时可把数据库推过安全边界

13. 可复现实验

所有实验都应在隔离测试库执行。记录 PostgreSQL/PgBouncer/Go 版本、参数、硬件、数据规模、开始结束时间和统计 reset 时间。不要在共享生产主机清 OS cache,也不要为“制造冷缓存”执行 echo 3 > /proc/sys/vm/drop_caches

13.1 实验一:行更新阻塞如何进入 P99

目标:复现锁等待、定位阻塞者,区分执行计划快与端到端慢。

环境:PostgreSQL 14—18;无需扩展;测试库 lab18

Schema 与数据

CREATE TABLE perf_account (
    id bigint PRIMARY KEY,
    balance bigint NOT NULL,
    pad text NOT NULL DEFAULT repeat('x', 100)
);

INSERT INTO perf_account (id, balance)
SELECT i, 1000
FROM generate_series(1, 200000) AS g(i);

ANALYZE perf_account;

Session A:制造阻塞者

BEGIN;
SELECT pg_backend_pid() AS session_a_pid;

UPDATE perf_account
SET balance = balance + 1
WHERE id = 1;

-- 保持事务,不提交。此时持有行版本相关锁。

Session B:成为等待者

SET lock_timeout = '3s';
BEGIN;

UPDATE perf_account
SET balance = balance + 10
WHERE id = 1;

-- 预期:等待 Session A;约 3 秒后失败。
-- 典型错误:canceling statement due to lock timeout
-- SQLSTATE:55P03(lock_not_available)

ROLLBACK;  -- 失败后事务处于 aborted 状态,必须回滚。

Session C:在 B 等待的 3 秒内采样

SELECT
    pid,
    state,
    wait_event_type,
    wait_event,
    pg_blocking_pids(pid) AS blockers,
    now() - query_start AS query_age,
    left(query, 120) AS query_sample
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY query_start;

SELECT
    blocked.pid AS blocked_pid,
    blocker.pid AS blocker_pid,
    now() - blocked.query_start AS blocked_for,
    left(blocked.query, 100) AS blocked_query,
    left(blocker.query, 100) AS blocker_query
FROM pg_stat_activity AS blocked
CROSS JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS p(blocker_pid)
JOIN pg_stat_activity AS blocker ON blocker.pid = p.blocker_pid;

执行计划验证:

EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY)
SELECT balance FROM perf_account WHERE id = 1;

计划应是主键索引点查且执行很快;这证明 B 的高延迟主要是锁队列,不是扫描成本。

时间线

t0 A BEGIN
 t1 A UPDATE id=1,成功并持锁
 t2 B BEGIN/UPDATE id=1,进入 Lock 等待
 t3 C 看到 B 的 blocker=A
 t4 B 达到 lock_timeout,55P03,事务 aborted
 t5 B ROLLBACK
 t6 A COMMIT,释放锁
 t7 B 重新 BEGIN/UPDATE/COMMIT,成功

预期结果与解释

  • B 的 wait_event_typeLock,具体事件常与 transaction ID 相关;
  • pg_blocking_pids(B) 返回 A;
  • B 的 SQL 计划没有恶化,但端到端延迟至少包含锁等待;
  • 增加连接只会增加同一热点上的等待者,不能提高该键的写吞吐。

清理

ROLLBACK; -- 若任一会话仍在事务中
DROP TABLE perf_account;

安全边界:生产排障优先 pg_cancel_backend(waiter_pid) 或让业务超时;pg_terminate_backend(blocker_pid) 可能回滚大事务、断开用户并放大恢复压力,必须经审批。

13.2 实验二:PgBouncer transaction pooling 的会话状态边界

目标:确定性证明 transaction pooling 不保证 SET 和 SQL PREPARE 跨事务生效,并观察 PgBouncer server pool。

环境:当前 PgBouncer;PostgreSQL 14—18;独立测试实例。设置:

pool_mode = transaction
default_pool_size = 2
reserve_pool_size = 0
max_prepared_statements = 200

max_prepared_statements 支持的是协议级 named prepared statement,不会修复 SQL PREPARE

Session A:经 PgBouncer 连接业务库

BEGIN;
SELECT pg_backend_pid() AS backend_s1;
SET search_path = pg_catalog;
PREPARE p18 AS SELECT 42;
COMMIT;

此时 SETp18 都存在于服务端连接 S1,但 S1 已归还池。

Session B:立即占住 S1

先在管理库用 SHOW SERVERS 确认实验池没有其他闲置 server;必要时在无人使用的实验环境先执行 RECONNECT app。然后:

BEGIN;
SELECT pg_backend_pid() AS held_backend;
SHOW search_path;  -- 可观察到 A 遗留在 S1 的 pg_catalog
EXECUTE p18;       -- 可返回 42,证明 SQL PREPARE 状态泄漏到另一个客户端
-- 保持事务打开,不执行 COMMIT。

由于池中此前只有 S1,B 会占用它。随后 A 的新事务需要 PgBouncer 建立/分配第二个连接 S2。

Session A:第二个事务

BEGIN;
SELECT pg_backend_pid() AS backend_s2;
SHOW search_path;
EXECUTE p18;

-- SHOW 通常回到角色/数据库默认值,而非上一事务的 pg_catalog。
-- EXECUTE 预期失败:prepared statement "p18" does not exist
-- SQLSTATE:26000(invalid_sql_statement_name)
ROLLBACK;

Session C:连接 PgBouncer 管理库

SHOW POOLS;
SHOW SERVERS;
SHOW STATS;

检查 A/B 分别绑定的 server、sv_active/sv_idle、server assignment 与等待时间。

完成与清理

-- Session B
COMMIT;

管理会话在无其他业务的实验环境执行:

RECONNECT app;

这会让服务端连接在可释放时重连,从而清理实验残留的 SQL prepared statement。不要在生产高峰直接执行。

时间线

t0 A→S1:SET + SQL PREPARE,COMMIT,S1 归还
 t1 B→S1:观察到 A 遗留的 search_path 与 p18,然后持续占用
 t2 A 新事务→S2
 t3 A 在 S2 观察不到 S1 的 search_path,EXECUTE p18 失败
 t4 A ROLLBACK;B COMMIT;管理员 RECONNECT 清理

预期结果与解释

  • transaction pooling 只保证同一事务内固定 backend;
  • SET LOCAL ... 可用于事务内参数,角色/数据库默认值适用于每次新会话初始化;
  • pgx 默认可能使用协议级 prepared statements;当前 PgBouncer 在 max_prepared_statements>0 时可跟踪和重写它们;
  • DDL 改变 prepared statement 的参数/返回类型后仍可能产生 cached plan 错误,应把 RECONNECT 纳入迁移 Runbook;
  • session mode 可保留这些能力,但服务端连接复用率较低。

安全边界:实验必须使用专用 PgBouncer。生产切换 pool mode、执行 PAUSE/RECONNECT/KILL 或修改 prepared statement 策略前,应灰度、排空、验证连接驱动行为并准备回滚。

14. Go 实现:有界并发压测器与 Admission Controller

14.1 设计目标

下面程序具备:

  • 固定数量 worker,不为每个请求创建无界 goroutine;
  • 按计划开始时间发放请求,延迟包含生成器/应用排队,降低协调遗漏;
  • 生成器队列、准入队列、SQL 都有界;
  • semaphore 并发闸门与简化熔断器;
  • context、信号取消、pgxpool.Close() 和启动 Ping()
  • $1 参数化 SQL;
  • errors.As 解析 *pgconn.PgError 和 SQLSTATE;
  • 输出全部请求及成功请求的 P50/P95/P99、错误分类和 pgxpool.Stat() 增量;
  • 可通过 PGX_QUERY_MODE=exec 禁用 named prepared statement,作为 PgBouncer 未启用 prepared tracking 时的兼容回退。

压测器故意不自动重试,以暴露原始过载与错误。生产业务重试必须服从幂等、总 deadline 和独立重试预算。

14.2 准备数据

CREATE TABLE IF NOT EXISTS perf_account (
    id bigint PRIMARY KEY,
    balance bigint NOT NULL,
    pad text NOT NULL DEFAULT repeat('x', 100)
);

INSERT INTO perf_account (id, balance)
SELECT i, 1000
FROM generate_series(1, 200000) AS g(i)
ON CONFLICT (id) DO NOTHING;

ANALYZE perf_account;

14.3 main.go

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"math"
	"math/rand"
	"os"
	"os/signal"
	"sort"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"syscall"
	"time"

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

var (
	errAdmissionTimeout = errors.New("admission queue timeout")
	errCircuitOpen      = errors.New("circuit breaker open")
	errGeneratorFull    = errors.New("load generator queue full")
)

type Config struct {
	DatabaseURL        string
	Requests           int
	Workers            int
	OfferedRate        int
	AdmissionLimit     int
	PoolMaxConns       int32
	QueueTimeout       time.Duration
	QueryTimeout       time.Duration
	BreakerThreshold   int
	BreakerOpenFor     time.Duration
	AccountCardinality int64
}

type CircuitBreaker struct {
	mu          sync.Mutex
	failures    int
	threshold   int
	openFor     time.Duration
	openedUntil time.Time
}

func NewCircuitBreaker(threshold int, openFor time.Duration) *CircuitBreaker {
	return &CircuitBreaker{threshold: threshold, openFor: openFor}
}

func (b *CircuitBreaker) Allow(now time.Time) bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	return !now.Before(b.openedUntil)
}

func (b *CircuitBreaker) Record(err error) {
	b.mu.Lock()
	defer b.mu.Unlock()

	if err == nil {
		b.failures = 0
		return
	}
	if !isInfrastructureFailure(err) {
		return
	}
	b.failures++
	if b.failures >= b.threshold {
		b.openedUntil = time.Now().Add(b.openFor)
		b.failures = 0
	}
}

type AdmissionController struct {
	sem          chan struct{}
	queueTimeout time.Duration
	breaker      *CircuitBreaker
	rejected     atomic.Uint64
}

func NewAdmissionController(limit int, queueTimeout time.Duration, breaker *CircuitBreaker) *AdmissionController {
	return &AdmissionController{
		sem:          make(chan struct{}, limit),
		queueTimeout: queueTimeout,
		breaker:      breaker,
	}
}

func (a *AdmissionController) Do(ctx context.Context, fn func(context.Context) error) error {
	if !a.breaker.Allow(time.Now()) {
		a.rejected.Add(1)
		return errCircuitOpen
	}

	timer := time.NewTimer(a.queueTimeout)
	defer timer.Stop()

	select {
	case a.sem <- struct{}{}:
		defer func() { <-a.sem }()
	case <-timer.C:
		a.rejected.Add(1)
		return errAdmissionTimeout
	case <-ctx.Done():
		return ctx.Err()
	}

	err := fn(ctx)
	a.breaker.Record(err)
	return err
}

type Job struct {
	ID        int
	Scheduled time.Time
}

type Result struct {
	Latency  time.Duration
	Category string
	Success  bool
}

type Summary struct {
	Offered          int
	Success          int
	Errors           map[string]int
	AllLatencies     []time.Duration
	SuccessLatencies []time.Duration
}

type PoolSnapshot struct {
	AcquireCount         int64
	AcquireDuration      time.Duration
	CanceledAcquireCount int64
	EmptyAcquireCount    int64
	EmptyAcquireWaitTime time.Duration
	AcquiredConns        int32
	IdleConns            int32
	TotalConns           int32
	MaxConns             int32
}

func main() {
	cfg, err := loadConfig()
	if err != nil {
		log.Fatal(err)
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	poolConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL)
	if err != nil {
		log.Fatalf("parse DATABASE_URL: %v", err)
	}
	poolConfig.MaxConns = cfg.PoolMaxConns
	poolConfig.MaxConnLifetimeJitter = 30 * time.Second
	poolConfig.ConnConfig.RuntimeParams["application_name"] = "ch18-bounded-loadgen"

	// For PgBouncer transaction pooling, the current preferred setup is to enable
	// PgBouncer protocol-level prepared statement tracking. Setting PGX_QUERY_MODE=exec
	// is an explicit fallback that avoids named prepared statements.
	if strings.EqualFold(os.Getenv("PGX_QUERY_MODE"), "exec") {
		poolConfig.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeExec
	}

	pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
	if err != nil {
		log.Fatalf("create pool: %v", err)
	}
	defer pool.Close()

	pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
	err = pool.Ping(pingCtx)
	cancel()
	if err != nil {
		log.Fatalf("ping database: %v", err)
	}

	breaker := NewCircuitBreaker(cfg.BreakerThreshold, cfg.BreakerOpenFor)
	admission := NewAdmissionController(cfg.AdmissionLimit, cfg.QueueTimeout, breaker)

	before := snapshotPool(pool.Stat())
	summary := runLoad(ctx, cfg, pool, admission)
	after := snapshotPool(pool.Stat())

	printSummary(summary, before, after, admission.rejected.Load())
}

func runLoad(ctx context.Context, cfg Config, pool *pgxpool.Pool, admission *AdmissionController) Summary {
	jobs := make(chan Job, cfg.Workers*2)
	results := make(chan Result, cfg.Workers*2)

	var workers sync.WaitGroup
	for workerID := 0; workerID < cfg.Workers; workerID++ {
		workers.Add(1)
		go func(id int) {
			defer workers.Done()
			rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(id)))
			for job := range jobs {
				startedForLatency := job.Scheduled
				if startedForLatency.IsZero() {
					startedForLatency = time.Now()
				}

				err := admission.Do(ctx, func(admittedCtx context.Context) error {
					queryCtx, cancel := context.WithTimeout(admittedCtx, cfg.QueryTimeout)
					defer cancel()

					accountID := 1 + rng.Int63n(cfg.AccountCardinality)
					var balance int64
					return pool.QueryRow(
						queryCtx,
						"SELECT balance FROM perf_account WHERE id = $1",
						accountID,
					).Scan(&balance)
				})

				results <- Result{
					Latency:  time.Since(startedForLatency),
					Category: classifyError(err),
					Success:  err == nil,
				}
			}
		}(workerID)
	}

	collected := make(chan Summary, 1)
	go func() {
		s := Summary{Errors: make(map[string]int)}
		for result := range results {
			s.Offered++
			s.AllLatencies = append(s.AllLatencies, result.Latency)
			if result.Success {
				s.Success++
				s.SuccessLatencies = append(s.SuccessLatencies, result.Latency)
			} else {
				s.Errors[result.Category]++
			}
		}
		collected <- s
	}()

	start := time.Now()
produce:
	for i := 0; i < cfg.Requests; i++ {
		if err := ctx.Err(); err != nil {
			break
		}

		scheduled := time.Now()
		if cfg.OfferedRate > 0 {
			scheduled = start.Add(time.Duration(i) * time.Second / time.Duration(cfg.OfferedRate))
			if delay := time.Until(scheduled); delay > 0 {
				timer := time.NewTimer(delay)
				select {
				case <-timer.C:
				case <-ctx.Done():
					timer.Stop()
					break produce
				}
			}
		}

		job := Job{ID: i, Scheduled: scheduled}
		select {
		case jobs <- job:
		default:
			// Never let the generator silently become closed-loop. A full bounded
			// queue is reported as load shedding, and scheduled delay is retained.
			results <- Result{
				Latency:  time.Since(scheduled),
				Category: classifyError(errGeneratorFull),
			}
		}
	}

	close(jobs)
	workers.Wait()
	close(results)
	return <-collected
}

func classifyError(err error) string {
	if err == nil {
		return "ok"
	}
	switch {
	case errors.Is(err, errAdmissionTimeout):
		return "admission_timeout"
	case errors.Is(err, errCircuitOpen):
		return "circuit_open"
	case errors.Is(err, errGeneratorFull):
		return "generator_queue_full"
	case errors.Is(err, context.DeadlineExceeded):
		return "deadline_exceeded"
	case errors.Is(err, context.Canceled):
		return "canceled"
	}

	var pgErr *pgconn.PgError
	if errors.As(err, &pgErr) {
		switch pgErr.Code {
		case "40001", "40P01":
			return "retryable_transaction"
		case "55P03":
			return "lock_not_available"
		case "57014":
			return "query_canceled"
		case "53300":
			return "too_many_connections"
		default:
			if len(pgErr.Code) >= 2 {
				return "sqlstate_class_" + pgErr.Code[:2]
			}
			return "postgres_error"
		}
	}
	return "connection_or_unknown"
}

func isInfrastructureFailure(err error) bool {
	if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
		return false
	}
	var pgErr *pgconn.PgError
	if !errors.As(err, &pgErr) {
		return true
	}
	return strings.HasPrefix(pgErr.Code, "08") ||
		pgErr.Code == "53300" ||
		pgErr.Code == "57P01" ||
		pgErr.Code == "57P02" ||
		pgErr.Code == "57P03"
}

func snapshotPool(s *pgxpool.Stat) PoolSnapshot {
	return PoolSnapshot{
		AcquireCount:         s.AcquireCount(),
		AcquireDuration:      s.AcquireDuration(),
		CanceledAcquireCount: s.CanceledAcquireCount(),
		EmptyAcquireCount:    s.EmptyAcquireCount(),
		EmptyAcquireWaitTime: s.EmptyAcquireWaitTime(),
		AcquiredConns:        s.AcquiredConns(),
		IdleConns:            s.IdleConns(),
		TotalConns:           s.TotalConns(),
		MaxConns:             s.MaxConns(),
	}
}

func printSummary(s Summary, before, after PoolSnapshot, rejected uint64) {
	fmt.Printf("offered=%d success=%d success_rate=%.2f%% rejected=%d\n",
		s.Offered, s.Success, percentage(s.Success, s.Offered), rejected)
	fmt.Printf("end_to_end p50=%s p95=%s p99=%s\n",
		percentile(s.AllLatencies, 0.50),
		percentile(s.AllLatencies, 0.95),
		percentile(s.AllLatencies, 0.99))
	fmt.Printf("success_only p50=%s p95=%s p99=%s\n",
		percentile(s.SuccessLatencies, 0.50),
		percentile(s.SuccessLatencies, 0.95),
		percentile(s.SuccessLatencies, 0.99))

	keys := make([]string, 0, len(s.Errors))
	for key := range s.Errors {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	for _, key := range keys {
		fmt.Printf("error[%s]=%d\n", key, s.Errors[key])
	}

	fmt.Printf("pool acquire_count_delta=%d acquire_duration_delta=%s canceled_acquire_delta=%d empty_acquire_delta=%d empty_wait_delta=%s\n",
		after.AcquireCount-before.AcquireCount,
		after.AcquireDuration-before.AcquireDuration,
		after.CanceledAcquireCount-before.CanceledAcquireCount,
		after.EmptyAcquireCount-before.EmptyAcquireCount,
		after.EmptyAcquireWaitTime-before.EmptyAcquireWaitTime)
	fmt.Printf("pool now acquired=%d idle=%d total=%d max=%d\n",
		after.AcquiredConns, after.IdleConns, after.TotalConns, after.MaxConns)
}

func percentile(values []time.Duration, p float64) time.Duration {
	if len(values) == 0 {
		return 0
	}
	copyOfValues := append([]time.Duration(nil), values...)
	sort.Slice(copyOfValues, func(i, j int) bool { return copyOfValues[i] < copyOfValues[j] })
	index := int(math.Ceil(p*float64(len(copyOfValues)))) - 1
	if index < 0 {
		index = 0
	}
	if index >= len(copyOfValues) {
		index = len(copyOfValues) - 1
	}
	return copyOfValues[index]
}

func percentage(part, total int) float64 {
	if total == 0 {
		return 0
	}
	return 100 * float64(part) / float64(total)
}

func loadConfig() (Config, error) {
	cfg := Config{
		DatabaseURL:        os.Getenv("DATABASE_URL"),
		Requests:           envInt("REQUESTS", 5000),
		Workers:            envInt("WORKERS", 32),
		OfferedRate:        envInt("OFFERED_RATE", 500),
		AdmissionLimit:     envInt("ADMISSION_MAX_INFLIGHT", 16),
		PoolMaxConns:       int32(envInt("PGX_MAX_CONNS", 20)),
		QueueTimeout:       envDurationMS("QUEUE_TIMEOUT_MS", 100),
		QueryTimeout:       envDurationMS("QUERY_TIMEOUT_MS", 500),
		BreakerThreshold:   envInt("BREAKER_THRESHOLD", 8),
		BreakerOpenFor:     envDurationMS("BREAKER_OPEN_MS", 2000),
		AccountCardinality: int64(envInt("ACCOUNT_CARDINALITY", 200000)),
	}
	if cfg.DatabaseURL == "" {
		return Config{}, errors.New("DATABASE_URL is required")
	}
	if cfg.AdmissionLimit > int(cfg.PoolMaxConns) {
		return Config{}, fmt.Errorf("ADMISSION_MAX_INFLIGHT (%d) must not exceed PGX_MAX_CONNS (%d) in this example",
			cfg.AdmissionLimit, cfg.PoolMaxConns)
	}
	return cfg, nil
}

func envInt(name string, fallback int) int {
	raw := os.Getenv(name)
	if raw == "" {
		return fallback
	}
	value, err := strconv.Atoi(raw)
	if err != nil || value <= 0 {
		log.Fatalf("%s must be a positive integer, got %q", name, raw)
	}
	return value
}

func envDurationMS(name string, fallbackMS int) time.Duration {
	return time.Duration(envInt(name, fallbackMS)) * time.Millisecond
}

14.4 运行方式

mkdir ch18-loadgen && cd ch18-loadgen
go mod init example.com/ch18-loadgen
go get github.com/jackc/pgx/v5@latest

export DATABASE_URL='postgres://app_user:[email protected]:6432/app?sslmode=require'
export REQUESTS=10000
export OFFERED_RATE=1000
export WORKERS=64
export ADMISSION_MAX_INFLIGHT=16
export PGX_MAX_CONNS=20
export QUEUE_TIMEOUT_MS=100
export QUERY_TIMEOUT_MS=500
export ACCOUNT_CARDINALITY=200000

go run .

容量实验应固定其他条件,阶梯调整 OFFERED_RATEADMISSION_MAX_INFLIGHT。示例输出格式如下,数值由实际环境产生:

offered=... success=... success_rate=... rejected=...
end_to_end p50=... p95=... p99=...
success_only p50=... p95=... p99=...
error[admission_timeout]=...
error[deadline_exceeded]=...
pool acquire_count_delta=... acquire_duration_delta=...
pool now acquired=... idle=... total=... max=...

解释顺序:

  1. generator_queue_full:负载发生器自身已过载,结果不能直接归因数据库;
  2. admission_timeout:有界队列主动丢弃,观察成功请求 P99 是否被保护;
  3. pool EmptyAcquireWaitTime 上升:pgxpool 曾为空;与 DB 资源和 PgBouncer 等待交叉验证;
  4. deadline_exceeded:可能发生在 pool Acquire、代理等待或 SQL 执行,需按 span 拆分;
  5. sqlstate_class_XX:按 PostgreSQL SQLSTATE 类别进一步定位;
  6. connection_or_unknowncircuit_open:检查网络、重启、故障转移和连接风暴。

使用 PgBouncer transaction pooling 时,优先启用其当前协议级 prepared statement tracking。只有在兼容性验证失败或功能被禁用时,再评估 PGX_QUERY_MODE=exec 的解析/规划开销。迁移 DDL 后出现 cached plan 类型错误,应受控清理服务端连接,而不是无限重试。

15. 生产 Runbook

15.1 统一处置顺序

  1. 确认影响面:业务、租户、区域、读/写、错误率、P50/P95/P99、开始时间;统一时区。
  2. 冻结变量:记录发布、DDL、参数、故障转移、批任务和流量变化;避免多人同时调参。
  3. 确认观测是否可信:统计 reset、采样窗口、丢失指标、时钟偏差、协调遗漏。
  4. 拆分延迟:应用队列、pgxpool Acquire、PgBouncer wait、SQL 执行、锁/I/O/WAL/复制、返回网络。
  5. 找等待与阻塞者pg_stat_activitypg_blocking_pids()pg_locks;记录 PID、事务年龄和应用名。
  6. 找工作量贡献者pg_stat_statements 增量按总时间、读块、临时块、WAL、calls 排序。
  7. 找主机证据:CPU/run queue/上下文切换、内存/Swap/PSI、I/O 延迟/QD、网络重传。
  8. 检查维护与保护链路:Checkpoint、autovacuum、WAL 归档、复制 lag、slot、备份。
  9. 选择最小风险止血:先限流、暂停后台、取消单条语句;后终止会话、改参数、切流或故障转移。
  10. 每次只改一个主要变量:记录执行人、时间、预期、回滚条件。
  11. 验证恢复:成功率和 P99 回落只是第一步,还要确认队列、复制、WAL、Vacuum、旧连接收敛。
  12. 保留证据并复盘:快照、计划、日志、配置、时间线、根因、行动项和报警缺口。

15.2 事故现场最小快照

SELECT clock_timestamp(), version(), pg_postmaster_start_time();

SELECT
    application_name,
    usename,
    state,
    wait_event_type,
    wait_event,
    count(*)
FROM pg_stat_activity
GROUP BY application_name, usename, state, wait_event_type, wait_event
ORDER BY count(*) DESC;

SELECT
    pid,
    application_name,
    now() - xact_start AS xact_age,
    now() - query_start AS query_age,
    wait_event_type,
    wait_event,
    pg_blocking_pids(pid) AS blockers,
    left(query, 160) AS query_sample
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
ORDER BY xact_start NULLS LAST, query_start NULLS LAST;

SELECT * FROM pg_stat_wal;
SELECT * FROM pg_stat_checkpointer;

SELECT
    datname,
    numbackends,
    xact_commit,
    xact_rollback,
    blks_read,
    blks_hit,
    temp_files,
    temp_bytes,
    deadlocks,
    blk_read_time,
    blk_write_time
FROM pg_stat_database
WHERE datname IS NOT NULL;

不要在事故中执行 pg_stat_reset()pg_stat_statements_reset(),否则会销毁关键增量基线。快照应由监控系统持续保存;现场 SQL 只是补证。

15.3 十四类故障 Runbook

场景第一批确认低风险止血根因修复恢复确认/报警
1. CPU 高user/system、run queue、context switch;活动且无 wait 的 backend;top SQL 总时间/calls;容器 throttling降低准入并发;暂停批任务;取消明确 runaway query修计划/索引/N+1;减少 backend;批处理/Batch;评估 JIT/表达式成本P99、run queue、CPU、完成率同时恢复;报警按持续时间而非瞬时峰值
2. I/O 饱和pg_stat_io 增量;读/写/WAL 类型;OS await/QD/吞吐/额度;冷缓存或大扫描丢弃低优先级扫描;暂停维护/批任务;限制并发修选择性/索引/分区;降低读写放大;提升存储或缓存;验证 PG18 AIO设备延迟、QD、读写字节和 P99 回落;不能只看 %util
3. P99 高但平均正常端到端直方图;应用/Pool/PgBouncer/DB 分段;锁和 I/O 尖峰;GC/网络;协调遗漏缩短有界队列;负载丢弃;隔离长查询修尾部主因;建立 span 与 request/query ID 关联;调整容量余量P99 与错误率在完整稳态窗口达标;报警同时看队列增长率
4. 锁等待blocker 链、最老事务、idle in transaction、锁对象、DDL先取消 waiter 或业务;必要时经审批终止 blocker;暂停新写入热点缩短事务;统一锁顺序;拆热点;使用 xact advisory lock/任务队列blocker 清零、事务年龄下降、死锁/lock timeout 回归基线
5. pgxpool 耗尽AcquireDuration、EmptyAcquireWaitTime、CanceledAcquire;DB/PgBouncer 是否有余量;长事务降准入上限;暂停后台;给 Acquire 设置短 deadline修慢 SQL/事务泄漏;分池;仅在 DB 有余量时小步增池pool wait P95/P99、DB 活动并发与成功率稳定;报警 EmptyAcquire 增量
6. 达到 max_connectionspg_stat_activity 按 app/user/state;连接建立速率;保留连接是否可用;PgBouncer server pool 总和阻止继续扩容/重启循环;下调应用池;保留 DBA 通道使用 PgBouncer;全局连接预算;清 idle-in-tx;连接生命周期 jitter新建连成功、保留运维连接可用、backend 数低于预算
7. WAL 激增pg_stat_wal/backend WAL 增量;FPI;写入模板;archive/slot;复制 lag限制批写并发;暂停非关键写;保护归档和副本减少无用索引/重复更新;优化批大小;平滑 checkpoint;处理 slot 消费者WAL 速率、归档延迟、磁盘占用和复制 lag 收敛
8. Checkpoint 抖动timed/requested/done、write/sync time;P99 时间对齐;OS 写延迟;WAL 生成速率平滑写入;暂停重 I/O 任务;避免手工 CHECKPOINT按存储和恢复目标调整 WAL/Checkpoint;解决写放大;提升写回能力requested checkpoint 不再异常,sync 尖峰与 P99 消失,恢复时间仍达标
9. Autovacuum 落后dead tuple、n_mod_since_analyze、progress、日志;长事务/slot;表级阈值结束无业务价值的长事务;降低批写;手工普通 VACUUM(先评估)表级 vacuum 参数;worker/cost 容量;分区生命周期;修长快照dead tuple 趋势下降、freeze 安全、复制/WAL 与 P99 可接受
10. 临时文件激增temp_files/temp_bytes 增量、log_temp_files、计划 Sort/Hash、并行度取消单个大查询;暂停分析任务;限制角色并发修 SQL/统计/索引;按角色或事务设置 work_mem;控制并行temp 增量、磁盘空间和查询 P99 恢复;报警速率与剩余空间
11. 复制延迟sent/write/flush/replay LSN;网络、WAL 写/回放 I/O;长查询冲突;slot限制 primary 写入;卸载副本读;禁止无界回灌 primary提升副本 I/O/CPU;修大事务/WAL 放大;调整同步策略与路由lag 收敛、归档正常、读一致性策略恢复;验证 RPO/RTO
12. 执行计划变化同 queryid/模板的计划日志;统计更新时间;数据分布;参数/DDL;估算与实际行数回滚发布/DDL;ANALYZE;临时路由或取消坏查询修统计(含扩展统计)、索引、SQL;处理参数敏感计划;建立计划基线计划节点估算恢复、P99/读块下降;报警 queryid 资源突变
13. 连接风暴每秒新连接、认证 CPU、TLS/DNS、部署/探活事件、各实例池上限停止重启循环;限制实例启动和建连;退避+jitter;PgBouncer 接管全局池预算;错峰/滚动预热;稳定 readiness;连接生命周期抖动建连速率、backend、CPU 和错误恢复;演练重启/故障转移
14. PgBouncer 排队SHOW POOLS/STATScl_waitingsv_active/idlemaxwait、wait time;DB 是否饱和应用 load shed;暂停后台;缩短事务;必要时受控扩 pool修慢事务/会话状态;分 user/db pool;仅有 DB 余量才增 server poolcl_waiting/maxwait 清零、DB 仍在安全容量、client 不再超时

15.4 找到“最早的计划估算错误点”

对代表性绑定值执行:

EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY)
SELECT ...;

从叶子节点向根节点检查 estimated rowsactual rows。找到第一个出现数量级偏差、且不是由下层错误机械传递上来的节点,这通常是最早估算错误点。随后检查:

  1. 列统计是否陈旧,n_mod_since_analyze 是否高;
  2. 数据是否偏斜、相关、表达式化或跨列相关;
  3. join 条件、范围、NULL、热门租户是否与默认分布假设冲突;
  4. prepared statement 是 generic plan 还是 custom plan,绑定值是否参数敏感;
  5. 隐式类型转换、函数封装和 collation 是否使统计/索引不可用;
  6. 计划变化是否紧随 ANALYZE、DDL、版本/参数或数据分布变化。

pg_stat_statements 不保存历史执行计划;若没有 auto_explain、Tracing 或外部计划仓库,就无法事后精确证明“哪一时刻先变”。这也是建立计划证据链的原因。

15.5 在线动作与高风险动作

动作风险级别说明
降应用准入、暂停后台队列低—中可快速回滚,优先止血;会增加拒绝/积压
pg_cancel_backend(pid)取消当前语句,连接保留;事务可能进入 aborted,应用需回滚
表级 ANALYZE通常在线,但消耗 CPU/I/O,可能引起计划变化
CREATE INDEX CONCURRENTLY中—高降低阻塞但耗时、I/O/WAL 大,失败会留 invalid index
pg_terminate_backend(pid)断开连接并回滚事务;可能放大 I/O、锁与用户影响
全局大幅提高 work_mem/连接数可能导致内存爆炸、Swap、调度抖动和故障恢复恶化
手工 CHECKPOINT可能制造写入/同步尖峰;仅在明确运维流程中使用
VACUUM FULL、非并发 REINDEX强锁、额外空间和长时间影响;需要维护窗口
生产清 OS cache禁止会影响整机工作负载且不代表真实可控条件
PgBouncer KILL/全库 RECONNECT大量连接重建;必须排空、限速并有回滚
故障转移极高涉及数据一致性、uncertain commit、旧主隔离和 RPO/RTO

16. 常见反模式与模拟生产事故

16.1 十个常见反模式

反模式表现后果如何识别正确做法如何验证
1. 从参数开始调优没有 SLO/负载模型就修改十几个 GUC无法归因,可能把瓶颈移到 HA/内存变更单没有基线、假设和回滚阈值先定义窗口、分布、容量和一个主要假设对照实验只变一个变量,复测正确性与 HA
2. 只看平均延迟平均稳定就宣称健康少量请求严重超时,用户体验恶化P99/超时率与平均趋势分离端到端直方图,拆分队列和执行阶段用 P50/P95/P99、错误率和排队共同验收
3. 混淆 QPS 与 TPS用 SQL calls 当业务事务数容量计算和 Little’s Law 边界错误一次事务多 SQL,自动提交比例不清明确计数边界并同时记录业务请求/SQL/事务对照应用计数与 xact_commit/rollback 增量
4. 把连接数当吞吐旋钮P99 高就增大所有池调度、锁、I/O、内存与故障转移恶化backend 增加而 TPS 不增、队列转移找饱和资源;用有界活动并发画连接/活动查询—TPS—P99 曲线
5. 无限 goroutine/队列高峰时“先全部接住”内存增长、截止时间过期、重试风暴队列长度只增不降,成功请求也变慢限流+semaphore+短有界队列+load shedding过载时内存稳定、拒绝可控、成功 P99 受保护
6. 闭环压测掩盖协调遗漏客户端等响应后才发下一次系统变慢时到达率自动下降,低估 P99无 schedule lag/计划时间,过载吞吐反而下降使用开放环目标速率并从计划时间计时比较闭环与 pgbench -R 或开放环程序结果
7. 全局放大 work_mem临时文件多就一次提高全局值并发 Sort/Hash 乘法导致 OOM/SwapRSS、Swap 与活动查询同时上升修 SQL/统计;按角色/事务局部设置temp 降低且峰值内存、P99、OOM 风险可接受
8. 生产高比例 auto_explainlog_analyze/timing 全开CPU 与日志 I/O 反过来制造事故开启后 QPS 降、日志激增、磁盘逼满小流量灰度、采样、log_timing=off、配额对比启用前后开销并验证日志覆盖率
9. transaction pool 中保存会话状态SET、SQL PREPARE、临时表、LISTEN 跨事务随机错误、状态泄漏、锁遗留backend PID 变化后行为改变改角色默认/SET LOCAL;协议 prepared tracking;专用 session pool用两 backend 实验和驱动集成测试验证
10. 无预算重试所有超时/断线立刻重试放大负载、重复副作用、延长 RTO尝试数/QPS 在故障时倍增仅重试明确错误;指数退避+jitter;幂等;uncertain commit 消歧故障演练中总尝试率有上限且无重复业务结果

16.2 模拟生产事故一:故障转移后的连接风暴

背景:多个 Go 服务实例直连 primary;每实例配置较大 pgxpool.MaxConns 和较高最小连接数。一次故障转移后,服务发现快速指向新主。

症状:新主可接受连接,但 API P99 与错误率继续恶化;max_connections 接近上限,CPU system 占比、认证和 TLS 开销显著上升,复制恢复变慢。

关键信号

  • 应用每秒建连数与实例重启数同时激增;
  • pg_stat_activity 大量新会话,活跃 SQL 并不多;
  • pgxpool 各实例同时预热;
  • 失败请求无退避重试,readiness 失败又触发重启;
  • 旧 primary 仍有未释放连接,部分提交结果不确定。

错误假设与错误动作:认为“新主连接不够”,临时继续提高 max_connections 和每实例池上限。结果调度/内存压力更大,运维连接也被占满。

根因:连接预算按单实例计算而非全局计算;缺少 PgBouncer;连接初始化无 jitter;探活把短暂数据库不可用转化为应用重启;重试没有总预算。

修复:立即限制实例扩容和新建连速率,降低应用准入,暂停后台工作,保留管理连接;使用指数退避+jitter;确认旧主已 fencing;通过幂等键核对 uncertain commit。恢复后部署中心/近端 PgBouncer transaction pool,降低 PostgreSQL backend 总量,并让各实例池上限之和受全局预算约束。

预防与代价:定期做“数据库重启+应用滚动发布+故障转移”组合演练;监控连接建立率而非只看连接存量。代价是多一层代理、需要维护 prepared statement 和 failover 行为,但换来更可控的 RTO。

16.3 模拟生产事故二:数据偏斜触发计划变化并拖垮共享池

背景:多租户订单表增长后,一个大租户占据大量活跃数据。在线 API 与日报任务共享同一角色、pgxpool 和 PgBouncer pool。自动 ANALYZE 后,某参数化查询从索引路径切换为对大范围数据更便宜、但对普通租户更慢的 generic plan。

症状:平均延迟变化有限,但普通租户 P99 上升数倍;临时文件、I/O 队列和 PgBouncer cl_waiting 同时增加,副本 replay lag 也上升。

关键信号

  • 同一 queryid 总执行时间、读块和 temp 写入突增;
  • auto_explain 显示估算行数与实际行数从某个过滤节点开始数量级偏离;
  • 大租户/普通租户绑定值表现不同;
  • batch 占满服务端连接,API 在 PgBouncer 等待;
  • OS 存储延迟和 QD 上升,CPU 并未满载。

错误假设与错误动作:只看到临时文件就全局提高 work_mem,同时增加 PgBouncer pool。结果并发大查询使用更多内存,I/O 与副本压力没有根治。

根因:数据分布和跨列相关性未被统计表达;参数敏感计划未被测试;在线/后台共用资源;容量测试使用均匀随机数据,未包含大租户热点。

修复:先暂停日报、降低后台并发和 API 入场量;对代表绑定值采集计划,更新/扩展统计并修 SQL/索引;按租户或查询形态选择稳定路径。拆分 API 与 batch 的角色、池、timeout 和队列;在影子数据上验证 generic/custom plan 行为。

预防与代价:压测数据采用生产分布和热门租户;保存计划证据与统计变更事件;对 queryid 资源突变报警。代价是更多池和路由复杂度、少量连接复用损失,但故障域明显缩小。

17. 面试题

17.1 QPS 与 TPS 有什么区别?

  • 考察主题:性能计数边界。
  • 30 秒答案:QPS 通常是每秒 SQL/业务查询数,TPS 是每秒事务完成数。一笔事务可含多条 SQL,自动提交下每条 SQL 又可能是一笔事务,因此二者不能互换;必须声明是否计回滚、重试和失败。
  • 深入答案:应用请求、SQL calls、提交事务和 pgbench 脚本各有不同边界。容量模型应同时记录到达请求、完成请求、SQL 模板调用和 xact_commit/xact_rollback 增量,并用同一时间窗口对齐。
  • 考察点:是否先定义分母、窗口和边界。
  • 常见错误:看到 pg_stat_statements.calls 就称为业务 TPS。
  • 追问:一个请求在事务中执行 5 条 SQL,失败后重试一次,如何计数?
  • 追问答案:业务到达 1、业务最终完成至多 1;SQL 尝试可能 10;事务尝试 2;提交 0 或 1,回滚/失败至少 1。监控要同时保留这些维度。

17.2 为什么平均延迟正常而 P99 很差?

  • 考察主题:尾延迟。
  • 30 秒答案:少量请求可能遇到锁、池等待、冷页、Checkpoint、GC 或网络重传,平均值被大量快请求稀释。P99 必须从端到端分布获得,并拆分各阶段队列。
  • 深入答案:接近饱和时排队非线性增长;高分位事件还可能相关,例如同一请求先等连接再等 I/O。各阶段 P99 不可简单相加,最好用 trace 关联单请求。
  • 考察点:能否从排队而非仅 SQL 解释尾部。
  • 常见错误:优化最慢一条日志就认为解决 P99。
  • 追问:为什么 pg_stat_statements 不能直接给 P99?
  • 追问答案:它按规范化语句累计 calls、总/均值/方差等聚合,不保存每次执行的完整延迟样本;应用直方图、Tracing 或逐请求日志才可计算分位。

17.3 如何使用 Little’s Law?

  • 考察主题:吞吐、延迟与在途量关系。
  • 30 秒答案:稳态且边界一致时 L=λW。例如完成率 2,000/s、平均端到端 40ms,平均在途约 80。它用于自洽检查,不直接预测 P99 或最大容量。
  • 深入答案:非稳态爬坡、超时后仍执行、重试、丢弃和不同采样窗口会破坏简单应用。生产上要分别对应用队列、pool、PgBouncer 和 DB 定义边界。
  • 考察点:是否知道稳态假设和单位换算。
  • 常见错误:把 P99 代入并称为平均并发,或用到达率与完成延迟混用。
  • 追问:推导在途 80,监控却显示 800,先查什么?
  • 追问答案:确认时间窗口、是否漏计排队/重试/超时后的工作、指标是否是瞬时峰值而非平均,以及 λ 是到达率还是完成率。

17.4 连接数、活动查询数和并发度有什么关系?

  • 考察主题:多层并发模型。
  • 30 秒答案:连接可以 idle;活动查询也可能在等锁/I/O;真正占 CPU 的只是其中一部分。goroutine、pgxpool connection、PgBouncer client/server、PostgreSQL backend 和 TPS 都是不同指标。
  • 深入答案:连接池决定资源上界,准入控制决定昂贵工作的在途上界。减少 backend 往往能降低调度成本,但若瓶颈在锁或存储,仍需修根因。
  • 考察点:是否反对“连接越多吞吐越高”。
  • 常见错误:直接把 max_connections 调到数千。
  • 追问:什么时候增加 pool 是合理的?
  • 追问答案:Acquire 明显排队,而 DB CPU/I/O/锁、内存和 PgBouncer server pool 都有可验证余量;应小步增加并观察吞吐/P99,而非只看等待下降。

17.5 冷缓存与热缓存测试分别回答什么问题?

  • 考察主题:缓存状态与可复现性。
  • 30 秒答案:热缓存反映稳定活跃集,冷/温缓存反映启动、故障转移或首次访问成本。二者都应测,但不能在共享生产机清 OS cache。
  • 深入答案:还要区分连接/计划缓存、shared_buffers 与 OS page cache。重启 PostgreSQL只冷却部分状态,OS cache 可能仍热;完整冷启动应在隔离环境按明确流程执行。
  • 考察点:是否知道 PostgreSQL 命中率不等于磁盘命中率。
  • 常见错误:只发布最佳热缓存数字,或用 drop_caches 影响生产。
  • 追问:如何让结果可比?
  • 追问答案:固定数据快照、预热流程、运行顺序、等待稳态条件和重复次数,记录缓存状态与 OS I/O;冷/热结果分别报告,不混为一个均值。

17.6 如何用 pg_stat_statements 排查性能问题?

  • 考察主题:SQL 聚合归因。
  • 30 秒答案:对同一统计 reset 周期做快照增量,分别按总执行时间、calls、读块、临时块和 WAL 排序,再对代表绑定值取计划。它找“总影响最大”的模板,不直接给 P99 和历史计划。
  • 深入答案:优点是低成本覆盖全局;缺点是规范化后混合不同参数与计划,数据为累计聚合。替代/补充是应用 trace、auto_explain、日志和计划仓库。生产上控制权限、查询文本敏感性和 reset。
  • 考察点:能否避免只按 mean 排序。
  • 常见错误:用启动以来总数判断最近五分钟,或在事故中 reset。
  • 追问:queryid 资源突然上升,下一步?
  • 追问答案:核对 calls 是流量增还是单次成本增;检查读/temp/WAL 增量、绑定值分布、计划日志、统计/DDL/发布时间,并找最早估算错误节点。

17.7 CPU 高时如何系统排查?

  • 考察主题:CPU 饱和归因。
  • 30 秒答案:先看 user/system、run queue、上下文切换和 cgroup throttling;再看活动且不等待的 backend、top SQL 总 CPU 近似贡献和连接数。止血优先降准入、暂停 batch、取消 runaway。
  • 深入答案:高 user 可能是扫描、表达式/JIT或海量小查询;高 system/context switch 可能是连接过多、网络或内核开销。增机器、修 SQL、批处理和缩 backend 各有不同代价。生产变更要看 TPS 与 P99 是否同时改善。
  • 考察点:是否把 iowait 或锁等待误称 CPU。
  • 常见错误:CPU 高就加连接/并行度。
  • 追问:缩连接后 CPU 降、TPS 不变、P99 改善,怎么解释?
  • 追问答案:原先活动 backend 超过有效并行度,额外连接只增加调度、缓存和锁重叠;缩并发把排队移到更便宜、可控的上游。

17.8 I/O 饱和时 PG18 提供了哪些新证据?

  • 考察主题:I/O 与 AIO。
  • 30 秒答案pg_stat_io 提供按 backend type/object/context 的累计 I/O;PG18 可用 pg_stat_get_backend_io(pid)pg_stat_get_backend_wal(pid) 做单后端归因,并用 pg_aios 看瞬时 AIO handle 状态。
  • 深入答案:优点是数据库内部归因更细;缺点是仍不能替代 OS 设备延迟/QD,也不能从空 pg_aios 推断 AIO 未用。替代证据包括 EXPLAIN BUFFERS/WALpg_statio_* 和主机工具。生产应以增量和相同时间窗口关联。
  • 考察点:是否区分 PG buffer read、OS cache 和物理盘。
  • 常见错误:把 shared_blks_read 直接当物理 IOPS。
  • 追问:启用 AIO 后 TPS 没变,是否失败?
  • 追问答案:不一定;工作集可能已缓存、计划路径不使用受益场景、存储并行度有限或瓶颈转到 CPU/WAL。应比较读字节、等待、CPU、P99 和计划,而非只看 TPS。

17.9 如何处理锁等待事故?

  • 考察主题:阻塞链和安全止血。
  • 30 秒答案:用 pg_blocking_pids() 找直接/间接 blocker,记录最老事务和应用;先停止新增热点流量、取消 waiter,必要时审批终止 blocker。之后修持锁时间、锁顺序和热点模型。
  • 深入答案:取消 waiter 风险较低但业务失败;终止 blocker 会回滚,可能释放大量脏工作并影响用户。替代方案是等事务自然完成或切断特定队列。生产必须保存 PID/事务/SQL 时间线。
  • 考察点:是否知道 idle in transaction 也可持锁。
  • 常见错误:看到等待就批量 terminate,或只提高 lock_timeout
  • 追问:为什么同一热点增加连接会更慢?
  • 追问答案:串行临界区吞吐受持锁服务时间限制,更多等待者增加驻留、超时、回滚和调度成本,不能增加该热点的有效并行度。

17.10 pgxpool 耗尽时应该增大池吗?

  • 考察主题:Pool queue 与数据库容量。
  • 30 秒答案:先看 AcquireDuration/EmptyAcquireWaitTime,再看 PgBouncer 等待和 DB CPU/I/O/锁。如果 DB 已饱和,增池只把更多工作推入争用;应先限并发和修慢事务。只有 DB 有余量时才小步增池。
  • 深入答案:小池优点是反压早、backend 少;缺点是可能在有余量时制造上游等待。大池反之。替代是独立 API/batch 池、PgBouncer 和自适应并发。生产按全局实例总和预算。
  • 考察点:是否把 MaxConns 当独立配置。
  • 常见错误:每个实例都配到数据库最大连接数。
  • 追问:Pool wait 高但 DB 很闲,可能原因?
  • 追问答案:池确实过小、连接泄漏/长事务、DNS/TLS 建连慢、健康检查失败、PgBouncer 阻塞或采样边界错误;应逐层计时验证。

17.11 PgBouncer transaction pooling 与 prepared statement 如何兼容?

  • 考察主题:协议和会话状态边界。
  • 30 秒答案:当前 PgBouncer 在 max_prepared_statements>0 时能跟踪协议级 named prepared statements,但 SQL PREPARE/EXECUTE 仍不保证跨事务。SET、LISTEN、session advisory lock、保留型临时表也不兼容。
  • 深入答案:协议跟踪可降低重复解析/规划,但增加 PgBouncer CPU/内存,并在 DDL 改变类型后可能出现 cached plan 错误;可用 RECONNECT 清连接。替代是 pgx QueryExecModeExec、session pooling 或直连。生产要做驱动集成和迁移演练。
  • 考察点:能否区分协议 Parse/Bind 与 SQL PREPARE
  • 常见错误:笼统说“transaction pooling 不支持 prepared statement”。
  • 追问:为什么 session advisory lock 危险?
  • 追问答案:锁属于服务端会话;客户端事务结束后连接归池,锁可能继续留在随机 backend,后续客户端继承影响。应使用事务级 advisory lock 或专用 session 连接。

17.12 设计一个数据库 Admission Controller

  • 考察主题:过载保护架构。
  • 30 秒答案:按业务优先级先限到达率,再用 semaphore 限在途,队列必须有长度和时间上限;结合 deadline、熔断、load shedding、错误分类和指标。准入应发生在获取连接之前。
  • 深入答案:固定并发简单稳定但不适应服务时间变化;自适应并发能追踪拐点但更复杂、可能振荡。替代包括令牌桶、队列消费者和服务网格限流。生产需按 API/租户/读写设置舱壁,保留管理容量,并演练过载恢复。
  • 考察点:是否把拒绝视为可设计结果。
  • 常见错误:无限队列、所有请求同优先级、失败立即重试。
  • 追问:并发上限与 pool 上限如何关系?
  • 追问答案:昂贵数据库操作的准入上限通常不大于可用 pool;pool 还可留少量余量给健康检查/辅助语句。多个实例的总活动并发必须服从数据库容量,而非各自最大化。

17.13 如何隔离在线、后台、读和写工作负载?

  • 考察主题:舱壁与一致性。
  • 30 秒答案:使用不同队列、角色、pgxpool、PgBouncer pool 和 timeout;写池指 primary,读池按一致性选择 primary/replica,后台池低优先级且可暂停,保留 admin pool。
  • 深入答案:优点是故障域清晰;缺点是池利用率和运维复杂度增加。替代是单池加公平调度,但 PostgreSQL 缺少完整资源治理,隔离较弱。生产需防止副本故障时读流量无界回灌 primary,并定义 read-your-write。
  • 考察点:是否同时考虑性能与数据新鲜度。
  • 常见错误:所有 SELECT 都路由副本,或 API/batch 只改 application_name 却共用池。
  • 追问:如何处理副本 lag 超阈值?
  • 追问答案:按业务等级降级:强一致读回 primary但受限流,可陈旧读继续或返回陈旧标识,非关键分析暂停;不得把全部读瞬间回灌 primary。

17.14 PgBouncer 参与故障转移时要解决什么?

  • 考察主题:连接代际、Fencing 与 uncertain commit。
  • 30 秒答案:PgBouncer 不选主也不 fencing。HA 层先隔离旧主、提升新主并更新入口;随后让代理停止分配旧 server connection,受控 PAUSE/RECONNECT,应用退避重连,并消歧提交结果。
  • 深入答案:直接重载连接串可渐进切换,优点是影响小,缺点是新旧连接可能并存;需要原子切换时用 PAUSE,但会等待事务结束并增加停顿。替代是外部四层代理/虚拟 IP,仍需清旧连接。生产演练要验证 RPO/RTO、旧主连接、归档与读路由。
  • 考察点:是否意识到 DNS 更新不等于已有连接切换。
  • 常见错误:新主可连接就立即全量恢复流量。
  • 追问:客户端收到连接断开,能否自动重试 COMMIT?
  • 追问答案:不能;提交可能已持久化但响应丢失。通过业务幂等键、唯一约束和状态查询确认结果,不能把网络错误当“必然未提交”。

17.15 如何做容量规划而不是跑一次最高 TPS?

  • 考察主题:容量曲线与稳态实验。
  • 30 秒答案:用生产分布、数据规模和 HA 配置,预热后以开放环阶梯提高到达率,记录完成率、P50/P95/P99、schedule lag、错误、CPU/I/O/WAL/锁/复制,找到拐点并在其前保留故障余量。
  • 深入答案:开放环更接近独立到达并暴露协调遗漏,但若发生器本身过载会污染结果;闭环适合测最大完成能力但低估排队。替代是生产影子流量/回放,需处理隐私和副作用。生产落地要保存原始数据、重复试验、回归正确性与恢复目标。
  • 考察点:是否报告环境、冷/热缓存和失败/跳过。
  • 常见错误:只记录最好 TPS,忽略 P99、恢复时间和复制 lag。
  • 追问:为什么容量不能设在压测最大吞吐点?
  • 追问答案:最大点常已进入队列非线性区,没有留出数据增长、Checkpoint、Vacuum、单节点/单 AZ 退化、部署和流量偏斜余量;生产会频繁越过崩溃边界。

18. 练习、参考答案与章节检查清单

18.1 理论题

题 1:Little’s Law

一个服务在 10 分钟稳态窗口内完成 1,500 req/s,平均端到端延迟为 80ms。估算平均在途请求数。若监控显示 600,应提出至少三个核查方向。

答案L=1500×0.080=120。差异方向:指标边界是否包含上游队列;600 是峰值还是平均;λ 是否用了完成率而监控包含重试/超时后仍执行的请求;窗口/时钟是否一致;是否存在尚未完成的积压而系统并非稳态。

题 2:QPS/TPS

一次业务请求开启一个事务,平均执行 4 条 SQL;10% 的事务因 40001 重试一次,最终全部成功。业务完成率为 1,000/s。忽略其他 SQL,估算事务尝试率和 SQL 调用率。

答案:初次事务 1,000/s,10% 多一次尝试,事务尝试约 1,100/s;每次 4 条 SQL,约 4,400 SQL calls/s。业务完成率仍为 1,000/s。真实系统还应计失败尝试在第几条 SQL 终止。

题 3:超时预算

上游总 deadline 为 1 秒,网络和序列化需预留 120ms,业务希望最多重试一次。如何安排应用排队、pool/PgBouncer 等待和单次 SQL 预算?

答案:不存在通用唯一分配。原则是先留返回余量,再为两次尝试和退避设置总预算;每次尝试都应读取剩余 deadline,不能固定各 700ms。示例可把排队/Acquire 设为短预算,单次 SQL/锁预算小于剩余时间的一半,并在剩余时间不足一次完整尝试时停止重试。用故障注入验证没有请求超过总 deadline。

题 4:PgBouncer 选型

应用需要短 OLTP、一个 LISTEN/NOTIFY 消费者和跨事务临时表。能否全部使用 transaction pooling?

答案:不能。短 OLTP 可用 transaction pooling;LISTEN 和跨事务临时表需要稳定服务端会话,应使用隔离的 session pool 或直连,并严格限制连接。发送 NOTIFY 的普通事务可经 transaction pool。三类连接必须分别监控和预算。

题 5:容量余量

压测发现 8,000 req/s 时吞吐仍增加但 P99、schedule lag 和复制延迟开始加速;9,500 req/s 达到最高完成率。应把生产容量定在哪里?

答案:不能直接用 9,500。8,000 已是拐点迹象,生产目标应低于该点,并扣除故障、维护、增长和流量偏斜余量。具体值需通过失去副本、Checkpoint、批任务和故障转移等场景复测后确定。

18.2 实验题

实验 1:画饱和曲线

使用 pgbench -R 对 5 个逐步增加的目标到达率各运行同样预热和稳态时间,记录完成率、平均/P95/P99、late/skipped、schedule lag、CPU、pg_stat_io、WAL 和复制 lag。

参考答案:同一数据快照和参数下重复至少数次,按中位结果和波动范围绘图。拐点不是单个 CPU 阈值,而是吞吐增益变小且延迟/队列/错误开始加速的位置。若发生器 CPU 或自身队列满,先扩展/分离发生器,不能归因数据库。

实验 2:比较三个准入上限

使用本章 Go 程序,固定 OFFERED_RATE,分别设置 ADMISSION_MAX_INFLIGHT=8/16/32,保持 PGX_MAX_CONNS 足够但不超过数据库预算。

参考答案:比较成功完成率、端到端/成功 P99、admission_timeout、pool 空等待和 DB 资源。最优不是拒绝最少,而是在成功率、P99 和资源安全之间满足 SLO 的最小并发。若 32 的吞吐不增而 P99、锁或 I/O 上升,应选更低值。

实验 3:PG18 I/O 观测

在隔离 PG18 环境中选择一个大表扫描,分别在代表性的温/热缓存状态运行;采集计划、pg_stat_io 增量、backend I/O、pg_aios 高频采样和 OS I/O。

参考答案:报告时区分 PG buffer hit、OS cache 和实际设备 I/O。pg_aios 为空可能只是采样未命中;需要与累计 I/O、计划和 OS 证据交叉验证。不得声称固定倍数收益。

18.3 故障排查题

题 1:应用 Pool 等待高,但数据库 CPU 只有 25%

给出排查顺序。

答案:先确认 pgxpool 的 Acquire/EmptyAcquire 指标和应用并发;查看连接是否被长事务或未及时 Scan/Close 占用;查 PgBouncer cl_waiting/maxwait;查 DB 是否在等锁/I/O而非 CPU;检查建连、DNS/TLS、连接错误和池配置;核对容器 CPU 指标是否只看宿主机。只有确认 DB 与代理都有余量,才小步增池。

题 2:WAL、requested checkpoint 和复制 lag 同时上升

给出止血与根因方向。

答案:止血先限写入并发、暂停非关键批任务和大 DDL,保护归档磁盘与副本;检查哪类语句贡献 WAL、索引/FPI、批大小、slot 和副本 I/O。根治可能是减少写/WAL 放大、平滑 checkpoint、提升归档/副本能力或重构批任务。不要立即手工 checkpoint,也不要只调大一个 WAL 参数。

18.4 架构设计题

题目:一个系统有 8 个 API 实例、2 个后台实例、一个必须 LISTEN 的通知消费者。数据库给业务连接的安全预算是 100 个 backend;API 为短事务,后台可暂停。设计连接、准入、PgBouncer、超时、观测与故障转移方案。

参考答案(一个可行方案,不是通用模板)

  • 中心 PgBouncer 使用 transaction pooling;按角色分 apibatch 两个 user/database pool;
  • 在 100 个 backend 预算内,示例分配:API server pool 56、batch 12、通知 session/direct 4、管理/迁移 8,保留 20 个故障与误差余量;最终数字需压测;
  • 每个 API 实例的数据库准入上限可先设 7,总活动上限约 56;其 pgxpool.MaxConns 可略高于准入用于辅助操作,但客户端池总量不等于 DB backend;
  • 两个后台实例各自低并发,合计不超过 batch pool,并支持一键暂停;
  • 通知消费者使用专用 session pool/直连,只做 LISTEN,断线后重订阅并处理通知非持久性的业务补偿;
  • 请求先检查 deadline,再限流、熔断、舱壁、semaphore、短排队,然后获取 pool;API/后台角色设置不同 statement_timeoutlock_timeout 和 idle-in-transaction timeout;
  • 观测覆盖应用到达/完成/P99、准入拒绝、pgx Acquire、PgBouncer cl_waiting/maxwait、PG waits/I/O/WAL/checkpoint/复制和 OS;
  • 故障转移先 fencing 旧主,再更新入口;PgBouncer 受控 PAUSE/RECONNECT,应用退避+jitter 重连;用幂等键处理 uncertain commit;
  • 通过正常峰值、突发、暂停副本、重启 primary、滚动发布和后台大任务组合演练验证。优点是故障域清晰,代价是多个池、代理和运维流程更复杂。

18.5 章节检查清单

SLO 与基线

  • SLO 包含成功率、P50/P95/P99、窗口和工作负载;
  • 已区分到达率、完成率、QPS、TPS、重试和丢弃;
  • 数据规模、分布、热点、读写比、事务长度和 HA 模式已记录;
  • 冷/温/热缓存与预热、稳态、重复次数已定义;
  • 基线保存版本、硬件、参数、计划和统计 reset 时间。

可观测性

  • 应用端有端到端直方图和分段队列计时;
  • pgxpool.Stat()、PgBouncer SHOW POOLS/STATS 已采集;
  • pg_stat_statements 使用增量而非启动以来总量;
  • blocker、长事务、等待事件可一键查询;
  • I/O、WAL、Checkpoint、Vacuum、临时文件、复制和 OS 指标能时间对齐;
  • PG18 AIO/单 backend I/O/WAL 查询已按版本保护;
  • 日志与 auto_explain 有采样、脱敏、容量和撤回方案。

准入与连接

  • goroutine、应用在途、pool、PgBouncer client/server、DB backend 分别限额;
  • 队列长度和等待时间都有上限;
  • API、后台、读、写、管理连接有必要的舱壁;
  • timeout 从端到端预算倒推,覆盖 pool 等待和 SQL;
  • 重试只针对明确错误,并有幂等、退避、jitter 和总预算;
  • 所有实例池上限总和服从全局数据库容量;
  • 部署/故障转移不会同时预热大量连接。

PgBouncer 与 HA

  • pool mode 根据会话状态选择,而不是默认 transaction 后碰运气;
  • 已验证协议 prepared statement 与 max_prepared_statements
  • SQL PREPARESET、临时表、advisory lock、LISTEN 边界已有测试;
  • max_client_conn、server pool、reserve pool 和文件描述符有预算;
  • DDL 后 cached plan 清理流程已演练;
  • 旧 primary fencing、代理重连、应用退避和 uncertain commit 消歧已演练;
  • 恢复确认包含复制、归档、Vacuum、旧连接和备份链路。

官方资料

  1. PostgreSQL 18:Monitoring Database Activity
  2. PostgreSQL 18:pg_aios
  3. PostgreSQL 18:pg_stat_statements
  4. PostgreSQL 18:auto_explain
  5. PostgreSQL 18:Error Reporting and Logging
  6. PostgreSQL 18:Run-time Statistics
  7. PostgreSQL 18:pgbench
  8. PostgreSQL 18 Release Notes
  9. PostgreSQL 18:EXPLAIN
  10. PostgreSQL 18:Client Connection Defaults / Timeouts
  11. PgBouncer:Features and pooling compatibility
  12. PgBouncer:Configuration
  13. PgBouncer:Administration and SHOW commands
  14. pgx/v5 pgxpool API
  15. Go testing package:Benchmarks