提示

返回博客列表

备份没验证过就等于没有:pg_dump、PITR 和我们的第一次真实恢复演练

我们有个跑了两年的备份脚本:每天凌晨 3 点 pg_dump,压缩后传到对象存储,保留 30 天。它一直"工作良好"——因为我从不检查。

直到有天需要回滚一批误删的数据。我登上去看备份目录,发现最新的文件是 27 天前的,之后每天都是 0 字节。查了半天才明白:三个月前我们给数据库加了张表,备份账号没有那张表的权限,pg_dump 从那天起就一直在失败退出——而脚本里没有检查退出码,cron 的报错邮件也早就被忽略了。

更可怕的是:就算备份文件是好的,我也不知道能不能恢复。我们从来没真的恢复过一次。

那次之后我们重做了整个备份体系,并且做了第一次完整的恢复演练。演练里发现了五个问题,每一个都足以让"恢复"这件事失败。这篇写全部过程。

TL;DR:备份的核心不是"每天跑一次脚本",而是三件事:备份失败要告警(静默失败是最危险的)、备份文件要可验证(至少用 pg_restore -l 列出内容)、定期真的恢复一次(演练)。方案上:每天 pg_dump -Fd -j 逻辑备份做兜底,重要系统加 WAL 归档做 PITR(时间点恢复)。记住 3-2-1 原则:3 份副本、2 种介质、1 份异地。RPO(能丢多少数据)和 RTO(多久能恢复)要写在文档里,演练就是为了验证这两个数字。

目录

一、备份的三个层次,先知道自己要哪种

类型 做法 恢复粒度 速度 复杂度
逻辑备份 pg_dump / pg_dumpall 整个库、单表、单 schema 备份慢、恢复也慢 低
物理备份 + WAL(PITR) pg_basebackup + WAL 归档 任意时间点(秒级) 备份快、恢复快 中
存储快照 云盘快照、LVM、ZFS 快照时刻 最快 低(依赖平台)

选择标准其实就两个数字:

  • RPO(Recovery Point Objective):能接受丢多少数据?
  • RTO(Recovery Time Objective):能接受多久恢复?

我们给自己的目标是:RPO ≤ 5 分钟,RTO ≤ 1 小时。

这意味着:

  • 只做每天一次的逻辑备份不够(最坏情况丢 24 小时数据);
  • 需要 PITR(WAL 归档),它把 RPO 压到秒级;
  • 逻辑备份保留作为"兜底"——因为 PITR 配置复杂,出问题时逻辑备份是最容易理解的救命稻草。

我的建议:至少要有逻辑备份,哪怕它不够好。一个每天跑的 pg_dump 胜过一堆没落地的完美方案。然后如果业务允许不了丢一天数据,再上 PITR。

二、逻辑备份:pg_dump 的正确姿势

格式选择

pg_dump 有几种输出格式,直接影响你能不能并行、能不能选择性恢复:

格式 参数 特点
纯 SQL 默认 / -Fp 文本,可用 psql 直接导入;不能并行、不能选择性恢复
自定义 -Fc 压缩、可用 pg_restore 选择性恢复、恢复可并行
目录 -Fd 备份可并行(-j),恢复也可并行;大库首选
tar -Ft 少用

大库用 -Fd(目录格式)+ -j(并行):

pg_dump -h 127.0.0.1 -U backup_user -d viddown \
  -Fd -j 4 -f /backup/viddown_$(date +%F) \
  --compress=9 \
  --no-owner --no-acl

注意:-j 只对 -Fd 有效(pg_dump 的并行需要目录格式)。-Fc 的并行只能在 pg_restore 时用。

参数说明:

  • -j 4:4 个并行任务(建议等于 CPU 核数,别超过);
  • --compress=9:压缩级别(-Fd 默认就压缩);
  • --no-owner --no-acl:不备份属主和权限,跨环境恢复时很有用(恢复到别的库/别的用户下不会报权限错误);代价是恢复后要自己设属主。

角色和全局对象要单独备份

这是我最容易忘的一条:pg_dump 只备份一个数据库的内容,不包含角色(用户)、表空间这些"全局对象"。

pg_dumpall --globals-only -h 127.0.0.1 -U backup_user -f /backup/globals_$(date +%F).sql

漏了它,恢复到一个新实例上会发现:所有用户都不存在。我们第一次演练就栽在这(见第六节)。

权限:别用 superuser 跑备份

建一个专用账号:

CREATE ROLE backup_user WITH LOGIN PASSWORD 'xxx';
GRANT pg_read_all_data TO backup_user;      -- PG 14+,只读所有数据
-- 老版本:
-- GRANT SELECT ON ALL TABLES IN SCHEMA public TO backup_user;
-- ALTER DEFAULT PRIVILEGES ... (新表也要覆盖)

PG 14+ 的 pg_read_all_data 很方便,它覆盖了现有和未来的所有表。老版本要手动 grant,并且要处理新建的表——我们那个备份失效的事故,就是因为新表没权限。

密码不要写在命令行

命令行里的密码会出现在 ps 输出和 shell 历史里。用 .pgpass:

# ~/.pgpass  权限必须是 600
127.0.0.1:5432:viddown:backup_user:你的密码
chmod 600 ~/.pgpass

或者用环境变量 PGPASSWORD(相对好一些,但同样会进进程环境)。

三、备份脚本:退出码、校验与告警

最重要的一节。 备份脚本有四个必须有,缺一个就可能重演我的事故:

#!/bin/bash
# /opt/scripts/pg_backup.sh
set -euo pipefail          # 1. 任何命令失败就退出(关键!)

DATE=$(date +%F_%H%M)
BACKUP_ROOT=/backup
DEST="$BACKUP_ROOT/viddown_$DATE"
LOG=/var/log/backup.log

log() { echo "[$(date '+%F %T')] $*" | tee -a "$LOG"; }

log "backup start"

# 2. 跑备份,检查退出码
if ! pg_dump -h 127.0.0.1 -U backup_user -d viddown -Fd -j 4 -f "$DEST"; then
    log "ERROR: pg_dump failed with code $?"
    alert "数据库备份失败:pg_dump 退出码非 0"        # 6. 告警
    exit 1
fi

# 3. 校验备份内容(不是只看文件大小!)
if ! pg_restore -l "$DEST" > /tmp/toc_$$ 2>/dev/null; then
    log "ERROR: backup is not readable"
    alert "数据库备份失败:备份文件无法解析"
    exit 1
fi

TABLE_COUNT=$(grep -c 'TABLE DATA' /tmp/toc_$$)
if [ "$TABLE_COUNT" -lt 10 ]; then
    log "ERROR: suspicious table count: $TABLE_COUNT"
    alert "数据库备份异常:表数量只有 $TABLE_COUNT"
    exit 1
fi
rm -f /tmp/toc_$$

# 4. 打包 + 上传
tar -czf "$DEST.tar.gz" -C "$BACKUP_ROOT" "viddown_$DATE"
SIZE=$(stat -c %s "$DEST.tar.gz")
if [ "$SIZE" -lt 1048576 ]; then          # 小于 1MB 视为异常
    log "ERROR: backup too small: $SIZE bytes"
    alert "数据库备份异常:太小(${SIZE} 字节)"
    exit 1
fi

rclone copy "$DEST.tar.gz" remote:backups/pg/ || {
    log "ERROR: upload failed"
    alert "数据库备份失败:上传到对象存储失败"
    exit 1
}

# 5. 清理本地旧备份(保留 7 天本地,30 天远端)
find "$BACKUP_ROOT" -name 'viddown_*.tar.gz' -mtime +7 -delete

log "backup done: $DEST.tar.gz ($SIZE bytes, $TABLE_COUNT tables)"

五个关键点:

  1. set -euo pipefail:任何一个命令失败就退出。我们原来那版没有它,pg_dump 失败了脚本还在继续跑,最后还"成功"上传了一个空文件。
  2. 检查退出码并显式告警。
  3. pg_restore -l 校验:这是真正的"验证备份可用"。它读取备份的目录(TOC),如果文件损坏或不完整会失败。
  4. 合理性检查:表数量、文件大小小于阈值就是异常。"备份成功但备份的是空库"是最坑的情况,只能靠这种检查发现。
  5. 清理策略要写在脚本里,否则磁盘迟早满。

alert() 函数(示例):

alert() {
    curl -s -X POST "https://告警机器人地址" \
         -H 'Content-Type: application/json' \
         -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"[$(hostname)] $1\"}}" || true
}

|| true 是故意的:告警发送失败不应该让备份脚本失败(备份本身是成功的)。

四、恢复:pg_restore 的那些参数

演练的时候我才真正搞明白这些参数的区别:

# 恢复到同名库(库已存在,先清空)
pg_restore -h 127.0.0.1 -U postgres -d viddown \
  --clean --if-exists -j 4 /backup/viddown_2026-03-11

# 恢复到全新库(先 create database)
createdb -U postgres viddown_restore
pg_restore -h 127.0.0.1 -U postgres -d viddown_restore -j 4 /backup/viddown_2026-03-11

参数含义:

参数 作用 什么时候用
--clean 恢复前先 DROP 已存在的对象 恢复到已有库
--if-exists 配合 --clean,DROP 时加 IF EXISTS(不报错) 几乎总是加上
-j 4 并行恢复 大库能快好几倍
--no-owner 不恢复属主信息 备份时没加 --no-owner 但恢复到别的用户下时
-t <表名> 只恢复某张表 只要一部分数据时
-n <schema> 只恢复某个 schema
--data-only / --schema-only 只要数据 / 只要结构

常见报错与处理:

ERROR:  relation "xxx" already exists

→ 加 --clean --if-exists,或者恢复到空库。

ERROR:  role "old_owner" does not exist

→ 备份时带了 owner 但目标库没有这个角色。解决:恢复加 --no-owner,或者先 pg_dumpall --globals-only 恢复角色。

ERROR:  there is no unique constraint matching given keys for referenced table

→ 数据本身的外键问题,或者只恢复了一部分表导致外键断裂。只恢复单表时要小心外键,通常要连带恢复关联表。

恢复时间要实测并记下来。我们 40GB 的库,逻辑备份恢复用了 52 分钟(并行 4)。这个数字直接决定了 RTO——当初我以为"应该很快",演练之后才发现远超预期。

五、PITR:把数据恢复到"误操作前 5 分钟"

逻辑备份最坏要丢一天数据。PITR(Point-In-Time Recovery,时间点恢复)能把 RPO 压到秒级。

原理:

基础备份(某天凌晨 3 点的完整快照)
   + WAL 归档(之后每一条数据变更的日志)
   = 可以"回放"到任意时间点

配置

# postgresql.conf
wal_level = replica              # 至少 replica
archive_mode = on
archive_command = 'test ! -f /wal_archive/%f && cp %p /wal_archive/%f'
archive_timeout = 300            # 5 分钟强制归档一个 WAL(即使没写满)
max_wal_senders = 4

改完要重启数据库(archive_mode 不能热加载)。

archive_command 必须检查目标文件不存在(test ! -f),防止覆盖已有的归档。更稳妥的是同步到对象存储:

archive_command = 'rclone copyto %p remote:wal_archive/%f'

但要注意:归档失败会阻塞数据库写入(WAL 堆积在 pg_wal 目录,写满就停服)。所以归档目标必须高可用,而且要监控 pg_wal 目录大小。

检查归档是否在跑:

SELECT archived_count, failed_count, last_archived_wal, last_archived_time
FROM pg_stat_archiver;

failed_count 大于 0 就是红色警报,要立刻查。

基础备份

pg_basebackup -h 127.0.0.1 -U backup_user -D /backup/base_$(date +%F) \
  -Ft -z -P -X stream
  • -Ft:输出为 tar 格式(-Fp 是纯目录拷贝);
  • -z:gzip 压缩;
  • -P:显示进度;
  • -X stream:同时流式接收备份期间产生的 WAL(保证一致性)。

基础备份不能太频繁(占空间),我们是一周一次。

恢复(演练步骤)

# 1. 停掉数据库
systemctl stop postgresql

# 2. 把现有数据目录移走(别直接删!)
mv /var/lib/postgresql/14/main /var/lib/postgresql/14/main.broken

# 3. 解压基础备份
mkdir -p /var/lib/postgresql/14/main
tar -xzf /backup/base_2026-03-11/base.tar.gz -C /var/lib/postgresql/14/main
# 如果有多个 tablespace,还有对应的 tar

# 4. 配置恢复目标
cat > /var/lib/postgresql/14/main/postgresql.auto.conf <<'EOF'
restore_command = 'cp /wal_archive/%f %p'
recovery_target_time = '2026-03-11 13:55:00'
recovery_target_action = 'promote'
EOF

# 5. 告诉 PostgreSQL 这是一次恢复
touch /var/lib/postgresql/14/main/recovery.signal

# 6. 权限(很重要,忘了会起不来)
chown -R postgres:postgres /var/lib/postgresql/14/main
chmod 700 /var/lib/postgresql/14/main

# 7. 启动
systemctl start postgresql

# 8. 观察日志
tail -f /var/log/postgresql/postgresql-14-main.log
# 看到 "recovery stopping before ..." 和 "database system is ready to accept connections"

关键细节:

  • PG 12 之前用 recovery.conf,12 及以后改成 recovery.signal 文件 + postgresql.auto.conf 里的参数;
  • 恢复完成后 recovery.signal 会被自动删除(如果是 promote 动作);
  • 恢复目标时间要用 UTC 还是本地时间? 取决于你的 timezone 配置和 recovery_target_time 的写法。我建议在配置里显式写时区:recovery_target_time = '2026-03-11 13:55:00+08'。这个坑在演练时让我们恢复错了 8 小时。

  • 恢复完成后第一件事:立刻做一次新的基础备份(因为现在的时间线是新分支了)。

进阶工具:如果觉得手工管理 PITR 麻烦,可以上 pgBackRest 或 Barman,它们把备份、归档、校验、恢复都封装好了。规模大了值得上;小团队先把手工流程跑通、演练过,再考虑工具。

六、第一次恢复演练:发现的五个问题

演练的做法:在一台独立机器上,用真实的备份文件,恢复到昨天下午 3 点,然后验证数据。全程计时、记录。

发现的问题

问题 1:角色(用户)不存在。

ERROR:  role "viddown_app" does not exist

原因:我们只备份了 pg_dump(库内容),没备份 pg_dumpall --globals-only(角色)。恢复到一个全新实例时,应用账号根本不存在。

修复:备份脚本加上 pg_dumpall --globals-only,演练重跑通过。

问题 2:恢复耗时 52 分钟,远超预期(我预估 15 分钟)。

原因:没用并行(-j),而且是机械盘。修复:加 -j 4,并且把恢复目标机换成 SSD,降到 18 分钟。这个数字直接写进了 RTO 文档——现在我们对外承诺的 RTO 是 1 小时,是有依据的,不是拍脑袋。

问题 3:WAL 归档从没真正生效过。

检查 pg_stat_archiver 发现 archived_count = 0,failed_count = 217。原因:archive_command 里的路径 /wal_archive 不存在(忘了创建),cp 一直失败。

这意味着我们以为有 PITR,其实根本没有。而因为 WAL 归档失败不阻塞(只有 pg_wal 写满才阻塞),这个错误安静地躺了三个月。

修复:创建目录 + 加 pg_stat_archiver 的 failed_count 告警。

问题 4:恢复出来的数据缺了 3 张表。

原因:备份账号 backup_user 是手工 grant 的老表,新建的表没有权限,pg_dump 跳过它们(而且只 warning 不报错,退出码还是 0)。

修复:换成 GRANT pg_read_all_data(PG 14+,覆盖未来新建的表)+ 加"表数量合理性检查"。

问题 5:备份文件没有加密,且对象存储桶的权限配置错了。

修复:加了 GPG 对称加密(密钥单独保管,不在同一台机器上),桶改成私有 + 最小权限的访问密钥。

# 加密
gpg --symmetric --cipher-algo AES256 --batch --passphrase-file /root/backup.key \
    -o "$DEST.tar.gz.gpg" "$DEST.tar.gz"

# 解密(恢复时)
gpg --decrypt --batch --passphrase-file /root/backup.key \
    -o dump.tar.gz "$DEST.tar.gz.gpg"

密钥不能和备份放在一起,否则加密毫无意义(别人拿到备份就拿到密钥)。我们放在了密码管理器里,恢复时人工取。

演练的价值

这五个问题里,没有一个是"不演练也能发现"的。备份脚本"成功"了两年,但从来没被验证过。演练一次花了半天,但把"以为有备份"变成了"确认能恢复"。

我现在的规定:每季度演练一次,演练完更新 runbook。

七、备份的监控:最该告警的指标

按重要性排:

指标 检查方式 告警级别
最近一次备份距今多久 对象存储里最新文件的 mtime P1(> 26 小时)
备份文件大小异常 跟昨天比,波动 > 50% P2
pg_stat_archiver.failed_count 增长 SQL 查询 P1
pg_wal 目录大小 du -sh P2(过大说明归档卡了)
备份脚本退出码 cron 结果检查 P1
恢复演练是否按时做 人工 + 日历提醒 P2

"最近一次备份距今多久"是最重要的指标。它比"备份脚本有没有跑"更可靠——因为它检查的是结果而不是过程。

写个检查脚本:

#!/bin/bash
# /opt/scripts/check_backup_freshness.sh
LATEST=$(rclone lsl remote:backups/pg/ --max-age 48h 2>/dev/null | head -1)
if [ -z "$LATEST" ]; then
    echo "ALERT: 没有 48 小时内的数据库备份!"
    # 发告警
else
    echo "OK: 最新备份 $LATEST"
fi

# WAL 归档状态
FAILED=$(psql -U postgres -tAc "SELECT failed_count FROM pg_stat_archiver")
if [ "$FAILED" != "0" ]; then
    echo "ALERT: WAL 归档失败次数 $FAILED"
fi

这个脚本本身也要被监控(比如它跑没跑)。我用"心跳"模式:脚本每次成功运行后往一个监控服务发一次心跳,超过 24 小时没心跳就告警——这样连"检查脚本自己挂了"也能发现。

八、别忘了数据库以外的东西

数据库是最受关注的,但恢复一个服务需要的远不止数据库:

要备份的 说明 频率
代码 Git 仓库(异地要有 mirror) 每次提交
配置文件 nginx、gunicorn、supervisor、crontab 变更时
环境变量 / 密钥 .env、证书、API key 变更时 + 加密存储
用户上传的文件 对象存储 + 跨区域复制 实时
数据库角色 pg_dumpall --globals-only 跟数据库同步
系统包列表 dpkg -l / rpm -qa 月度
Docker 镜像/Compose 文件 版本化 变更时

配置即代码是最好的解法:所有配置进 Git(密钥除外),恢复就是 git clone + 一条部署命令。我们现在的部署目录是版本化的,恢复一台新机器只需要:

git clone <deploy-repo> /opt/deploy
cd /opt/deploy && ./deploy.sh

大概 15 分钟能起一台全新的应用服务器(不含数据库恢复)。

密钥要单独管理:不要进 Git(哪怕私有仓库)。用密码管理器或者专门的密钥管理服务,恢复时人工注入。

九、3-2-1 与我们最终方案

3-2-1 原则:3 份副本、2 种不同介质、1 份异地。

我们的最终方案:

层次 内容 保留 位置
逻辑备份 pg_dump -Fd -j 4 每天 3 点 本地 7 天,对象存储 30 天,月度归档 1 年 本地 SSD + 对象存储(跨区域)
WAL 归档 持续归档 14 天 对象存储
基础备份 pg_basebackup 每周日 4 周 对象存储
代码/配置 Git 永久 GitHub + 内网 mirror
用户文件 对象存储跨区域复制 永久 双区域

成本(40GB 数据库,云上):

项目 月成本
对象存储(备份 ~200GB + WAL ~100GB) 约 45 元
跨区域复制流量 约 20 元
合计 约 65 元/月

这个价格相对"数据丢了"的损失,不值一提。 我见过因为舍不得几百块存储费而不做异地备份的,最后真出事的时候代价是几十万。

十、恢复手册(runbook)长什么样

演练之后,我们把步骤固化成文档。runbook 的原则:出事的时候人是慌的,所以每一步都要能直接复制粘贴执行。

# 数据库恢复手册

## 前置检查
- [ ] 确认故障类型:误删数据(用 PITR)/ 整库损坏(用逻辑备份)/ 机器没了(全量重建)
- [ ] 确认恢复目标时间点(**写清楚时区**)
- [ ] 通知相关人员(预计影响 XX 分钟)

## 方案 A:恢复到指定时间点(PITR)
预计耗时:25 分钟(不含数据校验)

1. 停应用(避免继续写入)
   systemctl stop gunicorn celery
2. 停数据库
   systemctl stop postgresql
3. 保留现场
   mv /var/lib/postgresql/14/main /var/lib/postgresql/14/main.broken
4. 解压基础备份(选择目标时间点之前最近的一份)
   ...(完整命令)
5. 配置恢复目标
   ...(完整命令,含时区说明)
6. 启动 & 观察日志
   ...
7. 验证数据(见下方验证清单)
8. 恢复后立即做一次新的基础备份

## 方案 B:从逻辑备份恢复
预计耗时:20 分钟(并行 4,SSD)

## 验证清单(必做)
- [ ] 关键表的行数跟预期一致
- [ ] 抽查 3 条业务数据(尤其是恢复目标时间点前后的)
- [ ] 应用能正常启动并登录
- [ ] 跑一次核心业务流程

"预计耗时"和"验证清单"是我演练之后加的。没有耗时,你无法判断"恢复是不是卡住了";没有验证清单,忙乱中会漏掉关键检查。

十一、坑清单

  1. 备份脚本没有 set -e → 失败了还在跑,最后上传空文件并"成功"。
  2. 不检查退出码 → pg_dump 失败一年没发现。
  3. 只检查文件大小 → 备份了个空库但大小正常。要检查表数量和可解析性(pg_restore -l)。
  4. 从没恢复过 → 备份能不能用完全不知道。必须演练。
  5. 没备份角色 → 恢复到新实例时应用账号不存在。用 pg_dumpall --globals-only。
  6. 备份账号权限不全 → 新表被跳过,而且只 warning 不报错。用 pg_read_all_data。
  7. WAL 归档目录不存在 → 归档一直失败,failed_count 涨到几百没人看。
  8. recovery_target_time 时区搞错 → 恢复错了 8 小时。显式写时区。
  9. 恢复后忘了重新做基础备份 → 新时间线没有兜底。
  10. 备份文件没加密 → 备份泄露等于数据泄露。
  11. 密钥和备份放一起 → 加密形同虚设。
  12. 只有本地备份 → 机器/机房出问题一起没。要异地。
  13. 恢复完不验证 → 恢复出来的是 27 天前的旧数据,照样接着用,问题更大。
  14. runbook 不存在或者过时 → 出事全靠回忆。演练后立刻更新。
  15. archive_command 失败会阻塞写入 → pg_wal 写满后数据库停服。要监控目录大小。

最后说说这次经历给我的改变。

以前我对备份的理解是"每天跑个脚本,文件在那儿就行"。现在我的理解是:备份是一个需要持续验证的流程,不是一次性的配置。它由四部分组成,缺一个就是不完整:

  1. 真的在跑(退出码检查 + 告警);
  2. 内容是对的(表数量、可解析性校验);
  3. 能恢复(定期演练,有耗时记录);
  4. 恢复完能用(验证清单)。

这四件事里,唯一能一次做完就一劳永逸的只有第 1 件。演练这件事必须定期做,因为环境会变:数据量变大、表结构变了、版本升级了、人员换了。我们吃过一次亏,所以现在每季度一次,雷打不动。

还有一条很实在的建议:演练不要挑"风平浪静"的时候假装做,要真的做——真的停服务、真的用备份文件、真的恢复到一个新实例、真的切换流量验证。半吊子的演练(比如"我看了一下备份文件存在")等于没做,而且会给你一种虚假的安全感,那比没有备份更危险。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部