PostgreSQL 复制与高可用:WAL 是唯一的日志

PG 的复制只有一条主线:WAL。崩溃恢复要用它,主从复制也用它,PITR 还用它。没有第二套日志,也就没有 MySQL 那种「redo 和 binlog 要两阶段提交」的问题。

这一篇讲清五件事:WAL 怎么流过去、同步到什么程度、复制槽怎么保命又怎么害人、什么时候必须用逻辑复制、出事了怎么恢复。

▶ 打开第二辑交互版图解

一、为什么 PG 的复制是「物理」的

MySQL 的主从传的是 binlog——里面记的是逻辑变更(哪一行、改成了什么),从库用 SQL 线程重放。PG 传的是 WAL,里面记的是**「第 3 号数据块的第 5 个字节改成了 X」**,从库照抄。

这个差别看着只是实现细节,实际上决定了六件事:

能力 PG 物理流复制 MySQL binlog 复制
从库能有不同索引吗 不能,数据文件必须逐字节一致 能
能跨大版本吗 不能,页格式变了就重放不了 能(有一定限制)
能只复制几张表吗 不能,要整实例 能
主从会逻辑漂移吗 不会,物理一致 会(非确定性函数、触发器、版本差异)
延迟怎么度量 LSN 差(字节) Seconds_Behind_Master(秒)
从库能写吗 不能 能(但不该)

第四行是 PG 的一个隐形优势:不需要 pt-table-checksum 那类工具去校验主从一致性,物理上就不可能不一致。第三行则是它的硬限制——「只同步一张表给报表库」这种需求,只能走逻辑复制。

二、三段进程,一条字节流

主库                                   从库
WAL buffer ──刷盘──> pg_wal/ 段文件
                        │
                   walsender ──复制协议(TCP 5432)──> walreceiver
                                                        │
                                                   写本地 pg_wal
                                                        │
                                                   startup 进程重放
                                                        │
                                                   数据文件

三个要点:

① walsender 不解析 SQL,连事件都不解析,纯搬字节。 所以它比 MySQL 的 dump 线程更轻。

② 从库的 startup 进程是「改页」,不是「跑 SQL」。 它按 WAL 记录直接修改数据块,因此不经过 SQL 层——这也解释了为什么从库不能有不同索引:它根本不知道索引是什么,只是把主库的字节抄过来。

③ 从库能提供只读查询(hot standby),读到的是「已经重放到的那个位置」的数据。所以延迟看的不是「收到多少」,而是「重放到哪」。

看延迟要用 replay_lag,不是 write_lag:

SELECT client_addr, state, sync_state,
       write_lag,   -- 从库收到并写进 WAL buffer 用了多久
       flush_lag,   -- 从库刷到自己磁盘用了多久
       replay_lag   -- 从库重放完用了多久  ← 这个才决定读到多旧的数据
FROM pg_stat_replication;

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

synchronous_commit 不是「开/关」,它决定主库什么时候才告诉客户端「提交成功」:

取值 主库等谁 主库崩了丢不丢 从库能立刻读到吗
off 谁都不等 丢最近若干毫秒 不能
local 本地刷盘 不丢 不能
remote_write 从库写进 WAL buffer 不丢;但主备同时挂会丢 不能
on(默认) 从库刷盘 不丢 不能(重放可能滞后)
remote_apply 从库重放完成 不丢 能

关键前提:synchronous_commit 只有在配了 synchronous_standby_names 时才真的在等备库。没配备库时它等价于 local——很多人以为「我开了同步复制」,其实只改了一个参数。

# 等 1 个即可(性能优先)
synchronous_standby_names = '1 (standby1, standby2)'
# 任意 2 个(quorum,避免单点失联卡死)
synchronous_standby_names = 'ANY 2 (s1, s2, s3)'

和 MySQL 半同步最关键的一个差别

MySQL 的半同步超时之后会自动降级为异步;PG 不会。

PG 的同步备库一旦失联,主库的每次提交都会一直等下去,业务全部卡在 COMMIT 上。想降级只能手工把 synchronous_standby_names 改空再 reload。

所以生产上配同步复制,必须同时准备两件事:

  1. 用 ANY 2 (s1, s2, s3) 这种 quorum 写法,一个失联不影响
  2. 把「摘掉同步配置」写进 runbook,并且演练过

切换会丢多少数据

这取决于同步级别,也是面试里最容易被追问的一点:

  • 异步:丢失的是「还没传过去的那部分 WAL」,可能几秒到几分钟
  • remote_write:主库确认的都已到达备库内存,但备库还没刷盘——备库也掉电会丢
  • on:主库确认的都已落到备库磁盘,单点故障不丢
  • remote_apply:不仅能读到,而且已经在备库应用完

四、复制槽:保命,也能要命

WAL 段文件会被循环回收。但主库怎么知道「备库还没收到,这一段不能删」?靠复制槽——主库给每个备库记一个书签:这个位置之前的 WAL 一律保留。

它救了什么:备库掉线两小时再回来,还能从断点续上,不用重做全量。

它害了什么:备库永远不回来,主库就一直保留 WAL,直到 pg_wal 把磁盘填满,主库变成只读,业务全部写失败。

监控这两个数:

-- 每个槽卡住了多少 WAL(不活跃的槽最危险)
SELECT slot_name, slot_type, active,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained,
       age(xmin), age(catalog_xmin)
FROM pg_replication_slots ORDER BY 4 DESC NULLS LAST;

-- pg_wal 目录总大小
SELECT pg_size_pretty(sum(size)) FROM pg_ls_waldir();

逻辑槽比物理槽更危险,因为它还要保留「某个时刻的系统表视图」,会同时阻止 VACUUM 清理系统表。一个被遗忘的逻辑槽,几周内能把库推到事务 ID 回卷的边缘。

常见的真实事故是这样的:备库重建失败 → 槽留在那儿没人管 → 三天后磁盘满 → 主库不可写。所以「重建成功就删旧槽」必须写进操作流程。

五、逻辑复制:什么时候非它不可

物理复制只能整实例、只能同版本。这两种需求它做不到:

  • 跨大版本升级(16 跑着,18 在旁边同步,验证没问题再切)
  • 只把三张表同步给报表库 / 数仓
  • 目标库还要写别的表
  • 跨云、跨平台(页大小、架构不同)
-- 主库(要改 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;

四个必须提前知道的行为

① DDL 不同步。 主库加了字段,目标库没有,复制立刻报错并停下,之后所有变更堆积。必须约定「DDL 两边都执行」的流程。

② 序列不同步。 SERIAL / IDENTITY 的当前值不会复制。目标库如果要插入,setval 必须手工对齐。

③ 大事务有内存风险。 逻辑解码要在内存里拼完整事务,一次改几百万行会 OOM。物理复制没这个问题(按字节流走)。

④ 冲突处理很弱。 目标库已有相同主键时,订阅会报错停下。双向同步要靠额外机制(比如 pglogical 或应用层处理)。

六、备份与 PITR:模型比 MySQL 干净

PG 的恢复模型只有一句话:基础备份 + 归档的 WAL = 能恢复到任意时间点。不需要算 binlog 位点,因为 WAL 是连续的字节流,接得上。

# 主库
archive_mode = on
archive_command = 'pgbackrest --stanza=main archive-push %p'
wal_level = 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'

三个容易踩的行为

① recovery_target_action 默认是 pause,不是 promote。 恢复完会停在只读状态等你确认,自动化脚本里不显式设置,实例会一直不可写——看起来像恢复失败。

② 恢复点之后的数据是「被丢弃」,不是「回滚」。 所以要恢复到新实例上验证,确认无误再切流量。

③ 切换后旧主库要用 pg_rewind 才能回去。 它被新主库甩开了一段 WAL,直接连上去会因时间线不一致而失败。pg_rewind 会把它「倒带」到分叉点,不用重做全量备份——这一步能省掉几小时的恢复时间。

决定成败的两件平时的事

归档失败必须有人看。 archive_command 失败只会不断重试,pg_stat_archiver.failed_count 一直涨,而 WAL 在 pg_wal 里堆积直到磁盘满。

必须定期做恢复演练。 备份存在 ≠ 能恢复。缺一个 WAL 段、版本不匹配、权限不对,都会让你在事故现场才发现。

七、高可用:内核只给一半

PG 内核提供流复制和 pg_promote(),但不提供自动故障转移。自动切换靠外部:

组件 说明
Patroni 最主流。用 etcd / Consul / ZooKeeper 做共识,自己实现 HA 状态机,能调 pg_rewind、能配同步复制
repmgr 更轻,配置简单,适合中小规模
pg_auto_failover 微软开源,两节点监控 + 一节点数据
云托管 RDS / Cloud SQL / PolarDB 之类,不用自己搭

必须配 fencing。 脑裂是 PG 高可用最容易出的事:老主库以为自己是主,新主库已经对外服务,两边都在写。防法有两层——Patroni 用 etcd 的租约保证只有一个 leader;再加一层 STONITH(直接关掉老主库的电源或网络),确保它不可能再写。

切换时一定会抖。 应用需要重连,连接池里的连接全断。所以切换流程里必须有「让应用重新解析 DNS / 重新连 VIP」这一步,否则数据库切完了业务还没恢复。

八、和 MySQL 复制的对照(背这张表)

维度 MySQL PostgreSQL
复制日志 binlog(逻辑) WAL(物理)
从库重放方式 SQL 线程重放事件 startup 进程改数据块
同步模式 半同步插件,超时自动降级 synchronous_commit,失联会阻塞主库
防 WAL/日志被删 expire_logs_days / 自动清理 复制槽,可能撑爆磁盘
部分表复制 原生支持 只能逻辑复制
跨版本复制 支持 物理不行,逻辑可以
主从一致性校验 需要 pt-table-checksum 物理必然一致,不需要
自动切换 MHA / Orchestrator / MGR Patroni / repmgr
按时间点恢复 全备 + mysqlbinlog --stop-datetime 基础备份 + recovery_target_time

这一篇讲的是「两台机器之间」。下一篇会进到面试与实战:索引类型怎么选(B-tree / GIN / BRIN 的取舍)、EXPLAIN 怎么读、参数怎么调、分区表、以及 PG 面试里最常被问的那十几个问题。