跳至正文

PostgreSQL 自动备份折腾记:pg_dump 定时任务 + 异地容灾,五个坑我全踩了一遍

前几天给 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 这些路径根本不在里面。

两种解法,我选第一种(更稳):

  1. 脚本里写死绝对路径:/usr/pgsql-16/bin/pg_dump(which pg_dump 查出来的路径直接粘进去)
  2. 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;"

现在我的演练脚本每周日自动跑:恢复到临时库 → 抽查行数 → 删临时库,全程无人值守,出问题发通知。

三、踩坑经验总结

  1. 退出码不可信,文件大小来兜底:pg_dump 部分失败退出码可能是 0,必须加最小体积检查 + 定期恢复演练
  2. cron 的 PATH 是另一个世界:用绝对路径最保险,或者直接上 systemd timer
  3. 密码进 .pgpass,权限 600,备份用户最小权限:千万别把密码硬编码进脚本再提交 git
  4. rsync 必须 BatchMode=yes:非交互环境认证失败要立刻暴露,上传后做大小校验
  5. 备份不是备份,能恢复的才是:没有恢复演练的备份方案,跟没有备份一样

四、还可以更进一步

现在这套方案跑了一周很稳定。后续打算加两点:一是 WAL 归档 + pg_basebackup 做增量,把 RPO 从 24 小时压到几分钟;二是备份文件用 age 或 GPG 加密后再上传,毕竟异地存储上躺着全库明文数据,泄了比丢了还难受。

备份这事的本质是:平时觉得多余,出事时跪着感谢。花一晚上配置,换以后每晚睡得踏实,这买卖值。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注