嘿,朋友,我是 Agnes。
我知道你此刻可能正对着黑漆漆的终端发呆,心里七上八下。也许是你刚接手了一个并不友好的生产环境,老板丢下一句:“帮我写个脚本,每天备份数据库,还要自动清理旧日志,搞不定就加班。” 你嘴上答应得爽快,手却在键盘上颤抖。
别慌,我见过太多这样的场景,包括我自己曾经也是那个连 chmod 都要查半天帮助手册的小白。今天,我不跟你整那些虚头巴脑的教科书定义,咱们直接聊干货。我要带你走的这条路,是我踩了无数个坑、掉了无数根头发后,用血泪换来的“保命指南”。我们将一起构建一个健壮、安全、且带有自我修复能力的 Shell 脚本,专门用于 MySQL 数据的循环备份与日志清理。
准备好了吗?让我们深入代码的世界,看看那里面的陷阱到底藏在哪里。
第一阶段:别急着写代码,先理清“为什么”和“在哪里”
很多新手上来就打开记事本,噼里啪啦敲下一行 mysqldump,然后运行,结果报错连天。这是因为你忽略了运维最核心的原则:环境感知。
在编写任何脚本之前,你必须像侦探一样搞清楚三个问题:
- MySQL 怎么连? 用户名、密码、主机地址、端口,甚至 Socket 路径。
- 备份在哪里存? 本地磁盘?NAS?还是远程 S3?本地磁盘的话,空间够不够?
- 日志是什么? 是 MySQL 的错误日志、慢查询日志,还是业务应用的
access.log?
假设你的环境是这样的:
- MySQL 运行在本地,端口 3306。
- 备份存放目录:
/data/backup/mysql/ - 需要清理的日志目录:
/var/log/myapp/ - 备份保留策略:保留最近 7 天的数据,超过 7 天的自动删除。
如果你连这些前提都没有确认,写出来的脚本就是空中楼阁。现在,让我们开始搭建脚手架。
第二阶段:基础版脚本——能跑,但风险极高
我们先写一个最基础的版本。这个版本能完成基本任务,但如果你直接扔给生产环境,它可能会让你“痛”不欲生。
#!/bin/bash
# 配置项
DB_USER="root"
DB_PASS="your_password"
DB_HOST="localhost"
BACKUP_DIR="/data/backup/mysql"
LOG_DIR="/var/log/myapp"
RETENTION_DAYS=7
# 创建备份目录(如果不存在)
mkdir -p $BACKUP_DIR
# 获取当前时间戳,用于文件名
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/db_backup_$DATE.sql.gz"
# 执行备份
mysqldump -u$DB_USER -p$DB_PASS -h$DB_HOST --all-databases | gzip > $BACKUP_FILE
# 清理旧备份
find $BACKUP_DIR -name "db_backup_*.sql.gz" -mtime +$RETENTION_DAYS -delete
# 清理旧日志(假设日志文件以 .log 结尾)
find $LOG_DIR -name "*.log" -mtime +$RETENTION_DAYS -delete
echo "Backup and cleanup completed at $(date)"
这段代码有什么问题?让我们来做个“尸检”。
首先,看这一行:mysqldump -u$DB_USER -p$DB_PASS。你把密码直接暴露在命令行参数里了。在 Linux 系统中,运行中的进程可以通过 /proc 文件系统被其他用户查看,这意味着任何同用户的进程都能看到你的密码。这是严重的安全漏洞。
其次,没有任何错误检查。如果 mysqldump 失败了(比如密码错了,或者磁盘满了),脚本依然会继续执行后面的清理命令,甚至可能误删数据。更糟糕的是,如果备份文件没有生成,你并不知道,直到真正需要恢复时才发现问题,那时候已经晚了。
再者,find ... -delete 是一个非常危险的操作。如果你的 $RETENTION_DAYS 变量因为某种原因变成了空值或负数,这条命令可能会在瞬间删除你目录下的所有文件。
所以,这个基础版只是“能跑”,绝对不能用于生产。接下来,我们要把它改造成一个真正的“工业级”脚本。
第三阶段:进阶版——加入防御性编程与详细日志
我们要引入“防御性编程”的概念。什么叫防御性编程?就是假设一切都会出错,然后为每一个可能的错误点做好准备。
1. 安全存储密码
最安全的做法是使用 MySQL 的配置文件,或者通过环境变量传递。这里我们推荐创建一个 .my.cnf 文件,并限制其权限。
在用户主目录下创建 ~/.my.cnf:
[client]
user=root
password=your_password
host=localhost
然后执行:
chmod 600 ~/.my.cnf
这样,mysqldump 就可以通过读取这个文件来认证,而无需在命令行中暴露密码。在脚本中,我们只需要加上 --defaults-file=~/.my.cnf 或者让 mysqldump 自动读取。为了脚本的通用性,我们可以在脚本中指定:
MYCNF="/root/.my.cnf"
2. 完整的错误处理与日志记录
我们需要一个日志系统,记录脚本的每一步操作,特别是错误信息。
#!/bin/bash
# ================= 配置区域 =================
DB_CONFIG="/root/.my.cnf"
BACKUP_DIR="/data/backup/mysql"
LOG_DIR="/var/log/myapp"
KEEP_DAYS=7
SCRIPT_LOG="/var/log/db_backup.log"
# ===========================================
# 定义日志函数,便于统一管理输出
log() {
local level=$1
local msg=$2
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] [$level] $msg" | tee -a "$SCRIPT_LOG"
}
# 备份数据库
backup_database() {
local date_str=$(date +%Y%m%d_%H%M%S)
local backup_file="${BACKUP_DIR}/full_backup_${date_str}.sql.gz"
log "INFO" "Starting database backup to ${backup_file}..."
# 使用 --defaults-file 避免密码暴露在进程列表中
# --single-transaction 确保 InnoDB 的一致性,不加锁
# --routines --triggers 备份存储过程和触发器
mysqldump --defaults-file="$DB_CONFIG" \
--single-transaction \
--routines \
--triggers \
--all-databases \
| gzip > "$backup_file"
local exit_code=$?
if [ $exit_code -ne 0 ]; then
log "ERROR" "Database backup failed with exit code ${exit_code}!"
return $exit_code
fi
# 检查备份文件是否非空
if [ ! -s "$backup_file" ]; then
log "ERROR" "Backup file is empty!"
rm -f "$backup_file"
return 1
fi
local file_size=$(du -h "$backup_file" | cut -f1)
log "INFO" "Backup completed successfully. Size: ${file_size}"
return 0
}
# 清理旧备份
cleanup_backups() {
log "INFO" "Starting cleanup of backups older than ${KEEP_DAYS} days..."
# 使用 -maxdepth 防止误删上级目录
# 先打印将要删除的文件,用于审计
local files_to_delete=$(find "$BACKUP_DIR" -name "full_backup_*.sql.gz" -type f -mtime +${KEEP_DAYS} 2>/dev/null)
if [ -n "$files_to_delete" ]; then
echo "$files_to_delete" | while read -r file; do
log "INFO" "Deleting old backup: $file"
rm -f "$file"
done
else
log "INFO" "No old backups to delete."
fi
}
# 清理旧日志
cleanup_logs() {
log "INFO" "Starting cleanup of logs older than ${KEEP_DAYS} days..."
# 同样,先审计
local logs_to_delete=$(find "$LOG_DIR" -name "*.log" -type f -mtime +${KEEP_DAYS} 2>/dev/null)
if [ -n "$logs_to_delete" ]; then
echo "$logs_to_delete" | while read -r file; do
log "INFO" "Deleting old log: $file"
rm -f "$file"
done
else
log "INFO" "No old logs to delete."
fi
}
# ================= 主程序 =================
main() {
log "INFO" "=== Backup Script Started ==="
# 检查配置文件是否存在
if [ ! -f "$DB_CONFIG" ]; then
log "ERROR" "MySQL config file not found at ${DB_CONFIG}!"
exit 1
fi
# 确保备份目录存在
mkdir -p "$BACKUP_DIR"
# 执行备份
backup_database
local backup_status=$?
if [ $backup_status -eq 0 ]; then
# 只有备份成功才清理
cleanup_backups
cleanup_logs
log "INFO" "=== Backup Script Finished Successfully ==="
else
log "ERROR" "=== Backup Script Finished with Errors ==="
# 这里可以添加报警逻辑,比如发送钉钉/企业微信通知
# send_alert "Backup failed!"
exit $backup_status
fi
}
# 运行主程序
main
这段脚本好在哪里?我们逐一分析:
log函数:我们将所有输出都记录到日志文件,并且实时打印到屏幕。这样,即使你在后台运行脚本,事后也能追溯每一步的操作。--single-transaction:这是 InnoDB 引擎备份的关键。它保证了备份期间的一致性,而不会锁表,对生产环境几乎无影响。- 错误码检查:每一次关键操作后,我们都检查
$?。如果失败,立即记录 ERROR 日志并返回非零状态码,终止后续操作(比如清理)。 - 空文件检查:备份后检查文件大小,防止因为磁盘满等原因生成了空备份文件,这种“假备份”是灾难性的。
- 安全删除:我们使用了
find配合-type f只查找普通文件,并且先打印将要删除的文件列表。这给了你最后一次“后悔”的机会。你也可以将删除操作改为移动到一个临时目录,过一天确认没问题再彻底删除,这样更稳妥。
第四阶段:终极避坑——应对那些“意想不到”的场景
即使有了上面的脚本,现实世界依然充满不确定性。以下是一些新手最容易忽略,却足以毁掉一切的“坑”。
坑一:磁盘空间不足
如果磁盘满了,mysqldump 可能写不出完整的文件,或者 gzip 压缩失败。你的清理脚本可能会因为无法写入日志而静默失败。
解决方案:在备份前检查磁盘空间。
check_disk_space() {
local dir=$1
local threshold=90 # 超过 90% 认为危险
local usage=$(df -h "$dir" | awk 'NR==2 {print $5}' | sed 's/%//')
if [ "$usage" -gt "$threshold" ]; then
log "ERROR" "Disk usage on ${dir} is ${usage}%, which exceeds threshold ${threshold}%"
return 1
fi
return 0
}
将这个检查加入到 main 函数的开头。
坑二:MySQL 服务不稳定
如果 MySQL 只是瞬间抖动,mysqldump 可能会失败。重试一次可能就好了。
解决方案:增加重试机制。
retry_backup() {
local max_retries=3
local retry_count=0
local date_str=$(date +%Y%m%d_%H%M%S)
local backup_file="${BACKUP_DIR}/full_backup_${date_str}.sql.gz"
until [ $retry_count -ge $max_retries ]; do
log "INFO" "Attempt ${retry_count} + 1 of ${max_retries}..."
mysqldump --defaults-file="$DB_CONFIG" \
--single-transaction \
--routines \
--triggers \
--all-databases \
| gzip > "$backup_file"
if [ $? -eq 0 ]; then
if [ -s "$backup_file" ]; then
log "INFO" "Backup succeeded on attempt $((retry_count + 1))"
return 0
fi
fi
retry_count=$((retry_count + 1))
log "WARN" "Backup failed or empty, retrying in 5 seconds..."
sleep 5
done
log "ERROR" "Backup failed after ${max_retries} attempts."
return 1
}
坑三:备份文件损坏
即使文件非空,也可能因为压缩算法问题导致文件损坏,无法解压恢复。
解决方案:定期(比如每周)自动测试一个最近期的备份文件,解压到一个临时目录,看是否能成功。
test_backup() {
local latest_backup=$(ls -t ${BACKUP_DIR}/full_backup_*.sql.gz 2>/dev/null | head -1)
if [ -z "$latest_backup" ]; then
return 1
fi
local test_dir=$(mktemp -d)
log "INFO" "Testing backup integrity: ${latest_backup}"
if gunzip -c "$latest_backup" | mysql --defaults-file="$DB_CONFIG" -o /dev/null 2>&1; then
log "INFO" "Backup integrity test passed."
else
log "ERROR" "Backup integrity test failed!"
fi
rm -rf "$test_dir"
}
注意:上面的测试方法其实有点取巧,直接管道到 mysql 而不创建实际库表只是测试连通性和 gz 完整性。更严谨的做法是 gunzip 到文件,然后用 mysqlfrm 或者在测试库中导入一小部分来验证。但对于日常巡检,确认 gunzip 不报错且文件大小合理通常是第一步。更简单的做法是直接 gunzip -t 测试 gzip 完整性。
# 更简单的完整性检查
if gunzip -t "$latest_backup" 2>/dev/null; then
log "INFO" "Backup gzip integrity OK."
else
log "ERROR" "Backup gzip integrity FAILED!"
fi
坑四:Crontab 的坑
你写好了脚本,权限也设好了(chmod +x backup.sh),但是 crontab -e 里加了任务却没运行。这是新手最常见的崩溃点。
原因分析:
- 环境变量不同:Cron 运行的环境非常精简,
PATH变量里可能没有/usr/local/bin或/usr/bin,导致mysqldump、find等命令找不到。 - 权限问题:备份目录的权限可能不允许 cron 用户写入。
- 日志权限:脚本试图写入
/var/log/db_backup.log,但 cron 用户可能没有权限。
解决方案:
- 使用绝对路径:在脚本中,所有命令都使用绝对路径。例如,用
/usr/bin/mysqldump而不是mysqldump。你可以用which mysqldump来查找路径。 - 在脚本开头设置 PATH:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin - Crontab 配置建议:
注意将标准输出和错误输出都重定向到一个日志文件,这样你可以看到 cron 是否有报错。# 每天凌晨 2 点执行备份 0 2 * * * /root/scripts/mysql_backup.sh >> /var/log/cron_backup.log 2>&1
第五阶段:完整的生产级脚本整合
现在,我们将上述所有考虑整合到一个最终的、健壮性的脚本中。
”`bash #!/bin/bash #
MySQL 自动备份与日志清理脚本
作者: Agnes
版本: 1.0
描述: 每日备份所有数据库,清理过期备份和日志
#
================= 严格模式 =================
set -euo pipefail
set -e: 命令失败时立即退出
set -u: 使用未定义变量时报错
set -o pipefail: 管道中任一命令失败则整体失败
===========================================
================= 配置区域 =================
readonly DB_CONFIG=“/root/.my.cnf” readonly BACKUP_DIR=“/data/backup/mysql” readonly LOG_DIR=“/var/log/myapp” readonly KEEP_DAYS=7 readonly SCRIPT_LOG=“/var/log/db_backup.log” readonly MAX_RETRY=3 readonly DISK_THRESHOLD=90
===========================================
================= 工具函数 =================
log() {
local level=$1
local msg=$2
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] [$level] $msg" | tee -a "$SCRIPT_LOG"
}
get_abs_path() {
local target="${1:-.}"
if [ -d "$target" ]; then
(cd "$target" && pwd)
else
echo "$target"
fi
}
check_disk_space() {
local dir=$1
local threshold=$2
local usage
# 使用 awk 提取百分比数字
usage=$(df -h "$dir" | awk 'NR==2 {gsub(/%/,"",$5); print $5}')
if [ "$usage" -gt "$threshold" ]; then
log "ERROR" "Disk usage on ${dir} is ${usage}
