ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL备份自动化:mysqldump脚本设计与实际落地

MySQL备份自动化:mysqldump脚本设计与实际落地

凌晨两点半,手机震动把我从梦里拉了出来——某项目核心库的备份任务推送了一条异常告警。爬起来一看,日志里留着一条不痛不痒的报错,备份文件却已经连续三天没有新增了。那是我第一次意识到一个扎心的事实:mysqldump 备份自动化脚本,看着就是“一行 mysqldump 加一个 crontab”的事,真正要在无人值守的深夜可靠跑上几个月,遇到的坑比想象中多得多。

那之后我重写了整个备份脚本,把磁盘预检、失败告警、管道退出码、密码安全、过期清理、恢复演练全部补上。这篇内容就是基于这次重构总结出来的,专门写给那些正在用 mysqldump 做 MySQL 全量备份,或者准备把备份机制“自动化”起来的运维和开发同学。我会先讲为什么选脚本化方案,再逐步拆解一套可直接抄走的备份脚本代码,最后分享定时任务落地和恢复演练的实战经验。

1. 为什么我最终选择了mysqldump脚本化,而不是现成的备份平台

1.1 先亮个观点:备份的价值要等灾难发生时才看得出来

备份这种工作,平时几乎没人关注。数据库没出事的时候,大家默认“每天都在备份,应该没问题”;真出事了,才第一次去翻备份目录,然后发现要么文件是空的,要么权限不对,要么恢复出来的数据和线上差了十万八千里。

我接手维护某套业务库之后做的第一件事,就是把所有备份任务列了个清单,逐个验证“能不能顺利恢复到一台干净的实例上”。结果让我后背发凉:有三分之一的备份文件根本没写完整,有几次是因为磁盘满了,有几次是 mysqldump 在管道压缩时悄悄报错,备份目录里躺着一个 0 字节的压缩包。从那以后我明白了一个道理:备份脚本不是写出来就算完,它得能在你完全不干预的情况下,自主完成“备份、校验、告警、清理”这一整条链路。

mysqldump 是 MySQL 自带的逻辑备份工具,参数成熟、生态广泛,网上资料多,新人容易入手。虽然它在大数据量下性能不如物理备份,但对绝大多数中小规模业务库来说,逻辑备份已经足够可靠,而且恢复方式灵活,可以只挑某张表出来恢复。

1.2 逻辑备份和物理备份怎么选

很多人在选型时会纠结:到底用 mysqldump 还是用物理备份工具?我的判断标准很简单,先看数据量级。

维度mysqldump(逻辑备份)物理备份工具
备份速度较慢,导出 SQL 文本快,直接拷贝数据文件
恢复粒度支持库级、表级恢复一般整实例恢复,单表恢复麻烦
跨版本恢复灵活,SQL 文本可调严格依赖版本,升级容易出问题
对线上影响配合一致性快照影响较小资源占用高,高峰期有压力
运维门槛低,自带原生工具较高,依赖额外组件和权限

我现在的原则是:单实例数据量在 50GB 以内、业务能接受每天固定时段做一次全量备份,mysqldump 完全够用。如果库已经上百 GB,或者对恢复时间要求很苛刻,再上物理备份工具不迟。反过来,如果硬要在几十 GB 的小库上上物理备份方案,光解决权限、依赖、版本匹配这些事,就够运维团队忙一阵子,并不划算。

1.3 脚本化的真实好处:可控、可审计、不过度设计

选择脚本化而不是接一套商业备份平台,核心原因是“可控”。脚本的逻辑每一行都写在自己人手里,哪天 MySQL 版本升级了,哪个参数不兼容了,直接改脚本就行,不需要等平台方发版。另外备份任务是要被审计的——哪天问起来“上周三的备份在不在”,我能直接从日志和元数据文件里给出答案,而不是登录平台翻半天记录。

当然,脚本化也有代价:所有边界情况都得自己处理。比如磁盘空间不够怎么办、mysqldump 卡住怎么办、备份文件写到一半服务器重启怎么办。这些我会在后面几节逐步展开,每一条都来自真实踩坑。

2. 备份脚本的核心设计与参数取舍

2.1 一套可靠备份方案的最低完成标准

我把“可靠”拆成五个必须满足的条件:

  • 无人值守:脚本不依赖人工盯着,定时任务自动触发。
  • 失败可感知:任何一步出错,都要有日志记录,并且能通过告警方式触达值班的人。
  • 磁盘预检:备份前先评估空间够不够,避免备份写到一半把磁盘塞满,拖垮线上业务。
  • 自动轮转:旧备份按保留策略删除,防止备份目录无限膨胀。
  • 可恢复验证:备份文件本身能通过命令解压、检测,且定期做恢复演练。

这五条缺一不可。很多所谓的“备份脚本”只完成了第一步和第二步,结果就是每天文件都在生成,但没人知道那些文件能不能用。真正检验备份方案的不是“备份了多少”,而是“能恢复多少”。

2.2 mysqldump关键参数逐个说清楚

直接看我最终采用的完整命令行:

mysqldump \ --single-transaction \ --routines \ --triggers \ --events \ --master-data=2 \ --set-gtid-purged=OFF \ --default-character-set=utf8mb4 \ --databases db_name

每个参数不是随便加的,背后都有特定目的。

--single-transaction是 InnoDB 表最核心的参数。它通过开启一个一致性的可重复读事务来获取快照,导出过程中不会锁表,也不影响线上读写。如果你的表全是 MyISAM,这个参数就没用了,需要改用--lock-tables锁表备份,但那样会阻塞写入,生产环境要特别谨慎。

--routines、--triggers、--events三个参数是把存储过程、触发器、定时事件一起备份。默认情况下 mysqldump 不导出这些对象,很多人在恢复之后才发现存储过程全部丢失,后悔都来不及。需要注意,导出这些对象分别需要 SELECT、EVENT、TRIGGER 等权限,备份账号要提前配齐。

--master-data=2会在备份文件里记录当时 binlog 的文件名和位置,以注释形式写在文件头部。这个信息是做时间点恢复的关键,没有它,全量备份只能恢复到“备份完成那一刻”,之后的数据变更全部丢失。我用它把 binlog 位点单独提取出来存成元数据,后面第 3 节会讲。

--set-gtid-purged=OFF是专门给开了 GTID 的实例准备的。如果不关掉,导出文件顶部会带SET @@GLOBAL.GTID_PURGED语句,恢复到已有业务数据的实例上时非常容易报错。日常全量备份默认 OFF 最省心;只有当你准备用这个备份文件去搭建一个新的从库,才需要改成ON或AUTO。

--default-character-set=utf8mb4用来锁定字符集。很多乱码事故都出在没指定字符集,备份时连接用了 latin1,导入到目标库后中文全部变问号。8.0 版本建议直接 utf8mb4,兼容性最好。

--databases db_name会在导出文件里包含CREATE DATABASE和USE语句,恢复的时候不需要特意指定库名。这个参数也可以一次接多个库名,内部实现是循环导出,比用一个 mysqldump 导出全实例更可控。

2.3 密码不进脚本:login-path的正确用法

脚本里直接写 MySQL 密码是最常见的坏习惯。一旦脚本被同步到代码仓库、被其他同事读到、或者服务器被入侵,数据库密码就彻底暴露了。

MySQL 官方提供了mysql_config_editor工具,可以用交互方式把登录凭据加密保存到当前用户家目录下的.mylogin.cnf文件里:

mysql_config_editor set --login-path=backup \ --host=127.0.0.1 \ --port=3306 \ --user=backup_user \ --password

执行后会提示输入密码,输入一次即可。之后在脚本里直接用--login-path=backup代替-u backup_user -p'明文密码':

mysqldump --login-path=backup \ -h 127.0.0.1 -P 3306 \ --single-transaction ...

这样文件系统里看不到明文密码,ps进程列表里也不会出现密码参数。备份脚本被拷走也不用担心泄露数据库口令。

3. 脚本代码逐段拆解:从磁盘检查到异常告警

下面是我重构后的完整备份脚本,核心逻辑都在注释里。我把脚本拆成几块来讲,方便你按需裁剪。

#!/usr/bin/env bash # 文件名:mysql_daily_backup.sh # 功能:基于 mysqldump 的全量备份 + gzip 压缩 + 自动轮转 + 异常告警 set -uo pipefail # ---------- 配置区 ---------- BACKUP_BASE_DIR="/data/backup/mysql" LOG_FILE="/var/log/mysql_backup.log" DB_HOST="127.0.0.1" DB_PORT="3306" DATABASE_LIST="db1 db2" RETENTION_DAYS=7 AUTH_ARGS="--login-path=backup" # 通知机器人地址,从环境变量读取,避免硬编码 WEBHOOK_URL="${BACKUP_WEBHOOK_URL:-}" # ---------- 工具函数 ---------- log() { echo "[$(date '+%F %T')] $*" >>"${LOG_FILE}" } notify() { local level="$1" local message="$2" log "[${level}] ${message}" if [[ -n "${WEBHOOK_URL}" ]]; then curl -fsS -X POST -H 'Content-Type: application/json' \ -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"${message}\"}}" \ "${WEBHOOK_URL}" >/dev/null 2>&1 || true fi } fatal() { notify "ERROR" "备份失败: $*" exit 1 } # ---------- 1. 预检 ---------- command -v mysqldump >/dev/null 2>&1 || fatal "未找到 mysqldump 命令" DATE_TAG="$(date +%Y%m%d_%H%M%S)" BACKUP_DIR="${BACKUP_BASE_DIR}/${DATE_TAG}" mkdir -p "${BACKUP_DIR}" || fatal "无法创建备份目录 ${BACKUP_DIR}" mysqladmin ${AUTH_ARGS} -h "${DB_HOST}" -P "${DB_PORT}" ping >/dev/null 2>&1 \ || fatal "MySQL 连接失败,无法开始备份" # ---------- 2. 磁盘空间检查 ---------- need_bytes=$(mysql ${AUTH_ARGS} -N -B -h "${DB_HOST}" -P "${DB_PORT}" \ -e "SELECT IFNULL(SUM(data_length + index_length), 0) FROM information_schema.tables WHERE table_schema NOT IN ('information_schema','mysql','performance_schema','sys');" 2>/dev/null || echo 0) free_bytes=$(df -P "${BACKUP_BASE_DIR}" | awk 'NR==2 {print $4 * 1024}') if (( free_bytes < need_bytes * 2 )); then fatal "磁盘空间不足: 剩余 ${free_bytes} 字节, 预估需要 ${need_bytes} 字节" fi # ---------- 3. 逐库备份、压缩 ---------- backup_one_db() { local db="$1" local out="${BACKUP_DIR}/${db}.sql.gz" mysqldump ${AUTH_ARGS} \ -h "${DB_HOST}" \ -P "${DB_PORT}" \ --single-transaction \ --routines \ --triggers \ --events \ --master-data=2 \ --set-gtid-purged=OFF \ --default-character-set=utf8mb4 \ --databases "${db}" \ 2>>"${LOG_FILE}" | gzip -c >"${out}" local my_status=("${PIPESTATUS[@]}") if [[ ${my_status[0]} -ne 0 ]]; then fatal "mysqldump 导出 ${db} 失败" fi if [[ ${my_status[1]} -ne 0 ]]; then fatal "gzip 压缩 ${db} 失败" fi local size_bytes size_bytes=$(stat -c%s "${out}" 2>/dev/null || echo 0) if (( size_bytes == 0 )); then fatal "备份文件大小为 0: ${out}" fi local binlog_pos binlog_pos=$(gzip -cd "${out}" 2>/dev/null | grep -m1 'CHANGE MASTER TO' || true) echo "${db} ${binlog_pos}" >>"${BACKUP_DIR}/binlog_position.txt" log "备份完成: ${out} ($(du -h "${out}" | cut -f1))" } for db in ${DATABASE_LIST}; do backup_one_db "${db}" done # ---------- 4. 清理过期备份 ---------- find "${BACKUP_BASE_DIR}" -maxdepth 1 -type d -name '[0-9]*' \ -mtime "+${RETENTION_DAYS}" -exec rm -rf {} \; # ---------- 5. 收尾与元数据 ---------- { echo "backup_time=${DATE_TAG}" echo "backup_host=${DB_HOST}:${DB_PORT}" echo "mysql_version=$(mysql --version 2>/dev/null || true)" echo "databases=${DATABASE_LIST}" } >"${BACKUP_DIR}/backup.meta" notify "INFO" "数据库备份成功: ${BACKUP_DIR}" log "全部备份任务结束"

3.1 配置区与日志函数:脚本的“地基”

脚本开头只做两件事:定义变量、定义日志和告警函数。配置区里每一项都应该能独立修改,比如备份目录、保留天数、备份库列表。我习惯把AUTH_ARGS单独抽出来,这样从 login-path 切换到明文用户名密码,只需要改这一处。

log函数让所有输出都带时间戳追加到日志文件,不污染标准输出。notify负责告警,逻辑是先写日志再发 Webhook。Webhook 地址从环境变量读取,不硬编码在脚本里,避免脚本被别人看到时把内部机器人地址也带走。fatal就是“出错立刻告警并退出”,任何一步失败都不会继续往下跑。

3.2 备份前的磁盘空间与MySQL连通性预检

很多备份脚本是不做磁盘预检的,结果就是某天数据翻了倍,备份写到一半磁盘满了,连带着线上业务一起遭殃。要规避这个风险,我提前用information_schema.tables统计所有业务库的数据体积,再和备份目录的剩余空间对比。

这里有个小技巧:统计时把系统库排除掉,只算业务数据;然后预留两倍余量。之所以留两倍,是因为单库存量数据和导出后 SQL 文本、索引开销并不完全一致,加上压缩过程也需要临时空间,留出一倍安全余量更稳妥。

mysqladmin ping这一步看起来多余,其实很重要。如果数据库挂了,mysqldump 连上去也会卡在认证或握手阶段,浪费大量时间,不如先快速探活。

3.3 执行mysqldump并进行压缩:管道退出码的问题

备份的核心动作是mysqldump ... | gzip -c > out.sql.gz。这个管道看着简单,实则暗藏一个非常经典的坑:默认情况下,shell 只会返回管道的最后一个命令的退出码,也就是 gzip 的退出码。就算 mysqldump 中途报错退出,gzip 可能依然正常结束,整个管道显示成功,备份文件却是不完整的。

所以我用了${PIPESTATUS[@]}把管道里每个命令的退出码都取出来,逐个检查。PIPESTATUS是 bash 的数组变量,记录上一条管道命令各段的返回状态。这里my_status[0]是 mysqldump 的退出码,my_status[1]是 gzip 的退出码,任何一个非零都直接判定备份失败。

备份完我还用stat -c%s检查文件大小是否为 0,把空文件提前拦截下来,避免后面排查时才发现备份根本不可用。

3.4 清理过期备份与元数据汇总

清理逻辑用的是find的-mtime参数:找出备份目录下所有早于保留天数的日期目录,直接删除。这里要注意,目录名必须规范,我用的%Y%m%d_%H%M%S格式能保证字典序和日期顺序一致,-name '[0-9]*'也可以防止误删其他非备份目录。

元数据汇总则是把备份时间、MySQL 版本、库列表写成backup.meta,和备份文件放在同一个目录里。哪天需要审计“这个备份是谁、在什么环境、什么时间生成的”,直接看这个文本文件就够了。另外 binlog 位点单独存在binlog_position.txt,恢复时拿出来参考。

4. 定时任务与备份策略的落地配置

4.1 crontab配置:把脚本交给系统

脚本写好后,交给系统定时执行是最自然的方式。我用的是 crontab:

30 1 * * * /usr/bin/env bash /opt/scripts/mysql_daily_backup.sh >> /var/log/mysql_backup_cron.log 2>&1

拆开看这条规则:30 1 * * *表示每天凌晨 1 点 30 分执行一次。为什么选这个时间?业务低峰期,对线上影响最小;而且要和别的运维任务错开,避免凌晨批量任务挤在一起把 IO 打满。脚本输出重定向到独立日志,这样 cron 不会因为脚本有输出就往系统邮箱发信,干扰排查。

4.2 cron环境里的三个隐藏坑

把脚本放到 crontab 之后,经常出现的一种情况是:手动执行脚本一切正常,定时执行就报“找不到 mysqldump”。原因很简单——cron 环境的 PATH 极其精简,通常只有/usr/bin:/bin,而 MySQL 命令往往安装在/usr/local/mysql/bin等目录下。

解决办法有两个:一是在脚本开头显式导出 PATH,二是调用时用command -v mysqldump拿到完整路径。我的脚本里已经做了command -v mysqldump的检查,所以这个问题会在预检阶段直接暴露。

第二个坑是 cron 不加载用户的环境变量。像.bash_profile、.bashrc里的设置,在 cron 环境里完全不生效。如果你在脚本里依赖某个自定义环境变量,要么在 crontab 里用VAR=xxx显式传入,要么在脚本内部自己定义。

第三个坑是时区和系统时间。备份文件名里的日期时间依赖系统时钟,如果服务器时区没配好,或者 NTP 没同步,可能出现文件命名错乱、清理时误删备份的问题。建议所有服务器统一用同一时区,并且开启 NTP 时间同步。

4.3 保留周期怎么定才合理

保留周期不是越长越好。我见过有的团队把备份保留一年,磁盘从 200GB 一路飙到 2TB,最后清理的时候才发现有大量冗余。倒过来,只保留一天又太激进,万一前一天备份损坏,第二天才发现,就什么都没有了。

我目前用的是保留 7 天全量备份,再加 binlog 做增量恢复窗口。这样既能覆盖大部分“误删数据、需要找回前几天状态”的场景,又不至于占用过多磁盘。如果业务对数据安全要求更高,可以保留 14 天或 30 天,但要在磁盘规划时提前算好容量。公式很简单:单次全量备份平均大小乘以保留天数,再留出 20% 到 30% 的余量。

5. 恢复演练:真正检验备份有效性的方式

5.1 没做过恢复演练的备份,等于没有备份

这是我反复跟团队强调的一句话。备份文件天天生成,不代表恢复一定能成功。我经历过一次真实事故:某项目核心库需要按时间点恢复,结果备份文件解压出来后,发现某张业务表的数据结构在导出时就已经不完整,导入步骤卡了几个小时。如果当初做过恢复演练,这种问题早就暴露了。

恢复演练的初衷不是验证“mysqldump 能不能跑通”,而是验证“从备份到导入的整条链路是不是通顺”。包括权限、字符集、SQL 模式、触发器依赖、版本兼容性,这些都可能让恢复流程卡壳。

5.2 全量恢复的标准操作步骤

完整的全量恢复流程,我一般在一台临时实例上做:

# 1. 解压并导入备份 gzip -dc /data/backup/mysql/20250115_013001/db1.sql.gz | mysql --login-path=backup # 2. 验证对象完整性 mysql --login-path=backup -e "SHOW DATABASES;" mysql --login-path=backup -e "USE db1; SHOW TABLES;" # 3. 抽查关键业务表行数 mysql --login-path=backup -e "USE db1; SELECT COUNT(*) FROM t_order;"

如果备份文件是用--databases生成的,导入时会自动创建库,不需要先建库再导入。导入过程中如果出现权限错误,大概率是当前登录用户在目标实例上权限不足,这和 mysqldump 备份时的权限是两回事,要分开排查。

做完这些,我会再做一个时间点恢复的测试:先全量导入,再找备份时间点之后的 binlog,用mysqlbinlog解析指定位点的增量事务,把数据追到目标时间点。

mysqlbinlog --start-position=12345678 /data/binlog/binlog.000123 | mysql --login-path=backup

binlog 的起始位点就是备份元数据文件里记录的那个位置。这条链跑通了,才算真正能应对数据找回的紧急情况。

5.3 单库、单表恢复的实用技巧

有时候不用恢复整个实例,只想找回某张表的数据。直接全量导入到生产库风险太大,正确做法是先从备份文件里把目标表的语句块捞出来,再导入到临时库,核对数据后单独处理。

mysqldump 导出的表结构格式比较固定,核心语句从CREATE TABLE开始,到UNLOCK TABLES结束,用 sed 就能精准切分:

gzip -dc /data/backup/mysql/20250115_013001/db1.sql.gz \ | sed -n '/CREATE TABLE `t_order`/,/UNLOCK TABLES/p' \ | mysql --login-path=backup db1

注意这里的技巧:备份时用了--databases,文件里会有CREATE DATABASE和USE语句,但 sed 提取的片段不包含它们,所以导入时必须显式指定目标库名。如果表的数据量特别大,sed 匹配可能占用较多内存,这是逻辑备份的天然限制,单表超过几个 GB 时建议改用专业工具。

5.4 恢复失败常见的检查点

恢复失败时,我最先检查四件事:目标实例的 MySQL 大版本、SQL 模式、字符集、以及 GTID 状态。比如源端是 8.0,备份文件里可能带utf8mb4_0900_ai_ci排序规则,恢复到 5.7 实例上直接报“Unknown collation”。这种情况要么把目标实例也升级到 8.0,要么在导出时显式指定目标实例能接受的排序规则。

SQL 模式不匹配也很常见。源库开启了严格模式,导出的语句在目标库宽松模式下没问题;反过来,源库宽松、目标库严格,插入数据时可能因为某个字段类型报错。我的建议是:恢复演练环境尽量和线上使用同一 MySQL 大版本、同一 SQL 模式,降低意外的发生概率。

GTID 问题在前面参数部分已经说过,如果备份文件里带了GTID_PURGED相关语句,导入到已有数据的实例上会非常痛苦。脚本里默认--set-gtid-purged=OFF,日常恢复基本不会踩这个雷。

6. 实战中踩过的坑:备份脚本没你想的那么容易

6.1 坑一:cron环境里找不到mysqldump

现象:手动执行脚本一切正常,crontab 定时执行时日志里出现“未找到 mysqldump 命令”。

原因:cron 环境的 PATH 不包含 MySQL 安装目录。我一开始也以为脚本写了#!/usr/bin/env bash就万事大吉,结果是command -v mysqldump直接返回非零。最终解决办法是在脚本配置区上方加上一段显式的 PATH 声明:

export PATH="/usr/local/mysql/bin:/usr/local/bin:/usr/bin:/bin"

如果你的 MySQL 是通过包管理器安装的,路径可能不同,先执行which mysqldump确认一下实际路径。

6.2 坑二:磁盘写满,备份文件体积为0

现象:某天磁盘告警,进备份目录一看,当天的.sql.gz文件只有几 KB,解压后是空文件。

原因:备份目录所在磁盘已满,mysqldump 导出的内容在写入时被系统截断,但命令本身没有立刻报错,脚本也没检查文件大小,备份就这样“成功”地留下了坏文件。

现在的脚本里专门做了两件事:备份前统计库体积并对比磁盘剩余空间,备份后检查文件大小是否为 0。这样即使哪天磁盘真的不够了,也会在备份开始前触发告警,而不是等备份坏了才发现。

6.3 坑三:管道压缩悄悄掩盖了mysqldump的错误

现象:mysqldump 导出中途失败,日志里没有任何报错,备份文件还是正常生成了。

原因:就是我在 3.3 节讲的管道退出码问题。mysqldump | gzip是两条命令组成的管道,shell 只看最后一条命令的返回码。mysqldump 失败了,gzip 照样把已经收到的数据压缩完,退出码是 0,脚本判断“成功”。

这个坑在 MySQL 客户端从 5.7 升到 8.0 时特别容易出现——新版 mysqldump 在某些情况下会多返回一个警告,而旧版脚本只检查$?,导致判断失真。改用${PIPESTATUS[@]}之后,错误立刻就能抓出来。

6.4 坑四:字符集没指定,导入后中文全是问号

现象:备份文件能正常导入,但业务表里的中文全部变成了“???”,数据直接没法用。

原因:mysqldump 导出时没有指定--default-character-set,默认跟随客户端连接的字符集。如果连接环境和库表字符集不一致,导出的 SQL 文本里中文就已经失真了。

解决办法就是脚本里强制加上--default-character-set=utf8mb4,同时恢复时也用同样的参数。再加上导入前检查一下目标库的默认字符集确实是 utf8mb4,基本就能杜绝这类问题。

最后再分享一个小习惯:我现在会每个月选一个低峰时段,把最新的一份备份恢复到一台临时实例上,跑几条关键 SQL 检查行数和最新数据。整个过程不到十分钟,但能让我对线上备份的真实可用性一直心里有底。备份自动化不是把 crontab 配上就结束,真正的工作量都在脚本的健壮性和恢复演练上,愿意花时间打磨这些细节的人,才能在数据库真的出问题时从容应对。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进