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。
所以生产上配同步复制,必须同时准备两件事:
- 用
ANY 2 (s1, s2, s3)这种 quorum 写法,一个失联不影响 - 把「摘掉同步配置」写进 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 面试里最常被问的那十几个问题。