多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

PostgreSQL 16 PITR 实战:基于 WAL 归档的任意时间点恢复与脚本化实现

PostgreSQL 16 PITR 实战:基于 WAL 归档的任意时间点恢复与脚本化实现 数据库出故障时最怕什么不是磁盘坏道也不是机房断电而是备份明明有恢复出来的数据却停在了错误的时点。像“昨晚凌晨的全量备份 今天白天的一堆 binlog”这种经典组合在 PostgreSQL 里对应的就是 Point-in-Time Recovery简称 PITR。PostgreSQL 16 里PITR 的链路已经非常清晰先做一次性基础备份再靠 WAL 归档把变更连续记录下来恢复时把备份放到目标实例回放归档日志最终停在你要的那个时间点。全程都可以用脚本固化这也是今天这篇博文的核心——我会一步一步拆解 PG 16 环境下 PITR 的配置、备份、恢复和脚本化实现。这篇内容适合两类人一类是正在维护 PG 生产库、想把备份方案从“每天全量”升级到“任意时间点恢复”的 DBA另一类是开发自建数据库服务、需要自己搞定备份恢复但不想上太重工具的运维。你不用对 WAL 机制有多深的理解按照下面思路走先能跑通再一点点吃透原理。1. 整体设计与核心思路拆解1.1 理解基础备份、WAL归档与恢复目标先打一个我常用的比方基础备份相当于你给房间拍了一张拥挤时的全景照片WAL 归档则是连续不断的监控录像。照片只能记录拍照那一刻但录像记录了之后每一秒发生的事。PITR 要做的事就是拿这张照片再对照录像回放一直放到你指定的某分某秒。PostgreSQL 不会丢帧——WALWrite-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/mainPG16 数据目录安装包默认主数据目录/opt/pg_archiveWAL 归档目录存放压缩后的 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。配置完记得 reloadsudo -u postgres psql -c SELECT pg_reload_conf();2.2 开启 WAL 归档archive_mode 与 archive_commandPITR 的前置开关是 WAL 归档。需要确认三个核心参数wal_level至少为 replicaPG16 默认已经是 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它的核心优势是不需要停库在业务运行期间就能拿到一份一致性备份。常用的参数-Fpplain生成一个完整的、可以直接作为数据目录使用的目录-Ft 则生成 tar 包。对于 PITR我推荐 -Fp因为恢复时少一步解包。-Xsstream备份过程中用流复制方式获取 WAL避免备份起始点和结束点之间的归档断档-X fetch 是结束时批量拉取效果稍差。--checkpointfast让备份快一点开始适合低峰期对 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_HOST127.0.0.1 PG_PORT5432 PG_USERbackup_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 --checkpointfast -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.12308。然后继续写入一些数据再模拟误删除INSERT INTO orders VALUES (3, 300, now()); DELETE FROM orders WHERE id 2; SELECT pg_switch_wal();现在表里有 id1 和 id3id2 被删了。我要把库恢复到 14:30:15 这个点让 id2 回来。这里的关键是选时间点如果误删操作发生在 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 # 文件不存在返回1PostgreSQL 会继续尝试下一个文件 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:1508 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:1508recovery_target_lsn恢复到指定 LSN0/2A5E1B8recovery_target_xid恢复到指定事务 ID12345recovery_target_name恢复到 restore point 名称before_upgraderecovery_target_inclusive是否包含目标点本身false 表示恢复到目标前一刻recovery_target_action达到目标后暂停/提升/关闭promote / pause / shutdown我最常用的组合是 recovery_target_time 加 recovery_target_actionpromote。这样恢复过程结束后数据库自动从只读回放状态切换为可写主库不需要再手动执行 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 提交那么恢复会停在这个事务提交之后或者用 inclusivefalse 停在这个事务开始之前。理解了这个机制你就不会因为恢复出来的数据和你眼里的时间点差了 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;如果 id2 回来了说明恢复成功。接下来清点原损坏现场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:1508。另外一个原因是误删的事务本身就在目标时间点附近恢复可能停在这个事务的边界。如果我要“跳过”某个事务可以用 recovery_target_xid 配合 inclusivefalse。方法是从原数据目录的 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 的变化就能把风险控制在早期。备份这个事宁可过度准备也不要心存侥幸。
返回列表