数据库备份与灾难恢复完整方案:MySQL自动备份、异地存储与RTO/RPO保障实战

备份是数据安全的最后一道防线。很多站长以为配置了主从同步就万事大吉,但主从同步无法防御误删除、逻辑损坏或勒索软件——这些灾难会同步到所有副本。本文建立一套完整的备份与恢复体系。

一、备份策略设计

RPO与RTO目标定义

指标全称含义典型目标
RPORecovery Point Objective可接受的最大数据丢失量(时间)电商:1小时内 / 博客:24小时内
RTORecovery Time Objective从灾难发生到业务恢复的最长时间电商:4小时内 / 博客:24小时内

3-2-1备份原则

<code">3 份数据副本(1份生产 + 2份备份)
    ↓
2 种不同的存储介质(如本地磁盘 + 对象存储)
    ↓
1 份异地存储(不在同一物理位置)

实现方案:
  本地备份:香港VPS本地磁盘(/backup/)→ 1份
  异地备份:Cloudflare R2对象存储(香港以外节点)→ 1份
  长期归档:Backblaze B2(低成本冷存储)→ 1份

二、MySQL全量备份脚本

<code">#!/bin/bash
# /usr/local/bin/mysql-backup.sh
# 完整的MySQL备份脚本,含压缩、加密、验证、上传

set -euo pipefail

# ============ 配置 ============
DB_USER="backup_user"
DB_PASS="${MYSQL_BACKUP_PASSWORD}"   # 从环境变量读取
DB_HOST="localhost"
BACKUP_DIR="/backup/mysql"
LOG_FILE="/var/log/mysql-backup.log"
RETENTION_DAYS=7                     # 本地保留7天
ENCRYPTION_KEY="${BACKUP_ENCRYPTION_KEY}"  # GPG加密密钥ID

# R2配置
R2_BUCKET="my-db-backups"
R2_ENDPOINT="https://账号ID.r2.cloudflarestorage.com"
AWS_ACCESS_KEY_ID="${R2_ACCESS_KEY}"
AWS_SECRET_ACCESS_KEY="${R2_SECRET_KEY}"

TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DATE_DIR=$(date +%Y/%m/%d)
BACKUP_FILE="${BACKUP_DIR}/${TIMESTAMP}.sql.gz"
ENCRYPTED_FILE="${BACKUP_FILE}.gpg"

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }

# ============ 创建专用备份账号(首次运行时执行) ============
create_backup_user() {
    mysql -u root -p -e "
    CREATE USER IF NOT EXISTS 'backup_user'@'localhost'
        IDENTIFIED BY '${DB_PASS}';
    GRANT SELECT, SHOW VIEW, TRIGGER, LOCK TABLES,
          PROCESS, REPLICATION CLIENT ON *.* TO 'backup_user'@'localhost';
    FLUSH PRIVILEGES;"
}

# ============ 执行备份 ============
mkdir -p "$BACKUP_DIR"
log "开始备份 MySQL..."

mysqldump \
    --host="$DB_HOST" \
    --user="$DB_USER" \
    --password="$DB_PASS" \
    --all-databases \
    --single-transaction \      # 不锁表(InnoDB热备)
    --flush-logs \              # 备份时刷新binlog
    --master-data=2 \           # 记录binlog位置(用于时间点恢复)
    --routines \
    --triggers \
    --events \
    --quick \                   # 大库必加,避免内存溢出
    2>>"$LOG_FILE" \
    | gzip -9 > "$BACKUP_FILE"

BACKUP_SIZE=$(du -sh "$BACKUP_FILE" | cut -f1)
log "备份完成,大小:${BACKUP_SIZE},文件:${BACKUP_FILE}"

# ============ GPG加密(防止备份泄露) ============
if [ -n "$ENCRYPTION_KEY" ]; then
    gpg --batch --yes \
        --recipient "$ENCRYPTION_KEY" \
        --output "$ENCRYPTED_FILE" \
        --encrypt "$BACKUP_FILE"
    rm -f "$BACKUP_FILE"
    UPLOAD_FILE="$ENCRYPTED_FILE"
    log "加密完成:${ENCRYPTED_FILE}"
else
    UPLOAD_FILE="$BACKUP_FILE"
fi

# ============ 备份验证 ============
verify_backup() {
    local file="$1"
    log "验证备份文件完整性..."

    if [[ "$file" == *.gpg ]]; then
        # 验证GPG文件格式
        gpg --batch --list-packets "$file" > /dev/null 2>&1 && \
            log "GPG加密验证通过" || { log "❌ GPG文件损坏!"; return 1; }
    else
        # 验证gzip文件完整性
        gzip -t "$file" && log "Gzip验证通过" || { log "❌ Gzip文件损坏!"; return 1; }
    fi
}

verify_backup "$UPLOAD_FILE"

# ============ 上传到R2异地存储 ============
log "上传到Cloudflare R2..."

AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" \
AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \
aws s3 cp "$UPLOAD_FILE" \
    "s3://${R2_BUCKET}/mysql/${DATE_DIR}/$(basename $UPLOAD_FILE)" \
    --endpoint-url "$R2_ENDPOINT" \
    --storage-class STANDARD \
    2>>"$LOG_FILE"

log "R2上传完成:mysql/${DATE_DIR}/$(basename $UPLOAD_FILE)"

# ============ 清理本地旧备份 ============
find "$BACKUP_DIR" -name "*.gz*" -mtime +$RETENTION_DAYS -delete
log "已清理${RETENTION_DAYS}天前的本地备份"

# ============ 发送备份完成通知 ============
curl -s -X POST "${TELEGRAM_WEBHOOK}" \
    -d "chat_id=${TELEGRAM_CHAT_ID}" \
    -d "text=✅ 数据库备份完成
时间:$(date)
大小:${BACKUP_SIZE}
文件:$(basename $UPLOAD_FILE)
已上传至R2异地存储" > /dev/null

log "备份流程完成 ✅"
<code"># 定时任务配置
crontab -e

# 每天凌晨2点全量备份
0 2 * * * MYSQL_BACKUP_PASSWORD=密码 BACKUP_ENCRYPTION_KEY=密钥ID \
    R2_ACCESS_KEY=R2密钥 R2_SECRET_KEY=R2密钥 \
    /usr/local/bin/mysql-backup.sh >> /var/log/mysql-backup-cron.log 2>&1

三、增量备份(Binlog)

<code"># 全量备份之间,用binlog实现增量(可恢复到任意时间点)

# 1. 确认binlog已开启
mysql -u root -p -e "SHOW VARIABLES LIKE 'log_bin';"
# log_bin = ON

# 2. 每小时归档当前binlog
#!/bin/bash
# /usr/local/bin/binlog-backup.sh
BINLOG_DIR="/var/lib/mysql"
ARCHIVE_DIR="/backup/binlog/$(date +%Y%m%d)"

mkdir -p "$ARCHIVE_DIR"

# 刷新binlog,让MySQL切换到新文件
mysql -u root -p密码 -e "FLUSH BINARY LOGS;"

# 将除当前binlog外的所有binlog归档
for binlog in $(ls ${BINLOG_DIR}/mysql-bin.* | head -n -1); do
    if [ -f "$binlog" ]; then
        gzip -c "$binlog" > "${ARCHIVE_DIR}/$(basename $binlog).gz"
        echo "已归档:$binlog"
    fi
done

四、灾难恢复演练

<code">#!/bin/bash
# /usr/local/bin/backup-restore-test.sh
# 每月执行一次,验证备份真实可恢复

set -e

BACKUP_DIR="/backup/mysql"
TEST_DB="restore_test_$(date +%Y%m)"
LATEST_BACKUP=$(ls -t "$BACKUP_DIR"/*.gz 2>/dev/null | head -1)

if [ -z "$LATEST_BACKUP" ]; then
    echo "❌ 未找到备份文件!"
    exit 1
fi

echo "=== 备份恢复测试 ==="
echo "使用备份:$LATEST_BACKUP"

# 创建测试数据库
mysql -u root -p密码 -e "DROP DATABASE IF EXISTS ${TEST_DB}; CREATE DATABASE ${TEST_DB};"

# 恢复到测试数据库
echo "正在恢复..."
gunzip < "$LATEST_BACKUP" | mysql -u root -p密码 "$TEST_DB" # 验证恢复结果(检查关键表是否存在) TABLES=$(mysql -u root -p密码 "$TEST_DB" -e "SHOW TABLES;" 2>/dev/null | wc -l)
echo "已恢复 ${TABLES} 张表"

if [ "$TABLES" -gt 0 ]; then
    echo "✅ 备份恢复测试通过"
    RESULT="✅ 本月备份恢复测试通过,恢复表数:${TABLES}"
else
    echo "❌ 备份恢复测试失败!"
    RESULT="❌ 备份恢复测试失败!请立即检查备份"
fi

# 清理测试数据库
mysql -u root -p密码 -e "DROP DATABASE ${TEST_DB};"

# 发送测试结果通知
curl -s -X POST "${TELEGRAM_WEBHOOK}" \
    -d "chat_id=${TELEGRAM_CHAT_ID}" \
    -d "text=${RESULT}"

五、时间点恢复(PITR)

<code">#!/bin/bash
# 从全量备份 + binlog恢复到指定时间点
# 场景:昨天下午3:30误删了重要数据,需要恢复到3:29的状态

FULL_BACKUP="/backup/mysql/20260101_020000.sql.gz"
TARGET_TIME="2026-01-02 15:29:59"

echo "=== 时间点恢复(恢复到 $TARGET_TIME 之前的状态)==="

# Step 1: 恢复最近一次全量备份
echo "Step 1: 恢复全量备份..."
gunzip < "$FULL_BACKUP" | mysql -u root -p密码

# Step 2: 从全量备份中找到binlog位置
BINLOG_INFO=$(gunzip -c "$FULL_BACKUP" | grep "MASTER_LOG_FILE" | head -1)
echo "Binlog起始位置:$BINLOG_INFO"

# Step 3: 应用binlog到目标时间点
echo "Step 2: 应用增量binlog..."
mysqlbinlog \
    --stop-datetime="$TARGET_TIME" \
    --base64-output=DECODE-ROWS \
    /backup/binlog/20260101/mysql-bin.000123.gz \
    /backup/binlog/20260102/mysql-bin.000124.gz \
    | mysql -u root -p密码

echo "✅ 时间点恢复完成,已恢复到 $TARGET_TIME 的状态"

六、备份监控告警

<code">#!/bin/bash
# /usr/local/bin/backup-monitor.sh
# 每天上午9点检查备份是否正常执行

BACKUP_DIR="/backup/mysql"
MAX_AGE_HOURS=26   # 超过26小时未备份则告警

# 查找最近的备份文件
LATEST=$(find "$BACKUP_DIR" -name "*.gz*" -type f -printf '%T@ %p\n' 2>/dev/null | \
    sort -n | tail -1 | awk '{print $2}')

if [ -z "$LATEST" ]; then
    MSG="❌ 严重告警:未找到任何备份文件!"
else
    AGE_SECONDS=$(( $(date +%s) - $(stat -c %Y "$LATEST") ))
    AGE_HOURS=$(( AGE_SECONDS / 3600 ))

    if [ "$AGE_HOURS" -gt "$MAX_AGE_HOURS" ]; then
        MSG="⚠️ 备份告警:最近备份已有 ${AGE_HOURS} 小时,超过预期!最新文件:$(basename $LATEST)"
    else
        MSG="✅ 备份正常:最近备份于 ${AGE_HOURS} 小时前完成($(basename $LATEST))"
    fi
fi

echo "$MSG"
# 发送到Telegram
curl -s -X POST "${TELEGRAM_WEBHOOK}" \
    -d "chat_id=${TELEGRAM_CHAT_ID}" \
    -d "text=$MSG" > /dev/null

七、总结

完整的备份体系:全量备份(每日)+ Binlog增量(每小时)+ 异地存储(R2)+ 定期恢复测试(每月)+ 备份监控告警(每日)。这五个环节缺一不可——没有恢复测试的备份是不可靠的备份。IDC.Net的香港VPS支持快照功能,可以与数据库备份结合,在系统级别提供额外的保护层。

THE END