连续 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"
首先应出现基础备份已经验证的标记,然后脚本把归档命令临时改成一个确实返回失败的程序。它等待统计中真实出现失败,确认失败文件没有进入归档,再请求检查点,并核对主库仍然保留原始日志。恢复归档命令后,只有进度增加且指定目标文件出现,才打印归档修复标记;随后还比较双方字节,检查复制出来的实际内容。
最后两个标记分别表示接受内容一致的重复归档,以及拒绝同名但内容不同的输入。后一项故意构造不同字节,并再次检查真实归档没有变化。辅助程序先写临时文件、同步内容,再以不覆盖目标的硬链接方式建立最终名字。发现已有名字时,只有比较一致并完成同步才能成功。这样验证的是归档器实际履行的行为,不是把某次命令运行结束当成完整性证明。
所有实验服务都会在脚本退出时停止,打印出的目录保留日志和文件供复核。等待或比较失败会终止实验;归档器故意失败是预期观察,但其他步骤的错误不能因此被统一忽略。实验还保留了原始归档,便于核对拒绝冲突之后的状态,而不是只依赖终端上的成功文字。
保留一个真正可恢复的区间
观察归档进度时,还要一起看积压和剩余空间。归档统计中的失败次数能够记录这次普通命令失败,但部分 shell 错误或归档进程崩溃会触发进程重启,而不增加这个计数,所以也需要读取服务端日志。归档成功数量增长,只证明有文件成功,不足以证明某个基础备份到指定目标之间所需的每个文件都在。
日志段通常结束后才进入归档;低写入实例可以通过归档超时促成切段,代价是占用更多归档空间。流复制发送日志的时机不同,不能把其中一条链路的延迟当成另一条的保证。保留策略必须同时覆盖基础备份、全部必要日志和相关时间线历史,并与承诺的可恢复区间一致。配置文件以及数据库外部依赖需要单独保护。
清单验证可以检查备份完整性,但仍然代替不了恢复演练。实际恢复到隔离实例后,应记录业务数据的状态和已经覆盖的终点,再判断是否达到恢复目标。只有文件数量、备份任务退出码或一个最近成功时间,无法单独回答错误操作发生之前究竟能恢复到哪里,也无法回答恢复出来的数据是否符合业务预期。
延伸阅读
来源
- PostgreSQL 18 连续归档,归档返回码、重复内容、日志保留与恢复链,已核验 2026-09-10。
- PostgreSQL 18 pg_verifybackup,备份清单验证及其边界,已核验 2026-09-10。
- PostgreSQL 18 累计统计,归档进度与失败计数,已核验 2026-09-10。