返回文章列表

PostgreSQL Patroni、Fencing、故障转移与综合高可用架构

从 Patroni、DCS、Leader Lock、同步复制、Fencing、HAProxy、PgBouncer、pg_rewind、未知提交、Runbook 与故障演练出发,理解 PostgreSQL 生产级综合高可用架构。

第 23 章:Patroni、Fencing、故障转移与 PostgreSQL 综合高可用架构

技术基线(核对日期:2026-06-21) PostgreSQL 18;Patroni 4.1.3;Go 1.25.x;github.com/jackc/pgx/v5pgxpool。PostgreSQL 19 仍不作为本文生产基线。本文所有容量与时间参数都服务于下述参考工作负载,不是通用推荐值。


1. 本章定位

前 22 章分别讨论了事务、WAL、复制、备份、逻辑复制、连接池和应用幂等。本章把这些能力组装为一套可以被检测、选主、隔离、切流、恢复和演练的生产级 PostgreSQL 高可用系统。

本章解决五个核心问题:

  1. Primary 失效时,谁有资格成为新 Primary;
  2. 如何证明旧 Primary 已经不能继续写,避免 Split Brain;
  3. 应用如何处理旧连接、连接重置、只读错误与 Commit 结果不确定;
  4. 故障转移后如何恢复旧节点,而不是直接把它重新接入;
  5. 如何把 RPO、RTO、监控、备份和演练落到可执行 Runbook。

本章依赖第 13 章的 WAL 与恢复、第 16 章的 pgx/pgxpool、第 17 章的幂等、第 20 章的 Backup/PITR、第 21 章的物理复制,以及第 22 章的逻辑复制与升级。它不展开 PostgreSQL 内核中的 WAL Record 编码、共识算法证明或云厂商 API 细节。

1.1 先定义设计前提

没有工作负载与业务目标,就不存在“正确的 HA 参数”。本文采用以下参考系统

维度参考假设设计含义
业务支付与订单核心写库已确认写入优先于写可用性
数据规模6 TiB,月增约 350 GiB单节点仍可承载,但恢复时间必须实测
WAL平均 30 MiB/s,峰值 120 MiB/s复制链路、归档和磁盘余量按峰值设计
QPS峰值读 18,000,写 4,000读可分流;写仍集中于一个 Primary
延迟 SLO读 P95 20 ms/P99 80 ms;写 P95 35 ms/P99 120 ms同步复制 RTT 必须计入写延迟
客户端连接应用逻辑连接峰值约 4,000必须通过 PgBouncer 限制数据库连接
数据库连接目标不超过 240,活跃查询峰值约 120故障恢复期间也必须限流
事务时长P95 80 ms,P99 300 ms,硬上限 5 s长事务会放大切换、锁和未知提交风险
区域内 RPO对已返回成功的关键交易为 0使用同步保护;不允许应用随意关闭 synchronous_commit
区域内 RTO目标不超过 60 sPatroni 计时、代理探测和应用退避共同受约束
区域级 DRRPO ≤ 5 min,RTO ≤ 60 min跨地域异步,不把跨地域 RTT放入每次提交
部署单地域三 AZ;异地独立备份库与 Standby ClusterDCS、数据节点和入口均跨故障域
读一致性交易确认后读走 Primary;报表允许 2 s 陈旧明确读写端点,不做无条件读写分离
运维能力7×24 值守;季度故障演练;半年恢复演练可以采用 Patroni,但不能把自动化当作免运维
成本约束三个等规格数据库节点,两个代理节点不采用仅两节点且无第三仲裁面的方案
容量余量CPU、连接、磁盘和 WAL 链路保留至少 30% 峰值余量单 AZ 故障后剩余容量仍需满足降级流量
单节点硬件32 vCPU、256 GiB RAM、12 TiB 本地 NVMe、25 GbE参数必须经压测和恢复演练校准

关键结论:本章的 ttl=30、连接数、池大小、WAL 阈值等只是这一参考系统的起点。换成 300 GiB 数据库、跨城 RTT、慢磁盘或 20 秒事务后,结论会改变。


2. 可验证的学习目标

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

  • 画出 Patroni、DCS、PostgreSQL、HAProxy、PgBouncer 与 Go 应用之间的数据流和控制流;
  • 根据 loop_waitretry_timeoutttlprimary_start_timeout 估算故障检测窗口;
  • 解释 synchronous_modesynchronous_mode_strict 与 quorum 模式在 RPO、延迟和写可用性上的差异;
  • 用 REST 健康检查构建明确的读写端点,而不是依赖“哪个 IP 看起来像主库”;
  • 对 20 类故障逐一判断是否应自动 Failover、是否需要 Fencing,以及应用是否可重试;
  • 复现 Planned Switchover、Unplanned Failover、旧连接失效和旧 Primary 回归;
  • 使用 pg_rewind 或 Reinitialize 安全恢复分叉时间线;
  • 在 Go 中实现有界重连、指数退避、Jitter、池重置、SQLSTATE 分类、幂等键和未知提交对账;
  • 写出并执行 Switchover、Failover、Failback、DCS 故障、WAL 写满和 AZ 故障 Runbook;
  • 用备份恢复和混沌演练验证实际 RPO/RTO,而不是只相信配置文件。

3. 核心术语

中文名称English准确定义容易混淆所属层次
主节点Primary当前接受读写并产生 WAL 的 PostgreSQL 实例“DCS Leader”是控制面租约持有者;正常时二者应一致数据面
同步副本Synchronous ReplicaPrimary 提交需要等待其满足同步确认条件的副本sync_state='sync' 不等于已应用到可读层;取决于 synchronous_commit数据面
异步副本Asynchronous Replica提交不等待该副本确认低延迟不等于 RPO=0数据面
分布式配置存储DCSPatroni 保存成员、配置、Leader Lock、同步状态等的共识存储DCS 不保存业务数据,也不转发 SQL控制面
领导者选举Leader Election多个合格副本竞争 Leader Lock 并决定谁可提升不是“谁先启动谁当主”控制面
领导者锁Leader Lock带 TTL 的排他租约,表示某 Patroni 节点当前有资格维持 Primary仅有锁仍不足以取代 Fencing控制面
租约期限TTLLeader Lock 未续约后失效的时间窗口不是固定 RTO;还受循环、重试、启动、代理和应用影响控制面
HA 循环间隔loop_waitPatroni 两次 HA 循环间的等待时间不是健康检查的唯一间隔控制面
重试超时retry_timeoutPatroni 对 DCS/PostgreSQL 操作重试的时间预算不应大到超过 TTL 安全约束控制面
故障转移最大延迟maximum_lag_on_failover副本参加选主允许落后的最大 WAL 字节数不是严格时间,也不能单独保证零数据丢失控制面/数据面
法定人数Quorum共识或同步提交所需的最小票数/确认数DCS quorum 与 PostgreSQL quorum commit 是两件事控制面/数据面
隔离Fencing确保旧写节点无法继续写或无法访问共享资源健康检查失败不是 Fencing基础设施
断电隔离STONITH通过电源、虚拟机或硬件管理面关闭/重启失控节点不是简单 kill postgres基础设施
提升前钩子pre_promote获得 Leader Lock 后、提升前执行的同步脚本;失败则放弃提升它必须“失败关闭”,不能只记录日志控制面
脑裂Split Brain两个节点同时接受写入,产生不可自动合并的分叉历史两个只读副本并不叫脑裂故障状态
网络分区Network Partition节点之间出现非对称或局部连通性不是所有节点都离线;最危险的是各自看到不同世界基础设施
计划切换Planned Switchover健康集群中受控转移 Primary 角色与故障状态下的 Failover 不同运维流程
非计划故障转移Unplanned FailoverPrimary 不可用时选择合格副本提升可能包含未知提交和旧主隔离运维/自动化
回切Failback将业务角色迁回原位置或新建节点后的目标位置不是旧主一上线就改回 Primary运维流程
时间线TimelinePostgreSQL 在恢复/提升时形成的 WAL 历史分支标识LSN 只在具体时间线语境中比较才安全存储/恢复
旧主节点Old Primary故障前曾接受写入、但在新时间线产生后必须被隔离的节点不能把它当普通副本直接启动故障状态
提交结果不确定Commit Outcome Unknown客户端未收到 COMMIT 结果,事务可能已提交也可能未提交“收到超时”不等于“数据库回滚”应用语义
优雅降级Graceful Degradation在能力下降时主动限制写入、读一致性或非核心功能不是静默返回错误数据应用/SRE

4. 整体心智模型

4.1 三节点、三可用区参考架构

PostgreSQL Patroni、Fencing、故障转移与综合高可用架构 flow 1

4.2 四条必须分开的路径

数据流:Go 请求经过 PgBouncer 与 HAProxy 到 Primary;Primary 修改数据页并产生 WAL;WAL 至少被同步保护节点确认后,关键事务才向客户端返回成功。

控制流:每个 Patroni 进程与 DCS 交互。当前 Leader 周期性续约 Leader Lock;副本发布成员与 LSN 状态;失效时合格节点参加选举。

状态变化replica → candidate → leader lock holder → pre_promote → PostgreSQL promote → primary。旧 Primary 必须走 primary → demoted/stopped → rewound/reinitialized → replica,不能跳过隔离与分叉检查。

故障路径:客户端 TCP 连接不会随角色迁移。旧连接要么被旧 PostgreSQL/HAProxy关闭,要么继续指向已降级的只读节点;应用必须识别错误、废弃连接并通过入口建立新连接。

4.3 控制面不能代替数据面,选主不能代替隔离

DCS 的作用是给出一个可线性化理解的“谁持有租约”视图;PostgreSQL 的 WAL/LSN 决定数据是否足够新;Fencing 决定旧写者是否真的失去写能力;入口层决定新连接去哪里;应用幂等决定未知提交是否会造成重复业务。少任何一层,HA 都只是部分自动化。

三节点 etcd 需要多数派 2/3 才能提交状态变更,可容忍一个节点故障;失去多数派时应停止自动选主,而不是手工制造第二个“多数派”。1


5. 使用方式

5.1 Patroni 动态配置:先从业务约束反推

下例适用于本章参考负载。Patroni 要求满足:

loop_wait + 2 × retry_timeout <= ttl
10 + 2 × 10 = 30

maximum_lag_on_failover 的单位是 WAL 字节;它只过滤落后候选,并不能把异步复制变成 RPO=0。synchronous_mode 可取 offon 或当前 Patroni 支持的 quorum;严格模式在无同步候选时阻塞写入,以耐久性换写可用性。23

# 所有节点共享的主要片段;name、connect_address、data_dir 按节点变化。
scope: payments-prod
namespace: /service/
name: pg-a

restapi:
  listen: 0.0.0.0:8008
  connect_address: 10.10.1.11:8008
  certfile: /etc/patroni/tls/server.crt
  keyfile: /etc/patroni/tls/server.key
  cafile: /etc/patroni/tls/ca.crt
  verify_client: optional
  authentication:
    username: patroni_ctl
    password: "${PATRONI_REST_PASSWORD}"   # 实际使用 secret manager 注入
  allowlist:
    - 10.10.0.0/16

etcd3:
  hosts:
    - 10.10.1.21:2379
    - 10.10.2.21:2379
    - 10.10.3.21:2379
  protocol: https
  cacert: /etc/etcd/tls/ca.crt
  cert: /etc/etcd/tls/client.crt
  key: /etc/etcd/tls/client.key

bootstrap:
  dcs:
    loop_wait: 10
    retry_timeout: 10
    ttl: 30
    primary_start_timeout: 20
    maximum_lag_on_failover: 67108864      # 64 MiB;必须按峰值 WAL 和 RPO 重算
    maximum_lag_on_syncnode: 67108864
    check_timeline: true
    synchronous_mode: quorum
    synchronous_mode_strict: true
    synchronous_node_count: 1
    failsafe_mode: true
    member_slots_ttl: 30min
    postgresql:
      use_pg_rewind: true
      use_slots: true
      parameters:
        wal_level: replica
        hot_standby: "on"
        wal_log_hints: "on"
        full_page_writes: "on"
        synchronous_commit: "on"
        max_wal_senders: 16
        max_replication_slots: 16
        wal_keep_size: 4096MB
        max_slot_wal_keep_size: 20480MB
        archive_mode: "on"
        archive_command: >-
          pgbackrest --stanza=payments archive-push %p
        archive_timeout: 60s

  initdb:
    - encoding: UTF8
    - data-checksums

postgresql:
  listen: 0.0.0.0:5432
  connect_address: 10.10.1.11:5432
  data_dir: /pgdata/18/data
  bin_dir: /usr/pgsql-18/bin
  pgpass: /var/lib/patroni/.pgpass
  use_pg_rewind: true
  pre_promote: /usr/local/sbin/fence-before-promote
  authentication:
    replication:
      username: replicator
      password: "${REPLICATION_PASSWORD}"
    superuser:
      username: postgres
      password: "${POSTGRES_PASSWORD}"
  pg_hba:
    - hostssl replication replicator 10.10.0.0/16 scram-sha-256
    - hostssl payments app_user 10.20.0.0/16 scram-sha-256

watchdog:
  mode: required
  device: /dev/watchdog
  safety_margin: 5

tags:
  noloadbalance: false
  nofailover: false
  nosync: false
  failover_priority: 100

配置解读

  • synchronous_mode: quorum + synchronous_node_count: 1 使 PostgreSQL 形成类似 ANY 1 (...) 的确认集合;两个低延迟 AZ 副本都追平时,任一确认即可降低尾延迟。Patroni 维护 DCS /sync 状态以限制安全候选。3
  • synchronous_mode_strict: true 表示无同步保护时宁可阻塞关键写入。它仍不是“宇宙级绝对零丢失”:后端在等待同步确认期间被取消,可能出现本地可见但尚未复制的特殊窗口,因此应用不能用短超时粗暴取消 COMMIT。3
  • failsafe_mode: true 只在当前 Primary 能通过 Patroni REST 联系 DCS 中已知的全部成员时,才允许 DCS 故障期间继续作为 Primary;任何成员不可达都会促使其降级。这要求 REST 网络、认证和监控真正可靠。4
  • watchdog.mode: required 让无法启用 watchdog 的节点不能成为 Leader;它是本机失控后的最后防线之一,不等于云层面的 STONITH。5
  • pre_promote 在取得 Leader Lock 后、真正提升前执行外部隔离检查。脚本非零退出时,Patroni 不提升并删除 Leader Key。5
  • data-checksumswal_log_hints=onpg_rewind 的前提之一;仍应保留全量重建路径。6
  • 不能只修改 bootstrap.dcs 期待已存在集群生效;首次引导后应使用 patronictl edit-config 或受控 REST API 修改动态配置。5

5.2 onstrictquorum 的选择

模式成功提交保护副本丢失时写延迟自动选主集合适用场景
synchronous_mode: off异步,可能丢失已确认事务写通常继续最低足够新且符合 lag 条件的副本缓存、可重建数据、可接受 RPO > 0
on, strict=false有同步节点时保护Patroni 可退化为无同步写;Primary 故障时可能不自动提升中等Leader 或 DCS 记录的同步副本更重视写可用性,业务允许受控风险
on, strict=true至少要求指定同步副本无同步副本时写阻塞中等到高严格受 /sync 限制关键账务、区域内已确认写 RPO=0 目标
quorum, strict=trueANY n 确认;Patroni维护安全投票集合可由其它追平节点接替确认;不足时阻塞通常尾延迟更稳满足 quorum 安全性与 LSN 条件的节点三个及以上低延迟数据节点

跨地域副本通常应设置 nosync: true,避免偶然进入同步集合并把跨地域 RTT 注入每次提交。维护中不希望被提升的节点设置 nofailover: true 或非正 failover_priority;两者不要混用。quorum 模式下当前 Patroni 文档提示 failover_priority 有已知限制。5

5.3 Fencing 与 pre_promote

一个安全的提升前钩子至少要验证:

  1. 新节点已持有 DCS Leader Lock;
  2. 外部 Fencing 服务已对旧 Primary 完成断电、隔离网络或撤销存储访问;
  3. Fencing 操作具有幂等请求 ID;
  4. 查询云/机房控制面确认最终状态,而不是只相信 API 返回“已受理”;
  5. 无法证明旧节点被隔离时,脚本非零退出
  6. 所有动作写入审计日志和事件系统。
#!/usr/bin/env bash
set -euo pipefail

cluster="payments-prod"
old_primary="${PATRONI_OLD_PRIMARY:-unknown}"
request_id="${cluster}-$(date +%s)-${HOSTNAME}"

# 示例接口仅表达契约,不对应任何云厂商。
# fencectl 必须完成幂等隔离并返回可验证状态。
fencectl isolate \
  --cluster "$cluster" \
  --node "$old_primary" \
  --request-id "$request_id"

state="$(fencectl status --cluster "$cluster" --node "$old_primary" --output state)"
case "$state" in
  powered_off|storage_revoked|network_isolated)
    logger -t patroni-fence "request=$request_id old_primary=$old_primary state=$state"
    exit 0
    ;;
  *)
    logger -p authpriv.crit -t patroni-fence \
      "FENCE_NOT_PROVEN request=$request_id old_primary=$old_primary state=$state"
    exit 42
    ;;
esac

高风险反例是:脚本超时后返回 0、只 Ping 旧主、只执行 systemctl stop postgresql、或者把“无法访问”误判为“已断电”。

5.4 Patroni REST API 与 HAProxy

Patroni REST API 同时服务于节点间选举、patronictl、监控和负载均衡健康检查。GET /primary/read-write 只应在节点是持有 Leader Lock 的 Primary 时返回 200;/replica?lag=... 可筛选健康且延迟受限的副本。7

# 参考片段:两个 HAProxy 实例应再由 VIP/云 LB/Keepalived 提供入口冗余。
global
    log stdout format raw local0

 defaults
    mode tcp
    timeout connect 3s
    timeout client  30s
    timeout server  30s

frontend postgres_rw
    bind :5432
    default_backend patroni_primary

backend patroni_primary
    option httpchk GET /primary
    http-check expect status 200
    default-server inter 2s fall 3 rise 2 on-marked-down shutdown-sessions
    server pg-a 10.10.1.11:5432 check port 8008
    server pg-b 10.10.2.11:5432 check port 8008
    server pg-c 10.10.3.11:5432 check port 8008

frontend postgres_ro
    bind :5433
    default_backend patroni_replicas

backend patroni_replicas
    balance leastconn
    option httpchk GET /replica?lag=64MB
    http-check expect status 200
    default-server inter 2s fall 3 rise 2
    server pg-a 10.10.1.11:5432 check port 8008
    server pg-b 10.10.2.11:5432 check port 8008
    server pg-c 10.10.3.11:5432 check port 8008

on-marked-down shutdown-sessions 的目的,是在后端失去 Primary 资格后主动终止经过该 HAProxy 的旧 TCP 会话,从而缩短“连接还黏在旧主”的窗口。启用前必须在预生产验证驱动、长事务和批处理的失败行为。

REST API 的危险写端点必须使用 TLS、认证、allowlist 和最小权限;不要把 8008 端口公开给业务网或互联网。

5.5 PgBouncer:压缩连接,不负责迁移事务

[databases]
payments = host=rw-db.internal port=5432 dbname=payments

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
pool_mode = transaction
max_client_conn = 4000
default_pool_size = 80
reserve_pool_size = 20
reserve_pool_timeout = 2
max_db_connections = 180
server_connect_timeout = 3
server_login_retry = 3
server_idle_timeout = 30
server_lifetime = 300
query_wait_timeout = 5
client_idle_timeout = 300
auth_type = scram-sha-256
admin_users = pgbouncer_admin
stats_users = pgbouncer_stats

这些数值基于“两个 PgBouncer 实例、数据库总连接预算约 240”的参考系统;真实值必须结合事务并发和 pgxpool 层数重新计算。多层连接池最常见的错误是每一层都按峰值放大,最终造成数据库连接风暴。

PgBouncer 的服务端 TCP 连接同样不能迁移。Failover 后可由 HAProxy 关闭后端会话,或在确认新 Primary 已就绪后执行受控的:

RECONNECT payments;

RECONNECT 会让旧服务端连接在归还池后关闭并重新解析目标;不要在未确认角色和流量窗口时盲目执行。事务池模式还会限制 Session 级临时表、Session Advisory Lock、某些 SET 行为和依赖会话状态的代码。

5.6 读写端点与 DNS

推荐暴露两个语义明确的端点:

  • rw-db.internal:6432:只路由到当前 Primary;
  • ro-db.internal:6432:只路由到满足延迟阈值的 Replica;关键“写后读”仍走 rw

DNS 只负责找到代理入口,不直接把单个 Primary IP 当作 HA 机制。TTL 降低也不能强制已运行进程立刻丢弃 DNS 缓存,更不能迁移现有 TCP。Go/libpq 风格连接串可配置多个主机与 target_session_attrs=read-write 作为代理故障时的受控兜底,但这仍要求客户端重建连接。8

5.7 常用 SQL、系统视图与函数

当前角色与可写性

SELECT
    inet_server_addr() AS server_addr,
    pg_is_in_recovery() AS is_replica,
    current_setting('transaction_read_only')::boolean AS transaction_read_only,
    current_setting('synchronous_commit') AS synchronous_commit;

Primary 上查看复制发送与同步状态

SELECT
    application_name,
    client_addr,
    state,
    sync_state,
    sync_priority,
    sent_lsn,
    write_lsn,
    flush_lsn,
    replay_lsn,
    pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS replay_gap,
    write_lag,
    flush_lag,
    replay_lag
FROM pg_stat_replication
ORDER BY application_name;
  • sync_stateasyncpotentialsyncquorum
  • flush_lsn:副本已持久化到磁盘的 WAL 位置;
  • replay_lsn:副本已重放位置;
  • write_lag/flush_lag/replay_lag:最近提交样本的时间估计,不应替代 LSN 字节差和业务探针。

Replica 上查看接收与重放

SELECT
    pg_last_wal_receive_lsn() AS receive_lsn,
    pg_last_wal_replay_lsn() AS replay_lsn,
    pg_size_pretty(
        pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn())
    ) AS local_replay_gap,
    pg_last_xact_replay_timestamp() AS last_replay_ts,
    clock_timestamp() - pg_last_xact_replay_timestamp() AS replay_time_gap;

空闲系统可能长时间没有事务,因此 replay_time_gap 变大不必然表示复制故障;应结合心跳 WAL、接收进程和字节差判断。

Slot 对 WAL 的保留量

SELECT
    slot_name,
    slot_type,
    active,
    active_pid,
    restart_lsn,
    wal_status,
    safe_wal_size,
    pg_size_pretty(
        pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
    ) AS retained_wal
FROM pg_replication_slots
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC NULLS LAST;

归档状态

SELECT
    archived_count,
    last_archived_wal,
    last_archived_time,
    failed_count,
    last_failed_wal,
    last_failed_time,
    stats_reset
FROM pg_stat_archiver;

连接和等待

SELECT
    application_name,
    state,
    wait_event_type,
    wait_event,
    count(*) AS sessions,
    max(clock_timestamp() - xact_start) AS max_xact_age
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY application_name, state, wait_event_type, wait_event
ORDER BY sessions DESC;

5.8 版本差异

  • PostgreSQL 14—18 都具备本章依赖的流复制、同步复制、Timeline、Replication Slot 与 pg_rewind 主路径。
  • [PG16+] pg_stat_io 可进一步区分 backend、checkpointer、autovacuum 等对象的 I/O 行为;它有助于判断 Failover 后冷缓存、恢复和 Checkpoint 压力。
  • [PG18] 异步 I/O 能改善部分读取/预取路径,但不会消除同步提交的网络 RTT、WAL fsync、DCS 仲裁、Fencing 或应用重连时间。
  • 版本升级前必须在目标 PostgreSQL 与 Patroni 组合上重新验证 REST 端点、参数、扩展、备份工具和 pg_rewind

6. 底层原理

6.1 正常提交路径

PostgreSQL Patroni、Fencing、故障转移与综合高可用架构 flow 2

PostgreSQL 同步复制把提交等待扩展到同步副本的指定阶段。synchronous_commit=on 通常等待远端 WAL flush;remote_apply 还会等待重放,因此可提供更强的写后读可见性,但延迟更高。同步提交至少增加一次到同步副本的网络往返,并可能让事务持有锁更久。9

6.2 Patroni HA 状态机

Replica
  │ 每个 loop_wait:读取 DCS、检查 PostgreSQL、发布 LSN/REST 状态

  ├─ Leader Lock 尚有效 ───────────────► 继续 Replica

  └─ Leader Lock 失效
       │ 检查 nofailover / timeline / lag / sync-quorum 资格
       │ 与其它候选比较 LSN

    竞争 Leader Lock

       ├─ 失败 ───────────────────────► Replica

       └─ 成功
            │ 执行 pre_promote,证明旧写者已隔离
            ├─ 非零退出:删除 Leader Key ─► Replica
            └─ 成功:pg_promote()

                 Primary
                    │ 周期续约 Leader Lock
                    ├─ DCS短暂异常且在 retry_timeout 内恢复:继续
                    ├─ failsafe 条件满足:受控继续
                    └─ 无法安全续约/无法证明成员状态:降级/停止

ttl 可以理解为自动 Failover 最早启动窗口的一部分,而不是完整 RTO。对于 PostgreSQL 进程崩溃,primary_start_timeout 还给本机 Crash Recovery 留出恢复时间;官方给出的最坏估算是 loop_wait + primary_start_timeout + loop_wait,若该参数为 0,则约为一个 loop_wait,但更激进的 Failover 会增加不必要切换和异步丢失风险。2

6.3 非计划 Failover 时间线

PostgreSQL Patroni、Fencing、故障转移与综合高可用架构 flow 3

6.4 为什么旧 Primary 不能直接回来

Promotion 会创建新 Timeline。旧 Primary 可能持有只存在于旧分支的 WAL,也可能缺少新 Primary 上已提交的 WAL。如果两者都接受写入,两个分支没有通用的自动合并语义。

安全回归只有两条主要路径:

  1. pg_rewind:找到共同检查点,复制分叉后变化的数据块和必要 WAL,把旧主对齐到新主;目标必须停止,且需要数据校验和/wal_log_hints 等前提。失败后目标数据目录可能不再可用,所以执行前要有重新克隆方案。6
  2. Reinitialize:删除旧数据目录,从新 Primary 或备份重新建立副本;更慢但边界更清晰。

6.5 DCS 失效与 Split Brain 边界

DCS 是控制面的共识来源。正常模式下,Primary 无法更新 DCS 时应在 Leader Lock 过期前主动降级,从而不给另一个分区提升后留下双写窗口。开启 Failsafe 后,Primary 只有在能通过 REST 联系到 DCS 中全部已知成员时才可继续;这适合“DCS 整体不可用但数据库节点彼此仍连通”的故障,不适合用来忽略节点间网络分区。4

6.6 Commit Outcome Unknown

客户端的观察链路是:

COMMIT 请求已发出
  ├─ Primary 在写 WAL 前断开:未提交
  ├─ Primary 本地提交后、回包前断开:可能已提交
  ├─ 同步确认到达后、回包前断开:已具备持久性但客户端不知道
  └─ 客户端超时并取消后端:可能处于特殊同步等待窗口

因此:

  • Connection Reset、超时或 Commit 返回错误不能直接映射为“安全重试”;
  • 对完整事务可自动重试的典型 SQLSTATE 是 4000140P01,前提是业务操作可重放;
  • Commit 结果不确定时,应使用 Idempotency Key 查询业务结果或进入对账队列;
  • SQLSTATE 08007 的名称就是 transaction_resolution_unknown,应用还应把其它连接类错误发生在 Commit 阶段的情形提升为未知结果。10

7. 内部数据结构和状态

7.1 DCS 中的关键状态

状态含义故障时的重要性
/leader当前 Leader Lock 与租约过期后候选才可竞争;不能手工复制成两个
/members/<name>节点 REST 地址、角色、状态、LSN、标签候选比较与 Failsafe 成员列表依赖它
/config动态配置修改后由所有 Patroni HA 循环应用
/sync最新 Leader、同步/Quorum 成员与票数限制谁能在不丢已确认事务的前提下提升
/historyFailover 与 Timeline 历史审计和分叉判断
/failsafeFailsafe 拓扑信息DCS 故障时用于逐一联系全部已知成员

DCS 中的状态是“控制意图与最近观测”,不是业务数据副本。LSN 仍需从 PostgreSQL 复制状态验证。

7.2 PostgreSQL 状态

  • WAL Record / LSN:Primary 产生单调推进的 WAL 位置;副本分别有 receive、write、flush、replay 位置。
  • Timeline ID:每次从恢复中提升形成新分支;Timeline History 记录分叉点。
  • Replication Slot:通过 restart_lsn 保留订阅者可能需要的 WAL;失控会把 pg_wal 撑满。
  • Shared Buffers 与 OS Page Cache:新 Primary 虽然数据完整,但缓存常是冷的,Failover 后 P95/P99 可能显著抬升。
  • Lock 与事务:故障会中断旧 Primary 上所有未完成事务;客户端必须重启完整事务,不能从中间语句续跑。
  • 系统目录:角色、对象与权限通过物理 WAL 复制;但节点本机配置、证书、systemd、watchdog 和代理配置不在 PostgreSQL 数据目录内。

7.3 应用与代理状态

  • pgxpool 内部持有已建立的 TCP 连接;角色变化不会自动改写这些 socket 的远端地址。
  • PgBouncer 持有客户端连接和服务端连接两个池;客户端保持不变并不意味着后端已经切换。
  • HAProxy 的健康状态只影响新路由或被主动关闭的会话;没有主动 shutdown 的既有 TCP 可能继续存在。
  • DNS Resolver、sidecar、JVM/Go 进程、操作系统和代理都可能有独立缓存周期。

8. 场景和选型决策

业务场景推荐方案不推荐方案原因性能代价并发代价一致性代价HA 代价运维复杂度
支付账务,已确认写不可丢三 AZ、Patroni sync/quorum strict、Fencing、幂等两节点异步自动提升需要把安全提交集合写入选主规则同步 RTT、尾延迟上升同步故障时提交排队极低;未知提交仍需对账无同步节点时可能停止写
可重建缓存/搜索索引异步 Patroni,较小 RTOstrict 同步跨 AZ数据可重放,低延迟优先故障重试较多允许有限 RPOFailover 更快
跨地域强一致写单写地域 + 异步 DR;业务层双写协议另行设计直接把远端副本设为每次同步提交WAN 抖动会进入写延迟和可用性高 WAN RTT锁持有时间增大同步可强但可用性差网络分区难处理很高
读多写少报表异步只读端点 + 延迟阈值 + 查询隔离所有读随机打副本明确陈旧度与长查询影响增加读容量副本长查询/冲突允许陈旧不应作为唯一故障候选
Kubernetes 平台Patroni Kubernetes DCS/Operator,跨节点与 Zone,独立备份把 Pod 重启等同于数据库 HAK8s 调度与数据库时间线是不同状态机控制面开销Pod 同时重建风险取决于复制模式API Server/DCS 也需 HA
传统 VM/裸机Patroni + etcd/Consul + HAProxy/PgBouncer + watchdog/STONITH共享 VIP 但无角色健康检查组件边界明确、可独立演练多一层代理需连接预算可精确选择需维护 DCS 与入口
小团队、非关键系统托管 PostgreSQL HA + 明确 SLA/恢复演练自建 Patroni 后无人值守降低控制面运维负担服务成本受服务配额由服务能力决定供应商边界需验证低到中

8.1 etcd、Consul 与 Kubernetes DCS

  • etcd:Raft 多数派,三节点可容忍一节点故障;适合显式自建控制面。
  • Consul:同样以共识和 Session/Lock 提供协调;需要管理 ACL、Agent、Server quorum 和故障域。
  • Kubernetes DCS:Patroni 可用 ConfigMap 或 Endpoints 等 Kubernetes API 对象参与选主;必须把 API Server/etcd 可用性、PodDisruptionBudget、拓扑分布和节点关机隔离纳入设计。

不要在同一个 Patroni 集群内同时把多个 DCS 当成并行真相源。迁移 DCS 必须按官方步骤和演练进行。


9. 高性能分析

9.1 CPU、内存与缓存

Failover 后的性能问题经常不是“新 Primary 不健康”,而是缓存和后台状态改变:

  • 新 Primary 的 shared_buffers 与 OS Page Cache 可能缺少热点页;
  • Checkpoint、恢复结束后的写入、Autovacuum 和业务查询可能同时争用 CPU/I/O;
  • 连接风暴造成 TLS、认证、Backend Process 创建和计划缓存重建;
  • 只读副本转为 Primary 后,原本为报表预留的 CPU 可能不足以承载写入。

监控应比较 Failover 前后:CPU user/system/iowait、pg_stat_io [PG16+]、Buffer 命中率、磁盘队列、WAL fsync、Checkpoint、活跃连接和查询 P95/P99。

9.2 网络往返与同步提交

同步提交的写延迟至少包含:

业务执行 + Primary WAL flush + 到同步副本的网络 RTT
+ 副本 write/flush(或 apply)+ 回包排队

因此不能只看平均 RTT。应压测丢包、跨 AZ 尾延迟、突发 WAL、同步副本 Checkpoint 和磁盘抖动。quorum commit 的价值在于可由较快的合格副本满足 ANY n,但它不能弥补所有候选同时慢。

9.3 WAL、Checkpoint 与写放大

  • 同步/异步复制都会传输完整 WAL;副本数量增加网络与 WAL Sender 开销,但不会让 Primary 业务数据页写入乘以副本数。
  • Replication Slot、归档失败或失联副本会放大本地 WAL 保留,最终转化为磁盘可用性故障。
  • Checkpoint 过密提高数据页写放大;过疏会增加 Crash Recovery 时间和磁盘峰值。HA 设计必须同时看 RTO 与稳态写入。
  • pgBackRest 备份和校验占用顺序 I/O、网络与 CPU;应限速并避开峰值,但不能因为性能压力长期停止备份。

9.4 PostgreSQL 18 AIO 的边界

[PG18] AIO 可改善部分读取、预取和后台 I/O 路径,但 HA 的关键等待仍包括 WAL 持久化、同步确认、DCS 租约、Fencing、Promotion、代理收敛和应用重连。不要把 AIO 当作降低 RPO/RTO 的直接开关。

9.5 读放大、写放大与空间放大

  • 读副本可以降低 Primary 查询负载,但会增加副本缓存、索引、Vacuum 和监控成本;读路由错误会造成业务一致性缺陷。
  • 每个副本保存完整物理数据,空间放大近似节点数;备份库和 WAL 归档另计。
  • 长时间保留 Slot 可能让 pg_wal 空间放大失控。
  • Failover 后在冷缓存下进行全量健康探针或同时重跑大量查询,会制造额外读放大。

10. 高并发分析

10.1 五个不能混为一谈的数量

指标含义HA 时的主要风险
goroutine 数应用并发任务无界创建会把故障变成内存与重试风暴
客户端连接数到 PgBouncer/数据库的 socket同时重建会打爆入口与认证
数据库连接数PostgreSQL Backend Process 数内存、调度和锁表压力
活跃查询数正在 CPU/I/O/Lock 上工作的 SQL决定实际数据库并发与排队
TPS/QPS完成的事务/语句速率高吞吐不等于低排队或低尾延迟

10.2 锁与长事务

同步副本慢时,提交事务可能等待 SyncRep,期间相关锁尚未释放,后续事务形成阻塞队列。此时盲目提高连接数只会扩大队列。应使用 pg_stat_activity.wait_event_type='IPC'、具体 wait_event、锁等待链和同步复制指标联合判断。

Primary 崩溃后,所有未提交事务回滚;新 Primary 不继承内存中的 Lock、Snapshot、临时表或 Session 状态。应用必须从事务入口重做,而不是继续执行“下一条 SQL”。

10.3 Retry Storm 与 Connection Storm

危险时间线:

Primary 故障
→ 100 个应用实例同时收到错误
→ 每实例 200 个 goroutine 无退避重试
→ 每个 pgxpool 同时补齐 MinConns
→ PgBouncer/HAProxy/新 Primary 同时承受数万次握手与重复写
→ 新 Primary 尚未热身即再次过载

控制方法:

  • 每实例使用有界 semaphore;
  • 指数退避并加入全抖动 Jitter;
  • MaxConnsMinConns、PgBouncer server pool 和数据库连接预算统一计算;
  • 连接池 MaxConnLifetimeJitter 打散生命周期;
  • Circuit Breaker 在持续数据库故障时快速失败;
  • Load Shedding 优先拒绝非核心请求;
  • Readiness 在数据库不可用或只读时摘除实例,但 Liveness 不应因短暂数据库故障重启整个应用;
  • 只有完整事务的明确可重试错误才自动重试;未知 Commit 进入对账。

10.4 幂等与事务边界

Idempotency Key 必须与业务动作处在同一个数据库事务里,并受唯一约束保护。仅在应用内存保存“已处理请求”无法跨进程、重启和 Failover。不要在数据库事务中调用慢外部支付网关;采用本地事务 + Outbox,再由异步消费者调用外部服务。


11. 高可用分析

11.1 RPO 分层

故障范围参考目标主要机制仍需注意
PostgreSQL 进程/单主机已确认关键事务 RPO=0区域内同步/Quorum、Patroni 安全候选Commit 未知、同步等待取消窗口
单 AZ已确认关键事务 RPO=0同步节点在其它 AZ、入口与 DCS 跨 AZ剩余容量和网络收敛
整个区域RPO ≤ 5 min跨区域异步 Standby + WAL Archive最近异步 WAL 可能丢失,需要业务补偿
误删除/逻辑破坏回到指定时间点pgBackRest + WAL + PITRHA 副本会立即复制误操作,不能替代备份
静默损坏依校验和与备份历史Data Checksums、备份校验、恢复演练多副本可能复制逻辑错误或部分损坏

11.2 RTO 组成

RTO = 检测/租约窗口
    + 候选选择与 Fencing
    + Promotion/Crash Recovery
    + HAProxy/PgBouncer/DNS 收敛
    + 应用退避与池重建
    + 缓存预热/负载恢复

因此将 ttl 从 30 秒改成 10 秒,并不能保证 RTO 下降 20 秒;它可能因为网络抖动带来误切换。RTO 必须由端到端演练测得。

11.3 Backup、PITR 与 HA 的职责边界

  • Patroni 解决节点角色与自动 Failover;
  • 流复制提供近实时副本;
  • pgBackRest Repository 提供独立时间维度和介质;
  • PITR 解决误删除、错误发布和需要回到历史时刻的问题;
  • 跨区域 Standby 解决区域级业务恢复;
  • 监控和演练证明上述链路真实可用。

pgBackRest 的 Repository 保存备份集和归档 WAL。归档积压、Repository 不可写、错误使用队列上限都可能破坏 PITR 连续性,因此必须同时监控数据库端 pg_stat_archiver 与 Repository 端状态。11

11.4 Planned Switchover、Failover 与 Failback

  • Switchover:健康集群内选择目标副本,确认追平、连接与容量,受控切换;通常不应丢数据。
  • Failover:Primary 不健康或不可达,必须先保证 Fencing,再由合格副本提升;patronictl failover 可越过部分安全限制,必须按故障流程审批。
  • Failback:先把旧主作为 Replica 安全重建并稳定追平,再决定是否另做一次 Planned Switchover。不要为了“拓扑好看”立即回切。

11.5 Graceful Degradation

当同步副本全部失联且 strict 模式阻塞写入时,正确策略通常是:

  1. 保持账务写失败关闭;
  2. 允许静态/缓存读;
  3. 对非关键异步任务暂停消费;
  4. 在入口实施限流和明确错误码;
  5. 绝不在无审批情况下把 synchronous_commit 改成 off
  6. 若业务批准牺牲 RPO,必须记录变更窗口、风险、回滚和补偿计划。

12. 三维影响矩阵

维度相关度核心收益主要风险关键指标
高性能读扩展、受控连接、快速恢复同步 RTT、冷缓存、代理开销、备份 I/OQPS、P95/P99、WAL/s、fsync、复制延迟、Cache Hit、I/O queue
高并发连接压缩、有界重试、故障背压Connection/Retry Storm、锁等待放大、池层级失控活跃查询、连接获取等待、排队数、重试率、SyncRep wait、拒绝率
高可用极高自动选主、安全切流、可恢复旧主、区域 DRSplit Brain、未知 Commit、DCS 失去 quorum、Slot/WAL 写满RPO/RTO、Leader 变更、Fencing 成功率、归档连续性、恢复演练成功率

13. 二十类故障逐项分析

以下 RPO/RTO 均以第 1.1 节参考系统为前提。“自动”表示已有配置和健康条件满足时的预期行为,不表示可以取消人工确认与事后审计。

13.1 数据库节点与网络故障

# / 故障检测方式自动恢复与是否 FailoverRPO / RTO脑裂与 Fencing应用错误、Go 处理与重试旧节点恢复监控与演练
1. Primary PostgreSQL 主进程崩溃Patroni 本地进程检查、REST 状态、PostgreSQL 日志、pg_up、Leader 状态Patroni 先尝试在 primary_start_timeout 内本机 Crash Recovery;超时且有合格候选时自动 Failover同步候选健康时,已确认关键写目标 RPO=0;参考 RTO 约 20—60 s,须实测主机和 Patroni仍可控时风险较低;若进程状态不明且准备异机提升,仍应验证 watchdog/Fencing常见 57P0108006、EOF、Reset;事务中非 Commit 阶段的连接错误可重试完整事务;Commit 阶段进入未知结果并按幂等键对账若未发生提升,完成 Crash Recovery;若已产生新 Timeline,先保持隔离,再 pg_rewind 或重建告警 postmaster 重启、Leader 变更、Crash Recovery 时长;演练 kill -9 postmaster,并核对业务幂等与 RTO
2. Primary 主机宕机主机心跳、DCS Leader Lock 不再续约、Patroni成员过期、HAProxy /primary 失败无法本机恢复;租约到期、候选通过安全检查和 Fencing 后自动 Failover同步候选健康时目标 RPO=0;参考 RTO 30—60 s高风险;“主机不可达”不等于“已关机”,提升前需 STONITH/云实例状态证明Connection Refused/Timeout/Reset;有界重连、池 Reset()、Jitter;Commit 错误不可盲重试主机回来后不得直接提供 SQL;先核对 Timeline,作为 Replica rewind/reinitialize监控主机、电源、DCS租约、Fencing耗时;演练关机或云实例 stop,验证旧机回归流程
3. Primary 与 DCS 断网Patroni DCS 调用失败、Leader续约失败、DCS端仍可见其它节点、网络路径探针正常模式下旧 Primary 在租约到期前主动降级,DCS多数派侧可 Failover;Failsafe 仅在它能联系全部已知成员时允许继续安全同步候选下 RPO=0;RTO 取决于 TTL、降级和提升典型脑裂场景;watchdog、Leader租约规则与外部 Fencing缺一不可旧连接可能收到 Admin Shutdown、Read-Only、Reset;Go 摘除 readiness、限流重连;Commit按未知结果处理网络恢复后旧主保持降级,检查是否已出现新 Timeline,再 rewind/reinit分别探测 DB→DCS、DB→DB、DCS→DB 的非对称网络;演练只阻断 Primary 到 etcd,不能只做全网断开
4. Primary 与 Replica 断网pg_stat_replication 消失/状态变化、WAL gap、Patroni同步成员变化、Replica WAL Receiver日志Primary 与 DCS正常时通常不 Failover;Patroni可把同步职责切到另一追平副本;strict 模式在无合格同步节点时阻塞写已确认写目标 RPO=0;写暂停通常为 HA 循环与候选追平窗口,RTO不等同于主库切换通常无需隔离 Primary;若网络分区同时影响 DCS,则按故障 3 处理写可能等待 SyncRep 并在客户端超时;不要用短超时取消 COMMIT;超时发生在 Commit 时对账,不立即重复扣款失联 Replica 恢复流复制;若 WAL 已回收则从归档追赶或重建监控 sync_state、WAL字节差、SyncRep wait、提交P99;演练分别断开一个和两个复制链路
7. 同步 Replica 失联sync_state 消失、Patroni /sync 改变、REST /synchronous 失败、提交等待抬升Primary 不必 Failover;另一健康副本可被选为同步节点;strict 且无替代时写阻塞仍以 RPO=0 为目标;切换同步节点的短暂写停顿需测量不需 Fencing 旧同步副本才能继续 Primary;但回归前确认其角色仍为 Replica可能出现事务超时而非连接错误;Go执行 Load Shedding、延长关键 Commit预算、未知结果对账检查磁盘、网络、WAL接收;追不上则重建,恢复后再允许 nosync=false告警同步节点数、同步提交延迟;演练 stop Patroni/PostgreSQL 与复制网卡故障

13.2 DCS、复制资格与旧主回归

# / 故障检测方式自动恢复与是否 FailoverRPO / RTO脑裂与 Fencing应用错误、Go 处理与重试旧节点恢复监控与演练
5. 单个 DCS 节点故障etcd member/endpoint health、Raft leader与proposal指标、磁盘延迟3节点仍有2/3,多数派可工作;不应触发 PostgreSQL FailoverRPO=0,业务RTO≈0;控制面冗余下降无需数据库 Fencing;错误地同时移除/添加成员可能导致失去 quorum应用通常无感;不要因 DCS 单节点告警重建数据库连接修复或按“先安全移除、再添加替代成员”的 DCS 流程恢复告警 quorum margin=1、DCS磁盘fsync;季度停一个etcd节点并验证选主仍正常
6. DCS 失去多数派etcd无法提交、endpoint status分裂、Patroni续约失败不应自动产生新 Leader。正常模式 Primary 会降级;Failsafe满足“联系全部已知成员”时可维持现主,但仍不能选新主业务数据RPO取决于现主是否继续;RTO直到安全恢复 quorum 或受控灾难流程完成极高;禁止在不同分区分别 force-new-cluster 或手工 Promote;任何手工提升前先 Fence 其它写者可能只读、Admin Shutdown、连接失败;Go熔断、快速失败、保持少量探针,不能持续满速重连先恢复原DCS多数派;若永久丢失,依据最新一致快照重建唯一DCS,再逐节点验证监控可提交性而非仅进程存活;演练停两个DCS节点,验证不会自动产生第二主
8. 所有 Replica 延迟过大maximum_lag_on_failover 不满足、Patroni候选为空、WAL gap与replay时间抬升Primary健康时不 Failover;Primary故障后应保持无主,而不是自动提升严重落后节点不手工越权时可保护既有RPO目标,但RTO变为“直到候选追平/旧主恢复/业务批准数据损失”若手工强制提升,必须先 Fence 旧主并明确接受RPO;否则脑裂和数据丢失并存应用不可连接或快速失败;重试必须限速;关键交易进入排队/补偿而不是切到只读副本写优先恢复最接近的副本;缺WAL则从Archive补齐或重建告警所有候选lag与WAL生成速率;演练暂停Replay并生成超过阈值WAL,再故障主库
9. 旧 Primary 重新上线Patroni检测角色/Timeline分叉、patronictl list、REST、pg_controldata/日志不应被自动作为可写节点接流量;Patroni可按配置 rewind,失败则等待人工或reinitialize若Fencing有效,RPO不变;恢复RTO不应计入新主业务RTO极高;必须保持电源、网络、存储或入口隔离,直到确认其为新Timeline上的Replica若DNS/直连错误,可能 Read-Only、Reset,最坏会双写;Go连接后做读写角色验证,收到25006立即废弃连接并重置池停库→检查共同祖先→pg_rewind→作为standby启动→追平→解除隔离;不满足条件则全量重建告警同时出现两个pg_is_in_recovery()=false;演练旧主断电后恢复,验证不会自动接流量
19. Patroni 配置错误patronictl show-config、配置审计、日志、DCS /config 变更、角色/同步状态异常取决于错误;通常不应自动Failover来“修配置”。先停止变更并回滚可能从RPO=0退化为异步,或因strict误配造成写中断;RTO取决于发现速度错误 scope/namespace/DCS 可把节点组成第二集群,脑裂风险极高,必须Fence并封锁入口Go只看到只读、连接失败或延迟;应用不能判断配置根因,只能熔断/降级回滚动态配置;逐节点校验本地YAML、环境变量与DCS优先级;必要时重启单节点配置即代码、双人审批、Canary、差异告警;演练错误lag阈值、strict切换,但不要在生产试错
20. 两个节点被人工 Promote两个节点均 pg_is_in_recovery()=false、两个Timeline推进、不同入口均可写、审计发现手工命令自动化无法合并;立即停止所有写入口并进入重大事故流程RPO/RTO暂时不可声明;需确定权威分支并做业务级差异核对脑裂已经发生。先Fence所有非权威候选,必要时全停,再选唯一权威节点Go必须停止写和自动重试;相同幂等键可能已在两个分支产生不同结果,需人工对账对比Timeline、LSN、业务流水与外部事实;选权威分支,其余全部重建,不能双向物理合并全局告警“可写节点数≠1”;限制pg_promote与systemd权限;演练用隔离实验环境模拟双主并执行取证

13.3 入口、连接与提交故障

# / 故障检测方式自动恢复与是否 FailoverRPO / RTO脑裂与 Fencing应用错误、Go 处理与重试旧节点恢复监控与演练
10. HAProxy 故障LB/VIP健康、进程/端口、连接错误率、备用实例状态由第二HAProxy、VIP或云LB接管;不应触发数据库 FailoverRPO=0;入口RTO应为秒级并单独测量无数据库脑裂;若两个代理健康检查规则不同,可能同时把不同节点视为PrimaryConnection Refused/Timeout;Go有界重连、Jitter;事务中断重做,Commit阶段对账无数据库旧节点;修复代理并验证配置哈希和/primary结果后再接流量监控两实例与VIP;演练杀进程、断网、错误后端检查,验证连接不会打到Replica
11. PgBouncer 故障管理库SHOW POOLS/STATS、进程/端口、客户端等待、服务端连接数双实例或sidecar重建;不触发数据库FailoverRPO=0;入口RTO秒到十几秒,取决于上游发现与重连无数据库脑裂;但错误目标配置可把写连接送到旧主客户端连接Reset;非Commit事务可完整重试;Commit错误对账。不要每个实例同时补满MinConns修复后空池启动、逐步放量;Session Pool用户需处理会话状态丢失监控cl_waiting、server active/idle、登录失败;演练单实例退出及RECONNECT行为
12. DNS 缓存旧地址客户端连接目标日志、DNS查询追踪、不同实例解析结果、旧入口连接仍增长DNS最终收敛,但不能迁移现有TCP;通常不触发数据库Failover数据RPO取决于旧地址是否被Fence;RTO受进程缓存和连接生命周期支配,不能只看TTL若DNS直指旧Primary且旧主未隔离,脑裂风险极高;代理/VIP可缩小风险Refused/Timeout/Read-Only;Go在角色/连接错误后pool.Reset(),使用多host+target_session_attrs=read-write或稳定代理入口旧地址必须返回失败或只读,直至所有客户端收敛记录远端IP、DNS年龄、池连接寿命;演练切换DNS并保留长连接,观察真实收敛时间
13. Failover 期间 Commit 超时应用Commit阶段日志、SQLSTATE/网络错误、幂等表与业务流水不一致告警数据库Failover可能自动完成;业务结果不能靠自动重试判定同步复制可使数据RPO=0,但客户端语义为Unknown;业务RTO包含对账时间仍需完成旧主Fencing,防止相同请求在两个分支各成功将错误包装为CommitUnknown;重连后按Idempotency Key查询;查到成功则返回原结果,查不到且能证明事务未提交后才重试旧主按标准rewind/reinit,不从旧分支“补写”未知交易指标区分execute错误与commit错误、未知结果数量/对账时长;演练在COMMIT附近断网/停主库

13.4 容量、逻辑破坏与故障域

# / 故障检测方式自动恢复与是否 FailoverRPO / RTO脑裂与 Fencing应用错误、Go 处理与重试旧节点恢复监控与演练
14. 数据盘写满OS df/inode、PostgreSQL PANIC/ENOSPC、WAL/数据/临时文件目录、Checkpoint/Archive失败可能导致PostgreSQL崩溃并Failover,但盲目Failover会把同类容量问题带到新主;先确认副本磁盘与Slot同步副本健康时目标RPO=0;RTO取决于安全释放空间、恢复或提升若提升,仍Fence旧主;严禁直接删除活动pg_wal文件53100 disk_full、连接中断、只读降级;Go停止非核心写、熔断,不对磁盘满进行高速重试扩容/清理安全对象后做一致性检查;若已分叉则rewind/reinit70/80/90%分级告警、inode、增长预测;演练用独立小文件系统填满,禁止生产随意实验
15. WAL 因 Slot 写满pg_replication_slots retained WAL、pg_wal目录、wal_status/safe_wal_size、消费者LSN不应靠Failover解决;Slot可能随集群继续保留。应恢复消费者、限流写入,或经审批丢弃/推进Slot业务数据尚未丢,但数据库可能因磁盘满停写;删除逻辑Slot会造成CDC缺口通常无脑裂;若Primary崩溃后提升仍需标准Fencing53100、写失败;Go Load Shedding,不能无限重试。CDC消费者必须能发现LSN缺口并重建修复消费者;确认下游重放点;必要时删除Slot并全量重做,清理后验证归档连续性监控每Slot retained bytes、消费速率、预计填满时间;演练暂停消费者并设置受控max_slot_wal_keep_size
16. 误删除审计日志、业务量突降、行数/校验异常、变更记录流复制会快速复制删除,不做Failover;冻结破坏源并从备份PITR到事故前HA副本RPO无帮助;PITR RPO取决于备份+归档连续性,RTO取决于恢复6TiB和逻辑回灌通常不需节点Fencing,但应Fence错误作业/账号并暂停相关写删除已成功就不能通过重试修复;Go应使用权限、审批和影响行数防护在隔离环境PITR,校验后逻辑导出缺失数据;全库回切仅在影响广泛时采用监控大规模DML、审计与备份恢复;演练指定时间PITR和单表数据回灌
17. 可用区故障区域/AZ告警、节点与网络同时失联、DCS成员、入口实例状态DCS剩余2/3继续;若Primary在故障AZ,跨AZ同步候选自动提升;入口切到幸存实例参考目标RPO=0,RTO≤60s;前提是同步副本和入口位于其它AZ需对失联AZ中的旧Primary做云级Fence,防止AZ恢复后带着旧角色回来大量Reset/Timeout;Go分散Jitter、降载到剩余容量,未知Commit对账AZ恢复后按旧主流程重建;不要自动把所有服务同时扩回监控故障域分布、剩余容量;季度切断整个AZ的DB+DCS+代理路径
18. 区域故障区域级健康、所有本地DCS/DB/入口不可达、跨区域WAL/Archive lag由独立DR控制面执行受控Promote和全局切流;通常不建议完全无人值守自动跨区提升参考RPO≤5min(实际为最后收到/归档的WAL);RTO≤60min需恢复演练证明必须全局Fence旧地域:撤销写入口、凭据、网络/存储;区域恢复时绝不能自动双写DNS/全局LB、Refused/Timeout;Go重新解析并重建池,按幂等键对账跨区窗口旧地域作为新DR重建;禁止把两个地域的分叉物理“合并”监控跨区receive/replay/Archive lag;半年做隔离式DR演练与回迁演练

13.5 故障矩阵的三个判定原则

  1. 能 Failover 不等于应该 Failover:误删除、Slot 积压、代理故障和全副本落后都不应通过随意提升“解决”。
  2. 不可达不等于已隔离:网络分区中的旧 Primary 可能仍在服务另一个客户端群体。
  3. RPO=0 不等于客户端知道结果:同步持久性和 Commit Outcome Unknown 属于不同层次。

14. Go、pgx 与故障转移

14.1 应用必须处理的错误类别

PostgreSQL 建议客户端按 SQLSTATE 而不是本地化错误文本分类。10

类别典型 SQLSTATE/错误含义默认策略
事务可重试40001, 40P01Serialization Failure、Deadlock在幂等前提下重试完整事务,有上限/退避/Jitter
角色变化25006read_only_sql_transaction废弃旧连接、重置池、重新连接可写端点
服务关闭/恢复57P01, 57P02, 57P03Admin Shutdown、Crash Shutdown、Cannot Connect Now限速重连;事务中断完整重做;Commit阶段未知
连接类SQLSTATE 08xxx、Refused、Reset、EOF网络或服务端连接失效依据失败阶段判断;Commit阶段按未知结果
资源不足53100磁盘满/资源不足熔断、降载、告警;不是高速重试对象
业务约束23505, 23503, 23514唯一、外键、Check违反通常返回业务错误;幂等键唯一冲突需要读取既有结果

pgconn.SafeToRetry(err) 只在驱动能确认数据尚未发送时提供窄范围保证,不能替代事务阶段与业务幂等判断。pgxpool.Pool.Reset() 会关闭池内连接但保持池对象可用,适合网络中断或服务端角色变化;已借出的连接会在归还时关闭。1213

14.2 幂等表

CREATE TABLE payment_requests (
    idempotency_key text PRIMARY KEY,
    account_id bigint NOT NULL,
    amount_cents bigint NOT NULL CHECK (amount_cents > 0),
    status text NOT NULL CHECK (status IN ('processing', 'succeeded')),
    payment_id bigint,
    created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
    completed_at timestamptz
);

CREATE TABLE payments (
    payment_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    account_id bigint NOT NULL,
    amount_cents bigint NOT NULL CHECK (amount_cents > 0),
    created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);

CREATE TABLE ledger_entries (
    entry_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payment_id bigint NOT NULL REFERENCES payments(payment_id),
    account_id bigint NOT NULL,
    delta_cents bigint NOT NULL,
    created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);

关键点是:幂等键占位、业务写入和最终结果更新必须在同一个事务提交。这样已提交的 payment_requests 行就是未知 Commit 的对账事实。

14.3 可编译示例

依赖安装:

go mod init example.com/ha-client
go get github.com/jackc/pgx/v5/pgxpool
export DATABASE_URL='postgresql://app_user:secret@pgbouncer-a:6432,pgbouncer-b:6432/payments?target_session_attrs=read-write&connect_timeout=3'
go run .

下面示例展示:池内旧连接处理、角色验证、Pool Reset、有界并发、熔断、Load Shedding、Readiness、退避与 Jitter、完整事务重试、Commit Unknown 对账,以及优雅停机。它仍需接入真实指标、分布式追踪、Secret Manager 和业务错误模型。

package main

import (
	"context"
	crand "crypto/rand"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"math/big"
	"net"
	"net/http"
	"os"
	"os/signal"
	"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 (
	ErrOverloaded  = errors.New("database admission limit reached")
	ErrCircuitOpen = errors.New("database circuit breaker is open")
	ErrWrongRole   = errors.New("connected PostgreSQL node is not writable")
)

type CommitUnknownError struct{ Cause error }

func (e *CommitUnknownError) Error() string {
	return "transaction commit outcome is unknown: " + e.Cause.Error()
}
func (e *CommitUnknownError) Unwrap() error { return e.Cause }

type PaymentResult struct {
	PaymentID int64
	Status    string
}

type Gate struct {
	tokens   chan struct{}
	maxQueue time.Duration
}

func NewGate(maxInFlight int, maxQueue time.Duration) *Gate {
	return &Gate{tokens: make(chan struct{}, maxInFlight), maxQueue: maxQueue}
}

func (g *Gate) Acquire(ctx context.Context) error {
	timer := time.NewTimer(g.maxQueue)
	defer timer.Stop()
	select {
	case g.tokens <- struct{}{}:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	case <-timer.C:
		return ErrOverloaded
	}
}
func (g *Gate) Release() { <-g.tokens }

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

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

func (b *CircuitBreaker) Allow() bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	return !time.Now().Before(b.openUntil)
}

func (b *CircuitBreaker) Success() {
	b.mu.Lock()
	b.failures = 0
	b.openUntil = time.Time{}
	b.mu.Unlock()
}

func (b *CircuitBreaker) Failure() {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.failures++
	if b.failures >= b.threshold {
		b.openUntil = time.Now().Add(b.openFor + fullJitter(500*time.Millisecond))
	}
}

type Store struct {
	pool      *pgxpool.Pool
	gate      *Gate
	breaker   *CircuitBreaker
	ready     atomic.Bool
	lastReset atomic.Int64
}

func envInt(name string, fallback int) int {
	raw := os.Getenv(name)
	if raw == "" {
		return fallback
	}
	n, err := strconv.Atoi(raw)
	if err != nil || n <= 0 {
		slog.Warn("invalid positive integer env; using fallback", "name", name, "value", raw)
		return fallback
	}
	return n
}

func fullJitter(max time.Duration) time.Duration {
	if max <= 0 {
		return 0
	}
	n, err := crand.Int(crand.Reader, big.NewInt(int64(max)+1))
	if err != nil {
		return max / 2
	}
	return time.Duration(n.Int64())
}

func backoff(attempt int) time.Duration {
	d := 100 * time.Millisecond
	for i := 0; i < attempt && d < 2*time.Second; i++ {
		d *= 2
	}
	if d > 2*time.Second {
		d = 2 * time.Second
	}
	return fullJitter(d) // Full Jitter: [0, exponential cap]
}

func sleepContext(ctx context.Context, d time.Duration) error {
	timer := time.NewTimer(d)
	defer timer.Stop()
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-timer.C:
		return nil
	}
}

func sqlState(err error) string {
	var pgErr *pgconn.PgError
	if errors.As(err, &pgErr) {
		return pgErr.Code
	}
	return ""
}

func isTxnRetryable(err error) bool {
	switch sqlState(err) {
	case "40001", "40P01":
		return true
	default:
		return false
	}
}

func isRoleOrConnectionError(err error) bool {
	code := sqlState(err)
	if strings.HasPrefix(code, "08") {
		return true
	}
	switch code {
	case "25006", "57P01", "57P02", "57P03":
		return true
	}

	if errors.Is(err, io.EOF) ||
		errors.Is(err, syscall.ECONNREFUSED) ||
		errors.Is(err, syscall.ECONNRESET) ||
		errors.Is(err, net.ErrClosed) {
		return true
	}
	var netErr net.Error
	return errors.As(err, &netErr)
}

func newPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
	cfg, err := pgxpool.ParseConfig(databaseURL)
	if err != nil {
		return nil, fmt.Errorf("parse DATABASE_URL: %w", err)
	}

	cfg.MaxConns = int32(envInt("DB_MAX_CONNS", 16))
	cfg.MinConns = 0 // 故障恢复时不让每个实例立即补满连接
	cfg.MaxConnIdleTime = 5 * time.Minute
	cfg.MaxConnLifetime = 30 * time.Minute
	cfg.MaxConnLifetimeJitter = 5 * time.Minute
	cfg.HealthCheckPeriod = 30 * time.Second
	cfg.PingTimeout = 2 * time.Second

	cfg.BeforeConnect = func(ctx context.Context, _ *pgx.ConnConfig) error {
		// 打散所有应用实例的新建连接,避免同时冲击 PgBouncer/Primary。
		return sleepContext(ctx, fullJitter(250*time.Millisecond))
	}

	cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
		if _, err := conn.Exec(ctx,
			`SELECT set_config('application_name', $1, false)`,
			"payments-api"); err != nil {
			return err
		}

		var writable bool
		if err := conn.QueryRow(ctx, `
            SELECT NOT pg_is_in_recovery()
               AND NOT current_setting('transaction_read_only')::boolean
        `).Scan(&writable); err != nil {
			return err
		}
		if !writable {
			return ErrWrongRole
		}
		return nil
	}

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

	pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()
	if err := pool.Ping(pingCtx); err != nil {
		pool.Close()
		return nil, fmt.Errorf("initial database ping: %w", err)
	}
	return pool, nil
}

func (s *Store) resetPoolThrottled() {
	now := time.Now().UnixNano()
	previous := s.lastReset.Load()
	if previous != 0 && time.Duration(now-previous) < 5*time.Second {
		return
	}
	if s.lastReset.CompareAndSwap(previous, now) {
		s.ready.Store(false)
		s.pool.Reset()
	}
}

func (s *Store) ApplyPayment(
	ctx context.Context,
	idempotencyKey string,
	accountID int64,
	amountCents int64,
) (PaymentResult, error) {
	if idempotencyKey == "" || accountID <= 0 || amountCents <= 0 {
		return PaymentResult{}, errors.New("invalid payment input")
	}
	if !s.breaker.Allow() {
		return PaymentResult{}, ErrCircuitOpen
	}
	if err := s.gate.Acquire(ctx); err != nil {
		return PaymentResult{}, err
	}
	defer s.gate.Release()

	const maxAttempts = 4
	var lastErr error

	for attempt := 0; attempt < maxAttempts; attempt++ {
		txCtx, cancel := context.WithTimeout(ctx, 4*time.Second)
		result, err := s.applyPaymentOnce(
			txCtx, idempotencyKey, accountID, amountCents,
		)
		cancel()

		if err == nil {
			s.breaker.Success()
			s.ready.Store(true)
			return result, nil
		}
		lastErr = err

		var unknown *CommitUnknownError
		if errors.As(err, &unknown) {
			// 不重放业务写。先丢弃可能连向旧角色的池,再查询幂等事实。
			s.resetPoolThrottled()
			checkCtx, checkCancel := context.WithTimeout(ctx, 3*time.Second)
			existing, found, checkErr := s.lookupPayment(
				checkCtx, idempotencyKey, accountID, amountCents,
			)
			checkCancel()
			if checkErr == nil && found {
				s.breaker.Success()
				return existing, nil
			}
			if checkErr != nil && isRoleOrConnectionError(checkErr) {
				s.breaker.Failure()
			}
			return PaymentResult{}, err
		}

		retryable := isTxnRetryable(err)
		if isRoleOrConnectionError(err) {
			s.resetPoolThrottled()
			s.breaker.Failure()
			retryable = true // 显式事务未到Commit阶段,可从头重做
		}
		if !retryable || attempt == maxAttempts-1 {
			return PaymentResult{}, err
		}
		if err := sleepContext(ctx, backoff(attempt)); err != nil {
			return PaymentResult{}, err
		}
	}
	return PaymentResult{}, fmt.Errorf("payment attempts exhausted: %w", lastErr)
}

func (s *Store) applyPaymentOnce(
	ctx context.Context,
	idempotencyKey string,
	accountID int64,
	amountCents int64,
) (PaymentResult, error) {
	tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
	if err != nil {
		return PaymentResult{}, err
	}
	defer func() { _ = tx.Rollback(ctx) }()

	var inserted bool
	err = tx.QueryRow(ctx, `
        INSERT INTO payment_requests (
            idempotency_key, account_id, amount_cents, status
        ) VALUES ($1, $2, $3, 'processing')
        ON CONFLICT (idempotency_key) DO NOTHING
        RETURNING true
    `, idempotencyKey, accountID, amountCents).Scan(&inserted)

	if errors.Is(err, pgx.ErrNoRows) {
		existing, found, lookupErr := lookupPaymentTx(
			ctx, tx, idempotencyKey, accountID, amountCents,
		)
		if lookupErr != nil {
			return PaymentResult{}, lookupErr
		}
		if !found {
			return PaymentResult{}, errors.New("idempotency row disappeared")
		}
		if err := tx.Commit(ctx); err != nil {
			// 服务器明确返回40001/40P01或ROLLBACK时,事务确定未提交,可由外层完整重试。
			if errors.Is(err, pgx.ErrTxCommitRollback) || isTxnRetryable(err) {
				return PaymentResult{}, err
			}
			return PaymentResult{}, &CommitUnknownError{Cause: err}
		}
		return existing, nil
	}
	if err != nil {
		return PaymentResult{}, err
	}

	var paymentID int64
	if err := tx.QueryRow(ctx, `
        INSERT INTO payments (account_id, amount_cents)
        VALUES ($1, $2)
        RETURNING payment_id
    `, accountID, amountCents).Scan(&paymentID); err != nil {
		return PaymentResult{}, err
	}

	if _, err := tx.Exec(ctx, `
        INSERT INTO ledger_entries (
            payment_id, account_id, delta_cents
        ) VALUES ($1, $2, $3)
    `, paymentID, accountID, -amountCents); err != nil {
		return PaymentResult{}, err
	}

	if _, err := tx.Exec(ctx, `
        UPDATE payment_requests
        SET status = 'succeeded',
            payment_id = $2,
            completed_at = clock_timestamp()
        WHERE idempotency_key = $1
    `, idempotencyKey, paymentID); err != nil {
		return PaymentResult{}, err
	}

	if err := tx.Commit(ctx); err != nil {
		// 服务器明确返回40001/40P01或ROLLBACK时,事务确定未提交;其它COMMIT错误按结果未知处理。
		if errors.Is(err, pgx.ErrTxCommitRollback) || isTxnRetryable(err) {
			return PaymentResult{}, err
		}
		return PaymentResult{}, &CommitUnknownError{Cause: err}
	}
	return PaymentResult{PaymentID: paymentID, Status: "succeeded"}, nil
}

func lookupPaymentTx(
	ctx context.Context,
	tx pgx.Tx,
	idempotencyKey string,
	accountID int64,
	amountCents int64,
) (PaymentResult, bool, error) {
	var result PaymentResult
	var storedAccountID, storedAmount int64
	err := tx.QueryRow(ctx, `
        SELECT COALESCE(payment_id, 0), status, account_id, amount_cents
        FROM payment_requests
        WHERE idempotency_key = $1
    `, idempotencyKey).Scan(
		&result.PaymentID, &result.Status, &storedAccountID, &storedAmount,
	)
	if errors.Is(err, pgx.ErrNoRows) {
		return PaymentResult{}, false, nil
	}
	if err != nil {
		return PaymentResult{}, false, err
	}
	if storedAccountID != accountID || storedAmount != amountCents {
		return PaymentResult{}, false, errors.New("idempotency key reused with different input")
	}
	return result, result.Status == "succeeded", nil
}

func (s *Store) lookupPayment(
	ctx context.Context,
	idempotencyKey string,
	accountID int64,
	amountCents int64,
) (PaymentResult, bool, error) {
	var result PaymentResult
	var storedAccountID, storedAmount int64
	err := s.pool.QueryRow(ctx, `
        SELECT COALESCE(payment_id, 0), status, account_id, amount_cents
        FROM payment_requests
        WHERE idempotency_key = $1
    `, idempotencyKey).Scan(
		&result.PaymentID, &result.Status, &storedAccountID, &storedAmount,
	)
	if errors.Is(err, pgx.ErrNoRows) {
		return PaymentResult{}, false, nil
	}
	if err != nil {
		return PaymentResult{}, false, err
	}
	if storedAccountID != accountID || storedAmount != amountCents {
		return PaymentResult{}, false, errors.New("idempotency key reused with different input")
	}
	return result, result.Status == "succeeded", nil
}

// 使用Rows的示例:必须Close并检查rows.Err。
func (s *Store) ListRecentPayments(ctx context.Context, limit int) ([]PaymentResult, error) {
	rows, err := s.pool.Query(ctx, `
        SELECT payment_id, 'succeeded'
        FROM payments
        ORDER BY payment_id DESC
        LIMIT $1
    `, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	results := make([]PaymentResult, 0, limit)
	for rows.Next() {
		var r PaymentResult
		if err := rows.Scan(&r.PaymentID, &r.Status); err != nil {
			return nil, err
		}
		results = append(results, r)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return results, nil
}

func (s *Store) probeLoop(ctx context.Context, wg *sync.WaitGroup) {
	defer wg.Done()
	ticker := time.NewTicker(2 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			probeCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
			var writable bool
			err := s.pool.QueryRow(probeCtx, `
                SELECT NOT pg_is_in_recovery()
                   AND NOT current_setting('transaction_read_only')::boolean
            `).Scan(&writable)
			cancel()

			if err != nil || !writable {
				s.ready.Store(false)
				if err != nil && isRoleOrConnectionError(err) {
					s.resetPoolThrottled()
				}
				continue
			}
			s.ready.Store(true)
		}
	}
}

func main() {
	databaseURL := os.Getenv("DATABASE_URL")
	if databaseURL == "" {
		slog.Error("DATABASE_URL is required")
		os.Exit(2)
	}

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

	pool, err := newPool(rootCtx, databaseURL)
	if err != nil {
		slog.Error("database initialization failed", "error", err)
		os.Exit(1)
	}
	defer pool.Close()

	store := &Store{
		pool:    pool,
		gate:    NewGate(envInt("DB_MAX_INFLIGHT", 64), 50*time.Millisecond),
		breaker: NewCircuitBreaker(5, 3*time.Second),
	}
	store.ready.Store(true)

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

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

	var wg sync.WaitGroup
	wg.Add(1)
	go store.probeLoop(rootCtx, &wg)

	serverErr := make(chan error, 1)
	go func() {
		err := server.ListenAndServe()
		if err != nil && !errors.Is(err, http.ErrServerClosed) {
			serverErr <- err
		}
	}()

	select {
	case <-rootCtx.Done():
	case err := <-serverErr:
		slog.Error("HTTP server failed", "error", err)
		stop()
	}

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := server.Shutdown(shutdownCtx); err != nil {
		slog.Error("HTTP shutdown failed", "error", err)
	}
	wg.Wait()
}

14.4 代码中的 HA 设计点

  1. 池内旧连接:角色或连接错误触发节流后的 pool.Reset();已借出连接归还时关闭。
  2. TCP 不能迁移:任何 Failover 都通过建立新连接完成,而不是修改旧 socket。
  3. DNS Cache:连接寿命、Reset、多主机 URL 和稳定代理入口共同降低旧解析影响。
  4. Connection Refused/Reset:归为基础设施错误,执行有限次数完整事务重试;Commit 阶段除外。
  5. Read-Only:识别 25006,并在 AfterConnect 与 Readiness 中验证 pg_is_in_recovery()
  6. Admin Shutdown57P01 等触发池轮换与退避。
  7. Commit Unknown:单独错误类型,不进入普通重试;先查幂等结果。
  8. Jitter:连接建立和事务重试都随机打散。
  9. Circuit Breaker:连续基础设施失败后快速拒绝,减少恢复中压力。
  10. Load Shedding:有界 Gate 和最大排队时间,非无限 goroutine。
  11. Readiness:只在能访问可写节点时返回 200;Liveness 不依赖数据库短期状态。
  12. 优雅停机:先停止接流量与探针,再关闭连接池;事务仍必须受请求 Context 限制。

示例中的断路器是教学版。生产实现还应有半开探针、按依赖/错误类别分桶、Prometheus 指标、实例间随机化和明确的上游错误契约。


15. 实验

统一安全要求:以下实验只能在隔离的非生产 Patroni 集群执行。所有节点必须有可用备份、带外管理入口和明确回滚人。禁止在生产环境用 kill -9iptablestc、填盘或手工 pg_promote() 做验证。

15.1 实验一:Planned Switchover、旧连接与零重复写

1. 实验目标

  • 验证健康集群的计划切换;
  • 观察 TCP 连接不能迁移、旧连接会失败;
  • 验证使用相同 Idempotency Key 重试后,每个业务请求最多一行;
  • 测量端到端写不可用窗口,而不是只看 Patroni 命令耗时。

2. 环境与版本

  • PostgreSQL 18;Patroni 4.1.3;三节点 DCS;
  • HAProxy 使用 /primary 检查,PgBouncer 为 transaction pooling;
  • 不要求扩展;
  • 记录:SHOW server_version;patronictl versionpatronictl list、动态配置和 HAProxy 检查周期。

3. 建表和准备数据

CREATE SCHEMA IF NOT EXISTS ha_lab;

CREATE TABLE ha_lab.write_probe (
    event_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    request_id text NOT NULL UNIQUE,
    payload text NOT NULL,
    server_addr inet NOT NULL DEFAULT inet_server_addr(),
    backend_pid integer NOT NULL DEFAULT pg_backend_pid(),
    created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);

INSERT INTO ha_lab.write_probe (request_id, payload)
VALUES ('switchover-baseline', 'before switch')
ON CONFLICT (request_id) DO NOTHING;

4. Session A:制造一条跨切换存活的旧连接

通过 rw-db.internal 连接:

SELECT inet_server_addr(), pg_backend_pid(), pg_is_in_recovery();

BEGIN;
SELECT clock_timestamp() AS tx_started, pg_backend_pid(), inet_server_addr();
SELECT pg_sleep(20);
INSERT INTO ha_lab.write_probe(request_id, payload)
VALUES ('session-a-held', 'old connection should not migrate');
COMMIT;

pg_sleep(20) 期间保持 Session A 不动。切换发生后,旧 Primary 会被降级/停止,或者 HAProxy 主动关闭会话;因此后续 INSERT/COMMIT 预期失败。失败是实验观察点,不应尝试从同一事务中继续。

5. Session B:使用同一请求键重试

在另一个终端运行。每个键只有成功后才进入下一个,失败时仍使用同一键:

#!/usr/bin/env bash
set -euo pipefail
: "${DATABASE_URL:?DATABASE_URL is required}"

for i in $(seq -w 1 100); do
  key="sw-${i}"
  while true; do
    started_ns=$(date +%s%N)
    if psql "$DATABASE_URL" -v ON_ERROR_STOP=1 \
      -v key="$key" \
      -c "INSERT INTO ha_lab.write_probe(request_id,payload)
          VALUES (:'key','writer-loop')
          ON CONFLICT (request_id) DO NOTHING"; then
      ended_ns=$(date +%s%N)
      printf '%s success latency_ms=%s\n' \
        "$key" "$(((ended_ns-started_ns)/1000000))"
      break
    fi
    sleep "0.$((RANDOM % 8 + 2))"
  done
done

6. Session C:执行 Planned Switchover

先检查健康:

patronictl -c /etc/patroni/patroni.yml list payments-prod
patronictl -c /etc/patroni/patroni.yml show-config payments-prod

确认 pg-b 已追平且有提升资格后:

patronictl -c /etc/patroni/patroni.yml \
  switchover payments-prod \
  --leader pg-a \
  --candidate pg-b \
  --force

执行前以当前 patronictl switchover --help 核对 CLI;生产应省略 --force 并走审批/交互确认。

7. 明确时间线

时间动作等待/失败/提交
T0Session A 开事务并睡眠A 等待;事务未提交
T1Session B 连续写入正常提交
T2Session C 发起 SwitchoverPatroni等目标追平并停旧主
T3pg-b Promote,新 TimelineHAProxy探测收敛;B可能短暂失败
T4Session A 从睡眠返回并继续旧连接预期 Reset/Admin Shutdown;A事务失败
T5B 通过新连接继续所有请求键最终提交一次

8. 预期结果与诊断 SQL

SELECT request_id, count(*)
FROM ha_lab.write_probe
WHERE request_id LIKE 'sw-%'
GROUP BY request_id
HAVING count(*) <> 1;
-- 预期 0 行

SELECT count(*) AS successful_keys
FROM ha_lab.write_probe
WHERE request_id LIKE 'sw-%';
-- 预期 100

SELECT server_addr, min(created_at), max(created_at), count(*)
FROM ha_lab.write_probe
WHERE request_id LIKE 'sw-%'
GROUP BY server_addr
ORDER BY min(created_at);

SELECT pg_is_in_recovery(), inet_server_addr();

在三个节点分别检查:

SELECT pg_is_in_recovery();
SELECT * FROM pg_stat_wal_receiver;
SELECT application_name, state, sync_state, flush_lsn, replay_lsn
FROM pg_stat_replication;

并记录:

  • Switchover 命令开始/结束时间;
  • Session B 首个错误到首个成功的窗口;
  • 错误类型与 SQLSTATE;
  • 写请求 P50/P95/P99;
  • 切换前后连接数、SyncRep 等待、WAL/s、CPU、I/O;
  • patronictl list 的 Timeline 变化。

不要伪造固定耗时;把结果与 ttl、HAProxy fall/rise/inter、PgBouncer和应用退避逐项对齐。

9. 清理

DROP SCHEMA ha_lab CASCADE;

如需恢复原拓扑,必须另做一次健康的 Planned Switchover;不要手工 Promote pg-a。

10. 生产安全警告

Switchover 可能终止长事务、Session Lock、COPY、在线 DDL 和批处理。执行前必须检查 pg_stat_activity、复制延迟、备份任务、DDL、连接池和下游 CDC。


15.2 实验二:Unplanned Failover 与 Commit Outcome Unknown

1. 实验目标

  • 模拟 Primary 进程/主机突然失效;
  • 测量 TTL、Fencing、Promotion、代理和应用恢复的总窗口;
  • 观察 Commit 返回错误时,数据库事实可能已存在;
  • 验证同一 Idempotency Key 不会产生两笔业务写。

2. 版本和必要条件

  • 与实验一相同;
  • synchronous_modesynchronous_mode_strict 已启用;
  • pre_promote 在实验环境能确认旧 Primary PostgreSQL 已停止;
  • 有带外终端可恢复旧节点;
  • 可选:专用 fault-proxy 对 server→client 方向增加 2 s 延迟,使“已提交但未收到响应”更容易复现。

3. 准备表

CREATE SCHEMA IF NOT EXISTS ha_lab;

CREATE TABLE ha_lab.idempotent_charge (
    idempotency_key text PRIMARY KEY,
    amount_cents bigint NOT NULL CHECK (amount_cents > 0),
    server_addr inet NOT NULL DEFAULT inet_server_addr(),
    created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);

4. Session A:准备未知结果事务

\set ON_ERROR_STOP on
BEGIN;
INSERT INTO ha_lab.idempotent_charge(idempotency_key, amount_cents)
VALUES ('unknown-commit-001', 9900)
ON CONFLICT (idempotency_key) DO NOTHING;
-- 暂停在这里,等待 Session C 倒计时。
COMMIT;

在 Session C 发出“3、2、1”时执行 COMMIT。

5. Session B:持续观察与记录

在第三节点或监控机上:

while true; do
  date -Ins
  curl -sk -o /dev/null -w 'pg-a=%{http_code}\n' https://10.10.1.11:8008/primary || true
  curl -sk -o /dev/null -w 'pg-b=%{http_code}\n' https://10.10.2.11:8008/primary || true
  curl -sk -o /dev/null -w 'pg-c=%{http_code}\n' https://10.10.3.11:8008/primary || true
  sleep 0.5
done

另开终端记录:

patronictl -c /etc/patroni/patroni.yml list payments-prod --watch 1

6. Session C:模拟突然失效

在确认当前 Primary 为 pg-a 后,仅在隔离实验机执行:

sudo kill -9 "$(cat /var/lib/patroni/postmaster.pid | head -n 1)"
sudo kill -9 "$(pgrep -f 'patroni.*payments-prod' | head -n 1)"

更接近主机宕机的演练应由虚拟化/云控制面关闭整台实验 VM,并让 pre_promote 调用同一控制面确认旧节点已断电。

7. 时间线与可能结果

时间动作结果
T0A 已执行 INSERT,尚未 COMMIT数据仅在未提交事务内
T1A 发送 COMMITPrimary可能开始本地/同步WAL持久化
T1+εC 杀死 Primary/PatroniA可能收到成功、Reset、EOF或超时
T2Leader Lock到期,候选竞争合格同步/Quorum副本取得锁
T3Fencing成功并Promote新Timeline开始
T4HAProxy/PgBouncer收敛新连接可写
T5查询同一幂等键可能存在,也可能不存在;两者都符合“客户端未知”

未知结果本身是竞态。要提高复现率,可在专用 fault-proxy 上延迟服务端回包,再在 COMMIT 发出后关闭代理或 Primary。无论得到哪种结果,安全性判据都是:用同一键重试后最终恰好一行。

8. Failover 后对账与安全重试

新连接建立后:

SELECT *
FROM ha_lab.idempotent_charge
WHERE idempotency_key = 'unknown-commit-001';
  • 查到一行:原事务已提交,返回该结果,禁止再次扣款;
  • 查不到:在确认连接已指向新 Primary 后,用相同键重试:
INSERT INTO ha_lab.idempotent_charge(idempotency_key, amount_cents)
VALUES ('unknown-commit-001', 9900)
ON CONFLICT (idempotency_key) DO NOTHING;

最终验证:

SELECT idempotency_key, count(*)
FROM ha_lab.idempotent_charge
GROUP BY idempotency_key
HAVING count(*) <> 1;
-- 预期 0 行

9. 诊断和统计指标

SELECT pg_is_in_recovery(), inet_server_addr();

SELECT application_name, state, sync_state,
       sent_lsn, flush_lsn, replay_lsn
FROM pg_stat_replication;

SELECT datname, numbackends, xact_commit, xact_rollback,
       deadlocks, temp_bytes
FROM pg_stat_database
WHERE datname = current_database();

记录:故障注入时刻、旧主最后成功续约、Fencing开始/结束、Promotion时刻、/primary 首次200、应用首次成功写、Commit Unknown数量、对账成功率、重试次数、连接创建速率、P50/P95/P99和最大排队。

10. 清理

DROP SCHEMA ha_lab CASCADE;

清除 fault-proxy/tc/防火墙规则,并保持旧 pg-a 隔离,进入实验三。

11. 生产安全警告

不要把“客户端超时”自动翻译成失败,也不要用新的随机 Idempotency Key 重试同一业务动作。否则一次网络抖动即可产生重复扣款。


15.3 实验三:旧 Primary 使用 pg_rewind 回归

1. 实验目标

  • 识别旧 Primary 与新 Timeline 的分叉;
  • 验证旧主不会直接成为可写节点;
  • 通过 Patroni 自动 pg_rewind 或 Reinitialize 作为 Replica 回归;
  • 验证回归后追平、只读且具备未来 Failover 资格。

2. 版本与前提

  • PostgreSQL 18;Patroni 4.1.3;
  • 集群初始化启用 Data Checksums 或 wal_log_hints=on,且 full_page_writes=on
  • 新 Primary 与备份可用;
  • 旧主仍被入口和 Fencing 隔离。

3. Session A:新 Primary 产生新时间线数据

CREATE SCHEMA IF NOT EXISTS ha_lab;
CREATE TABLE IF NOT EXISTS ha_lab.after_failover (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    note text NOT NULL,
    server_addr inet NOT NULL DEFAULT inet_server_addr(),
    created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);

INSERT INTO ha_lab.after_failover(note)
SELECT 'new timeline row ' || g
FROM generate_series(1, 1000) AS g;

CHECKPOINT;

4. Session B:检查集群和 Timeline

patronictl -c /etc/patroni/patroni.yml list payments-prod

记录新 Primary 的 TL、LSN 与旧 pg-a 的状态。不要在旧 pg-a 上直接运行 pg_ctl start

5. Session C:在旧节点启动 Patroni

sudo systemctl start patroni
sudo journalctl -u patroni -f

预期日志顺序:发现自己曾是 Primary → 检测 Timeline 分叉 → 获取新主连接信息 → 执行 pg_rewind → 写入 Standby 配置 → 以 Replica 启动 → 开始流复制。

如果自动 rewind 失败,保留日志和数据目录证据,然后选择全量重建:

patronictl -c /etc/patroni/patroni.yml \
  reinit payments-prod pg-a --force

reinit 会破坏目标节点现有数据目录,只能在确认它不是权威副本后执行。

6. 哪一步等待、失败与提交

  • A 的 1000 行在新 Primary 正常提交;
  • C 的 pg_rewind 在复制变更块和获取所需 WAL 时等待;
  • 缺少共同 WAL、校验和/wal_log_hints 前提、权限或网络时 rewind 失败;
  • 回归节点在追平前不可加入只读流量,更不可提升。

7. 预期结果和诊断

在 pg-a 上:

SELECT pg_is_in_recovery(), inet_server_addr();
SELECT status, sender_host, slot_name, written_lsn, flushed_lsn,
       received_tli, last_msg_send_time, last_msg_receipt_time
FROM pg_stat_wal_receiver;

SELECT count(*) FROM ha_lab.after_failover;
-- 追平后预期 1000

在 Primary 上:

SELECT application_name, state, sync_state,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS gap
FROM pg_stat_replication
ORDER BY application_name;

验证 HAProxy:

curl -sk -o /dev/null -w '%{http_code}\n' https://10.10.1.11:8008/primary
# pg-a 是 Replica 时预期非 200
curl -sk -o /dev/null -w '%{http_code}\n' https://10.10.1.11:8008/replica
# 健康追平后预期 200

记录 rewind 时长、复制字节、归档取回、I/O、CPU、追平时间和新主性能影响。

8. 清理与生产警告

DROP SCHEMA ha_lab CASCADE;

解除 pg-a Fencing 前,必须确认:pg_is_in_recovery()=true、Timeline与集群一致、复制gap在阈值内、REST /primary 非200、/replica 200、监控已纳管。Failback 应另做 Planned Switchover,不是本实验自动步骤。


16. 生产排障 Runbook

当出现“数据库不可用/正在切换”告警时,按以下顺序执行:

  1. 确认事故范围与冻结变更:记录 UTC 时间、业务影响、当前Primary、最近发布;暂停DDL、配置变更、扩缩容和自动修复任务。
  2. 确认是否存在多个可写节点:从独立管理网查询所有节点 pg_is_in_recovery() 与 Patroni /primary;若可写节点数大于1,立即停止写入口并执行Fencing。
  3. 查看控制面patronictl list/show-config/history,DCS endpoint/quorum、Leader Lock TTL、Patroni HA循环与REST连通性。
  4. 查看数据面:Primary/Replica进程、Timeline、receive/flush/replay LSN、同步成员、Replication Slot、归档状态。
  5. 定位连接和阻塞pg_stat_activity 查看活动/空闲事务、wait_event_type/event、最老事务;用 pg_blocking_pids(pid) 找 blocker。Failover事故中仍要排除长DDL/锁队列导致的“看似宕机”。
  6. 定位最早计划估算错误:如果事故表现为过载而非节点死亡,查 pg_stat_statements(若已启用)中最早突增的高总耗时/高行数偏差语句,并回看发布前后 EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY);DML会真实执行,生产不得随意运行 ANALYZE 版本。
  7. 区分瓶颈:CPU看run queue/steal;内存看swap/OOM;I/O看延迟、队列和pg_stat_io;锁看等待链;连接池看Acquire等待/PgBouncer cl_waiting;WAL看生成、归档、Slot;Vacuum看长事务和Freeze;复制看字节gap与同步等待。
  8. 安全在线命令:只读SQL、patronictl list/history/show-config、REST健康查询、pgbackrest info/check、DCS endpoint status通常可在线执行;仍需限制频率和超时。
  9. 高风险命令patronictl failover --forcepg_promote()、删除Slot、删除pg_walpg_resetwal、DCS force-new-clusterreinit、关闭同步保护、启动旧Primary都需要事故指挥官审批。
  10. 临时止损:Load Shedding、暂停非核心消费者、降低只读报表并发、冻结发布、只保留少量探针、对关键写返回明确“稍后查询结果”而非重复执行。
  11. 根本修复与验证:恢复唯一Primary、DCS多数派、同步保护、归档和备份;旧节点rewind/reinit;用业务探针验证写、读后写、幂等、CDC、备份和监控。
  12. 补充告警与复盘:记录实测RPO/RTO、未知Commit、Fencing耗时、代理收敛、连接峰值、错误预算;将缺失检测转成监控并安排复演。

16.1 blocker 诊断 SQL

SELECT
    a.pid AS blocked_pid,
    a.usename,
    a.application_name,
    a.wait_event_type,
    a.wait_event,
    clock_timestamp() - a.query_start AS blocked_for,
    blocker_pid,
    b.state AS blocker_state,
    clock_timestamp() - b.xact_start AS blocker_xact_age,
    left(a.query, 200) AS blocked_query,
    left(b.query, 200) AS blocker_query
FROM pg_stat_activity AS a
CROSS JOIN LATERAL unnest(pg_blocking_pids(a.pid)) AS blocker_pid
JOIN pg_stat_activity AS b ON b.pid = blocker_pid
ORDER BY blocked_for DESC;

重要字段:blocked_for 表示当前语句等待时长;blocker_xact_age 揭示阻塞者事务是否长期未提交;blocker_state='idle in transaction' 通常比单条慢SQL更值得优先处理。终止Backend会回滚事务,必须确认业务影响。


17. 常见错误与反模式

  1. 只有自动选主,没有 Fencing:把“旧主Ping不通”当成“旧主已停止写”。
  2. DCS 与三个数据库节点同故障域:一次机架/AZ故障同时失去数据和多数派。
  3. 把 HAProxy 单实例当作 HA:数据库三节点,入口却只有一个进程/IP。
  4. DNS 直接指向 Primary:既有连接和进程缓存让低TTL失去意义,旧地址还可能双写。
  5. synchronous_mode 开启但应用可随意 SET synchronous_commit=off:局部优化绕过全局RPO承诺。
  6. 所有应用实例配置高 MinConns 并同时重启:新Primary在冷缓存阶段被连接风暴压垮。
  7. 对所有错误统一重试三次:把唯一冲突、磁盘满、只读和未知Commit混在一起。
  8. Commit报错就换新幂等键重试:直接制造重复订单、扣款或库存扣减。
  9. Failover 后立即 Failback:旧主尚未验证、缓存未稳定,又制造第二次风险窗口。
  10. Replication Slot 无上限、无消费者SLO:单个失联CDC把pg_wal写满。
  11. HA副本替代备份:误删除和错误DDL会同步复制到所有副本。
  12. 在生产直接修改 bootstrap.dcs:已引导集群不生效,造成“文件看着对、集群状态没变”。
  13. 手工 pg_promote() 绕过 Patroni:DCS、代理和同步状态不知道真实角色。
  14. 只监控进程存活,不监控可提交性:etcd进程都在但失去quorum、磁盘fsync过慢,控制面仍不可用。
  15. 只测数据库Promotion时间:忽略Fencing、代理、池重建、DNS和应用对账,RTO被严重低估。

18. 模拟生产事故案例

18.1 模拟生产案例一:网络分区后旧主继续写

系统背景

三节点 PostgreSQL 跨三 AZ,Patroni + etcd;HAProxy按 /primary 检查,但 watchdog 配置为 automaticpre_promote 只执行一次 Ping。

故障现象

AZ-A 到 etcd 的链路中断,但一部分应用仍可直连 AZ-A 的旧Primary;AZ-B 取得Leader Lock并提升。十分钟后发现两个节点都有新订单。

错误假设

团队认为“旧主访问不到 etcd,就一定会自动停写”;同时认为 Ping 不通即可证明节点失效。

排查过程

  1. DCS history 显示 pg-b 正常取得Leader Lock;
  2. pg-a Patroni日志显示降级动作因本机I/O卡顿未及时完成;
  3. watchdog设备不可用但 automatic 允许继续;
  4. pre_promote 对 pg-a Ping 失败后返回0;
  5. 部分应用绕过HAProxy使用旧IP;
  6. 两个Timeline均有业务提交。

根因

控制面租约、主机隔离和客户端入口三层都存在缺口:旧主没有被硬隔离,提升钩子失败开放,且存在直连路径。

临时止损

关闭所有写入口;在云控制面断电 pg-a;选择外部支付流水一致的 pg-b 为权威分支;对 pg-a 分支做只读取证并逐笔业务补偿。

最终修复

  • watchdog改为required并验证设备;
  • pre_promote接入幂等STONITH,无法证明隔离则非零退出;
  • 防火墙禁止应用直连数据节点,只允许代理网段;
  • 增加“全局可写节点数≠1”告警;
  • 所有写API强制Idempotency Key。

监控补充与防复发

监控Fencing请求、状态确认、Leader变更、所有节点pg_is_in_recovery()、直连流量;季度执行非对称网络分区演练,而不是只做关机演练。

18.2 模拟生产案例二:Failover 成功但新主被重连风暴压垮

系统背景

80个Go实例,每实例 pgxpool.MaxConns=50MinConns=20;两个PgBouncer实例各 default_pool_size=200。没有Jitter、Circuit Breaker或Load Shedding。

故障现象

Primary宕机后Patroni 36秒完成Promotion,但业务持续不可用8分钟。新Primary CPU 100%,连接认证激增,P99超过10秒,又触发上游重试。

错误假设

团队把“Promotion完成”当成“服务恢复”,并认为更多连接能更快清空积压。

排查过程

  1. Patroni与DCS均在36秒恢复唯一Primary;
  2. HAProxy 4秒后正确指向新主;
  3. 80个实例同时补齐至少1600条连接,业务goroutine又并发重试;
  4. PgBouncer等待队列和PostgreSQLBackend数快速上升;
  5. 新主缓存冷,认证、计划和随机读同时争用CPU/I/O;
  6. 上游超时后再次重试,形成正反馈。

根因

连接预算在三层重复放大;缺少有界并发、随机退避和故障后的渐进放量。

临时止损

入口限流到正常峰值的30%;暂停报表和异步消费者;将应用实例分批恢复;降低连接池目标并清空异常重试队列。

最终修复

  • 统一计算“应用实例 × MaxConns → PgBouncer server pool → PostgreSQL连接预算”;
  • MinConns=0或极低,设置MaxConnLifetimeJitter
  • 错误分类、Full Jitter、Circuit Breaker和50ms排队上限;
  • Readiness只在可写且依赖健康时放量;
  • 新Primary进行受控热点预热,而非全量扫表。

监控补充与防复发

新增连接创建速率、Acquire等待、PgBouncer cl_waiting、Backend创建、重试率、Load Shed率与Promotion后15分钟性能面板;每次HA演练同时施加真实业务并发。


19. 附录:ADR 与专项 Runbook

19.1 高可用 ADR

ADR-023:支付核心 PostgreSQL 区域内高可用与跨区域灾备

项目决策
状态Accepted;每半年或SLO/拓扑变化时复审
背景6 TiB支付写库;已确认交易区域内不可丢;RTO≤60s;跨区域RPO≤5min/RTO≤60min
决策单地域三AZ三数据节点;Patroni 4.1.x;三节点etcd;同步Quorum strict;watchdog required;pre_promote调用外部STONITH;双HAProxy、双PgBouncer;pgBackRest异地Repository;独立跨区Standby Cluster
写端点应用只能访问rw-db.internal,由PgBouncer→HAProxy→Patroni /primary选路;防火墙禁止直连数据节点
读端点ro-db.internal只接延迟受限Replica;关键写后读仍走rw端点
RPO策略区域内关键事务保持synchronous_commit=on;无同步保护时失败关闭;跨区异步接受明确RPO
Fencing新主提升前必须证明旧主断电、网络隔离或存储撤权;失败关闭;所有动作带幂等ID和审计
应用策略Idempotency Key、Outbox、Commit Unknown对账、最多4次完整事务重试、Full Jitter、Circuit Breaker、Load Shedding
恢复策略旧主优先pg_rewind,失败则Reinitialize;误删除使用PITR;区域灾难由DR集群提升
备选方案A异步复制自动提升:延迟低但不能满足已确认交易RPO=0目标,拒绝
备选方案B跨区域同步提交:WAN尾延迟和分区会直接损害写SLO,拒绝
备选方案C两数据节点+单独仲裁:成本低但维护和同步故障窗口更脆弱,拒绝
正向后果明确唯一写者;区域内单节点/AZ故障可自动恢复;旧主回归可自动化;入口与应用具备恢复能力
负向后果写延迟上升;同步副本全失时停止写;组件和演练复杂;三份数据及备份成本
安全护栏配置即代码、双人审批、受控管理网、MFA、最小权限、禁止人工pg_promote、全局唯一Primary告警
可观测性DCS quorum、Leader/Timeline、同步节点数、WAL gap、Fencing、连接创建、未知Commit、归档和恢复演练
退出条件数据规模超过单Primary能力、跨区写SLO变化、托管服务提供经验证的更优SLA,或组织无法维持演练能力
OwnerDatabase Platform;业务Owner负责幂等与对账;SRE负责演练和事故指挥

19.2 Planned Switchover Runbook

进入条件

  • 集群健康且只有一个Primary;
  • 目标Replica与Primary同Timeline,lag低于批准阈值;
  • 同步保护、归档、备份和DCS quorum正常;
  • 无长DDL、备份关键阶段、批量导入、重大发布或未处理告警;
  • 变更单、回滚人和业务观察人已就位。

操作步骤

  1. 公告变更窗口,冻结Patroni/DCS/网络/Schema并行变更。
  2. 保存证据:patronictl list/history/show-configpg_stat_replicationpg_stat_activity、代理状态和监控截图。
  3. 检查长事务:
SELECT pid, usename, application_name, state,
       clock_timestamp() - xact_start AS xact_age,
       wait_event_type, wait_event, left(query, 200)
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;
  1. 检查目标容量、WAL flush/replay、归档和Replica磁盘。
  2. 对非关键任务暂停或降并发;确认应用Idempotency与重连指标可见。
  3. 发起 patronictl switchover,明确旧Leader与Candidate;不使用手工pg_promote()
  4. 实时记录:旧主停止、目标Promotion、Timeline变化、HAProxy健康收敛、应用首个成功写。
  5. 验证唯一Primary:所有节点角色、DCS Leader、/primarypg_is_in_recovery()一致。
  6. 验证同步保护:synchronous_standby_namespg_stat_replication.sync_state、Patroni /sync
  7. 验证业务:参数化写入、按主键读取、写后读、幂等重复请求、Outbox/CDC。
  8. 观察至少一个业务峰值周期或批准的稳定窗口:P95/P99、错误率、连接、WAL、I/O、锁、复制。
  9. 关闭变更;旧Primary应作为Replica追平。若需要回切,创建新的Switchover变更,不立即操作。

中止/回滚条件

  • Candidate lag持续增长;
  • Fencing/停止旧主无法确认;
  • 出现两个可写节点;
  • 新主错误率、磁盘或同步保护异常;
  • 代理仍把新连接送往旧主。

“回滚”不是手工把旧主Promote;先恢复唯一、安全、稳定的Primary,再另行计划切换。


19.3 Unplanned Failover Runbook

  1. 宣布数据库重大事件,冻结变更;记录首个告警和业务影响时间。
  2. 从独立管理面确认当前可写节点数。若大于1,先切断写入口并Fence非权威节点。
  3. 检查DCS是否有多数派;无多数派时不要手工制造Leader。
  4. 判断旧Primary状态:PostgreSQL进程、主机电源、网络、存储访问。不可达不等于已隔离。
  5. 观察Patroni自动过程,不与自动选主并发执行人工Failover;记录Leader Lock过期、候选和Fencing。
  6. 若自动Failover未发生,确认原因:无合格同步候选、lag过大、Timeline不符、nofailoverpre_promote失败、DCS无quorum。
  7. 任何强制Failover前,由业务Owner明确接受潜在RPO,并由基础设施完成旧主Fencing。
  8. 新主出现后验证:唯一可写、Timeline、同步节点、归档、Slot、磁盘、时钟和证书。
  9. 让HAProxy/PgBouncer收敛;必要时受控 RECONNECT;应用池按Jitter重建,禁止全量同时放流。
  10. 对所有Commit Unknown按Idempotency Key查询;不生成新键重做同一业务动作。
  11. 暂停报表和非核心消费者,直到新主CPU/I/O/缓存稳定;逐步恢复流量。
  12. 保持旧主隔离,进入Failback/重建Runbook;完成RPO/RTO、错误数和业务对账报告。

19.4 Failback Runbook

原则

Failback 是一次新的计划变更,目标是恢复期望拓扑,不是“把机器开回来”。业务已经在新Primary稳定运行时,拓扑美观优先级低于稳定性。

步骤

  1. 确认现Primary至少稳定一个批准观察窗口,备份和归档已恢复。
  2. 旧主保持Fenced;收集其Timeline、最后LSN、故障日志和磁盘健康。
  3. 选择 pg_rewind 或 Reinitialize:前提不满足、数据目录可疑、rewind失败时直接重建。
  4. 以Patroni管理方式作为Replica启动;禁止独立 pg_ctl start
  5. 验证 pg_is_in_recovery()=true、WAL Receiver、slot、Timeline和lag。
  6. 做数据校验:关键表抽样、业务不变量、Data Checksums/备份工具校验。
  7. 解除基础设施Fencing,但仍通过/primary规则禁止写流量;先不加入报表池。
  8. 观察追平、I/O、Vacuum、WAL和连接稳定性。
  9. 若只是恢复冗余,到此结束;不要自动回切。
  10. 若有明确容量/延迟/合规理由回切,另开Planned Switchover变更。
  11. 回切后重复唯一Primary、同步、业务、备份和监控验证。
  12. 更新资产、拓扑、事故时间线和演练结果。

19.5 DCS 故障 Runbook

  1. 确认是单节点故障、性能退化还是失去多数派:
etcdctl endpoint status --cluster -w table
etcdctl endpoint health --cluster
etcdctl member list -w table
  1. 检查DCS节点磁盘空间、fsync延迟、网络、证书到期和时钟。
  2. 单节点故障且仍有quorum:不切数据库;先恢复冗余,不同时变更多个成员。
  3. 失去多数派:冻结DCS成员变更和人工数据库Promotion;确认Patroni正常模式/Failsafe行为。
  4. 若Failsafe维持现Primary,确认所有数据库成员REST互通且唯一可写;限制非核心变更。
  5. 若Primary已降级,保持业务失败关闭,等待恢复原多数派。
  6. 优先修复原成员或网络;不要在两个分区分别执行灾难恢复。
  7. 永久丢失多数派时,依据最近、已验证的一致DCS快照执行供应商批准的单一恢复流程;此步骤必须由两人复核。
  8. 重建后核对cluster ID、成员列表、Patroni namespace/scope、Leader和配置,防止连接到错误DCS。
  9. 恢复数据库自动化前,确认没有第二个DCS集群和第二个Primary。
  10. 逐节点恢复、观察多个TTL周期,再解除业务限流。
  11. 复盘是否需要5节点DCS、独立故障域、更快磁盘或证书自动轮换。

禁止把 force-new-cluster 当作普通修复命令。永久多数派丢失是DCS灾难恢复事件,错误操作可产生两个各自自洽的控制面。


19.6 WAL 磁盘写满 Runbook

  1. 确认哪个文件系统接近满:数据、pg_wal、归档spool、临时目录还是日志;同时检查inode。
  2. 立即Load Shed非核心写、暂停批量导入和可延后消费者;不要先重启数据库。
  3. 检查Slot:
SELECT slot_name, slot_type, active, active_pid,
       restart_lsn, confirmed_flush_lsn, wal_status, safe_wal_size,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC NULLS LAST;
  1. 检查归档:pg_stat_archiver、pgBackRest日志/Repository容量/网络。
  2. 检查Replica断连、wal_keep_size、Checkpoint和WAL生成突增。
  3. 最快安全止损通常是扩容文件系统/挂载;严禁手工删除活动pg_wal
  4. 如果是活跃消费者落后,恢复/扩容消费者并监控追赶速度。
  5. 如果Slot已废弃,由Slot Owner和业务Owner确认下游重建方案后再 pg_drop_replication_slot();记录将产生的数据缺口。
  6. 归档恢复后可用 SELECT pg_switch_wal(); 触发测试,并验证新的WAL成功归档;不要循环调用。
  7. 空间下降后逐步恢复写流量,观察WAL生成与清理是否平衡。
  8. 若数据库已崩溃,确认副本空间和同步状态后按Failover Runbook处理;旧节点修复后做一致性检查。
  9. 根治:Slot retained bytes/预计填满时间告警、max_slot_wal_keep_size策略、消费者SLO、Repository容量预测和自动扩容。

19.7 可用区故障 Runbook

  1. 由云/网络/主机多信号确认AZ级故障,避免把单节点误报扩大。
  2. 宣布AZ事件,冻结受影响区扩缩容和自动重建,防止控制面抖动。
  3. 检查DCS剩余quorum、当前Leader所在AZ、同步副本所在AZ。
  4. 若旧Primary在故障AZ,使用云控制面Fence整个实例/网络身份;记录状态证明。
  5. 观察Patroni选择其它AZ合格候选,禁止在DCS无quorum时强制提升。
  6. 验证双入口中至少一套位于幸存AZ;更新全局LB/VIP健康。
  7. 应用降载到单/双AZ剩余容量,暂停报表、批处理、非核心消费者。
  8. 验证唯一Primary、同步保护、归档、WAL、Slot和业务幂等。
  9. 监控幸存节点CPU、磁盘、连接和网络,防止第二故障由过载触发。
  10. AZ恢复后,所有旧节点按Old Primary/Replica流程逐个回归,不能批量自动启动。
  11. 恢复冗余后再逐步放开流量;不为恢复原AZ位置立即Failback。
  12. 记录实测RTO、容量余量、Fencing和入口收敛,更新季度演练。

19.8 季度 HA 演练清单

演练前

  • 变更审批、事故指挥官、观察员和停止条件明确;
  • 最近备份与归档连续,带外入口可用;
  • 基线版本、Patroni配置、拓扑、QPS、P95/P99和连接数已记录;
  • 测试数据使用固定Idempotency Key,可自动核对重复/缺失;
  • Fencing作用域限定为实验节点;
  • 上游知道可能收到Unknown结果,且不会换键重试。

至少轮换的场景

  • Planned Switchover;
  • postmaster crash;
  • Primary主机断电;
  • Primary↔DCS非对称分区;
  • 同步Replica失联;
  • 单个DCS节点与DCS多数派故障;
  • HAProxy/PgBouncer单实例故障;
  • 旧Primary重新上线;
  • 连接风暴与Load Shedding;
  • AZ级故障。

必须测量

  • 检测、TTL、Fencing、Promotion、代理、应用首次成功各阶段耗时;
  • RPO:固定请求集是否缺失;
  • 重复业务行数与Commit Unknown数量;
  • 最大连接创建速率、重试率、拒绝率;
  • 新Primary P50/P95/P99、CPU、I/O、WAL和锁等待;
  • 旧主回归方式与耗时;
  • 监控是否早于人工发现;
  • Runbook是否有歧义或无权限命令。

演练后

  • 唯一Primary、同步保护、DCS quorum和入口均正常;
  • 所有Replica追平,归档/备份恢复;
  • 临时防火墙、tc、Fencing和降级配置全部清除;
  • 缺陷有Owner、截止日期和复演条件;
  • 实测RPO/RTO写回ADR与SLO报告。

19.9 备份恢复演练清单

目标

验证“能够列出备份”之外的完整闭环:从Repository恢复到隔离环境、应用WAL到目标时间、校验数据、启动应用探针并测量RTO。

清单

  • 在与生产隔离的账号、网络、DCS namespace和存储路径执行;
  • pgbackrest info 能看到预期Full/Diff/Incr链与Archive范围;
  • pgbackrest check 成功,Repository权限、加密密钥和证书可用;
  • 随机选择一个备份集,而非永远选择最新;
  • 选择一个已记录业务事件前后的PITR目标时间/LSN;
  • 恢复命令经过双人复核,目标PGDATA确认不是生产目录;
  • 记录下载、解压、WAL replay、Crash Recovery各阶段时间;
  • 恢复实例使用独立端口且不注册到生产HAProxy/Patroni;
  • 验证 pg_is_in_recovery()、目标时间和恢复日志;
  • 运行Data Checksums/pgBackRest校验和关键表抽样;
  • 核对行数、余额总和、订单状态机、外键、唯一性和业务不变量;
  • 验证目标时间前事件存在、目标时间后事件不存在;
  • 用只读应用版本执行核心查询;需要写验证时克隆后再Promote;
  • 测量实际RPO/RTO并与SLO比较;
  • 验证恢复后能建立新的备份和WAL归档链;
  • 销毁隔离数据前保留演练报告、日志、校验结果和改进项。

20. 面试题:30 题

20.1 Patroni 是否负责复制业务数据?

  • 30 秒回答:不负责。业务数据由 PostgreSQL 物理流复制和 WAL 传输;Patroni负责角色状态机、DCS租约、同步成员管理、选主、Promotion/降级和运维API。
  • 深入回答:Patroni编排PostgreSQL,而不是替代PostgreSQL。它读取LSN、Timeline、恢复状态和标签,把Leader Lock与同步状态写入DCS,并调用PostgreSQL启动、停止、Promote、rewind。优点是自动化一致;缺点是增加控制面。替代方案是托管HA或其它编排器,但都需要复制与Fencing。
  • 真正考察:能否分清数据面和控制面。
  • 常见错误:说“Patroni把数据复制到etcd”。
  • 追问:DCS丢失是否等于数据丢失?
  • 追问答案:通常不等于;业务数据仍在PostgreSQL,但自动选主和安全租约受影响,可能导致写停或需要控制面灾难恢复。

20.2 DCS 为什么必须使用多数派?

  • 30 秒回答:多数派使任意两个同时可提交的分区必有交集,从而只能有一个权威Leader Lock。三节点需要2票,可容忍1个节点故障。
  • 深入回答:DCS通过Raft等共识提交成员、配置和租约。失去多数派时停止状态变更是安全特性,代价是可用性下降。不能在网络分区两侧各恢复一个新集群,否则会制造两个控制面真相源。
  • 真正考察:是否理解quorum而非只会部署etcd。
  • 常见错误:认为4节点比3节点多容忍一个故障;4节点仍需3票,只容忍1个。
  • 追问:三节点DCS能否与三个数据库节点一一同机?
  • 追问答案:技术上能,生产通常不理想;同一故障会同时损失数据库和仲裁余量,应按故障域分布并评估资源干扰。

20.3 loop_waitretry_timeoutttl 如何约束?

  • 30 秒回答:Patroni要求 loop_wait + 2×retry_timeout <= ttl。TTL是Leader Lock失效窗口的一部分,不是完整RTO。
  • 深入回答:HA循环每隔loop_wait观察状态;DCS/PostgreSQL短故障可在retry_timeout内重试;Leader必须在TTL到期前续租或降级。减小TTL能缩短部分检测时间,但会提高网络抖动误切换概率。RTO还含Fencing、Promotion、代理和应用恢复。
  • 真正考察:能否做时间预算和安全权衡。
  • 常见错误:直接把TTL写成5秒,忽略约束和基础设施尾延迟。
  • 追问primary_start_timeout 会影响什么?
  • 追问答案:给本地Primary Crash Recovery留时间;过大延长Failover,设为0则更快切换但可能把短暂进程故障升级为拓扑切换。

20.4 同步复制与异步复制的本质取舍是什么?

  • 30 秒回答:同步复制让提交等待至少一个副本达到指定持久化阶段,降低已确认事务丢失风险;代价是网络RTT、吞吐和副本故障时写可用性。异步延迟低但Failover可丢最近事务。
  • 深入回答:同步确认可等待write、flush或apply,remote_apply可增强副本可见性但更慢。异步RPO与WAL生成速率、采样和副本lag有关。生产应按业务数据价值分层,不能对所有库一刀切。
  • 真正考察:能否把一致性、性能和可用性联系起来。
  • 常见错误:声称“同步复制绝对不会丢任何事务”。
  • 追问:为什么跨地域通常不做每次同步提交?
  • 追问答案:WAN RTT、丢包和区域分区会直接进入每次写延迟并降低可用性;通常采用单写地域+异步DR和明确RPO。

20.5 synchronous_modesynchronous_mode_strict 有何区别?

  • 30 秒回答synchronous_mode限制自动提升到已知安全的Leader/同步节点;非strict时无同步候选可暂时退化写入,但故障后可能不自动提升。strict禁止退化,无同步节点时阻塞写。
  • 深入回答:strict适合已确认写不可丢的场景,代价是同步副本全失时写不可用。即便strict,后端在等待同步确认时被取消仍有特殊风险,所以Commit超时和取消策略也属于RPO设计。
  • 真正考察:是否理解“复制参数”和“选主资格”必须协同。
  • 常见错误:只设置PostgreSQL synchronous_standby_names,不让Patroni管理DCS /sync
  • 追问:业务要求继续写时能否临时关闭strict?
  • 追问答案:只能由业务Owner明确接受RPO、记录变更窗口和补偿计划后受控执行,不能由应用自动降级。

20.6 Patroni quorum 模式解决什么问题?

  • 30 秒回答:它让PostgreSQL以 ANY n 从多个合格副本中取得确认,并由Patroni维护quorum安全状态;可降低某个慢副本对提交P99的影响。
  • 深入回答:候选集合追平后,提交可由较快子集确认;Failover时Patroni比较quorum成员的LSN,确保提升集合包含最新成功提交。优点是延迟韧性,缺点是状态推理和运维更复杂,仍需足够节点和strict策略。
  • 真正考察:是否区分DCS quorum与同步提交quorum。
  • 常见错误:认为“有三个节点,ANY 1就自动允许任意节点安全提升”。
  • 追问:quorum模式下failover_priority有什么注意点?
  • 追问答案:当前Patroni文档提示它有已知限制;安全LSN/quorum条件优先,不能把人工优先级当作数据新鲜度替代品。

20.7 Fencing、STONITH、watchdog 和 pre_promote 的关系是什么?

  • 30 秒回答:Fencing是目标;STONITH是外部断电/隔离手段;watchdog是本机失控保护;pre_promote是在新主提升前调用外部隔离并失败关闭的挂钩。
  • 深入回答:任何单一手段都有盲区。DCS租约限制控制权,watchdog在本机HA循环卡死时复位,STONITH从独立控制面撤销旧主能力,pre_promote把隔离证明置于Promotion前。优点是降低脑裂,代价是RTO和基础设施集成复杂度。
  • 真正考察:是否把Fencing看成可验证的安全条件。
  • 常见错误:把Ping失败或HAProxy摘除当作Fencing。
  • 追问:Fencing API超时时脚本应返回什么?
  • 追问答案:不能证明隔离时返回非零,阻止Promotion;超时不代表操作失败或成功,必须查询最终状态。

20.8 什么是 Split Brain,为什么物理复制不能自动合并?

  • 30 秒回答:两个节点同时接受写入会产生不同Timeline和业务历史。物理WAL是顺序状态变更,不具备通用冲突合并语义,因此必须选一个权威分支,另一个重建或做业务级补偿。
  • 深入回答:即使两边修改不同表,序列、约束、外部副作用、时间顺序和事务依赖也可能冲突。自动双向重放会破坏一致性。正确流程是停写、Fence、取证、选择权威、对账、重建。
  • 真正考察:是否知道脑裂是数据事故,不只是“两个主”。
  • 常见错误:建议把两个数据目录复制到一起或互相应用WAL。
  • 追问:如何自动发现脑裂?
  • 追问答案:从独立监控面统计所有节点pg_is_in_recovery()=false/primary=200数量,期望恒为1,并检测多个Timeline同时推进。

20.9 Timeline 与 LSN 的关系是什么?

  • 30 秒回答:LSN表示某条WAL流中的位置;Promotion会创建新Timeline。只比较LSN数值而忽略Timeline和共同祖先,可能把分叉节点误判为“更先进”。
  • 深入回答:Timeline history记录分叉点。旧主在旧Timeline上的高LSN不一定包含新主提交。Patroni可启用Timeline检查;回归时由pg_rewind基于共同检查点同步分叉块。
  • 真正考察:是否理解Failover后的WAL历史。
  • 常见错误:认为LSN大的节点必然可安全提升。
  • 追问pg_rewind 的关键前提是什么?
  • 追问答案:目标停止、可找到共同历史与所需WAL,并启用Data Checksums或wal_log_hints,同时保持full_page_writes=on;失败时要能全量重建。

20.10 Switchover、Failover 与 Failback 如何区分?

  • 30 秒回答:Switchover是健康集群的计划角色转移;Failover是故障下提升合格副本;Failback是旧节点安全回归后,必要时再计划迁回角色。
  • 深入回答:Switchover可等待追平和排空;Failover需要Fencing、RPO判断和未知Commit处理;Failback先rewind/reinit并稳定运行,不能旧主一上线就提升。三者的审批、风险和验证不同。
  • 真正考察:运维流程成熟度。
  • 常见错误:把所有角色变化都称为Failover并使用同一命令。
  • 追问:为什么不建议故障后立即Failback?
  • 追问答案:会在缓存、复制和业务尚未稳定时制造第二次切换,增加未知提交与入口抖动,收益通常只是拓扑美观。

20.11 Primary 进程崩溃后 Patroni 会直接提升副本吗?

  • 30 秒回答:不一定。它可先在primary_start_timeout内尝试本机Crash Recovery;无法恢复后才在安全候选存在时Failover。
  • 深入回答:本机恢复避免不必要Timeline切换,但等待太久会拉长RTO。需要结合Crash Recovery实测、Checkpoint和SLO设定。若主机完全宕机,则本机恢复路径不存在。
  • 真正考察:能否描述实际状态机。
  • 常见错误:认为任何postmaster退出都会立刻选新主。
  • 追问:如何选择primary_start_timeout
  • 追问答案:统计真实Crash Recovery分布,结合RTO和Failover成本;例如P99恢复15秒、RTO60秒时可给有限预算,而不是照抄默认值。

20.12 Primary 与 DCS 网络分区时最危险的是什么?

  • 30 秒回答:旧Primary仍能服务一部分客户端,而DCS多数派侧又提升新主。安全要求旧主在租约到期前降级,或由watchdog/STONITH强制隔离。
  • 深入回答:这是非对称分区,不同参与者看到不同拓扑。Failsafe只有在旧主能联系全部已知成员时才允许继续;否则必须失败关闭。入口直连会放大风险。
  • 真正考察:是否理解不可达、租约和隔离的差异。
  • 常见错误:只测试“整台机器关机”,从不测试单向网络故障。
  • 追问:为什么DCS Leader Lock还不够?
  • 追问答案:租约是控制面事实;旧PostgreSQL进程可能因暂停、网络隔离或Patroni失控继续写,需要数据节点侧和外部Fencing执行能力撤销。

20.13 DCS 失去多数派时应该自动提升副本吗?

  • 30 秒回答:不应该。没有权威共识就无法安全授予新的Leader Lock。应保持唯一现主的受控状态或停止写,先恢复quorum。
  • 深入回答:Failsafe可在全体数据库节点彼此可达时维持现主,但不会创造新Leader。手工在分区侧强制新DCS可能形成两个集群。可用性代价是必要的安全边界。
  • 真正考察:故障时是否敢于“不切”。
  • 常见错误:看到etcd不可用就执行pg_promote()
  • 追问:何时需要DCS快照恢复?
  • 追问答案:原多数派永久丢失且无法修复时,按单一、受审计的灾难恢复流程从一致快照重建;必须先排除另一个活跃控制面。

20.14 同步副本失联为什么可能导致写卡住而不是Failover?

  • 30 秒回答:Primary本身仍健康,问题是提交确认条件不满足。Patroni可切换到其它追平副本;strict下没有替代者就阻塞写,以保持RPO承诺。
  • 深入回答:此时切Primary并不会凭空产生同步数据,反而增加风险。应观察SyncRep等待、同步节点选择和其它副本lag。可用性优先系统可经审批降级,但关键账务通常失败关闭。
  • 真正考察:能否区分“主故障”和“耐久性条件故障”。
  • 常见错误:一看到写延迟就强制Failover。
  • 追问:客户端超时后可否重试?
  • 追问答案:若发生在Commit阶段先按未知结果对账;短超时取消Commit还可能引入特殊同步窗口,不能统一重试。

20.15 所有副本都超过 maximum_lag_on_failover 时怎么办?

  • 30 秒回答:自动化应拒绝提升,保持无主,直到旧主恢复、候选追平或业务明确接受数据损失后人工操作。
  • 深入回答:这是RPO和RTO冲突的显性时刻。强制提升能缩短RTO但会丢失分叉WAL。决策需要量化lag、受影响事务和补偿能力,并先Fence旧主。
  • 真正考察:是否会在压力下保护数据边界。
  • 常见错误:把lag阈值设得极大以“保证总能切”。
  • 追问maximum_lag_on_failover 能精确限制丢失为该字节数吗?
  • 追问答案:不能完全精确;异步模式下还存在Primary状态采样间隔内产生的WAL,需结合TTL和WAL速率估算。

20.16 旧 Primary 恢复供电后应做什么?

  • 30 秒回答:继续Fence,禁止入口;确认是否已有新Timeline。然后由Patroni执行pg_rewind或Reinitialize,作为Replica追平并验证后再解除隔离。
  • 深入回答:直接启动可能恢复旧写角色或持有分叉数据。pg_rewind快但有前提与失败风险;全量重建慢但边界清晰。回归完成不等于需要Failback。
  • 真正考察:旧主回归安全意识。
  • 常见错误:启动systemd后把DNS改回旧IP。
  • 追问:何时跳过rewind直接重建?
  • 追问答案:数据目录/磁盘可疑、缺少前提或所需WAL、rewind失败、合规要求更强验证时。

20.17 HAProxy、PgBouncer 与 DNS 在 Failover 中分别做什么?

  • 30 秒回答:HAProxy根据Patroni角色健康把新连接送到Primary;PgBouncer压缩数据库连接;DNS找到冗余入口。三者都不能迁移已建立TCP。
  • 深入回答:HAProxy可在后端失去Primary资格时关闭旧会话;PgBouncer可RECONNECT轮换服务端连接;DNS TTL只影响后续解析且受进程缓存影响。应用仍需处理Reset、只读和池重建。
  • 真正考察:是否理解连接生命周期。
  • 常见错误:认为DNS改完后所有连接立刻切换。
  • 追问:为什么应用还要做角色验证?
  • 追问答案:代理配置错误、旧连接或直连都可能到Replica/旧主;新连接执行低成本可写检查可防止静默写错节点,但不能替代正确入口。

20.18 什么是 Commit Outcome Unknown,如何处理?

  • 30 秒回答:COMMIT已发送但客户端没收到结果,事务可能提交也可能未提交。不能直接重试;应重连后按同一Idempotency Key查询结果。
  • 深入回答:连接Reset、超时、Failover都可能发生在WAL持久化与回包之间。数据库层同步RPO=0不代表客户端知道结果。幂等记录与业务写必须同事务;查到成功返回原结果,查不到才按受控规则重做。
  • 真正考察:分布式系统语义和业务幂等。
  • 常见错误:Commit报错就换UUID再INSERT一次。
  • 追问:仅依赖唯一约束够吗?
  • 追问答案:只能防重复键,仍要校验同一键的请求参数、保存结果状态并处理外部副作用,通常还要Outbox。

20.19 哪些数据库错误适合自动重试?

  • 30 秒回答:典型是4000140P01,重试完整事务并有上限、退避、Jitter和Context。连接错误只在明确未到Commit阶段时可重做;Commit错误按Unknown处理。
  • 深入回答:错误分类用errors.As*pgconn.PgError.Code,不用字符串。约束错误、磁盘满、语法错误不应重试。即使SQLSTATE可重试,业务动作也必须幂等且事务外没有已执行的不可逆副作用。
  • 真正考察:能否写出安全重试边界。
  • 常见错误:任何5xx或任何08xxx都立即重试三次。
  • 追问:为什么必须重试完整事务?
  • 追问答案:并发失败后原Snapshot、锁和中间读取结论已失效,只重放最后一条SQL可能破坏业务不变量。

20.20 如何防止 Connection Storm 和 Retry Storm?

  • 30 秒回答:统一连接预算、低MinConns、有界并发、指数退避+Full Jitter、Circuit Breaker、Load Shedding、Readiness和分批放量。
  • 深入回答:应用实例数×池上限、PgBouncer server pool和PostgreSQL Backend预算必须闭合。Failover后新主缓存冷,不应同时补齐所有连接。重试应按错误类别和幂等性,且服从原始Context。
  • 真正考察:是否把应用并发纳入数据库HA。
  • 常见错误:把MaxConns翻倍作为故障恢复方案。
  • 追问:Liveness是否应依赖数据库?
  • 追问答案:通常不应因短暂数据库故障杀死应用,否则会加剧重启风暴;Readiness可摘流,Liveness只反映进程自身不可恢复故障。

20.21 Replication Slot 把 WAL 盘写满时是否应 Failover?

  • 30 秒回答:通常不应。Slot问题可能随新Primary继续,Failover只转移症状。应恢复消费者、扩容或经审批删除/推进废弃Slot。
  • 深入回答restart_lsn阻止WAL回收。先看每Slot retained bytes、消费者速率、归档和磁盘预计填满时间。删除逻辑Slot会造成CDC缺口,必须有全量重建方案。绝不能手工删活动pg_wal
  • 真正考察:是否能区分容量故障和节点故障。
  • 常见错误:执行rm pg_wal/*或反复CHECKPOINT。
  • 追问max_slot_wal_keep_size 的代价是什么?
  • 追问答案:可保护主库磁盘,但超过上限后消费者可能无法继续并需重建;它是显式故障边界,不是免监控参数。

20.22 误删除后为什么不能切到Replica?

  • 30 秒回答:物理复制会重放同一DELETE,Replica很快也丢数据。应停止破坏源,从独立备份+WAL做PITR,再逻辑回灌或全库切换。
  • 深入回答:HA解决节点故障,备份提供历史时间维度。延迟副本可提供短窗口但会增加运维和RPO复杂度,也不能替代备份。恢复必须在隔离环境验证业务不变量。
  • 真正考察:是否分清HA与DR。
  • 常见错误:把三副本等同于三份备份。
  • 追问:什么时候选择逻辑回灌而不是全库PITR切换?
  • 追问答案:影响对象和时间范围有限、现库后续合法写较多时;隔离PITR后导出缺失行可减少整体回退。

20.23 如何设计三 AZ Patroni 集群?

  • 30 秒回答:每AZ一个PostgreSQL/Patroni和一个DCS成员;同步确认跨AZ;入口至少双实例跨AZ;备份异地;Fencing使用独立控制面;单AZ故障后剩余容量可承载降级流量。
  • 深入回答:Primary、同步候选和DCS不能集中同域;网络RTT影响写P99。三数据节点允许一个同步确认和一个额外候选。代理、监控、Secret和管理入口也要跨域,否则数据库HA仍有单点。
  • 真正考察:端到端故障域思维。
  • 常见错误:只把数据库跨AZ,etcd和HAProxy仍单AZ。
  • 追问:单AZ故障后是否立即扩容?
  • 追问答案:先稳定与限流,避免控制面抖动;在确认剩余容量和新主状态后分批扩容,恢复节点逐个加入。

20.24 跨地域灾备为何通常采用独立 Standby Cluster?

  • 30 秒回答:独立DCS和入口隔离地域故障,远端通过异步WAL/归档跟随;区域灾难时受控Promote。它避免把WAN分区直接带入本地主集群选举。
  • 深入回答:跨区RPO是实际receive/replay/archive lag,RTO含宣告灾难、Fencing旧地域、Promotion、全局流量和业务对账。缺点是成本和回迁复杂;替代是托管跨区服务或业务级多活,但复杂度更高。
  • 真正考察:是否会设计区域级边界。
  • 常见错误:把三个DCS节点分别放三个遥远地域并使用默认超时。
  • 追问:旧地域恢复后如何回迁?
  • 追问答案:把旧地域从新权威地域重新建立为Standby,追平并验证,再做计划迁移;不能把两个分叉直接合并。

20.25 如何估算和验证 RTO?

  • 30 秒回答:分解为检测/TTL、Fencing、Promotion、代理收敛、应用重连、对账和性能恢复,并在带真实流量的故障演练中逐段打点。
  • 深入回答:配置只能给理论边界。HAProxy fall×inter、PgBouncer连接、应用Jitter和冷缓存常比Promotion更慢。应同时报告“首个成功写”和“恢复到SLO”的时间。
  • 真正考察:是否会从SLO而非组件指标出发。
  • 常见错误:以patronictl list显示Leader的时刻作为RTO结束。
  • 追问:RPO如何验证?
  • 追问答案:故障前持续写入带序号/幂等键的固定请求集,故障后与客户端确认日志和数据库事实核对缺失、重复及未知项。

20.26 nofailovernosync 标签分别用于什么?

  • 30 秒回答nofailover禁止节点参加Leader竞选;nosync禁止成为同步副本。常用于维护节点、慢链路/跨区节点或仅报表节点。
  • 深入回答:标签会改变安全候选集合,维护后必须清除。把唯一健康副本设为nofailover会延长RTO;把所有副本设nosync并启用strict会停止写。需要配置审计和到期机制。
  • 真正考察:是否理解标签的生产后果。
  • 常见错误:维护结束忘记恢复标签。
  • 追问noloadbalance 呢?
  • 追问答案:使节点对/replica健康检查返回不可负载状态,用于暂时摘除只读流量,但不等同于禁止Failover或同步。

20.27 Patroni DCS Failsafe Mode 何时有用?

  • 30 秒回答:当DCS整体不可用,但当前Primary仍能通过REST联系DCS中全部已知数据库成员时,可维持现主,避免不必要写停。
  • 深入回答:它用数据库成员间全连通证明没有另一个可提升分区;任一成员不响应就降级。优点是提高“仅DCS故障”可用性,缺点是依赖REST网络、成员列表和认证,不能掩盖数据库网络分区。
  • 真正考察:是否理解Failsafe条件而不是“忽略DCS”。
  • 常见错误:认为开启后即使Primary与所有副本断网也会继续写。
  • 追问:启用前应演练什么?
  • 追问答案:DCS多数派丢失但数据库全连通、一个成员REST不可达、REST证书过期和非对称网络,验证Primary行为符合预期。

20.28 PostgreSQL HA 最关键的监控有哪些?

  • 30 秒回答:唯一Primary、DCS quorum/租约、Patroni HA循环、同步节点数、receive/flush/replay lag、Timeline、Fencing、入口健康、连接/重试、WAL/Slot/归档、备份与恢复演练。
  • 深入回答:必须同时监控状态和用户体验:数据库可写探针、业务幂等探针、P95/P99、未知Commit和Load Shed。时间差指标在空闲库会误导,应结合LSN和心跳WAL。
  • 真正考察:是否有多层可观测性。
  • 常见错误:只监控pg_isready和CPU。
  • 追问:哪个告警最应立即升级?
  • 追问答案:可写节点数不等于1、Fencing失败、DCS失去quorum、同步保护消失且业务承诺RPO=0、归档连续性中断和WAL预计即将写满。

20.29 如何设计 HA Chaos Test?

  • 30 秒回答:按故障模型逐层注入进程、主机、非对称网络、DCS、同步副本、代理、连接风暴和AZ故障;每次有停止条件、Fencing范围、固定请求集和RPO/RTO测量。
  • 深入回答:先实验环境,再预生产,最后受控生产GameDay。不能只测试关机;网络分区和旧主回归更能暴露脑裂。注入工具必须可撤销,监控应先于人工发现,Runbook由非作者执行。
  • 真正考察:是否把演练当工程验证。
  • 常见错误:只证明Patroni会出现新Leader,不核对重复/缺失交易。
  • 追问:何时应立即停止演练?
  • 追问答案:出现第二可写节点、Fencing越界、备份/归档异常、剩余容量低于安全线、未知业务副作用或监控失明时。

20.30 为什么“自动Failover成功”仍不代表HA设计完成?

  • 30 秒回答:完整HA还包括唯一写者证明、应用重连、未知Commit、容量、备份/PITR、旧主恢复、跨区DR、监控和演练。新Leader只是中间状态。
  • 深入回答:自动化可缩短角色切换,但也可能更快地执行错误决策。必须用业务SLO验证:是否丢/重交易、何时恢复P99、是否仍有同步保护、能否从备份恢复。替代“全自动”的是分层自动化加明确人工决策点。
  • 真正考察:综合架构能力。
  • 常见错误:把Patroni安装完成当作项目交付。
  • 追问:你会用哪三个指标向管理层报告?
  • 追问答案:实测端到端RTO、按业务事实验证的RPO/重复率、季度演练与备份恢复成功率;组件uptime只作为支撑指标。

21. 系统设计题

21.1 系统设计题一:支付系统高可用 PostgreSQL

题目

设计一个支付账务平台:峰值4,000写QPS、6 TiB数据、写P99≤120 ms;区域内已确认扣款不能丢,单AZ故障RTO≤60 s;跨区域RPO≤5 min、RTO≤60 min。要求处理重复请求、数据库Failover和外部支付网关。

需求澄清

  • “不能丢”限定为已向客户端返回成功的区域内关键交易;
  • 单笔扣款必须Exactly-once业务效果,但底层网络只能提供至少一次调用;
  • 外部网关不参与本地数据库长事务;
  • 余额与流水需满足守恒不变量;
  • 查询账单可短暂降级,扣款失败关闭;
  • 合规要求备份加密、审计、季度HA和半年恢复演练。

架构

PostgreSQL Patroni、Fencing、故障转移与综合高可用架构 flow 4

数据模型与事务

核心表:

  • payment_requests(idempotency_key PK, request_hash, status, payment_id, result)
  • payments(payment_id PK, account_id, amount, status)
  • ledger_entries(entry_id PK, account_id, payment_id, delta)
  • outbox(event_id PK, aggregate_id, event_type, payload, published_at)

本地事务:

  1. 尝试插入Idempotency Key;已有则校验请求哈希并返回既有结果;
  2. 锁定/原子更新余额,写不可变Ledger;
  3. 写Payment状态与Outbox;
  4. 更新幂等结果;
  5. 单独检查Commit错误。

外部网关由Worker读取Outbox调用,使用相同业务键;回调再以幂等事务推进状态。数据库事务中不等待网关HTTP。

HA 决策

  • Patroni synchronous_mode=quorum、strict、synchronous_node_count=1;三个数据节点跨AZ;
  • synchronous_commit=on,应用角色无权修改;
  • DCS三节点跨AZ,watchdog required,pre_promote必须完成云级STONITH;
  • HAProxy只对/primary为200的节点转发;PgBouncer transaction pool;
  • Go使用低MinConns、有界并发、Jitter、Breaker和Commit Unknown对账;
  • 跨区异步Standby和WAL Archive,不把WAN放入提交路径。

关键故障行为

故障行为
Primary进程/主机故障Fence旧主;同步安全候选提升;应用Reset旧池并按幂等键对账
同步副本全失strict阻塞新扣款;账单读和静态能力降级;不自动牺牲RPO
Commit超时查询payment_requests;不换键重扣
外部网关超时Worker按网关幂等协议重试;不回滚已提交本地Outbox
误删除停止破坏账号;隔离PITR;逻辑回灌
区域故障全局Fence旧地域;提升DR;接受≤5min数据差并运行网关/账务对账

容量与性能

  • 单Primary按写峰值的1.3倍和单AZ故障后资源预算压测;
  • 同步副本磁盘P99与跨AZ网络尾延迟必须满足写SLO;
  • 应用总数据库连接预算≤240,PgBouncer总server连接约180,保留运维余量;
  • Outbox消费者与报表可在故障时暂停;
  • 监控余额不变量、重复幂等键参数、未知Commit和对账时长。

权衡与替代

  • strict会在失去同步保护时停止写,这是满足RPO承诺的代价;
  • 两区域多写需要全局一致性、冲突和外部副作用协议,复杂度远高于本题,除非业务有明确多活需求;
  • 托管PostgreSQL可替代自建控制面,但仍需验证Fencing、RPO语义、连接行为、PITR和跨区恢复。

面试官加分点

明确区分“数据库Exactly-once不存在”和“通过幂等键、唯一约束、Outbox与对账实现一次业务效果”;同时把Commit Unknown、旧连接和Fencing纳入主流程,而不是附加说明。


21.2 系统设计题二:订单系统读扩展与 Failover

题目

设计订单平台:峰值读60,000 QPS、写3,000 QPS;用户下单后必须立即看到自己的订单,列表/运营查询允许2秒陈旧;单节点故障RTO≤45秒。

架构与路由

  • 三AZ Patroni集群负责写;一个当前同步副本、一个异步/候选副本;
  • rw端点只接Primary;ro端点接延迟≤2秒且noloadbalance=false的Replica;
  • 用户下单、取消、支付状态更新和“下单后立即查询”走rw
  • 历史列表、运营报表走ro,并有最大查询时长和资源组/独立报表副本;
  • 缓存只存可失效视图,不作为订单事实源。

写后读一致性

三种方案按场景选择:

  1. 短期Primary粘滞:写成功后数秒内该用户读走rw;简单可靠,代价是Primary读量。
  2. LSN一致性令牌:提交后获取Primary LSN,读副本先等待/检查 pg_last_wal_replay_lsn() >= token,超时回退Primary;更精细但增加请求复杂度。
  3. 版本号/事件确认:返回订单版本,Replica读不到该版本时回退Primary;适合领域模型。

不推荐在所有Replica上强制remote_apply同步提交,因为会把读扩展目标转化为写延迟和可用性成本。

Failover 行为

  • HAProxy /primary切换写端点;/replica?lag=...自动排除提升中的节点和落后Replica;
  • Go对25006/57P01/08xxx重置池并退避;订单创建使用Idempotency Key;
  • Commit Unknown按订单请求键查询;
  • 新Primary提升后,原先ro池中指向它的连接应从只读入口摘除,避免读流量挤占恢复写负载;
  • 故障阶段暂停运营大查询并降低列表刷新频率。

容量

  • 60k读QPS按查询类型、缓存命中和每副本能力拆分,不能简单除以2;
  • Replica必须为未来Primary保留写CPU、WAL和连接余量,不能被报表打满;
  • 单个慢查询通过statement_timeout、并发阈值和只读连接池隔离;
  • 监控每端点QPS、Replica replay lag、回退Primary比例、写后读未命中率。

取舍

读副本提升吞吐但引入陈旧、Recovery Conflict、缓存重复和路由复杂度。若读主要是按订单ID点查,Redis/应用缓存可降低成本;若需要复杂分析,应使用独立分析平台或逻辑复制下游,不把HA副本当无限报表库。


21.3 系统设计题三:跨地域 PostgreSQL 灾难恢复平台

题目

为多个业务数据库提供统一跨地域DR平台:主地域RPO=0(单节点/AZ),地域故障RPO≤5分钟、RTO≤60分钟;要求自动收集证据,但跨地域Promote需人工批准。

平台分层

主地域DR地域
数据库每集群三AZ Patroni独立Patroni Standby Cluster,三AZ或按等级配置
DCS独立etcd/Consul集群独立DCS,namespace隔离
数据复制区域内同步,跨区异步streaming接收WAL并从共享/复制Repository取归档
备份pgBackRest生成与校验异地不可变Repository副本
入口区域LB/DNS预置但默认不接写流量
控制DR Orchestrator收集状态执行Fence验证、Promote、全局流量切换

灾难宣告状态机

Suspected
→ Collect evidence(地域健康、WAL lag、旧地域可达性)
→ Human approval
→ Fence old region(入口、凭据、网络/实例)
→ Freeze last received/replayed/archive LSN
→ Promote DR standby leader
→ Validate unique writable + business invariants
→ Shift global traffic progressively
→ Reconcile RPO window
→ Rebuild former region as new standby

RPO 证明

平台持续记录:

  • 主地域 pg_current_wal_lsn()
  • DR receive_lsn/replay_lsn
  • 最后归档WAL与时间;
  • 业务心跳表的最新序号;
  • 网络与复制吞吐。

区域故障后,以DR最后可重放LSN和业务心跳计算实际缺口,而不是写死“5分钟”。若流复制断开但Archive仍连续,可从Repository补齐。

RTO 预算

阶段预算示例
事故确认与审批10 min
旧地域Fencing证明10 min
补齐WAL/Promotion10 min
数据与业务校验10 min
全局流量渐进切换10 min
缓冲10 min

这些是目标预算,必须用6 TiB级恢复、真实DNS/全球LB和应用连接演练校准。

多租户平台护栏

  • 每个数据库有等级、RPO/RTO、Owner、Fencing适配器和演练日期;
  • 跨区Promotion要求双人审批和一次性令牌;
  • 自动化拒绝在旧地域未Fence、DR lag超阈值、备份不连续或出现第二可写节点时执行;
  • 所有命令写审计事件;
  • 应用必须声明Idempotency和Commit Unknown处理能力,否则不授予自动切流资格。

回迁

灾区恢复后,不反向“同步两个Primary”。销毁/隔离旧分支,从当前权威地域重建Standby;追平、校验、演练后再计划迁回。回迁本身是一场完整的跨地域Switchover。


22. 练习与完整参考答案

22.1 理论题

题 1

参考配置 loop_wait=10retry_timeout=8ttl=20 是否有效?为什么?

答案:无效,因为 10 + 2×8 = 26 > 20,不满足Patroni安全约束。至少把TTL提高到26以上,或降低循环/重试;实际还要按DCS与网络P99留余量,不能只取等号。

题 2

异步模式下 maximum_lag_on_failover=64 MiB,峰值WAL为120 MiB/s,是否能宣称最多丢64 MiB?

答案:不能。候选lag不是实时连续采样,最坏还包含最近TTL窗口产生的WAL;粗略风险上界至少要把64 MiB与采样/TTL期间的WAL相加。若业务要求已确认事务RPO=0,应使用同步安全模式而非仅调lag阈值。

题 3

为什么 synchronous_mode_strict=true 仍需要 Idempotency Key?

答案:strict解决副本持久性和选主资格,不解决客户端是否收到Commit响应。回包前连接中断仍会产生Unknown;此外客户端重放、上游超时和外部副作用都需要业务幂等。

题 4

三节点etcd失去一个节点后,为什么不应立即添加第四节点?

答案:三节点剩两节点仍有2票quorum。直接添加第四成员会把多数派提高到3;若新成员配置错误,现有两节点无法提交回滚成员变更。应按etcd流程先安全移除永久故障成员,再添加替代者。

题 5

HA副本、延迟副本和PITR分别解决什么问题?

答案:HA副本解决节点失效和快速角色接管;延迟副本提供有限的逻辑误操作缓冲,但增加RPO/运维复杂度;PITR利用独立备份与连续WAL恢复到指定历史点,解决误删除、错误发布和更广泛灾难。

22.2 实验题

题 6

设计实验验证 HAProxy 是否会在旧Primary失去角色后关闭既有连接。

答案:建立长连接记录backend_pid/server_addr;在HAProxy开启on-marked-down shutdown-sessions;执行Planned Switchover;观察旧连接在角色变更后收到Reset/Admin Shutdown,并通过新连接到新地址。记录健康检查fall/inter到连接关闭的时间;对照关闭该选项的实验。验证旧连接不能执行写,且业务使用同一幂等键恢复。

题 7

如何验证 nosync 标签会影响写可用性?

答案:在隔离三节点集群启用strict和一个同步节点;把另一个Replica设nosync=true;停止当前同步Replica。预期Patroni不能选nosync节点替代,Primary提交阻塞。清除标签并让其追平后,观察同步集合恢复和写继续。记录/syncpg_stat_replicationSyncRep等待。

题 8

设计实验测量实际RTO各阶段。

答案:持续发送带序号幂等写;对Primary做云级关机;在DCS、Fencing、Patroni、HAProxy、PgBouncer和Go应用统一UTC打点。计算:故障→Leader锁失效、→Fence完成、→Promote、→/primary=200、→首个成功写、→P99恢复SLO。故障后核对固定请求集,单独报告Unknown与重试。

22.3 排障题

题 9

现象:Patroni已显示新Leader,但应用仍持续收到25006。给出排查顺序。

答案:先记录错误连接的远端IP与路径;检查HAProxy /primary后端与配置一致性;检查PgBouncer是否仍复用旧服务端连接,必要时受控RECONNECT;检查DNS/sidecar缓存和应用pool连接年龄;确认防火墙无直连;应用对25006执行池Reset并验证新连接可写。不要再次Failover,因为数据库角色可能已正确。

题 10

现象:数据库写延迟突然升高,连接正常,wait_event='SyncRep',一个Replica replay lag很低但flush lag很高。根因和动作是什么?

答案:同步提交等待副本WAL持久化,replay指标不能掩盖当前flush路径异常;可能是副本磁盘、WAL Receiver、Checkpoint或网络。检查pg_stat_replication write/flush、Replica磁盘延迟和日志;若有其它追平合格节点,让Patroni切同步成员;strict且无替代时保持写保护并Load Shed,不以Failover掩盖副本I/O问题。

22.4 系统设计练习

题 11

一个SaaS平台有300个租户、2 TiB数据、读写比20:1。大租户要求读后写一致,小租户允许3秒陈旧;区域内RPO=0,预算只允许三个数据库节点。设计路由、同步与降级。

参考答案

  • 三AZ Patroni,Primary+两Replica,区域内quorum strict;
  • 写全部走rw;大租户写后读在短窗口走rw或使用LSN令牌,普通历史读走延迟≤3秒ro;
  • 通过tenant_id索引/分区、查询限时和每租户Admission防止大租户压垮Replica;
  • Replica必须保留Failover容量,报表重查询迁往异步分析系统;
  • PgBouncer限制连接,Go按租户与全局双层限流;
  • 同步副本全失时停止写,小租户缓存读可降级;
  • Commit Unknown使用tenant_id + idempotency_key唯一约束;
  • 监控每租户QPS/P99、ro回退率、Replica lag、同步保护和容量;
  • 当单Primary写或存储接近上限时,按tenant_id分片,每个分片独立三节点HA,路由目录自身也要HA。

23. 最终检查清单

架构与数据安全

  • 我能够解释Primary、同步Replica、异步Replica、Patroni和DCS的职责边界;
  • 我能够证明任何时刻只有一个写入口和一个可写节点;
  • 我能够解释DCS quorum与同步提交quorum的区别;
  • 我能够根据业务RPO选择async、sync、strict或quorum;
  • 我能够说明watchdog、STONITH、pre_promote各自覆盖的失效模式;
  • 我能够解释Timeline分叉以及为什么旧主不能直接上线;
  • 我能够在pg_rewind失败时安全Reinitialize;
  • 我能够用备份和PITR恢复误删除,而不是切Replica。

性能与并发

  • 我能够量化同步复制对WAL flush、网络RTT、锁持有和P99的影响;
  • 我能够区分goroutine、客户端连接、数据库连接、活跃查询和TPS;
  • 我能够统一计算pgxpool、PgBouncer和PostgreSQL连接预算;
  • 我能够防止Connection Storm与Retry Storm;
  • 我能够在Failover后识别冷缓存、I/O和Checkpoint导致的二次过载;
  • 我能够监控Slot retained WAL并在写满前处理。

Go 与业务正确性

  • 我能够用errors.As*pgconn.PgError和SQLSTATE分类错误;
  • 我只对40001/40P01等明确错误重试完整事务;
  • 我能够区分非Commit连接错误与Commit Outcome Unknown;
  • 我能够用同事务Idempotency Key和Outbox保证业务效果;
  • 我能够重置旧连接池并用Jitter渐进重连;
  • 我实现了Circuit Breaker、Load Shedding、Readiness和优雅停机;
  • 我的应用不会因数据库短暂故障触发无限实例重启。

运维与演练

  • 我能够执行Planned Switchover并验证业务、同步与入口;
  • 我能够在Unplanned Failover中先确认Fencing,再考虑强制提升;
  • 我能够处理DCS单节点与多数派故障;
  • 我能够处理WAL磁盘写满而不删除活动WAL;
  • 我能够执行AZ与区域级DR流程;
  • 我用端到端故障演练测量真实RPO/RTO;
  • 我定期从pgBackRest Repository恢复并验证业务不变量;
  • Runbook由非作者成功执行过,所有高风险命令有审批和审计。

24. 官方资料来源与版本说明

核对日期:2026-06-21。 本章的命令、配置项与行为描述以 PostgreSQL 18、Patroni 4.1.3、pgx/v5 当前公开文档为基线。生产环境应固定补丁版本,并在与生产等价的内核、文件系统、网络、DCS、负载均衡器与云平台上复测;本章中的超时、WAL Lag、连接数和容量值是参考起点,不是可直接复制的通用最优值。

24.1 主要官方文档

主题官方资料本章用途
Patroni 版本Patroni v4.1.3 Release版本基线
动态配置Patroni Dynamic Configurationloop_waitretry_timeoutttl、最大Failover Lag、同步模式
复制模式Patroni Replication ModesAsync、Sync、Strict、Quorum的语义与代价
DCS失效安全Patroni DCS Failsafe ModeDCS不可用时Primary能否继续服务的边界
Patroni YAMLPatroni YAML Configurationwatchdog、pre_promote、tags、use_pg_rewind、REST TLS
REST APIPatroni REST APIHAProxy健康检查、角色探测、监控与运维调用
patronictlPatroni patronictlSwitchover、Failover、Reinitialize与历史查询
PostgreSQL复制与FailoverWarm StandbyFailover流复制、同步提交、旧主隔离与Failover基本语义
pg_rewindpg_rewindTimeline分叉后的旧主回归
libpq连接参数Database Connection Control FunctionsMulti-host、连接超时与target_session_attrs
SQLSTATEPostgreSQL Error CodesGo错误分类与有限重试
etcd多数派etcd FAQ三节点DCS的quorum与容错数量
pgxpoolpgxpool packagePool配置、生命周期、健康检查与Reset
pgconnpgconn packagePgError、SQLSTATE与连接错误识别
PgBouncerPgBouncer ConfigurationUsage事务池、连接回收、Pause/Resume/Reconnect
HAProxyHAProxy Health Checks主从端点的主动健康检查与摘除
pgBackRestpgBackRest User Guide全量/增量备份、WAL归档、恢复与演练
Go版本Go Release HistoryGo工具链与安全补丁基线

24.2 文内脚注

Footnotes

  1. etcd官方FAQ说明,集群需要多数派才能继续处理写操作;三节点集群的quorum为2,可容忍1个成员失效。参见etcd FAQ

  2. Patroni动态配置文档定义了loop_waitretry_timeoutttl及其约束,并说明最大Failover Lag、同步模式、Strict模式和Quorum相关参数。参见Patroni Dynamic Configuration 2

  3. Patroni复制模式文档描述异步复制的数据丢失窗口,以及同步、Strict和Quorum模式的可用性与一致性取舍。参见Patroni Replication Modes 2 3

  4. Patroni DCS Failsafe要求Leader在DCS不可用时能够与DCS中已知的全部成员通信;否则应降级,避免网络分区演化为双主。参见Patroni DCS Failsafe Mode 2

  5. Patroni YAML文档覆盖watchdog模式、pre_promote回调、节点tags、REST TLS和use_pg_rewind等配置。参见Patroni YAML Configuration 2 3 4

  6. pg_rewind用于把发生Timeline分叉的旧Primary同步回目标集群状态;执行前仍应确认节点已被Fencing,且必要的WAL可获得。参见PostgreSQL 18 pg_rewind 2

  7. Patroni REST API既供Patroni与patronictl使用,也可供HAProxy和监控系统根据节点角色与健康状态进行探测。参见Patroni REST API

  8. libpq连接参数支持多个host、连接超时与target_session_attrs,但应用仍需把入口治理、旧连接回收和写角色验证作为独立问题处理。参见PostgreSQL 18 libpq Connect

  9. PostgreSQL Warm Standby文档描述流复制、同步复制、synchronous_standby_names与同步提交行为。参见PostgreSQL 18 Warm Standby

  10. PostgreSQL建议应用基于五字符SQLSTATE而不是本地化错误文本处理异常。参见PostgreSQL 18 Error Codes 2

  11. pgBackRest官方用户指南覆盖备份、归档、恢复、校验与Repository运维。参见pgBackRest User Guide

  12. pgxpool公开API包含连接生命周期配置、BeforeConnectAfterConnect、健康检查、寿命抖动与Pool.Reset等能力。参见pgxpool package

  13. pgconn公开PgError及SQLSTATE字段,并提供底层连接错误处理能力。参见pgconn package