KernelRanch
返回文章列表

PostgreSQL 时间点恢复演练:pg_basebackup + WAL 归档 + recovery_target_time 的一次完整跑通

2026-11-10 #数据库

时间点恢复能不能用,不取决于备份命令有没有返回 0,而取决于归档链和恢复目标能不能对上。2026-11-10 在 Windows 11 的 Docker Desktop 上,用一台 768 MB 内存的 postgres:18 容器把整条链跑了一遍:0.75 秒、3.2 MB 的基线备份,128 MB 的 WAL 归档,3.22 秒恢复到 DROP TABLE 之前,被删掉的 4 万行原样回来。同一套环境里我又故意制造了三次失败恢复,拿到三段原始报错——它们比成功日志更能说明一次备份离"能恢复"还有多远。

1. 演练环境

项值
宿主机Windows 11 + Docker Desktop 29.4.3(WSL2 后端)
镜像postgres:18 → PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2)
容器内存上限768 MB,primary / restore / 失败演练各一个
PGDATA/var/lib/postgresql/18/docker
网络docker network create pitr-net,容器按名字互访;不做宿主端口映射,全部 docker exec 进出
时区容器内 UTC,下文所有时间戳都带 +00

不做端口映射是硬约束:Windows 的动态端口保留段会让 -p 5432:5432 时不时 bind 失败,而这次演练不需要从宿主机连数据库。备份、灌数、恢复、查询全在容器内完成。

2. 先让归档跑起来,再谈备份

wal_level 在这个镜像里默认就是 replica,archive_mode 默认 off。这两个参数都只能在启动时改,所以流程是:改配置、重启、验证。

wal_level = replica
archive_mode = on
archive_command = 'test ! -f /archive/%f && cp %p /archive/%f'
archive_timeout = 60s

archive_command 里那个 test ! -f 不是装饰。文档要求归档端拒绝覆盖已存在的文件:两次备份或两台服务器把同名段写进同一目录时,覆盖会静默毁掉归档链。加上 test 后重复归档返回非零,PostgreSQL 保留原段并重试。

重启后验证:

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

archive_timeout = 60s 让低写入量的空闲期也能把当前段切出去,避免"事务已提交、段还没归档"的窗口被拉长。代价是没写满 16 MB 的段也按整段大小落盘,归档体积虚高。

3. 基线备份:0.75 秒,3.2 MB

备份在灌数据之前做,集群基本是空的:

pg_basebackup -D /backup -Ft -z -X stream -l pitr_drill_base

结果:

产物字节数说明
base.tar.gz3,139,782数据目录压缩包
pg_wal.tar.gz17,091备份期间流式收到的那一段 WAL
backup_manifest137,786文件清单,供 pg_verifybackup 校验
合计3,294,6593.2 MB

耗时 0.75 秒,输出里先出现一行 waiting for checkpoint:pg_basebackup 会先触发一次检查点,再以该检查点的 LSN 作为备份起点。这次起点是 0/2000028,落在段 000000010000000000000002,归档目录里那个 000000010000000000000002.00000028.backup 就是它的历史文件。后面算归档链够不够,起点段要从这个文件名里读,不能从备份命令执行前的 pg_current_wal_lsn() 猜。

备份产物的自校验先跑一次,会遇到一个 tar 格式特有的坑:

pg_verifybackup: error: pg_waldump cannot read tar files
pg_verifybackup: hint: You must use -n/--no-parse-wal when verifying a tar-format backup.

加上 -n 之后 0.24 秒通过,输出 backup successfully verified。这一步只证明"备份文件没被改坏",不证明"能恢复到某个时间点",两件事差着一整条 WAL 归档。

4. 灌数据,然后制造一次 DROP

pgbench -i -s 5

pgbench 自报 0.55 秒,从外面量 0.85 秒,得到 500,000 行 pgbench_accounts,库大小 82 MB。接着建一张业务表模拟订单,灌两遍凑到 4 万行,然后记下"安全点":

事件时间戳(UTC)LSN
插入完成、记安全点2026-11-10 05:20:14.7428060/71C3DC8
命名恢复点 before_drop同上0/71C3E30
误操作 DROP TABLE pitr_demo2026-11-10 05:20:25.2932060/71C3E68
DROP 完成2026-11-10 05:20:25.4160300/71C4A10

安全点和误操作之间隔了约 10.7 秒,做时间点恢复够用。然后强制切段并检查点,把最后一段推进归档:

select pg_switch_wal();   -- 0/71C4A60
checkpoint;
select pg_switch_wal();   -- 0/8000110

稍等几秒查归档器:

archived_count = 9, failed_count = 0
last_archived_wal = 000000010000000000000008

归档目录里 9 个文件、128.0 MB:段 1 到段 8,外加那个 .backup。注意体积对比——备份 3.2 MB,归档 128 MB,差 40 倍。恢复时要重放的数据几乎全在归档里,不在备份里;这也是"备份成功"最容易被误读的地方。

5. 从备份 + 归档恢复到 05:20:18

5.1 恢复目录

恢复实例不共用 primary 的容器,用一个独立数据卷装解包后的数据目录,另一个卷装归档副本,restore_command 从后者取段:

rm -rf $PGDATA/*
tar -xzf base.tar.gz    -C $PGDATA
mkdir -p $PGDATA/pg_wal
tar -xzf pg_wal.tar.gz  -C $PGDATA/pg_wal

tar 格式下 -X stream 把 WAL 单独放进 pg_wal.tar.gz,数据包不带这些段;先 mkdir -p $PGDATA/pg_wal 再解,省得目录不存在时解包失败。解完把属主改回 postgres、权限收到 700,否则 postmaster 直接拒绝启动。

5.2 三个文件决定恢复行为

# $PGDATA/postgresql.auto.conf
restore_command = 'cp /archive/%f %p'
recovery_target_time = '2026-11-10 05:20:18+00'
recovery_target_action = 'promote'
touch $PGDATA/recovery.signal

recovery.signal 是空的,它只表示"这次启动要做定向恢复"。recovery_target_time 要晚于基础备份的结束时间;本次目标 05:20:18 比恢复日志里 last known up at 2026-11-10 05:19:55 UTC 还晚 23 秒,满足。recovery_target_action 默认是 pause,会停下来等人工确认;演练里用 promote 直接完成恢复并切新时间线。recovery_target_inclusive 保持默认 on,即包含目标时间点上的提交。

5.3 恢复日志逐行读

05:24:26.788 UTC [32] LOG:  restored log file "000000010000000000000002" from archive
05:24:26.808 UTC [32] LOG:  redo starts at 0/2000028
05:24:26.832 UTC [32] LOG:  consistent recovery state reached at 0/2000120
05:24:27.168 UTC [32] LOG:  recovery stopping before commit of transaction 777, time 2026-11-10 05:20:25.293676+00
05:24:27.168 UTC [32] LOG:  last completed transaction was at log time 2026-11-10 05:20:14.538258+00
05:24:27.207 UTC [32] LOG:  selected new timeline ID: 2
05:24:27.531 UTC [1] LOG:  database system is ready to accept connections

三行关键:

  • redo starts at 0/2000028 与备份历史文件里的段号对得上,说明恢复确实从备份检查点开始重放,而不是从更晚的地方。
  • recovery stopping before commit of transaction 777 停下的正是执行 DROP 的那个事务,时间 05:20:25.293676;last completed transaction 停在 05:20:14.538258,即插入提交的时刻。目标时间 05:20:18 落在两个提交之间,不需要精确命中某个 LSN。
  • selected new timeline ID: 2 表示恢复结束形成了一条新时间线,原来的时间线 1 的 WAL 不会被覆盖。

5.4 数据核对

select count(*), sum(id) from pitr_demo;
-- 40000 | 400020000
select count(*) from pgbench_accounts;
-- 500000
select pg_last_wal_replay_lsn(), (select timeline_id from pg_control_checkpoint());
-- 0/71C45D8 | 2

4 万行回来了,sum(id) 与插入前的算术值 400,020,000 一致,pgbench 的 50 万行也一并重放出来。pg_last_wal_replay_lsn() 报 0/71C45D8,落在段 7,比 DROP 事务的 LSN 0/71C3E68 靠前,符合"停在误操作之前"的预期。

耗时:从 docker run 到 pg_isready 返回 0 是 3.22 秒;按日志口径,postmaster 启动 05:24:24.569,ready to accept connections 05:24:27.531,2.96 秒,其中重放 WAL 约 0.74 秒。82 MB 的库、6 个归档段,启动和初始化占了剩下的大头。

6. 三次故意失败的恢复

6.1 目标时间晚于 WAL 末尾

把 recovery_target_time 改成 2026-11-10 06:00:00+00,其余不动:

LOG:  starting point-in-time recovery to 2026-11-10 06:00:00+00
LOG:  redo done at 0/80000F8 system usage: CPU: user 0.05 s, system 0.10 s, elapsed 0.34 s
LOG:  last completed transaction was at log time 2026-11-10 05:20:25.293676+00
FATAL:  recovery ended before configured recovery target was reached

redo done at 0/80000F8 就是归档的末尾。恢复把能读的段全读完,发现离目标还差得远,直接 FATAL 退出,容器 exit code 1。这类失败容易被误判成成功:它启动过、读过归档、也重放过 WAL,唯一没做到的是到达目标点。

6.2 归档里少一个段

从归档目录删掉 000000010000000000000004,目标是合法的 05:20:18:

cp: cannot stat '/archive/000000010000000000000004': No such file or directory
cp: cannot stat '/archive/000000010000000000000004': No such file or directory
LOG:  redo done at 0/3FFE680 system usage: CPU: user 0.01 s, system 0.01 s, elapsed 0.08 s
FATAL:  recovery ended before configured recovery target was reached

重放在段 3 的末尾 0/3FFE680 就断了。restore_command 返回非零对 PostgreSQL 只意味着"这段不在归档里",它会回头去 pg_wal/ 找,找不到就认为 WAL 放完了,于是又落到同一条 FATAL。缺段和"目标太远"在外观上是同一条报错,定位得靠 redo done at 的 LSN 落在哪一段——这也是恢复演练要保留完整日志的原因。

顺带一个容易被忽略的现象:日志里还有一行 cp: cannot stat '/archive/00000003.history'。时间线历史文件缺失时恢复照样能走,但要切到某条历史时间线,它就是前提。

6.3 段在归档里,但内容坏了

把段 4 补回去,然后截成 0 字节:

LOG:  redo starts at 0/2000028
FATAL:  archive file "000000010000000000000004" has wrong size: 0 instead of 16777216

这次报错足够精确,直接指出文件名、实际大小和期望大小。恢复在打开段时先校验长度,不合格立刻停,不会带着半个段去重放。三种失败里这一种的日志最有诊断价值,因为它把"归档里有这个文件"和"这个文件可用"区分开了。

7. 三个验收点

备份命令返回 0 之后,至少还要过这三关,才谈得上"能恢复"。

验收点一,归档链是否闭合。从 .backup 历史文件名里读出起始段(这次是 000000010000000000000002),到恢复目标所在段(000000010000000000000007),中间每个段都得在归档里连续存在。这次归档里有段 1 到段 8,链是闭合的;如果只有段 2 到段 5,恢复就会像 6.2 那样断在中间。

验收点二,备份产物自身可校验。pg_verifybackup -n 这次 0.24 秒通过。它证明 tar 包和 manifest 一致,证明不了归档齐全和恢复目标可达;两项检查覆盖的故障不同:备份被改坏,与归档缺段。

验收点三,真跑一次带 recovery_target_time 的恢复并核对数据。看三样东西:日志里的 recovery stopping 与 last completed transaction 是否夹在预期的两个事件之间;pg_last_wal_replay_lsn() 是否落在预期的段;业务表的行数和校验值是否等于误操作前的值。这次 4 万行、sum(id)=400020000,与插入时一致。

第一次尝试恢复时我漏挂了一个卷,归档根本没进容器。日志是这样的:

LOG:  starting point-in-time recovery to 2026-11-10 05:20:18+00
LOG:  redo starts at 0/2000028
LOG:  redo done at 0/2000120
FATAL:  recovery ended before configured recovery target was reached

redo done at 0/2000120 说明它只重放了 pg_wal.tar.gz 里自带的那一小段——备份自带的 WAL 只够把集群带到一致性点,再往后一个字节都没有。备份成功、解包成功、postmaster 也启动过,最后照样是一无所有。这一条比任何说理都直接:PITR 的可用性由归档决定,不由备份决定。

8. 这次踩到的两个坑

第一个是卷挂载的层级。恢复目录用命名卷挂在 /var/lib/postgresql/18/docker,归档一开始放在 /var/lib/postgresql/18/archive,看着像同一棵树,实际落在容器的可写层里,容器删掉归档就没了。挪到独立卷 /archive 之后才对上。

第二个是恢复后的实例把文件写回了原归档目录。恢复完成后实例按 postgresql.conf 里的 archive_command 继续归档,第一件事就是把新的 00000002.history(50 字节)写进同一个 /archive,pg_stat_archiver 显示 archived_count = 1。这次没造成损坏,但时间线文件混进原归档之后,再想审计"哪条时间线的 WAL 从哪来"就得多花功夫。生产上恢复实例的 archive_command 应该指向新目录,或者先关掉归档再验证。

9. 对照数据

指标实测值说明
基线备份耗时 / 体积0.75 s / 3,294,659 B空库,-Ft -z -X stream
归档体积9 个文件 / 128.0 MB段 1–8 + .backup,du 显示 129M
pgbench -i -s 50.85 s(自报 0.55 s)500,000 行,库 82 MB
备份校验0.24 s,backup successfully verifiedpg_verifybackup -n
恢复耗时3.22 s(日志口径 2.96 s)其中 WAL 重放约 0.74 s
recovery_target_time2026-11-10 05:20:18+00夹在两次提交之间
实际停止点事务 777 之前最后提交 05:20:14.538258+00
停止时 LSN / 时间线0/71C45D8 / 2段 7,新时间线
恢复后数据40,000 行,sum(id)=400,020,000与插入时一致
失败 A(目标过远)FATAL: recovery ended before configured recovery target was reachedredo done at 0/80000F8
失败 B(缺段)同上报错 + cp: cannot stat '/archive/…0004'redo done at 0/3FFE680
失败 C(段损坏)FATAL: archive file "…0004" has wrong size: 0 instead of 16777216长度校验先拦下

小结

这次演练里最值钱的数字不是 3.22 秒的恢复时间,而是备份 3.2 MB、归档 128 MB 这个比例。它说明备份只是个起点,真正决定能恢复到哪一刻的是归档链是否连续、是否完整。目标时间夹在两次提交之间就够了,不用精确到某个 LSN;但归档少一段,恢复就会退回同一条 FATAL,外观上和"目标太远"一模一样。

三个验收点各有分工:段号连续性检查归档,pg_verifybackup 检查备份产物,真跑一次带目标的恢复检查整条链。前两项都是秒级操作,只有第三项会暴露"备份自带的 WAL 只够到一致性点"这类问题。演练的价值也在这里:失败要发生在测试环境里,而不是在误删之后。

三次失败恢复拿到的报错各不相同,恰好覆盖了三种典型故障——目标不可达、归档断链、归档损坏。把它们连同成功日志一起留在演练记录里,下一次真出事时,第一眼看日志的 redo done at 和 recovery stopping 就能判断属于哪一类。

数据来源与检索时间

官方文档检索时间 2026-11-10,全部数字来自 2026-11-10 05:19–05:26 UTC 的实测。

来源URL本次引用的具体数据
PostgreSQL 18 文档《25.3 Continuous Archiving and Point-in-Time Recovery (PITR)》https://www.postgresql.org/docs/18/continuous-archiving.html归档命令不得覆盖已有文件、restore_command 非零返回表示"归档中没有"、恢复目标需晚于基础备份结束时间、时间线与历史文件的产生时机
PostgreSQL 18 文档《pg_basebackup》https://www.postgresql.org/docs/18/app-pgbasebackup.html-Ft 生成 base.tar 与独立的 pg_wal.tar、-X stream 需要两条复制连接、备份开始时先做检查点、-z 只对 tar 格式生效
PostgreSQL 18 文档《19.5 Write Ahead Log》https://www.postgresql.org/docs/18/runtime-config-wal.htmlwal_level/archive_mode 只能在启动时设置、archive_command 的 %p/%f 语义、recovery_target_time 与 recovery_target_action 的取值与行为、recovery_target_inclusive 默认 on

核验说明

跑过的部分:单机 primary 开归档、pg_basebackup、pgbench 灌数、DROP TABLE 误操作、四次恢复(一次成功、三次故意失败;成功那次核对了 4 万行与 sum(id))、pg_verifybackup -n、归档器计数、LSN 到段号的映射。

没跑的部分:流复制 standby 未搭建,"归档 + 流复制"混用、pg_rewind 回切、recovery_target_lsn 与命名恢复点恢复都只有文档依据;表空间、增量备份(--incremental + pg_combinebackup)和 archive_library 未测;归档一直在本地,restore_command 用 cp,命中页缓存,换网络存储会更慢。

局限:数据集只有 82 MB、6 个归档段,重放不到 1 秒,不能外推到几百 GB 的库;备份与恢复同机同盘,没有跨机传输开销;recovery_target_action 用的是 promote,没验证 pause 下人工确认再 resume 的流程;三次失败都是单因素注入,没测"目标过远 + 缺段"叠加时的报错优先级。另外,第 6.2 节 00000003.history 缺失的那条日志出现在恢复实例生成时间线 2 之后,纯时间线 1 的恢复不会请求它。