做数据库运维的人,最怕凌晨两点的电话。我刚接手一套电商系统时,生产库已经涨到800多GB,每天凌晨用mysqldump做全备,跑一次要五六个小时,期间主库负载一直飘着,主从延迟一度冲到十几秒。更难受的是,mysqldump的全量导出对InnoDB虽然不锁表,但大事务和长查询互相影响,凌晨的跑批任务经常被拖垮。后来我把备份方案整体换成了Xtrabackup+Shell+Crond的组合,实现了全量+增量双轨热备份,才真正把备份这件事从“能跑”变成“可靠”。
这套方案的核心思路不复杂:Xtrabackup负责物理层面的热备份,Shell脚本负责全量与增量的自动化编排、日志记录和清理策略,Crond负责按固定周期触发。它解决了三个关键问题——备份窗口对业务的影响、全量与增量切换的自动化管理、以及恢复时的可操作性。适合正在面对大实例备份压力、或者还在用mysqldump硬扛的DBA同学参考。
1. 生产环境下mysqldump的瓶颈与Xtrabackup的破局思路
1.1 mysqldump在什么场景下真的不合适
mysqldump是逻辑备份工具,导出的是SQL语句,恢复时需要一条条执行,速度天然比物理备份慢一个数量级。数据量在50GB以内时它够用,一旦到了几百GB,问题就全部暴露出来。
首先是备份窗口。mysqldump导出大表时,InnoDB需要维护一个一致的读视图,这本身不锁表,但它会拉长事务执行时间、增加undo日志的保留压力。如果你的库里有跑批任务,两者叠加很容易把临时表空间和undo表空间打爆。
其次是恢复速度。逻辑备份的恢复是执行SQL的过程,插入索引、维护统计信息、更新自增列,800GB的库恢复出来可能要七八个小时甚至更久。真出了故障,这个时间代价是业务完全不能接受的。
最后是增量能力。mysqldump本身不直接支持增量备份,你需要自己结合binlog做二次开发,而且每次全量都要从头导一遍,日积月累的备份成本非常高。
1.2 Xtrabackup的物理备份机制:为什么它能“热”起来
Xtrabackup做的是物理备份,直接复制InnoDB的数据文件。它之所以能在数据库运行期间执行,靠的是InnoDB自身的崩溃恢复机制。
备份启动时,它会记录当前的LSN位置,然后开始拷贝ibd数据文件。这个拷贝过程不是原子的——数据文件一边被拷贝,MySQL一边还在写。为了解决这个问题,Xtrabackup在备份过程中会持续监控并保存新的redo log,把备份期间产生的所有日志变更单独留存下来。
等数据文件拷贝完毕,得到的是一份“在时间上不一致”的数据文件集合。这时候需要执行prepare(恢复准备)阶段,用备份期间保存的redo log把数据文件前滚到一致状态,再回滚掉未提交的事务。这一步做完,备份集才真正可恢复。
这也是Xtrabackup和很多人的直觉不一样的地方:备份完成的那一刻,备份集还不能直接用,必须先prepare。后面恢复演练章节我会再细说这个流程。
1.3 LSN、checkpoints与增量备份的逻辑基础
增量备份是Xtrabackup最有价值的特性,它的底层依赖LSN(Log Sequence Number,日志序列号)。
InnoDB每次页面修改都会产生新的LSN,Xtrabackup在备份时会把起始LSN写入xtrabackup_checkpoints文件。做增量备份时,它会扫描自上一次备份以来LSN发生变化的InnoDB数据页,只拷贝这些变化的页面,而不是整个数据文件。
这个机制直接决定了全量与增量之间是“串联链路”的关系:
- 全量备份是基线,记录了某个LSN时刻的完整数据。
- 第一个增量基于全量备份,记录全量LSN之后变化的数据页。
- 第二个增量基于第一个增量,记录第一个增量LSN之后变化的数据页。
恢复时,必须先把全量prepare,再按顺序把每个增量逐个应用上去,顺序错了、中间缺一个,备份集就废了。所以脚本里对“基准备份目录”的识别和记录,是整个方案的生命线。
2. 整体方案架构与备份产物组织
2.1 本地暂存、状态记录、保留清理的三层结构
把方案拆开看,其实就三层:执行层——Xtrabackup命令完成备份;编排层——Shell脚本决定哪天做全量、哪天做增量,并记录状态;调度层——Crond按固定时间触发脚本。
我给这套方案设计的目录结构如下:
/data/backups/ ├── full/ # 全量备份目录 │ └── full_20240603_023001/ # 示例:2024年6月3日02:30:01的全量 │ ├── ibdata1 │ ├── db1/ │ ├── xtrabackup_checkpoints │ └── xtrabackup_logfile ├── inc/ # 增量备份目录 │ └── inc_20240604_023001/ ├── logs/ # 备份日志 │ ├── mysql_backup.log │ └── cron_backup.log └── backup.state # 记录最近一次成功备份的信息backup.state这个文件很关键,我用它记录上一次成功备份的类型、目录和结束LSN。下一次增量备份时,脚本直接读取这个文件确定--incremental-basedir指向哪里。这比用ls -t猜目录可靠得多,因为ls只能看出哪个目录是最新创建的,却无法判断它是否真的是完整可用的备份。
2.2 备份保留策略与清理规则
没有清理策略的备份方案是灾难——磁盘迟早被撑爆。我的保留策略是:
| 备份类型 | 保留策略 | 说明 |
|---|---|---|
| 全量备份 | 保留最近2份 | 出问题时可以回退到上一个完整周期 |
| 增量备份 | 保留14天 | 与全量周期配合,覆盖一个月的恢复窗口 |
| 备份日志 | 保留30天 | 用于排查问题,不需要太久 |
| binlog | 保留7天 | 配合增量做任意时间点恢复,按实际需求调整 |
清理逻辑放在每次备份成功之后执行。用find按目录名格式加时间戳匹配,因为它比mtime更精确——mtime可能因为目录被读取而受到影响,但目录名里的时间戳是写入时就固定的。
2.3 Xtrabackup关键参数与选择依据
直接列一张参数表,都是生产环境实际在用的配置:
| 参数 | 值 | 作用与选型理由 |
|---|---|---|
--backup | 必选 | 指定执行备份而不是恢复 |
--target-dir | 按日期生成 | 每次备份独立目录,避免互相覆盖 |
--parallel | 4 | 并行拷贝文件数,按CPU核数一半左右设置 |
--incremental-basedir | 上次成功备份目录 | 增量备份的基线,脚本自动读取 |
--no-server-version-check | 可选 | 跳过版本检查,版本不一致的环境可能有用 |
--compress | 可选 | 启用压缩,qpress工具配合 |
关于--parallel,我见过有人把8核机器直接设成8,反而把IO打满影响了线上业务。我的建议是先从4开始,观察备份期间的系统负载和磁盘IO,再逐步调整。备份不是越快越好,稳定且不干扰业务才是第一原则。
3. Shell脚本实现:从全量/增量判断到异常兜底
3.1 脚本骨架与配置变量设计
脚本整体设计遵循一个原则:配置与逻辑分离。数据库连接信息、备份根目录、保留天数这些都可能按环境变化,单独放在脚本头部,方便迁移和二次维护。
#!/bin/bash # mysql_backup.sh - Xtrabackup全量+增量备份脚本 # 使用方式: mysql_backup.sh {full|inc} BACKUP_BASE=/data/backups FULL_DIR=$BACKUP_BASE/full INC_DIR=$BACKUP_BASE/inc LOG_FILE=$BACKUP_BASE/logs/mysql_backup.log STATE_FILE=$BACKUP_BASE/backup.state MYSQL_USER=backup_user MYSQL_PASSWORD='your_password' MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 DATE=$(date +%Y%m%d_%H%M%S) LOCK_FILE=/var/lock/mysql_backup.lock log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE" }脚本接受的参数是full或inc,这个设计后面配合Crond很清爽——周日在crontab里调full,其他日子调inc,手动执行某个类型也方便,不需要改任何逻辑。
3.2 全量备份与增量备份函数核心代码
全量备份函数:
backup_full() { local target_dir="$FULL_DIR/full_${DATE}" log "开始全量备份: $target_dir" xtrabackup --backup \ --user=$MYSQL_USER --password=$MYSQL_PASSWORD \ --host=$MYSQL_HOST --port=$MYSQL_PORT \ --target-dir="$target_dir" \ --parallel=4 2>> "$LOG_FILE" if [ $? -eq 0 ] && [ -f "$target_dir/xtrabackup_checkpoints" ]; then log "全量备份完成: $target_dir" # 更新状态文件 to_lsn=$(grep 'to_lsn' "$target_dir/xtrabackup_checkpoints" | awk '{print $3}') echo "BACKUP_TYPE=full" > "$STATE_FILE" echo "BACKUP_DIR=$target_dir" >> "$STATE_FILE" echo "TO_LSN=$to_lsn" >> "$STATE_FILE" return 0 else log "ERROR: 全量备份失败" return 1 fi }增量备份函数:
backup_inc() { # 读取上次成功备份信息 if [ ! -f "$STATE_FILE" ]; then log "ERROR: 状态文件不存在,无法执行增量备份" return 1 fi local base_dir=$(grep 'BACKUP_DIR' "$STATE_FILE" | cut -d= -f2) if [ -z "$base_dir" ] || [ ! -d "$base_dir" ]; then log "ERROR: 基准备份目录无效: $base_dir" return 1 fi local target_dir="$INC_DIR/inc_${DATE}" log "基于 $base_dir 执行增量备份: $target_dir" xtrabackup --backup \ --user=$MYSQL_USER --password=$MYSQL_PASSWORD \ --host=$MYSQL_HOST --port=$MYSQL_PORT \ --target-dir="$target_dir" \ --incremental-basedir="$base_dir" \ --parallel=4 2>> "$LOG_FILE" if [ $? -eq 0 ] && [ -f "$target_dir/xtrabackup_checkpoints" ]; then log "增量备份完成: $target_dir" to_lsn=$(grep 'to_lsn' "$target_dir/xtrabackup_checkpoints" | awk '{print $3}') echo "BACKUP_TYPE=inc" > "$STATE_FILE" echo "BACKUP_DIR=$target_dir" >> "$STATE_FILE" echo "TO_LSN=$to_lsn" >> "$STATE_FILE" return 0 else log "ERROR: 增量备份失败" return 1 fi }这里有个细节值得单独提一下:增量备份的base目录永远指向最近一次成功备份的目录,而不是指定“上一次增量的基准备份”。也就是说,如果周一是全量,周二、周三各做一次增量,周四的增量--incremental-basedir应该指向周三的增量目录,而不是周一的全量目录。Xtrabackup的增量备份天然支持这种链式结构。
脚本通过状态文件记录上一个成功备份的BACKUP_DIR,下一次执行时直接读取,避开了“找目录”的模糊逻辑。第一次做全量时,状态文件被写入;一旦某次备份失败,状态文件不会更新,下一次增量备份还会指向最近一次成功的备份上,不会因为一次失败就断链。
3.3 状态记录、锁文件与结果感知
很多备份脚本只关心“有没有跑成功”,不关心“跑完之后状态是否一致”。我在这套方案里加了三个兜底机制:
第一,锁文件防止重入。Crond偶尔会因为系统时钟跳动或手动误操作,在同一个时间点触发两次脚本。两个备份进程同时写同一个数据目录,后果不堪设想。用flock做进程锁是最简洁的方案:
exec 9>"$LOCK_FILE" if ! flock -n 9; then log "已有备份任务正在运行,本次跳过" exit 1 fi第二,备份完成后的文件校验。Xtrabackup执行完看退出码还不够,我还会检查xtrabackup_checkpoints是否存在、文件里to_lsn的值是否合法。有时候进程因为磁盘空间不足中止,退出码非0,这个能捕获;但有时退出码是0,实际却因为写入缓存问题产出了不完整文件,这时候辅助检查就起作用了。
第三,失败告警的通道预留。脚本里我用log函数统一输出日志,但在实际部署中,你完全可以在失败分支里加一行调用企业微信机器人、钉钉群或者短信接口的代码。我的建议是不要只依赖crontab的邮件通知,那玩意儿漏报率太高,生产环境的备份失败必须主动感知。
3.4 清理逻辑与下一轮备份的衔接
清理放在备份成功之后执行,避免备份失败时把上一轮的老数据删了,再想恢复就没有基线了。
cleanup() { # 全量备份保留最近2份 ls -td $FULL_DIR/full_* 2>/dev/null | tail -n +3 | xargs rm -rf # 增量备份保留14天 find $INC_DIR -maxdepth 1 -type d -name "inc_*" -mtime +14 -exec rm -rf {} + # 清理日志,保留30天 find $BACKUP_BASE/logs -type f -name "*.log" -mtime +30 -delete }注意清理全量备份时我没有直接用find -mtime,而是用ls -td按目录名排序。因为find -mtime依据的是文件系统修改时间,但全量备份目录执行完prepare之后时间戳会被改动,可能导致误判。目录名中的日期是生成时写入的,更可靠。
4. Crond调度配置与备份窗口管理
4.1 crontab配置与固定周期设计
脚本准备好之后,Crond的配置就很简单了。我的生产环境用的是这样一套:
SHELL=/bin/bash PATH=/usr/local/bin:/usr/bin:/bin:/sbin 30 2 * * 0 root /opt/scripts/mysql_backup.sh full >> /data/backups/logs/cron_backup.log 2>&1 30 2 * * 1-6 root /opt/scripts/mysql_backup.sh inc >> /data/backups/logs/cron_backup.log 2>&1每周日02:30做全量,周一至周六02:30做增量。为什么选周日而不是周一?因为周一通常是业务高峰的开始,周日晚上的负载相对最低,全量备份这种“重活”放在负载最低点最稳妥。如果你的业务有别的低谷窗口,比如电商大促期、月末结算期,把这些特殊情况单独摘出来评估,不要让crontab一成不变。
4.2 备份时长的估算与调度错峰
备份时长决定了你对“备份窗口”的判断。我在上线时做过一次统计:
| 数据量 | 全量耗时 | 增量耗时 |
|---|---|---|
| 200GB | 35分钟 | 8分钟 |
| 500GB | 80分钟 | 15分钟 |
| 800GB | 130分钟 | 22分钟 |
以上数据基于8核16GB、普通SATA盘、--parallel=4的配置,仅供参考。你的环境IO能力不同,数值会差别很大。
关键是这个时长会影响两件事:一是crond的执行时间必须保证备份在业务早高峰前结束;二是如果备份时间超过了预期,要分析是IO瓶颈还是网络瓶颈。也就是我前面建议的——先跑两周记录耗时基线,再最终确定crontab的触发时间。
4.3 日志聚合与定时任务健康检查
Crond执行本身也可能出错——比如系统重启后crond没起来、脚本路径被改动、环境变量加载失败。我养成了一个习惯:每天早上到办公室第一件事就是检查备份日志的关键行:
grep -E "备份完成|ERROR" /data/backups/logs/mysql_backup.log | tail -20另外,建议给备份日志做logrotate,防止日志文件无限增长。配置一个简单的logrotate规则:
/data/backups/logs/mysql_backup.log { daily rotate 30 compress missingok notifempty }做logrotate时有个常见问题:如果脚本往日志文件里写内容时用的是追加方式且文件被rotate了,需要给脚本加一个重开文件描述符的逻辑,否则日志会继续写到已经被rename的旧文件里。最简单的办法是在logrotate配置里加copytruncate选项,代价是可能丢失极少量的日志尾部内容,对于备份日志来说完全可接受。
5. 恢复演练:让备份真正兑现承诺
5.1 prepare再copy-back:全量恢复的标准动作
没有经过恢复演练的备份方案,就像没测试过的代码——大概率有问题,只是还没触发。Xtrabackup的恢复流程和备份同样重要,甚至更需要花时间验证。
全量恢复的标准动作分两步:
第一步,对备份集执行prepare(恢复准备):
xtrabackup --prepare --target-dir=/data/backups/full/full_20240603_023001这一步会把备份期间累积的redo log应用到数据文件上,前滚已提交的事务,回滚未提交的事务,数据文件此时变成一致的“可启动状态”。
第二步,把数据文件拷贝回MySQL的数据目录。执行前必须清空目标datadir:
systemctl stop mysqld rm -rf /var/lib/mysql/* xtrabackup --copy-back --target-dir=/data/backups/full/full_20240603_023001 --datadir=/var/lib/mysql chown -R mysql:mysql /var/lib/mysql systemctl start mysqld注意几个细节:copy-back前datadir必须为空;拷贝完成后目录属主要改回MySQL用户;如果有SELinux,恢复完还要注意文件上下文,否则MySQL会起不来。
5.2 增量链路恢复的顺序与细节
增量恢复比全量恢复复杂得多,它要求你严格按备份时的顺序操作。假设你有以下备份链:
full_20240603_023001 # 周一全量 inc_20240604_023001 # 周二增量 inc_20240605_023001 # 周三增量恢复命令如下:
# 第一步:prepare全量,只回放redo log,不回滚未提交事务 xtrabackup --prepare --apply-log-only --target-dir=/data/backups/full/full_20240603_023001 # 第二步:依次应用增量,注意顺序不能乱 xtrabackup --prepare --apply-log-only --target-dir=/data/backups/full/full_20240603_023001 \ --incremental-dir=/data/backups/inc/inc_20240604_023001 xtrabackup --prepare --apply-log-only --target-dir=/data/backups/full/full_20240603_023001 \ --incremental-dir=/data/backups/inc/inc_20240605_023001这里最核心的一个细节:应用完最后一个增量后,还需要再执行一次不带--apply-log-only的prepare,让InnoDB回滚未提交的事务,生成最终一致的状态:
xtrabackup --prepare --target-dir=/data/backups/full/full_20240603_023001很多人第一次做增量恢复时就栽在这一步——所有增量都apply完了,直接copy-back,结果MySQL启动时报错,数据文件不一致。原因就是没有最后一步的回滚动作。
最后查看xtrabackup_checkpoints文件确认状态:
cat /data/backups/full/full_20240603_023001/xtrabackup_checkpoints正常情况下backup_type显示为full-backuped,说明备份集已经是可用的完整备份。
5.3 恢复演练中发现的高频问题与调节
我自己在恢复演练中踩过几次坑,整理了一张排查表,供参考:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| prepare报错找不到文件 | 增量目录和全量目录不在同一磁盘或路径被移动 | 恢复前先核对xtrabackup_checkpoints中的LSN范围 |
| copy-back后MySQL启动失败 | datadir权限、SELinux上下文、my.cnf里的路径不一致 | 检查/var/lib/mysql属主、restorecon重置上下文 |
| 恢复后部分表查询报错 | 增量prepare顺序错乱 | 重新按备份顺序执行prepare,中间不要跳步 |
| 数据恢复到备份点但缺少最后几秒 | 增量完成后没有配合binlog做时间点恢复 | 结合binlog做增量后的日志重放 |
| 恢复出来的库大小远小于源库 | 增量base选错,备份的是不完整的增量 | 检查backup.state中记录的基线目录 |
5.4 binlog补位:增量到任意时间点的延伸
如果业务要求的恢复目标是“恢复到今天下午3点15分22秒”而不是“恢复到备份时刻”,增量备份是不够的,必须配合binlog。
所以我在方案里默认开启了binlog,并保留7天。恢复路径变成:全量prepare → 增量prepare → 再追加binlog日志:
mysqlbinlog --start-position=123456 --stop-datetime="2024-06-05 15:15:22" \ /data/binlogs/mysql-bin.000014 | mysql -uroot -p--start-position是最后一个增量备份的to_lsn对应binlog位置,需要提前记录好。这也是为什么我在状态文件里专门存了TO_LSN——它不只是给Xtrabackup内部用的,也是binlog重放起点定位的依据。
6. 上线至今踩过的坑与方案的二次迭代
6.1 磁盘空间被“备份+binlog”双重挤压
第一次上线这套方案时,我给自己留了个教训。当时只算了备份文件占用的空间——全量100GB、增量每天20GB、保留策略14天,觉得300GB的备份盘绰绰有余。
结果一个月后的某天凌晨,备份脚本连续报错。排查发现是binlog把磁盘填满了——binlog本身一天就有30GB,我忘了把它算进空间规划,也没有给binlog设置自动清理的保留时间。那次故障之后,我做了三件事:一是给备份盘预留了至少30%的余量监控;二是binlog清理脚本独立部署,按7天保留;三是在备份脚本开头加了一个磁盘空间预检查,小于设定阈值直接告警并退出。
6.2 备份可信度校验与定期演练机制
备份文件在磁盘上“存在”和“可用”是两回事。我见过有人备份了半年,从来没有成功恢复过一次——直到真的故障发生时才发现备份集有问题。
我给这套方案加了一个“季度恢复演练”机制:每个季度挑一个低峰期,在测试实例上完整走一遍恢复流程——prepare所有备份、copy-back、启库、跑几条关键SQL校验数据正确性。每次演练的步骤和结果都记录在案。这比任何监控告警都更有效地保证备份方案真正可用。
6.3 后续优化方向
这套方案跑通之后仍有可以优化的空间,我的迭代计划大致是:
- 备份流式传输:用
--stream=xbstream替代本地落盘,配合远程存储或对象存储归档,解决单机磁盘容量瓶颈。 - 备份加密:对备份集加密后传输和存储,避免备份文件泄露导致的数据风险。
- 集成监控平台:把备份状态、耗时、产物大小作为指标上报,配合告警规则实现主动感知,而不是每天人工看日志。
- 多实例并行管理:如果服务器上有多套MySQL实例,可以把脚本参数化成通用的备份工具,支持传入实例端口和不同配置。
每套方案都是在一次次实际故障和演练中打磨出来的。备份这件事,做得越多越觉得基础工作的重要。我最后想分享的一个习惯是:每个月随机抽一天,不通知任何人,自己在测试环境完整恢复一遍备份链。这个习惯帮我提前发现过两次潜在故障——一次是增量base目录记录逻辑有bug,另一次是磁盘raid卡写缓存异常导致备份文件静默损坏。都算不上大问题,但真等到故障发生再发现,代价就完全不一样了。