在生产环境中,数据备份是运维的最后一道防线。一个合格的备份脚本不仅要能“把文件传上去”,更要解决备份完整性验证、网络抖动重试、本地历史轮转、并发安全等一系列工程问题。
本文分享一个经过生产验证的 Bash 自动备份脚本,支持 MySQL 分库备份、指定目录打包、腾讯云 COS 上传及智能历史清理,开箱即用。
1. 功能介绍
该脚本并非简单的命令拼接,而是针对生产痛点做了针对性设计:
MySQL 分库备份与完整性校验:自动遍历所有业务数据库(排除系统库),逐库执行
mysqldump。关键创新在于三重校验机制——不仅检查返回码和文件大小,还会验证 SQL 文件末尾的-- Dump completed标记,有效拦截中途断开导致的不完整备份。COS 上传指数退避重试:网络传输不可避免会遇到抖动或 API 限流。脚本内置可配置的重试次数与指数退避延迟(5s → 10s → 20s),大幅提升弱网环境下的上传成功率。
双策略历史清理:支持“保留天数”和“最大容量”两种清理策略同时生效。当磁盘空间紧张时,自动从最旧文件开始删除,避免备份撑爆服务器磁盘。
并发安全与优雅退出:使用
flock文件描述符锁防止 cron 重叠执行;捕获 SIGTERM/SIGINT 信号后仅清理本次产生的临时文件,不会误删历史备份,锁随进程退出自动释放。跨平台兼容:对
du、stat等命令在 GNU/Linux 与 BSD/macOS 上的差异做了自适应处理,同一份脚本可在不同发行版上运行。结构化日志与汇总报告:每个阶段输出带时间戳的结构化日志,MySQL 备份和 COS 上传结束后分别输出成功/失败计数汇总,便于监控告警系统解析。
2. 配置介绍
脚本顶部为集中配置区,所有可调参数一目了然:
# ======================== 配置区域 ============================
BACKUP_DIR="/data/backup" # 备份工作目录
HISTORY_DIR="${BACKUP_DIR}/history" # 已上传文件的本地归档目录
LOG_FILE="${BACKUP_DIR}/backup.log" # 日志文件路径
LOCK_FILE="/tmp/auto_backup.lock" # 并发锁文件
# 尽量不要使用硬编码密码,避免密码暴漏在ps,history等命令中!请使用 MYSQL_CNF 指向配置文件(权限600)
MYSQL_USER="root"
MYSQL_PASS="" # 留空,通过 cnf 文件认证
MYSQL_HOST="127.0.0.1"
MYSQL_PORT="3306"
MYSQL_CNF="/etc/mysql/backup.cnf" # 推荐方式
EXCLUDE_DBS="^(information_schema|performance_schema|sys|mysql)$"
# 需要备份的目录,格式: "源路径:别名"
BACKUP_DIRS=(
"/etc/redis:redis-config"
"/etc/nginx:nginx-config"
)
COSCLI_PATH="/usr/local/bin/coscli" # coscli 二进制路径
COS_BUCKET="cos://your-bucket/backup-path/" # COS 目标路径
COS_MAX_RETRIES=3 # 上传最大重试次数
COS_RETRY_BASE_DELAY=5 # 首次重试延迟(秒),后续指数增长
HISTORY_RETAIN_DAYS=30 # 历史文件保留天数
HISTORY_MAX_SIZE_GB=50 # 历史目录最大容量(GB)
# ======================== 配置结束 ============================MySQL 安全认证配置
请尽量不要在脚本中硬编码数据库密码。请创建专用配置文件:
cat > /etc/mysql/backup.cnf << 'EOF'
[mysqldump]
user=root
password=YourStrongPassword
host=127.0.0.1
port=3306
EOF
chmod 600 /etc/mysql/backup.cnf然后将脚本中的 MYSQL_CNF 指向该文件路径。脚本会优先使用 --defaults-extra-file 方式读取凭证,密码不会出现在进程列表中。
3. 安装说明
3.1 前置依赖安装
安装 coscli(腾讯云对象存储命令行工具)
# 下载最新版 coscli ,链接地址随时可变,请到腾讯云官网找到最新地址(以 Linux amd64 为例)
# 腾讯云官网地址:https://cloud.tencent.com/document/product/436/63144
wget https://coscli-1253960454.cos.ap-guangzhou.myqcloud.com/coscli-linux-amd64 -O /usr/local/bin/coscli
chmod +x /usr/local/bin/coscli
# 交互式初始化配置(填写 SecretId/SecretKey/Region)
coscli config init
# 验证连通性
coscli ls cos://your-bucket/提示:建议使用 CAM 子账号并仅授予目标 Bucket 的写入权限,遵循最小权限原则。
确认 MySQL 客户端工具
# 确认mysqldump命令存在
mysqldump 3.2 部署脚本
# 创建备份目录
mkdir -p /data/backup/history
# 放置脚本并赋予执行权限
cp auto_backup.sh /usr/local/sbin/auto_backup.sh
chmod 700 /usr/local/sbin/auto_backup.sh
# 手动执行一次验证
bash /usr/local/sbin/auto_backup.sh
# 检查日志确认各阶段正常
tail -f /data/backup/backup.log3.3 配置定时任务
编辑 crontab,建议安排在业务低峰期执行:
crontab -e添加以下条目(每天凌晨 3:00 执行):
0 3 * * * /usr/local/sbin/auto_backup.sh >> /data/backup/cron.log 2>&13.4 验证清单
部署完成后,请逐项确认:
检查项 | 验证方法 |
|---|---|
并发锁生效 | 同时启动两个实例,第二个应输出“另一个备份实例正在运行”并退出 |
MySQL 备份完整 | 解压任意 |
COS 上传成功 | 登录腾讯云控制台确认文件存在且大小一致 |
历史清理正常 | 手动调小 |
中断清理安全 | 备份过程中 |
日志可解析 | 确认汇总行格式固定,便于接入 Prometheus/Grafana 等监控系统 |
最后提醒:备份的终极验证是恢复测试。请定期从 COS 下载备份文件并在隔离环境中执行恢复演练,确保备份真正可用。未经恢复验证的备份,等于没有备份。
4.完整文件
以下为完整脚本文件,附件里也可以直接下载
#!/bin/bash
#================================================================
# Linux 自动备份脚本 (MySQL + 指定目录 + 腾讯云COS + History清理)
# 版本: 2.4
# 注意: 必须使用 bash 执行 (./auto_backup.sh 或 bash auto_backup.sh)
#================================================================
set -o pipefail
# ======================== 配置区域 ============================
BACKUP_DIR="/data/backup"
HISTORY_DIR="${BACKUP_DIR}/history"
LOG_FILE="${BACKUP_DIR}/backup.log"
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
LOCK_FILE="/tmp/auto_backup.lock"
# [安全] 禁止硬编码密码,请使用 MYSQL_CNF 指向含 [mysqldump] 段的配置文件(权限600)
MYSQL_USER="root"
MYSQL_PASS=""
MYSQL_HOST="127.0.0.1"
MYSQL_PORT="3306"
MYSQL_CNF=""
EXCLUDE_DBS="^(information_schema|performance_schema|sys|mysql)$"
BACKUP_DIRS=(
"/etc/redis:redis-config"
"/etc/nginx:nginx-config"
)
COSCLI_PATH="/usr/local/bin/coscli"
COS_BUCKET="cos://your-bucket-name/backup-path/"
COS_MAX_RETRIES=3
COS_RETRY_BASE_DELAY=5
HISTORY_RETAIN_DAYS=30
HISTORY_MAX_SIZE_GB=50
# ======================== 配置结束 ============================
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
# 跨平台获取目录字节大小 (GNU du -sb / BSD du -sk)
get_dir_bytes() {
local dir="$1"
if du --version &>/dev/null 2>&1; then
du -sb "$dir" 2>/dev/null | awk '{print $1}'
else
local kb
kb=$(du -sk "$dir" 2>/dev/null | awk '{print $1}')
echo $((kb * 1024))
fi
}
format_bytes() {
local bytes="$1"
local gb=$((bytes / 1073741824))
local mb_pct=$(( (bytes % 1073741824) * 100 / 1073741824 ))
printf "%d.%02dGB" "$gb" "$mb_pct"
}
# 跨平台查找最旧文件 (GNU stat -c / BSD stat -f)
find_oldest_file() {
local dir="$1"
if stat --version &>/dev/null 2>&1; then
find "$dir" -maxdepth 1 -type f -exec stat -c '%Y %n' {} \; 2>/dev/null \
| sort -n | head -1 | cut -d' ' -f2-
else
find "$dir" -maxdepth 1 -type f -exec stat -f '%m %N' {} \; 2>/dev/null \
| sort -n | head -1 | cut -d' ' -f2-
fi
}
# 中断时仅清理本次时间戳产生的不完整文件,锁随进程退出自动释放
cleanup_on_exit() {
log "WARN: 收到中断信号,正在清理本次临时文件..."
local cleaned=0
while IFS= read -r f; do
[ -z "$f" ] && continue
rm -f "$f" 2>>"$LOG_FILE"
((cleaned++))
done < <(find "$BACKUP_DIR" -maxdepth 1 -type f -name "*-${TIMESTAMP}.*" 2>/dev/null)
log "已清理 ${cleaned} 个临时文件"
exit 130
}
trap cleanup_on_exit SIGTERM SIGINT SIGHUP
check_env() {
mkdir -p "$BACKUP_DIR" "$HISTORY_DIR"
if ! command -v mysqldump &> /dev/null; then
log "ERROR: mysqldump 未安装"; exit 1
fi
if [ ! -x "$COSCLI_PATH" ]; then
COSCLI_PATH=$(command -v coscli 2>/dev/null || true)
fi
if [ -z "$COSCLI_PATH" ] || [ ! -x "$COSCLI_PATH" ]; then
log "ERROR: coscli 不可用,请检查配置或PATH"; exit 1
fi
log "INFO: 使用 coscli: $COSCLI_PATH"
}
backup_mysql() {
log "===== 开始 MySQL 分库备份 ====="
local mysql_auth=""
if [ -n "$MYSQL_CNF" ] && [ -f "$MYSQL_CNF" ]; then
mysql_auth="--defaults-extra-file=$MYSQL_CNF"
elif [ -n "$MYSQL_PASS" ]; then
mysql_auth="-u${MYSQL_USER} -p${MYSQL_PASS} -h${MYSQL_HOST} -P${MYSQL_PORT}"
else
mysql_auth="-u${MYSQL_USER} -h${MYSQL_HOST} -P${MYSQL_PORT}"
fi
databases=$(mysql $mysql_auth -N -e "SHOW DATABASES;" 2>>"$LOG_FILE")
if [ $? -ne 0 ]; then
log "ERROR: 无法连接 MySQL"; return 1
fi
local db_total=0 db_success=0 db_fail=0
for db in $databases; do
[[ "$db" =~ $EXCLUDE_DBS ]] && continue
((db_total++))
local dump_file="${BACKUP_DIR}/mysql-${db}-${TIMESTAMP}.sql"
local tar_file="${BACKUP_DIR}/mysql-${db}-${TIMESTAMP}.tar.gz"
log "正在备份数据库: $db"
mysqldump $mysql_auth --single-transaction --routines --triggers "$db" > "$dump_file" 2>>"$LOG_FILE"
# 三重校验: 返回码 + 非空 + 尾部完成标记(防止中途断开的不完整备份)
local dump_ok=false
if [ $? -eq 0 ] && [ -s "$dump_file" ]; then
if tail -1 "$dump_file" 2>/dev/null | grep -q "^-- Dump completed"; then
dump_ok=true
else
log "ERROR: 数据库 $db dump缺少完成标记,可能不完整"
fi
else
log "ERROR: 数据库 $db mysqldump 失败或输出为空"
fi
if $dump_ok; then
tar -czf "$tar_file" -C "$BACKUP_DIR" "$(basename "$dump_file")" 2>>"$LOG_FILE"
rm -f "$dump_file"
if [ $? -eq 0 ] && [ -s "$tar_file" ]; then
log "SUCCESS: $tar_file ($(du -h "$tar_file" | cut -f1))"
((db_success++))
else
log "ERROR: 数据库 $db 压缩失败"
rm -f "$tar_file"
((db_fail++))
fi
else
rm -f "$dump_file"
((db_fail++))
fi
done
log "MySQL 备份汇总: 总计=${db_total} 成功=${db_success} 失败=${db_fail}"
[ "$db_fail" -gt 0 ] && log "WARN: 有 ${db_fail} 个数据库备份失败"
return 0
}
backup_dirs() {
log "===== 开始目录备份 ====="
local valid_count=0
for item in "${BACKUP_DIRS[@]}"; do
[ -n "$item" ] && ((valid_count++))
done
if [ "$valid_count" -eq 0 ]; then
log "INFO: BACKUP_DIRS 为空,跳过目录备份"
return 0
fi
for item in "${BACKUP_DIRS[@]}"; do
[ -z "$item" ] && continue
local src_path="${item%%:*}"
local alias_name="${item##*:}"
local tar_file="${BACKUP_DIR}/${alias_name}-${TIMESTAMP}.tar.gz"
if [ ! -d "$src_path" ]; then
log "WARN: 目录不存在,跳过: $src_path"
continue
fi
log "正在备份目录: $src_path -> $alias_name"
tar -czf "$tar_file" -C "$(dirname "$src_path")" "$(basename "$src_path")" 2>>"$LOG_FILE"
if [ $? -eq 0 ] && [ -s "$tar_file" ]; then
log "SUCCESS: $tar_file ($(du -h "$tar_file" | cut -f1))"
else
log "ERROR: 目录 $src_path 备份失败"
fi
done
}
upload_with_retry() {
local file="$1"
local dest="$2"
local retry_delay=$COS_RETRY_BASE_DELAY
for ((i = 1; i <= COS_MAX_RETRIES; i++)); do
"$COSCLI_PATH" cp "$file" "$dest" 2>>"$LOG_FILE" && return 0
if [ "$i" -lt "$COS_MAX_RETRIES" ]; then
log "WARN: 第${i}次上传失败,${retry_delay}s后重试..."
sleep "$retry_delay"
retry_delay=$((retry_delay * 2))
fi
done
return 1
}
# 使用进程替换而非管道while,确保计数器在当前Shell作用域内正确累加
upload_to_cos() {
log "===== 开始上传至腾讯云 COS ====="
local upload_ok=0 upload_fail=0
while IFS= read -r file; do
[ -z "$file" ] && continue
local filename
filename=$(basename "$file")
log "上传中: $filename"
if upload_with_retry "$file" "${COS_BUCKET}${filename}"; then
mv "$file" "$HISTORY_DIR/"
log "SUCCESS: $filename 已上传并移至 history"
((upload_ok++))
else
log "ERROR: $filename 上传失败(已重试${COS_MAX_RETRIES}次),保留在备份目录"
((upload_fail++))
fi
done < <(find "$BACKUP_DIR" -maxdepth 1 -type f \( -name "*.tar.gz" -o -name "*.sql" \) 2>/dev/null)
log "COS 上传汇总: 成功=${upload_ok} 失败=${upload_fail}"
}
cleanup_history() {
log "===== 开始清理 History 目录 ====="
if [ "$HISTORY_RETAIN_DAYS" -gt 0 ] 2>/dev/null; then
local old_files
old_files=$(find "$HISTORY_DIR" -maxdepth 1 -type f -mtime +"$HISTORY_RETAIN_DAYS" 2>>"$LOG_FILE")
if [ -n "$old_files" ]; then
local count
count=$(echo "$old_files" | wc -l)
echo "$old_files" | xargs rm -f 2>>"$LOG_FILE"
log "按时间清理: 删除超过 ${HISTORY_RETAIN_DAYS} 天的文件 ${count} 个"
else
log "按时间清理: 无过期文件"
fi
fi
if [ "$HISTORY_MAX_SIZE_GB" -gt 0 ] 2>/dev/null; then
local max_bytes=$((HISTORY_MAX_SIZE_GB * 1024 * 1024 * 1024))
while true; do
local current_bytes
current_bytes=$(get_dir_bytes "$HISTORY_DIR")
[ -z "$current_bytes" ] && break
if [ "$current_bytes" -le "$max_bytes" ]; then
log "按容量清理: 当前 $(format_bytes "$current_bytes"), 未超限(${HISTORY_MAX_SIZE_GB}GB)"
break
fi
local oldest_file
oldest_file=$(find_oldest_file "$HISTORY_DIR")
if [ -z "$oldest_file" ]; then
log "按容量清理: 目录已空但仍超限,请检查子目录或非备份文件"
break
fi
rm -f "$oldest_file" 2>>"$LOG_FILE"
log "按容量清理: 已删除最旧文件 $(basename "$oldest_file")"
done
fi
log "History 清理完成"
}
main() {
exec 200>"$LOCK_FILE"
if ! flock -n 200; then
log "WARN: 另一个备份实例正在运行,本次跳过"
exit 0
fi
local start_seconds=$SECONDS
log "=========================================="
log "备份任务启动 | 时间戳: $TIMESTAMP"
log "=========================================="
check_env
backup_mysql
backup_dirs
upload_to_cos
cleanup_history
local elapsed=$((SECONDS - start_seconds))
local exit_code=0
local residual_count
residual_count=$(find "$BACKUP_DIR" -maxdepth 1 -type f \( -name "*.tar.gz" -o -name "*.sql" \) 2>/dev/null | wc -l)
if [ "$residual_count" -gt 0 ]; then
exit_code=1
log "WARN: 备份目录存在 ${residual_count} 个未上传文件"
fi
log "========== 备份任务结束 | 耗时:${elapsed}s | 退出码:${exit_code} =========="
exit $exit_code
}
main "$@"