跳转到主要内容

22 四通八达:服务接入、连接池与路由

应用需要的不是“连接 10.10.10.11”,而是:

把这笔短事务交给当前可写主库
把这批可容忍陈旧的查询交给合格副本
让迁移工具跟随主库,但不要经过事务池
把重型分析限制在指定离线副本

机器地址只回答“TCP 包送到哪里”,服务端点还必须回答:

role          选择主库、副本还是指定成员
consistency   允许多旧,是否要求 read-your-writes
path          经过代理、连接池还是直达 PostgreSQL
session       客户端会话是否稳定绑定一个 backend
capacity      最多建立多少 client/server connection,在哪里排队
failure       旧连接如何结束,新连接何时恢复,提交结果如何判定
security      使用哪个身份、HBA/TLS/证书与审计边界
discovery     一个地址、VIP、DNS、多 host 还是驱动拓扑发现

只要其中一项没有写清,端口号就是一个未经定义的偶然实现。

本章把 PostgreSQL backend、PgBouncer、HAProxy、Patroni 和客户端驱动放进 同一条证据链。学习顺序不是先背 5433/5434/5436/5438,而是先定义服务 语义,再计算连接预算,选择池化模式,最后验证 Pigsty 的具体映射与切换行为。

本章目标

完成本章后,你应当能够:

  1. 把连接 URI 当作版本化服务合同,而不是主机别名;
  2. 区分主写、只读、同步读取、离线读取和直连管理端点;
  3. 解释异步副本为什么不能天然提供 read-your-writes;
  4. 用 LSN、sticky-primary 或业务一致性 token 设计读取策略;
  5. 说明一个 PostgreSQL backend 的进程、内存、锁、事务和会话成本;
  6. 拒绝用调大 max_connections 代替容量规划;
  7. 把应用进程数、应用池、PgBouncer pool 与数据库保留槽放进同一预算;
  8. 正确比较 session、transaction 与 statement pooling;
  9. 判断临时表、会话 GUC、LISTEN、咨询锁和安全上下文是否兼容事务池;
  10. 区分协议级 prepared statement 与 SQL PREPARE
  11. SHOW POOLS 证明排队、复用与服务端连接上限;
  12. 阅读 HAProxy 的 Patroni 角色健康检查、backup 选择和连接关闭策略;
  13. 为切换中的断连、退避、抖动和提交结果未知编写客户端合同;
  14. 识别角色已经正确、但池化后端仍保留旧角色状态的情况;
  15. 在 Pigsty 中声明、渲染、校验和重载服务,而不是手改产物;
  16. 完成一次带端点、池化、异步可见性和双向计划切换的可重放演练。

前置与后续

前置:

后续:

  • 第 23 章把身份、HBA、TLS、RLS、审计与池化上下文纳入安全模型;
  • 第 24 章把端点合同、切换 SOP 和例外变成组织治理;
  • 第 25、26、27 章分别补齐指标、容量压测与参数治理;
  • 第 31、34 章会复用本章的 queue、timeout、reserve 与 reconnect 控制点 处理事故和过载。

学习路径

业务动作
  -> 一致性和会话要求
      -> 语义服务端点
          -> PostgreSQL backend 成本
              -> 全局连接与并发预算
                  -> PgBouncer 模式及兼容性
                      -> HAProxy/Patroni 角色路由
                          -> 断连、重试与结果判定
                              -> Pigsty 声明和渲染
                                  -> 端到端演练与生产差距

这条路径故意把“端口”放在后面。5433 不是 PostgreSQL 标准语义;只有 当声明、渲染配置、健康检查、池状态和 SQL 观察相互吻合时,它才是当前 环境里的主写服务。

五层连接证明

要回答的问题 本章证据
声明 服务本来应当选择什么 Pigsty inventory/defaults、ADR
渲染 实际代理与池配置是什么 HAProxy service file、SHOW CONFIG
运行 当前哪些 backend 合格、池是否排队 Patroni、HAProxy health、SHOW POOLS
SQL 客户端最终落到什么角色与会话 recovery/read-only、PID、GUC、token
故障 角色变化时确认/未知结果如何收敛 client event、timeline、token reconcile

任意一层单独通过都不够:

Patroni 有 leader
  != 应用主写端点可用

HAProxy backend UP
  != PgBouncer 身份、会话语义正确

SHOW POOLS 有空位
  != 业务延迟和数据库并发安全

连接失败后重试成功
  != 上一次写入一定没有提交

Pigsty 默认服务语义

本章 exact v4.5.0 沙箱保留四个服务:

名称 入口 目标 Patroni 检查 本章用途
primary 5433 当前主库 PgBouncer :6432 /primary 短 OLTP 读写
replica 5434 副本优先 PgBouncer :6432 /read-only 可容忍陈旧的只读
default 5436 当前主库 PostgreSQL :5432 /primary 管理、迁移、会话敏感工具
offline 5438 指定离线副本 PostgreSQL :5432 /replica 受控 OLAP/ETL

replica 的 primary 是 backup,offline 的普通 replica 也是 backup。 因此名称表达“选择偏好与降级策略”,不是永恒承诺。客户端仍要用 target_session_attrs、只读事务和业务策略防止错误落点。

这些都是可修改的 Pigsty 默认值,不是 PostgreSQL 标准端口。实际系统必须 读取自己的声明与渲染产物。

正式实验

target          pg36-l2-vagrant/pg-test
entry           10.10.10.11 HAProxy
Pigsty          v4.5.0
PostgreSQL      18.6
PgBouncer       1.25.2, transaction mode
HAProxy         3.4.2
fixture         database/user test/test, schema pg36_ch22
initial         pg-test-1 primary, timeline 9
forward         pg-test-2 primary, timeline 10
restored        pg-test-1 primary, timeline 11

池化实验临时把入口节点的默认 server pool 从:

default_pool_size=50
reserve_pool_size=30
query_wait_timeout=120

改为:

default_pool_size=2
reserve_pool_size=0
query_wait_timeout=5

所有值在角色切换前精确恢复。正式观测:

endpoint semantics                      4 / 4 matched
concurrent clients                      12
maximum active PostgreSQL backends       2
maximum waiting clients                 10
fastest / slowest query              254 / 1519 ms

session SET stayed with backend          yes
same state leaked to another client      yes
original client got another backend      yes

protocol prepared iterations            12 / 12 correct
backend reassignment                     observed
SQL PREPARE after reassignment           SQLSTATE 26000

replica token visibility                 11.092 ms
interpretation                           one observation only

forward command                          2.774 s
restore command                          2.766 s
acknowledged writes                      339
unknown outcomes                         48, all absent after lookup
acknowledged missing                       0
duplicate token                            0
unreconciled unknown                       0
maximum conservative write gap          8.510 s
counterexamples rejected                  15

最重要的观测不是某个毫秒数字,而是一个跨层失配:

pg-test-2 的 PostgreSQL 已经是只读副本,Patroni 和 HAProxy 健康检查 也正确,但 PgBouncer 的既有 pool 仍可能保留上一次角色周期的状态。 RECONNECT test 让服务端连接重新发现当前角色,端点验证才重新通过。

正式切换因此把三节点 RECONNECT test 计入恢复路径,并要求刷新后出现新的 确认写入。它不是隐藏的实验准备,更不是生产 SLO。

结论边界

four service paths on one entry          通过
two-slot queue and backpressure          通过
transaction-pool session hazard          通过
protocol prepared exact version pair     通过
SQL PREPARE cross-backend failure        通过
one async visibility observation         通过
two healthy planned switchovers          通过
token reconciliation                     通过
original pool settings/final leader       恢复

redundant HAProxy/VIP/DNS entry           未证明
client/server TLS                         未验收
representative production load           未测试
driver/ORM version matrix                 未测试
unplanned failover/fencing                未测试
bounded replica staleness                 未承诺
production approval                       pending

十四项例外

沿用第 19 章六项与第 20 章四项,本章新增:

EX22-SINGLE-HAPROXY-ENTRY
  只从 10.10.10.11 进入;不能声称入口冗余。

EX22-ASYNC-READ-OBSERVATION
  只采样一个 token;不能声称 read-your-writes 或 bounded staleness。

EX22-SYNTHETIC-LOAD
  负载小且运行在 laptop sandbox;不能推出生产容量。

EX22-RUNTIME-POOL-OVERRIDE
  只临时改变一个 PgBouncer 进程并恢复;不是声明式生产策略验收。

本章目录

22.1 服务端点的语义

22.2 连接的服务端成本

22.3 PgBouncer 池化模式

22.4 路由与故障切换

22.5 连接预算与过载边界

22.6 Pigsty 服务接入层

22.7 实战:写入、只读与管理三类接入

实验入口

task.sh all 只重验既有证据,不连接 fixture、不改配置、不切换角色,也不 删除对象。drill:servicereset:fixture 是两条独立、精确守卫的路径。

参考资料


上一章:未雨绸缪:备份体系与恢复演练 · 返回下卷导读 · 下一章:固若金汤:认证、授权与数据安全 · 查看全书目录 · 查看索引中心

22.1 服务端点的语义

数据库连接串经常被当成部署细节:

postgresql://user@host:port/database

但每个字段都在选择行为:

host/port                入口和发现机制
database/user            catalog、身份和 pool key
target_session_attrs     可接受的服务端角色
sslmode/certificate      传输与身份验证
connect_timeout          一次建立连接最多占用多久
options/startup params   会话初始语义

所以 URI 是应用与数据平台之间的 API。修改它可能改变一致性、会话、性能、 安全与故障行为,不能当作无语义的运维替换。

22.1.1 主写、只读、同步只读与直连管理端点

从业务动作定义端点

先列动作,再列端口:

动作 必要语义 不必要或有害的假设
创建订单 当前可写主库、提交结果可判定 固定机器
查看刚下的订单 read-your-writes 任意异步副本
浏览商品目录 可接受少量陈旧、只读 必须占用主库
生成日报 大查询、资源隔离、允许陈旧 普通 OLTP 副本
执行迁移 跟随主库、稳定 backend、完整会话能力 事务池
调查故障 保留管理槽、短超时、可审计 与应用共享无界池

由此可以得到几类服务,而不是几个机器别名。

主写服务

主写服务至少承诺:

new connection
  -> currently accepted writable role
  -> transaction_read_only = off
  -> write identity and permissions valid

它不承诺:

existing connection survives promotion/demotion
in-flight transaction transparently migrates
connection success means the next commit cannot fail
network error means the last commit did not happen

客户端可用 libpq 的:

target_session_attrs=read-write

作为最后一道角色检查。它不能选主,也不能修复代理;它只是在连接建立后拒绝 不满足属性的 session。

对写端点还要定义:

  • 事务被断开后是否自动重试;
  • 哪些动作有 idempotency key;
  • 提交结果未知时如何查询;
  • 是否允许 session pooling;
  • 应用侧 pool acquire/connect/statement timeout;
  • 最大并发和排队位置。

只读服务

只读端点有两个不同维度:

role semantics
  当前 session 不能写

freshness semantics
  数据最多允许落后多少

transaction_read_only=on 只证明第一项,不证明第二项。一个停止 replay 数小时的副本仍然只读。

客户端可以组合:

target_session_attrs=read-only

以及事务级护栏:

BEGIN READ ONLY;
SELECT ...;
COMMIT;

前者防错误落点,后者把意图交给 PostgreSQL。二者都不能自动提供 bounded staleness。

只读服务还要明确降级:

replica unavailable
  -> fail closed
  -> fall back to primary as read-only workload
  -> return cached/stale result
  -> shed optional traffic

Pigsty 默认 replica service 把 primary 作为 backup。因此它表达 “副本优先”,而不是“永远只去副本”。如果业务必须隔离主库,声明与客户端 都要 fail closed,不能只相信服务名称。

同步只读不是“副本端口”的同义词

同步复制控制提交确认条件。例如:

primary commit waits until chosen standby has durable WAL

这仍不自动表示:

任意只读副本已经 replay 到该提交
客户端下一次连接一定选择那个同步副本
同步副本没有降级或被替换
查询开始前 replay 已越过业务 token

真正的“同步只读服务”至少要同时约束:

  1. 哪些 standby 进入同步集合;
  2. commit 等待到 write、flush 还是 apply;
  3. 端点只选择哪个集合;
  4. 同步状态丢失时 fail closed 还是降级;
  5. 客户端如何携带上次写入边界。

synchronous_commit=remote_apply 且读取端点只选择参与确认的已 apply 副本,可以收紧窗口,但切换、负载均衡和事务起点仍需单独证明。

离线/分析服务

“replica”表示复制角色,“offline”通常表达调度意图:

普通 OLTP 查询不选它
重型 OLAP/ETL 优先选它
资源参数、索引或延迟容忍可能不同

Pigsty 的 offline service 使用 /replica 检查,并通过 inventory selector 优先选 pg_offline_query 成员。它仍会 replay WAL,长查询、临时文件与 I/O 可能拖慢 replay;“离线”不表示与主集群完全隔离。

应额外写:

  • 最大可接受 replay lag;
  • 查询并发、statement timeout、temp file 限额;
  • 是否允许 hot standby conflict 取消查询;
  • 普通副本能否作为 backup;
  • replay 明显落后时是否摘除。

直连管理端点

直连管理并不一定是固定主机。更实用的是:

client -> semantic primary proxy -> PostgreSQL 5432

它跟随当前主库,但绕过事务池,适合:

  • schema migration;
  • 需要 session advisory lock 的部署工具;
  • LISTEN/NOTIFY consumer;
  • 大型 COPY 或特殊驱动工作流;
  • 需要稳定 backend 的诊断;
  • CDC/逻辑复制管理,在完成额外评审后。

绕过池不等于无限连接。管理端点通常应有更小、更受控的来源网段、身份、连接 预算与审计。

Pigsty 默认 default service 是这种路径:HAProxy 仍用 /primary 选择主库,但目标是 PostgreSQL 5432 而不是 PgBouncer 6432

写一份端点合同

一个最小合同可以写成:

service: pg36_shop_rw
purpose: short OLTP read-write transactions
role: primary
path: haproxy -> pgbouncer(transaction) -> postgres
client_guard: target_session_attrs=read-write
consistency: primary transaction semantics
session_features:
  allowed: [SET LOCAL, protocol_prepare_tested]
  forbidden: [LISTEN, session_advisory_lock, persistent_temp_state]
budget:
  app_instances: 8
  app_pool_per_instance: 12
  pgbouncer_server_pool: 40
  database_reserved_for_platform: 60
timeouts:
  acquire: 200ms
  connect: 2s
  statement: 3s
failure:
  retry: capped exponential backoff with jitter
  write_outcome: reconcile by idempotency token
security:
  tls: verify-full
  role: pg36_shop_app
owner: shop-platform

端口只是这个合同的实现字段之一。

22.1.2 复制延迟、一致性与 read-your-writes

“刚写完却读不到”为什么完全正常

异步流复制的路径是:

primary commit
  -> WAL generated/flushed
      -> sender transmits
          -> standby receives/writes/flushes
              -> startup process replays
                  -> read query starts with a snapshot

写请求收到成功,只表示其提交满足当前 synchronous_commit 规则。若规则不 等待目标副本 apply,随后的 replica query 可能先到。

形式化地,令:

L_commit = 写事务之后主库的提交边界
L_replay = 读取开始前目标副本的 replay LSN

要让该副本具备读取边界,至少需要:

LreplayLcommit L_{\text{replay}} \ge L_{\text{commit}}

这仍不代表业务一定读到目标行:查询条件、事务 snapshot、权限、分区、 soft delete 和应用 cache 都可能改变结果。

四种常用策略

策略一:写后粘主

write success
  -> same request/session reads primary
  -> or tenant/user sticks to primary for bounded time

优点是简单;缺点是时间窗口不是因果证明,可能过长浪费主库,也可能过短。

适合:

  • 用户刚提交后查看详情;
  • 一次 HTTP request 内写后读;
  • 没有 LSN token 基础设施的普通应用。

策略二:携带一致性 token

写事务完成后,从主库取得一个 WAL 边界:

SELECT pg_current_wal_flush_lsn();

客户端把它作为 opaque token 传给后续读取。读取端点在副本上观察:

SELECT pg_last_wal_replay_lsn();

只有 replay 越过 token 才查询,否则:

short wait
  -> primary fallback
  -> fail with retryable freshness error

注意:

  • LSN 必须来自提交之后;事务内提前读取可能落在 commit record 之前;
  • timeline 改变后,不能只做字符串大小比较;
  • token 协议要绑定 cluster identity;
  • 等待必须受 request deadline 限制;
  • connection pool 可能在两次语句间换 backend;
  • 读取事务 snapshot 必须在 replay 达标之后建立。

事务池场景最好把“等待边界 + 业务查询”放在同一只读事务或服务端函数里, 避免检查和查询落到不同副本。

策略三:同步 apply 与限定路由

可以让提交等待同步副本 replay,再让读取只去被确认的集合。它增加写延迟, 并把副本可用性纳入提交路径。

必须定义降级时:

block writes
reduce synchronous quorum
route reads back to primary
declare consistency downgrade

没有明确降级合同的“同步”会在故障时悄悄变成另一个语义。

策略四:业务版本/事件边界

有些系统不用裸 LSN,而用:

aggregate version
event sequence
updated_at + unique version
outbox offset

副本读到至少该业务版本才返回。这更贴近业务,但底层仍需可靠映射和超时。

延迟指标的几个阶段

副本延迟不是一个数字:

阶段 PostgreSQL 观察 能推出什么
sent primary sent_lsn WAL 已发到何处
write primary write_lsn standby OS 收到/写入
flush primary flush_lsn standby 持久化
replay primary replay_lsn / standby replay LSN 查询可见边界接近何处
query 业务 token 查询 目标事实是否可见

replay_lag=0 是某次采样,不是未来保证。低流量时 timestamp lag 也可能显得 陈旧,LSN gap 与 wall-clock delay 要结合读。

本章的一个样本

正式实验:

write path       10.10.10.11:5433 -> primary PgBouncer
read path        10.10.10.11:5434 -> replica PgBouncer
selected replica pg-test-2
token visible    11.092 ms
poll count       1

它只证明:

在这个时刻、这个 token、这个小型异步沙箱和当前负载下,副本在约 11.092 ms 后返回了该行。

它不能证明:

P99 replica lag
切换期间延迟
高 WAL 吞吐下延迟
网络抖动下延迟
所有读取 read-your-writes

因此 evidence 把 EX22-ASYNC-READ-OBSERVATION 作为必需例外。

读端点失败也是语义

本章准备实验时曾观察到:

Patroni topology          correct
direct PostgreSQL state   recovery=true, read_only=true
HAProxy health            UP
target_session_attrs      rejects one pooled path

根因位于 PgBouncer server pool 的角色状态,而不是 PostgreSQL 复制本身。 执行受控的:

RECONNECT test;

让空闲/完成的服务端连接重新建立后,端点属性恢复。

这说明一致性证据必须从客户端走完整路径。只在副本上执行 SELECT pg_is_in_recovery(),不能证明应用的 5434 连接会得到同样 session。

22.1.3 DNS、VIP、代理与客户端发现

四种入口解决不同问题

固定节点地址

host=10.10.10.11
  • 优点:简单、可诊断。
  • 缺点:节点故障就是入口故障;即使 HAProxy 能路由数据库角色,客户端也到 不了它。

本章 formal run 只验证这种单入口,因此明确保留例外。

DNS

DNS 可以:

  • 把名称指向 VIP/代理;
  • 返回多个 A/AAAA 记录;
  • 在切换时修改地址;
  • 为不同服务提供稳定名称。

但 DNS 不迁移既有 TCP 连接。还要考虑:

authoritative TTL
resolver/client cache
JVM/driver cache policy
negative caching
record order and happy-eyeballs
DNS control-plane availability

“TTL 5 秒”不等于 5 秒内所有应用都换地址。

VIP

VIP 把一个网络地址移动到健康入口。它解决入口地址连续性,不决定数据库主库。

需要证明:

  • 谁持有 VIP,使用什么仲裁;
  • split brain 时是否可能双持;
  • gratuitous ARP/NDP 与交换网络收敛;
  • 跨网段/跨 AZ 是否可达;
  • VIP manager 与 Patroni 状态如何组合;
  • 入口节点的 HAProxy/PgBouncer 是否健康。

数据库主库、VIP owner 和 HAProxy backend 是三个状态机,不能假设天然一致。

客户端多 host

libpq 连接串可以列多个 host:

host=proxy-a,proxy-b
port=5433,5433
target_session_attrs=read-write
connect_timeout=2

客户端按规则尝试,target_session_attrs 拒绝错误角色。这减少单一入口依赖, 但每个应用/驱动的:

  • host 顺序;
  • DNS 展开;
  • 并行或串行尝试;
  • overall deadline;
  • pool 的 address refresh;
  • TLS hostname verification;

都要实测。

专用代理/服务发现

外部 HAProxy、云负载均衡、Kubernetes Service 或服务网格可以提供入口。 仍要验证:

L4 vs L7 protocol handling
idle timeout and TCP keepalive
health check semantics
connection draining
source IP and HBA
TLS termination/passthrough
backend queue
control-plane blast radius

一个完整发现链

application logical name
  -> resolver / service discovery
      -> one or more reachable proxy addresses
          -> role-aware health check
              -> PgBouncer or PostgreSQL destination
                  -> SQL role assertion

每一步都有独立的 stale state:

DNS cache stale
VIP owner stale
HAProxy health stale
PgBouncer backend state stale
application pool holds old TCP connection

切换测试必须穿过整个链,而不是只验证最末端的数据库角色。

target_session_attrs 的位置

它是客户端接受条件:

any        任意 session
read-write 必须可写
read-only  必须只读
primary    不是 hot standby
standby    是 hot standby
prefer-standby 优先 standby,必要时接受其他

具体取值以当前 libpq 官方文档为准。它不能:

  • 限制副本延迟;
  • 保证连接经过或绕过 PgBouncer;
  • 替代 TLS hostname 检查;
  • 让失败事务自动迁移;
  • 识别业务上的“正确集群”。

因此连接串还需要正确的 host、database、user、证书和 cluster identity 治理。

入口验收矩阵

场景 要执行的动作 通过条件
正常主库 从每个入口连接写服务 都选同一可写 leader
正常副本 连接只读服务 只读,成员在允许集合
一个入口故障 停止/隔离入口 客户端转向另一入口
DNS 变化 修改记录并保留旧连接 新连接收敛,旧连接行为已定义
数据库切换 planned role change 入口不变,新连接到新主库
proxy reload 配置校验后 reload 现有/新连接行为符合 draining 合同
stale pool 角色变化后保留 server pool 能检测并安全 refresh
TLS rotation 轮换 CA/cert 新旧窗口与 hostname 验证通过

本章只执行正常四端点和数据库 planned switch。入口节点故障、DNS/VIP 和 TLS 留给生产准入矩阵,不能由一次成功连接替代。

本节检查表

[ ] 每个服务按业务动作命名,而不是按主机命名
[ ] role、freshness、session、path、budget、failure 全部成文
[ ] 写端点有 target role 与 outcome-unknown 策略
[ ] 读端点写清 primary fallback 和 staleness 边界
[ ] 分析端点有资源与 replay 保护
[ ] 管理端点跟随主库但有独立预算/权限
[ ] DNS/VIP/multi-host 的失败域经过测试
[ ] SQL 观察走完整客户端路径
[ ] 角色变化后重新验证池化 session

参考资料


返回本章目录 · 下一节:连接的服务端成本 · 查看全书目录 · 查看索引中心

22.2 连接的服务端成本

PostgreSQL 不是把所有客户端请求放进一个无状态线程池。经典架构中,每个已 认证客户端连接对应一个 backend process:

client TCP/socket
  -> postmaster accepts
      -> one backend process
          -> one session state
              -> zero or one current transaction

因此“连接数”同时占用身份、进程、内存、文件描述符、共享结构与调度能力。 连接池的目标不是让数据库接受无限请求,而是把大量客户端等待放到比 backend 更便宜、更可控的位置。

22.2.1 后端进程、内存、事务与会话状态

一个连接得到什么

backend 建立后持有:

  • OS process、PID、栈和私有地址空间;
  • 与 shared memory 的映射;
  • database、role、application name、client address;
  • session GUC 和 prepared statement;
  • 临时 schema、临时表与 cursor;
  • LISTEN registration;
  • session-level advisory lock;
  • relation/catalog cache;
  • 当前事务、snapshot、lock 与 resource owner;
  • socket buffer、日志上下文和统计状态。

可以观察:

SELECT pid,
       usename,
       datname,
       application_name,
       client_addr,
       backend_start,
       xact_start,
       state,
       wait_event_type,
       wait_event
FROM pg_stat_activity
WHERE backend_type = 'client backend'
ORDER BY backend_start;

state='idle' 只表示当前没有执行 query,不表示连接免费。它仍占用 backend slot 和 process,并保留 session state。

connection、session、transaction、statement

四个边界不能混用:

connection
  一条客户端到 PostgreSQL/PgBouncer 的协议连接

session
  登录后到断开前的逻辑上下文

transaction
  BEGIN/COMMIT 或一个 autocommit statement 的原子边界

statement
  一条 SQL 执行

直连 PostgreSQL 时:

client connection ~= PostgreSQL backend session

事务池时:

client connection A
  transaction 1 -> backend X
  transaction 2 -> backend Y

client connection B
  transaction 1 -> backend X

应用仍觉得自己有一个长连接,但 server session 已不是它的私有状态容器。

私有内存与共享内存

连接成本不能简化成固定的“每连接 10 MB”。大致有:

baseline private process memory
+ catalog/relation cache growth
+ query executor memory
+ sort/hash/work_mem consumers
+ temp_buffers actually touched
+ protocol/result buffers
+ extension/PL runtime state
+ kernel socket/process overhead

work_mem 不是每个连接一次,而可能是每个执行节点、并行 worker 各自一次:

Mquerymemory nodework_mem×workers M_{\text{query}} \approx \sum_{\text{memory node}} work\_mem \times \text{workers}

若 200 个 backend 同时执行多 hash/sort 查询,work_mem=64MB 并不意味着 最多使用 200×64MB200 \times 64\text{MB};实际可能更高。

本章沙箱观察:

max_connections                  500
superuser_reserved_connections    10
reserved_connections               0
work_mem                         64 MB
temp_buffers                      8 MB
max_locks_per_transaction        500

这些是配置事实,不是“500 个并发重查询安全”的证明。

共享结构也随连接上限变化

某些 shared memory 与数组在启动时按:

max_connections
max_prepared_transactions
max_locks_per_transaction
max_worker_processes

等参数估算。提高 max_connections 可能要求重启,并增加:

  • backend/proc array;
  • lock table 的潜在规模;
  • per-backend shared bookkeeping;
  • snapshot、predicate lock 等间接压力。

更隐蔽的是 CPU scheduler:大量 runnable backend 会争抢 CPU 和 cache, 吞吐可能下降而不是上升。

事务状态比连接数更危险

最常见的坏状态是:

idle in transaction

它可能:

  • 保留 snapshot,妨碍 vacuum 清理 dead tuple;
  • 持有 row/table/advisory lock;
  • 阻塞 DDL;
  • 增长 xmin horizon;
  • 长期占用事务池 backend。

观察:

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

护栏:

ALTER ROLE pg36_shop_app IN DATABASE pg36_shop
  SET idle_in_transaction_session_timeout = '30s';

本章沙箱全局值是 600 秒。生产应按 workload 和 role 收紧,不要把长 ETL 与 OLTP 共用一个默认。

建连本身也有成本

一次新 PostgreSQL connection 包含:

TCP + TLS
authentication
postmaster fork/exec or process setup
startup parameters
catalog/role/database initialization
extension/session initialization

若请求每次新建直连,建连成本和认证压力会被放大。连接池复用 server connection,既减少延迟,也把认证、TLS 和 backend churn 从每请求移开。

但复用不能隐藏身份边界:不同 database/user 通常形成不同 server pool, 不同 TLS、startup parameter 和 session feature 也可能阻止复用。

22.2.2 max_connections 不是容量规划答案

ceiling、budget 与 concurrency

区分三个数字:

max_connections
  PostgreSQL 接受的普通 backend slot 上限

connection budget
  分配给应用、运维、复制/扩展和紧急访问的合同

active concurrency
  某时刻真正占用 CPU/I/O/lock 执行工作的事务数

把 ceiling 调高,只改变第一项。

先保留逃生通道

PostgreSQL 有:

superuser_reserved_connections
reserved_connections

达到普通上限后,拥有相应资格的角色仍可连接。PostgreSQL 18 中, reserved_connectionspg_use_reserved_connections 可以为非 superuser 的受控运维角色保留槽。

预算示意:

max_connections                 500
- superuser reserved             10
- platform/monitor/admin          40
- logical replication/maintenance 20
- incident reserve               30
= maximum allocatable app slots 400

但这仍不是建议同时运行 400 条重查询。PgBouncer server pool 应进一步把 active backend 控制在 CPU、I/O 和 workload 能承受的范围。

Little’s Law 先给量级

稳态系统中:

L=λW L = \lambda W

其中:

  • LL:系统内平均并发请求;
  • λ\lambda:每秒到达/完成请求数;
  • WW:平均服务时间。

例如:

2000 transactions/s
average database service time 10 ms

则平均 active database concurrency 约:

2000×0.010=20 2000 \times 0.010 = 20

这不表示 pool 只设 20:要考虑 P95/P99、burst、锁等待、连接抖动和 headroom。 但它说明 500 backend 不一定比 40 更快。

排队不可消灭,只能选择位置

当 arrival rate 超过 service capacity:

application queue
PgBouncer cl_waiting
HAProxy queue
PostgreSQL lock wait
CPU run queue
storage queue

总会有一个地方排队。好的设计让它:

  • 边界明确;
  • 有上限;
  • 能超时;
  • 可观察;
  • 不占用昂贵 backend;
  • 能按租户/服务隔离;
  • 过载时先拒绝低价值工作。

坏的设计把 20,000 个客户端全部变成 PostgreSQL backend,再让 CPU scheduler 充当连接池。

max_connections 提高后的反效果

常见链条:

pool acquire timeout
  -> raise max_connections
      -> more active queries
          -> CPU/cache contention + I/O queue
              -> service time grows
                  -> connections live longer
                      -> pool still exhausted

这是正反馈。正确诊断要看:

arrival rate
transaction service time
active vs idle connection
pool waiting
lock wait
CPU run queue
I/O latency
result size/client consumption
retry amplification

不能只看“连接占了 95%”。

何时真的需要提高上限

可能合理的场景:

  • 大量长期 idle session,活跃并发低,且 session 语义无法池化;
  • 多租户/多 database 需要更多独立最小 pool;
  • 运维、复制、监控预算被明确分离;
  • 经压测证明 backend 增长不破坏延迟/内存;
  • 迁移到更大 CPU/内存并更新所有 guardrail。

变更前至少证明:

memory worst-case reviewed
shared memory/startup impact reviewed
reserved slots preserved
OS pid/fd limits sufficient
pool multiplication calculated
load test latency/throughput acceptable
rollback and restart plan present

22.2.3 应用池、代理池与数据库预算

三层池的乘法

假设:

application replicas            A
pool max per replica             P
deployment versions/environments D

潜在客户端连接:

Cclient=A×P×D C_{\text{client}} = A \times P \times D

如果每个客户端直连 PostgreSQL,这也是潜在 backend 数。

经过 PgBouncer transaction pool 后,server connection 通常按:

database × user × pool configuration × PgBouncer instance

形成。粗略上限:

Cserverpool keys(default_pool_size+reserve_pool_size) C_{\text{server}} \le \sum_{\text{pool keys}} (default\_pool\_size + reserve\_pool\_size)

还要受:

max_db_connections
max_user_connections
global process/FD capacity
PostgreSQL max_connections

约束。

default_pool_size 是每个 pair,不是全局

本章 PgBouncer:

default_pool_size=50
reserve_pool_size=30
max_db_connections=100
max_user_connections=100

若有:

10 database × 5 user

不能理解成“总共 50 条 PostgreSQL connection”。默认 pool size 会对每个 实际活跃 pair 生效,再被 db/user 上限裁剪。

因此 role 爆炸、database-per-tenant 和多 PgBouncer 实例会改变预算。

一个预算例子

业务:

8 app instances
每实例 max client pool 16
突发 worker 4 × pool 8
后台任务 2 × pool 6

潜在客户端:

8×16 + 4×8 + 2×6 = 172

设计 PgBouncer:

pg36_shop_app server pool        32
pg36_shop_worker server pool      8
pg36_shop_report server pool      4
reserve shared/limited            8

数据库预算:

application active backend       52
monitor/exporter                  8
admin/migration                  10
maintenance/extension            10
incident reserve                 20
other databases                  80
headroom                         20
total                           200

数字必须由压测和生产 profile 校准,但计算结构应先存在。

应用 pool 不应等于线程数

每个 HTTP worker 都持一个数据库连接,常造成:

100 app pods × 20 workers = 2000 client connections

如果每次请求只有 5 ms 数据库时间,真实 active concurrency 可能很低。

应用 pool 应由:

  • 每实例数据库并发;
  • acquire deadline;
  • request fan-out;
  • transaction duration;
  • burst headroom;
  • PgBouncer queue 目标;

决定,而不是由 CPU thread 数机械复制。

多层 timeout 要共同设计

如果:

HTTP deadline           2s
application pool wait   5s
PgBouncer query wait    120s
statement_timeout       0

请求已取消后,数据库工作仍可能运行数十秒甚至无限。预算不是只有连接数, 还包括“连接占用多久”。

更合理的关系:

pool acquire < connect < database work < request deadline

具体顺序会因重试和事务而变化,但下游不能普遍比上游 deadline 更长且不传播 取消。

按 workload 分池

不要让所有动作共享一个无差别 pair:

oltp app
background worker
reporting
migration/admin
monitor

独立 role/pool 可以提供:

  • 不同 pool size;
  • 不同 statement/lock/idle timeout;
  • 不同权限;
  • 不同端点;
  • 不同监控标签;
  • 过载时独立降级。

但 role/pool 太多又会造成 pool multiplication。目标是按失败域与资源合同 分组,不是每个微服务随意造一个 50-connection pool。

预算表

消费者 endpoint client 上限 server 上限 active 目标 queue timeout 备注
shop API primary pooled 128 32 24 200 ms 短事务
worker primary pooled 32 8 6 1 s 可退避
catalog read replica pooled 64 12 8 300 ms 允许陈旧
report offline direct/pool 8 4 2 2 s 长查询限额
migration default direct 2 2 1 fail fast 独立身份
monitor/admin direct/private 10 10 low fail fast 保留槽

client 上限 可以大于 server 上限,差值由有界队列吸收。若 arrival 持续 超过 capacity,队列必须超时/拒绝,不能无限累积。

观察预算是否成立

PostgreSQL:

SELECT usename,
       datname,
       state,
       count(*)
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY usename, datname, state
ORDER BY count(*) DESC;

PgBouncer:

SHOW POOLS;
SHOW STATS;
SHOW DATABASES;
SHOW USERS;

重点列:

cl_active / cl_waiting
sv_active / sv_idle / sv_login
maxwait / maxwait_us
total_xact_count / total_query_count
avg_xact_time / avg_query_time / avg_wait_time

应用:

pool in-use/idle/waiters
acquire latency and timeout
request deadline/cancel
retry count
database call duration

三个视角必须用同一 service/database/user/application_name 对齐。

本节检查表

[ ] 知道 idle backend 仍然有成本
[ ] active transaction 与 connection 数分开观测
[ ] work_mem 按执行节点/worker 而非连接一次估算
[ ] max_connections 保留运维和事故槽
[ ] 用吞吐×服务时间估算 active concurrency 量级
[ ] 应用实例×pool×版本的乘法已计算
[ ] PgBouncer 按 database/user/instance 的乘法已计算
[ ] queue 有上限、超时和指标
[ ] workload 按资源/失败域分池,不过度碎片化
[ ] load test 同时看 throughput、tail latency 和资源

参考资料


上一节:服务端点的语义 · 返回本章目录 · 下一节:PgBouncer 池化模式 · 查看全书目录 · 查看索引中心

22.3 PgBouncer 池化模式

PgBouncer 的核心价值是把:

many client connections

复用到:

fewer PostgreSQL server connections

复用边界越短,利用率通常越高;但 client session 能拥有的 server state 越少。选择 pool mode 本质上是在效率和会话语义之间选合同。

22.3.1 session、transaction、statement pooling

session pooling

client connects
  -> obtains one server connection when needed
      -> keeps it until client disconnects

特点:

  • client session 稳定绑定同一个 PostgreSQL backend;
  • 大部分 PostgreSQL session feature 可用;
  • server connection 复用发生在客户端 session 之间;
  • 大量长连接会长期占住 server slot,即使 idle。

适合:

  • 依赖 session state 的旧应用;
  • LISTEN/NOTIFY
  • session advisory lock;
  • persistent temp table;
  • 无法修改的 driver/tool;
  • 需要逐 session 安全上下文且已经审查。

它减少建连 churn,却不一定显著减少同时 backend 数。

transaction pooling

client transaction begins
  -> borrows one server connection
      -> transaction ends
          -> returns pool

下一个事务可能得到另一个 backend。优点:

  • idle client 不占 PostgreSQL backend;
  • 短事务 workload 复用率高;
  • 可以在 PgBouncer 处排队;
  • 应用连接数与 database active concurrency 解耦。

代价:

  • arbitrary session state 不能视为 client 私有;
  • backend PID 会变化;
  • session-scoped feature 可能错误、泄漏或失效;
  • driver behavior 必须按协议和版本测试。

Pigsty 默认 pgbouncer_poolmode: transaction,本章 exact 运行也是 transaction mode。

statement pooling

one statement
  -> one server connection
      -> immediately returned

它提供最强复用,也最严格:

  • multi-statement transaction 不可作为一般能力;
  • transaction-level state 都难以保留;
  • 许多应用和 driver 不兼容;
  • 显式 BEGIN 通常会被禁止。

除非 workload 真正是独立 statement 且通过完整测试,不应只为追求更少连接 就使用。

模式比较

能力 session transaction statement
client 稳定绑定 backend 事务期间 单 statement
multi-statement transaction 否/受限
idle client 占 server 常见
arbitrary session SET 通常可 不可依赖 不可依赖
session advisory lock 不可依赖 不可
LISTEN 不可依赖 不可
persistent temp table 风险高 不可
server connection 复用 最高
应用兼容成本 中/高

具体能力矩阵必须以当前 PgBouncer feature map 为准,不能把表格跨版本永久化。

pool mode 是接口版本

从 session 改 transaction,不是性能参数微调,而是 API breaking change:

backend identity changes
session state lifetime changes
prepared behavior changes
cancel path changes
security context risk changes

需要:

  1. inventory/配置 diff;
  2. driver/ORM feature inventory;
  3. integration test;
  4. canary;
  5. pool 与 SQL 双层观察;
  6. rollback;
  7. release note。

长事务会抵消事务池

transaction pool 只有在事务短时才有效:

server pool occupancyarrival rate×transaction duration \text{server pool occupancy} \approx \text{arrival rate} \times \text{transaction duration}

如果应用:

BEGIN
call remote API
wait user input
stream large response
COMMIT

它仍长期独占 backend。优化 pool mode 不能替代缩短事务。

22.3.2 临时表、会话 GUC、监听与咨询锁

会话 GUC:状态跟 backend,不跟 client

危险例子:

SET search_path = tenant_42, public;
COMMIT;

SELECT * FROM orders;

transaction pool 中,第二个事务可能:

  • 落到另一 backend,没有 tenant_42
  • 另一 client 借到第一条 backend,继承 tenant_42
  • 与 PgBouncer tracked parameter 行为交互。

本章正式实验强制两个 backend:

client A, backend 65057: SET search_path=pg_catalog; COMMIT
client B, backend 65057: sees pg_catalog
client A, backend 65058: sees "$user", public

两个失败方向都出现:

state loss     A 不能依赖它
state leakage  B 收到它

安全替代:

BEGIN;
SET LOCAL search_path = tenant_42, public;
SELECT ...;
COMMIT;

或:

  • fully qualified object name;
  • 把 context 作为 SQL 参数;
  • 使用 PgBouncer 明确支持/跟踪的 startup parameter;
  • 为必须 session state 的工作使用 session/direct endpoint。

SET LOCAL 生命周期被限制在事务内,与 transaction pooling 边界一致。

server_reset_query 不能想当然

本章 PgBouncer 配置:

server_reset_query=DISCARD ALL
server_reset_query_always=0
pool_mode=transaction

看到 DISCARD ALL 不能立刻得出“每个 transaction 后一定清理”。具体执行 条件与 pool mode 受 PgBouncer 配置语义约束。本章故意用实验验证,而不是 从配置名推断。

若将 server_reset_query_always=1 作为补救,还要评估:

  • 每事务额外成本;
  • prepared statement 与 cache;
  • extension/session cleanup;
  • 是否真正覆盖所有业务状态;
  • 当前版本行为。

更安全的原则仍是:不要跨事务依赖未声明的 server session state。

临时表

PostgreSQL temporary table 通常属于 session:

CREATE TEMP TABLE staged (...);
INSERT INTO staged ...;
COMMIT;
SELECT * FROM staged;

transaction pool 中后续事务可能到另一 backend,表不存在;另一 client 也 可能得到保留该 temp schema 的 backend。

可选策略:

  • 在一个显式事务内创建、使用并 ON COMMIT DROP
  • 使用普通 staging table + run/tenant key + 权限/清理;
  • 选择 session/direct endpoint;
  • 把计算改成 CTE、unnest、COPY 到受控表;
  • 对 driver/ORM 的隐式 temp table 做集成测试。

即使 ON COMMIT PRESERVE ROWS 在 feature map 中有特定支持描述,也不要把 “某些操作可工作”升级成“temp session semantics 完整保留”。

LISTEN/NOTIFY

LISTEN channel 注册在 PostgreSQL session。transaction pool 释放 backend 后,client 不再稳定拥有那个 registration。

消费者应使用:

  • session pooling;
  • direct endpoint;
  • 专用少量连接;
  • reconnect 后重新 LISTEN
  • 通知丢失后的 durable catch-up。

NOTIFY 不是持久消息队列。断连与切换时要从表/outbox/offset 补齐。

咨询锁

区分:

pg_advisory_lock(...)       -- session level
pg_advisory_xact_lock(...)  -- transaction level

事务池中,优先使用 transaction-level lock,并让整个受保护动作处于同一事务。

session lock 的危险:

client A acquires on backend X
transaction ends, X returns pool
client B gets X and inherits lock ownership
client A gets backend Y and cannot reliably unlock X

部署工具常用 session advisory lock 保证单实例迁移;这类工具应走直连管理 端点,不能在没有验证时经过 transaction pool。

cursor、portal 与 COPY

一般原则:

只要协议对象必须跨 transaction 存活,就怀疑 transaction pooling
  • WITH HOLD cursor 跨事务;
  • 某些 ORM server-side cursor;
  • streaming result;
  • COPY 双向协议;
  • replication protocol;

都要按具体 driver/PgBouncer 版本测试。不要只看 SQL 文本。

安全上下文

尤其危险:

SET app.tenant_id = '42';
SET ROLE tenant_role;

若 RLS policy 或函数依赖这些 session GUC,而 transaction pool 没有可靠 设置/清理,可能形成跨租户泄漏。

更安全:

BEGIN;
SET LOCAL app.tenant_id = '42';
SET LOCAL ROLE tenant_role;
... all protected queries ...
COMMIT;

并:

  • deny-by-default policy;
  • 每事务显式设置;
  • missing/invalid context 立即失败;
  • 注入 backend reassignment 测试;
  • pool 与 security review 联动。

第 23 章会深入该问题。

22.3.3 预备语句支持必须绑定 PgBouncer 与驱动版本

为什么旧结论互相矛盾

常见说法:

transaction pooling 不支持 prepared statements

另一种新说法:

PgBouncer 已支持 prepared statements

两句都过度概括。至少要区分:

protocol-level named prepared statement
SQL text PREPARE / EXECUTE / DEALLOCATE
unnamed statement
client-side statement cache
driver emulation/simple protocol

协议级 prepared statement

PostgreSQL extended query protocol 使用:

Parse -> Bind -> Execute

现代 PgBouncer 在:

max_prepared_statements > 0

时可以跟踪/重写协议级 prepared statement,并在 client 换 backend 时准备 对应 server statement。

但结论必须绑定:

  • PgBouncer version;
  • max_prepared_statements
  • driver version;
  • driver prepare threshold/cache;
  • query string identity;
  • pool mode;
  • failover/reconnect;
  • ORM query mode。

本章 exact matrix:

PgBouncer                   1.25.2
max_prepared_statements     256
psycopg                     3.2.9
prepare_threshold           1
pool mode                   transaction
iterations                  12
server backends             2
correct results             12

实验先在 backend 65171 建立协议 prepared 状态,再占住它,让同一 client 去 65172。所有结果仍正确。这只接受该版本组合。

SQL PREPARE

PREPARE add_one(integer) AS SELECT $1 + 1;
COMMIT;
EXECUTE add_one(41);

这些是普通 SQL text。PgBouncer 不按协议 prepared statement 的方式重写 它们。名称只存在于创建它的 PostgreSQL session。

正式实验:

PREPARE backend       65285
another client holds  65285
EXECUTE backend       65286
result                InvalidSqlStatementName
SQLSTATE              26000

这是正确的负面结果。不能因为协议级测试通过,就允许 SQL PREPARE 跨 transaction。

driver 可能悄悄改变协议

需要检查:

  • simple vs extended query;
  • auto prepare threshold;
  • named vs unnamed statements;
  • statement cache size/lifetime;
  • pooler compatibility option;
  • binary parameter/result;
  • multi-statement batch;
  • connection reset hook。

升级 driver 或 PgBouncer 后,应把同一 compatibility suite 重跑。版本说明 不能替应用自己的 query shape。

prepared statement 的容量成本

max_prepared_statements 也不是免费开关。PgBouncer 要维护映射,PostgreSQL backend 要保存 prepared plan。大量唯一 SQL text、动态注释或 query literal 可能造成:

  • mapping/cache 增长;
  • server-side prepared statement 增长;
  • deallocation churn;
  • generic/custom plan 行为变化;
  • schema change 后 invalidation。

监控并限制 query shape,参数化而不是把值拼进 SQL。

一个兼容性测试矩阵

driver versions       current, previous, candidate
pool mode             direct, session, transaction
prepare behavior      disabled, threshold, forced
query types           scalar, array, COPY, cursor, batch
role change           reconnect, planned switch
schema change         invalidate/reprepare
error cases           timeout, cancel, backend close

输出应写“这个矩阵通过”,而不是“prepared statements 支持”。

22.3.4 池等待、服务时间与背压

SHOW POOLS 是瞬时状态

关键列:

cl_active    正在使用/等待 server 的 client
cl_waiting   等待分配 server 的 client
sv_active    正在服务 client 的 server connection
sv_idle      可立即借出的 server connection
sv_login     正在建立的 server connection
maxwait      最老等待者等待时间
pool_mode    当前 pool 模式

采样一次 cl_waiting=0 不证明没有排队。要:

  • 周期采样;
  • 导出 Prometheus 指标;
  • 记录 acquire/wait histogram;
  • 与应用和 PostgreSQL active backend 对齐。

本章两槽实验

配置在第一个 test/test 客户端连接前临时变为:

default_pool_size=2
reserve_pool_size=0
query_wait_timeout=5

12 个 client 同时执行:

SELECT pg_sleep(0.25), pg_backend_pid();

结果:

completed clients              12
unique backend PIDs             2
maximum sv_active               2
maximum cl_waiting             10
minimum duration          254.451 ms
maximum duration         1519.423 ms

最慢请求大约经历 6 个 250 ms 服务批次。它说明 pool 正在做有界排队,不是 性能 SLO;SSH 采样、调度和连接开销也包含在时间里。

queueing latency

当:

arrival rate < sustainable service rate

短 burst 可以排队后恢复。

当:

arrival rate >= service rate for long enough

队列长度和延迟持续增长。必须:

  • 超时;
  • 拒绝;
  • 降级;
  • 限流;
  • 减少工作;
  • 或增加经过验证的容量。

不能靠无限 max_client_conn 吸收持续过载。

query_wait_timeout

PgBouncer 的 query_wait_timeout 限制 client 等待 server connection 的 时间。超时会断开 client,从而:

  • 释放无限排队;
  • 给应用一个可观察失败;
  • 迫使请求遵守 deadline。

它要小于业务还能接受的剩余 deadline,并与应用 acquire timeout 协调。

过小:

健康短 burst 也被拒绝

过大:

过期请求占队列
上游已经取消,下游还在等
恢复时形成陈旧洪峰

reserve pool

reserve_pool_size 允许等待超过 reserve_pool_timeout 后额外建立 server connection。它适合有限 burst headroom,不是永久绕过预算。

要问:

  • reserve 乘以多少 database/user pair;
  • 多个 PgBouncer instance 的总和;
  • PostgreSQL 是否仍有保留槽;
  • burst 激活时 CPU/I/O 是否安全;
  • reserve 使用是否告警。

背压应向上游传播

一个健康链条:

PgBouncer wait grows
  -> app pool acquire grows
      -> concurrency limiter rejects optional work
          -> HTTP returns retryable overload
              -> client uses bounded jitter/backoff

一个危险链条:

pool timeout
  -> immediate retry × N layers
      -> reconnect storm
          -> more auth/backend pressure
              -> longer timeout

第 22.5 节会把 timeout、breaker 和负载削减放进同一控制面。

恢复配置

实验使用 finally

snapshot 50 / 30 / 1 / 120
override 2 / 0 / 1 / 5
run probes
restore  50 / 30 / 1 / 120
verify exact equality
only then allow switchover

恢复失败是 stop condition,不能“继续看看切换会怎样”。生产变更应通过 Pigsty 声明管理,本章 runtime override 只为低噪声教学实验。

本节检查表

[ ] pool mode 作为接口版本管理
[ ] 长事务不会长期占满 transaction pool
[ ] session GUC 使用 SET LOCAL 或专用端点
[ ] temp table/LISTEN/advisory lock/cursor 逐项盘点
[ ] RLS/tenant context 做 backend reassignment 测试
[ ] prepared 结论绑定 PgBouncer、driver 和配置版本
[ ] SQL PREPARE 与 protocol prepare 分开测试
[ ] cl_waiting、sv_active、maxwait 有指标
[ ] query_wait_timeout 与 request deadline 对齐
[ ] reserve pool 纳入全局预算
[ ] 配置实验有 exact rollback 和 verification

参考资料


上一节:连接的服务端成本 · 返回本章目录 · 下一节:路由与故障切换 · 查看全书目录 · 查看索引中心

22.4 路由与故障切换

高可用控制面决定“谁应当是主库”,服务接入层决定“客户端新连接实际去了 哪里”。二者相关,却不是同一个状态机:

Patroni/DCS       membership, leader, promotion/demotion
HAProxy           health sample, eligible backend, TCP lifecycle
PgBouncer         client/server pool, authentication, session/protocol state
client pool       cached address, existing socket, retry/backoff
PostgreSQL        transaction, commit, recovery/read-only state

故障切换必须让这五层收敛。只看到 Patroni leader 变化,不能宣告应用恢复。

22.4.1 HAProxy 健康检查与角色判断

角色健康接口

Patroni REST API 可以按当前角色返回健康状态。Pigsty 默认 HAProxy service 使用 HTTP health check:

/primary    只选择当前 primary
/replica    选择 replica
/read-only  接受可提供只读的成员

本章渲染配置的核心形态:

listen pg-test-primary
    bind *:5433
    mode tcp
    option httpchk
    http-check send meth OPTIONS uri /primary
    http-check expect status 200
    server pg-test-1 10.10.10.11:6432 check port 8008
    server pg-test-2 10.10.10.12:6432 check port 8008
    server pg-test-3 10.10.10.13:6432 check port 8008

数据流是 TCP 到 6432,健康检查却到 Patroni 8008。不要把 destination port 与 check port 混为一谈。

“UP”表示什么

对这种配置,HAProxy backend UP 表示:

最近若干次 Patroni HTTP 检查满足期望状态

它不直接证明:

  • PostgreSQL 对业务 role 的认证成功;
  • PgBouncer userlist/auth query 正确;
  • pool 有可用 server slot;
  • session 角色属性满足客户端;
  • 数据足够新;
  • SQL 可以提交;
  • TLS hostname/CA 正确。

因此需要 SQL 端到端 probe。

检查节奏和抖动

本章实际 default-server:

inter=2s
fastinter=1s
downinter=2s
rise=3
fall=3
timeout check=3s
slowstart=30s
maxconn=3000
maxqueue=128
on-marked-down shutdown-sessions

粗略地,状态发现时间受:

[ T_{\text{detect}} \approx \text{interval} \times \text{rise/fall}

  • \text{request/timeout jitter} ]

但切换总时间还包括:

Patroni decision/promotion
HAProxy next health samples
old connection close
PgBouncer backend refresh
client reconnect/backoff
application readiness

不能从 fall=3, inter=2s 单独推导应用 RTO。

shutdown-sessions

当 backend 被标记 down,HAProxy 可以关闭其活动 session。这会让客户端尽快 离开旧主库,避免长连接继续停留在错误角色。

代价是:

  • in-flight transaction 中断;
  • client 收到连接错误;
  • commit 结果可能未知;
  • reconnect 同时发生;
  • cancel/cleanup 不一定完成。

它是故障收敛手段,不是透明迁移。

backup 不是注释

本章 replica service:

pg-test-2, pg-test-3 normal
pg-test-1 primary backup

offline service:

pg-test-3 offline normal
pg-test-2 ordinary replica backup

当 normal backend 不可用时,backup 改变 workload 去向。因此降级时可能:

  • 只读流量回到 primary;
  • 分析流量回到普通 replica;
  • 原有容量隔离消失。

告警不能只说“服务仍然可用”,还要指出“服务正在使用备用后端”。

健康检查本身也要保护

检查太宽松:

TCP 端口开 -> 误把错误角色当健康

检查太严格:

非关键依赖抖动 -> 反复摘除健康数据库

角色服务应检查决定路由所需的最小语义。业务 readiness 可以另有端点,避免 把每个业务依赖都塞进数据库角色检查。

从 HAProxy stats 取证

可以通过受控 stats socket/API 观察:

pxname / svname
status
check_status / check_code
lastchg
queue/current sessions
selected backup

不要把 stats credential 或完整配置中的密码写进证据。正式实验只投影服务、 地址、端口、健康路径、backup flag 和安全参数。

22.4.2 旧连接、重连风暴与客户端退避

新连接路由不迁移旧连接

HAProxy 更新 backend 选择后:

new connections -> new eligible backend
existing TCP     -> old backend until closed

旧主库 demote/restart、HAProxy shutdown session 或 PostgreSQL close 才会让 旧连接离开。应用 pool 可能继续把坏 socket 发给请求,直到 validation 或 query 暴露错误。

因此 client pool 需要:

  • borrow 前/失败后的 connection validation;
  • 最大 connection lifetime;
  • idle timeout;
  • broken connection eviction;
  • address/DNS refresh;
  • pool warmup 限速;
  • 切换后的 error budget。

reconnect storm

假设 200 个 app instance,每个 pool 20:

role change
  -> 4000 sockets fail
      -> every worker immediately reconnects
          -> proxy accept/auth/server-login storm

即使数据库已恢复,风暴也可能把它再次压垮。

客户端退避:

dn=min(dmax,d02n)×J d_n = \min(d_{\max}, d_0 2^n) \times J

其中 JJ 是随机 jitter,例如 [0.75, 1.25]

还要:

  • overall request deadline;
  • 最大尝试次数;
  • 每 host connect timeout;
  • circuit breaker;
  • global concurrency limiter;
  • retry budget;
  • readiness 与 background reconnect 分离。

什么可以重试

读:

  • 幂等 SELECT 通常可在新 transaction 重试;
  • 仍要考虑 snapshot、timeout 和 side-effect function。

写:

连接前失败
  较可能没有发送,但仍按 driver stage 判断

执行中/commit 时断开
  结果未知:可能提交,也可能没有

不能把所有 connection error 当作“未提交”,否则重试可能重复扣款、下单或 发券。

安全模式:

INSERT INTO request_log(idempotency_key, ...)
VALUES ($1, ...)
ON CONFLICT (idempotency_key)
DO UPDATE ...
RETURNING ...;

断连后用同一个 token 查询。重试同一个业务动作时,唯一性与状态机必须使其 幂等;不要生成新 token 假装是新动作。

本章 client probe

六个 worker:

short connection per attempt
target_session_attrs=read-write
unique token per logical attempt
0.15 s normal interval
exponential backoff + jitter on failure
24 s total

每个 event 记录:

worker/attempt/token
monotonic start/end
acknowledged or unknown
error class + SQLSTATE, no credential/message
backend/postmaster identity for acknowledged

失败 token 不盲目重发。最终统一查询数据库:

acknowledged_missing
unknown_committed
unknown_absent
duplicate_tokens
unreconciled_unknown

正式结果:

events                  387
acknowledged            339
unknown                  48
unknown committed         0
unknown absent           48
acknowledged missing      0
duplicates                0
unreconciled              0

“48 个 unknown 都 absent”是事后 lookup 结果,不应在异常发生瞬间假设。

probe 不是生产 driver

它使用 Psycopg、短连接和 synthetic INSERT。生产应用还要按实际:

  • driver;
  • pool;
  • ORM;
  • transaction wrapper;
  • retry middleware;
  • service mesh;
  • load balancer;
  • request deadline;

运行矩阵。否则 middleware 可能在你不知道的地方二次重试。

22.4.3 故障切换中的 DNS、连接池和事务失败

一次切换的状态序列

t0   old primary serves writes
t1   switchover/failure decision
t2   old connections interrupted or rejected
t3   candidate promotes
t4   Patroni topology converges
t5   HAProxy health converges
t6   PgBouncer server pool reflects new role
t7   client reconnects after backoff
t8   first acknowledged business write

应用恢复点是 t8,不是 t3

DNS 不处理 transaction

即使 DNS 立即指向新入口:

  • 已解析地址仍在 client cache;
  • 已建立 socket 不重新解析;
  • pool 可能只在耗尽时建新连接;
  • in-flight transaction 已经失败;
  • old primary commit outcome 仍未知。

DNS 是 discovery 层,不是 transaction continuity。

PgBouncer role-state refresh

本章发现:

Patroni              replica/running
PostgreSQL direct    recovery=true, transaction_read_only=true
HAProxy health       backend UP
PgBouncer path       target_session_attrs rejects

对具体 database 执行:

RECONNECT test;

后,新的 server connection 重新发现当前角色,连续属性检查恢复。

正式切换因此在每次 topology 稳定后:

  1. 对三台 PgBouncer 发 RECONNECT test
  2. 等待每条旧 server connection 在安全边界关闭/重建;
  3. 要求 client probe 出现一次新的 acknowledged write;
  4. 把这段时间计入 conservative write gap。

不要把它泛化成“任何切换都必须手工 RECONNECT”。正确结论是:

角色切换后必须端到端验证 pooled path;若 pool 保留错误角色/协议状态, 应有受控、可观测的 refresh 机制。

自动 callback、PgBouncer restart、database reconnect 或 connection lifetime 各有不同 blast radius,需按平台设计。

pool refresh 的风险

RECONNECT database

  • 让对应 database 的 server connection 在释放后重新连接;
  • 不应误操作所有 database;
  • 会增加短时 server login/auth;
  • 可能让等待者暂时增加;
  • 要与 client backoff 协同;
  • 多 PgBouncer 实例必须全覆盖。

正式实验只操作 sandbox test,并记录三成员 action 与刷新后的首笔确认。

正向和回切证据

initial   pg-test-1, timeline 9
forward   pg-test-2, timeline 10
restored  pg-test-1, timeline 11

forward patronictl command       2.774 s
restore patronictl command       2.766 s
forward conservative write gap   6.995 s
restore conservative write gap   8.510 s

为何 command time 小于 write gap:

command return
  != all replicas streaming
  != HAProxy health converged
  != pool refreshed
  != client backoff ended
  != first write committed

8.510 秒是一个 sandbox observation,不是 RTO。

planned switch 不能证明 unplanned failover

本章没有注入:

  • primary process crash;
  • host power loss;
  • network partition;
  • DCS loss;
  • storage stall;
  • split brain;
  • watchdog/fencing failure;
  • proxy entry failure。

planned switch 知道 leader/candidate,成员健康且可协调。unplanned failure 的 检测、仲裁、RPO 和 fencing 风险完全不同。

故障时的 transaction 分类

client 观察 可安全推出 不能推出
connect refused 此次连接未建立 前一请求未提交
read-only error session 角色不满足 集群没有主库
serialization/deadlock 当前事务回滚 可无界立即重试
connection lost during query 结果未知 一定未执行
connection lost during COMMIT 结果未知 一定提交/未提交
unique token already exists 同 token 有结果 业务 payload 必然一致

token 表还要验证 payload hash/状态,防止同 key 被不同请求误用。

切换验收不变量

topology:
  exactly one primary
  expected member set
  replicas streaming
  timeline advances

service:
  write endpoint read-write
  read endpoint read-only/allowed member
  direct endpoint follows primary
  pool state refreshed/observable

client:
  acknowledged rows present
  unknown reconciled
  duplicates zero
  retry/backoff bounded

restore:
  final intended leader
  pool configuration exact
  postflight baseline passes

任何一层失败都不该被“Patroni 已正常”覆盖。

旧主库恢复后的流量

旧主库变成 replica 后:

  • primary health 应摘除它;
  • replica/offline 策略可能纳入它;
  • 原 server connection/session 必须重新评估角色;
  • replay lag 要回到门槛;
  • connection storm 不应阻塞 rewind/rejoin;
  • 监控要区分新的 timeline。

回切会再经历一次完整过程,不是把 timeline 倒回去。本章 9 -> 10 -> 11 证明每次 promotion 都生成新 timeline。

本节检查表

[ ] HAProxy destination 和 Patroni check port 分开理解
[ ] backend UP 不被当成业务 SQL 可用
[ ] rise/fall/interval 不被直接冒充应用 RTO
[ ] backup backend 激活有告警
[ ] old TCP connection 行为已定义
[ ] reconnect 有 jitter、上限、deadline 和 retry budget
[ ] 写入结果未知用 token reconcile
[ ] 角色变化后验证 pooled session 属性
[ ] pool refresh 覆盖所有实例且有首笔恢复证据
[ ] planned 与 unplanned 结论严格分开
[ ] 回切被当成第二次 promotion/timeline

参考资料


上一节:PgBouncer 池化模式 · 返回本章目录 · 下一节:连接预算与过载边界 · 查看全书目录 · 查看索引中心

22.5 连接预算与过载边界

连接治理的目标不是让所有请求最终都能排到数据库,而是:

正常负载低延迟通过
短 burst 在便宜位置有限排队
持续过载尽早拒绝或降级
高价值与控制流保留能力
取消与 deadline 向下传播
恢复时不产生重试洪峰

这需要同时预算 connection、active transaction、queue length、等待时间和 重试。只设置一个 max_connections 没有形成过载边界。

22.5.1 按服务分配连接、并发与队列

先分服务,再分数字

一个数据库集群常同时承担:

critical OLTP writes
interactive reads
background jobs
reporting/ETL
migration/admin
monitoring/backup/control

它们的价值、服务时间和失败策略不同。共享一个 pool 意味着:

报表占满 backend
  -> 下单请求排队
      -> health/readiness 也超时
          -> orchestration 重启更多实例
              -> reconnect storm

按服务分配至少包括:

维度 问题
client connections 应用能保持多少逻辑连接
server connections 最多占多少 PostgreSQL backend
active concurrency 同时执行多少事务/查询
queue length/time 多久后拒绝,最多积压多少
resource CPU、I/O、work_mem、temp、lock
priority 过载时谁先被削减
reserve 谁能在拥塞时进入控制面

connection budget 不是 concurrency budget

transaction pool 可以有:

1000 client connections
40 server connections
20 typical active transactions

三者都合理,只要:

  • client process/FD/TLS 成本可承受;
  • server pool 不超过数据库预算;
  • active workload 经压测;
  • 960 个等待者不会无限停留;
  • deadline 和拒绝策略有效。

把所有实例相加

预算必须按全局 deployment:

blue version
green version
autoscaling maximum
cron workers
manual jobs
disaster-recovery warm instance

例如:

normal 20 pods × pool 8     = 160 clients
deploy overlap another 20   = 160
autoscale headroom 10       =  80
workers 8 × pool 4          =  32
total potential             = 432

若 PgBouncer server pool 是 32,客户端能排队;若直连,则一次 blue/green 发布就可能把 backend 翻倍。

database/user pair 的隔离

PgBouncer pool key 通常包含 database 与 user。可以用:

pg36_shop_app
pg36_shop_worker
pg36_shop_report
pg36_shop_migrate

建立不同预算与权限。

不要无界创建 role:

100 tenants × default_pool_size 20

即使每个 pair 很小,乘法也可能超过 PostgreSQL。可通过:

  • max_db_connections
  • max_user_connections
  • per-database/user override;
  • group role + application context;
  • tenant 分片;
  • idle pool cleanup;

控制。

负载并发门槛

连接池限制的是 backend 数,不直接限制一条连接里 query 的资源。还需要:

  • application concurrency semaphore;
  • job worker count;
  • query governor;
  • statement timeout;
  • role/database resource policy;
  • workload isolation/offline replica。

例如 8 条并行 hash join 可能比 32 条短索引查询更重。server pool size 要按 workload mix 压测。

queue 的接受条件

定义:

Q_max      最大等待者
W_max      最大等待时间
λ          到达率
μ          每 server slot 服务率
c          server slots

长期稳定至少要求:

λ<cμ \lambda < c\mu

否则任何有限 queue 最终都会满。

短 burst 的近似吸收能力:

Qneededburstmax(0,λ(t)cμ(t))dt Q_{\text{needed}} \approx \int_{\text{burst}} \max(0,\lambda(t)-c\mu(t))dt

这不是精确 queueing model,但迫使团队问“burst 多大、持续多久”,而不是把 max queue 随手设成 10,000。

Pigsty/HAProxy/PgBouncer 三处上限

本章渲染配置含:

HAProxy service maxconn          5000
HAProxy per backend maxconn      3000
HAProxy per backend maxqueue      128
PgBouncer max_client_conn       20000
PgBouncer default_pool_size        50
PgBouncer reserve_pool_size        30
PgBouncer max_db_connections      100
PgBouncer max_user_connections    100
PostgreSQL max_connections         500

大数字不表示应使用到它。真正的有效边界是这些限制、活跃 pair 和所有实例 总和的组合。

如果 HAProxy 允许 5000、PgBouncer 允许 20000,而应用 deadline 2 秒, 仍应在更早层用 application concurrency/query wait timeout 拒绝过期工作。

一份分配账本

cluster_backend_ceiling: 500
reserved:
  superuser: 10
  platform_admin_monitor: 40
  incident_control: 20
  maintenance_replication: 30
allocatable_workload: 400
services:
  shop_rw:
    server_pool: 40
    reserve: 8
    active_target: 24
    queue_timeout: 200ms
  shop_ro:
    server_pool_per_replica: 20
    active_target: 12
    queue_timeout: 300ms
  report:
    server_pool: 4
    active_target: 2
    statement_timeout: 10min
  migration:
    direct_connections: 2
headroom:
  unallocated: 100

headroom 不是浪费,而是吸收估算误差、maintenance 和 incident action。

22.5.2 超时层级、取消传播与熔断

一次请求经过多个时钟

user deadline
HTTP/RPC deadline
application pool acquire timeout
DNS/connect/TLS timeout
HAProxy queue/connect timeout
PgBouncer query_wait_timeout
PostgreSQL lock_timeout
PostgreSQL statement_timeout
idle_in_transaction_session_timeout
client read/write socket timeout

若各自独立设置,会出现:

上游 2 秒取消
下游 pool 还等 120 秒
SQL 继续跑 10 分钟
失败后 middleware 再重试

资源在用户离开后继续消耗。

deadline budget

把请求总预算 DD 分解:

D=Tacquire+Tconnect+Tqueue+Tlock+Texecute+Treturn+Tmargin D = T_{\text{acquire}} +T_{\text{connect}} +T_{\text{queue}} +T_{\text{lock}} +T_{\text{execute}} +T_{\text{return}} +T_{\text{margin}}

各层不一定串行,有些重叠;这个式子是设计账本,不是精确 profiler。

例如 2 秒交互请求:

app acquire             150 ms
connect/role check      300 ms
database statement     1200 ms
return/margin           350 ms

PgBouncer query_wait_timeout=120s 显然不匹配该请求。可以按服务 override, 或让应用 acquire/overall deadline 更早终止并正确 cancel。

connect timeout 不是 failover timeout

多 host 串行尝试时:

[ T_{\text{connect worst}} \approx \sum_{\text{host}} T_{\text{connect host}}

  • DNS/TLS/backoff ]

三个 host × 5 秒可能已经超过 request deadline。驱动是否并行尝试、每 host 还是全局 timeout,要按版本确认。

statement_timeout

PostgreSQL 从收到命令开始计时;达到后取消当前 statement。它不一定包含:

  • 应用 pool 等待;
  • PgBouncer queue;
  • DNS/TCP/TLS;
  • 客户端处理结果;
  • 上一次 idle transaction。

按 role/database 设置比一个全局值更实用:

ALTER ROLE pg36_shop_app IN DATABASE pg36_shop
  SET statement_timeout = '3s';

ALTER ROLE pg36_shop_report IN DATABASE pg36_shop
  SET statement_timeout = '10min';

事务级可以:

BEGIN;
SET LOCAL statement_timeout = '800ms';
...
COMMIT;

lock_timeout

它只限制等待 lock 的时间,不限制 query 总执行时间。

DDL/migration 常用:

SET lock_timeout = '1s';
SET statement_timeout = '30min';

意图是:

拿不到锁快速失败,不在生产长队列中等待;
拿到锁后允许受控操作运行。

不要把 lock timeout 设置得比 statement timeout 更长而期待它生效。

idle transaction timeout

idle_in_transaction_session_timeout

切断已开始事务但长期不发 query 的 session。它是 vacuum/lock 保护,不应拿来 清理普通 idle pool connection。

还有普通 idle_session_timeout,但连接池可能把 idle connection 视为资产; 使用前要评估 reconnect churn 和 middleware 兼容。

取消传播

客户端取消 PostgreSQL query 通常需要单独 cancel request/connection path。 经过 pool/proxy 时要验证:

  • cancel 能定位正确 backend;
  • client 已换 backend 后不会 cancel 别人;
  • proxy 是否转发;
  • timeout 后 transaction 是否处于 aborted;
  • connection 是否应丢弃;
  • server query 是否确实停止。

不能只看到 HTTP 499/timeout 就认为数据库工作结束。

熔断器

breaker 保护的是下游与自身:

closed     正常请求
open       快速拒绝,停止放大故障
half-open  少量探针判断恢复

触发信号应比“任意 SQL error”精细:

  • pool acquire timeout;
  • connection/role check failure;
  • sustained queue;
  • downstream saturation;
  • known infrastructure outage。

不要因业务约束错误、syntax error 或唯一冲突打开数据库 breaker。

retry budget

如果原始请求率为 λ\lambda,平均重试 rr 次:

λdownstream=λ(1+r) \lambda_{\text{downstream}} = \lambda(1+r)

故障时 r 往往上升,正好放大最脆弱的下游。为服务定义:

maximum retries per request
maximum retry traffic percentage
backoff+jitter
non-retryable SQLSTATE
outcome-unknown reconciliation

load shedding

在数据库彻底饱和前:

  1. 拒绝可选报表/推荐;
  2. 降低 background worker;
  3. 使用缓存/较旧副本;
  4. 限制 expensive endpoint;
  5. 保留写入/控制面;
  6. 必要时进入只读或功能降级。

load shedding 是业务决策,不应完全交给随机 connection timeout。

22.5.3 为 ch34 的止血动作预留控制点

第 34 章会处理连接风暴、CPU、内存、磁盘和 I/O 过载。本章要提前提供可用 控制点,否则事故中只能粗暴重启。

控制点一:客户端并发

feature flag
rate limiter
per-tenant quota
worker concurrency
queue consumer pause
autoscaler maximum
retry switch

优点:最接近业务价值,能在请求进入数据库前止血。

控制点二:应用 pool

max size
min idle
acquire timeout
connection lifetime
idle timeout
warmup rate
validation

动态缩 pool 可能只影响新借用,不能立即终止 in-flight transaction。变更行为 按具体 driver 验证。

控制点三:PgBouncer

管理 console 可观察/控制:

SHOW POOLS / STATS / CLIENTS / SERVERS
PAUSE / RESUME
DISABLE / ENABLE
RECONNECT
RELOAD
SET changeable_setting
KILL database

这些动作风险不同:

  • PAUSE 等待 server connection 释放,可用于维护;
  • RECONNECT database 刷新 server connection;
  • KILL 更具破坏性;
  • runtime SET 会形成声明漂移;
  • global action blast radius 可能跨服务。

必须有 exact database/instance/role guard 和复位证据。

控制点四:HAProxy

disable/enable server
backend weight
maxconn/maxqueue
drain
health threshold
service removal

把流量移到副本/其他主库前,先确认目标容量与一致性。转移过载往往只是移动 事故。

控制点五:PostgreSQL role/database

ALTER ROLE ... CONNECTION LIMIT ...;
ALTER DATABASE ... ALLOW_CONNECTIONS false;
ALTER ROLE ... SET statement_timeout ...;
ALTER ROLE ... SET work_mem ...;
REVOKE CONNECT ...;
SELECT pg_cancel_backend(...);
SELECT pg_terminate_backend(...);

其中 revoke/terminate 可能影响业务或锁,属于受控事故动作。不要把示例 SQL 做成无 guard 的一键脚本。

控制点六:保留管理路径

过载时最怕 DBA 也连不进去。预留:

  • superuser/reserved connection;
  • 独立 admin role;
  • direct endpoint;
  • source network allowlist;
  • 小而独立的 admin pool;
  • break-glass credential;
  • out-of-band host access。

监控 exporter 也不应完全依赖已满的普通业务池。

每个控制点都要有回滚

事故动作表:

动作 目的 成功证据 副作用 复位
降 worker 减少 arrival queue/DB active 降 backlog 增 分阶段恢复
缩 app pool 限制 client acquire wait 可控 request reject 恢复声明
pool PAUSE 维护/切换 server released client wait RESUME
RECONNECT db 刷新 backend role/path probe 通过 login burst 无持久配置
HAProxy drain 摘除 backend sessions 收敛 容量下降 enable/weight
cancel query 释放资源 query 消失 事务 abort 应用重试
terminate 强制止血 backend 结束 outcome unknown reconcile

“止血成功”不等于“事故解决”。必须保留证据、找根因和复位。

最小过载仪表盘

应用:

request rate/error/latency
pool waiters/acquire timeout
retry rate
in-flight by endpoint

PgBouncer:

cl_active/cl_waiting
sv_active/sv_idle
maxwait
login/error

PostgreSQL:

active/idle in transaction
wait events/locks
CPU/run queue
I/O latency
temp bytes
WAL/replication lag

代理:

backend status/backup use
current sessions/queue
connect errors

端到端 correlation 必须有 service、database、user、application_name 和时间。

本节检查表

[ ] service 按价值、服务时间和失败策略分预算
[ ] client/server/active/queue 四种上限分开
[ ] blue-green/autoscale/worker 乘法已计入
[ ] sustained overload 有拒绝而非无限排队
[ ] timeout 形成一份 deadline budget
[ ] cancel 到 PostgreSQL backend 已实测
[ ] retry 有 SQLSTATE 分类、budget 和 jitter
[ ] optional workload 能独立 shed
[ ] admin/monitor 有保留连接与直连路径
[ ] PgBouncer/HAProxy/PostgreSQL 控制动作有 guard 与复位
[ ] ch34 可以复用这些控制点,而不是事故时发明

参考资料


上一节:路由与故障切换 · 返回本章目录 · 下一节:Pigsty 服务接入层 · 查看全书目录 · 查看索引中心

22.6 Pigsty 服务接入层

Pigsty 不发明 PostgreSQL 的主库、副本或 session 语义。它把:

inventory intent
  -> Patroni role API
      -> HAProxy service
          -> PgBouncer/PostgreSQL destination
              -> DNS/VIP/client service material
                  -> metrics and administration

组合成可交付实现。

理解 Pigsty 服务层的关键不是记命令,而是能把任何观察反向映射到原生组件。

22.6.1 服务定义、角色选择与端口

默认变量

本章参考实现的关键声明形态:

pgbouncer_enabled: true
pgbouncer_port: 6432
pgbouncer_poolmode: transaction
pgbouncer_sslmode: disable

pg_service_provider: ''
pg_default_service_dest: pgbouncer
pg_default_services:
  - { name: primary, port: 5433, dest: default,
      check: /primary, selector: "[]" }
  - { name: replica, port: 5434, dest: default,
      check: /read-only, selector: "[]",
      backup: "[? pg_role == `primary` || pg_role == `offline` ]" }
  - { name: default, port: 5436, dest: postgres,
      check: /primary, selector: "[]" }
  - { name: offline, port: 5438, dest: postgres,
      check: /replica,
      selector: "[? pg_role == `offline` || pg_offline_query ]",
      backup: "[? pg_role == `replica` && !pg_offline_query]" }

版本和自定义配置可能不同。读取实际 inventory、role defaults 与 rendered file,不要把这段当成跨版本常量。

dest

服务 destination 可以表达:

default     使用 pg_default_service_dest
postgres    PostgreSQL pg_port,常见 5432
pgbouncer   PgBouncer pgbouncer_port,常见 6432
number      指定端口

因此:

primary/replica dest=default

在本章 pg_default_service_dest=pgbouncer 时走连接池;如果用户改成 postgres,同一个 5433/5434 就绕过池。

端口名不能替实际路径。

check

check 是 Patroni REST health path:

/primary
/replica
/read-only

HAProxy 对成员的 8008 检查,数据流则去 dest。角色判断来自 Patroni, 不是 HAProxy 解析 PostgreSQL protocol。

selector

selector 从 cluster member inventory 中选普通 backend。

selector: "[]"

表示全集。

offline 示例只选择:

pg_role == offline OR pg_offline_query

这让平台能把特定 replica 标为重查询目标。selector 是期望集合,运行时健康 检查仍可能摘除不合格成员。

backup

backup selector 形成 HAProxy backup server。它决定正常集合不可用时是否 降级。

要把 backup 语义写进服务合同:

  • replica 回 primary 是否允许;
  • offline 回普通 replica 是否允许;
  • backup 激活是否告警;
  • 目标是否有容量;
  • client target_session_attrs 会接受还是拒绝。

pg_service_provider

默认空值通常在每个 PostgreSQL node 上交付 local HAProxy service。

也可指定专用 HAProxy node group。此时要重新设计:

  • provider 高可用;
  • provider 到数据库网络;
  • DNS/VIP/multi-host;
  • config rollout;
  • source IP/HBA;
  • stats/metrics;
  • 故障域。

把 HAProxy 从数据库节点移出,不自动获得入口 HA。

VIP 与 DNS

相关声明包括:

pg_vip_enabled: false
pg_vip_address: 127.0.0.1/24
pg_vip_interface: auto
pg_dns_suffix: ''
pg_dns_target: auto

这是交付入口的机制选择。启用前要按第 22.1.3 节验证网络、仲裁、DNS cache 与证书,不能因为变量存在就宣称通过。

自定义业务服务

可以在 pg_services 添加服务,而不是修改默认列表。例如概念上:

pg_services:
  - name: shop-ro
    port: 5444
    dest: pgbouncer
    check: /read-only
    selector: "[? pg_role == `replica` && !pg_offline_query]"

生产声明还应补:

  • backup/fail-closed;
  • maxconn;
  • balance;
  • options/rise/fall;
  • owner 和用途;
  • TLS/网络;
  • driver endpoint。

不要为每个应用随意开端口;只有语义或资源/失败域不同才需要新服务。

22.6.2 PgBouncer、HAProxy 与数据库的证据链

第一步:声明证据

从 reviewed inventory 提取 secret-free projection:

cluster/member addresses
pg_role/pg_offline_query
pg_services/default services
default destination
PgBouncer mode/port/budget
VIP/DNS/provider
declared users and pgbouncer participation

凭据值不进入报告,只记录:

present
source class
rotation owner

第二步:rendered HAProxy

本章 /etc/haproxy/pg-test-*.cfg 投影:

primary  :5433 -> all members :6432, /primary
replica  :5434 -> members :6432, /read-only, primary backup
default  :5436 -> all members :5432, /primary
offline  :5438 -> pg-test-3 :5432, pg-test-2 backup, /replica

同时核对:

bind/mode/maxconn
balance
health method/path/status
inter/fastinter/downinter
rise/fall
shutdown-sessions
slowstart
backend maxconn/maxqueue
member address/destination/check port/backup

只核对文件 diff 仍不够;进程可能未 reload 或 runtime state 不同。

第三步:HAProxy runtime

从 stats socket/API 看:

frontend OPEN
backend UP/DOWN
health code
last state change
sessions/queue
backup activation

敏感 stats user/password 不输出。使用 local protected socket 比把管理页面凭据 写入脚本更安全。

第四步:PgBouncer config

在 local Unix admin socket:

SHOW CONFIG;
SHOW DATABASES;
SHOW USERS;
SHOW POOLS;
SHOW STATS;

本章 safe projection:

pool_mode=transaction
listen_addr=0.0.0.0
listen_port=6432
max_client_conn=20000
default_pool_size=50
reserve_pool_size=30
reserve_pool_timeout=1
query_wait_timeout=120
max_db_connections=100
max_user_connections=100
max_prepared_statements=256
server_reset_query=DISCARD ALL
server_reset_query_always=0
client_tls_sslmode=disable
unix_socket_dir=/run/postgresql

不要采集:

  • password;
  • SCRAM verifier;
  • auth file 内容;
  • inventory secret;
  • admin credential。

数据库 LOGIN 不等于池化身份已交付

本章开发过程中故意撞到一个重要边界:

CREATE ROLE pg36_ch22_app LOGIN PASSWORD ...
direct PostgreSQL auth works
PgBouncer auth fails

因为本章 Pigsty 默认:

pgbouncer_auth_query: false

PgBouncer authentication surface 由声明式用户清单管理。只有数据库 catalog 里存在 role,不等于 pooler 的 auth file/query 已经认识它。

生产用户应在 Pigsty pg_users 中声明并明确:

- name: pg36_shop_app
  password: <secret reference/material>
  pgbouncer: true

实际字段与 secret workflow 以当前版本文档和组织规范为准。不要手改 userlist.txt 制造不可追踪漂移。

本章 formal run 因此使用既有、Pigsty 已声明的 nonproduction test 用户, 只创建专属 schema/table;脚本永不修改或删除该 role。

若启用 pgbouncer_auth_query,还要评审:

  • auth_user 与查询权限;
  • query 在 replica/primary 的行为;
  • password rotation;
  • role expiration;
  • auth database;
  • failover;
  • secret exposure。

第五步:PostgreSQL 原生状态

对每个 member:

SELECT pg_is_in_recovery(),
       current_setting('transaction_read_only'),
       current_setting('cluster_name'),
       current_setting('port'),
       pg_postmaster_start_time();

并观察连接预算:

SELECT name, setting, unit, source
FROM pg_settings
WHERE name IN (
  'max_connections',
  'superuser_reserved_connections',
  'reserved_connections',
  'idle_in_transaction_session_timeout',
  'statement_timeout',
  'max_locks_per_transaction',
  'work_mem',
  'temp_buffers'
);

pg_postmaster_start_time() 在本章用来把经过 local Unix socket 的 PgBouncer session 映射回具体 member;inet_server_addr() 对 Unix backend 可能为空。

第六步:从 client 走完整路径

每个 service 用真实 database/user:

SELECT pg_is_in_recovery(),
       current_setting('transaction_read_only')::boolean,
       current_setting('cluster_name'),
       current_setting('port')::integer,
       pg_backend_pid(),
       pg_postmaster_start_time();

预期:

service recovery read_only path
primary 5433 false false HAProxy → PgBouncer
replica 5434 true true HAProxy → PgBouncer
default 5436 false false HAProxy → PostgreSQL
offline 5438 true true HAProxy → PostgreSQL

再到每台 PgBouncer SHOW POOLS,证明 pooled endpoint 真正在对应 process 形成了 database/user pool。

第七步:行为证据

配置与角色通过后仍要测:

  • 12-client/2-server queue;
  • backend reassignment;
  • session state 丢失/泄漏;
  • protocol prepared;
  • SQL PREPARE negative;
  • async token visibility;
  • planned switch/reconnect;
  • final config/topology restore。

这才完成从声明到用户体验的链。

证据矩阵

Claim 声明 渲染 runtime SQL/client
5433 主写 service check/dest primary cfg backend status writable
5434 副本优先 backup/selector replica cfg selected pool read-only/member
事务池 pool mode pgbouncer ini SHOW CONFIG/POOLS PID reassignment
2 server cap runtime override N/A sv_active ≤ 2 12 clients complete
prepared 支持 max_prepared SHOW CONFIG two server PID correct protocol results
switch recovery Patroni/service health config topology/pool refresh token reconcile

22.6.3 配置变更、reload 与连接行为验证

不要直接编辑 rendered file

错误流程:

vim /etc/haproxy/pg-test-primary.cfg
systemctl reload haproxy

问题:

  • inventory 不知道;
  • 下次 automation 覆盖;
  • 多节点不一致;
  • review/rollback 不完整;
  • secret/权限可能漂移。

正确流程:

edit reviewed Pigsty declaration
  -> render diff/plan
      -> validate generated config
          -> staged reload
              -> runtime observation
                  -> client behavior test
                      -> commit evidence/rollback

service tag

参考代码中服务生成/reload 由 pg_service 相关 task/tag 管理,典型调用形态:

./pgsql.yml -l pg-test -t pg_service

生产执行前必须按当前 Pigsty 版本查看 help/plan、限定 inventory 和 host。 不要从书中复制命令直接指向未知集群。

render task 会生成 service config,并在 reload 前运行 HAProxy config check。

配置校验

原生检查:

haproxy -f /etc/haproxy/haproxy.cfg -c -q

它证明语法/引用可加载,不证明路由语义正确。

PgBouncer reload:

RELOAD;
SHOW CONFIG;

不是所有配置都支持在线改变;某些需要 reconnect/restart。SHOW CONFIG 的 changeable 列与当前文档共同决定。

reload 与现有连接

必须回答:

old HAProxy process 是否 drain
existing TCP 是否保留
new connections 是否使用新 config
PgBouncer existing client/server pool 是否继承
authentication file rotation 对既有 session 是否影响
pool size 改变对已有 server connection 如何收敛

“reload 成功”不能替代这些答案。

role change 与 config change 是两类变更

config change:

declaration -> render -> reload

role change:

Patroni/DCS -> health convergence -> pool/client refresh

二者可能同时发生,但 rollback 不同。故障切换时不应顺手修改持久配置, 否则难以分辨恢复来自哪项动作。

本章 runtime pool override 只用于实验,并在切换前恢复,正是为了隔离变量。

staged rollout

多入口环境:

  1. 选一个无生产或低流量 provider;
  2. render/check;
  3. reload;
  4. direct health + client probe;
  5. 观察 queue/error/session;
  6. 扩到下一 provider;
  7. 完整端点矩阵;
  8. 保留旧配置与回滚。

若所有 provider 同时 reload,错误配置会同时摧毁入口冗余。

变更后的强制验证

declaration projection equals reviewed intent
rendered files equal expected member/dest/check/backup
all proxy instances loaded intended config
runtime backend states make sense
PgBouncer config/pools within budget
primary endpoint writable
replica/offline endpoint readonly + allowed member
direct endpoint bypasses pool
session/prepared compatibility suite passes
old/new connection behavior matches change plan

如果变更涉及 role/promotion,再执行 pool role-state refresh 检查。

回滚

回滚不是把文件复制回去:

restore declaration
render/check
staged reload
verify runtime
verify client path
close/refresh incompatible existing sessions if needed
record final state

如果数据库 role 已在期间改变,旧 rendered config 的成员角色仍由 health check 动态判断,但 selector/backup/destination 可能不再合适,要重新评审。

secret 与证据

服务变更会接触:

  • inventory password;
  • PgBouncer userlist/verifier;
  • HAProxy stats auth;
  • TLS key;
  • HBA/identity。

证据只保留:

hash/projection/presence
mode/owner
rotation metadata
behavioral result

不要把整个 inventory、auth file 或 config 原文无差别上传。正式 lab 对 临时 credential inventory 要求 mode 0600,使用后删除副本,报告 secret_values_exported=0

本节检查表

[ ] 实际版本的 pg_default_services 已读取
[ ] dest/check/selector/backup 分别解释
[ ] local/dedicated service provider 的失败域明确
[ ] PostgreSQL LOGIN 与 PgBouncer auth delivery 分开验收
[ ] rendered HAProxy 与 runtime stats 都检查
[ ] SHOW CONFIG/POOLS 不导出敏感材料
[ ] client probe 映射到具体 member/role
[ ] 变更来自 inventory,不手改渲染产物
[ ] config check 只是语法门,不是完成条件
[ ] staged reload 验证 old/new connection
[ ] role change 后重新验证 pool state
[ ] rollback 恢复声明、runtime 与 client behavior

参考资料


上一节:连接预算与过载边界 · 返回本章目录 · 下一节:实战:写入、只读与管理三类接入 · 查看全书目录 · 查看索引中心

22.7 实战:写入、只读与管理三类接入

本节把前六节变成一个可重放验收:

baseline gate
  -> declared identity + private service material
      -> four endpoint semantics
          -> two-slot queue
              -> transaction-session counterexample
                  -> prepared-statement matrix
                      -> async visibility sample
                          -> exact pool rollback
                              -> forward/restore planned switch
                                  -> role-aware pool refresh
                                      -> token reconciliation
                                          -> postflight + adversarial review

它在本地 Pigsty nonproduction sandbox 执行 L1/L2 动作。不要把 guard 改掉后 指向生产。

22.7.1 为 pg36_shop 配置端点和连接预算

先写生产设计,后映射 sandbox

pg36_shop 的概念设计:

logical service 用途 role/path session freshness
pg36_shop_rw API/worker 短写事务 primary pooled transaction primary
pg36_shop_ro catalog/非因果读 replica pooled transaction 声明 staleness
pg36_shop_admin migration/诊断 primary direct full session primary
pg36_shop_olap 报表/ETL offline direct/受控 pool workload-specific 可陈旧

本章不创建真实 pg36_shop database,而把它映射到保留沙箱:

logical service    pg36_shop
sandbox database   test
declared user      test, pgbouncer=true
fixture schema     pg36_ch22
fixture table      route_probe

为什么不临时创建一个 LOGIN:

PostgreSQL role exists
  != Pigsty/PgBouncer authentication surface delivered

正式 runner 从 private reviewed Pigsty inventory 读取既有 test credential, 写入 mode 0600 的临时 libpq service file,结束后删除;credential 不打印、 不 hash 到报告、不进入 Git。

生产 identity 应如何声明

生产应在 reviewed Pigsty inventory/secret workflow 中声明:

pg_databases:
  - name: pg36_shop

pg_users:
  - name: pg36_shop_app
    password: <approved secret material/reference>
    pgbouncer: true

字段与 secret 语法按当前 Pigsty 版本确认。还要设置:

  • owner/group role 与 login role 分离;
  • least privilege;
  • connection limit;
  • default privilege;
  • role/database timeout;
  • TLS/HBA;
  • rotation;
  • application_name;
  • direct admin role;
  • PgBouncer per-user/database budget。

不要把书中 placeholder 作为可用 secret。

libpq service file

概念结构:

[pg36-shop-rw]
host=pg36-shop.example
port=5433
dbname=pg36_shop
user=pg36_shop_app
sslmode=verify-full
target_session_attrs=read-write
connect_timeout=2

[pg36-shop-ro]
host=pg36-shop.example
port=5434
dbname=pg36_shop
user=pg36_shop_app
sslmode=verify-full
target_session_attrs=read-only
connect_timeout=2

[pg36-shop-admin]
host=pg36-shop.example
port=5436
dbname=pg36_shop
user=pg36_shop_migrate
sslmode=verify-full
target_session_attrs=read-write
connect_timeout=2

密码应来自 .pgpass、secret manager 或受控 service material。文件权限:

directory 0700
service/pgpass 0600
no symlink
no stdout/log

本章沙箱 PgBouncer client TLS 是 disable,使用 sslmode=prefer 只为匹配 事实,并保留 EX20-CLIENT-PROXY-NO-TLS;生产必须另做 TLS 验收。

预算草案

假设:

API pods                  12
API client pool           12 each
worker pods                6
worker client pool         6 each
read pods                 12
read client pool           8 each
migration/admin            2

客户端上限:

rw clients      12×12 + 6×6 = 180
ro clients      12×8         =  96
admin direct                   =   2

不是 278 个 backend。一个候选 server budget:

rw app/worker server pool       40 + reserve 8
ro per eligible replica         20
admin direct                      2
monitor/platform/incident        separately reserved

需要在生产规模压测后定稿。

fixture 合同

setup.sql 创建:

CREATE SCHEMA pg36_ch22 AUTHORIZATION postgres;

CREATE TABLE pg36_ch22.route_probe (
    run_id         uuid        NOT NULL,
    worker_no      integer     NOT NULL,
    attempt_no     integer     NOT NULL,
    token          text        NOT NULL UNIQUE,
    client_sent_at timestamptz NOT NULL,
    committed_at   timestamptz NOT NULL DEFAULT clock_timestamp(),
    PRIMARY KEY (run_id, worker_no, attempt_no)
);

它验证:

  • existing schema owner/comment;
  • exact columns/type/nullability;
  • primary/unique constraints;
  • declared login safe attributes;
  • grants only USAGE/SELECT/INSERT;
  • role 未被 runner 创建、修改或接管。

fixture 是 synthetic data,drill 不自动删除它。

四端点预期

primary  5433 -> writable pg-test-1 through PgBouncer
replica  5434 -> read-only pg-test-2/3 through PgBouncer
default  5436 -> writable pg-test-1 direct
offline  5438 -> read-only pg-test-3 direct

SQL 同时记录 postmaster start time,与直连三成员的基线映射,解决 PgBouncer local Unix backend 下 inet_server_addr() 可能为空的问题。

22.7.2 验证会话状态、预备语句与只读一致性

风险分级

动作 风险 改动
capture L0 只读快照
verify/review/all L0 重验既有证据
schema setup L1 synthetic schema/table
pool override L1 一个 PgBouncer process runtime 值
queue/session/prepare/visibility L1 synthetic connection/row
planned switch + restore L2 Patroni role/timeline
reset:fixture L3 删除 synthetic schema

all 从不:

创建连接
SET pool config
RECONNECT
写行
切换
删除

preflight

在任何 mutation 前,第 19 章 gate 验证:

exact target pg36-l2-vagrant
Pigsty v4.5.0 declaration
PostgreSQL 18
four distinct hosts
pg-test-1 primary
pg-test-2/3 replicas
required exceptions accepted
production approval false

第 22 章 capture 再验证:

timeline/member/lag
package versions
listeners
four rendered services
all three PgBouncer configs
all three PostgreSQL connection settings

任何 drift 先停。

pool role-state baseline

在第一个应用 probe 前:

RECONNECT test;

在三台 PgBouncer 分别执行,清除上一轮角色周期遗留的服务端连接状态。

这一步来自真实失败发现:

pg-test-2 direct PostgreSQL was read-only
but its pooled target_session_attrs check rejected
RECONNECT test restored repeated checks

它只影响 sandbox teaching database,且是 evidence-bearing action。

临时两槽 pool

先 snapshot:

default_pool_size       50
reserve_pool_size       30
reserve_pool_timeout     1
query_wait_timeout     120

再 runtime SET:

default_pool_size        2
reserve_pool_size        0
reserve_pool_timeout     1
query_wait_timeout       5

为什么 runtime:

  • 让 12-client queue 低噪声可观察;
  • 避免为实验 saturate 50+30;
  • 不修改 rendered file;
  • exact finally rollback。

生产 pool policy 必须回到 Pigsty declaration,不照抄 runtime SET。

endpoint probe

每个 service 连接后:

SELECT pg_is_in_recovery(),
       current_setting('transaction_read_only')::boolean,
       current_setting('cluster_name'),
       current_setting('port')::integer,
       pg_backend_pid(),
       pg_postmaster_start_time();

同时三节点 SHOW POOLS 证明 test/test pooled path。

saturation probe

12 个 client 同时:

SELECT pg_sleep(0.25),
       pg_backend_pid(),
       current_setting('transaction_read_only')::boolean;

管理 console 周期采样:

SHOW POOLS;

验收:

completed=12
max sv_active<=2
max cl_waiting>=1
unique backend PID<=2
all transactions read-write

正式:

max sv_active=2
max cl_waiting=10
unique PID=2
fastest=254.451ms
slowest=1519.423ms

session counterexample

为确定性分配:

  1. RECONNECT test
  2. A 在 backend X SET search_path=pg_catalog,commit;
  3. B 借到 X 并保持 transaction;
  4. A 被迫借 backend Y;
  5. 比较 PID 与 search_path;
  6. 关闭 client,RECONNECT test 清理实验状态。

正式:

A first       PID 65057, pg_catalog
B borrowed    PID 65057, pg_catalog
A reassigned  PID 65058, "$user", public

既证明 state leakage,也证明 state loss。

protocol prepared

Psycopg:

prepare_threshold=1
12 parameterized executions
hold first backend
force same client to second backend

正式:

PIDs        65171, 65172
results     12/12 correct

接受范围:

PgBouncer 1.25.2
max_prepared_statements=256
psycopg 3.2.9
transaction pooling
exact tested query

SQL PREPARE negative

PREPARE pg36_ch22_sql(integer) AS SELECT $1 + 1;
COMMIT;

占住创建 backend,再:

EXECUTE pg36_ch22_sql(41);

正式:

prepared PID  65285
execute PID   65286
SQLSTATE      26000
class         InvalidSqlStatementName

这是必须出现的失败。

replica visibility

写端点插入 unique token,commit 后取得主库 LSN;只读端点轮询 exact token, 记录:

selected member
recovery/read-only
replay LSN
elapsed
polls/connection rejections

正式:

member        pg-test-2
visible       true
delay         11.092 ms
polls         1
rejections    0

验收只要求在 5 秒 sandbox window 内看见,不形成 freshness SLO。

pool rollback gate

以上任一步成功或失败,finally 恢复:

50 / 30 / 1 / 120

读取 SHOW CONFIG exact compare。只有:

restored_before_switch=true

才允许 L2 切换。

22.7.3 注入切换与连接风暴,观察退避和恢复

这里“注入”的边界

本节只执行:

healthy planned switchover
pg-test-1 -> pg-test-2 -> pg-test-1

不注入 process/network/storage/DCS failure。标题中的“连接风暴”是小型 6-worker 重连探针,不是生产规模压力。

exact guards

需要两份 private input:

PG36_CH19_INVENTORY
  第 19 章 exact baseline gate 使用,mode 0600

PG36_CH22_CREDENTIAL_INVENTORY
  包含已声明 test/pgbouncer user credential,mode 0600

在普通环境两者可以来自同一 reviewed inventory 的安全副本。本书 local sandbox 的已部署 v4.5 baseline 与当前工作目录声明版本不同,因此 formal run 明确分离,避免用新声明冒充旧部署。

执行:

export PG36_EVIDENCE_DIR=/absolute/private/path/to/new-empty/ch22-run
export PG36_CH19_INVENTORY=/absolute/private/path/to/baseline.yml
export PG36_CH22_CREDENTIAL_INVENTORY=/absolute/private/path/to/credential.yml

export PG36_CH22_TARGET=pg36-l2-vagrant/pg-test
export PG36_CH22_NONPRODUCTION=true
export PG36_CH22_PRODUCTION_DATA=false
export PG36_CH22_PRODUCTION_TRAFFIC=false
export PG36_CH22_CONFIRM=POOL_ROUTE_SWITCH_AND_RESTORE_CH22

static/labs/ch22/task.sh drill:service

所有值 exact match。output 非空、inventory 缺失/权限错误、topology drift 都会拒绝。

client workload

六个 worker,24 秒:

INSERT INTO pg36_ch22.route_probe
  (run_id, worker_no, attempt_no, token, client_sent_at)
VALUES (...)
RETURNING committed_at, pg_backend_pid(), pg_postmaster_start_time();

每 attempt 短连接,service:

host=10.10.10.11
port=5433
target_session_attrs=read-write
connect_timeout=2

失败:

same token is not blindly resubmitted
outcome recorded unknown
worker uses capped exponential backoff + jitter

forward

exact executor:

patronictl -c /etc/patroni/patroni.yml \
  switchover pg-test \
  --leader pg-test-1 \
  --candidate pg-test-2 \
  --force

完成条件:

pg-test-2 sole primary/running
pg-test-1/3 replica/streaming
all timeline 10
lag within gate

然后三节点:

RECONNECT test;

必须出现一次 refresh 之后的 acknowledged write,才能进入回切。

restore

patronictl ... switchover pg-test \
  --leader pg-test-2 \
  --candidate pg-test-1 \
  --force

完成:

pg-test-1 sole primary/running
pg-test-2/3 replica/streaming
all timeline 11

再次刷新三节点 pool,并要求首笔确认。

pool refresh evidence

正向三成员 action:

pg-test-1  172.318 ms
pg-test-2  276.016 ms
pg-test-3  175.812 ms
first acknowledged after final refresh action  140.283 ms

回切:

pg-test-1  176.102 ms
pg-test-2  167.016 ms
pg-test-3  173.996 ms
first acknowledged after final refresh action  1697.326 ms

这些 action time 只是管理命令耗时;write gap 还包括 topology、health、 server login 和 client backoff。

reconcile

结束后查询本 run 的所有 worker row:

SELECT worker_no, attempt_no, token, committed_at
FROM pg36_ch22.route_probe
WHERE run_id = $1
  AND worker_no > 0;

分类:

acknowledged token -> must exist
unknown token      -> lookup says committed or absent
duplicate token    -> must be zero

正式:

events                        387
acknowledged                  339
unknown                        48
persisted                     339
acknowledged missing            0
unknown committed               0
unknown absent                 48
duplicate                       0
unreconciled                    0
distinct postmaster generations 3

unknown_absent=48 不是失败;它们已被确定分类。若 unknown committed > 0, 也可以通过,只要 token lookup 明确且业务不重复执行。真正不允许的是 unreconciled。

时间口径

forward command                    2.774 s
forward conservative write gap     6.995 s
restore command                    2.766 s
restore conservative write gap     8.510 s
maximum adjacent ack gap           7.653 s

conservative gap:

last ack before action start
  -> first ack after stable topology and pool refresh

它包含 probe interval、connection attempt 和 backoff,不是纯数据库 promotion 时间,也不是 production RTO。

postflight 和反例

第 19 章 postflight 再次通过。十五个 evidence mutation 必须被指定错误码拒绝:

production claim
primary routed read-only
replica routed writable
offline wrong member
sticky session claim
broken protocol prepare
SQL PREPARE cross-backend success
pool server cap exceeded
no waiter observed
pool config not restored
acknowledged write missing
unknown unreconciled
write gap over objective
wrong final leader
degraded source

反例不是额外单元测试装饰,它防止 validator 只检查“文件存在”。

evidence tree

ch22-run/
├── preflight-ch19/
├── drill/
│   ├── before.json
│   ├── endpoint-observations.json
│   ├── fixture.json
│   ├── pool-settings.json
│   ├── pool-saturation.json
│   ├── session-semantics.json
│   ├── prepared-statements.json
│   ├── replica-visibility.json
│   ├── phases/
│   │   ├── pre-switch.json
│   │   ├── after-forward.json
│   │   └── restored.json
│   ├── switch-forward.json
│   ├── switch-restore.json
│   ├── pool-refresh-actions.json
│   ├── client-events.jsonl
│   ├── reconciliation.json
│   ├── after.json
│   ├── drill-manifest.json
│   ├── validation-report.json
│   └── negative-report.json
├── postflight-ch19/
└── review.txt

完整证据含 token 和运行细节,应放 private evidence store,不提交 Git。 仓库只保留 secret-free 聚合 connection-run.json

read-only 重验

export PG36_EVIDENCE_DIR=/absolute/path/to/ch22-run
static/labs/ch22/task.sh all

应输出:

status=review-ok
endpoints=4
pool_active_max=2
waiters_max=10
acknowledged=339
unknown=48
missing=0
duplicates=0
unreconciled=0
counterexamples=15-rejected
production_ch22_gate=pending
mutation=none

reset

reset 与 drill 完全分离:

export PG36_CH22_TARGET=pg36-l2-vagrant/pg-test
export PG36_CH22_NONPRODUCTION=true
export PG36_CH22_PRODUCTION_DATA=false
export PG36_CH22_PRODUCTION_TRAFFIC=false
export PG36_CH22_RESET_CONFIRM=DROP_CH22_SYNTHETIC_SCHEMA_AND_ROLE

static/labs/ch22/task.sh reset:fixture

确认 token 为兼容已发布的实验接口保留旧名称,但当前 reset 只:

terminate application_name like pg36_ch22_% for user test
DROP SCHEMA pg36_ch22 CASCADE
preserve declared role test

它不回滚 timeline、不清理 evidence、不改 pool。删除前仍应阅读脚本并确认 exact target。

失败时保守恢复

脚本:

  • pool override 已开始就尝试恢复 baseline;
  • switch 未开始则不触碰 topology;
  • pg-test-2 是唯一稳定 leader,允许计划切回;
  • topology ambiguous/degraded 时不猜、不 force;
  • 保留 failure manifest;
  • 不自动 drop fixture。

finally 能降低风险,不能替代 operator inspection。

生产准入差距

本章 sandbox contract:

accepted-with-exceptions

生产仍需:

  1. 在 reviewed Pigsty inventory 声明 database/user/service/budget;
  2. 验收 client/server TLS 与证书轮换;
  3. 证明 VIP、DNS 或 multi-host entry failover;
  4. 跑真实 driver/ORM/query-mode matrix;
  5. 在 production-class 资源做容量和 reconnect load test;
  6. 注入 unplanned failure、partial network 与 cancel;
  7. 为每个 replica workload 定义 consistency contract;
  8. 把 pool refresh 自动化、告警化并限定 blast radius;
  9. 把结果纳入 SLO/SOP/change review;
  10. 由业务 owner、安全与平台共同签署。

不要把本章 8.510 秒写进生产 SLO。

本章完成定义

读者应能独立解释并证明:

为什么应用连服务而不是机器
四个端点选择什么角色/路径
异步副本为何无天然 read-your-writes
连接预算如何跨应用/pool/database 相乘
transaction pooling 会丢失/泄漏什么状态
两类 prepared statement 为什么结论不同
SHOW POOLS 如何证明排队和 backend cap
HAProxy health 为什么不等于 SQL 可用
切换后 pool state 为什么必须重验
write outcome unknown 如何 reconcile
何时只能说 sandbox accepted-with-exceptions

若只能背端口和 RECONNECT 命令,本章还没有完成。

参考资料


上一节:Pigsty 服务接入层 · 返回本章目录 · 下一章:固若金汤:认证、授权与数据安全 · 查看全书目录 · 查看索引中心