我们有个跑了两年的备份脚本:每天凌晨 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_restore 的那些参数
- 五、PITR:把数据恢复到"误操作前 5 分钟"
- 六、第一次恢复演练:发现的五个问题
- 七、备份的监控:最该告警的指标
- 八、别忘了数据库以外的东西
- 九、3-2-1 与我们最终方案
- 十、恢复手册(runbook)长什么样
- 十一、坑清单
一、备份的三个层次,先知道自己要哪种
| 类型 | 做法 | 恢复粒度 | 速度 | 复杂度 |
|---|---|---|---|---|
| 逻辑备份 | 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)"
五个关键点:
set -euo pipefail:任何一个命令失败就退出。我们原来那版没有它,pg_dump失败了脚本还在继续跑,最后还"成功"上传了一个空文件。- 检查退出码并显式告警。
pg_restore -l校验:这是真正的"验证备份可用"。它读取备份的目录(TOC),如果文件损坏或不完整会失败。- 合理性检查:表数量、文件大小小于阈值就是异常。"备份成功但备份的是空库"是最坑的情况,只能靠这种检查发现。
- 清理策略要写在脚本里,否则磁盘迟早满。
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 条业务数据(尤其是恢复目标时间点前后的)
- [ ] 应用能正常启动并登录
- [ ] 跑一次核心业务流程
"预计耗时"和"验证清单"是我演练之后加的。没有耗时,你无法判断"恢复是不是卡住了";没有验证清单,忙乱中会漏掉关键检查。
十一、坑清单
- 备份脚本没有
set -e→ 失败了还在跑,最后上传空文件并"成功"。 - 不检查退出码 →
pg_dump失败一年没发现。 - 只检查文件大小 → 备份了个空库但大小正常。要检查表数量和可解析性(
pg_restore -l)。 - 从没恢复过 → 备份能不能用完全不知道。必须演练。
- 没备份角色 → 恢复到新实例时应用账号不存在。用
pg_dumpall --globals-only。 - 备份账号权限不全 → 新表被跳过,而且只 warning 不报错。用
pg_read_all_data。 - WAL 归档目录不存在 → 归档一直失败,
failed_count涨到几百没人看。 recovery_target_time时区搞错 → 恢复错了 8 小时。显式写时区。- 恢复后忘了重新做基础备份 → 新时间线没有兜底。
- 备份文件没加密 → 备份泄露等于数据泄露。
- 密钥和备份放一起 → 加密形同虚设。
- 只有本地备份 → 机器/机房出问题一起没。要异地。
- 恢复完不验证 → 恢复出来的是 27 天前的旧数据,照样接着用,问题更大。
- runbook 不存在或者过时 → 出事全靠回忆。演练后立刻更新。
archive_command失败会阻塞写入 →pg_wal写满后数据库停服。要监控目录大小。
最后说说这次经历给我的改变。
以前我对备份的理解是"每天跑个脚本,文件在那儿就行"。现在我的理解是:备份是一个需要持续验证的流程,不是一次性的配置。它由四部分组成,缺一个就是不完整:
- 真的在跑(退出码检查 + 告警);
- 内容是对的(表数量、可解析性校验);
- 能恢复(定期演练,有耗时记录);
- 恢复完能用(验证清单)。
这四件事里,唯一能一次做完就一劳永逸的只有第 1 件。演练这件事必须定期做,因为环境会变:数据量变大、表结构变了、版本升级了、人员换了。我们吃过一次亏,所以现在每季度一次,雷打不动。
还有一条很实在的建议:演练不要挑"风平浪静"的时候假装做,要真的做——真的停服务、真的用备份文件、真的恢复到一个新实例、真的切换流量验证。半吊子的演练(比如"我看了一下备份文件存在")等于没做,而且会给你一种虚假的安全感,那比没有备份更危险。