Postgres WAL 归档实现时间点恢复
时间点恢复从一份有效的物理基础备份开始,重放到指定恢复目标所需的 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,证明错误修改确实已经发生,不能把没有真正写入的错误当作成功排除的对象。 ...