持续归档 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
一棵用持久性换写入速度的 Postgres 表。

UNLOGGED 表绕过了预写日志

PostgreSQL 的 UNLOGGED 表保留正常 SQL 事务行为,但放弃其内容的崩溃恢复能力。它适合能够重复生成的暂存数据,不适合把唯一副本托付给一次提交。判断边界是服务器是否经历了非正常关闭后的恢复,不能简单地把“重启”两个字等同于数据丢失,也不能因为一次正常重启后行还在,就认为它具备持久性保证。 哪些内容跳过 WAL 非日志表的数据变化及其索引变化不写入预写日志。系统目录的变化仍可能产生 WAL,同一个事务还可能修改普通表,所以不能把整个工作负载描述为“完全没有 WAL”。表的数据页仍然占据存储空间,仍要经过缓冲区管理和后续写回;UNLOGGED 并不是内存表,也不是不需要磁盘的缓存。写入收益来自减少特定日志工作,实际改善幅度需要测量,不能承诺固定倍数。 服务器正常运行时,事务、快照、约束和回滚仍然生效。语句被拒绝或应用断开连接,并不意味着此前提交的全部行都会消失。但是后端进程异常退出可能导致服务器重启其他进程并进入恢复,因此也不能声称“应用事件绝不影响非日志表”。排查时应查看 PostgreSQL 日志和恢复过程,而不是仅凭事故最初发生在哪一层就推断数据状态。 重启与复制的边界 干净关闭后,非日志表内容会保留到下次启动。pg_ctl 的 fast 模式会回滚正在执行的事务并正常关闭,immediate 模式则直接中止进程,不完成干净关闭。后者需要崩溃恢复,恢复期间非日志关系会被重置:表定义仍在,但此前提交的行不再存在。主机维护若完成了干净关闭,并不会仅因为“重启”就清空这类表;反过来,不能把正常关闭的测试当成抗崩溃证明。 物理备库不会收到非日志表的数据内容。备库仍处于恢复模式时,读取这类关系会报错,而不是成功返回零行。提升为主库后可以读取它,但没有已经复制过来的行可以恢复。因此故障切换流程必须先重建或重新填充,再允许依赖这些数据的消费者进入服务。主备拥有同一个表定义,只说明目录信息存在,并不代表表中业务数据已经复制。 普通日志表不能用外键引用非日志表。PostgreSQL 会拒绝这样的建表语句,因为持久化的子表引用不能安全依赖可能在恢复中消失的父表行。下面的实验直接检查这一限制,不会把它描述成“外键还在、父表行没了、数据库默默允许不一致”。设计暂存区时,要把关系约束放到符合持久性边界的位置,而不是事后修补孤立记录。 坏掉的样子 一个常见事故是导入程序把文件装入非日志暂存表后就确认成功,并删除唯一的原始文件。随后服务器崩溃,暂存行消失,而输入已无处重取,这就变成不可恢复的数据丢失。原始输入应该至少保留到持久化目标提交成功,重建流程也必须知道需要哪一个输入版本。去重记录和导入检查点要属于持久化契约,不能和暂存内容一起丢掉。 另一个问题出现在报表服务改读备库,或刚完成提升就立即对外服务。只检测行数为零会漏掉恢复模式下的读取错误;无限重试也无法生成不存在的数据。应明确标记“正在重建”,在重建完成且结果验证通过前控制请求准入。演练要测量重建耗时、上游是否可用、记录数及内容是否符合预期;只建回一张空表不算恢复,能运行的脚本也不等于已经证明恢复时间满足要求。 快速验证 把 Bash 代码保存为 unlogged.sh。它要求以非 root 用户执行 PostgreSQL 18 的命令行工具。下面的容器命令提供工具,不发布网络端口,也不挂载宿主机数据;两个实验数据库都位于容器内的新临时目录。 docker run --rm --network none --user postgres --entrypoint bash -i \ postgres:18-alpine < unlogged.sh #!/usr/bin/env bash set -euo pipefail pg_lab=$(mktemp -d /tmp/unlogged-lab.XXXXXX) cleanup() { for cluster in standby primary; do if [ -f "$pg_lab/$cluster/postmaster.pid" ]; then pg_ctl -D "$pg_lab/$cluster" -m immediate -w stop >/dev/null fi done rm -rf -- "$pg_lab" } trap cleanup EXIT primary() { psql -X -h "$pg_lab" -p 55432 -d postgres -v ON_ERROR_STOP=1 "$@"; } standby() { psql -X -h "$pg_lab" -p 55433 -d postgres -v ON_ERROR_STOP=1 "$@"; } start_primary() { pg_ctl -D "$pg_lab/primary" -l "$pg_lab/primary.log" \ -o "-k $pg_lab -p 55432 -c listen_addresses=''" -w start >/dev/null } initdb -D "$pg_lab/primary" --no-locale --encoding=UTF8 --auth=trust >/dev/null start_primary primary <<'SQL' CREATE TABLE durable (id integer PRIMARY KEY); CREATE UNLOGGED TABLE scratch (id integer PRIMARY KEY); INSERT INTO durable VALUES (1); INSERT INTO scratch VALUES (1); BEGIN; INSERT INTO scratch VALUES (2); ROLLBACK; DO $$ BEGIN EXECUTE 'CREATE TABLE durable_ref (id integer REFERENCES scratch(id))'; RAISE EXCEPTION 'a logged table unexpectedly referenced an unlogged table'; EXCEPTION WHEN invalid_table_definition THEN RAISE NOTICE 'logged-to-unlogged foreign key rejected'; END $$; SQL counts='SELECT (SELECT count(*) FROM durable), (SELECT count(*) FROM scratch)' test "$(primary -Atc "$counts")" = '1|1' pg_ctl -D "$pg_lab/primary" -m fast -w stop >/dev/null start_primary test "$(primary -Atc "$counts")" = '1|1' printf 'clean restart: 1|1\n' pg_basebackup -h "$pg_lab" -p 55432 -D "$pg_lab/standby" -R -X stream -c fast pg_ctl -D "$pg_lab/standby" -l "$pg_lab/standby.log" \ -o "-k $pg_lab -p 55433 -c listen_addresses=''" -w start >/dev/null test "$(standby -Atc 'SELECT pg_is_in_recovery()')" = t test "$(standby -Atc 'SELECT count(*) FROM durable')" = 1 if standby -Atc 'SELECT count(*) FROM scratch' 2>"$pg_lab/read-error"; then echo 'unlogged read unexpectedly succeeded on standby' >&2 exit 1 fi grep -F 'cannot access temporary or unlogged relations during recovery' "$pg_lab/read-error" pg_ctl -D "$pg_lab/standby" -w promote >/dev/null test "$(standby -Atc "$counts")" = '1|0' printf 'promoted standby: 1|0\n' pg_ctl -D "$pg_lab/primary" -m immediate -w stop >/dev/null start_primary test "$(primary -Atc "$counts")" = '1|0' printf 'crash recovery: 1|0\n' primary -Atc 'SHOW server_version' echo 'PASS: rollback, foreign key restriction, clean restart, standby, promotion, crash' 主库与备库使用同一个临时目录中的不同 Unix 套接字端口,退出时的 trap 只关闭这两个实验集群并移除其文件。实验故意对临时主库执行 immediate 关闭,不能把这一步改成已有数据库目录。PostgreSQL 18.6 上,回滚和正常重启后的计数都是 1|1;仍处于恢复模式的备库拒绝读取 scratch。备库提升和主库崩溃恢复后分别得到 1|0,表明持久化行还在而暂存内容不在。错误文本断言使用集群的 C locale,避免本地化消息造成误判。 ...

五月 24, 2027 · 2 分钟 · XB Field Notes