#!/usr/bin/env bash # backup-inc.sh —— 每 15 分钟增量备份(RPO ≤ 15min) # MySQL:xtrabackup 增量 + binlog flush & 归档 # PostgreSQL:pg_dump(轻量,一致性快照) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" source "${SCRIPT_DIR}/lib.sh" TIMESTAMP="$(date +%Y%m%d_%H%M%S)" START="$(timer_start)" log_info "---------- 增量备份开始 ${TIMESTAMP} ----------" ERRORS=0 # ────────────────────────────────────────────────────── # 1. MySQL 增量备份(xtrabackup incremental) # ────────────────────────────────────────────────────── mysql_incremental_backup() { # 确定 basedir:优先用最近一次增量,若无则用最近全量 local basedir="" if [ -f "${BACKUP_DIR}/mysql/latest-inc.txt" ]; then basedir="$(cat "${BACKUP_DIR}/mysql/latest-inc.txt")" elif [ -f "${BACKUP_DIR}/mysql/latest-full.txt" ]; then basedir="$(cat "${BACKUP_DIR}/mysql/latest-full.txt")" else log_warn "[MySQL] 未找到基础备份,跳过增量(请先执行全量备份)" return 1 fi if [ ! -d "${basedir}" ]; then log_warn "[MySQL] 基础备份目录不存在: ${basedir},跳过增量" return 1 fi local target="${BACKUP_DIR}/mysql/inc/${TIMESTAMP}" local archive="${BACKUP_DIR}/mysql/inc/${TIMESTAMP}.tar.gz" local s3_key="mysql/inc/${TIMESTAMP}.tar.gz" log_info "[MySQL] xtrabackup 增量备份,basedir=${basedir}" mkdir -p "${target}" if ! mysql_ping; then log_warn "[MySQL] 无法连接 MySQL,跳过增量" return 1 fi xtrabackup \ --backup \ --host="${MYSQL_HOST}" \ --port="${MYSQL_PORT}" \ --user="${MYSQL_USER}" \ --password="${MYSQL_PASSWORD}" \ --target-dir="${target}" \ --incremental-basedir="${basedir}" \ --compress \ --compress-threads=2 \ ${MYSQL_DATADIR:+--datadir="${MYSQL_DATADIR}"} \ 2>&1 | tee "${target}/xtrabackup.log" tar -czf "${archive}" -C "$(dirname "${target}")" "$(basename "${target}")" # 更新最新增量引用 echo "${target}" > "${BACKUP_DIR}/mysql/latest-inc.txt" encrypt_and_upload "${archive}" "${s3_key}" rm -f "${archive}" # 清理 2 天前的增量目录(全量已上 S3,本地只保留最近供下一次增量) find "${BACKUP_DIR}/mysql/inc" -maxdepth 1 -type d -mtime +2 \ -not -path "${BACKUP_DIR}/mysql/inc" \ -exec rm -rf {} + 2>/dev/null || true log_ok "[MySQL] 增量备份完成: ${target}" } # ────────────────────────────────────────────────────── # 2. MySQL Binlog 归档(FLUSH BINARY LOGS + 复制新 binlog) # ────────────────────────────────────────────────────── mysql_binlog_backup() { if ! mysql_ping; then log_warn "[MySQL-binlog] 无法连接 MySQL,跳过 binlog 归档" return 1 fi log_info "[MySQL-binlog] FLUSH BINARY LOGS" mysql \ --host="${MYSQL_HOST}" \ --port="${MYSQL_PORT}" \ --user="${MYSQL_USER}" \ --password="${MYSQL_PASSWORD}" \ -e "FLUSH BINARY LOGS;" 2>/dev/null # 从 MySQL 取 binlog 文件列表 local binlog_dir="${MYSQL_DATADIR:-/var/lib/mysql}" local dest="${BACKUP_DIR}/mysql/binlog" mkdir -p "${dest}" # 使用 mysqlbinlog 工具读取当前活跃 binlog 之前的文件并归档 local binlog_index binlog_index="$(mysql \ --host="${MYSQL_HOST}" \ --port="${MYSQL_PORT}" \ --user="${MYSQL_USER}" \ --password="${MYSQL_PASSWORD}" \ --batch --skip-column-names \ -e "SHOW BINARY LOGS;" 2>/dev/null | awk '{print $1}')" if [ -z "${binlog_index}" ]; then log_warn "[MySQL-binlog] 无法获取 binlog 列表(binlog 未开启?),跳过" return 0 fi # 找出尚未归档的 binlog 文件(非最新一个,最新的正在写入) local count count=$(echo "${binlog_index}" | wc -l) # 跳过最后一个(正在写入) local to_archive to_archive=$(echo "${binlog_index}" | head -n $(( count - 1 ))) for binlog_file in ${to_archive}; do local dest_file="${dest}/${binlog_file}.gz" if [ -f "${dest_file}" ]; then continue # 已归档 fi log_info "[MySQL-binlog] 归档 ${binlog_file}" # 使用 mysqlbinlog 以 raw 模式读取 + gzip 压缩 mysqlbinlog \ --host="${MYSQL_HOST}" \ --port="${MYSQL_PORT}" \ --user="${MYSQL_USER}" \ --password="${MYSQL_PASSWORD}" \ --read-from-remote-server \ --raw \ --result-file="${dest}/" \ "${binlog_file}" 2>/dev/null \ && gzip -f "${dest}/${binlog_file}" \ && encrypt_and_upload "${dest_file}" "mysql/binlog/${TIMESTAMP}_${binlog_file}.gz" \ && rm -f "${dest_file}" \ || log_warn "[MySQL-binlog] ${binlog_file} 归档失败,忽略" done # 清理 3 天前的本地 binlog 备份 find "${dest}" -name "*.gz" -mtime +3 -delete 2>/dev/null || true log_ok "[MySQL-binlog] binlog 归档完成" } # ────────────────────────────────────────────────────── # 3. PostgreSQL 增量备份(pg_dump,15min 一次) # ────────────────────────────────────────────────────── postgres_incremental_backup() { if ! pg_ping; then log_warn "[PostgreSQL] 无法连接,跳过增量" return 1 fi IFS=',' read -ra DBS <<< "${PG_DATABASES:-postgres}" for DB in "${DBS[@]}"; do DB="$(echo "${DB}" | tr -d ' ')" local archive="${BACKUP_DIR}/postgres/inc_${DB}_${TIMESTAMP}.pgdump" local s3_key="postgres/inc/${DB}/${TIMESTAMP}.pgdump" PGPASSWORD="${PG_PASSWORD:-}" pg_dump \ --host="${PG_HOST}" \ --port="${PG_PORT}" \ --username="${PG_USER:-postgres}" \ --format=custom \ --compress=6 \ --no-password \ "${DB}" > "${archive}" encrypt_and_upload "${archive}" "${s3_key}" rm -f "${archive}" log_ok "[PostgreSQL] ${DB} 增量快照完成" done } # ────────────────────────────────────────────────────── # 执行 # ────────────────────────────────────────────────────── mysql_incremental_backup || { log_error "[MySQL] 增量备份失败"; ERRORS=$(( ERRORS + 1 )); } mysql_binlog_backup || { log_warn "[MySQL] binlog 归档失败"; } postgres_incremental_backup || { log_error "[PG] 增量备份失败"; ERRORS=$(( ERRORS + 1 )); } ELAPSED=$(timer_elapsed "${START}") DURATION=$(format_duration "${ELAPSED}") if [ "${ERRORS}" -eq 0 ]; then log_ok "---------- 增量备份完成,耗时 ${DURATION} ----------" else log_error "---------- 增量备份完成(${ERRORS} 个失败),耗时 ${DURATION} ----------" exit 1 fi