时间点恢复从一份有效的物理基础备份开始,重放到指定恢复目标所需的 WAL。目标必须位于这份备份及其可用时间线能够覆盖的历史中。开启归档并不会自动获得恢复到任意更早日期的能力,也不会修复基础备份与期望终点之间已经丢失的一段日志。恢复范围由真正保存并可读取的材料决定。

坏掉的样子

复制出来的数据目录不会自动成为有效的在线备份。应采用受支持的备份方式,保存必要日志与时间线历史,并检查备份清单。清单校验程序能发现许多完整性问题,但它运行成功,不能证明更晚的归档日志存在,也不能证明业务期望的恢复目标对应正确状态。后两者需要实际启动恢复,再检查恢复出来的数据。

选择目标也需要明确边界。时间、事务标识、日志位置以及命名恢复点表达的含义不同;对适用的目标类型,是否包含目标边界由对应参数控制。本实验使用命名恢复点,避免依赖人工判断某个墙上时钟时间,但这个标记必须由管理操作事先写入日志。复用同一个名字可能使恢复停在首个同名位置,而不是操作人员心中较新的那个位置。

快速验证

在 Linux 环境中使用非超级操作系统账户运行脚本,准备 PostgreSQL 18 服务端、客户端以及支持保留属性复制、临时文件、硬链接和按文件同步的工具。本次在 PostgreSQL 18.6 上实际执行。集群位于私有临时目录,使用独立 Unix 套接字并关闭网络监听。本地归档用于演练恢复机制,不代表能够抵抗整台宿主机丢失。

#!/bin/sh
set -eu
umask 077
lab=$(mktemp -d /tmp/xbcatm-pitr.XXXXXX)
mkdir "$lab/socket" "$lab/archive"
primary=$lab/primary
restored=$lab/restored
missing=$lab/missing
cleanup() {
    for cluster in "$missing" "$restored" "$primary"; do
        if test -f "$cluster/postmaster.pid"; then
            pg_ctl -D "$cluster" -m fast -w stop >/dev/null
        fi
    done
}
trap cleanup EXIT
initdb -D "$primary" --no-locale --encoding=UTF8 --auth-local=trust --auth-host=reject >/dev/null
cat >> "$primary/postgresql.conf" <<CONF
listen_addresses = ''
unix_socket_directories = '$lab/socket'
port = 56432
wal_level = replica
archive_mode = on
archive_command = '$lab/archive-copy.sh "%p" "%f" "$lab/archive"'
CONF
cat > "$lab/archive-copy.sh" <<'ARCHIVE_HELPER'
#!/bin/sh
set -eu
wal_source=$1
wal_name=$2
archive_directory=$3
archive_target=$archive_directory/$wal_name
if test -e "$archive_target"; then
    cmp -s "$wal_source" "$archive_target"
    sync "$archive_target" "$archive_directory"
    exit 0
fi
archive_temporary=$(mktemp "$archive_directory/.incoming.XXXXXX")
trap 'rm -f "$archive_temporary"' EXIT HUP INT TERM
cp "$wal_source" "$archive_temporary"
sync "$archive_temporary"
ln "$archive_temporary" "$archive_target" || cmp -s "$wal_source" "$archive_target"
sync "$archive_target" "$archive_directory"
ARCHIVE_HELPER
chmod 700 "$lab/archive-copy.sh"
pg_ctl -D "$primary" -l "$lab/primary.log" -w -t 15 start >/dev/null
export PGHOST=$lab/socket PGPORT=56432 PGDATABASE=postgres PGUSER=$(id -un)
export PGCONNECT_TIMEOUT=2
psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL'
CREATE TABLE marker (id integer PRIMARY KEY,value text);
INSERT INTO marker VALUES (1,'base');
SQL
pg_basebackup -D "$lab/base" -X stream -c fast -w
pg_verifybackup -q "$lab/base"
psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_switch_wal()' >/dev/null
psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL'
INSERT INTO marker VALUES (2,'keep');
SELECT pg_create_restore_point('before_bad_change');
UPDATE marker SET value='bad' WHERE id=1;
DELETE FROM marker WHERE id=2;
SQL
segment=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_walfile_name(pg_current_wal_lsn())')
psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_switch_wal()' >/dev/null
attempt=0
while ! test -f "$lab/archive/$segment"; do
    attempt=$((attempt+1))
    test "$attempt" -lt 100 || { echo 'archive segment did not arrive' >&2; exit 1; }
    sleep 0.1
done
printf 'primary_now '
psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT string_agg(id||':'||value,',' ORDER BY id) FROM marker"
mkdir "$restored"
cp -a "$lab/base/." "$restored/"
cat >> "$restored/postgresql.conf" <<CONF
port = 56433
archive_mode = off
restore_command = 'cp "$lab/archive/%f" "%p"'
recovery_target_name = 'before_bad_change'
recovery_target_action = 'pause'
CONF
touch "$restored/recovery.signal"
pg_ctl -D "$restored" -l "$lab/restored.log" -w -t 15 start >/dev/null
attempt=0
while test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_get_wal_replay_pause_state()='paused'")" != t; do
    attempt=$((attempt+1))
    test "$attempt" -lt 100 || { echo 'restore target was not reached' >&2; exit 1; }
    sleep 0.1
done
PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL'
SELECT pg_is_in_recovery(),pg_get_wal_replay_pause_state();
SELECT id,value FROM marker ORDER BY id;
DO $$ BEGIN
  IF (SELECT string_agg(id||':'||value,',' ORDER BY id) FROM marker)
     IS DISTINCT FROM '1:base,2:keep' THEN
    RAISE EXCEPTION 'point-in-time result mismatch';
  END IF;
END $$;
SQL
test ! -e "$lab/base/pg_wal/$segment"
mkdir "$lab/archive-missing" "$missing"
for archived_file in "$lab/archive/"*; do
    if test "${archived_file##*/}" != "$segment"; then
        cp "$archived_file" "$lab/archive-missing/"
    fi
done
cp -a "$lab/base/." "$missing/"
cat >> "$missing/postgresql.conf" <<CONF
port = 56434
archive_mode = off
restore_command = 'cp "$lab/archive-missing/%f" "%p"'
recovery_target_name = 'before_bad_change'
recovery_target_action = 'pause'
CONF
touch "$missing/recovery.signal"
if pg_ctl -D "$missing" -l "$lab/missing.log" -w -t 15 start >/dev/null 2>&1; then
    echo 'missing-segment server was briefly available'
fi
attempt=0
while pg_ctl -D "$missing" status >/dev/null 2>&1; do
    attempt=$((attempt+1))
    test "$attempt" -lt 100 || { echo 'missing-segment server did not stop' >&2; exit 1; }
    sleep 0.1
done
grep -F 'recovery ended before configured recovery target was reached' "$lab/missing.log"
test -f "$lab/archive/$segment"
echo missing_segment_target_unreachable
printf 'lab_directory %s\nrequired_segment %s\n' "$lab" "$segment"

主库最初只有第一行,值为 base。取得并验证基础备份后,脚本切换日志段,再插入值为 keep 的第二行,并创建错误修改之前的命名恢复点。只有在标记之后,才把第一行改成 bad、删除第二行。输出中的主库当前状态应只剩 1:bad,证明错误修改确实已经发生,不能把没有真正写入的错误当作成功排除的对象。

恢复使用基础备份的独立副本。恢复信号文件请求归档恢复,恢复命令提供归档文件,命名目标和暂停动作让回放停下来供检查。脚本等待暂停状态函数明确返回已经暂停。仅用另一个布尔函数得到真,只能证明暂停请求已发出,不一定证明回放此刻已经停止;两者在并发恢复时不能互换。

预期结果先表示实例仍在恢复且已暂停,再显示第一行为 base、第二行为 keep。SQL 断言核对两行组合后的精确内容。这同时证明较早的正确事务被保留,较晚的错误变更被排除,比“数据库已经接受连接”更有说服力:服务可能在恢复尚未到达目标时,就允许只读连接进入。

负向对照另外复制归档,但排除备份完成后必需的日志段。原归档保持完整,并且先确认基础备份本身没有带上被排除的那一段。使用不完整副本恢复时,最终必须因配置的目标尚未到达而发生致命错误。脚本等待该实验服务确实停止,并核对日志中的原因,而不是只凭首次启动命令的退出码下结论。缺段目标不可到达的标记,表示这项预期失败已经被观察到。

所有实验集群都会在脚本退出时停止,打印目录保留日志与数据便于复核。这次演练不会把恢复副本提升或交给业务应用使用,恢复到正确内容与接管生产流量是不同的验证事项。复制出来的失败实验也不会破坏原始归档,便于在同一套材料上再次复现成功路径。

接受恢复结果之前先核对

目标处的暂停给了核对业务不变量的机会。除了总行数,还应检查关联记录、事务边界和最后一项可以接受的操作。恢复目标处继续执行会结束恢复,不等于请求任意重放目标之后的所有变更。若要追求更晚的目标,应按文档规定停止实例、调整目标再继续,避免错误理解暂停与继续的含义。

记录基础备份身份、可用时间线、目标选择、恢复事实和实际耗时。配置文件及必需的外部软件也应纳入恢复方案,因为 WAL 不会恢复所有外部依赖。实验期间保护原始备份和归档,使不同目标的尝试有共同可信起点。一次成功证明的是一个具体目标;随着备份轮换,持续跨保留区间演练,才能判断承诺的恢复窗口是否始终可用。

延伸阅读

来源