PostgreSQL 原理图解 · 复制与高可用

第二辑。PG 的复制是物理的——传的是 WAL 字节流,不是 SQL。这一条决定了从库能干什么、不能干什么。

流复制:从库拿到的是一堆字节,不是 SQL

这是 PG 和 MySQL 主从最大的区别。MySQL 传 binlog(逻辑变更),从库用 SQL 线程重放;PG 传 WAL(物理日志),从库按页级别把主库的改动「照抄」一遍。所以 PG 的从库必然和主库物理一致——一模一样,一个字节都不差。

主库 primary 从库 standby WAL buffer(共享内存) 提交时写入,walwriter 刷盘 pg_wal/ 段文件(16MB 一个) 落盘后才算提交成功 walsender 进程 读 WAL,通过复制协议发送 walreceiver 进程 接收并写入本地 pg_wal startup 进程重放 按 WAL 记录修改数据页 数据文件(与主库物理一致) 块级相同,不是逻辑相同 TCP 5432 · 复制协议 回执:已收到 LSN 0/3000000
这一步在做什么—
和 MySQL 的差别—

「物理一致」意味着什么

能力PG 物理流复制MySQL binlog 复制
从库能不能有不同索引不能。数据文件必须逐字节一致能,binlog 重放不关心索引
能不能跨大版本不能。页格式变了就重放不了能,5.7 可以复制到 8.0(有坑)
能不能只复制某几张表不能,要整库能,replicate-do-table
从库能不能写不能,read_only 且物理上会冲突能(但不该)
主从数据会不会逻辑漂移不会,物理一致会,非确定性函数、触发器、版本差异都可能
延迟的度量LSN 差(字节数)Seconds_Behind_Master(秒)
第三行是很多人不知道的:PG 物理复制只能整实例。想只同步某几张表,只能用逻辑复制(第九个考点)。这个限制经常在「我们只要同步一张表给分析用」的需求上撞墙。
延迟怎么看:PG 没有 Seconds_Behind_Master。要看的是 pg_stat_replication 里的 write_lag / flush_lag / replay_lag——分别是「从库收到」「从库刷盘」「从库重放完」三个阶段的延迟。真正影响读到旧数据的是 replay_lag。

同步复制:五个档位,不是一个开关

MySQL 的半同步是一个插件,PG 的同步是 synchronous_commit 的五个取值——它决定的是「主库什么时候才告诉客户端提交成功」,不是一个「开/关」。

客户端等待多久
—
主库崩溃会丢吗
—
从库此刻能读到吗
—
保证的是—
代价—
取值主库等谁典型用途
off谁都不等,WAL 还在内存就返回批量导入、可重跑的任务
local等本地 WAL 刷盘单机场景,没有从库时等价于 on
remote_write等从库收到并写进自己的 WAL buffer要「主库挂了数据还在」,但不介意从库也挂
on(默认)等从库刷到自己的磁盘绝大多数生产环境
remote_apply等从库重放完成,能被查询看到读己之写(read-your-writes)要求强的场景
关键前提:synchronous_commit 只有在配了 synchronous_standby_names 时才有意义。没配同步备库时,这个参数实际上就是本地的 local——看着是 on,其实一个备库都没等。很多人以为「我开了同步复制」,其实只是改了 synchronous_commit。

怎么指定「必须等几个备库」

# 等 1 个备库确认即可(性能优先)
synchronous_standby_names = '1 (standby1, standby2)'

# 等 2 个里全部确认(金融级)
synchronous_standby_names = '2 (standby1, standby2)'

# 按应用名,支持 quorum(PG 9.6+)
synchronous_standby_names = 'FIRST 2 (s1, s2, s3)'   -- 前 2 个可达的
synchronous_standby_names = 'ANY 2 (s1, s2, s3)'     -- 任意 2 个
同步复制最大的坑:备库全挂,主库会卡住。配了 '1 (s1)',s1 掉线之后主库的每次提交都无限等待,业务直接挂死。所以要设 wal_sender_timeout,并且理解 PG 的行为:同步备库失联时,主库的写入会阻塞,而不是自动降级为异步(这一点和 MySQL 半同步超时自动降级不一样)。

要降级只能手工把 synchronous_standby_names 改空并 reload——所以这个操作必须写进应急预案,并且演练过。
面试里区分度高的一句:「MySQL 半同步超时会自动降级成异步,PG 不会——PG 的同步备库失联会直接阻塞主库写入,必须人工介入。所以我们配同步复制时一定会同时准备好『摘掉同步配置』的 runbook,并且用 ANY 2 (s1,s2,s3) 这种 quorum 写法,避免单点失联就全站写不进去。」

复制槽:保命的机制,也是撑爆磁盘的元凶

WAL 段文件会被循环回收——「这个位置之前的所有备库都确认收到了」。可如果备库掉线很久,主库凭什么知道它还没收到?复制槽就是主库给每个备库记的一个书签:这个位置之前的 WAL 一律不许删。

已回收 备库已确认 槽卡住,不能删
pg_wal 目录:1.2 GB
主库状态—
会发生什么—
怎么处理—

两种槽,用途完全不同

物理复制槽(physical)逻辑复制槽(logical)
用在哪流复制、pg_basebackup逻辑复制、CDC(Debezium / wal2json)
保留什么WAL 段文件WAL + 系统表的旧版本(catalog xmin)
危害磁盘被 WAL 填满磁盘 + 阻止 VACUUM 清理系统表
查SELECT slot_name, slot_type, active, restart_lsn, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained FROM pg_replication_slots;
逻辑槽更危险,因为它同时污染两个方向。它要保留「某个时刻的系统表视图」,所以系统表上的死元组不能被 vacuum 清掉——这会同时造成磁盘被 WAL 占满和系统表膨胀 / xid age 升高。一个被遗忘的逻辑槽,几周内就能把库拖到回卷边缘(第一辑第五个考点)。

三个必须进监控的指标

-- 1. 每个槽卡住了多少 WAL(超过几十 GB 就该报警)
SELECT slot_name, active,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal,
       age(xmin) AS xmin_age, age(catalog_xmin) AS catalog_age
FROM pg_replication_slots ORDER BY 3 DESC NULLS LAST;

-- 2. pg_wal 目录多大(这个涨起来就是磁盘告警)
SELECT pg_size_pretty(sum(size)) FROM pg_ls_waldir();

-- 3. 备库到底跟到哪了
SELECT client_addr, state, sync_state,
       write_lag, flush_lag, replay_lag
FROM pg_stat_replication;
active = false 的槽是最危险的。活跃的槽说明备库在追,WAL 最终会被消费掉;不活跃的槽说明没人在消费,但主库仍然不许删——它在无限累积。所以监控要盯「不活跃 + 保留量大」这个组合,而不是只看总量。
面试里怎么说这一题:「复制槽解决了『备库掉线回来还得能续上』的问题,代价是主库会为它保留 WAL 和系统表版本。最常见的线上事故是备库重建失败后槽被遗忘,几天后 pg_wal 把磁盘填满,主库直接不可写。所以我们对不活跃的槽单独告警,并且在 pg_basebackup 流程里明确『重建成功就删旧槽』。」

逻辑复制:什么时候必须用它

物理复制是「整实例照抄」,逻辑复制是「按表解析出行变更再重放」。它慢、限制多,但只有它能做到跨版本、跨平台、只同步部分表、以及从库可写。

WAL wal_level=logical 解码(decode) pgoutput / wal2json publication 发布:哪些表 subscription 订阅:写到哪 目标表 普通 SQL 重放
-- 主库
ALTER SYSTEM SET wal_level = 'logical';     -- 需要重启
CREATE PUBLICATION pub_orders FOR TABLE orders, order_items;

-- 目标库
CREATE SUBSCRIPTION sub_orders
  CONNECTION 'host=primary dbname=app user=repl'
  PUBLICATION pub_orders;

和物理复制的取舍

维度物理流复制逻辑复制
粒度整个实例按表(可以只同步几张)
跨大版本不行行(16 → 18 可以)
跨平台 / 跨云要求架构、页大小一致不要求
目标库可写不行(只读)可以,不同表可以各自写
DDL自动同步不同步,要手工在两边都执行
序列(serial)自动同步不同步,要单独处理
大事务按字节流,影响小要在内存里拼完整事务,可能 OOM
延迟毫秒级通常更高,取决于解码
两个必踩的坑:
① DDL 不同步。主库加了字段,目标库没加,复制立刻报错并停下——之后所有变更都堆积。必须约定「DDL 双写」流程,或者用工具统一执行。
② 序列不同步。SERIAL / IDENTITY 的当前值不会复制。目标库如果也要插入(比如双写或切主),序列落后会导致主键冲突。切换前要手工 setval。
什么时候该用逻辑复制:跨大版本升级(一边跑一边验证)、只同步几张表给报表库、多活/双向同步、以及 CDC 上游。什么时候不该用:纯粹为了高可用做只读副本,用物理复制——更快、更可靠、没有 DDL 问题。

备份与 PITR:全备 + 无限重放 WAL

PG 的恢复模型比 MySQL 干净得多:基础备份(pg_basebackup)+ 归档的 WAL = 可以恢复到任意一个时间点。不需要「先恢复全备再算 binlog 位点」,因为 WAL 是连续的字节流,天然接得上。

pg_basebackup
DROP TABLE
周日 02:00
基础备份
WAL 持续归档
archive_command / pgBackRest
周三 14:32
恢复到—
丢了什么—
做法—
# 主库:开启归档(改完 reload 即可,不用重启)
archive_mode = on
archive_command = 'pgbackrest --stanza=main archive-push %p'
wal_level = replica            -- 至少要 replica

# 做一次基础备份
pg_basebackup -h primary -D /backup/base -Ft -z -Xs -P

# 恢复:在目标实例上放一个 recovery.signal + 配置
restore_command = 'pgbackrest --stanza=main archive-get %f "%p"'
recovery_target_time = '2026-11-12 14:31:59+08'
recovery_target_action = 'promote'    -- 到了就停在那儿并变成可写

三个必须理解的行为

① 恢复点之后的数据是「被丢弃」,不是「回滚」。PG 到 recovery_target_time 就停止重放并 promote,之后的 WAL 全部不要。所以要恢复到新实例上验证,确认无误再切流量——别在原实例上直接恢复。

② recovery_target_action 默认是 pause,不是 promote。恢复完会停在只读状态等你确认。这是好事(可以检查数据),但自动化脚本里必须显式设置,否则实例一直不可写,看起来像「恢复失败」。

③ 主备切换后的分叉要靠 pg_rewind。旧主库被提升过的备库甩开了一段 WAL,直接当备库连上去会失败(时间线不一致)。pg_rewind 会把旧主库「倒带」到分叉点,再让它作为备库追上去——不需要重新做全量基础备份。

决定成败的不是恢复命令,是两件平时的事:
① 归档必须有人监控失败。archive_command 失败只会不断重试,pg_stat_archiver 里的 failed_count 一直涨,而 WAL 会在 pg_wal 里堆积直到磁盘满。你必须监控这个数。
② 必须定期做恢复演练。备份存在 ≠ 能恢复。版本不匹配、归档缺一个段、权限不对,都会让你在事故现场才发现。

工具怎么选

工具类型适用
pg_dump / pg_dumpall逻辑备份小库、单表迁移、跨版本升级、导出给分析
pg_basebackup物理基础备份标准做法,但不支持增量
pgBackRest物理备份套件支持增量、并行、压缩、保留策略、校验。生产首选
Barman物理备份套件多实例集中管理,云上常见
pg_rewind不是备份切换后让旧主库重新加入,避免重做全备
高可用这一层:PG 内核只提供流复制和 promote 能力,不提供自动故障转移。自动切换靠外部组件:Patroni(最主流,用 etcd/Consul 做共识 + 自己写 HA 逻辑)、repmgr(更轻,配置简单)、pg_auto_failover。云上用托管服务的就不用自己搭。

面试里被问「PG 怎么做高可用」,答「流复制 + Patroni + etcd,配 VIP 或 HAProxy 做流量切换」,再说一句「要配 fencing 防止脑裂」,基本就完整了。