PostgreSQL 高可用、恢复与长期治理
从一次“切换成功”后的双主险情开始
凌晨告警显示 primary 失联,值班同学把 standby 提升为新 primary,应用也改到了新地址。十分钟后,旧 primary 所在宿主机恢复网络,进程继续接受写入。监控里两边都是绿色,业务却开始出现订单状态分叉。更糟的是,团队每天都能看到“备份成功”,真正恢复时才发现缺少目标时间点所需的一段 WAL。
这不是两个孤立故障。它们暴露了 PostgreSQL 生产体系中四条彼此独立的链路:
WAL 数据面负责把变化送到 standby,但不决定谁有资格写。HA 控制面负责判活、选主和编排,却不能替代物理隔离。路由面负责让新连接找到正确角色,但不能让旧事务自动重放。
恢复面负责保住 base backup 与连续 WAL,并证明数据能回到目标状态。
只跑通 SELECT pg_is_in_recovery(),最多证明节点当前角色;只有旧主已经 fenced、路由已经收敛、时间线没有分叉写入、备份能在隔离环境恢复,才能说故障闭环成立。
先搭一套可以破坏的双节点实验
下面的实验使用 Docker、Bash、psql 和 PostgreSQL 官方镜像。它适合 Linux、macOS 或 WSL;PowerShell 用户可以在 WSL 中执行,避免引号和权限语义差异。镜像使用受支持的 major 线 postgres:18-bookworm,团队模板应进一步固定已验证的 minor 或 digest,并在升级时重跑复制与恢复实验。PostgreSQL 的 major 支持周期和当前版本状态以版本策略为准。
先准备一次性网络、volume 和随机实验凭证:
export PG_IMAGE='postgres:18-bookworm'
export PG_ADMIN_PASSWORD="$(openssl rand -base64 24 | tr -d '\n')"
export PG_REPL_PASSWORD="$(openssl rand -base64 24 | tr -d '\n')"
docker network create --subnet 172.28.0.0/16 pg-ha-lab
docker volume create pg-primary-data
docker volume create pg-standby-data启动 primary。--data-checksums 让后面的 pg_rewind 具备必要条件之一;也可以使用 wal_log_hints=on。max_slot_wal_keep_size 是实验保护上限,不是生产推荐值,生产值要从 WAL 生成速率、最长可接受离线时间和磁盘预算反推。
docker run -d --name pg-primary \
--network pg-ha-lab --ip 172.28.0.10 \
-e POSTGRES_PASSWORD="$PG_ADMIN_PASSWORD" \
-e POSTGRES_INITDB_ARGS='--data-checksums' \
-e PGDATA=/var/lib/postgresql/data \
-v pg-primary-data:/var/lib/postgresql \
"$PG_IMAGE" \
-c wal_level=replica \
-c max_wal_senders=10 \
-c max_replication_slots=10 \
-c max_slot_wal_keep_size=512MB \
-c hot_standby=on \
-c password_encryption=scram-sha-256
until docker exec -e PGPASSWORD="$PG_ADMIN_PASSWORD" pg-primary \
pg_isready -U postgres; do sleep 1; done创建最小权限复制账号,并只允许实验网段发起复制连接:
docker exec -i -e PGPASSWORD="$PG_ADMIN_PASSWORD" pg-primary \
psql -U postgres -v repl_password="$PG_REPL_PASSWORD" <<'SQL'
CREATE ROLE replicator WITH LOGIN REPLICATION PASSWORD :'repl_password';
SQL
docker exec pg-primary bash -lc \
'printf "%s\n" "host replication replicator 172.28.0.0/16 scram-sha-256" >> "$PGDATA/pg_hba.conf"'
docker exec -e PGPASSWORD="$PG_ADMIN_PASSWORD" pg-primary \
psql -U postgres -c 'SELECT pg_reload_conf();'命令成功后,CREATE ROLE 和 pg_reload_conf 都应返回成功。若复制账号能登录普通业务库,并不代表权限更完善;REPLICATION 本身是高敏感能力,应使用专用账号、专用网络规则和独立轮换周期。PostgreSQL 客户端认证解释了 pg_hba.conf 的首条匹配语义,密码认证说明了 SCRAM 与已弃用 MD5 密码的边界。
用 base backup 建立物理 standby
物理流复制不是逐条重放 SQL。primary 的 WAL sender 从某个 LSN 开始发送 WAL,standby 的 WAL receiver 写入本地,startup process 再 replay 到数据页。write_lsn、flush_lsn 和 replay_lsn 分别表示网络接收、持久化和可见状态推进到了哪里。
先创建一个物理 slot。slot 保护 standby 尚未确认的 WAL,代价是消费端失联时 primary 必须继续保留这些文件。
docker exec -e PGPASSWORD="$PG_ADMIN_PASSWORD" pg-primary \
psql -U postgres -c \
"SELECT * FROM pg_create_physical_replication_slot('standby1');"用 pg_basebackup 初始化 standby。实验为了让命令可以独立复制,把一次性密码写进了 recovery connection string;共享环境和生产环境不要这样做,应把密码放进权限为 0600 的 passfile 或受控 secret,并限制配置文件读取权限。
docker run --rm --entrypoint bash \
--network pg-ha-lab \
-v pg-standby-data:/var/lib/postgresql \
-e REPL_PASSWORD="$PG_REPL_PASSWORD" \
-e PGDATA=/var/lib/postgresql/data \
"$PG_IMAGE" -lc '
rm -rf /var/lib/postgresql/data
install -d -o postgres -g postgres -m 700 /var/lib/postgresql/data
exec gosu postgres pg_basebackup \
-D /var/lib/postgresql/data \
-d "host=pg-primary port=5432 user=replicator password=$REPL_PASSWORD application_name=standby1" \
-Fp -Xs -P -R -S standby1
'
docker run -d --name pg-standby \
--network pg-ha-lab --ip 172.28.0.11 \
-e PGDATA=/var/lib/postgresql/data \
-v pg-standby-data:/var/lib/postgresql \
"$PG_IMAGE"
until docker exec pg-standby pg_isready -U postgres; do sleep 1; done-R 会创建 standby.signal 并写入连接配置,-S standby1 则把 standby 和刚才的 slot 绑定。官方 pg_basebackup文档强调:它复制整个 cluster,不是单库备份;执行账号需要 REPLICATION 或 superuser,pg_hba.conf 与 max_wal_senders 也必须允许这条连接。
先验证角色和流状态:
docker exec -e PGPASSWORD="$PG_ADMIN_PASSWORD" pg-primary \
psql -U postgres -x -c \
"SELECT application_name, client_addr, state, sync_state,
sent_lsn, write_lsn, flush_lsn, replay_lsn
FROM pg_stat_replication;"
docker exec pg-standby psql -U postgres -Atc \
"SELECT pg_is_in_recovery(), status, slot_name, sender_host
FROM pg_stat_wal_receiver;"primary 上应看到 application_name=standby1、state=streaming;standby 上 pg_is_in_recovery() 应为 t,WAL receiver 状态应为 streaming。连接存在却没有进入 streaming 时,先看两端日志,再检查账号、HBA、DNS、slot 和请求 LSN 是否仍可获得。
正向实验:证明写入真的被 replay
在 primary 建表并写入可识别记录:
docker exec -i -e PGPASSWORD="$PG_ADMIN_PASSWORD" pg-primary \
psql -U postgres <<'SQL'
CREATE TABLE ha_probe (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
marker text NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
INSERT INTO ha_probe(marker) VALUES ('stream-ok');
SELECT pg_current_wal_flush_lsn() AS primary_flush_lsn;
SQL然后从 standby 查询:
docker exec -i pg-standby psql -U postgres -x <<'SQL'
SELECT pg_is_in_recovery() AS is_standby,
pg_last_wal_receive_lsn() AS receive_lsn,
pg_last_wal_replay_lsn() AS replay_lsn,
pg_last_xact_replay_timestamp() AS last_replay_at;
SELECT id, marker FROM ha_probe;
SQL预期能读到 stream-ok,且 is_standby=t。再做一次反向写入:
docker exec pg-standby psql -U postgres -v ON_ERROR_STOP=1 \
-c "INSERT INTO ha_probe(marker) VALUES ('should-fail');"预期返回类似 cannot execute INSERT in a read-only transaction。这个失败很重要:它证明连接确实落到了恢复中的 standby,而不是误连 primary。测试脚本若只做 SELECT 1,会把角色路由错误隐藏起来。
不要把复制延迟压成一个秒数
write_lag、flush_lag、replay_lag 是最近事务在不同阶段的提交延迟测量,不等于永久准确的“副本落后秒数”。低写入时它们可能为 NULL 或保留最近值。更稳定的诊断方法是同时比较 LSN 字节差和 replay 时间:
SELECT application_name,
state,
sync_state,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_flush_lsn(), replay_lsn)) AS replay_bytes,
write_lag,
flush_lag,
replay_lag
FROM pg_stat_replication;在 standby 上补充:
SELECT now() - pg_last_xact_replay_timestamp() AS time_since_last_replay,
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn(),
pg_is_wal_replay_paused();判断时先分型:
| 卡点 | 证据 | 常见原因 |
|---|---|---|
sent_lsn 推不动 | primary WAL sender 状态、网络错误 | primary 忙、网络阻塞、进程异常 |
sent_lsn 与 write_lsn 差距扩大 | 网络吞吐、standby 接收状态 | 带宽不足、丢包、接收端资源不足 |
write_lsn 与 flush_lsn 差距扩大 | standby 磁盘延迟 | fsync 慢、存储抖动 |
flush_lsn 与 replay_lsn 差距扩大 | replay、锁冲突、长只读查询 | CPU/IO 不足、hot standby conflict、回放暂停 |
生产告警不应写死一个通用延迟值。以业务允许的陈旧窗口、故障时可接受的数据丢失量、WAL 生成基线和恢复带宽共同定义阈值,并同时监控趋势。PostgreSQL 的pg_stat_replication 与 pg_stat_wal_receiver是两端证据入口。
反向实验:slot 如何把 primary 磁盘拖满
先停止 standby,再制造一小段可控 WAL:
docker stop pg-standby
docker exec -i -e PGPASSWORD="$PG_ADMIN_PASSWORD" pg-primary \
psql -U postgres <<'SQL'
INSERT INTO ha_probe(marker)
SELECT 'slot-backlog-' || g FROM generate_series(1, 50000) AS g;
CHECKPOINT;
SELECT slot_name,
active,
wal_status,
safe_wal_size,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;
SQL预期 active=f,retained 相比停止前增长。不要为了“看得明显”无限生成数据;观察到增长趋势就足够。restart_lsn 表示 slot 仍可能需要的最老 WAL,checkpoint 不会随意回收它。若 max_slot_wal_keep_size=-1,slot 可以不受该参数限制地保留 WAL;设置有限值后,超过边界并经过 checkpoint 可能使 slot 失去继续复制所需的 WAL,standby 最终需要重建。当前字段含义以pg_replication_slots为准。
恢复 standby 并观察 backlog 回落:
docker start pg-standby
docker exec -e PGPASSWORD="$PG_ADMIN_PASSWORD" pg-primary \
psql -U postgres -c \
"SELECT application_name, state, replay_lsn FROM pg_stat_replication;"治理时至少把以下量放在同一张图上:pg_wal 文件系统剩余空间、WAL 每分钟生成量、每个 slot 的 retained bytes、slot 是否 active、归档失败次数和 standby replay 差距。删除 slot 前必须先确认消费者已永久下线;误删活跃 slot 可能把一次短暂断网升级为全量重建。
同步复制把 RPO 换成了提交等待
异步流复制默认允许 primary 在 standby 尚未确认时提交,因此切换可能丢失末尾 WAL。同步复制让事务等待指定 standby 的确认,但“确认到哪里”由 synchronous_commit 决定:remote_write、on、remote_apply 对应的远端持久化或回放保证不同。同步复制配置支持优先级 FIRST 和仲裁 ANY 两种选择方式。
先让 primary 等待 standby1 回放:
docker exec -i -e PGPASSWORD="$PG_ADMIN_PASSWORD" pg-primary \
psql -U postgres <<'SQL'
ALTER SYSTEM SET synchronous_standby_names = 'FIRST 1 (standby1)';
ALTER SYSTEM SET synchronous_commit = 'remote_apply';
SELECT pg_reload_conf();
SQL
docker exec -e PGPASSWORD="$PG_ADMIN_PASSWORD" pg-primary \
psql -U postgres -c \
"SELECT application_name, sync_state, sync_priority FROM pg_stat_replication;"standby 应显示 sync_state=sync。正常写入在 standby replay 后返回:
docker exec -e PGPASSWORD="$PG_ADMIN_PASSWORD" pg-primary \
psql -U postgres -c "INSERT INTO ha_probe(marker) VALUES ('sync-ok');"现在停止唯一同步 standby,再执行带超时的事务:
docker stop pg-standby
docker exec -i -e PGPASSWORD="$PG_ADMIN_PASSWORD" pg-primary \
psql -U postgres -v ON_ERROR_STOP=1 <<'SQL'
SET statement_timeout = '3s';
INSERT INTO ha_probe(marker) VALUES ('sync-standby-down');
SQL预期约三秒后返回 canceling statement due to statement timeout。这不是 primary 挂了,而是提交正在等待同步确认。客户端收到取消或连接中断时,不能盲目重试非幂等写入;应先按业务键查询最终状态,因为事务可能已经在 primary 本地形成 WAL,只是确认链路没有完整返回。
恢复实验为异步模式:
docker start pg-standby
docker exec -i -e PGPASSWORD="$PG_ADMIN_PASSWORD" pg-primary \
psql -U postgres <<'SQL'
ALTER SYSTEM RESET synchronous_standby_names;
ALTER SYSTEM SET synchronous_commit = 'on';
SELECT pg_reload_conf();
SQL架构选型不能只写“金融用同步、普通业务用异步”。同城低延迟、至少两个候选 standby、明确的降级策略和幂等写入更适合同步复制;跨地域链路通常要在零数据丢失诉求与尾延迟、可用性之间做更谨慎的取舍。生产变更前必须演练“同步节点失联时继续阻塞、自动降级还是人工切换”,并把选择写进事故手册。
故障转移必须先解决谁还能写
原生 PostgreSQL 提供 pg_promote() 和 pg_ctl promote,但不会替你判断 primary 是否真的死亡,也不会自动隔离旧主。正确顺序是:
冻结或隔离旧写入口。确认候选 standby 的 receive/replay 位置和数据损失窗口。对旧 primary 执行 fencing,证明确实不能继续接受写入。
promote 候选节点。验证新时间线、业务身份和关键数据。切换路由并观察重连。
用 pg_rewind 或新 base backup 回收旧主,绝不直接重启加入。
用实验完成一次受控切换
先记录两端 LSN,再停止旧 primary。docker stop 在这里充当可验证 fencing;生产中可以是 STONITH、电源隔离、云 API 停机、安全组拒绝、存储租约撤销或交换机端口隔离。
docker exec -e PGPASSWORD="$PG_ADMIN_PASSWORD" pg-primary \
psql -U postgres -Atc 'SELECT pg_current_wal_flush_lsn();'
docker exec pg-standby psql -U postgres -Atc \
'SELECT pg_last_wal_replay_lsn();'
docker stop pg-primary
docker exec pg-standby pg_ctl -D "$PGDATA" promote -w验证新角色、时间线和写能力:
docker exec -i pg-standby psql -U postgres -x <<'SQL'
SELECT pg_is_in_recovery() AS is_standby;
SELECT timeline_id, redo_lsn FROM pg_control_checkpoint();
INSERT INTO ha_probe(marker) VALUES ('after-promote');
SELECT marker FROM ha_probe ORDER BY id DESC LIMIT 3;
SQL预期 is_standby=f,写入成功。promote 会产生新 timeline;timeline history 让恢复和复制工具知道新分支从旧分支的哪个 LSN 产生。它不会自动合并两个都接受过写入的分支,因此旧 primary 一旦未经隔离继续写,就只能依靠业务对账和冲突处置,不能指望 WAL 自动“合并”。
为什么不能直接启动旧 primary
旧 primary 的数据目录仍认为自己是 primary。若把它直接启动并重新接入应用,就会恢复第二个写源。即使人为创建 standby.signal,它的数据文件也可能已经与新 primary 的 timeline 分叉。
pg_rewind会找到共同 checkpoint 后的分叉点,只复制目标端发生变化的数据块和必要文件,通常比重新做全量 base backup 更快。它要求两个 cluster 有共同祖先,并要求目标启用 data checksums 或 wal_log_hints=on;目标端还必须拿到从分叉点开始所需的 WAL。条件不满足时应重建 standby,不要强行绕过。
实验中先删除已经停止的旧容器对象,保留 volume;然后对旧 primary 数据目录执行 rewind:
docker rm pg-primary
docker run --rm --entrypoint bash \
--network pg-ha-lab \
-v pg-primary-data:/var/lib/postgresql \
-e SOURCE_PASSWORD="$PG_ADMIN_PASSWORD" \
-e PGDATA=/var/lib/postgresql/data \
"$PG_IMAGE" -lc '
exec gosu postgres pg_rewind \
--target-pgdata=/var/lib/postgresql/data \
--source-server="host=pg-standby port=5432 user=postgres password=$SOURCE_PASSWORD dbname=postgres" \
--write-recovery-conf --progress
'
docker run -d --name pg-rejoined \
--network pg-ha-lab --ip 172.28.0.12 \
-e PGDATA=/var/lib/postgresql/data \
-v pg-primary-data:/var/lib/postgresql \
"$PG_IMAGE"
until docker exec pg-rejoined pg_isready -U postgres; do sleep 1; done
docker exec -i pg-rejoined psql -U postgres -x <<'SQL'
SELECT pg_is_in_recovery() AS is_standby,
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn();
SELECT marker FROM ha_probe ORDER BY id DESC LIMIT 3;
SQL预期旧主以 is_standby=t 回归,并能看到 after-promote。生产中不要把 superuser 密码写进 source connection string;使用受限 rewind 账号、TLS、passfile 或 secret,并在操作后轮换临时凭证。若 rewind 失败,保留日志和数据目录快照,转为从新 primary 做 base backup 重建。
Patroni、repmgr 和云控制面分别解决什么
自动化工具的价值不是“省掉 promote 命令”,而是让角色状态、选主条件和操作顺序可重复。它们仍无法消除网络分区、路由缓存、存储故障和人为误操作。
Patroni:租约、DCS 与节点状态机
Patroni 使用 etcd、Consul、Kubernetes API 等 DCS 保存 leader lock 和集群动态配置。节点只有持续持有 leader lock 才能维持 primary 资格。DCS 失联时,Patroni 默认按网络分区的最坏情况处理并降级旧 primary;DCS failsafe mode只有在当前 leader 能联系 /failsafe 中所有已知成员时才允许继续服务,不能把它理解为“DCS 挂了照常写”。
Patroni 的 Linux watchdog 可以在 HA loop 无法及时降级时强制复位节点。把 watchdog 配为 required 后,无法激活 watchdog 的节点会拒绝成为 leader;这是一层额外防护,不是硬件 fencing 的同义词。其行为以Patroni watchdog说明为准。
上线前至少验证:
patronictl -c /etc/patroni/patroni.yml list
patronictl -c /etc/patroni/patroni.yml topology
curl -fsS http://pg-node-1:8008/primary
curl -fsS 'http://pg-node-2:8008/replica?lag=64MB'/primary 只有节点是 primary 且持有 leader lock 时返回 200;/replica?lag=... 还能按字节差拒绝过度落后的只读节点。HAProxy 应检查 Patroni REST API 的角色端点,而不是只检查 PostgreSQL 端口。端点语义见Patroni REST API。生产阈值应由业务陈旧窗口和 WAL 基线决定,64MB 只是演示值。
Patroni 的 DCS 与 REST API 都是高敏感控制入口。DCS 需要 TLS、认证和最小网络暴露;REST API 的不安全操作端点需要认证或客户端证书,不能把 8008 端口向业务网段开放。Patroni 安全说明明确列出了这两个受保护接口。
repmgr:贴近 PostgreSQL 的注册、提升与 follow
repmgr 适合传统 VM、systemd 和清晰主备拓扑。repmgrd 可以监控 upstream、执行 promote_command 和 follow_command,但 fencing 仍需外部脚本或基础设施配合。两节点跨故障域场景可以引入 witness 辅助判断网络分区;witness 不是数据副本,也不能和被保护节点放在同一物理宿主机。repmgr witness和网络分区处理说明了这一限制。
常用验证入口:
repmgr -f /etc/repmgr.conf cluster show
repmgr -f /etc/repmgr.conf service status
repmgr -f /etc/repmgr.conf standby switchover --dry-run
repmgr -f /etc/repmgr.conf node check自动 failover 前,promote_command、follow_command、服务启停命令、failover validation 和事件通知必须在每个节点一致。repmgr standby switchover --dry-run 适合在维护切换前发现 SSH、密码、WAL、归档与 rewind 条件问题,但 dry run 通过不等于真实故障转移一定成功。
云数据库:厂商接管了一部分执行,不接管业务判断
云 PostgreSQL 往往提供多可用区、自动切换、只读 endpoint、备份和 PITR,但不同产品在以下方面差异很大:
endpoint 是否保持不变,DNS TTL 和连接重建需要多久;同步确认发生在哪一层,承诺的是实例、可用区还是区域级 RPO;是否开放 replication slot、逻辑复制、pg_rewind、superuser 和文件系统;
PITR 最细粒度、备份保留、跨账号复制、跨区域恢复和导出能力;参数组变更是否重启,major upgrade 是否允许回退;只读实例、备份存储、跨区流量、IOPS、代理和长期日志的计费方式。
云控制台显示“高可用”不能替代演练。使用厂商提供的故障注入或切换 API,在非生产环境观察旧连接报错、DNS 收敛、事务结果不确定性、只读副本延迟和恢复时长;没有这些证据,就无法把 SLA 翻译成业务 SLO。
PgBouncer 与 HAProxy 要分开算职责
PgBouncer 解决连接复用,HAProxy 或云 endpoint 解决角色路由。二者可以串联,但不要让同一个健康检查同时猜测进程存活、节点角色、复制延迟和业务可写性。
HAProxy 角色检查
下面的片段使用 Patroni REST API 判断角色,而不是只探测 5432 端口:
frontend postgres_write
bind *:5432
mode tcp
default_backend patroni_primary
backend patroni_primary
mode tcp
option httpchk GET /primary
http-check connect port 8008
http-check expect status 200
default-server inter 2s fall 3 rise 2 on-marked-down shutdown-sessions
server pg1 10.10.0.11:5432 check
server pg2 10.10.0.12:5432 check
server pg3 10.10.0.13:5432 checkHAProxy 的 active health check、fall、rise 和 HTTP check 行为见HAProxy health checks。shutdown-sessions 会主动断开被标记下线节点上的连接,可能加快收敛,也会扩大客户端瞬时错误;应用必须具备有限重试、幂等键和事务结果确认。
只做 option pgsql-check 只能证明 PostgreSQL 协议可响应,不能证明该节点是合法 primary。对 Patroni 集群,角色端点更接近写路由需要的真相。
PgBouncer 池模式与故障语义
[databases]
app = host=haproxy port=5432 dbname=app auth_user=pgbouncer_auth
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 40
reserve_pool_size = 10
server_connect_timeout = 5
server_login_retry = 2
query_wait_timeout = 15
auth_type = scram-sha-256
auth_file = /run/secrets/pgbouncer_users
admin_users = pgbouncer_admin
stats_users = pgbouncer_metrics这些数字只是容量计算示例。真正的 server connection 上限要按 database、user、PgBouncer 实例数和应用副本数相乘,并给管理连接、复制、迁移和备份预留余量。PgBouncer 官方配置说明指出,transaction pooling 会在事务结束后释放 server connection,客户端不能依赖跨事务 session state;临时表、SET、advisory lock、LISTEN/NOTIFY 和某些 prepared statement 用法都要单独验证。
观察连接池不能只看“客户端连得上”:
SHOW POOLS;
SHOW CLIENTS;
SHOW SERVERS;
SHOW STATS;故障转移时重点看 cl_waiting、平均等待时间、server login error 和旧连接释放速度。若 PgBouncer 自身只有一个实例,它就是新的单点;若多个实例各自开放过大的 default_pool_size,数据库会被总连接数反向打满。
备份不是 standby 的同义词
standby 会忠实 replay 误删、错误 DDL 和恶意操作;它只能缩短节点故障恢复时间,不能提供历史版本。完整恢复体系至少包含:
可验证的 base backup;从 base backup 开始连续可读的 WAL;独立故障域中的备份仓;
加密、保留、不可变或防删除控制;在隔离实例完成的定期恢复演练。
pg_basebackup适合创建完整 cluster 的物理备份和 standby 起点,不能选择单表或单库。pg_verifybackup 校验 manifest 与文件,不证明所有 WAL 都连续,也不证明恢复后的业务语义正确。
用 pg_basebackup 留下一份可校验副本
创建备份 volume 并从当前 primary,也就是 pg-standby,拉取备份:
docker volume create pg-backup-data
docker run --rm --entrypoint bash \
--network pg-ha-lab \
-v pg-backup-data:/backup \
-e PGPASSWORD="$PG_ADMIN_PASSWORD" \
"$PG_IMAGE" -lc '
rm -rf /backup/base
install -d -o postgres -g postgres /backup/base
exec gosu postgres pg_basebackup \
-h pg-standby -U postgres \
-D /backup/base -Fp -Xs -P --checkpoint=fast
'
docker run --rm --entrypoint pg_verifybackup \
-v pg-backup-data:/backup \
"$PG_IMAGE" /backup/base成功时最后会显示备份验证完成。若文件被篡改或缺失,pg_verifybackup 应返回非零状态并指出 manifest 不匹配。把这一反例加入备份流水线:复制一份实验备份、删除一个普通关系文件,再确认校验确实失败;不要破坏唯一备份。
物理备份会读取大量数据并生成额外 IO。大库可从合适的 standby 备份,但需要保证 primary 的 full_page_writes、standby 不在备份中被 promote,并验证备份期间的存储和复制延迟。备份并发、速率限制和窗口应进入容量预算。
PITR:恢复到误操作之前,而不是恢复到“最近备份”
PITR 的本质是“恢复一个 base backup,再按 timeline 顺序回放连续 WAL,停在目标时间、事务、LSN 或命名 restore point”。官方连续归档与 PITR同时解释了归档、恢复配置和 timeline;缺一段 WAL,恢复就只能停在缺口之前。
下面用独立容器演练,避免污染前面的 HA 节点。先准备 source、archive、backup 和 restore volume:
export PG_PITR_PASSWORD="$(openssl rand -base64 24 | tr -d '\n')"
docker volume create pg-pitr-source
docker volume create pg-pitr-archive
docker volume create pg-pitr-backup
docker volume create pg-pitr-restore
docker run --rm --entrypoint bash \
-v pg-pitr-archive:/archive \
"$PG_IMAGE" -lc 'chown postgres:postgres /archive && chmod 700 /archive'
docker run -d --name pg-pitr-source \
--network pg-ha-lab --ip 172.28.0.20 \
-e POSTGRES_PASSWORD="$PG_PITR_PASSWORD" \
-e POSTGRES_INITDB_ARGS='--data-checksums' \
-e PGDATA=/var/lib/postgresql/data \
-v pg-pitr-source:/var/lib/postgresql \
-v pg-pitr-archive:/archive \
"$PG_IMAGE" \
-c wal_level=replica \
-c archive_mode=on \
-c "archive_command=test ! -f /archive/%f && cp %p /archive/%f" \
-c archive_timeout=30s
until docker exec -e PGPASSWORD="$PG_PITR_PASSWORD" pg-pitr-source \
pg_isready -U postgres; do sleep 1; done创建初始数据,然后做 base backup:
docker exec -i -e PGPASSWORD="$PG_PITR_PASSWORD" pg-pitr-source \
psql -U postgres <<'SQL'
CREATE TABLE recovery_probe (
id bigint PRIMARY KEY,
status text NOT NULL
);
INSERT INTO recovery_probe VALUES (1, 'base-row');
SQL
docker run --rm --entrypoint bash \
--network pg-ha-lab \
-v pg-pitr-backup:/backup \
-e PGPASSWORD="$PG_PITR_PASSWORD" \
"$PG_IMAGE" -lc '
rm -rf /backup/base
install -d -o postgres -g postgres /backup/base
exec gosu postgres pg_basebackup \
-h pg-pitr-source -U postgres \
-D /backup/base -Fp -Xs -P --checkpoint=fast
'在备份之后写入一条需要保留的数据,创建 restore point,再模拟误删:
docker exec -i -e PGPASSWORD="$PG_PITR_PASSWORD" pg-pitr-source \
psql -U postgres <<'SQL'
INSERT INTO recovery_probe VALUES (2, 'keep-before-delete');
SELECT pg_create_restore_point('before_bad_delete');
DELETE FROM recovery_probe;
SELECT pg_switch_wal();
SQL
docker exec -e PGPASSWORD="$PG_PITR_PASSWORD" pg-pitr-source \
psql -U postgres -x -c \
"SELECT archived_count, failed_count, last_archived_wal, last_failed_wal
FROM pg_stat_archiver;"等待 last_archived_wal 更新且 failed_count 不再增长,然后停止 source,复制 base backup 到全新的恢复目录并创建 recovery.signal:
docker stop pg-pitr-source
docker run --rm --entrypoint bash \
-v pg-pitr-backup:/backup:ro \
-v pg-pitr-restore:/var/lib/postgresql \
"$PG_IMAGE" -lc '
rm -rf /var/lib/postgresql/data
cp -a /backup/base /var/lib/postgresql/data
chown -R postgres:postgres /var/lib/postgresql/data
chmod 700 /var/lib/postgresql/data
touch /var/lib/postgresql/data/recovery.signal
chown postgres:postgres /var/lib/postgresql/data/recovery.signal
'
docker run -d --name pg-pitr-restored \
--network pg-ha-lab --ip 172.28.0.21 \
-e PGDATA=/var/lib/postgresql/data \
-v pg-pitr-restore:/var/lib/postgresql \
-v pg-pitr-archive:/archive:ro \
"$PG_IMAGE" \
-c "restore_command=cp /archive/%f %p" \
-c "recovery_target_name=before_bad_delete" \
-c "recovery_target_action=promote"
until docker exec pg-pitr-restored pg_isready -U postgres; do sleep 1; done
docker exec -i pg-pitr-restored psql -U postgres -x <<'SQL'
SELECT pg_is_in_recovery() AS is_recovering;
SELECT timeline_id FROM pg_control_checkpoint();
TABLE recovery_probe ORDER BY id;
SQL预期恢复实例已经 promote,表中有 base-row 和 keep-before-delete,误删结果没有被 replay。若报 requested WAL segment ... has already been removed 或 could not restore file ...,先停止恢复实例并保留日志;这通常意味着归档缺口、目标选错或保留策略提前删除了所需 WAL,不能靠反复重启解决。
恢复完成后不要直接把隔离实例暴露给生产。先校验 cluster identifier、timeline、schema 版本、关键表行数、业务不变量、账号权限和 extension,再决定导回少量数据、替换整个实例还是走业务补偿。恢复实例中的审计日志和临时导出同样是敏感数据,验收后要按变更单清理。
pgBackRest 与 Barman 的边界
手写 archive_command 适合学习机制,不适合作为大多数团队的长期备份平台。它缺少仓库并发控制、重试语义、保留依赖、对象存储、加密、增量链、监控和多实例编排。
pgBackRest
pgBackRest 运行在数据库节点或专用 repository host,围绕 stanza 管理 cluster、仓库、WAL 归档、full/diff/incr 备份、校验和恢复。一个最小配置骨架如下:
[app-prod]
pg1-path=/var/lib/postgresql/18/main
[global]
repo1-type=s3
repo1-s3-bucket=example-pgbackrest
repo1-s3-endpoint=s3.example.internal
repo1-s3-region=example-region
repo1-path=/postgresql/app-prod
repo1-retention-full=4
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass-file=/run/secrets/pgbackrest_repo_key
process-max=4
start-fast=y
[global:archive-push]
compress-level=3落地顺序不是直接执行 backup:
sudo -u postgres pgbackrest --stanza=app-prod stanza-create
sudo -u postgres pgbackrest --stanza=app-prod check
sudo -u postgres pgbackrest --stanza=app-prod --type=full backup
sudo -u postgres pgbackrest --stanza=app-prod info
sudo -u postgres pgbackrest --stanza=app-prod checkarchive_command 通常交给 pgbackrest archive-push %p,恢复时由 archive-get 提供 WAL。保留策略必须同时推演 backup dependency 与 archive retention;删除一个 full backup 可能连带其 differential、incremental 和 PITR 窗口。仓库加密密钥丢失等于备份永久不可恢复,密钥和仓库不能只放在同一故障域。pgBackRest User Guide给出了 stanza、归档、保留、加密、恢复和监控的连续配置链。
Barman
Barman 更偏集中式备份服务器,可以通过 streaming 或 SSH/rsync 路径接收备份与 WAL,并从中心节点编排恢复。适合希望把多套 PostgreSQL 备份集中治理、审计和跨主机恢复的团队。它不是 HA 控制面,不负责选主和应用路由。
barman check app-prod
barman status app-prod
barman backup app-prod --wait
barman list-backups app-prod
barman recover --remote-ssh-command 'ssh postgres@restore-host' \
app-prod BACKUP_ID /var/lib/postgresql/18/main使用 --get-wal 恢复时,PostgreSQL 会按需从 Barman 获取 WAL;不用它时,工具可能需要把目标恢复所需的 WAL 复制到恢复端。具体恢复参数与 partial WAL 行为以Barman Recovery为准。
二者选型看团队运行模型,而不是功能数量:
| 决策面 | pgBackRest 更自然 | Barman 更自然 |
|---|---|---|
| 管理模型 | cluster 与 repository 紧密配合 | 集中式备份服务器管理多 cluster |
| 执行位置 | 数据库节点、仓库主机或对象存储 | 独立 Barman 主机与远端 PostgreSQL |
| 团队能力 | 熟悉 stanza、archive-push/get | 熟悉集中运维、SSH/streaming 接入 |
| 必验风险 | 保留依赖、仓库密钥、并行 IO | 中心节点容量、网络、SSH 权限、WAL 连续性 |
无论选择哪个工具,都要在目标 PostgreSQL major、目标操作系统、真实数据量和真实对象存储上完成恢复计时。产品页面支持某项能力,不等于团队已经拥有该能力。
把 RPO 和 RTO 变成演练证据
RPO 是事故后最多允许丢失的数据窗口,RTO 是从宣布故障到业务恢复可服务的时间。它们不是数据库参数,而是业务约束;复制、归档频率、备份带宽、DNS、连接重建、审批和人工校验都会消耗预算。
一次完整演练至少记录这些相对时间点:
| 阶段 | 证据 | 主要消耗 |
|---|---|---|
T0 故障注入 | 宿主机、进程、网络或误删事件 | 检测窗口开始 |
T1 告警触发 | 告警事件和第一响应 | 监控与通知延迟 |
T2 完成 fencing | 电源、网络、存储或安全组证据 | 决策与隔离时间 |
T3 新主可写 | 角色、timeline、关键写入 | promote 与恢复时间 |
T4 路由收敛 | 新连接目标、旧连接错误分布 | proxy、DNS、连接池 |
T5 业务验收 | 冒烟、对账、权限与审计 | 应用恢复时间 |
RPO 证据来自最后一个确认提交、候选节点 replay LSN、归档最后连续 WAL 与恢复后的业务键对账。RTO 证据则必须包含人工决策和应用收敛,不能只记录 PostgreSQL promote 用了几秒。
建议轮换以下故障:
primary 进程退出,宿主机仍在线。primary 与 DCS 网络分区,但数据库端口仍可访问。唯一同步 standby 失联,观察提交等待和降级策略。
standby 长时间离线,观察 slot、WAL 和磁盘保护。旧 primary 恢复,验证 fencing 与 rejoin。误删表或错误 DDL,执行 PITR 到隔离实例。
备份仓权限被撤销或对象存储限流,观察归档告警。
演练结束条件不是“服务回来了”,而是新主唯一可写、旧主不可写、路由稳定、数据损失在预算内、备份链重新建立、临时权限和资源已清理。
权限、凭证与审计不能附着在 superuser 上
生产体系至少拆分这些身份:
| 身份 | 能力 | 不应拥有 |
|---|---|---|
| replication role | 建立物理复制、执行受控 base backup | 业务 DDL/DML、任意 superuser |
| backup role | 读取备份所需对象或复制协议 | 应用写权限、HA 控制权限 |
| HA agent | 管理 PostgreSQL 服务、角色与配置 | 通用业务数据库账号 |
| application role | 指定 schema 的最小 DML | REPLICATION、superuser、控制面 API |
| operator | 受审计的 switchover/failover/recovery | 长期共享密码 |
| metrics role | 读取必要统计视图 | DDL、DML、凭证读取 |
复制连接使用 TLS 时要验证服务端证书、主机名和 CA,而不是只写 sslmode=require;verify-full 才同时验证 CA 与主机身份。passfile 权限必须限制,容器 secret 不应通过环境变量、docker inspect、进程参数或日志泄露。临时恢复账号在演练结束后轮换或删除。
审计至少覆盖:
谁执行了 promote、switchover、reinitialize、rewind 和恢复;谁修改了 Patroni dynamic config、DCS、HAProxy 与 PgBouncer;谁下载、恢复或删除了备份与 WAL;
谁改变了 slot、归档、同步复制和保留参数;恢复数据是否包含个人信息、密钥、token 或生产凭证。
控制面日志与数据库日志要进入独立存储。若攻击者已经获得数据库 superuser,不能再依赖同一 cluster 内的审计表证明其行为完整。
容量与成本从 WAL 速率开始计算
HA 和恢复的成本不只是多买一台 standby。至少建立以下预算:
WAL 日增量 = 平均 WAL 字节/秒 × 86400
slot 风险空间 = 峰值 WAL 字节/秒 × 最长允许离线秒数 × 安全系数
归档容量 = WAL 日增量 × PITR 保留天数 + base backup 依赖
恢复下限 = 待恢复字节 / 有效读取吞吐 + WAL replay + 校验与切流
连接上限 = PgBouncer 实例数 × database/user 池数量 × 每池 server connections这些公式用于建立量级,不替代压测。压缩、增量备份、对象存储延迟、加密 CPU、跨区流量、索引数量、full page writes 和 checkpoint 都会改变真实结果。
持续采集:
SELECT now(), wal_bytes, wal_records, wal_fpi
FROM pg_stat_wal;
SELECT archived_count, failed_count, last_archived_wal,
last_archived_time, last_failed_wal, last_failed_time
FROM pg_stat_archiver;
SELECT slot_name, active, restart_lsn, wal_status, safe_wal_size
FROM pg_replication_slots;用固定采样间隔计算增量,区分日常、批处理、索引重建和版本发布峰值。云环境还要把跨可用区复制流量、快照、备份超额存储、只读实例、代理、IOPS 和恢复临时实例计入账单;自建环境则要计算值班、演练、硬件冗余和备件成本。
常见故障要沿四个平面分型
standby 报所需 WAL 已被删除
日志出现 requested WAL segment has already been removed,WAL receiver 反复重连。
检查 standby 请求 LSN、primary 的 slot restart_lsn、wal_status、wal_keep_size、归档是否仍有该段 WAL。
结论:归档中有连续 WAL 时可以补取;没有时必须重新 base backup。不要修改控制文件或伪造 LSN。
slot 不活跃且 retained bytes 持续增长
active=f,pg_wal 占用增长。
查清 slot 对应的节点、订阅、owner 和最后心跳。确认是临时中断还是消费者永久下线。
恢复消费者,或在变更审批后删除废弃 slot。同步调整告警与资产记录,避免下次无法判断归属。
promote 后应用仍写旧主
新主写入成功,但部分连接仍落旧地址,或两边业务序列同时增长。
检查 fencing 证据、HAProxy 后端状态、DNS 缓存、PgBouncer server connection、应用池连接寿命和 service discovery。
立即阻断旧主网络或存储,冻结相关写入,分别导出分叉业务键做对账。不要先做 rewind,它会覆盖旧主分支并销毁取证材料。
Patroni 节点都不愿成为 leader
PostgreSQL 进程可能存活,但 /primary 全部返回非 200。
看 DCS quorum、leader lock、Patroni loop、maximum_lag_on_failover、watchdog、pause 状态和节点 timeline。
先恢复 DCS 或明确执行受控人工切换,不要在多个节点上分别强制 promote。若启用了 failsafe mode,验证 leader 是否能访问全部已知成员。
PgBouncer 切换后大量客户端等待
cl_waiting 上升,旧 server connection 报错,新连接建立缓慢。
检查 HAProxy 是否已选中新主、PgBouncer 的 SHOW SERVERS、认证查询、DNS、server_login_retry 和数据库总连接余量。
先恢复可用 server connection,再按限速策略释放等待客户端。不要同时无限扩大应用池和 PgBouncer 池。
备份成功但 PITR 找不到目标点
base backup 校验通过,恢复却在某段 WAL 停止。
确认 backup start/stop LSN、目标 restore point 或时间、archive continuity、timeline history 和保留策略。
选择能够覆盖目标的更早 backup,补齐可获得的 WAL;缺口无法补齐时,明确可恢复边界并转业务补偿。随后修复归档告警和保留依赖。
团队长期治理要把所有权写到对象上
一套稳定的 PostgreSQL HA 平台需要明确 owner,而不是把问题统称为“DBA 负责”:
| 对象 | 主要 owner | 验收证据 |
|---|---|---|
| PostgreSQL 参数、复制与 slot | DBA / 数据平台 | 配置基线、复制视图、容量告警 |
| DCS、Patroni/repmgr、watchdog | 平台团队 | quorum、角色状态、fencing 演练 |
| HAProxy、PgBouncer、DNS | 平台与应用共同负责 | 路由探针、连接收敛、容量模型 |
| 业务幂等与事务确认 | 应用团队 | 重试测试、业务键对账、错误处理 |
| 备份仓、密钥与保留 | 数据平台 / 安全 | 不可变策略、恢复记录、访问审计 |
| RPO/RTO 与切换授权 | 业务 owner / SRE | SLO、演练报告、值班手册 |
每次 PostgreSQL major 升级、HA 工具升级、DCS 迁移、代理升级、存储变更或备份仓迁移,都要重跑代表性实验。配置漂移检查至少覆盖 postgresql.conf、pg_hba.conf、Patroni dynamic config、repmgr 配置、HAProxy、PgBouncer、归档命令和备份保留。
变更顺序应保持可回退:先扩容或新增 standby,观察追平;再变更路由或同步集合;最后回收旧节点。删除 slot、备份、WAL 和旧 primary 数据目录是不可逆动作,必须晚于业务验收和回滚窗口。
复制与角色:
primary 和 standby 的 major、extension、locale、checksum 设置兼容。pg_stat_replication 与 pg_stat_wal_receiver 能证明 streaming 和 replay。每个 slot 有 owner、消费者、容量上限和失联告警。
同步复制的确认级别、候选集合和降级策略已经演练。只读节点写入会失败,写入口不会把只读节点误判为 primary。
故障转移:
failover 前先完成 fencing,并有独立证据证明旧主不可写。候选节点的 replay LSN、数据损失窗口和 timeline 已记录。Patroni/repmgr/DCS 的网络分区和控制面故障已经演练。
HAProxy、PgBouncer、DNS 和应用连接池的收敛时间已测量。旧主通过 pg_rewind 或新 base backup 回归,不直接重启复用。
备份恢复:
base backup 有 manifest 校验,WAL 归档连续性有独立告警。PITR 能在隔离实例恢复到命名点或目标 LSN,并完成业务校验。pgBackRest/Barman 的保留依赖、仓库容量、密钥恢复和访问权限已验证。
备份位于独立故障域,并有防误删、加密和跨账号策略。恢复临时实例、导出文件、账号和 secret 在验收后完成清理。
容量与治理:
WAL 生成速率、slot backlog、归档失败、磁盘余量和 replay lag 同图观测。RPO/RTO 来自业务约束和真实演练,不是工具默认值。PgBouncer 与应用池总连接数没有超过数据库容量预算。
promote、rewind、恢复、备份下载和配置变更都有审计。云服务的扩展、权限、PITR、跨区恢复、成本和退出路径已验证。每次版本、存储、网络和控制面变更后重跑代表性故障与恢复实验。
清理实验资源
确认不再需要取证后,再清理容器、volume、网络和当前 shell 中的实验凭证:
docker rm -f pg-standby pg-rejoined pg-pitr-source pg-pitr-restored 2>/dev/null || true
docker volume rm \
pg-primary-data pg-standby-data pg-backup-data \
pg-pitr-source pg-pitr-archive pg-pitr-backup pg-pitr-restore
docker network rm pg-ha-lab
unset PG_ADMIN_PASSWORD PG_REPL_PASSWORD PG_PITR_PASSWORD PG_IMAGEvolume 删除不可恢复。若实验中出现与预期不同的 timeline、WAL 缺口或双主写入,先保留容器日志、pg_controldata、HA 控制面事件和业务对账结果,再清理环境。生产故障更不能用“重建得更干净”替代证据保全。
