从三次单独的一致快照里导出的逻辑转储。

pg_dump 拍一张一致的快照

pg_dump 使用事务数据的一致视图导出一个 PostgreSQL 数据库,同时允许其他会话继续提交变化。它避免了普通逐表复制的问题:前一张表和后一张表不会因为读取时间不同,就混入彼此不一致的已提交状态。但快照只是备份契约的一部分;归档还必须覆盖需要的数据范围,能被兼容工具读取,并且实际恢复成可使用的数据库。 理解快照覆盖什么 导出期间,快照可见的行构成稳定视图。后续提交不会因为某张表读得晚,就逐渐混进同一次导出。并行导出让各 worker 使用同步快照,而不是各自随时读取。并行 dump 要求 directory 格式;custom 格式支持并行恢复,但不支持并行导出。格式选择应根据导出与恢复两边的需求分别判断,不能把两个方向混为一谈。 数据库快照不会自动让整个业务流程原子化。如果应用分两个事务提交批次头和明细,一致快照完全可能合法地落在这两次提交之间。序列状态也不是普通的 MVCC 行数据,外部文件和其他服务更不在数据库快照范围内。恢复需要满足什么业务不变量,必须结合真实事务边界和所需数据源定义,不能只凭“同一快照”推断所有业务状态都完整。 计算负载和依赖 普通读取和写入通常可以在导出期间继续,但 pg_dump 仍获取 ACCESS SHARE 锁,也消耗 CPU、I/O、连接和快照存活时间。冲突的 DDL 可能等待,也可能使导出失败;长快照会阻止某些旧行版本被清理。增加并行 worker 只是用更多并发资源换时间,不会让备份开销消失。应记录事务年龄和线上负载,避免把备份的影响误认为无关故障。 使用备库可以分担读取,但要考虑恢复冲突与复制延迟,而且从备库导出会排除非日志表内容。一次 pg_dump 也不包含集群级角色和表空间。扩展、所有者、权限、排序规则行为及目标配置,都需要通过合适机制纳入恢复计划。把整个库拆成互不协调的单表 dump,可能丢失共同快照,也可能遗漏这些表依赖的对象,不能当成免费的优化。 坏掉的样子 归档目录能够列出,不代表恢复一定成功。目标缺少角色或扩展时,表数据可能只恢复了一部分。应把归档加载到隔离空库,明确处理错误,再验证业务不变量和代表性查询。pg_restore 默认遇到 SQL 错误仍会继续,最后汇总错误数量;只看到最后一条查询有输出是不够的。小型演练可使用单事务恢复获得原子性,但这个模式不能与并行 job 同用。 工具版本是另一条边界。pg_dump 客户端会拒绝比自身主版本更新的服务器,输出主要面向相同或更新的目标版本,不能保证可以加载到更旧的主版本。应核对客户端、源库、目标库、扩展和恢复选项的实际组合,而不是只说“都是 PostgreSQL 所以可以恢复”。即使语法兼容,排序规则或权限差异也可能改变应用行为。 ...

一月 14, 2028 · 2 分钟 · XB Field Notes
持续归档 WAL,实现精确到某一时刻的恢复。

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,证明错误修改确实已经发生,不能把没有真正写入的错误当作成功排除的对象。 ...

六月 25, 2027 · 3 分钟 · XB Field Notes
一个备份架,用一次恢复演练证明副本能回来。

没验证的备份只是空想

备份任务完成,证明备份过程生成了一份材料;恢复演练则要证明这份材料、现有工具以及必要环境,能共同恢复出可用系统。这是不同的结论。恢复证据应明确使用了哪份备份、恢复到什么状态、补齐了哪些依赖,以及花了多久才让结果对应用真正有用。不能只保留一个任务成功时间,就省略后面的验证。 坏掉的样子 合法的数据库归档仍然可能依赖范围之外的对象。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" 源库包含一个客户、两笔订单、汇总视图、外键以及只读角色授权。自定义格式归档捕获这个状态,之后源库又增加第三笔订单。输出中源库订单数为三,证明选定备份点之外确实存在后续活动,避免测试把当前源库和已经保存的快照混为一谈。恢复目标是否应该包含第三笔,需要由选择的备份点决定。 ...

六月 2, 2027 · 3 分钟 · XB Field Notes
一个有版本对象、并带把老对象移到更冷层的规则的桶。

桶版本化与生命周期管理成本

版本化把覆盖和删除都变成了追加。它不去替换字节,而是新铸一个版本 ID 并把旧的留着,于是一次写错的 PUT、一次删错的 DELETE 都还有退路。对于任何通过桶而不是通过数据库管理的数据,这是真正便宜的容灾。 同一个性质也正是麻烦所在。你留下的每一个版本都是要付钱的存储,而它的失败方式并不戏剧化——账单随写入量一路爬升,你能看到的对象数量却纹丝不动。更要命的是,这条曲线通常不会触发任何告警,因为它长得太像正常的业务增长了。 版本 ID 与 null 版本 打开版本化后,每次 PUT 都会产生一个新的版本 ID,最新的那个成为当前版本。不带版本 ID 的 GET 返回当前版本,带版本 ID 的 GET 精确返回那一个。所以「这个对象」的真实含义是「这个对象的当前版本」,任何假定「一个 key 一个对象」的工具都在悄悄出错。 在版本化启用之前写入的对象带着版本 ID null,而启用版本化并不会追溯地把它们变成有版本的。把这串操作重放一遍:启用前先 PUT 一次,存下 null;再启用版本化,然后覆盖两次,这个 key 现在有三个版本、合计 80 字节。那个 null 版本还在那里,而且仍然是一个可以被非版本化写入直接盖掉的单一槽位。 删除标记是墓碑,不是删除 在版本化的桶上不带版本 ID 执行 DELETE,会写入一个删除标记,并由这个标记成为当前版本。此后 GET 返回 404——但什么都没被释放。重放里,桶在删除前持有 80 字节,删除后还是 80 字节。标记是一个零字节的表项,把底下的一切都遮住了。 ...

五月 8, 2027 · 1 分钟 · XB Field Notes
每一段 WAL 都留档持久化,从而支撑按时间点恢复。

WAL 归档保留可恢复的历史

连续 WAL 归档把已经完成的日志文件保存在 PostgreSQL 活跃日志目录之外。配合合适的基础备份和不中断的恢复链,这些文件允许恢复过程把有日志保护的数据库变更重放到覆盖范围内的目标。归档与普通提交确认是两条不同的路径:事务可能已经提交,但所在日志段还没有结束,或者归档目的地仍然不可用。 坏掉的样子 PostgreSQL 在执行过程中会修改缓冲区里的数据页;依赖某段日志的数据页持久化之前,相应 WAL 必须先持久化。这不表示数据库从不写数据文件,也不表示每次提交都要等待归档完成。临时表和不记录日志的表内容,也不会仅因开启归档就获得普通 WAL 所提供的恢复保证。把这些边界混在一起,会高估某份备份能恢复的内容。 归档命令的返回码是一条数据完整性契约。返回零意味着文件已经成功保存;其他保留条件也满足后,数据库就可以回收源文件。如果复制失败却返回成功,这条契约便被悄悄打断。非零返回会触发重试,持续失败会使尚未归档的日志留在主库,最终可能填满文件系统。执行检查点既不会修好目的地,也不会解除这份保留义务。 已有文件名必须仔细处理。内容完全相同且已经可靠落盘的重复归档应当成功;同名但字节不同则必须失败,并保留原文件。仅因为目标已经存在就返回成功,会掩盖损坏,或掩盖两个不同集群错误地共用同一个归档命名空间。这里不能用“文件存在”替代“这份内容已经安全保存”。 快速验证 使用非超级操作系统账户运行下面的脚本,并把 PostgreSQL 18 服务端和客户端程序放入 PATH。环境需要支持保留属性复制、临时文件、硬链接以及按文件同步的 Linux 工具。本次在 PostgreSQL 18.6 上执行。脚本创建自己的私有临时集群与 Unix 套接字,关闭网络监听,只写测试数据。本地归档用于展示命令契约,并不是独立故障域里的灾难恢复副本。 #!/bin/sh set -eu umask 077 lab=$(mktemp -d /tmp/xbcatm-archive.XXXXXX) mkdir "$lab/socket" "$lab/archive" primary=$lab/primary cleanup() { if test -f "$primary/postmaster.pid"; then pg_ctl -D "$primary" -m fast -w stop >/dev/null fi } trap cleanup EXIT initdb -D "$primary" --no-locale --encoding=UTF8 --auth-local=trust --auth-host=reject >/dev/null 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" 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 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 -c 'CREATE TABLE archive_marker (id integer PRIMARY KEY)' pg_basebackup -D "$lab/base" -X stream -c fast -w pg_verifybackup -q "$lab/base" echo base_backup_verified failures_before=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT failed_count FROM pg_stat_archiver') psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' ALTER SYSTEM SET archive_command='/bin/false'; SELECT pg_reload_conf(); SQL attempt=0 while test "$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT current_setting('archive_command')='/bin/false'")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 100 || exit 1 sleep 0.1 done psql -X -v ON_ERROR_STOP=1 -qAt -c 'INSERT INTO archive_marker VALUES (1)' -c 'SELECT pg_switch_wal()' >/dev/null attempt=0 while test "$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT failed_count>$failures_before FROM pg_stat_archiver")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 700 || { echo 'archive failure was not observed' >&2; exit 1; } sleep 0.1 done failed_segment=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT last_failed_wal FROM pg_stat_archiver') test ! -f "$lab/archive/$failed_segment" echo archive_failure_observed psql -X -v ON_ERROR_STOP=1 -qAt -c CHECKPOINT test -f "$primary/pg_wal/$failed_segment" echo unarchived_wal_retained_after_checkpoint archived_before=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT archived_count FROM pg_stat_archiver') psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' ALTER SYSTEM RESET archive_command; SELECT pg_reload_conf(); SQL attempt=0 while ! test -f "$lab/archive/$failed_segment" || test "$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT archived_count>$archived_before FROM pg_stat_archiver")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 700 || { echo 'archive repair was not observed' >&2; exit 1; } sleep 0.1 done cmp -s "$primary/pg_wal/$failed_segment" "$lab/archive/$failed_segment" echo archive_repaired "$lab/archive-copy.sh" "$primary/pg_wal/$failed_segment" "$failed_segment" "$lab/archive" echo identical_repeat_accepted printf 'different bytes\n' > "$lab/conflicting-input" if "$lab/archive-copy.sh" "$lab/conflicting-input" "$failed_segment" "$lab/archive"; then echo 'conflicting archive input was accepted' >&2 exit 1 fi cmp -s "$primary/pg_wal/$failed_segment" "$lab/archive/$failed_segment" echo conflicting_repeat_rejected printf 'lab_directory %s\nfailed_segment %s\n' "$lab" "$failed_segment" 首先应出现基础备份已经验证的标记,然后脚本把归档命令临时改成一个确实返回失败的程序。它等待统计中真实出现失败,确认失败文件没有进入归档,再请求检查点,并核对主库仍然保留原始日志。恢复归档命令后,只有进度增加且指定目标文件出现,才打印归档修复标记;随后还比较双方字节,检查复制出来的实际内容。 ...

四月 22, 2027 · 3 分钟 · XB Field Notes
一层摞一层的堆叠,地基就是全量备份。

全量与增量备份

全量备份提供一个不依赖同种方法更早备份的基线。增量备份保存相对于指定参考点的变化,恢复时仍然依赖补全省略内容所需的材料。这一区别描述的是恢复依赖,不是对执行速度或传输字节数的普遍保证。如果变化过的数据范围很大,增量备份本身也可能相当大,不能仅凭“增量”两个字就承诺备份很快。 坏掉的样子 全量备份损坏,可能使所有依赖它的增量都失去恢复能力。同样,删除必要的中间增量,即使最新目录与清单还完整,也可能破坏后续恢复。保留策略必须沿着依赖关系作决定,不能只按时间戳独立删除看起来较旧的目录。一个较新的文件,并不自动替代所有较旧的恢复材料。 参考对象同样重要。相对于上一次增量建立备份,会形成一条链;相对于某个全量基线建立备份,则具有另一种依赖形状,通常称为差异备份。合成全量通过已有备份材料重建一个新基线。这些名词在不同工具中不保证实现完全相同,因此,不能只依据名称设计轮换,必须核对实际产品的清单格式与恢复规则。 物理数据库备份还需要让捕获文件达到一致状态的必要日志。全量独立于更早备份,并不意味着它独立于全部恢复日志、软件、配置或外部依赖。本实验通过流式收集带上所需 WAL,并保持整个备份链中的数据库版本与页面校验配置一致。这样验证的是明确的一套恢复条件,而不是省略前提的“有全量就一定能启动”。 快速验证 在 Linux 环境中,以非超级操作系统账户运行下面脚本,准备 PostgreSQL 18 服务端和客户端程序,包括合成备份工具。本次实际执行版本是 PostgreSQL 18.6。脚本创建私有临时主库与恢复集群,不打开网络监听。取得备份之前先开启日志摘要,让数据库能够为增量请求识别发生变化的数据块。 #!/bin/sh set -eu umask 077 lab=$(mktemp -d /tmp/xbcatm-incremental.XXXXXX) mkdir "$lab/socket" primary=$lab/primary restored=$lab/restored cleanup() { for cluster in "$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 summarize_wal = on CONF 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,'full'); SQL pg_basebackup -D "$lab/base" -X stream -c fast -w psql -X -v ON_ERROR_STOP=1 -qAt -c "INSERT INTO marker VALUES (2,'first_increment')" pg_basebackup -D "$lab/inc1" --incremental="$lab/base/backup_manifest" -X stream -c fast -w psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' UPDATE marker SET value='final' WHERE id=1; DELETE FROM marker WHERE id=2; INSERT INTO marker VALUES (3,'second_increment'); SQL pg_basebackup -D "$lab/inc2" --incremental="$lab/inc1/backup_manifest" -X stream -c fast -w for backup in "$lab/base" "$lab/inc1" "$lab/inc2"; do pg_verifybackup -q "$backup" done echo input_backups_verified if pg_combinebackup -o "$lab/missing-middle" "$lab/base" "$lab/inc2" >"$lab/missing-middle.log" 2>&1; then echo 'incomplete chain unexpectedly combined' >&2 exit 1 fi grep -E 'starts at LSN .*but expected' "$lab/missing-middle.log" echo missing_middle_rejected pg_combinebackup -o "$lab/combined" "$lab/base" "$lab/inc1" "$lab/inc2" pg_verifybackup -q "$lab/combined" echo combined_backup_verified mkdir "$restored" cp -a "$lab/combined/." "$restored/" cat >> "$restored/postgresql.conf" <<CONF port = 56433 summarize_wal = off CONF pg_ctl -D "$restored" -l "$lab/restored.log" -w -t 15 start >/dev/null PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' SELECT pg_is_in_recovery(); 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:final,3:second_increment' THEN RAISE EXCEPTION 'incremental restore result mismatch'; END IF; END $$; SQL printf 'lab_directory %s\n' "$lab" 全量备份包含第一行,值为 full。第一次增量参考这份全量的清单,并增加第二行。第二次增量参考第一次增量的清单,把第一行改为 final,删除第二行,再增加第三行。这不仅测试新行出现,也测试已有行的更新和删除,避免把简单追加成功误当成整个增量恢复语义正确。 ...

十二月 27, 2026 · 2 分钟 · XB Field Notes
三份拷贝存放在两个位置,其中一份在很远的地方。

备份遵循 3-2-1 原则

经典的三二一备份规则要求三份数据,分布在两类存储介质上,其中一份位于异地。正在使用的数据本身算一份,因此还需要两份备份。规则的作用是开始拆分共同故障影响,并不保证任意三个文件名都能抵抗硬件故障、误删、管理凭据被盗,或者被同步传播到所有副本的错误。 坏掉的样子 副本之间共享的可能不仅是内容。两个目录可能在同一块盘上,两个虚拟卷可能依赖同一个存储阵列,若干远端备份也可能都能被已经失陷的生产管理员删除。因此,设备、地理位置和访问权限边界需要分别检查。异地降低了本地灾害的共同影响,却不会自动使数据变成离线或不可修改状态。 硬链接是“只数名字”的直接反例。它给同一个底层文件增加目录入口,原地修改文件时,两个名字所看到的内容都会变化。单独复制出来的文件可以抵抗这一种修改,保留较早字节,但仍然可能与原文件共享设备和管理权限风险。因此,“不是同一个文件”和“具备独立灾难恢复能力”之间还有很大距离。 同步也可能造成误判。一个迅速传播删除的镜像,会把恢复所需的旧版本一起移除。保留时间与版本选择,和目标数量一样重要。校验和可以发现内容与可信预期值不符,但若攻击者可以同时替换文件和校验值,同一权限范围内保存的校验和本身并不能证明没有发生恶意替换。 快速验证 把以下内容保存为 copies_lab.py,在支持硬链接的文件系统上用普通 Python 运行,并保持断言开启。脚本只使用标准库和临时文件。两个目录名模拟位置,实际上仍在同一个本地文件系统中。这是一次修改隔离与恢复演练,不是这台机器已经拥有真正异地备份介质的证明。 import hashlib import json import os import shutil import tempfile from pathlib import Path def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest() with tempfile.TemporaryDirectory(prefix="xbcatm-copies-") as directory: root = Path(directory) primary_site = root / "site-a" other_site = root / "site-b-simulation" primary_site.mkdir() other_site.mkdir() primary = primary_site / "account.json" primary.write_text('{"account":"example","balance_cents":2500}\n') expected = digest(primary) alias = primary_site / "hardlink.json" os.link(primary, alias) independent = other_site / "snapshot.json" shutil.copy2(primary, independent) assert os.path.samefile(primary, alias) assert not os.path.samefile(primary, independent) primary.write_text('{"account":"example","balance_cents":0}\n') assert digest(alias) != expected assert digest(independent) == expected print("hardlink_changed_with_primary") print("independent_copy_kept_original_bytes") primary_site.rename(root / "site-a-unavailable") assert not primary.exists() restored = root / "restored.json" shutil.copy2(independent, restored) assert digest(restored) == expected assert json.loads(restored.read_text()) == {"account": "example", "balance_cents": 2500} print("restored_without_original_path", 2500) assert independent.stat().st_dev == restored.stat().st_dev print("same_filesystem_lab_not_real_offsite_protection") 夹具最初保存一个余额为两千五百分的账户。随后创建硬链接名字和单独复制的快照,并在修改源文件之前记录预期摘要。文件身份检查明确哪一个路径仍指向同一文件。一次原地写入把源文件改为零,硬链接看到的值也随之变化;独立复制的文件则继续保持预期字节。 ...

八月 30, 2026 · 1 分钟 · XB Field Notes