备份任务完成,证明备份过程生成了一份材料;恢复演练则要证明这份材料、现有工具以及必要环境,能共同恢复出可用系统。这是不同的结论。恢复证据应明确使用了哪份备份、恢复到什么状态、补齐了哪些依赖,以及花了多久才让结果对应用真正有用。不能只保留一个任务成功时间,就省略后面的验证。

坏掉的样子

合法的数据库归档仍然可能依赖范围之外的对象。PostgreSQL 的单库导出工具只导出一个数据库,角色却属于整个集群。在独立集群里恢复带授权信息的备份时,可能因为被引用的角色不存在而失败。如果演练始终在原集群进行,这种缺少恢复材料的问题就会被环境中已有的角色掩盖。

恢复错误也需要明确处理。恢复工具默认可以在 SQL 出错后继续,并在末尾报告错误数量。部分表能够查询,不代表半恢复状态就可以接受。这个小型夹具选择把恢复放进一个事务,使成功具有原子性;发生预期失败时,也不会留下已经恢复了一半的表,便于确认依赖修正之后重新执行的结果。

恢复位置正确是另一项要求。完全恢复成功的备份,也可能按设计不包含它之后发生的变更。校验和证明材料与记录一致,不证明其中包含最新业务事件。同样,总行数相同,并不能排除金额、关联关系或权限错误。验证项需要表达应用语义,不能因为容易查询就只检查几个不能区分正确与错误状态的数字。

快速验证

在 Linux 环境中使用非超级操作系统账户运行脚本,准备 PostgreSQL 18 服务端、客户端和摘要校验工具。本次实际执行版本是 PostgreSQL 18.6。脚本建立互相独立的临时源集群与目标集群,使用私有 Unix 套接字,不打开网络监听。实验角色没有登录能力,通过受控角色切换验证授权,不涉及真实账户凭据。

#!/bin/sh
set -eu
umask 077
lab=$(mktemp -d /tmp/xbcatm-restore.XXXXXX)
mkdir "$lab/socket"
source=$lab/source
target=$lab/target
cleanup() {
    for cluster in "$target" "$source"; do
        if test -f "$cluster/postmaster.pid"; then
            pg_ctl -D "$cluster" -m fast -w stop >/dev/null
        fi
    done
}
trap cleanup EXIT
for cluster in "$source" "$target"; do
    initdb -D "$cluster" --no-locale --encoding=UTF8 --auth-local=trust --auth-host=reject >/dev/null
    cat >> "$cluster/postgresql.conf" <<CONF
listen_addresses = ''
unix_socket_directories = '$lab/socket'
CONF
done
printf 'port = 56432\n' >> "$source/postgresql.conf"
printf 'port = 56433\n' >> "$target/postgresql.conf"
pg_ctl -D "$source" -l "$lab/source.log" -w -t 15 start >/dev/null
pg_ctl -D "$target" -l "$lab/target.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 ROLE backup_reader NOLOGIN;
CREATE TABLE customers (id integer PRIMARY KEY,name text NOT NULL);
CREATE TABLE orders (id integer PRIMARY KEY,customer_id integer REFERENCES customers,amount_cents integer CHECK (amount_cents>0));
INSERT INTO customers VALUES (1,'Ada');
INSERT INTO orders VALUES (10,1,4210),(11,1,1865);
CREATE VIEW order_totals AS SELECT c.name,sum(o.amount_cents) AS total_cents FROM customers c JOIN orders o ON o.customer_id=c.id GROUP BY c.id,c.name;
GRANT SELECT ON customers,orders,order_totals TO backup_reader;
SQL
pg_dump -Fc -w -f "$lab/snapshot.dump" postgres
sha256sum "$lab/snapshot.dump" > "$lab/snapshot.sha256"
psql -X -v ON_ERROR_STOP=1 -qAt -c 'INSERT INTO orders VALUES (12,1,1000)'
printf 'source_after_backup_orders '
psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT count(*) FROM orders'
sha256sum -c "$lab/snapshot.sha256"
if PGPORT=56433 pg_restore --single-transaction --exit-on-error -w -d postgres "$lab/snapshot.dump" >"$lab/missing-role.log" 2>&1; then
    echo 'restore unexpectedly ignored a missing role' >&2
    exit 1
fi
grep -F 'role "backup_reader" does not exist' "$lab/missing-role.log"
test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT to_regclass('public.orders') IS NULL")" = t
echo failed_restore_rolled_back
PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c 'CREATE ROLE backup_reader NOLOGIN'
PGPORT=56433 pg_restore --single-transaction --exit-on-error -w -d postgres "$lab/snapshot.dump"
PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL'
SELECT count(*) FROM orders;
SELECT name,total_cents FROM order_totals;
SELECT has_table_privilege('backup_reader','orders','SELECT'),has_table_privilege('backup_reader','orders','INSERT');
DO $$ BEGIN
  IF (SELECT count(*) FROM orders)<>2 OR
     (SELECT string_agg(id||':'||amount_cents,',' ORDER BY id) FROM orders) IS DISTINCT FROM '10:4210,11:1865' OR
     (SELECT total_cents FROM order_totals WHERE name='Ada') IS DISTINCT FROM 6075 OR
     (SELECT count(*) FROM orders o LEFT JOIN customers c ON c.id=o.customer_id WHERE c.id IS NULL)<>0 THEN
    RAISE EXCEPTION 'restored business data mismatch';
  END IF;
END $$;
DO $$ BEGIN
  BEGIN
    INSERT INTO orders VALUES (98,999,100);
    RAISE EXCEPTION 'restored foreign key unexpectedly accepted an orphan';
  EXCEPTION WHEN foreign_key_violation THEN
    RAISE NOTICE 'restored foreign key rejected orphan';
  END;
END $$;
SET ROLE backup_reader;
SELECT id,amount_cents FROM orders ORDER BY id;
DO $$ BEGIN
  BEGIN
    INSERT INTO orders VALUES (99,1,100);
    RAISE EXCEPTION 'read-only role unexpectedly wrote data';
  EXCEPTION WHEN insufficient_privilege THEN
    RAISE NOTICE 'read-only role rejected write';
  END;
END $$;
SQL
printf 'lab_directory %s\n' "$lab"

源库包含一个客户、两笔订单、汇总视图、外键以及只读角色授权。自定义格式归档捕获这个状态,之后源库又增加第三笔订单。输出中源库订单数为三,证明选定备份点之外确实存在后续活动,避免测试把当前源库和已经保存的快照混为一谈。恢复目标是否应该包含第三笔,需要由选择的备份点决定。

摘要检查能够成功,首次恢复却必须由于目标缺少只读角色而失败。脚本核对具体错误,并确认订单表随事务回滚。这就是只检查归档文件无法发现的环境依赖。随后,在目标创建已经说明的实验角色,再使用同一份归档重复恢复,不通过跳过授权来掩盖缺失对象。

恢复目标必须包含原来的两笔订单及其正确标识、金额,视图必须给出客户 Ada 和六千零七十五分的汇总。第三笔订单此时缺席才是正确结果。插入不存在客户的订单,必须被已经恢复的外键拒绝。权限查询显示允许读取、拒绝插入;切换到只读角色后,实际查询应成功,真实写入尝试则必须失败。

这些断言分别覆盖数据内容、应用查询、关联约束以及实际权限。所有实验服务在退出时停止,打印目录保留归档和日志。演练没有测试外部认证、加密密钥恢复或应用进程启动;若这些属于真实系统的恢复依赖,就必须在完整恢复方案中补充,不能把角色授权验证直接当成整套登录链路已经正常。

留下能支持决策的恢复结果

生产演练应从准备承担恢复职责的来源取回材料,重建所需环境,恢复数据,再执行代表性的应用行为。安装兼容软件是正常恢复工作的一部分,应把所需时间和前提计入,而不是假定一台已经准备好的主机永远存在。生产系统不可用时,恢复所需访问能力也应该仍然能够取得。

把恢复总耗时与恢复时间目标比较,把恢复出来的终点与允许丢失的数据范围比较。计时应包括获取材料、验证以及应用真正可用,而不只是数据库命令本身运行了几秒。较大的恢复任务可以选择适合负载的事务与并行选项,但要明确发生部分失败之后如何判断和处理,不能一边要求原子性一边又采用不兼容的执行方式。

备份工具、模式、角色、保留策略或基础设施发生变化后,应重新演练。保存成功检查和已经修正的失败作为证据,让下一次恢复能够沿着确定输入重现。具有已知边界的可重复恢复流程,才能支持运行决策;备份调度器显示绿色,单独回答不了系统是否已经具备可用的恢复能力。

延伸阅读

来源