跳转到内容

复制与时间点恢复

准备中…

上一节的 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
SELECT name, setting, short_desc
FROM pg_settings
WHERE name IN ('wal_level', 'max_wal_senders', 'max_replication_slots', 'hot_standby')
ORDER BY name;
⌘/Ctrl + Enter

物理复制(流复制)直接传输 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 备库重放完毕——只有这档能保证「写完立刻去备库读能读到」

逻辑复制把 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、数据集成

复制槽(replication slot)解决一个真实问题:备库断线期间,主库不能把它还没收到的 WAL 删掉。

槽的作用就是让主库记住「这个消费者读到哪了」,并保证在此之前的 WAL 不被回收。

一个被遗忘的复制槽如何撑爆磁盘编排回放 · 非实时执行
Session A
Session B
0 / 6

PITR 的组成只有两样:一个基础备份 + 从那时起的连续 WAL 归档

基础备份(周日凌晨)
├─ WAL ─ WAL ─ WAL ─ WAL ─ WAL ─ WAL ─→ 现在
│ ↑
│ 想恢复到这个时刻
└──────── 重放到这里停下 ──┘

归档配置:

archive_mode = on
archive_command = 'test ! -f /archive/%f && cp %p /archive/%f'

做基础备份:

Terminal window
pg_basebackup -D /backup/base -Fp -Xs -P

恢复到指定时刻:

# postgresql.conf
restore_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_lsnrecovery_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 不被回收。

两种事故:

  1. 撑爆磁盘。 一个停滞的槽(CDC 服务下线了但没人删槽)会让主库不敢回收任何 WAL,pg_wal 一路堆积,最终 No space left on device,数据库直接 PANIC 停止服务。
  2. 钉住 VACUUM 逻辑复制槽还需要旧的行版本来解码,一个停滞的逻辑槽会同时制造第二个问题——表膨胀。

监控:

SELECT slot_name, slot_type, active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS 落后量
FROM pg_replication_slots
ORDER 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 = onarchive_command)。缺一不可——只有基础备份就只能回到备份那一刻;只有 WAL 归档则没有可以重放的起点。

恢复时用 restore_command 取回归档,配上 recovery_target_time(或 recovery_target_xid / recovery_target_lsn / recovery_target_name)指定停在哪里。

「没演练过的备份等于没有备份」是字面意义上的真理:archive_command 静默失败、归档目录满了、基础备份缺文件——这些问题只有在真正恢复时才会暴露,而那通常是最糟糕的时刻。

常见错误:以为 archive_command 配好了就等于归档在工作。它只是一条 shell 命令,成败取决于返回码和命令本身写得对不对;出问题时的表现往往只是日志里多一行。跑一次真实恢复,是唯一能一次性验证整条链(归档写入 + 归档可读 + 基础备份完整 + 重放能走到底)的方法。

常见错误二:以为「有物理备库就等于有备份」。备库是实时跟随主库的——主库上一条漏了 WHEREDELETE FROM orders 会在几毫秒内忠实地同步到备库。备库防的是硬件故障,PITR 防的是人为错误和逻辑错误,两者不能互相替代。

生产环境别手写这套流程,用 pgBackRest / barman / WAL-G——它们把备份验证、保留策略、增量备份和定期演练恢复都做掉了。