有一年凌晨两点,我在办公室盯着终端,手指悬在回车键上不敢按下去。原因是白天执行存储过程清理数据时,where条件写错了,一个运行多年的业务表被update成了空表。那晚我翻遍了所有能找到的备份文件,最后靠着一份三个月前的全量备份加一个月的binlog,花了四个小时把数据恢复到误操作前两分钟的状态。从那天起,我再也不敢把“备份恢复”当成一句挂在嘴边的口号。这篇笔记就是我这些年折腾MySQL备份与恢复的完整记录,包含了工具选型、命令参数、恢复流程,以及几段差点让我失眠的踩坑经历。无论你是刚接触MySQL的开发,还是已经在维护生产库的运维,这篇内容都应该能帮你少走不少弯路。
1. 备份恢复这件事,为什么值得当成一套体系来做
1.1 从一次凌晨误删数据说起
那次事故的经过其实特别简单,简单到我现在回想起来都觉得丢人。当时某个后台管理系统要做一次数据订正,从另一个同事那里拿到一段写好的SQL,逻辑大概是“更新一批订单的金额字段,条件是订单ID在某个列表里面”。我把列表放进临时表后,执行update时条件写成了“订单ID不在这个列表里面”,刚好反了。结果就是全表几百万行记录,除了那几十个目标ID之外,全部被更新成了同一笔金额。
这种错误在生产环境里并不罕见,错的不只是SQL的level,而是“你以为你写的是A,其实执行的是B”。更惨的是,当时系统的备份策略等于没有策略:只有一台测试机每天凌晨用mysqldump导一次全库,binlog参数虽然配了,但因为长期没人检查,日志早就被磁盘清理任务删得只剩三天。我翻遍所有能找到的备份文件,最后靠着一份三个月前的全量备份加一个月的binlog,花了四个小时把数据恢复到误操作前两分钟的状态。
那次经历之后我做了一件事,就是把备份恢复从“脚本而已”提升到了“体系”的高度。因为备份这件事,表面上看是一只crontab定时任务在跑,但实际上它决定的是整个业务在最糟糕情况下能承受多少数据丢失,能多快恢复业务。它不是一个孤立的脚本,而是一套由全量备份、增量日志、定期演练和恢复预案组成的体系。
1.2 备份的三种形态:全量、增量、日志归档
很多人对备份的理解停留在“把数据库文件拷贝一份”的层面,但完整的备份体系需要三种形态配合,缺一个都可能出问题。
- 全量备份:某一时刻所有数据的一个完整副本,相当于给房子拍了一套高清全家福。你能拿它还原整个房子的格局,但照片拍完之后发生的所有变化,它都不知道。
- 增量备份:基于上一次备份以来的变化数据,相当于每天记录家里添置了哪些家具、哪些位置变动过。增量备份可以节省空间和时间,但恢复时往往需要把多个备份点依次合并回放,链路越长,越容易出岔子。
- binlog日志归档:记录每次数据变更的完整历史,相当于每个房间装了一台摄像头,随时可以回放过去任意时刻的画面。binlog是MySQL层面最细粒度的恢复依据,配合全量备份可以做到精确到秒甚至到具体SQL语句的恢复。
这三种形态不是三选一,而是层层递进的关系。全量是底子,增量是中间过程,binlog是最后一道细粒度保障。一个合理的设计通常是:周期性做全量备份(比如每天一次),两次全量之间靠binlog覆盖变化,再根据恢复目标决定是否需要中间增量的快速衔接。这样既能控制备份成本,又能把恢复窗口压到最小。
1.3 用RPO和RTO这两个指标来衡量你的备份方案
每次有同行问我“你们的备份方案够不够好”,我都会先反问一句:你们能接受的RPO和RTO是多少?这两个指标是衡量备份恢复方案的核心标尺,少讨论方案细节,先把这个定下来。
- RPO(Recovery Point Objective,恢复点目标):业务最多允许丢失多长时间的数据。比如RPO=15分钟,就意味着最多容忍丢失15分钟内的新数据。
- RTO(Recovery Time Objective,恢复时间目标):从故障发生到业务恢复可用的最长耗时。比如RTO=2小时,意味着2小时内必须把服务拉起来。
不同的业务场景,目标完全不一样。我给几个参照值供大家参考。
| 业务类型 | RPO建议 | RTO建议 | 常见实现方式 |
|---|---|---|---|
| 核心交易系统 | 接近0 | 30分钟内 | 主从复制+定期全备+实时binlog同步 |
| 普通业务系统 | 15分钟到1小时 | 2到4小时 | 每日全备+binlog保留15天 |
| 内部后台/分析系统 | 1天 | 24小时 | 每日全备,不依赖binlog |
| 测试/开发环境 | 不敏感 | 1天 | 每周全备即可 |
明确指标之后再设计备份方案,很多决策会清晰很多。比如说你定了RPO是15分钟,那binlog必须至少保留到上一个全备点之后再加15分钟的量,否则恢复时会缺档;你定了RTO是2小时,那你就得评估mysqldump导入2小时能不能完成,不行的话就要考虑物理备份或者从库切换方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 备份工具怎么选:mysqldump、mysqlpump、Xtrabackup各有各的活
2.1 逻辑备份和物理备份的本质区别
MySQL备份工具从大类上分,就是逻辑备份和物理备份两种。我常用一个比喻来解释它们的区别:逻辑备份是“把家具拆开,按图纸打包,到了新家再一件件组装”,物理备份是“把整个房子连地基一起搬走,到新地方直接放下就能住”。
逻辑备份工具导出的是SQL文本或者CSV之类的可读格式,与存储引擎的关系不那么紧密,迁移到不同的MySQL版本、不同的架构时比较灵活,甚至可以只导出一部分表。但缺点也很明显:数据量一大,导出和导入都慢,尤其是导入时每一条INSERT都要过一遍SQL解析、执行、索引维护,整个过程极其考验耐心。
物理备份工具直接复制MySQL的数据文件目录,备份时不需要一条条翻译成SQL,恢复时把文件放回datadir就能用,速度通常快一个量级。缺点是文件格式和MySQL版本、存储引擎强绑定,想跨大版本迁移基本不现实,而且备份目录也不能随意改名字、改路径。
2.2 三款主流工具对比
mysqldump、mysqlpump、Xtrabackup这三款工具是我日常接触最多的,我把它们的核心差异整理成一张表。
| 工具 | 类型 | 适合数据量 | 备份速度 | 恢复速度 | 是否锁表 | 额外特点 |
|---|---|---|---|---|---|---|
| mysqldump | 逻辑备份 | 小到中型(百GB以内) | 一般 | 一般 | InnoDB下配合--single-transaction不锁表 | 跨版本迁移方便,可按表备份 |
| mysqlpump | 逻辑备份 | 中型 | 比mysqldump快 | 一般 | 同上 | 支持并行导出,MySQL 8.0后更稳定 |
| Xtrabackup | 物理备份 | 大数据量(百GB以上) | 快 | 快 | 几乎不阻塞业务 | 支持增量备份,恢复能力强 |
需要注意,这里说的“适合数据量”不是绝对的,只是一个经验边界。我见过有人拿mysqldump备份2TB的库,虽然也能跑完,但那个导出SQL文件足足占了几百GB,恢复时导入了两天都没导完,最后大家还是老老实实切到了Xtrabackup。
2.3 我个人的选型思路
选型这件事没有标准答案,不同团队、不同业务场景,取舍完全不同。我一般按这套逻辑来定:
- 数据量小于50GB,且偶尔需要跨版本迁移,优先用mysqldump。因为它的产出是可读的SQL,哪怕恢复时出问题,还可以手动改SQL来绕过,灵活度最高。
- 数据量在50GB到200GB之间,mysqldump导入时间还可以接受的话,继续用逻辑备份也没问题,但建议把“恢复演练”的周期缩短,提前摸清实际耗时。
- 数据量超过200GB,或者业务要求RTO很短,我会直接上Xtrabackup。物理备份的恢复速度优势在这个量级会体现得非常明显。
- 如果你的数据库是云厂商提供的,比如RDS之类的托管实例,优先用云平台自带的备份和克隆功能,通常能做到秒级快照。但有一条原则不能破:云平台说能恢复,不代表真的能恢复,还是得自己定期做恢复验证。
另外,mysqlpump这个工具在8.0之前问题比较多,我很少在生产上用它。8.0之后的稳定性好了一些,支持按表并行导出,速度比mysqldump快不少,如果你所有表都是InnoDB,可以试试。但要注意mysqlpump和mysqldump的很多参数不兼容,脚本迁移成本要考虑进去。
3. mysqldump实操:全量备份的命令、参数和恢复流程
3.1 备份前要确认的三件事
在我见过的备份事故里,有相当一部分不是备份命令写错,而是备份前没确认环境,导致备份出来的文件根本不能用。所以mysqldump跑起来之前,建议先把这三件事确认完。
第一,确认MySQL版本和mysqldump客户端版本是否兼容。尤其是MySQL 8.0之后默认认证插件改成了caching_sha2_password,你从5.7机器上拿老版本mysqldump去连8.0,很可能报认证协议不支持的错。我本机是MySQL 8.0.36,版本差异如果超过一个大版本,建议先去MySQL官方文档看看有没有已知兼容性问题。
第二,确认备份账号的权限。mysqldump备份通常需要SELECT、SHOW VIEW、RELOAD、LOCK TABLES、PROCESS等权限,如果你要导存储过程和触发器,还需要EVENT权限。我常用的授权语句是这样:
sql复制GRANT SELECT, SHOW VIEW, RELOAD, LOCK TABLES, PROCESS, EVENT, TRIGGER ON *.* TO 'backup_user'@'localhost' IDENTIFIED BY 'your_password';
FLUSH PRIVILEGES;
第三,确认磁盘空间够不够。备份前先算一下要备份的库有多大,再留出一倍以上的空间给备份文件和后续的临时产物。计算空间可以用这条SQL:
sql复制SELECT table_schema AS '库名',
ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS '大小MB'
FROM information_schema.tables
GROUP BY table_schema;
一条常见的教训是:备份脚本备份到一半,磁盘满了,生成的SQL文件是一个不完整的半截文件,但脚本因为没有检查退出码,仍然返回了成功。这种问题我在后面专门用一个章节讲。
3.2 一条完整备份命令的参数拆解
我之前用的备份命令长这样,每个参数都有它的理由:
bash复制mysqldump -h127.0.0.1 -P3306 -ubackup_user -p \
--single-transaction \
--master-data=2 \
--routines \
--triggers \
--events \
--set-gtid-purged=OFF \
--databases mydb \
> /backup/mydb_20250101.sql
逐个解释一下:
- --single-transaction:这是InnoDB下能够实现“备份不锁表”的关键参数。它会让mysqldump启动一个REPEATABLE READ级别的一致性事务,在这个事务里看到的数据是同一个快照。正因为有它,你才能在不影响业务写入的情况下做全量备份。但要注意,如果表是MyISAM,这个参数不生效,仍然需要加--lock-tables,对MyISAM表做全局读锁。
- --master-data=2:会在dump文件头部写入当前binlog的文件名和position,并且是以注释形式写入。这个信息是后续做增量恢复时定位“备份点”的关键依据,非常重要。如果写增量脚本,恢复时一定要从dump文件里记录的这个点开始,而不是从备份开始时间。
- --routines、--triggers、--events:这三个参数分别负责导出存储过程、触发器、事件。很多人备份只导数据,忘了导这些,恢复之后应用能连上,但跑批任务、存储过程全没了,排查半天才发现问题。
- --set-gtid-purged=OFF:如果你开了GTID模式,备份文件里默认会带有SET @@GLOBAL.GTID_PURGED语句。恢复时如果目标实例已经有了GTID集合,这条语句可能直接报错。所以我通常在不需要跨实例同步GTID的场景下把它关掉。
- --databases mydb:带上这个参数,dump文件里会包含CREATE DATABASE和USE语句,恢复时不需要手动建库。如果你用mysqldump mydb这种语法,是不会带CREATE DATABASE的,恢复前必须自己先建数据库。
另外还有两个参数我觉得很容易用到,一个是--where,比如只需要导某张表最近一个月的数据:
bash复制mysqldump -h127.0.0.1 -ubackup_user -p --single-transaction mydb orders \
--where="create_time >= '2025-01-01'" > /backup/orders_20250101.sql
另一个是--ignore-table,比如你想备份整个库,但有一张日志表特别大又不重要,可以把它排除掉:
bash复制mysqldump -h127.0.0.1 -ubackup_user -p --single-transaction --databases mydb \
--ignore-table=mydb.big_log_table > /backup/mydb_no_log.sql
3.3 恢复流程与常见问题处理
mysqldump备份出来的SQL文件,恢复的核心操作很简单,就是把它导进目标MySQL:
bash复制mysql -h127.0.0.1 -P3306 -uroot -p < /backup/mydb_20250101.sql
但实际操作中要注意几点:
一是如果备份命令没带--databases,恢复之前必须先手动创建目标数据库,否则会报“Unknown database”之类的错误。我用习惯的写法是备份时一定带--databases,省得恢复时多一步。
二是导入大文件时,建议直接重定向,不要用mysql的source命令在交互式终端里跑。source这种手输式的导入方式适合几百MB的小文件,几个GB的SQL文件在交互会话里跑,一旦终端断开就前功尽弃。正确的做法是用nohup或者screen在后台执行重定向导入,同时把日志输出到文件,方便查看进度和报错。
三是导入过程中如果遇到外键约束导致的报错,可以在导入前临时关闭外键检查。在SQL文件最前面加一行SET FOREIGN_KEY_CHECKS=0,在文件末尾加回来。虽然很多dump文件默认带了这些设置,但你自己拼装部分表的数据时经常用得上。
四是字符集问题。备份和恢复时都建议显式指定字符集,避免因为客户端默认字符集不一致导致中文乱码:
bash复制mysqldump --default-character-set=utf8mb4 ...
mysql --default-character-set=utf8mb4 < backup.sql
五是如果dump文件里带了--master-data=2写入的binlog position信息,恢复完成后一定要先记下来。比如恢复后的下一阶段要做binlog增量恢复,这个position必须记在纸上,因为它就是你全量恢复完成时数据对应的准确时间点。
4. binlog增量恢复:误删数据的最后一道防线
4.1 为什么binlog是增量恢复的关键
如果没有binlog,全量备份只能恢复到上一次备份时刻,备份之后的所有变更都会丢失。开启binlog后,每次数据变更都会按顺序写日志,恢复时可以按时间或位置点重放,做到秒级恢复。甚至像我开篇讲的那种“update条件写反导致全表数据被改”的场景,只要binlog还在,就能定位到那条错误SQL的位置,把数据恢复到它执行前的那一瞬。
很多人把binlog理解成“主从复制才需要的东西”,其实这是低估了它。主从复制只是binlog的一个应用场景,它更大的价值在于灾备和恢复。这也是为什么我会强烈建议:哪怕你只有一个实例,没有从库,也一定把binlog打开。
4.2 开启binlog与参数选择
在my.cnf里加上以下配置,然后重启MySQL:
ini复制[mysqld]
server-id=1
log-bin=mysql-bin
binlog_format=ROW
expire_logs_days=15
max_binlog_size=1024M
binlog_row_image=FULL
-
server-id:开启binlog后必须设置,用于标识实例身份。
-
log-bin:指定binlog文件前缀,比如mysql-bin.000001。
-
binlog_format:这个最关键,有三种取值。
- STATEMENT:记录SQL语句本身,日志小,但某些场景下恢复时结果可能与原库不一致,比如用了UUID()、NOW()这类非确定性函数。
- ROW:记录每一行数据的具体变更,准确、恢复精度高,日志体积偏大。
- MIXED:混合模式,MySQL自动在两者间切换。
生产环境我建议直接用ROW。虽然日志会大一些,但在误删数据时,ROW格式能精确告诉你哪一行变成了什么样子,恢复的把握更大。而且在做主从复制时,ROW格式几乎不存在复制不一致的风险。
-
expire_logs_days:控制binlog保留天数。这个要根据RPO来定,至少覆盖到上一个全量备份点,否则从全备恢复到当前时间点时中间会断档。
-
max_binlog_size:单个binlog文件大小上限,超过后自动滚动到下一个文件。
-
binlog_row_image=FULL:让ROW格式记录完整的前后镜像。这个对恢复特别重要,如果设置成MINIMAL,binlog里可能只记录被修改的字段,万一你想恢复被误更新的其他字段,数据就不够了。
4.3 从全备+binlog恢复到任意时间点的完整过程
整套流程可以概括成三步:
第一步,恢复全量备份。这一步就是前面mysqldump恢复的方法,恢复完成后记下dump文件里的binlog position,记为START_POS。假设是mysql-bin.000015的position 1024。
第二步,把binlog从START_POS开始解析成SQL文件。比如要恢复到今天下午14:30,可以这样:
bash复制mysqlbinlog --start-position=1024 --stop-datetime='2025-01-05 14:30:00' \
/var/lib/mysql/mysql-bin.000015 > inc_1.sql
mysqlbinlog --stop-datetime='2025-01-05 14:30:00' \
/var/lib/mysql/mysql-bin.000016 > inc_2.sql
注意,mysqlbinlog解析出来的SQL文件里可能包含GTID相关的SET语句,如果目标实例已经开启GTID,且这些GTID已经执行过,导入时会报重复执行。这种情况要么在解析时加--skip-gtids参数,要么在导入前先清空gtid_executed,具体看你的环境。
第三步,将解析出来的几个增量SQL文件依次导入:
bash复制mysql -uroot -p < inc_1.sql
mysql -uroot -p < inc_2.sql
导入完成后,数据库就恢复到了14:30那一刻的状态。
4.4 一个“drop table”误操作后的抢救演示
假设今天是2025年1月5日,凌晨1点做了全量备份,14:23误执行了DROP TABLE orders,业务瞬间报错。目标是把orders表恢复到14:22的状态。
首先别慌,先把MySQL停写或者至少通知业务暂停写入操作,避免新数据继续产生。然后按这个流程来:
第一步,查看当前binlog列表,找到全量备份时刻到误操作时刻之间的所有binlog文件:
sql复制SHOW BINARY LOGS;
假设输出里有mysql-bin.000015和mysql-bin.000016两个文件。
第二步,在binlog里找到误执行的DROP TABLE语句位置。用mysqlbinlog配合grep定位:
bash复制mysqlbinlog --no-defaults --start-datetime='2025-01-05 14:00:00' /var/lib/mysql/mysql-bin.000016 | grep -n 'DROP TABLE'
找到类似这样的输出:
code复制# at 15623
DROP TABLE `orders` /* generated by server */
这里的15623就是DROP语句的位置。那我们恢复时要以这个位置为终点,并且要精确地恢复到15623之前,即stop-position设为15622。
不过更稳妥的办法是,先解析出这段binlog的末尾部分,肉眼确认没有其他业务写入:
bash复制mysqlbinlog --no-defaults /var/lib/mysql/mysql-bin.000016 > /tmp/all_binlog.sql
然后打开/tmp/all_binlog.sql,搜索DROP TABLE,记录它前面最近的“# at 数字”位置。比如误操作前最后一条正常事务的位置是14889,那stop-position用14889。
第三步,把orders表所在库从全量备份恢复出来:
bash复制mysql -uroot -p < /backup/mydb_20250101.sql
第四步,回放全量备份点到DROP之前的binlog:
bash复制mysqlbinlog --stop-position=14889 /var/lib/mysql/mysql-bin.000015 > inc_part1.sql
mysqlbinlog --stop-position=14889 /var/lib/mysql/mysql-bin.000016 > inc_part2.sql
mysql -uroot -p < inc_part1.sql
mysql -uroot -p < inc_part2.sql
这里要注意,mysqlbinlog的--stop-position参数,如果指定的position在某个文件里已经超过了文件末尾,会报错。所以实际操作中我先用SHOW BINLOG EVENTS确定DROP语句精确落在哪个文件,然后只针对那个文件设置stop-position,之前的文件全部解析导入。
第五步,验证数据。恢复完成后立刻查一下orders表的行数、最大create_time、关键字段的汇总值,和业务方确认是否符合预期。确认无误后再让业务恢复写入。
这套流程的核心要点是:先恢复全量,再按binlog位置点精确跳过误操作语句。只要binlog够全,恢复精度基本能做到“不丢一条好数据,也不放走一条坏数据”。
5. Xtrabackup物理备份:大数据量场景的恢复实践
5.1 什么时候该从mysqldump切到Xtrabackup
提到Xtrabackup,就绕不开“恢复速度”这四个字。当数据库数据量超过100GB,mysqldump导出SQL文件、再导入SQL文件的全流程会变得非常漫长。我见过一个300GB的库,mysqldump导出3小时,导入花了8小时,整个过程里业务完全不可用,堪称灾难。而Xtrabackup备份300GB的数据文件,在线拷贝加prepare,通常1到2小时能搞定,恢复时把文件放回datadir,启动MySQL,十几分钟就能让业务恢复。这里的差异就是物理备份和逻辑备份的本质区别。
另外,如果在做在线备份时,业务对锁表极其敏感,mysqldump需要依赖InnoDB的MVCC快照才能不锁表,但某些DDL操作、MyISAM表混用场景下还是可能触发锁。Xtrabackup基于物理文件级拷贝,配合redo log的日志追平,几乎不阻塞业务,更符合生产环境的高可用要求。
5.2 完整备份与恢复操作
Xtrabackup的完整备份命令很简单,但要注意备份目录的磁盘空间,因为这个工具并不是只生成一个文件,而是会生成一整套和datadir结构类似的文件树。
bash复制xtrabackup --backup \
--target-dir=/backup/20250101_full \
--host=127.0.0.1 \
--user=backup_user \
--password=your_password
备份完成后,/backup/20250101_full目录下会有一堆文件,包括ibdata1、表结构frm文件或bdp文件、undo日志、redo日志等。这个目录从某种意义上说,就是一份“冻结了但又没完全冻结”的数据副本。
恢复的时候不要直接把这个目录里的文件丢到datadir,需要先做prepare:
bash复制xtrabackup --prepare --target-dir=/backup/20250101_full
prepare完成后,把MySQL停掉(如果是恢复一台独立的测试机,直接停掉即可),然后执行copy-back:
bash复制xtrabackup --copy-back --target-dir=/backup/20250101_full
执行完之后,修改数据目录的属主:
bash复制chown -R mysql:mysql /var/lib/mysql
最后启动MySQL:
bash复制systemctl start mysqld
这里有一个细节:copy-back之前,原datadir必须是空的,或者里面没有冲突文件。如果原datadir已经有数据,建议先备份或者清空。实际操作中,我习惯先把原datadir改名成datadir_bak,再建一个新的空目录,然后copy-back,这样万一恢复失败还可以退回原来的数据。
5.3 增量备份的合并与回放
Xtrabackup的增量备份需要基于一个基础全备。比如周一全备,周二周三各做一次增量,那么周一的base全备目录是/backup/20250101_full,周二的增量基于它,周三的增量基于周二。
做增量备份的命令:
bash复制xtrabackup --backup \
--target-dir=/backup/20250102_inc \
--incremental-basedir=/backup/20250101_full \
--host=127.0.0.1 --user=backup_user --password=your_password
bash复制xtrabackup --backup \
--target-dir=/backup/20250103_inc \
--incremental-basedir=/backup/20250102_inc \
--host=127.0.0.1 --user=backup_user --password=your_password
恢复时,要先把所有增量合并到基础全备上。注意顺序不能乱:
bash复制xtrabackup --prepare --target-dir=/backup/20250101_full --incremental-dir=/backup/20250102_inc
xtrabackup --prepare --target-dir=/backup/20250101_full --incremental-dir=/backup/20250103_inc
最后再执行一次不带增量参数的prepare,让整个数据目录达到一致状态:
bash复制xtrabackup --prepare --target-dir=/backup/20250101_full
合并完成后,/backup/20250101_full就是一个包含了截至周三所有数据变更的完整全备,之后按5.2的流程copy-back即可。
5.4 prepare阶段到底做了什么
这应该是新手最容易困惑的一步。物理备份就像你把一个正在写字的账本复印了一份,复印的时候账本还没写完,有些页的笔记是半截的。prepare阶段做的事情,就是照着redo log(相当于账本的流水记录)把这些半截字迹补齐,让整个账本呈现出一个“完整、可读、一致性”的状态。
MySQL的InnoDB引擎在运行期间,会把数据页的修改先记到redo log里,数据页本身可能还没来得及更新。物理备份直接复制了数据文件,但redo log里的很多变更并没有完整地应用到数据页上。如果没有prepare,这份备份启动后InnoDB会发现数据文件不一致,大概率直接崩溃或者拒绝启动。prepare阶段把这些redo log逐条回放到数据文件中,最终得到一份和正常停机的实例一样干净的数据目录。
这也是Xtrabackup和“直接tar一下datadir”这种野路子的本质区别。直接冷拷贝数据目录,从文件层面看可能一切正常,但启动时InnoDB的崩溃恢复机制能不能正确处理,完全取决于运气。我自己的经验是:不到万不得已,不要用直接拷贝的方式做备份,规范操作永远比赌运气可靠。
6. 自动化备份脚本与远程存储方案
6.1 一个可以直接用的全备+binlog轮转脚本
备份必须自动化,纯手工备份等于没备份。我分享一个目前还在用的简易脚本,麻雀虽小五脏俱全。它完成的事包括:mysqldump全量备份并gzip压缩、记录binlog位置、按日期文件命名、保留最近N天备份、将备份文件用scp发到远程机器。如果你是Docker起的MySQL,只需要把脚本里的host或socket路径改成容器内对应的路径。
bash复制#!/bin/bash
# 全备 + binlog位置记录
# 用法:配合crontab每天凌晨执行
set -eu
BACKUP_BASE=/data/backup
DATE=$(date +%Y%m%d_%H%M%S)
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=backup_user
MYSQL_PASSWORD='your_password'
MYSQL_BIN=$(which mysql)
MYSQLDUMP_BIN=$(which mysqldump)
GZIP_BIN=$(which gzip)
REMOTE_HOST='backup@remote_host:/backup/mysql/'
KEEP_DAYS=7
mkdir -p ${BACKUP_BASE}/${DATE}
DUMP_FILE=${BACKUP_BASE}/${DATE}/${DATE}_full.sql
GZIP_FILE=${DUMP_FILE}.gz
# 1. 执行全量备份
${MYSQLDUMP_BIN} -h${MYSQL_HOST} -P${MYSQL_PORT} \
-u${MYSQL_USER} -p${MYSQL_PASSWORD} \
--single-transaction --master-data=2 --routines --triggers --events \
--set-gtid-purged=OFF --all-databases > ${DUMP_FILE}
if [ $? -eq 0 ]; then
echo "$(date '+%F %T') mysqldump OK" >> ${BACKUP_BASE}/backup.log
else
echo "$(date '+%F %T') mysqldump FAILED" >> ${BACKUP_BASE}/backup.log
exit 1
fi
# 2. 压缩
${GZIP_BIN} ${DUMP_FILE}
# 3. 记录binlog位置(从第一个注释行中提取)
grep -m1 'CHANGE MASTER TO' ${DUMP_FILE} > ${BACKUP_BASE}/${DATE}/binlog_position.txt || true
# 4. 清理7天前的备份
find ${BACKUP_BASE} -maxdepth 1 -type d -name '20*' -mtime +${KEEP_DAYS} -exec rm -rf {} \;
# 5. 推送到远程备份机
scp ${GZIP_FILE} ${REMOTE_HOST}/${DATE}_full.sql.gz
if [ $? -eq 0 ]; then
echo "$(date '+%F %T') scp OK" >> ${BACKUP_BASE}/backup.log
else
echo "$(date '+%F %T') scp FAILED" >> ${BACKUP_BASE}/backup.log
exit 1
fi
echo "$(date '+%F %T') done" >> ${BACKUP_BASE}/backup.log
然后写进crontab,每天凌晨1点执行:
bash复制0 1 * * * /usr/local/bin/mysql_backup.sh >/dev/null 2>&1
脚本里我特意在每一步都加了退出码判断,这是血泪教训换来的。如果不加判断,脚本在某些步骤失败时仍然会走完,日志里显示“done”,实际上备份文件是坏的,等到需要恢复时才发现就晚了。
6.2 备份保留策略怎么定
保留策略的核心是在“恢复能力”和“存储成本”之间找平衡。我常见的一套做法是这样的:
- 每天做一次全备,保留最近7天。
- binlog实时或每小时同步到备份机,保留15到30天,至少覆盖到上一个全备点并多出冗余。
- 每月1日做一次月度归档全备,保留6个月到1年。
- 核心业务系统,哪怕数据量小,也建议在异地或者另一台物理机上保留一份副本。
这样做的好处是:日常恢复用7天内的全备加当天binlog就够了;出了事需要追溯更久之前的数据时,有月度归档兜底;异地副本则防止机房级别的故障。
6.3 远程存储与恢复演练
备份文件如果只放在生产服务器本地,那和没有备份没有本质区别——机器挂了,盘阵坏了,备份和源数据一起没了。所以远程存储是底线,哪怕先手动scp到一台测试机或者公司NAS,也比只存在本地强。
但比远程存储更重要的,是定期做恢复演练。我经常跟同行说一句话:备份本身没有价值,能恢复的备份才有价值。备份文件在,不代表能恢复出来,中间隔着一个“恢复流程是否完整”的大坑。
我自己的习惯是每季度至少做一次演练:拿最近的全备文件,在一台独立的测试实例上做一次完整恢复,然后比对关键表的行数和最新时间戳。如果你用Docker搭了测试环境,恢复时注意容器卷的挂载路径,别把备份文件放到容器里再找半天路径。恢复演练不需要太复杂,但一定要真的把恢复流程完整走一遍。演练次数多了,你对真正恢复时的耗时、报错、依赖关系都会心里有数。
7. 我在生产环境踩过的备份恢复坑
7.1 恢复后权限丢失,客户端全部连不上
有一段时间,我用mysqldump带--databases只导出业务库,恢复完业务库之后,应用端全部报Access denied。排查了半天才发现,业务账号是存在mysql库里的,我备份时压根没把mysql系统库导出来,恢复后的实例上账号表是空的。
后来我的做法调整成:除非有非常明确的原因,否则全量备份一律带--all-databases,把mysql系统库一起导出来。这样恢复后的实例账号、权限配置都还在。如果目标实例是全新的,仍需要重新初始化数据目录再导入,或者恢复后手动重建账号。
7.2 备份成功但恢复时文件不一致
这个坑出现在Xtrabackup恢复阶段。当时把备份好的文件copy-back到目标机器后,MySQL启动直接报错,error log里一堆InnoDB错误。后来发现原因是:备份文件在传输过程中有部分文件被覆盖了,或者说copy-back前原datadir没有清理干净,残留的旧文件和新文件混在一起。
正确的操作是:copy-back之前,把目标datadir清空或者改名,确认里面没有任何旧文件,然后执行copy-back。copy-back完成后别急着启动,先确认属主和权限:chown -R mysql:mysql /var/lib/mysql。很多启动失败都是因为属主不对,MySQL进程没有权限读写数据文件。
7.3 字符集不一致导致中文乱码
从旧库导出的数据,在另一台机器上导入之后,所有中文变成了问号。原因是旧库的表是latin1字符集,导出时客户端又用了默认的utf8,导入的时候目标库是utf8mb4,三重叠加,字符彻底乱了。
解决方法是备份和恢复时都显式加上--default-character-set=utf8mb4,同时检查原表的字符集,如果源表是latin1,最好先做一次ALTER TABLE CONVERT TO CHARACTER SET utf8mb4的迁移,再备份。如果情况不允许改表,那也要在导入前后用iconv或sed对SQL文件做转码,总之不能什么都不管直接导。
7.4 忘了校验备份文件的完整性
有一年某台机器做磁盘迁移,备份脚本一直没报错,直到真出故障要恢复时才发现,最近三天的备份文件每个都是20多KB的“空壳”,因为脚本执行时磁盘分区满了,mysqldump写了几行SQL就被中断,但脚本没检查退出码,照样返回0。那三天里,你以为有备份,其实什么都没有。
这个教训让我在脚本里加了几层防线:
- 每一步命令执行后检查$?退出码,非0直接退出并告警。
- 备份生成后用gzip -t来验证压缩文件的完整性。
- 记录备份文件大小,如果小于某个阈值,直接报警。
7.5 冷备份直接拷文件,数据目录损坏
我一再强调,不要用“停MySQL然后直接复制整个数据目录”的方式来备份。有人觉得mysqldump慢、Xtrabackup要装,干脆把datadir整个tar一下,省事。但InnoDB在运行期间,buffer pool里有很多页还没完全落盘,redo log和数据文件之间也存在时间差。直接冷拷贝出来的文件,恢复到另一台机器上大概率启动失败,或者说数据不是一致性的。
如果你一定要用冷拷贝,那也必须先登录MySQL执行FLUSH TABLES WITH READ LOCK,让所有表缓存刷到磁盘,然后再拷贝。但要清楚,FLUSH TABLES WITH READ LOCK会阻塞写入,相当于对业务做了只读冻结,这已经不是在线备份的范畴了。更稳妥的做法还是用Xtrabackup,它专门解决这种在线一致性物理备份的问题。
这篇笔记写到这里,我想起另一个实际案例:一个朋友的公司,数据库只有300MB,他们觉得mysqldump都嫌重,于是把备份脚本放在crontab里每天跑一次,但从来没有做过一次恢复测试。直到某天误删了用户表,才发现脚本里的密码写错了,备份文件都是空的。后来我帮他搭了一套最简单的备份脚本,加了一个每周自动恢复演练,虽然多花了点时间,但心里踏实多了。如果你看完这篇笔记只记住一件事,我希望是:备份不是用来交差的,恢复才是目的。在你最不希望出问题的时候,提前演练过的那条恢复路径,就是你唯一能抓住的绳索。最后一个小提醒:恢复操作尽量在测试实例上先完整跑一遍,再上生产;真到了生产恢复那一步,每一步都慢一点,多看一眼输出,比抢那几分钟重要得多。
