系统学习 演练学习路径:35 个 lab 的难度排序、4 周基础计划与复盘方法

版本信息最后核对:2026-10-25

Lab 状态:实跑通过2026-10-25 · Docker Compose

目标时间暂停、提升与约束复验通过

在一次性 PostgreSQL 容器中开启 WAL 归档,制造误删后恢复到目标时间点并核对数据差距

演练目标

安全边界:本 Lab 只使用本机 Docker、/tmp/lab-pg-pitr 目录和独立端口,不连接任何生产数据库。恢复目标是新容器、新数据目录,绝不覆盖源数据目录,也不在生产执行 pg_resetwal、删除 WAL 或直接 promote。真实 PITR 还要冻结写入、保留原实例、确认时区与业务验收标准。

环境准备

准备 Docker,预留约 1 GB 内存。源实例使用宿主机目录保存数据、WAL 归档和基础备份;恢复实例只读挂载同一份 WAL。镜像标签不写死,请用你已验证、且与基础备份来源主版本一致的 PostgreSQL 镜像;本仓库其他 Lab 使用过的标签可作参考。

timeout 10 mkdir -p /tmp/lab-pg-pitr/data /tmp/lab-pg-pitr/wal /tmp/lab-pg-pitr/base /tmp/lab-pg-pitr/restore/data /tmp/lab-pg-pitr/evidence
export PG_IMAGE="${PG_IMAGE:?请设置已验证的 PostgreSQL 镜像}"
timeout 30 docker version --format '{{.Server.Version}}'
timeout 60 docker run --rm -u 0 -v /tmp/lab-pg-pitr:/lab --entrypoint sh "$PG_IMAGE" \
  -c 'chown -R postgres:postgres /lab/wal /lab/base /lab/restore'
timeout 180 docker run -d --name lab-pg-pitr-src \
  -e POSTGRES_USER=lab -e POSTGRES_PASSWORD=labpass -e POSTGRES_DB=app \
  -v /tmp/lab-pg-pitr/data:/var/lib/postgresql/data \
  -v /tmp/lab-pg-pitr/wal:/archive \
  -v /tmp/lab-pg-pitr/base:/backup \
  -p 127.0.0.1:55432:5432 "$PG_IMAGE"
timeout 120 bash -c 'until docker exec lab-pg-pitr-src pg_isready -U lab -d app; do sleep 2; done'

开启归档并重启使参数生效。archive_command 先检查目标文件是否存在,避免重名覆盖;真实环境应使用对象存储或专用归档工具,并监控归档失败:

timeout 30 docker exec -i lab-pg-pitr-src psql -U lab -d app -v ON_ERROR_STOP=1 <<'EOF'
ALTER SYSTEM SET wal_level = 'replica';
ALTER SYSTEM SET archive_mode = 'on';
ALTER SYSTEM SET archive_command = 'test ! -f /archive/%f && cp %p /archive/%f';
EOF
timeout 60 docker restart lab-pg-pitr-src
timeout 120 bash -c 'until docker exec lab-pg-pitr-src pg_isready -U lab -d app; do sleep 2; done'
timeout 30 docker exec lab-pg-pitr-src psql -U lab -d app -Atc \
  "SELECT current_setting('wal_level'), current_setting('archive_mode'), current_setting('archive_command');"

准备业务表和 5 条基线数据,记录行数、金额汇总和最大 ID。随后切换一次 WAL,确认归档目录出现文件,而不是只看 archive_mode=on:

timeout 30 docker exec -i lab-pg-pitr-src psql -U lab -d app -v ON_ERROR_STOP=1 <<'EOF'
CREATE TABLE orders (
  id bigserial PRIMARY KEY,
  customer text NOT NULL,
  amount numeric(12,2) NOT NULL CHECK (amount >= 0),
  created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO orders (customer, amount) VALUES
  ('baseline-1',100),('baseline-2',200),('baseline-3',300),
  ('baseline-4',400),('baseline-5',500);
EOF
timeout 30 docker exec lab-pg-pitr-src psql -U lab -d app -Atc \
  "SELECT count(*), sum(amount), max(id) FROM orders;" | timeout 30 tee /tmp/lab-pg-pitr/evidence/baseline.txt
timeout 30 docker exec lab-pg-pitr-src psql -U lab -d app -Atc "SELECT pg_switch_wal();"
timeout 30 bash -c 'sleep 3; ls -1 /tmp/lab-pg-pitr/wal | tail -5'
timeout 30 docker exec lab-pg-pitr-src psql -U lab -d app -Atc \
  "SELECT archived_count, failed_count, last_archived_wal FROM pg_stat_archiver;"

演练步骤

1. 制作基础备份并校验

timeout 180 docker exec -u postgres lab-pg-pitr-src pg_basebackup -U lab -D /backup/base -Fp -Xs -P
timeout 60 docker exec -u postgres lab-pg-pitr-src pg_verifybackup /backup/base
timeout 30 shasum -a 256 /tmp/lab-pg-pitr/base/base/backup_manifest | timeout 30 tee /tmp/lab-pg-pitr/evidence/manifest.sha256

pg_verifybackup 成功只说明备份清单和文件校验通过,不证明 WAL 连续。继续检查归档目录里基础备份之后是否还有 WAL,并确认 failed_count 没有增长。基础备份期间和之后产生的 WAL 必须完整保留,缺一个段就可能停在错误位置。

2. 制造目标时间点与误删

基础备份完成后写入 2 条订单,记录恢复目标时间,再等待片刻后执行误删并写入 2 条事故后数据。这样恢复结果应包含 1 至 7 号订单,缺少 8、9 号订单,正好量化 RPO:

timeout 30 docker exec lab-pg-pitr-src psql -U lab -d app -v ON_ERROR_STOP=1 -c \
  "INSERT INTO orders (customer, amount) VALUES ('pre-target-1',600),('pre-target-2',700);"
timeout 30 docker exec lab-pg-pitr-src psql -U lab -d app -Atc "SELECT pg_switch_wal();"
TARGET=$(timeout 30 docker exec lab-pg-pitr-src psql -U lab -d app -Atc \
  "SELECT to_char(clock_timestamp(), 'YYYY-MM-DD HH24:MI:SS.USOF');")
TARGET_EPOCH=$(timeout 5 date +%s)
timeout 10 tee /tmp/lab-pg-pitr/evidence/target.txt >/dev/null <<< "target=$TARGET epoch=$TARGET_EPOCH"
timeout 10 sleep 2
timeout 30 docker exec lab-pg-pitr-src psql -U lab -d app -v ON_ERROR_STOP=1 -c \
  "DELETE FROM orders WHERE id <= 5;
   INSERT INTO orders (customer, amount) VALUES ('post-accident-1',800),('post-accident-2',900);"
ACCIDENT_EPOCH=$(timeout 5 date +%s)
timeout 30 docker exec lab-pg-pitr-src psql -U lab -d app -Atc "SELECT pg_switch_wal();"
timeout 30 docker exec lab-pg-pitr-src psql -U lab -d app -Atc \
  "SELECT count(*), sum(amount), max(id) FROM orders;" | timeout 30 tee /tmp/lab-pg-pitr/evidence/source-after.txt
timeout 30 bash -c 'sleep 3; ls -1 /tmp/lab-pg-pitr/wal | tail -8'
timeout 60 docker stop lab-pg-pitr-src

源库现在应有 4 条订单,金额合计 3000,最大 ID 为 9。记录完成后停止源容器,冻结写入并保留现场;恢复目标是误删之前的时间点,因此 8、9 号订单属于可解释的丢失窗口,而 1 至 5 号订单应被恢复。

3. 从基础备份和 WAL 恢复到独立实例

把基础备份复制到新的恢复目录,放入 recovery.signal,并通过启动参数设置恢复目标。recovery_target_action=pause 让实例到达目标后暂停,先只读验证,不要自动开放写入:

RESTORE_START=$(timeout 5 date +%s)
timeout 120 docker run --rm -u postgres --entrypoint sh \
  -v /tmp/lab-pg-pitr/base:/backup:ro -v /tmp/lab-pg-pitr/restore:/restore "$PG_IMAGE" \
  -c 'cp -a /backup/base/. /restore/data/'
timeout 30 docker run --rm -u postgres --entrypoint sh \
  -v /tmp/lab-pg-pitr/restore:/restore "$PG_IMAGE" \
  -c 'touch /restore/data/recovery.signal && chmod 700 /restore/data'
timeout 180 docker run -d --name lab-pg-pitr-restore \
  -e PGDATA=/restore/data \
  -v /tmp/lab-pg-pitr/restore:/restore \
  -v /tmp/lab-pg-pitr/wal:/archive:ro \
  -p 127.0.0.1:55433:5432 "$PG_IMAGE" \
  -c restore_command='cp /archive/%f %p' \
  -c recovery_target_time="$TARGET" \
  -c recovery_target_inclusive=on \
  -c recovery_target_action=pause \
  -c recovery_target_timeline=latest \
  -c hot_standby=on -c listen_addresses='*' -c archive_mode=off
timeout 180 bash -c 'until docker exec lab-pg-pitr-restore psql -U lab -d app -Atc "SELECT pg_is_in_recovery() AND pg_is_wal_replay_paused();" 2>/dev/null | grep -qx t; do sleep 2; done'
timeout 30 docker logs lab-pg-pitr-restore 2>&1 | tail -50 | timeout 30 tee /tmp/lab-pg-pitr/evidence/recovery.log

4. 只读核对与提升

先检查回放状态和目标时间,再核对行数、金额、最大 ID、序列和约束。任何一项不符都先停在 pause 状态定位,不要直接 pg_promote:

timeout 30 docker exec lab-pg-pitr-restore psql -U lab -d app -Atc \
  "SELECT pg_is_in_recovery(), pg_last_xact_replay_timestamp();"
timeout 30 docker exec lab-pg-pitr-restore psql -U lab -d app -Atc \
  "SELECT count(*), sum(amount), max(id), min(id) FROM orders;"
timeout 30 docker exec lab-pg-pitr-restore psql -U lab -d app -Atc \
  "SELECT last_value, is_called FROM orders_id_seq;"
timeout 30 docker exec lab-pg-pitr-restore psql -U lab -d app -Atc \
  "SELECT count(*) FROM pg_constraint WHERE connamespace='public'::regnamespace;
   SELECT count(*) FROM pg_indexes WHERE schemaname='public';"
timeout 30 docker exec lab-pg-pitr-restore psql -U lab -d app -Atc \
  "SELECT count(*) FROM orders WHERE customer IN ('post-accident-1','post-accident-2');"

预期只读结果为 7 条订单、金额合计 2800、最大 ID 为 7、最小 ID 为 1,事故后的 2 条订单为 0,序列值不小于 7。确认无误后提升并做一次可回滚写入,验证约束和序列都可用:

timeout 30 docker exec lab-pg-pitr-restore psql -U lab -d postgres -c "SELECT pg_promote();"
timeout 30 docker exec lab-pg-pitr-restore psql -U lab -d postgres -Atc "SELECT pg_is_in_recovery();"
timeout 30 docker exec lab-pg-pitr-restore psql -U lab -d app -v ON_ERROR_STOP=1 -c \
  "BEGIN;
   INSERT INTO orders (customer, amount) VALUES ('restore-canary', 1) RETURNING id;
   INSERT INTO orders (customer, amount) VALUES ('constraint-check', -1);
   ROLLBACK;"
RESTORE_DONE=$(timeout 5 date +%s)
timeout 10 tee /tmp/lab-pg-pitr/evidence/rpo-rto.txt >/dev/null <<< \
  "rpo_rows=2 rpo_seconds=$((ACCIDENT_EPOCH-TARGET_EPOCH)) rto_seconds=$((RESTORE_DONE-RESTORE_START))"

最后一条插入使用负数金额,应当被 CHECK 约束拒绝,事务整体回滚;这验证恢复后的约束确实生效,而不是只恢复了一张“能读的表”。序列在回滚后仍会前进,这是 PostgreSQL 序列的非事务特性,不是恢复错误。

观察现象

基础备份和 WAL 归档完成后,恢复日志应显示回放停止在目标时间附近并进入 pause;pg_is_in_recovery() 返回 t,pg_last_xact_replay_timestamp() 与 target.txt 接近。恢复实例应有 7 条订单、金额 2800、最大 ID 7、最小 ID 1;源实例只有 4 条、金额 3000、最大 ID 9,差额正是事故后的 2 条写入。post-accident-* 查询为 0,约束和索引数量与基线一致。

成功的判据是:pg_verifybackup 通过;归档目录包含基础备份之后的连续 WAL,failed_count 没有增长;恢复停在目标时间,日志没有缺段、时间线或页面校验错误;行数、聚合、序列、约束可解释;提升后可写且约束生效;RPO 行数与秒数、RTO 已记录。失败判据是:基础备份无法校验;WAL 缺口未解决就继续;恢复行数无法用目标时间解释;序列回退导致主键冲突;为了绕过错误使用 pg_resetwal;在同一个数据目录上反复改时间重启。

判断与定位

  1. 先确认恢复目标在基础备份结束之后、最后一份可用 WAL 之前。目标早于基础备份时,这份基础备份不能作为起点;目标晚于最后一份 WAL 时,只能恢复到最后一个可回放点。
  2. WAL 缺口不能跳过。日志会停在缺口或报找不到段;应把目标改到缺口之前,或从其他归档、副本、对象存储找回缺失段。不要用 pg_resetwal 绕过,它会破坏一致性并让数据库失去可信状态。
  3. 恢复停住不等于失败。recovery_target_action=pause 本来就是停在目标,等待人工只读验证;没有到达目标就停住,才要查 WAL、权限、时间线和配置冲突。
  4. 行数比预期多,通常说明目标时间设得太晚,把事故后事务也回放了;行数比预期少,可能是目标时间早于部分提交,或恢复用错了基础备份。先查 target.txt、恢复日志和 WAL 时间线,不要在恢复库上手工补数据。
  5. 序列要单独核对。序列值可能因缓存而领先于最大 ID,但不应该落后;落后会导致后续插入主键冲突。恢复后先读序列,再做 canary 写入并回滚。
  6. 时区要显式。recovery_target_time 不带时区时按数据库配置解释;本 Lab 记录带偏移量的时间字符串,避免“差八小时”导致恢复到错误事务。目标时间最后落在意外事务中间时,可改用命名还原点或 LSN。

恢复与回滚

如果恢复实例无法启动或日志报 WAL 缺失,先停止并保留恢复目录、日志和归档,不要在原目录上反复重启。换一个全新的空目录,从同一份基础备份重新开始;如果目标时间选错,也要重新复制基础备份,而不是修改已回放目录的配置继续跑。若基础备份损坏或扩展缺失,改用另一份已校验的备份,并在提升前补齐扩展和依赖。

一旦恢复实例接受写入,就不能简单“回退 WAL”。本次演练的回滚是停止并销毁恢复容器,源实例保持原样作为对照;真实环境回切前要确认源实例冻结后没有新写入,否则必须先做差异核对,避免出现两个可写主库。不要把恢复实例的新写入直接覆盖回源库。

演练结束先归档证据,再清理本实验的容器和目录。只删除带 lab-pg-pitr 名称的资源,不要执行宽泛的 Docker 清理:

timeout 60 docker rm -f lab-pg-pitr-restore lab-pg-pitr-src
timeout 30 rm -rf /tmp/lab-pg-pitr

验证

复盘

相关命令

参考链接

读完自测

  1. 基础备份已经通过 pg_verifybackup,为什么仍不能保证可以恢复到目标时间?
  2. 恢复后行数和金额都对,为什么还要检查序列、约束和一次写入?
  3. WAL 在目标时间之前出现缺口时,为什么不能跳过缺口继续恢复?
答案
  1. pg_verifybackup 只校验基础备份的文件和清单;PITR 还需要从基础备份到目标时间的连续 WAL。WAL 缺失、时间线错误或目标时间超出归档范围,都会让恢复停在错误位置或无法到达目标。
  2. 行数和金额一致仍可能隐藏序列回退、约束失效或表不可写。序列决定后续插入是否主键冲突,约束决定数据是否可信;只有 canary 写入和约束拒绝都符合预期,才能说明恢复实例可接管。
  3. WAL 是有顺序的日志链,跳过缺口会丢失中间事务并破坏一致性。应把目标改到缺口之前,或从其他归档、副本、对象存储找回缺失段;找不到就只能接受更早的恢复点。
微信小程序码:扫码打开命令速查小程序

小程序版

微信扫码,随时查命令