数据库出故障时最怕什么?不是磁盘坏道,也不是机房断电,而是备份明明有,恢复出来的数据却停在了错误的时点。像“昨晚凌晨的全量备份 + 今天白天的一堆 binlog”这种经典组合,在 PostgreSQL 里对应的就是 Point-in-Time Recovery,简称 PITR。PostgreSQL 16 里,PITR 的链路已经非常清晰:先做一次性基础备份,再靠 WAL 归档把变更连续记录下来,恢复时把备份放到目标实例,回放归档日志,最终停在你要的那个时间点。全程都可以用脚本固化,这也是今天这篇博文的核心——我会一步一步拆解 PG 16 环境下 PITR 的配置、备份、恢复和脚本化实现。
这篇内容适合两类人:一类是正在维护 PG 生产库、想把备份方案从“每天全量”升级到“任意时间点恢复”的 DBA;另一类是开发自建数据库服务、需要自己搞定备份恢复但不想上太重工具的运维。你不用对 WAL 机制有多深的理解,按照下面思路走,先能跑通,再一点点吃透原理。
1. 整体设计与核心思路拆解
1.1 理解基础备份、WAL归档与恢复目标
先打一个我常用的比方:基础备份相当于你给房间拍了一张拥挤时的全景照片,WAL 归档则是连续不断的监控录像。照片只能记录拍照那一刻,但录像记录了之后每一秒发生的事。PITR 要做的事,就是拿这张照片,再对照录像回放,一直放到你指定的某分某秒。PostgreSQL 不会丢帧——WAL(Write-Ahead Log)在事务提交前就写好了,只要归档持续进行,理论上可以恢复到故障前最后一个成功提交的事务。
这里要分清一个概念:PITR 不是“增量备份”。增量备份一般是只保存两次备份之间的变化文件,而 PITR 的基础备份是一份完整一致的数据目录快照,后续所有修改都是通过重放 WAL 记录来还原。WAL 记录的是物理变化,包括页面的字节级修改,因此它能还原到非常精确的位置。PostgreSQL 16 还引入了一些改进,但我个人体会,PITR 这套核心机制在 PG 12 之后就已经很稳定了,16 版本只需要按新语法配置即可。
恢复时还要理解“时间线”(timeline)。每次执行恢复到某个点并启动数据库后,如果继续写入,就进入了一个新的时间线,PostgreSQL 会用时间线 ID 来区分不同分支的 WAL 文件,避免新旧数据交错。这就像录像带从某个时间点开始重新录制,原录像还在,但新内容录在新磁带上。
1.2 为什么脚本化配置特别重要
手工配置 PITR 最大的问题是步骤多、容易漏。比如忘记改 archive_command 的属主权限、恢复前没有删掉 standby.signal、restore_command 路径写错,每一个小错都会让恢复失败。而脚本化之后,这些动作被固化成固定命令,执行一次就是一套完整流程,同时日志输出会告诉你哪个环节出了问题。
脚本化还有个隐藏收益:高频演练。数据库备份最怕的是“备而不验”。你永远不知道备份能不能恢复,直到你真的去恢复。如果把恢复动作写成了脚本,就可以在测试实例上每周做一次恢复演练,验证备份可用性,这比任何备份监控面板都更实在。
1.3 先规划好目录和脚本职责
PITR 涉及的路径和角色比较多,建议一开始就规划清楚。我自己的习惯是单独一个 /opt/pg_archive 做归档目录,/opt/pg_backup 做基础备份目录,两个目录分开,避免恢复时把备份目录和数据目录搞混。
规划目录时还要考虑两个场景。第一个是归档命令:PostgreSQL 主库的每个 WAL 文件写完,都会调用 archive_command,把文件归档到独立目录;第二个是恢复命令:恢复时数据库会调用 restore_command,从归档目录取回 WAL 文件并重放。这两个动作可以理解为归档脚本是“写入端”,恢复脚本是“读取端”。下面我给出的脚本会让两端保持对称:归档脚本怎么存,恢复脚本就怎么取,这样排查问题会简单很多。
| 路径/组件 | 用途 | 说明 |
|---|---|---|
| /var/lib/postgresql/16/main | PG16 数据目录 | 安装包默认主数据目录 |
| /opt/pg_archive | WAL 归档目录 | 存放压缩后的 WAL 文件 |
| /opt/pg_backup | 基础备份目录 | 存放 pg_basebackup 生成的完整备份 |
| /var/log/pg_archive.log | 归档日志 | 记录每次归档成功/失败 |
| /var/log/pg_backup.log | 备份日志 | 记录备份过程 |
| archive_wal.sh | 归档脚本 | 由 PostgreSQL 在归档时调用 |
| restore_wal.sh | 恢复取数脚本 | 由 PostgreSQL 在恢复时调用 |
| backup.sh | 基础备份脚本 | 手动或按计划任务执行 |
| restore.sh | 时间点恢复脚本 | 手动执行恢复流程 |
这个表格基本就是整个项目的骨架,后面所有操作都围绕这些路径展开。
2. 环境准备与基础参数配置
2.1 版本、目录、账号权限准备
我以 Ubuntu 上的 PostgreSQL 16 为例,其他发行版差别不大。先确认版本:
postgres --version如果输出 PostgreSQL 16.x,就没问题。然后准备目录:
sudo mkdir -p /opt/pg_archive /opt/pg_backup sudo chown -R postgres:postgres /opt/pg_archive /opt/pg_backup归档目录和备份目录的属主必须是 postgres 用户,因为 PostgreSQL 进程是以 postgres 身份运行的。接下来创建一个用于备份的数据库角色,不要直接用 postgres 超级用户跑备份,更规范:
CREATE ROLE backup_user REPLICATION LOGIN PASSWORD 'strong_password';这里的 REPLICATION 权限是 pg_basebackup 建立流复制连接所必需的。然后在 pg_hba.conf 里允许本机访问:
local replication backup_user scram-sha-256如果备份脚本要从远程执行,把 local 改成 host 并限制 IP。配置完记得 reload:
sudo -u postgres psql -c "SELECT pg_reload_conf();"2.2 开启 WAL 归档:archive_mode 与 archive_command
PITR 的前置开关是 WAL 归档。需要确认三个核心参数:
- wal_level:至少为 replica,PG16 默认已经是 replica,但如果之前调成过 minimal,必须改回来。
- archive_mode:设置为 on,开启归档。注意 archive_mode 需要重启数据库才能生效,不是 reload 就能生效。
- archive_command:指定归档命令,可以是一条 shell 命令,也可以是一个脚本路径。
我用 ALTER SYSTEM 设置,避免直接改 postgresql.conf 时手滑:
ALTER SYSTEM SET wal_level = 'replica'; ALTER SYSTEM SET archive_mode = 'on'; ALTER SYSTEM SET archive_command = '/opt/pg_archive/archive_wal.sh %p %f'; ALTER SYSTEM SET archive_timeout = '300';然后重启数据库:
sudo pg_ctlcluster 16 main restart这里重点解释一下 archive_command 里的 %p 和 %f。PostgreSQL 归档时会把当前 WAL 文件的完整路径传给 %p,把文件名传给 %f。比如 %p 可能是 /var/lib/postgresql/16/main/pg_wal/000000010000000000000001,%f 则是 000000010000000000000001。脚本拿到这两个参数后,再决定怎么压缩、放哪里。archive_timeout 是一个兜底机制,它会在数据库空闲时也强制切换 WAL,保证归档目录不会长期停滞。不要设置太小,否则会产生大量空 WAL 文件,建议 300 秒左右。
2.3 编写归档脚本 archive_wal.sh
归档脚本是整个 PITR 的第一道生命线,必须简单、可靠、有日志。我直接把常用脚本贴出来:
#!/bin/bash # /opt/pg_archive/archive_wal.sh # 用法:archive_wal.sh %p %f WAL_SRC="$1" WAL_NAME="$2" ARCHIVE_DIR="/opt/pg_archive" LOG_FILE="/var/log/pg_archive.log" DEST_FILE="${ARCHIVE_DIR}/${WAL_NAME}.gz" # 已经存在同名的归档,说明重复归档,直接成功返回 if [ -f "${DEST_FILE}" ]; then exit 0 fi # 压缩写入临时文件,避免覆盖已有文件 gzip -c "${WAL_SRC}" > "${DEST_FILE}.tmp" RC=$? if [ ${RC} -ne 0 ]; then echo "$(date '+%F %T') [error] gzip failed: ${WAL_NAME} rc=${RC}" >> "${LOG_FILE}" rm -f "${DEST_FILE}.tmp" exit 1 fi mv "${DEST_FILE}.tmp" "${DEST_FILE}" echo "$(date '+%F %T') [info] archived ${WAL_NAME}" >> "${LOG_FILE}" # 清理7天前的归档文件,按需调整 find "${ARCHIVE_DIR}" -type f -name "*.gz" -mtime +7 -delete exit 0这段脚本有几个设计要点值得说:
- 用临时文件名再加 mv 的方式,避免 gzip 写到一半时进程崩溃留下半截文件;PostgreSQL 如果发现归档命令返回非 0,会反复重试,所以成功之后再输出主文件。
- gzip 压缩率对 WAL 很友好,通常能压到原来的 1/5 到 1/10,大幅降低归档目录存储压力。
- 每次归档都写日志,后续排查时可以直接看 /var/log/pg_archive.log,而不是翻 PostgreSQL 日志。
需要注意,脚本必须可执行且属主是 postgres 用户:
sudo chown postgres:postgres /opt/pg_archive/archive_wal.sh sudo chmod 755 /opt/pg_archive/archive_wal.sh2.4 验证归档是否生效
配置完成后不要急着做备份,先验证归档链路。执行一次手动 WAL 切换:
SELECT pg_switch_wal();然后查看归档目录:
ls -l /opt/pg_archive应该能看到类似 000000010000000000000001.gz 的文件。同时查询归档统计视图:
SELECT archived_count, last_archived_wal, last_failed_wal, last_failed_time FROM pg_stat_archiver;如果 archived_count 开始增长,last_failed_wal 为空,说明归档链路正常。如果 last_failed_time 有值,去 /var/log/pg_archive.log 或 PostgreSQL 日志看具体错误。
还有一个磁盘空间的提醒:归档目录需要按业务写入量估算,不能简单拍脑袋。一个 WAL segment 是 16MB,如果业务高峰期每分钟切换一次,一天就是 1440 个 segment,约 23GB 原始数据,压缩后大概 2~5GB。保存 7 天,最少预留 50GB。我见过不少实例因为归档目录写满导致数据库无法分配 WAL,进而直接卡住,这个坑一定要提前避开。
3. 基础备份的脚本化生成
3.1 在线备份工具 pg_basebackup 的参数选择
验证归档之后,开始做基础备份。PostgreSQL 官方在线备份工具是 pg_basebackup,它的核心优势是不需要停库,在业务运行期间就能拿到一份一致性备份。常用的参数:
- -Fp(plain):生成一个完整的、可以直接作为数据目录使用的目录;-Ft 则生成 tar 包。对于 PITR,我推荐 -Fp,因为恢复时少一步解包。
- -Xs(stream):备份过程中用流复制方式获取 WAL,避免备份起始点和结束点之间的归档断档;-X fetch 是结束时批量拉取,效果稍差。
- --checkpoint=fast:让备份快一点开始,适合低峰期;对 I/O 敏感的库可以用默认的 spread。
- -R:生成 standby.signal 和连接配置,这是做从库时才用的。做独立恢复备份时不要加 -R,否则后面还要手动删文件。
3.2 编写基础备份脚本 backup.sh
备份脚本我一般这样写:
#!/bin/bash # /opt/pg_backup/backup.sh set -euo pipefail BACKUP_BASE="/opt/pg_backup" BACKUP_DIR="${BACKUP_BASE}/base_$(date +%Y%m%d_%H%M%S)" PG_HOST="127.0.0.1" PG_PORT="5432" PG_USER="backup_user" LOG_FILE="/var/log/pg_backup.log" mkdir -p "${BACKUP_BASE}" echo "$(date '+%F %T') [info] start backup" >> "${LOG_FILE}" pg_basebackup -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_USER}" -Fp -Xs -P -v --checkpoint=fast -D "${BACKUP_DIR}" echo "$(date '+%F %T') [info] backup success: ${BACKUP_DIR}" >> "${LOG_FILE}" # 只保留最近7份备份 ls -1dt ${BACKUP_BASE}/base_* 2>/dev/null | tail -n +8 | xargs -r rm -rf注意几点:
- set -euo pipefail 让脚本在任意一步失败时立刻退出,避免生成一个残次品备份还自以为成功。
- -P 显示进度,-v 输出详细信息,在日志里能看到传输了多少数据。
- 备份目录名带时间戳,多个备份不会互相覆盖。
- 最后一行是保留策略,只留最近 7 份,防止备份把磁盘撑爆。
验证脚本可以执行一次:
sudo -u postgres /opt/pg_backup/backup.sh3.3 备份完整性检查
备份完成后,先检查目录结构:
ls /opt/pg_backup/base_20260923_000000关键文件包括 backup_label、backup_manifest、base、global、pg_wal 等。backup_label 记录了备份起始的 LSN 和时间线,恢复时会用到。PG16 还生成了 backup_manifest,这是备份文件的清单,可以用官方工具校验:
sudo -u postgres pg_verifybackup -D /opt/pg_backup/base_20260923_000000如果输出一堆 OK,说明文件完整。如果提示缺少某些 WAL 文件,不要慌,这往往是因为备份过程中的 WAL 没有全部放进备份目录,需要结合归档目录做二次校验。真正的终极验证还是做一次恢复演练,这是后话,但也是我强烈建议的环节。
4. 恢复实操:把一个数据库恢复到指定时间点
4.1 模拟一个需要 PITR 的故障场景
理论说够了,直接进入恢复实操。假设我有一张订单表,业务运行期间误删了一批数据,我需要在删除之前的时间点把库恢复出来。
先造一个场景:
CREATE TABLE orders (id int, amount numeric, created_at timestamptz); INSERT INTO orders VALUES (1, 100, now()), (2, 200, now()); SELECT pg_switch_wal();等几秒让归档完成,记录一个时间点:
SELECT now();假设输出是 2026-09-23 14:30:15.123+08。然后继续写入一些数据,再模拟误删除:
INSERT INTO orders VALUES (3, 300, now()); DELETE FROM orders WHERE id = 2; SELECT pg_switch_wal();现在表里有 id=1 和 id=3,id=2 被删了。我要把库恢复到 14:30:15 这个点,让 id=2 回来。这里的关键是选时间点:如果误删操作发生在 14:31,而我要恢复到 14:30:15,那是安全的。实际操作中你未必记得精确时间,但可以从应用日志、慢查询日志或者监控系统里找到大概窗口,再用时间点恢复逐步试探。
4.2 编写恢复取数脚本 restore_wal.sh
恢复时 PostgreSQL 会执行 restore_command。由于归档脚本把 WAL 压缩成了 .gz,恢复时就不能用简单的 cp,必须解压。我写了独立的 restore_wal.sh:
#!/bin/bash # /opt/pg_archive/restore_wal.sh # 用法:restore_wal.sh %f %p WAL_NAME="$1" DEST_PATH="$2" ARCHIVE_BASE="/opt/pg_archive" SRC_FILE="${ARCHIVE_BASE}/${WAL_NAME}.gz" if [ ! -f "${SRC_FILE}" ]; then # 文件不存在,返回1,PostgreSQL 会继续尝试下一个文件 exit 1 fi gunzip -c "${SRC_FILE}" > "${DEST_PATH}" exit $?这个脚本和 archive_wal.sh 是严格对称的:归档脚本存成 /opt/pg_archive/文件名.gz,恢复脚本就去同一目录找同名 .gz 文件并解压到 %p。这种对称设计能少踩很多坑,尤其是当你后续把归档目录换到对象存储或异地节点时,只要修改两个脚本的“存取规则”即可。
4.3 执行恢复主流程
我通常会把恢复流程再封装成一个更上层的 restore.sh,这样手动执行时不用记一长串步骤。下面是一个示例,假设目标备份是 /opt/pg_backup/base_20260923_000000,目标恢复时间是上面记录的时间:
#!/bin/bash # /opt/pg_backup/restore.sh # 用法:restore.sh "2026-09-23 14:30:15+08" set -euo pipefail TARGET_TIME="$1" TARGET_BACKUP="/opt/pg_backup/base_20260923_000000" PG_DATA="/var/lib/postgresql/16/main" ARCHIVE_BASE="/opt/pg_archive" LOG_FILE="/var/log/pg_restore.log" DAMAGED_DIR="${PG_DATA}.damaged_$(date +%Y%m%d_%H%M%S)" # 1. 安全检查:备份目录必须存在且包含 backup_label if [ ! -f "${TARGET_BACKUP}/backup_label" ]; then echo "[error] backup_label not found in ${TARGET_BACKUP}" | tee -a "${LOG_FILE}" exit 1 fi echo "$(date '+%F %T') [info] stop postgresql" | tee -a "${LOG_FILE}" sudo pg_ctlcluster 16 main stop -m fast || true # 2. 保留原数据目录,留作现场,确认后再删 if [ -d "${PG_DATA}/base" ]; then echo "$(date '+%F %T') [info] move old data to ${DAMAGED_DIR}" | tee -a "${LOG_FILE}" mv "${PG_DATA}" "${DAMAGED_DIR}" fi # 3. 用基础备份重建数据目录 mkdir -p "${PG_DATA}" cp -a "${TARGET_BACKUP}/." "${PG_DATA}/" chown -R postgres:postgres "${PG_DATA}" # 4. 清理可能存在的 standby.signal rm -f "${PG_DATA}/standby.signal" # 5. 写入恢复配置。注意恢复信号文件 recovery.signal cat >> "${PG_DATA}/postgresql.conf" <<EOF restore_command = '/opt/pg_archive/restore_wal.sh %f %p' recovery_target_time = '${TARGET_TIME}' recovery_target_action = 'promote' EOF touch "${PG_DATA}/recovery.signal" echo "$(date '+%F %T') [info] start postgresql with recovery target ${TARGET_TIME}" | tee -a "${LOG_FILE}" sudo pg_ctlcluster 16 main start这个脚本自动完成停库、保留现场、重建数据目录、写入恢复配置、创建 recovery.signal、启动数据库。启动后要立刻看日志,确认恢复是否到达目标时间并成为可写主库。
PG16 的恢复配置有两个关键点:
- recovery.signal 是一个空文件,只要它存在于数据目录,数据库启动就会进入恢复模式。恢复完成后,PostgreSQL 会自动删除这个文件,下次重启就不再恢复。
- 恢复目标参数直接写在 postgresql.conf 里,和普通参数一样。因为 recovery.signal 在成功后删除,即使 restore_command 这些参数还留在配置文件里,也不会影响后续启动。
4.4 恢复目标参数和行为解读
很多人只知道 recovery_target_time,其实 PG16 提供了一整套恢复目标参数:
| 参数 | 作用 | 示例 |
|---|---|---|
| recovery_target_time | 恢复到指定时间点 | '2026-09-23 14:30:15+08' |
| recovery_target_lsn | 恢复到指定 LSN | '0/2A5E1B8' |
| recovery_target_xid | 恢复到指定事务 ID | '12345' |
| recovery_target_name | 恢复到 restore point 名称 | 'before_upgrade' |
| recovery_target_inclusive | 是否包含目标点本身 | false 表示恢复到目标前一刻 |
| recovery_target_action | 达到目标后暂停/提升/关闭 | promote / pause / shutdown |
我最常用的组合是 recovery_target_time 加 recovery_target_action='promote'。这样恢复过程结束后,数据库自动从只读回放状态切换为可写主库,不需要再手动执行 pg_promote()。如果我想在提升前先看一眼数据,就把 recovery_target_action 设为 pause,启动后查询确认,然后执行:
SELECT pg_promote();再用 ALTER SYSTEM 把 recovery_target_action 改回去,避免下次重建时误用。不过如果用的是我的 restore.sh,每次都是临时写入配置文件,重建时会重新生成,不受残留影响。
还要提一个事务边界的问题。WAL 重放粒度是事务,不是单条 SQL。如果你的目标时间落在某个事务中间,PostgreSQL 会选择停在该事务的边界。比如目标时间是 14:30:15,但有一个事务在 14:30:10 开始、14:30:20 提交,那么恢复会停在这个事务提交之后,或者用 inclusive=false 停在这个事务开始之前。理解了这个机制,你就不会因为恢复出来的数据和你眼里的时间点差了 5 秒而感到奇怪。
4.5 恢复后的验证与清理
启动后,日志里应该能看到类似这样的信息:
LOG: recovery stopping before commit of transaction 12345, time ... LOG: recovery complete LOG: database system is ready to accept connections然后验证:
SELECT pg_is_in_recovery();恢复完成并 promote 后,这个函数返回 false,表示已经是一个独立的主库。再查数据:
SELECT * FROM orders ORDER BY id;如果 id=2 回来了,说明恢复成功。接下来清点原损坏现场:
sudo ls -d /var/lib/postgresql/16/main.damaged_*确认不需要旧数据后可以删除,但至少保留一天,等业务验证完全没有问题再删。这个习惯能救命,因为如果恢复出来的数据和预期不一致,你还可以拿原损坏数据目录里的 pg_wal 继续追数据。
5. 常见问题与排查技巧实录
5.1 archive_command 失败,归档堆积
最常见的故障是归档目录权限不对或脚本写错,导致 archive_command 一直返回失败。表象是 pg_stat_archiver 里 last_failed_time 不断更新,归档目录没有新文件,而 pg_wal 目录不断膨胀。处理方式很简单:
SELECT last_failed_wal, last_failed_time, failed_count, archived_count FROM pg_stat_archiver;然后看 PostgreSQL 日志,里面会直接打印 archive_command 失败时的输出和错误码。绝大多数情况是脚本没有执行权限、路径写错、未设置 chown,或者是磁盘满。修复后,PostgreSQL 会继续重试失败的那个 WAL,不需要额外操作。这里提醒一下:如果 because 磁盘满导致归档失败,先清理空间再解决脚本,顺序别反了。
5.2 恢复时一直找不到 WAL 文件
恢复过程中出现 “could not restore file ... from archive” 时,第一反应是 restore_wal.sh 找不到文件。常见原因有三个:
- 文件名不匹配:归档脚本和恢复脚本使用的路径或压缩后缀不一致,归档存成 .gz,恢复时却找不 .gz。
- 归档目录里确实没有这个文件:可能是备份完成后某段时间 archive_command 一直在失败,WAL 断档了。解决办法是先查归档日志,确认断档时间段,再想办法从 pg_wal 里补归档。
- restore_command 返回码语义错误:PostgreSQL 期望文件不存在时返回非 0,否则会认为文件已恢复。我的 restore_wal.sh 缺失时返回 1,这是符合语义的。
排查时先在命令行手动模拟一下:
sudo -u postgres /opt/pg_archive/restore_wal.sh 000000010000000000000001 /tmp/test_wal如果这条命令本身都无法生成文件,说明脚本逻辑有问题,先修脚本再谈恢复。
5.3 恢复到的数据总是比预期少一段
这十有八九是时区问题或者 inclusive 参数没搞对。recovery_target_time 如果没带时区,PostgreSQL 会按数据库所在时区解析,而应用记录的时间可能是另一个时区。所以写恢复目标时,一定带上完整的 +08 或者其他时区偏移,例如 '2026-09-23 14:30:15+08'。
另外一个原因是误删的事务本身就在目标时间点附近,恢复可能停在这个事务的边界。如果我要“跳过”某个事务,可以用 recovery_target_xid 配合 inclusive=false。方法是从原数据目录的 pg_wal 里用 pg_waldump 找目标事务 ID,然后重新做一次恢复,精确度比时间点更高。
5.4 加了 -R 导致恢复后一直是只读 standby
你会遇到一种情况:数据都恢复了,但数据库一直处于 hot standby 只读状态,怎么都无法写入。最常见的原因就是生成备份时 pg_basebackup 带了 -R,自动生成了 standby.signal。恢复脚本里我特意加了 rm -f standby.signal 这一步,但你如果是手工恢复,很容易漏掉。
检查办法:
ls -l /var/lib/postgresql/16/main/standby.signal如果这个文件存在,删除后重启数据库就会升级为独立主库。或者不重启,直接执行SELECT pg_promote();也可以。
5.5 给我的恢复演练脚本加了一个“隔离端口”
恢复演练最怕影响线上实例。我的习惯是恢复时给临时实例用不同的端口和不同的数据目录,验证完直接删掉。这只需要在 start 之前改一下 postgresql.conf 里的 port 参数,并把 recovery_target_action 设成 pause,这样临时实例不会自动 promote,也不会有写流量进去,安全很多。演练的核心不是“能不能启动”,而是“数据和时间点对不对”。数据对了,再决定是否提升并继续使用。
6. 写在最后:我的几点实操心得
做 PITR 这些年,最大的体会是:归档脚本和恢复脚本一定要成对设计,就像一把锁配一把钥匙。归档时怎么命名、怎么压缩、放在哪个目录,恢复时就原样反向取回,不要两边各自发挥,否则你会浪费大量时间在调试文件路径上。我的脚本虽然看起来简单,但已经稳定跑过多个版本的 PG,核心逻辑没怎么改过。
另一个心得是:不要等灾难发生时才去测恢复。我在测试环境每个季度固定做一次 PITR 恢复演练,用当天的基础备份,恢复到上周的某个时间点,然后对比数据。演练看起来费时,但能帮你提前发现备份文件损坏、归档断档、权限变更等一系列问题,真到故障那天,你会感谢平时攒下的这些经验。
最后送一个小技巧:把归档目录的磁盘监控报警级别设成和数据库本身一样高。WAL 归档失败后数据库未必立刻出问题,但 PITR 的可用性已经开始倒计时。安排一个简单的 cron 检查 /var/log/pg_archive.log 里最近 30 分钟有没有 error,或者直接监控 pg_stat_archiver 中 last_failed_time 的变化,就能把风险控制在早期。备份这个事,宁可过度准备,也不要心存侥幸。