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/v5与pgxpool。PostgreSQL 19 仍不作为本文生产基线。本文所有容量与时间参数都服务于下述参考工作负载,不是通用推荐值。
1. 本章定位
前 22 章分别讨论了事务、WAL、复制、备份、逻辑复制、连接池和应用幂等。本章把这些能力组装为一套可以被检测、选主、隔离、切流、恢复和演练的生产级 PostgreSQL 高可用系统。
本章解决五个核心问题:
- Primary 失效时,谁有资格成为新 Primary;
- 如何证明旧 Primary 已经不能继续写,避免 Split Brain;
- 应用如何处理旧连接、连接重置、只读错误与 Commit 结果不确定;
- 故障转移后如何恢复旧节点,而不是直接把它重新接入;
- 如何把 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 s | Patroni 计时、代理探测和应用退避共同受约束 |
| 区域级 DR | RPO ≤ 5 min,RTO ≤ 60 min | 跨地域异步,不把跨地域 RTT放入每次提交 |
| 部署 | 单地域三 AZ;异地独立备份库与 Standby Cluster | DCS、数据节点和入口均跨故障域 |
| 读一致性 | 交易确认后读走 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_wait、retry_timeout、ttl和primary_start_timeout估算故障检测窗口; - 解释
synchronous_mode、synchronous_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 Replica | Primary 提交需要等待其满足同步确认条件的副本 | sync_state='sync' 不等于已应用到可读层;取决于 synchronous_commit | 数据面 |
| 异步副本 | Asynchronous Replica | 提交不等待该副本确认 | 低延迟不等于 RPO=0 | 数据面 |
| 分布式配置存储 | DCS | Patroni 保存成员、配置、Leader Lock、同步状态等的共识存储 | DCS 不保存业务数据,也不转发 SQL | 控制面 |
| 领导者选举 | Leader Election | 多个合格副本竞争 Leader Lock 并决定谁可提升 | 不是“谁先启动谁当主” | 控制面 |
| 领导者锁 | Leader Lock | 带 TTL 的排他租约,表示某 Patroni 节点当前有资格维持 Primary | 仅有锁仍不足以取代 Fencing | 控制面 |
| 租约期限 | TTL | Leader Lock 未续约后失效的时间窗口 | 不是固定 RTO;还受循环、重试、启动、代理和应用影响 | 控制面 |
| HA 循环间隔 | loop_wait | Patroni 两次 HA 循环间的等待时间 | 不是健康检查的唯一间隔 | 控制面 |
| 重试超时 | retry_timeout | Patroni 对 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 Failover | Primary 不可用时选择合格副本提升 | 可能包含未知提交和旧主隔离 | 运维/自动化 |
| 回切 | Failback | 将业务角色迁回原位置或新建节点后的目标位置 | 不是旧主一上线就改回 Primary | 运维流程 |
| 时间线 | Timeline | PostgreSQL 在恢复/提升时形成的 WAL 历史分支标识 | LSN 只在具体时间线语境中比较才安全 | 存储/恢复 |
| 旧主节点 | Old Primary | 故障前曾接受写入、但在新时间线产生后必须被隔离的节点 | 不能把它当普通副本直接启动 | 故障状态 |
| 提交结果不确定 | Commit Outcome Unknown | 客户端未收到 COMMIT 结果,事务可能已提交也可能未提交 | “收到超时”不等于“数据库回滚” | 应用语义 |
| 优雅降级 | Graceful Degradation | 在能力下降时主动限制写入、读一致性或非核心功能 | 不是静默返回错误数据 | 应用/SRE |
4. 整体心智模型
4.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 可取 off、on 或当前 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状态以限制安全候选。3synchronous_mode_strict: true表示无同步保护时宁可阻塞关键写入。它仍不是“宇宙级绝对零丢失”:后端在等待同步确认期间被取消,可能出现本地可见但尚未复制的特殊窗口,因此应用不能用短超时粗暴取消 COMMIT。3failsafe_mode: true只在当前 Primary 能通过 Patroni REST 联系 DCS 中已知的全部成员时,才允许 DCS 故障期间继续作为 Primary;任何成员不可达都会促使其降级。这要求 REST 网络、认证和监控真正可靠。4watchdog.mode: required让无法启用 watchdog 的节点不能成为 Leader;它是本机失控后的最后防线之一,不等于云层面的 STONITH。5pre_promote在取得 Leader Lock 后、真正提升前执行外部隔离检查。脚本非零退出时,Patroni 不提升并删除 Leader Key。5data-checksums或wal_log_hints=on是pg_rewind的前提之一;仍应保留全量重建路径。6- 不能只修改
bootstrap.dcs期待已存在集群生效;首次引导后应使用patronictl edit-config或受控 REST API 修改动态配置。5
5.2 on、strict 与 quorum 的选择
| 模式 | 成功提交保护 | 副本丢失时 | 写延迟 | 自动选主集合 | 适用场景 |
|---|---|---|---|---|---|
synchronous_mode: off | 异步,可能丢失已确认事务 | 写通常继续 | 最低 | 足够新且符合 lag 条件的副本 | 缓存、可重建数据、可接受 RPO > 0 |
on, strict=false | 有同步节点时保护 | Patroni 可退化为无同步写;Primary 故障时可能不自动提升 | 中等 | Leader 或 DCS 记录的同步副本 | 更重视写可用性,业务允许受控风险 |
on, strict=true | 至少要求指定同步副本 | 无同步副本时写阻塞 | 中等到高 | 严格受 /sync 限制 | 关键账务、区域内已确认写 RPO=0 目标 |
quorum, strict=true | ANY n 确认;Patroni维护安全投票集合 | 可由其它追平节点接替确认;不足时阻塞 | 通常尾延迟更稳 | 满足 quorum 安全性与 LSN 条件的节点 | 三个及以上低延迟数据节点 |
跨地域副本通常应设置
nosync: true,避免偶然进入同步集合并把跨地域 RTT 注入每次提交。维护中不希望被提升的节点设置nofailover: true或非正failover_priority;两者不要混用。quorum 模式下当前 Patroni 文档提示failover_priority有已知限制。5
5.3 Fencing 与 pre_promote
一个安全的提升前钩子至少要验证:
- 新节点已持有 DCS Leader Lock;
- 外部 Fencing 服务已对旧 Primary 完成断电、隔离网络或撤销存储访问;
- Fencing 操作具有幂等请求 ID;
- 查询云/机房控制面确认最终状态,而不是只相信 API 返回“已受理”;
- 无法证明旧节点被隔离时,脚本非零退出;
- 所有动作写入审计日志和事件系统。
#!/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_state:async、potential、sync或quorum;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 同步复制把提交等待扩展到同步副本的指定阶段。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 时间线

6.4 为什么旧 Primary 不能直接回来
Promotion 会创建新 Timeline。旧 Primary 可能持有只存在于旧分支的 WAL,也可能缺少新 Primary 上已提交的 WAL。如果两者都接受写入,两个分支没有通用的自动合并语义。
安全回归只有两条主要路径:
pg_rewind:找到共同检查点,复制分叉后变化的数据块和必要 WAL,把旧主对齐到新主;目标必须停止,且需要数据校验和/wal_log_hints等前提。失败后目标数据目录可能不再可用,所以执行前要有重新克隆方案。6- 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 是
40001和40P01,前提是业务操作可重放; - 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 成员与票数 | 限制谁能在不丢已确认事务的前提下提升 |
/history | Failover 与 Timeline 历史 | 审计和分叉判断 |
/failsafe | Failsafe 拓扑信息 | 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,较小 RTO | strict 同步跨 AZ | 数据可重放,低延迟优先 | 低 | 故障重试较多 | 允许有限 RPO | Failover 更快 | 中 |
| 跨地域强一致写 | 单写地域 + 异步 DR;业务层双写协议另行设计 | 直接把远端副本设为每次同步提交 | WAN 抖动会进入写延迟和可用性 | 高 WAN RTT | 锁持有时间增大 | 同步可强但可用性差 | 网络分区难处理 | 很高 |
| 读多写少报表 | 异步只读端点 + 延迟阈值 + 查询隔离 | 所有读随机打副本 | 明确陈旧度与长查询影响 | 增加读容量 | 副本长查询/冲突 | 允许陈旧 | 不应作为唯一故障候选 | 中 |
| Kubernetes 平台 | Patroni Kubernetes DCS/Operator,跨节点与 Zone,独立备份 | 把 Pod 重启等同于数据库 HA | K8s 调度与数据库时间线是不同状态机 | 控制面开销 | 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;
MaxConns、MinConns、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 + PITR | HA 副本会立即复制误操作,不能替代备份 |
| 静默损坏 | 依校验和与备份历史 | 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 模式阻塞写入时,正确策略通常是:
- 保持账务写失败关闭;
- 允许静态/缓存读;
- 对非关键异步任务暂停消费;
- 在入口实施限流和明确错误码;
- 绝不在无审批情况下把
synchronous_commit改成off; - 若业务批准牺牲 RPO,必须记录变更窗口、风险、回滚和补偿计划。
12. 三维影响矩阵
| 维度 | 相关度 | 核心收益 | 主要风险 | 关键指标 |
|---|---|---|---|---|
| 高性能 | 高 | 读扩展、受控连接、快速恢复 | 同步 RTT、冷缓存、代理开销、备份 I/O | QPS、P95/P99、WAL/s、fsync、复制延迟、Cache Hit、I/O queue |
| 高并发 | 高 | 连接压缩、有界重试、故障背压 | Connection/Retry Storm、锁等待放大、池层级失控 | 活跃查询、连接获取等待、排队数、重试率、SyncRep wait、拒绝率 |
| 高可用 | 极高 | 自动选主、安全切流、可恢复旧主、区域 DR | Split Brain、未知 Commit、DCS 失去 quorum、Slot/WAL 写满 | RPO/RTO、Leader 变更、Fencing 成功率、归档连续性、恢复演练成功率 |
13. 二十类故障逐项分析
以下 RPO/RTO 均以第 1.1 节参考系统为前提。“自动”表示已有配置和健康条件满足时的预期行为,不表示可以取消人工确认与事后审计。
13.1 数据库节点与网络故障
| # / 故障 | 检测方式 | 自动恢复与是否 Failover | RPO / 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 | 常见 57P01、08006、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、复制资格与旧主回归
| # / 故障 | 检测方式 | 自动恢复与是否 Failover | RPO / RTO | 脑裂与 Fencing | 应用错误、Go 处理与重试 | 旧节点恢复 | 监控与演练 |
|---|---|---|---|---|---|---|---|
| 5. 单个 DCS 节点故障 | etcd member/endpoint health、Raft leader与proposal指标、磁盘延迟 | 3节点仍有2/3,多数派可工作;不应触发 PostgreSQL Failover | RPO=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 入口、连接与提交故障
| # / 故障 | 检测方式 | 自动恢复与是否 Failover | RPO / RTO | 脑裂与 Fencing | 应用错误、Go 处理与重试 | 旧节点恢复 | 监控与演练 |
|---|---|---|---|---|---|---|---|
| 10. HAProxy 故障 | LB/VIP健康、进程/端口、连接错误率、备用实例状态 | 由第二HAProxy、VIP或云LB接管;不应触发数据库 Failover | RPO=0;入口RTO应为秒级并单独测量 | 无数据库脑裂;若两个代理健康检查规则不同,可能同时把不同节点视为Primary | Connection Refused/Timeout;Go有界重连、Jitter;事务中断重做,Commit阶段对账 | 无数据库旧节点;修复代理并验证配置哈希和/primary结果后再接流量 | 监控两实例与VIP;演练杀进程、断网、错误后端检查,验证连接不会打到Replica |
| 11. PgBouncer 故障 | 管理库SHOW POOLS/STATS、进程/端口、客户端等待、服务端连接数 | 双实例或sidecar重建;不触发数据库Failover | RPO=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 容量、逻辑破坏与故障域
| # / 故障 | 检测方式 | 自动恢复与是否 Failover | RPO / 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/reinit | 70/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崩溃后提升仍需标准Fencing | 53100、写失败;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 故障矩阵的三个判定原则
- 能 Failover 不等于应该 Failover:误删除、Slot 积压、代理故障和全副本落后都不应通过随意提升“解决”。
- 不可达不等于已隔离:网络分区中的旧 Primary 可能仍在服务另一个客户端群体。
- RPO=0 不等于客户端知道结果:同步持久性和 Commit Outcome Unknown 属于不同层次。
14. Go、pgx 与故障转移
14.1 应用必须处理的错误类别
PostgreSQL 建议客户端按 SQLSTATE 而不是本地化错误文本分类。10
| 类别 | 典型 SQLSTATE/错误 | 含义 | 默认策略 |
|---|---|---|---|
| 事务可重试 | 40001, 40P01 | Serialization Failure、Deadlock | 在幂等前提下重试完整事务,有上限/退避/Jitter |
| 角色变化 | 25006 | read_only_sql_transaction | 废弃旧连接、重置池、重新连接可写端点 |
| 服务关闭/恢复 | 57P01, 57P02, 57P03 | Admin 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 设计点
- 池内旧连接:角色或连接错误触发节流后的
pool.Reset();已借出连接归还时关闭。 - TCP 不能迁移:任何 Failover 都通过建立新连接完成,而不是修改旧 socket。
- DNS Cache:连接寿命、Reset、多主机 URL 和稳定代理入口共同降低旧解析影响。
- Connection Refused/Reset:归为基础设施错误,执行有限次数完整事务重试;Commit 阶段除外。
- Read-Only:识别
25006,并在AfterConnect与 Readiness 中验证pg_is_in_recovery()。 - Admin Shutdown:
57P01等触发池轮换与退避。 - Commit Unknown:单独错误类型,不进入普通重试;先查幂等结果。
- Jitter:连接建立和事务重试都随机打散。
- Circuit Breaker:连续基础设施失败后快速拒绝,减少恢复中压力。
- Load Shedding:有界 Gate 和最大排队时间,非无限 goroutine。
- Readiness:只在能访问可写节点时返回 200;Liveness 不依赖数据库短期状态。
- 优雅停机:先停止接流量与探针,再关闭连接池;事务仍必须受请求 Context 限制。
示例中的断路器是教学版。生产实现还应有半开探针、按依赖/错误类别分桶、Prometheus 指标、实例间随机化和明确的上游错误契约。
15. 实验
统一安全要求:以下实验只能在隔离的非生产 Patroni 集群执行。所有节点必须有可用备份、带外管理入口和明确回滚人。禁止在生产环境用
kill -9、iptables、tc、填盘或手工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 version、patronictl 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. 明确时间线
| 时间 | 动作 | 等待/失败/提交 |
|---|---|---|
| T0 | Session A 开事务并睡眠 | A 等待;事务未提交 |
| T1 | Session B 连续写入 | 正常提交 |
| T2 | Session C 发起 Switchover | Patroni等目标追平并停旧主 |
| T3 | pg-b Promote,新 Timeline | HAProxy探测收敛;B可能短暂失败 |
| T4 | Session A 从睡眠返回并继续 | 旧连接预期 Reset/Admin Shutdown;A事务失败 |
| T5 | B 通过新连接继续 | 所有请求键最终提交一次 |
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_mode和synchronous_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. 时间线与可能结果
| 时间 | 动作 | 结果 |
|---|---|---|
| T0 | A 已执行 INSERT,尚未 COMMIT | 数据仅在未提交事务内 |
| T1 | A 发送 COMMIT | Primary可能开始本地/同步WAL持久化 |
| T1+ε | C 杀死 Primary/Patroni | A可能收到成功、Reset、EOF或超时 |
| T2 | Leader Lock到期,候选竞争 | 合格同步/Quorum副本取得锁 |
| T3 | Fencing成功并Promote | 新Timeline开始 |
| T4 | HAProxy/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
当出现“数据库不可用/正在切换”告警时,按以下顺序执行:
- 确认事故范围与冻结变更:记录 UTC 时间、业务影响、当前Primary、最近发布;暂停DDL、配置变更、扩缩容和自动修复任务。
- 确认是否存在多个可写节点:从独立管理网查询所有节点
pg_is_in_recovery()与 Patroni/primary;若可写节点数大于1,立即停止写入口并执行Fencing。 - 查看控制面:
patronictl list/show-config/history,DCS endpoint/quorum、Leader Lock TTL、Patroni HA循环与REST连通性。 - 查看数据面:Primary/Replica进程、Timeline、receive/flush/replay LSN、同步成员、Replication Slot、归档状态。
- 定位连接和阻塞:
pg_stat_activity查看活动/空闲事务、wait_event_type/event、最老事务;用pg_blocking_pids(pid)找 blocker。Failover事故中仍要排除长DDL/锁队列导致的“看似宕机”。 - 定位最早计划估算错误:如果事故表现为过载而非节点死亡,查
pg_stat_statements(若已启用)中最早突增的高总耗时/高行数偏差语句,并回看发布前后EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY);DML会真实执行,生产不得随意运行 ANALYZE 版本。 - 区分瓶颈:CPU看run queue/steal;内存看swap/OOM;I/O看延迟、队列和
pg_stat_io;锁看等待链;连接池看Acquire等待/PgBouncercl_waiting;WAL看生成、归档、Slot;Vacuum看长事务和Freeze;复制看字节gap与同步等待。 - 安全在线命令:只读SQL、
patronictl list/history/show-config、REST健康查询、pgbackrest info/check、DCS endpoint status通常可在线执行;仍需限制频率和超时。 - 高风险命令:
patronictl failover --force、pg_promote()、删除Slot、删除pg_wal、pg_resetwal、DCSforce-new-cluster、reinit、关闭同步保护、启动旧Primary都需要事故指挥官审批。 - 临时止损:Load Shedding、暂停非核心消费者、降低只读报表并发、冻结发布、只保留少量探针、对关键写返回明确“稍后查询结果”而非重复执行。
- 根本修复与验证:恢复唯一Primary、DCS多数派、同步保护、归档和备份;旧节点rewind/reinit;用业务探针验证写、读后写、幂等、CDC、备份和监控。
- 补充告警与复盘:记录实测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. 常见错误与反模式
- 只有自动选主,没有 Fencing:把“旧主Ping不通”当成“旧主已停止写”。
- DCS 与三个数据库节点同故障域:一次机架/AZ故障同时失去数据和多数派。
- 把 HAProxy 单实例当作 HA:数据库三节点,入口却只有一个进程/IP。
- DNS 直接指向 Primary:既有连接和进程缓存让低TTL失去意义,旧地址还可能双写。
synchronous_mode开启但应用可随意SET synchronous_commit=off:局部优化绕过全局RPO承诺。- 所有应用实例配置高
MinConns并同时重启:新Primary在冷缓存阶段被连接风暴压垮。 - 对所有错误统一重试三次:把唯一冲突、磁盘满、只读和未知Commit混在一起。
- Commit报错就换新幂等键重试:直接制造重复订单、扣款或库存扣减。
- Failover 后立即 Failback:旧主尚未验证、缓存未稳定,又制造第二次风险窗口。
- Replication Slot 无上限、无消费者SLO:单个失联CDC把
pg_wal写满。 - HA副本替代备份:误删除和错误DDL会同步复制到所有副本。
- 在生产直接修改
bootstrap.dcs:已引导集群不生效,造成“文件看着对、集群状态没变”。 - 手工
pg_promote()绕过 Patroni:DCS、代理和同步状态不知道真实角色。 - 只监控进程存活,不监控可提交性:etcd进程都在但失去quorum、磁盘fsync过慢,控制面仍不可用。
- 只测数据库Promotion时间:忽略Fencing、代理、池重建、DNS和应用对账,RTO被严重低估。
18. 模拟生产事故案例
18.1 模拟生产案例一:网络分区后旧主继续写
系统背景
三节点 PostgreSQL 跨三 AZ,Patroni + etcd;HAProxy按 /primary 检查,但 watchdog 配置为 automatic,pre_promote 只执行一次 Ping。
故障现象
AZ-A 到 etcd 的链路中断,但一部分应用仍可直连 AZ-A 的旧Primary;AZ-B 取得Leader Lock并提升。十分钟后发现两个节点都有新订单。
错误假设
团队认为“旧主访问不到 etcd,就一定会自动停写”;同时认为 Ping 不通即可证明节点失效。
排查过程
- DCS history 显示 pg-b 正常取得Leader Lock;
- pg-a Patroni日志显示降级动作因本机I/O卡顿未及时完成;
- watchdog设备不可用但
automatic允许继续; pre_promote对 pg-a Ping 失败后返回0;- 部分应用绕过HAProxy使用旧IP;
- 两个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=50、MinConns=20;两个PgBouncer实例各 default_pool_size=200。没有Jitter、Circuit Breaker或Load Shedding。
故障现象
Primary宕机后Patroni 36秒完成Promotion,但业务持续不可用8分钟。新Primary CPU 100%,连接认证激增,P99超过10秒,又触发上游重试。
错误假设
团队把“Promotion完成”当成“服务恢复”,并认为更多连接能更快清空积压。
排查过程
- Patroni与DCS均在36秒恢复唯一Primary;
- HAProxy 4秒后正确指向新主;
- 80个实例同时补齐至少1600条连接,业务goroutine又并发重试;
- PgBouncer等待队列和PostgreSQLBackend数快速上升;
- 新主缓存冷,认证、计划和随机读同时争用CPU/I/O;
- 上游超时后再次重试,形成正反馈。
根因
连接预算在三层重复放大;缺少有界并发、随机退避和故障后的渐进放量。
临时止损
入口限流到正常峰值的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,或组织无法维持演练能力 |
| Owner | Database Platform;业务Owner负责幂等与对账;SRE负责演练和事故指挥 |
19.2 Planned Switchover Runbook
进入条件
- 集群健康且只有一个Primary;
- 目标Replica与Primary同Timeline,lag低于批准阈值;
- 同步保护、归档、备份和DCS quorum正常;
- 无长DDL、备份关键阶段、批量导入、重大发布或未处理告警;
- 变更单、回滚人和业务观察人已就位。
操作步骤
- 公告变更窗口,冻结Patroni/DCS/网络/Schema并行变更。
- 保存证据:
patronictl list/history/show-config、pg_stat_replication、pg_stat_activity、代理状态和监控截图。 - 检查长事务:
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;
- 检查目标容量、WAL flush/replay、归档和Replica磁盘。
- 对非关键任务暂停或降并发;确认应用Idempotency与重连指标可见。
- 发起
patronictl switchover,明确旧Leader与Candidate;不使用手工pg_promote()。 - 实时记录:旧主停止、目标Promotion、Timeline变化、HAProxy健康收敛、应用首个成功写。
- 验证唯一Primary:所有节点角色、DCS Leader、
/primary、pg_is_in_recovery()一致。 - 验证同步保护:
synchronous_standby_names、pg_stat_replication.sync_state、Patroni/sync。 - 验证业务:参数化写入、按主键读取、写后读、幂等重复请求、Outbox/CDC。
- 观察至少一个业务峰值周期或批准的稳定窗口:P95/P99、错误率、连接、WAL、I/O、锁、复制。
- 关闭变更;旧Primary应作为Replica追平。若需要回切,创建新的Switchover变更,不立即操作。
中止/回滚条件
- Candidate lag持续增长;
- Fencing/停止旧主无法确认;
- 出现两个可写节点;
- 新主错误率、磁盘或同步保护异常;
- 代理仍把新连接送往旧主。
“回滚”不是手工把旧主Promote;先恢复唯一、安全、稳定的Primary,再另行计划切换。
19.3 Unplanned Failover Runbook
- 宣布数据库重大事件,冻结变更;记录首个告警和业务影响时间。
- 从独立管理面确认当前可写节点数。若大于1,先切断写入口并Fence非权威节点。
- 检查DCS是否有多数派;无多数派时不要手工制造Leader。
- 判断旧Primary状态:PostgreSQL进程、主机电源、网络、存储访问。不可达不等于已隔离。
- 观察Patroni自动过程,不与自动选主并发执行人工Failover;记录Leader Lock过期、候选和Fencing。
- 若自动Failover未发生,确认原因:无合格同步候选、lag过大、Timeline不符、
nofailover、pre_promote失败、DCS无quorum。 - 任何强制Failover前,由业务Owner明确接受潜在RPO,并由基础设施完成旧主Fencing。
- 新主出现后验证:唯一可写、Timeline、同步节点、归档、Slot、磁盘、时钟和证书。
- 让HAProxy/PgBouncer收敛;必要时受控
RECONNECT;应用池按Jitter重建,禁止全量同时放流。 - 对所有Commit Unknown按Idempotency Key查询;不生成新键重做同一业务动作。
- 暂停报表和非核心消费者,直到新主CPU/I/O/缓存稳定;逐步恢复流量。
- 保持旧主隔离,进入Failback/重建Runbook;完成RPO/RTO、错误数和业务对账报告。
19.4 Failback Runbook
原则
Failback 是一次新的计划变更,目标是恢复期望拓扑,不是“把机器开回来”。业务已经在新Primary稳定运行时,拓扑美观优先级低于稳定性。
步骤
- 确认现Primary至少稳定一个批准观察窗口,备份和归档已恢复。
- 旧主保持Fenced;收集其Timeline、最后LSN、故障日志和磁盘健康。
- 选择
pg_rewind或 Reinitialize:前提不满足、数据目录可疑、rewind失败时直接重建。 - 以Patroni管理方式作为Replica启动;禁止独立
pg_ctl start。 - 验证
pg_is_in_recovery()=true、WAL Receiver、slot、Timeline和lag。 - 做数据校验:关键表抽样、业务不变量、Data Checksums/备份工具校验。
- 解除基础设施Fencing,但仍通过
/primary规则禁止写流量;先不加入报表池。 - 观察追平、I/O、Vacuum、WAL和连接稳定性。
- 若只是恢复冗余,到此结束;不要自动回切。
- 若有明确容量/延迟/合规理由回切,另开Planned Switchover变更。
- 回切后重复唯一Primary、同步、业务、备份和监控验证。
- 更新资产、拓扑、事故时间线和演练结果。
19.5 DCS 故障 Runbook
- 确认是单节点故障、性能退化还是失去多数派:
etcdctl endpoint status --cluster -w table
etcdctl endpoint health --cluster
etcdctl member list -w table
- 检查DCS节点磁盘空间、fsync延迟、网络、证书到期和时钟。
- 单节点故障且仍有quorum:不切数据库;先恢复冗余,不同时变更多个成员。
- 失去多数派:冻结DCS成员变更和人工数据库Promotion;确认Patroni正常模式/Failsafe行为。
- 若Failsafe维持现Primary,确认所有数据库成员REST互通且唯一可写;限制非核心变更。
- 若Primary已降级,保持业务失败关闭,等待恢复原多数派。
- 优先修复原成员或网络;不要在两个分区分别执行灾难恢复。
- 永久丢失多数派时,依据最近、已验证的一致DCS快照执行供应商批准的单一恢复流程;此步骤必须由两人复核。
- 重建后核对cluster ID、成员列表、Patroni namespace/scope、Leader和配置,防止连接到错误DCS。
- 恢复数据库自动化前,确认没有第二个DCS集群和第二个Primary。
- 逐节点恢复、观察多个TTL周期,再解除业务限流。
- 复盘是否需要5节点DCS、独立故障域、更快磁盘或证书自动轮换。
禁止把
force-new-cluster当作普通修复命令。永久多数派丢失是DCS灾难恢复事件,错误操作可产生两个各自自洽的控制面。
19.6 WAL 磁盘写满 Runbook
- 确认哪个文件系统接近满:数据、
pg_wal、归档spool、临时目录还是日志;同时检查inode。 - 立即Load Shed非核心写、暂停批量导入和可延后消费者;不要先重启数据库。
- 检查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;
- 检查归档:
pg_stat_archiver、pgBackRest日志/Repository容量/网络。 - 检查Replica断连、
wal_keep_size、Checkpoint和WAL生成突增。 - 最快安全止损通常是扩容文件系统/挂载;严禁手工删除活动
pg_wal。 - 如果是活跃消费者落后,恢复/扩容消费者并监控追赶速度。
- 如果Slot已废弃,由Slot Owner和业务Owner确认下游重建方案后再
pg_drop_replication_slot();记录将产生的数据缺口。 - 归档恢复后可用
SELECT pg_switch_wal();触发测试,并验证新的WAL成功归档;不要循环调用。 - 空间下降后逐步恢复写流量,观察WAL生成与清理是否平衡。
- 若数据库已崩溃,确认副本空间和同步状态后按Failover Runbook处理;旧节点修复后做一致性检查。
- 根治:Slot retained bytes/预计填满时间告警、
max_slot_wal_keep_size策略、消费者SLO、Repository容量预测和自动扩容。
19.7 可用区故障 Runbook
- 由云/网络/主机多信号确认AZ级故障,避免把单节点误报扩大。
- 宣布AZ事件,冻结受影响区扩缩容和自动重建,防止控制面抖动。
- 检查DCS剩余quorum、当前Leader所在AZ、同步副本所在AZ。
- 若旧Primary在故障AZ,使用云控制面Fence整个实例/网络身份;记录状态证明。
- 观察Patroni选择其它AZ合格候选,禁止在DCS无quorum时强制提升。
- 验证双入口中至少一套位于幸存AZ;更新全局LB/VIP健康。
- 应用降载到单/双AZ剩余容量,暂停报表、批处理、非核心消费者。
- 验证唯一Primary、同步保护、归档、WAL、Slot和业务幂等。
- 监控幸存节点CPU、磁盘、连接和网络,防止第二故障由过载触发。
- AZ恢复后,所有旧节点按Old Primary/Replica流程逐个回归,不能批量自动启动。
- 恢复冗余后再逐步放开流量;不为恢复原AZ位置立即Failback。
- 记录实测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_wait、retry_timeout 与 ttl 如何约束?
- 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_mode 与 synchronous_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 秒回答:典型是
40001和40P01,重试完整事务并有上限、退避、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 nofailover 与 nosync 标签分别用于什么?
- 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和半年恢复演练。
架构

数据模型与事务
核心表:
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)。
本地事务:
- 尝试插入Idempotency Key;已有则校验请求哈希并返回既有结果;
- 锁定/原子更新余额,写不可变Ledger;
- 写Payment状态与Outbox;
- 更新幂等结果;
- 单独检查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,并有最大查询时长和资源组/独立报表副本; - 缓存只存可失效视图,不作为订单事实源。
写后读一致性
三种方案按场景选择:
- 短期Primary粘滞:写成功后数秒内该用户读走rw;简单可靠,代价是Primary读量。
- LSN一致性令牌:提交后获取Primary LSN,读副本先等待/检查
pg_last_wal_replay_lsn() >= token,超时回退Primary;更精细但增加请求复杂度。 - 版本号/事件确认:返回订单版本,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/Promotion | 10 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=10、retry_timeout=8、ttl=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提交阻塞。清除标签并让其追平后,观察同步集合恢复和写继续。记录/sync、pg_stat_replication与SyncRep等待。
题 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 Configuration | loop_wait、retry_timeout、ttl、最大Failover Lag、同步模式 |
| 复制模式 | Patroni Replication Modes | Async、Sync、Strict、Quorum的语义与代价 |
| DCS失效安全 | Patroni DCS Failsafe Mode | DCS不可用时Primary能否继续服务的边界 |
| Patroni YAML | Patroni YAML Configuration | watchdog、pre_promote、tags、use_pg_rewind、REST TLS |
| REST API | Patroni REST API | HAProxy健康检查、角色探测、监控与运维调用 |
| patronictl | Patroni patronictl | Switchover、Failover、Reinitialize与历史查询 |
| PostgreSQL复制与Failover | Warm Standby、Failover | 流复制、同步提交、旧主隔离与Failover基本语义 |
| pg_rewind | pg_rewind | Timeline分叉后的旧主回归 |
| libpq连接参数 | Database Connection Control Functions | Multi-host、连接超时与target_session_attrs |
| SQLSTATE | PostgreSQL Error Codes | Go错误分类与有限重试 |
| etcd多数派 | etcd FAQ | 三节点DCS的quorum与容错数量 |
| pgxpool | pgxpool package | Pool配置、生命周期、健康检查与Reset |
| pgconn | pgconn package | PgError、SQLSTATE与连接错误识别 |
| PgBouncer | PgBouncer Configuration、Usage | 事务池、连接回收、Pause/Resume/Reconnect |
| HAProxy | HAProxy Health Checks | 主从端点的主动健康检查与摘除 |
| pgBackRest | pgBackRest User Guide | 全量/增量备份、WAL归档、恢复与演练 |
| Go版本 | Go Release History | Go工具链与安全补丁基线 |
24.2 文内脚注
Footnotes
-
etcd官方FAQ说明,集群需要多数派才能继续处理写操作;三节点集群的quorum为2,可容忍1个成员失效。参见etcd FAQ。 ↩
-
Patroni动态配置文档定义了
loop_wait、retry_timeout、ttl及其约束,并说明最大Failover Lag、同步模式、Strict模式和Quorum相关参数。参见Patroni Dynamic Configuration。 ↩ ↩2 -
Patroni复制模式文档描述异步复制的数据丢失窗口,以及同步、Strict和Quorum模式的可用性与一致性取舍。参见Patroni Replication Modes。 ↩ ↩2 ↩3
-
Patroni DCS Failsafe要求Leader在DCS不可用时能够与DCS中已知的全部成员通信;否则应降级,避免网络分区演化为双主。参见Patroni DCS Failsafe Mode。 ↩ ↩2
-
Patroni YAML文档覆盖watchdog模式、
pre_promote回调、节点tags、REST TLS和use_pg_rewind等配置。参见Patroni YAML Configuration。 ↩ ↩2 ↩3 ↩4 -
pg_rewind用于把发生Timeline分叉的旧Primary同步回目标集群状态;执行前仍应确认节点已被Fencing,且必要的WAL可获得。参见PostgreSQL 18 pg_rewind。 ↩ ↩2 -
Patroni REST API既供Patroni与
patronictl使用,也可供HAProxy和监控系统根据节点角色与健康状态进行探测。参见Patroni REST API。 ↩ -
libpq连接参数支持多个host、连接超时与
target_session_attrs,但应用仍需把入口治理、旧连接回收和写角色验证作为独立问题处理。参见PostgreSQL 18 libpq Connect。 ↩ -
PostgreSQL Warm Standby文档描述流复制、同步复制、
synchronous_standby_names与同步提交行为。参见PostgreSQL 18 Warm Standby。 ↩ -
PostgreSQL建议应用基于五字符SQLSTATE而不是本地化错误文本处理异常。参见PostgreSQL 18 Error Codes。 ↩ ↩2
-
pgBackRest官方用户指南覆盖备份、归档、恢复、校验与Repository运维。参见pgBackRest User Guide。 ↩
-
pgxpool公开API包含连接生命周期配置、
BeforeConnect、AfterConnect、健康检查、寿命抖动与Pool.Reset等能力。参见pgxpool package。 ↩ -
pgconn公开
PgError及SQLSTATE字段,并提供底层连接错误处理能力。参见pgconn package。 ↩