周期性冲刷脏页,从而缩短重启所需的重做日志。

检查点为恢复建立起点

检查点记录一部分已经完成的工作,使数据库恢复不必把整个日志历史都视为待处理内容。具体操作取决于引擎:PostgreSQL 同步检查点要求的数据变更,建立重做起点;SQLite 的 WAL 模式则把符合条件的已提交页面帧从日志转移到主数据库文件。两者都不能简单当作事务提交的另一个名字。 坏掉的样子 已经请求检查点与检查点完整完成,是两个不同观察。SQLite 读者可能持有旧快照,阻止较新的日志帧写入主文件,或者阻止日志重置。被动检查点可以只做当前能够完成的部分,然后正常返回。若只看成功标志,不看日志总帧数与已完成帧数,就会漏掉尚未处理的工作,以及那个仍然阻止完成的读事务。 日志大小也要放在具体上下文中理解。文件可以继续复用原有空间,因此已分配大小不变时,检查点进度仍可能推进。PostgreSQL 完成检查点之后,也可能继续为归档或复制槽保留旧日志。为了让磁盘曲线变小而手动删除日志,会破坏恢复材料。应先核对引擎进度和保留义务,再判断文件大小究竟代表什么。 检查点间隔不是恢复时间的硬上限。重放速度、存储吞吐、写入形态以及实际崩溃位置,都会影响恢复耗时。更频繁的检查点可能减少未来重做,也可能增加平时写流量。把日志放到另一块设备,不会把所有数据页写入也搬过去,更不会使检查点的写盘成本自动消失。参数选择需要对应具体工作负载。 快速验证 把下面内容保存为 checkpoint_lab.py,使用普通的 python3 checkpoint_lab.py 运行,保持断言开启。脚本只使用标准库 SQLite 模块与私有临时文件。本次记录来自 Python 3.14.4 搭配 SQLite 3.53.0。比较结果时应同时查看运行时的引擎版本,因为 Python 版本与实际链接的 SQLite 版本并不是一回事。 import sqlite3 import tempfile from pathlib import Path with tempfile.TemporaryDirectory(prefix="xbcatm-checkpoint-") as directory: path = Path(directory) / "test.db" writer = sqlite3.connect(path, isolation_level=None, timeout=0.1) reader = sqlite3.connect(path, isolation_level=None, timeout=0.1) try: assert writer.execute("PRAGMA journal_mode=WAL").fetchone() == ("wal",) writer.execute("PRAGMA synchronous=FULL") writer.execute("PRAGMA wal_autocheckpoint=0") writer.execute("CREATE TABLE marker (id INTEGER PRIMARY KEY, value INTEGER)") writer.execute("INSERT INTO marker VALUES (1,10)") assert writer.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone() == (0, 0, 0) reader.execute("BEGIN") assert reader.execute("SELECT value FROM marker").fetchone() == (10,) writer.execute("UPDATE marker SET value=20 WHERE id=1") assert writer.execute("SELECT value FROM marker").fetchone() == (20,) passive = writer.execute("PRAGMA wal_checkpoint(PASSIVE)").fetchone() blocked = writer.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone() print("passive", passive) print("truncate_with_reader", blocked) assert passive[0] == 0 and passive[1] > passive[2] >= 0 assert blocked[0] == 1 and blocked[1] > blocked[2] assert Path(str(path) + "-wal").stat().st_size > 0 assert reader.execute("SELECT value FROM marker").fetchone() == (10,) reader.execute("COMMIT") completed = writer.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone() print("truncate_after_reader", completed) assert completed == (0, 0, 0) assert Path(str(path) + "-wal").stat().st_size == 0 assert reader.execute("SELECT value FROM marker").fetchone() == (20,) print("fresh_reader", 20) finally: reader.close() writer.close() 初始截断检查点把值十写入主文件,并重置日志。随后一个读者显式开启事务,读取十;另一个连接在关闭自动检查点之后,把值二十提交。写者立刻就能读到二十,而旧读者仍然需要自己的原始快照。这个顺序用真实连接建立约束,不依赖线程恰好在某个微小时间窗口发生竞争。 ...

十月 17, 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