预写日志允许数据库把事务变成持久状态,而不必立即刷出这个事务修改过的每一个表页与索引页。在 PostgreSQL 中,恢复某个页面所需的日志,必须在这个页面写入存储之前先持久化。正常同步提交还会等待提交记录完成刷盘。因此,缓冲区里的表变更与已经持久化的日志记录,沿着不同路径向前推进。

坏掉的样子

WAL 不是一张稍后再执行 SQL 的预约单。执行过程已经修改内存页面,日志记录描述的是恢复动作。有些日志来自最终没有提交的事务。恢复需要结合重做与事务状态,确保一个未完成事务不会仅因部分日志或数据页已经落盘,就变成其他查询可见的提交结果。把“日志存在”当成“事务已经成功”,会误解恢复后数据的可见性。

持久性设置会改变承诺。关闭同步提交后,成功响应可能先于日志持久化,崩溃时最近已经确认的事务因此可能消失。关闭文件同步的风险更大,系统故障后甚至可能留下不一致存储。整页写入解决的是另一类问题:在页面只写入一部分时,提供重新构造页面的材料。这些机制都无法弥补存储虚假确认同步,也不能补回所有持久副本同时丢失的数据。

客户端没有收到回复,又是另一种不确定性。服务端可能已经成功提交,连接随后才断开。恢复后事务仍然存在,客户端却从未看到成功,此时直接重试业务动作可能造成重复。因此,对需要保证业务操作唯一性的场景,应使用稳定操作标识并核对结果,不能把网络失败一概解释成数据库已经回滚。

快速验证

在 Linux 环境中,以非超级操作系统账户运行脚本,并准备 PostgreSQL 18 服务端与客户端程序。本次实际使用 PostgreSQL 18.6。脚本初始化隔离的临时集群,使用私有 Unix 套接字,并显式开启正常持久性设置。立即停止操作只针对它刚创建的集群。因为演练会故意打断数据库进程,应使用完整测试夹具,不要把路径替换成生产数据目录。

#!/bin/sh
set -eu
umask 077
lab=$(mktemp -d /tmp/xbcatm-crash.XXXXXX)
mkdir "$lab/socket"
cluster=$lab/data
writer_pid=''
cleanup() {
    if test -f "$cluster/postmaster.pid"; then
        pg_ctl -D "$cluster" -m fast -w stop >/dev/null
    fi
    if test -n "$writer_pid"; then wait "$writer_pid" || :; fi
}
trap cleanup EXIT
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'
port = 56432
fsync = on
synchronous_commit = on
full_page_writes = on
checkpoint_timeout = '1h'
CONF
pg_ctl -D "$cluster" -l "$lab/server.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');
CHECKPOINT;
SQL
checkpoint_before=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT checkpoint_lsn FROM pg_control_checkpoint()')
psql -X -v ON_ERROR_STOP=1 -qAt -c "INSERT INTO marker VALUES (2,'committed')"
commit_end=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_current_wal_lsn()')
test "$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_current_wal_flush_lsn()>='$commit_end'::pg_lsn")" = t
PGAPPNAME=crash_uncommitted psql -X -v ON_ERROR_STOP=1 -qAt >"$lab/writer.log" 2>&1 <<'SQL' &
BEGIN;
UPDATE marker SET value='uncommitted' WHERE id=1;
SELECT pg_sleep(120);
COMMIT;
SQL
writer_pid=$!
attempt=0
while test "$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT EXISTS (SELECT FROM pg_stat_activity WHERE application_name='crash_uncommitted' AND wait_event='PgSleep')")" != t; do
    attempt=$((attempt+1))
    test "$attempt" -lt 100 || { echo 'uncommitted writer was not ready' >&2; exit 1; }
    sleep 0.1
done
test "$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT checkpoint_lsn='$checkpoint_before'::pg_lsn FROM pg_control_checkpoint()")" = t
echo committed_wal_flushed_without_new_checkpoint
pg_ctl -D "$cluster" -m immediate -w stop >/dev/null
if wait "$writer_pid"; then
    writer_pid=''
    echo 'interrupted writer unexpectedly succeeded' >&2
    exit 1
fi
writer_pid=''
pg_ctl -D "$cluster" -l "$lab/server.log" -w -t 15 start >/dev/null
grep -F 'redo starts at' "$lab/server.log"
psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL'
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:committed' THEN
    RAISE EXCEPTION 'crash recovery result mismatch';
  END IF;
END $$;
SQL
printf 'lab_directory %s\n' "$lab"

第一行以 base 值完成检查点。随后脚本提交第二行,并核对已经刷出的日志位置覆盖提交之后捕获的位置。另一个后端在事务里修改第一行,然后在提交之前进入休眠。通过统计视图观察该后端真实进入休眠状态,证明这项未完成更新确实执行了,而不是只把一条尚未送达数据库的命令放在客户端等待。

中断之前,控制文件中的检查点位置必须仍然等于基线。因此,已经确认的插入并不需要另一个检查点。立即停止会打断休眠后端,而且不执行正常关闭检查点。脚本要求这个客户端操作失败,再启动同一个集群,并检查服务端日志中是否真正开始重做。

恢复后必须得到第一行 base、第二行 committed。SQL 断言会拒绝缺行、多行或内容错误的结果。已提交事务被保留,未完成更新不可见。这不能证明某个具体页面一定由重做重新生成,而不是之前已经被后台活动写出;它验证的是存在待决工作且数据库真实被中断时,最终恢复状态符合事务契约。

脚本退出时停止恢复后的集群,并打印保存日志的目录。它没有模拟整机断电、磁盘缓存虚假确认、卷丢失或远端复制故障,这些情形需要各自的恢复证据。特别是进程重启仍然保留宿主机操作系统状态,不能从这个演练推导出所有硬件故障下的保证。

核算完整写入路径

追加日志可以避免每次提交都强制写出分散的数据页,但代价并没有消失。日志仍然消耗带宽、同步等待、存储与复制容量,整页镜像还可能扩大日志流。脏页最终仍需写回,检查点提供恢复起点,并不免除这些工作。把 WAL 描述成简单的一次复制,会漏掉后续数据页写入和恢复期间的成本。

应同时测量提交延迟、日志生成、检查点行为与恢复耗时。通过削弱同步来缩短确认延迟,改变的是持久性承诺,不能当成语义完全相同的优化。还要把本地崩溃恢复与归档恢复、故障切换的保证分开记录。真正有用的结论,是明确哪些已经确认的事务可以恢复,以及这个结论覆盖了实际演练过的哪种故障模型。

来源