PostgreSQL 时间点恢复演练:pg_basebackup + WAL 归档 + recovery_target_time 的一次完整跑通
时间点恢复能不能用,不取决于备份命令有没有返回 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.gz | 3,139,782 | 数据目录压缩包 |
pg_wal.tar.gz | 17,091 | 备份期间流式收到的那一段 WAL |
backup_manifest | 137,786 | 文件清单,供 pg_verifybackup 校验 |
| 合计 | 3,294,659 | 3.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.742806 | 0/71C3DC8 |
命名恢复点 before_drop | 同上 | 0/71C3E30 |
误操作 DROP TABLE pitr_demo | 2026-11-10 05:20:25.293206 | 0/71C3E68 |
| DROP 完成 | 2026-11-10 05:20:25.416030 | 0/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 5 | 0.85 s(自报 0.55 s) | 500,000 行,库 82 MB |
| 备份校验 | 0.24 s,backup successfully verified | pg_verifybackup -n |
| 恢复耗时 | 3.22 s(日志口径 2.96 s) | 其中 WAL 重放约 0.74 s |
recovery_target_time | 2026-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 reached | redo 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.html | wal_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 的恢复不会请求它。