前几天给 token-reseller 的 PostgreSQL 做备份方案,本来觉得这事十分钟搞定——pg_dump 加个 cron 完事。结果从晚上八点折腾到十二点,中间还被自己吓出一身冷汗:第一次真实恢复演练时发现备份文件是空的。这篇文章把整个过程和踩过的坑记下来,特别是那个”备份成功但根本没法用”的坑,希望你永远别遇上。
一、先说结论:备份方案长什么样
我的方案分三层,缺一不可:
- 本机每日全量备份:pg_dump 压缩落盘,保留 7 天
- 异地上传:备份完立刻 rsync 推到 NAS(异地容灾的核心,光在本机备份等于没备份)
- 恢复演练:每周把最近的备份真实恢复一次,验证可用性
为什么强调第三层?因为下面第一个坑就是教训。
二、五个坑,一个比一个阴
坑 1:备份”成功”,文件却是空的
这是最吓人的一个。pg_dump 报错时退出码不一定是非零——比如用 pg_dump -h localhost -U postgres db 时提示词里敲错密码,或者权限不够某张表,它可能一边报 WARNING 一边输出一个残缺的 dump,退出码还是 0。我的脚本用 pg_dump ... && rsync ... 判断成功,结果残缺文件照样上传了。
后来我改成双保险:先检查退出码,再检查文件大小是否超过阈值,最后定期做真实恢复。脚本骨架如下:
#!/bin/bash
set -euo pipefail
BACKUP_DIR=/data/backups/postgres
DB_NAME=token_reseller
KEEP_DAYS=7
MIN_SIZE=1048576 # 1MB,低于这个直接告警
mkdir -p "$BACKUP_DIR"
STAMP=$(date +%F_%H%M)
OUT="$BACKUP_DIR/${DB_NAME}_${STAMP}.dump"
# -Fc 自定义压缩格式,支持 pg_restore 选择性恢复
pg_dump -Fc -d "$DB_NAME" -f "$OUT" 2>> "$BACKUP_DIR/error.log"
# 退出码非零直接退出(set -e 也会兜底)
SIZE=$(stat -c%s "$OUT" 2>/dev/null || stat -f%z "$OUT")
if [ "$SIZE" -lt "$MIN_SIZE" ]; then
echo "[$(date)] 备份文件过小: $SIZE bytes,疑似失败" | tee -a "$BACKUP_DIR/error.log"
exit 1
fi
# 清理过期备份
find "$BACKUP_DIR" -name "*.dump" -mtime +$KEEP_DAYS -delete

注意 stat 那行:Linux 是 stat -c%s,macOS 是 stat -f%z,跨平台脚本不处理这个会在 Mac 测试机上直接翻车。
坑 2:cron 里 pg_dump 找不到
本机手动跑脚本一切正常,放进 cron 就报 pg_dump: command not found。原因很简单:cron 的 PATH 跟登录 shell 不一样,/usr/local/bin、/usr/pgsql-16/bin 这些路径根本不在里面。
两种解法,我选第一种(更稳):
- 脚本里写死绝对路径:
/usr/pgsql-16/bin/pg_dump(which pg_dump查出来的路径直接粘进去) - crontab 顶部加
PATH=/usr/local/bin:/usr/bin:/usr/pgsql-16/bin
其实我最后是从 cron 迁到了 systemd timer(上次写过那篇),Persistent=true 还能把停机期间错过的备份补跑上。
坑 3:密码硬编码在脚本里,还被 git 提交了
第一版脚本里直接写了 PGPASSWORD=xxx pg_dump ...,然后习惯性 git add . 差点把密码推上去。正确做法是用 ~/.pgpass 文件:
# 格式:hostname:port:database:username:password
localhost:5432:token_reseller:backup_user:S3cretP4ss
权限必须是 600,否则 pg_dump 会拒绝读取:
chmod 600 ~/.pgpass
另外建议专门建一个最小权限的备份用户,只给 pg_read_all_data 角色,别拿超级用户跑备份。

坑 4:rsync “备份成功” 其实只传了个空目录
异地上传用 rsync,我一开始写的是 rsync -avz /data/backups/ nas:/volume1/backups/postgres/,看着输出挺开心。后来发现 NAS 上目录是空的——rsync 命令本身退出码是 0,但 SSH 密钥认证失败后走了密码交互,非交互环境下连接直接断开,我 && 链里居然没报错(因为用了 || true 兜底,纯属自作聪明)。
修正后的关键点:
- 用
rsync -e "ssh -i /root/.ssh/backup_key -o BatchMode=yes",BatchMode=yes禁止交互式密码输入,认证失败立刻非零退出 - 上传后加校验:对比本地和远端的文件大小,或者直接记录 rsync 的
--stats输出
rsync -az --stats -e "ssh -i /root/.ssh/backup_key -o BatchMode=yes -o StrictHostKeyChecking=accept-new" "$BACKUP_DIR" backup@nas.local:/volume1/backups/postgres/ > "$BACKUP_DIR/rsync.log" 2>&1
StrictHostKeyChecking=accept-new 也是坑:第一次连新机器不设这个,cron 环境里没法回答 “are you sure you want to continue connecting”,照样挂。

坑 5:恢复演练才发现 pg_restore 的顺序问题
备份是 -Fc 自定义格式,恢复用 pg_restore。第一次演练直接 pg_restore -d db file.dump,报了一堆 “object already exists”——因为我没加 --clean,旧库里的表跟 dump 冲突。
生产环境推荐的恢复姿势:
# 先建个空的恢复目标库
createdb -h localhost restore_test
# --clean 先删后建;--if-exists 避免对象不存在时报错
pg_restore -h localhost -d restore_test --clean --if-exists file.dump
# 恢复完抽查几行数据
psql -h localhost -d restore_test -c "SELECT count(*) FROM orders;"
现在我的演练脚本每周日自动跑:恢复到临时库 → 抽查行数 → 删临时库,全程无人值守,出问题发通知。
三、踩坑经验总结
- 退出码不可信,文件大小来兜底:pg_dump 部分失败退出码可能是 0,必须加最小体积检查 + 定期恢复演练
- cron 的 PATH 是另一个世界:用绝对路径最保险,或者直接上 systemd timer
- 密码进
.pgpass,权限 600,备份用户最小权限:千万别把密码硬编码进脚本再提交 git - rsync 必须 BatchMode=yes:非交互环境认证失败要立刻暴露,上传后做大小校验
- 备份不是备份,能恢复的才是:没有恢复演练的备份方案,跟没有备份一样
四、还可以更进一步
现在这套方案跑了一周很稳定。后续打算加两点:一是 WAL 归档 + pg_basebackup 做增量,把 RPO 从 24 小时压到几分钟;二是备份文件用 age 或 GPG 加密后再上传,毕竟异地存储上躺着全库明文数据,泄了比丢了还难受。
备份这事的本质是:平时觉得多余,出事时跪着感谢。花一晚上配置,换以后每晚睡得踏实,这买卖值。