复制与时间点恢复
上一节的 WAL 是为崩溃恢复设计的,但它还有个副作用:如果把 WAL 流实时送到另一台机器上重放,那台机器就是一份实时副本。 Postgres 的复制和备份体系全部建立在这个观察上——下面三条路径,源头都是同一份 WAL:
flowchart LR
subgraph primary["主库"]
direction TB
w(["写事务"]) --> wal["WAL"]
end
wal == "① 物理复制<br/>原样传 WAL 字节" ==> standby[("备库<br/>整库 · 只读 · 同版本")]
wal == "② 逻辑解码<br/>解析成行变更" ==> slot["复制槽"]
slot ==> sub[("订阅端<br/>按表 · 可写 · 可跨版本")]
wal -. "③ 归档" .-> arch[["WAL 归档"]]
base[("基础备份")] -.-> pitr[("恢复到任意时刻")]
arch -. "重放到指定时间点" .-> pitr
style w fill:#8a8f9c,color:#fff,stroke:none
style wal fill:#2a6796,color:#fff,stroke:none
style standby fill:#4d9bd4,color:#fff,stroke:none
style slot fill:#d8933a,color:#fff,stroke:none
style sub fill:#d8933a,color:#fff,stroke:none
style arch fill:#3b8f6d,color:#fff,stroke:none
style base fill:#3b8f6d,color:#fff,stroke:none
style pitr fill:#3b8f6d,color:#fff,stroke:none
物理复制:字节级的一模一样
Section titled “物理复制:字节级的一模一样”SELECT name, setting, short_desc
FROM pg_settings
WHERE name IN ('wal_level', 'max_wal_senders', 'max_replication_slots', 'hot_standby')
ORDER BY name;物理复制(流复制)直接传输 WAL 记录,备库逐条重放。结果是备库和主库在磁盘字节层面完全一致。
特点:
- ✅ 开销最低,备库只是重放 WAL,不需要解析 SQL;
- ✅ 备库可读(
hot_standby = on),适合分担只读查询; - ❌ 全库复制,不能只复制一张表;
- ❌ 备库完全只读,连临时表都建不了;
- ❌ 主备版本必须一致,无法用于大版本升级。
synchronous_standby_names = '' → 异步:主库提交不等备库synchronous_standby_names = 'standby1' → 同步:主库等备库确认才返回同步复制保证「提交成功 = 至少两台机器有数据」,代价是每次提交都要付一个网络往返,而且备库挂掉会让主库写入卡死(除非配了多个备库和 ANY n)。
synchronous_commit 在复制场景下有更细的档位:
| 值 | 提交时等待到 |
|---|---|
off |
不等,本地 WAL 都不等 |
local |
本地 WAL 落盘 |
remote_write |
备库收到并写入 OS 缓存 |
on |
备库 WAL 落盘(默认) |
remote_apply |
备库重放完毕——只有这档能保证「写完立刻去备库读能读到」 |
逻辑复制:按表、按行
Section titled “逻辑复制:按表、按行”逻辑复制把 WAL 解码成逻辑的行变更(「表 t 插入了一行,值是 …」),再在订阅端回放。
-- 发布端CREATE PUBLICATION my_pub FOR TABLE orders, order_items;
-- 订阅端CREATE SUBSCRIPTION my_sub CONNECTION 'host=primary dbname=app' PUBLICATION my_pub;特点正好和物理复制互补:
- ✅ 可以只复制部分表,甚至只复制部分行(PG 15 起支持
WHERE); - ✅ 跨大版本——这是零停机大版本升级的标准方案;
- ✅ 订阅端是可写的,可以有自己的索引、自己的额外表;
- ❌ 开销高得多(解码 + 重放 SQL);
- ❌ 不复制 DDL——主库加了一列,订阅端不会自动跟上,得手工同步;
- ❌ 复制的表必须有副本标识(主键,或显式
REPLICA IDENTITY FULL),否则UPDATE/DELETE无法复制。
| 物理复制 | 逻辑复制 | |
|---|---|---|
| 粒度 | 整个实例 | 表级 / 行级 |
| 开销 | 低 | 高 |
| 跨版本 | ❌ | ✅ |
| 备库可写 | ❌ | ✅ |
| 复制 DDL | ✅(本来就是字节复制) | ❌ |
| 典型用途 | 高可用、只读扩展 | 升级、CDC、数据集成 |
复制槽:救命也要命
Section titled “复制槽:救命也要命”复制槽(replication slot)解决一个真实问题:备库断线期间,主库不能把它还没收到的 WAL 删掉。
槽的作用就是让主库记住「这个消费者读到哪了」,并保证在此之前的 WAL 不被回收。
时间点恢复(PITR)
Section titled “时间点恢复(PITR)”PITR 的组成只有两样:一个基础备份 + 从那时起的连续 WAL 归档。
基础备份(周日凌晨) │ ├─ WAL ─ WAL ─ WAL ─ WAL ─ WAL ─ WAL ─→ 现在 │ ↑ │ 想恢复到这个时刻 └──────── 重放到这里停下 ──┘归档配置:
archive_mode = onarchive_command = 'test ! -f /archive/%f && cp %p /archive/%f'做基础备份:
pg_basebackup -D /backup/base -Fp -Xs -P恢复到指定时刻:
# postgresql.confrestore_command = 'cp /archive/%f %p'recovery_target_time = '2024-06-15 14:30:00+08'recovery_target_action = 'promote'再创建一个 recovery.signal 空文件,启动即可。
除了 recovery_target_time,还能按 recovery_target_xid(恢复到某个事务之前)、recovery_target_lsn、recovery_target_name(事先用 pg_create_restore_point() 打的标记)来定位。
先自己回答,再点开对照。
物理复制和逻辑复制各自的三个优势和三个限制是什么?
物理复制(原样传 WAL 字节,备库逐条重放,磁盘字节层面完全一致):
- 优势:开销最低(不解析 SQL);备库可读(
hot_standby = on);DDL 天然跟随(本来就是字节复制)。 - 限制:只能整库复制;备库完全只读,连临时表都建不了;主备版本必须一致。
逻辑复制(把 WAL 解码成行变更,在订阅端回放):
- 优势:可以只复制部分表甚至部分行(PG 15 起支持
WHERE);可跨大版本;订阅端可写,能有自己的索引和额外的表。 - 限制:开销高得多(解码 + 重放);不复制 DDL;被复制的表必须有副本标识(主键或显式
REPLICA IDENTITY FULL),否则UPDATE/DELETE无法复制。
常见错误:以为「逻辑复制更灵活,所以是更现代的方案,应该优先选」。两者是互补而非替代。做同版本高可用和只读扩展,物理复制仍然是默认答案——用逻辑复制反而要自己解决 DDL 同步、副本标识,还要为每一行付解码成本。逻辑复制的主场是升级、CDC、数据集成。
常见错误二:以为「备库可读」是逻辑复制才有的能力。物理备库同样可读,区别在可写不在可读——物理备库连一张临时表都建不了。
想做零停机的大版本升级,该用哪种复制?为什么另一种不行?
用逻辑复制。它传的是解码之后的行变更(「表 t 插入了一行,值是 …」),订阅端怎么把这些变更落盘是它自己的事,因此主从可以是不同大版本。流程是:新版本实例做订阅端追平数据,然后切流量。
物理复制不行,因为它传的是 WAL 字节,备库直接照着改自己的页面。不同大版本之间页面格式和 WAL 记录格式都可能变化,字节级复制无从谈起——所以主备版本必须一致。
常见错误:以为逻辑复制配好之后就可以放着不管,等追平了直接切。两个坑会在切换窗口里咬人:
- 它不复制 DDL。追平期间主库上任何
ALTER TABLE都不会到订阅端,得手工在两边同步。 - 没有主键也没有
REPLICA IDENTITY的表,UPDATE/DELETE根本复制不过去。这类表要在开始之前就全部找出来处理掉,而不是等到数据对不上才发现——那时已经在切换窗口里了。
synchronous_commit = remote_apply 相比 on 多保证了什么?
on 保证备库的 WAL 已经落盘;remote_apply 保证备库已经重放完毕。多出来的正是「落盘」到「应用」这一段。
结果上的差别只有一个,但很关键:只有 remote_apply 能保证「写完立刻去备库读能读到」。
常见错误:在读写分离架构里,看到 synchronous_commit = on 就认为「提交返回时备库已经有这条数据了」,于是写完立刻把请求路由到备库读。备库确实「有」了——WAL 躺在它的磁盘上——但还没「应用」,查询看不见。
这是读写分离最经典的一类幽灵 bug:用户提交表单后跳转到详情页,详情页空白;刷新一下又好了。它只在主备之间有重放延迟时出现,本地开发和低负载测试环境几乎必然复现不出来。
代价也要记住:remote_apply 让每次提交都要等备库把这条记录重放完,是所有档位里延迟最高的。
复制槽解决什么问题?它可能造成哪两种事故?怎么监控和兜底?
解决的问题:消费者断线期间,主库不能把它还没收到的 WAL 删掉。 槽让主库记住「这个消费者读到哪了」,并保证在此之前的 WAL 不被回收。
两种事故:
- 撑爆磁盘。 一个停滞的槽(CDC 服务下线了但没人删槽)会让主库不敢回收任何 WAL,
pg_wal一路堆积,最终No space left on device,数据库直接 PANIC 停止服务。 - 钉住
VACUUM。 逻辑复制槽还需要旧的行版本来解码,一个停滞的逻辑槽会同时制造第二个问题——表膨胀。
监控:
SELECT slot_name, slot_type, active, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS 落后量FROM pg_replication_slotsORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC;兜底:max_slot_wal_keep_size = '64GB'(PG 13 起),超过就让槽失效——宁可断掉复制,也不撑爆磁盘。
常见错误:设了 max_slot_wal_keep_size 就认为复制槽的风险已经解除。它只兜住了第一种事故。逻辑槽钉住 VACUUM 造成的表膨胀完全不受这个参数保护——WAL 被限住了,膨胀还在继续长。
常见错误二:把 active = true 当成健康信号。active 只说明有连接挂在这个槽上,不说明它在推进。真正的健康指标是落后量有没有在持续增长。
(事务号回卷那条监控 和这条一样,属于「不看就可能整站宕机」的指标)
PITR 需要哪两样东西?为什么说「没演练过的备份等于没有备份」?
两样:一个基础备份(pg_basebackup)+ 从那一刻起连续不断的 WAL 归档(archive_mode = on 加 archive_command)。缺一不可——只有基础备份就只能回到备份那一刻;只有 WAL 归档则没有可以重放的起点。
恢复时用 restore_command 取回归档,配上 recovery_target_time(或 recovery_target_xid / recovery_target_lsn / recovery_target_name)指定停在哪里。
「没演练过的备份等于没有备份」是字面意义上的真理:archive_command 静默失败、归档目录满了、基础备份缺文件——这些问题只有在真正恢复时才会暴露,而那通常是最糟糕的时刻。
常见错误:以为 archive_command 配好了就等于归档在工作。它只是一条 shell 命令,成败取决于返回码和命令本身写得对不对;出问题时的表现往往只是日志里多一行。跑一次真实恢复,是唯一能一次性验证整条链(归档写入 + 归档可读 + 基础备份完整 + 重放能走到底)的方法。
常见错误二:以为「有物理备库就等于有备份」。备库是实时跟随主库的——主库上一条漏了 WHERE 的 DELETE FROM orders 会在几毫秒内忠实地同步到备库。备库防的是硬件故障,PITR 防的是人为错误和逻辑错误,两者不能互相替代。
生产环境别手写这套流程,用 pgBackRest / barman / WAL-G——它们把备份验证、保留策略、增量备份和定期演练恢复都做掉了。