Skip to content
复制与高可用

复制与高可用

PostgreSQL 的复制家族解决的是三类不同的问题:机器挂了不丢数据(高可用)、读流量太大分担压力(读扩展)、把增量数据喂给外部系统(迁移与 CDC)。本章讲清楚每种手段怎么搭、边界在哪、出了事故怎么切回去。

前置阅读:WAL 的产生与归档见 01-架构与核心概念.md,synchronous_commit 语义见 05-事务与锁.md,vacuum 被复制槽与长事务拖住的问题见 06-MVCC与Vacuum.md。


三种复制,一张全景

先建立心智模型,避免拿错工具:

  • 流复制(物理复制):主机的 WAL 像流水一样源源不断发给备机,备机把 WAL 原样回放成一份和主机字节级一致的副本。它是"整库、只读镜像",解决高可用和读扩展。叫"流式"是为了对比更早的文件级 WAL 归档——walsender 进程持续推送 WAL,延迟是毫秒级而不是分钟级。
  • 逻辑复制:把 WAL 解码成"哪张表的哪一行,从什么值改成什么值"这样的行级变更事件,再发给订阅端(通常是另一个 PostgreSQL,也可以是 Kafka)。它是"选择性、可解释的数据变更流",解决跨大版本零停机迁移和 CDC。
  • 共享存储:多个计算节点挂同一份存储(Aurora、PolarDB 这类云原生架构的思路),故障切换不需要拷贝数据。原生 PostgreSQL 没有开箱即用的共享存储复制,用云盘块复制/快照拼出来的方案不在本章范围,交给云厂商文档。
你想解决的问题 该用的手段
主机宕机,备机顶上(高可用 / failover) 流复制(+ 第 7 节的 Patroni 等编排)
报表、备份查询分摊主库读压力 流复制的 hot standby
13/15 升到 18,业务不能停 逻辑复制(见 11-备份恢复与运维.md)
把 binlog 式变更流导出到 Kafka/数仓 逻辑复制(见 kafka/ 手册)
只同步部分表到另一个库 逻辑复制(物理复制只能全库)

一句话记法:物理复制搬"日志",逻辑复制搬"行变化"。前者快而全但死板,后者灵活而有语义但有开销和限制。


流复制实操(本章核心)

前提条件

wal_level 有三档:minimal / replica / logical(PG 18 文档确认三档仍在)。做流复制至少 replica,做逻辑复制要 logical。minimal 只保留崩溃恢复所需的最少日志,与归档和复制互斥(开了 max_wal_senders 就起不来,archive_mode 也不允许开),生产上基本没有它的用武之地,不必纠结。这个参数只能重启生效(见 00-安装与配置.md)。

主机侧还要保证有足够额度:

# 主机 postgresql.conf
listen_addresses = '*'
wal_level = replica
max_wal_senders = 10          # walsender 进程上限,每个备机/CDC 消费者占一个
max_replication_slots = 10    # 槽位上限
hot_standby = on              # 默认 on:备机允许接受只读查询

搭一台备机:三步

第一步,主机建复制角色和 pg_hba 规则。 pg_hba.conf 里复制连接用的数据库名写关键字 replication 而不是库名,这是新手最容易漏的一行:

CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'S3cret!';
# pg_hba.conf:允许 10.0.0.0/24 网段的 replicator 建复制连接
host    replication   replicator   10.0.0.0/24   scram-sha-256

第二步,建物理复制槽,然后从主机直接克隆一台备机。 pg_basebackup -R 是一条龙命令:拉基础备份的同时,自动把 primary_conninfo / primary_slot_name 写进备机新数据目录的配置,并创建 standby.signal 文件:

# 主机上先建槽
psql -c "SELECT pg_create_physical_replication_slot('standby1_slot');"

# 备机上执行:-R 自动写连接配置,-S 绑定槽,-Xs 流式带走备份期间的 WAL,-P 显示进度
pg_basebackup -h 10.0.0.11 -p 5432 -U replicator \
    -D /var/lib/pgsql/18/data -Fp -Xs -R -P \
    -S standby1_slot

第三步,起备机,回主机看状态。 pg_ctl start -D /var/lib/pgsql/18/data,几秒后主机 pg_stat_replication 里应出现一行 state = streaming。

注意:老教程里"编辑 recovery.conf"的写法从 PG 12 起已作废——如果数据目录里还存在 recovery.conf,实例会直接拒绝启动。12 用 standby.signal(进备机模式)/ recovery.signal(进恢复模式)两个空文件加 postgresql.conf 里的 primary_conninfo 等参数替代。不用 -R 手工搭建时,standby.signal 单独 touch 一个即可,配置写进 postgresql.conf:

# 备机 postgresql.conf(等价于 -R 帮你生成的内容)
primary_conninfo      = 'host=10.0.0.11 port=5432 user=replicator password=S3cret! application_name=standby1'
primary_slot_name     = 'standby1_slot'
hot_standby           = on

确认复制健康:两边各跑一条

主机侧,pg_stat_replication 是主库视角的"每个 walsender 一个连接"全景:

SELECT application_name, state, sync_state,
       write_lag, flush_lag, replay_lag,
       client_addr
FROM pg_stat_replication;

三个 lag 对应 WAL 在备机上走过的三级台阶,务必分清:write_lag 是备机 walreceiver 收到(落进 OS 页缓存),flush_lag 是备机把 WAL fsync 落盘,replay_lag 是数据文件回放完成、查询能看到的进度。sync_state 有 async / potential / sync / quorum 四种,对应下一节的同步配置。

备机侧,看自己追到哪:

SELECT pg_is_in_recovery(),
       pg_last_wal_receive_lsn()  AS received,
       pg_last_wal_replay_lsn()   AS replayed,
       last_msg_receipt_time
FROM pg_stat_wal_receiver;

拿备机 received 和主机 pg_current_wal_lsn() 对比可以量化积压字节:

-- 主机执行,replay 落后多少 WAL
SELECT application_name,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS behind
FROM pg_stat_replication;

hot standby 的回放瓶颈与查询冲突

备机的 WAL 回放进程(startup)基本是单线程的——主机上并行写很猛的一大段 WAL,备机要一个人顺序重放。所以高写入系统上常见 write/flush_lag 很小而 replay_lag 持续拉大:网络没问题,是回放跟不上。PG 18 新增的 recovery_prefetch 参数让回放时用并行 worker 预读 WAL 和数据页,缓解"回放瓶颈其实是随机 IO"的场景,但线程模型本身仍是单回放流。

更隐蔽的是回放与查询的冲突:备机查询正读一个页,回放进程要清理该页的死元组或者更新它,startup 会等,等过 max_standby_streaming_delay(默认 30s)后反过来取消查询并报 canceling ... because it conflicts with recovery。解法两头权衡:备机开 hot_standby_feedback = on 能把备机查询的 xmin 反馈给主机、避免清理冲突——代价是主机 vacuum horizon 被备机长查询钉住,死元组堆积(和 06-MVCC与Vacuum.md 里长事务的危害同源,且备机上膨胀还会独立发生,见本章末尾事故表)。


复制槽 vs 不建槽

复制槽(replication slot)解决的是一个时序问题:备机(或 CDC 消费者)还没读完的 WAL,不能被主机回收删除。没有槽时,主机按 wal_keep_size(PG 13 起用它代替按块计的 wal_keep_segments)粗粒度地圈一段 WAL 不删——这是"保量不保质":备机断线超过这段量就永远追不回,只能重做基础备份;而如果备机断线远小于这段量,你又白白多留了 WAL 占空间。

有槽时精确得多:槽记录每个消费者的 restart_lsn,这个点之前的 WAL 一定不删,备机重连后从断点续传。

但槽是把双刃剑——槽不消费,WAL 就一直堆,这是 PG 运维的经典事故(呼应 06-MVCC与Vacuum.md 里"长事务钉住 xmin"的姊妹篇):

  • pg_basebackup -S slot 传到一半被 Ctrl-C,槽留在主机上,WAL 以每天几十 GB 的速度堆满磁盘;
  • CDC 程序(Debezium/Kafka Connect)崩溃下线,逻辑槽把 pg_wal 和 WAL 归档目录一起堆爆。

所以 PG 13 起有 max_slot_wal_keep_size 兜底:限制单个槽最多钉住多少 WAL(单位 MB,默认 -1 不限)。超限后槽被标记为失效(pg_replication_slots.invalidated 非空),备机彻底废掉必须重建——用"丢一个备机"换"不丢整个集群的磁盘",通常是对的取舍。

日常监控就一条 SQL,建议进告警(阈值看 06-MVCC与Vacuum.md 与本章事故表):

SELECT slot_name, slot_type, active, inactive_since,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;

inactive_since 列从 PG 17 起提供,直接给出"槽已经闲置多久",比自己做时间差方便。下线的备机/消费者,记得显式清理:

SELECT pg_drop_replication_slot('standby1_slot');

同步与异步复制

默认是异步:主机的 WAL 本地 fsync 后立刻向客户端返回 COMMIT,不等备机。主库彻底损毁时,最后几笔"已确认提交"的副本可能还没到备机——RPO 不为零。

想让提交等备机,两个旋钮配合使用:

# 主机:谁算同步备机
# FIRST 1:按列表顺序,排在前面且在线的 1 台必须确认
# ANY 1:列表里任意 1 台确认即可(quorum 语义,推荐三备机取 1)
synchronous_standby_names = 'ANY 1 (standby1, standby2, standby3)'

# 每个会话/事务可调(详见 05-事务与锁.md)
# synchronous_commit = on | remote_write | remote_apply | local | off

档位对应 pg_stat_replication 里等待的那个 lag:on 等备机 flush,remote_apply 等备机 replay(备机此时读必然能看到刚提交的数据),remote_write 只等备机收到——备机自己掉电时它的 OS 缓存可能还没落盘,但备机 PostgreSQL 进程崩溃不会丢。

同步复制的三个边界,比配置本身重要:

  1. 同步复制把"数据丢失风险"换成了"提交阻塞风险"。 所有同步备机都失联时,主库新事务的提交会停在同步等待上(pg_stat_activity 里表现为 wait_event_type = Activity、wait_event = SyncRep)——业务整体卡死,比丢几笔更难排查。没有任何可用同步备机时它会降级为异步放行(宁可丢不卡死),但降级窗口内的提交就是可能丢。
  2. 它保证的是"提交成功的 WAL 至少两份",不保证一切。 主机生成 WAL 之前的故障(应用没跑完、事务回滚)不归它管;共享存储/同机房双损它也救不了。
  3. 提交 ACK 丢失的二义性依旧存在:客户端发完 COMMIT 后网络断了,不知道对方到底提没提交——幂等设计(呼应 kafka/ 手册的 at-least-once 思路)永远是应用层的功课。

生产建议:对一致性极敏感的业务用 ANY 1 (两台异地备机) 的 quorum,避免单台同步备机维护时拖住主库;报表类业务保持异步,接受秒级 RPO。


故障切换:promote、时间线与 pg_rewind

提升备机

备机转主有两种触发:人工 pg_ctl promote -D 数据目录(或在任意连接上 SELECT pg_promote();),以及 Patroni 等编排工具在锁丢失后的自动提升。提升动作本身很快:walreceiver 停止接收,startup 把剩余 WAL 回放完,时间线号(timeline)加一,进程转为可写。时间线历史会留下 00000002.history 这样的小文件,这是 PG 用来判断"哪些 WAL 还属于同一条故事线"的依据。

真正危险的环节在旧主:如果旧主还活着还在写(网络分区误判、手工 promote 忘了先停旧主),两边时间线分叉,旧主的数据就成了孤儿。这就是为什么自动 failover 必须配合 fencing(见"高可用方案图景")。

pg_rewind:旧主追新主

想让分叉后的旧主重新变成新主的备机,别重做基础备份(TB 级要数小时),用 pg_rewind:它把旧主在新主提升之后多写的数据页回退掉,让两边重新对齐到共同历史点。

# 前提:旧主干净停机(pg_ctl stop -m fast);且 wal_log_hints=on 或建库时开了 data checksums
pg_rewind -D /var/lib/pgsql/18/data \
    --source-server='host=10.0.0.12 port=5432 dbname=postgres user=postgres'
# 然后 touch standby.signal、配好 primary_conninfo,作为备机启动

wal_log_hints 让满页写也进 WAL(WAL 量增大,适合本就可能回退的拓扑),data checksums(initdb -k,见 11-备份恢复与运维.md)同样满足前提——这是"建库时开校验和"的一个附带收益:pg_rewind 可用。

级联复制

备机可以再接备机(primary_conninfo 指向某台备机即可),形成树状拓扑:主库的 walsender 压力减轻,异地机房从就近的备机拉流。代价是延迟逐层累加——叶子备机的 replay_lag 天然落后中间层,别让关键读落在叶子上。

读写分离的"读到旧数据"问题

应用写主库、读备库,中间隔着一个 replay_lag 的窗口:用户下完单刷新列表,看不到自己的单——这不是 bug,是复制延迟的物理事实。三种标准解法按严格程度递增:

  1. 同一会话内"写后的读"强制走主库(连接池按事务标记路由,呼应 08-Go客户端pgx.md 的连接管理);
  2. synchronous_commit = remote_apply:提交等备机回放完成,此后读备机必有新数据——但每个提交的延迟变成"最慢同步备机的回放时间";
  3. 应用层比较 LSN:写操作从主库拿 pg_current_wal_lsn(),查询前轮询备机 pg_last_wal_replay_lsn() 直到追平。

多数业务的正确姿势是第 1 种加一点第 3 种,别为了少数场景全局开 remote_apply。


逻辑复制实操

原理与模型

订阅端通过复制槽拉取发布端的逻辑解码结果,把每行变更在本地用普通 DML 重放。模型是 Publication(发什么)/ Subscription(收什么),都建立在单个数据库上:

-- 发布端(旧库 / 业务主库)
CREATE PUBLICATION app_pub FOR TABLE orders, products, users;
-- 也支持 FOR ALL TABLES、FOR TABLES IN SCHEMA public、按列发布(UPDATE 只带关注的列)

-- 订阅端(新库 / 数仓入口)
CREATE SUBSCRIPTION app_sub
    CONNECTION 'host=10.0.0.11 port=5432 dbname=app user=replicator password=S3cret!'
    PUBLICATION app_pub;

订阅创建后自动做 initial sync:按表复制结构和存量数据(通过一个一致性快照),之后转入增量回放。监控看 pg_stat_subscription(订阅端)和发布端的 pg_replication_slots / pg_stat_replication。

注意 逻辑复制的边界必须背下来:只复制行级 DML。DDL 不复制(建表语句、改表结构都要两边分别做);序列的当前值不复制——PG 18 起可以把序列加入 publication 同步"结构"(官方 release notes 确认),但数值仍不同步,切换时务必手工 setval;大对象(lo)不复制;权限、事件触发器不复制。会被 UPDATE/DELETE 的表要有主键或显式 REPLICA IDENTITY——没有主键的表,订阅端 UPDATE 无法定位目标行会直接报错,发布前先检查 pg_class.relreplident。

两个实用细节:订阅端有 two_phase 选项(PG 15 起)让 PREPARE TRANSACTION 以两阶段形式发到订阅端,订阅者崩溃后不会出现"半应用"状态;wal_level = logical 会增加 WAL 总量(尤其是 REPLICA IDENTITY FULL 的表,每行更新前后像全写进 WAL),逻辑解码还要额外 CPU——从 replica 切到 logical 前评估好写入量。

复制槽的 failover(PG 17 起)

物理复制槽 + Patroni 的痛点是:主库切换后,槽没了、订阅/备机全部要重建。PG 17 引入 failover 槽(官方 release notes 确认):建槽时可标记 failover,备机会自动同步这些槽(GUC sync_replication_slots = on,配合 pg_sync_replication_slots() 函数),主库切换后新主接着用同名槽供数,订阅端无需重新初始化。

典型用途一:跨大版本零停机迁移

流程骨架(详细演练步骤见 11-备份恢复与运维.md):

  1. 新实例装好目标大版本、同名扩展、schema 差异先对齐;
  2. 旧库 wal_level = logical,建 publication;新库建 subscription 全表同步,initial sync 期间业务照常跑;
  3. 存量追平后做校验(行数、抽样、聚合和),让新库并行跑一段只读流量;
  4. 切换:业务停写(或双写开关)→ 等 pg_stat_subscription 无积压 → 同步序列值与权限 → 连接串指向新库;
  5. 保留旧库只读兜底 N 天,确认后下线并清理槽。

典型用途二:CDC 到 Kafka / 数仓

用 pgoutput 插件(内置)接 Debezium / Kafka Connect,或 test_decoding 调试。下游消费同样走复制槽,所以"消费者停、WAL 堆满盘"的教训与本章第 3 节完全一致——给 CDC 槽设 max_slot_wal_keep_size,并监控 inactive_since。分区/主题映射、至少一次语义与幂等消费的完整讨论见 kafka/ 手册。


高可用方案图景

复制只是"数据有副本",高可用还差"谁能成为主"的裁决。常见四层玩法:

  • 纯手动:一主一备 + pg_basebackup -R + lag 监控,故障时人工 pg_ctl promote、改连接串。中小团队完全够用(前提见下一节军规)。
  • Patroni + etcd:事实上的开源自建标准。每个节点跑 Patroni agent,leader 身份是 etcd 里一把带 TTL 的锁—— Patroni 定期续约,失联超时即发起选举(本质就是 etcd 手册里的 lease + CAS 分布式锁,见 etcd/ 手册)。Patroni 负责 promote、把旧主拉回来 pg_rewind 成新备,并对外提供 REST API(/primary /replica 端点)供 HAProxy/VIP/DNS 做流量切换。
  • repmgr / pg_auto_failover:repmgr 有自己的守护进程和 witness 节点做仲裁;pg_auto_failover 是简化版(monitor + 两节点),官方文档自己就声明不适合跨地域复杂拓扑。
  • 云托管:RDS Multi-AZ(同步备机对用户不可见,故障自动换 ENI)、Cloud SQL HA,以及 Aurora/PolarDB 的共享存储路线——切换秒级、不用管 WAL 搬运,但复制语义握在厂商手里,逃不出"锁存储"的形态。

自动 failover 的代价必须写进方案评审:

  1. fencing 不是可选。旧主没被隔离(杀进程/断网/拔存储)就 promote 新主,双主各写各的,最后只能靠 pg_rewind 救回或丢数据。
  2. 误判比宕机更常见。监控超时不等于数据库死了——主库被打满 CPU 时"假死",自动切换后新主接住同样的流量,整体系雪崩。失败检测阈值、pg_isready 之外的业务级探测,是真正需要演练的功夫。
  3. DCS 少数派会拒绝切换。etcd 网络分区时,凑不齐 quorum 的一侧 Patroni 会把自己降级只读——宁可全站只读也不允许双写,想清楚你能否接受这个行为。

常见复制事故速查

现象 根因 处置
备机报 requested WAL segment ... has already been removed 无槽/槽失效,备机断线太久 WAL 被回收 重做 pg_basebackup,补建槽
pg_wal 膨胀撑爆磁盘 遗留槽(失败的 basebackup、下线的 CDC)不消费 查 pg_replication_slots.retained,pg_drop_replication_slot 或设 max_slot_wal_keep_size
主库大量提交 hang 住 同步备机全部失联,等待同步复制 临时从 synchronous_standby_names 摘除,事后复盘网络
备机查询报 conflicts with recovery 回放要清理/改页,被备机长查询挡住后超时取消查询 备机短查询化;或评估 hot_standby_feedback(注意主机膨胀代价)
备机表膨胀、死元组持续涨 hot_standby_feedback 把备机 xmin 反馈回主机钉住清理;或备机自身长查询 定位备机长事务(见 06-MVCC与Vacuum.md),错峰 vacuum
主备切换后旧主拒绝以原身份启动、时间线分叉 旧主 promote 后还在写,或 WAL 里有新时间线记录 pg_rewind 对齐后作为备机拉回
复制连接报 no pg_hba.conf entry for replication connection pg_hba 漏了 replication 关键字行 补 host replication ... 并 reload
逻辑复制对某表持续报找不到标识符 表无 PK / 未设 REPLICA IDENTITY ALTER TABLE ... REPLICA IDENTITY FULL(注意 WAL 暴涨)或补主键
订阅一直 initializing 不动 大表 initial sync 慢,或发布端权限/槽被别的订阅占用 看 pg_stat_subscription;大表拆批次、错峰

实战建议

先给结论:绝大多数中小团队,“一主一备异步流复制 + 可靠备份 + 手动提升演练"就是正确答案。自动 failover 带来的复杂度和误判风险,通常大于它省下的那几分钟 RTO。

最小生产拓扑起步配置清单(1 主 1 备):

# 主机
wal_level = replica
max_wal_senders = 10
max_replication_slots = 10
wal_keep_size = 4096              # 有槽时做兜底,无槽时是生命线
max_slot_wal_keep_size = 32768    # 防爆盘,按磁盘余量设
archive_mode = on                 # PITR 与复制是两件事,都要做(见 11-备份恢复与运维.md)
  • 备机一律用 pg_basebackup -R -S slot 生成,不要手抄配置,避免 recovery.conf 时代的历史残留。
  • 给 pg_replication_slots 的 retained 和 inactive_since、pg_stat_replication 的 replay_lag 配告警;阈值参考:retained 超过磁盘 10%、replay_lag 超 60s。
  • 每季度做一次"拔电源演练”:杀主库,人工 promote 备机、pg_rewind 拉回旧主。没演练过的切换预案等于没有预案(呼应 11-备份恢复与运维.md 的恢复演练军规)。
  • 读写分离上线前,明确哪些路径必须读主(写后立即读、支付对账),在应用层做路由而不是赌 replay_lag 够小。
  • CDC 与迁移任务结束后,第一件事清 publication/subscription 和槽,第二件事确认 pg_wal 体积回落。