1. 备份方案怎么选:逻辑备份还是物理备份
先说结论:绝大多数业务场景,用 mysqldump 做逻辑备份 + crontab 定时执行,就是最稳妥、最不容易出错、也最容易上手恢复的方案。别一上来就想着上 xtrabackup,没必要。
在聊定时备份之前,得先回答一个问题:你备份到底是为了防什么?
- 防的是误操作,比如 DELETE 忘加 WHERE、DROP TABLE 手滑、UPDATE 覆盖了整张表——这种情况 90% 靠逻辑备份就能救回来。
- 防的是磁盘损坏、机房断电、服务器被格式化——这种情况逻辑备份救不回来,需要物理备份 + Binlog 配合。
- 防的是版本迁移、换服务器、环境重建——逻辑备份完胜,因为它是纯 SQL 文本,跨版本、跨平台都能导入。
所以备份方案的取舍逻辑其实很简单:
数据量在 50GB 以内、单库不超过 20GB,优先逻辑备份(mysqldump)。数据量大了,mysqldump 慢、恢复也慢,再考虑 Percona XtraBackup 这种物理备份工具,或者做基于 Binlog 的增量备份。
备份类型对比表:
| 对比项 | 逻辑备份(mysqldump) | 物理备份(xtrabackup) | 冷备份(停库拷贝) |
|---|---|---|---|
| 备份内容 | SQL 语句文本 | 数据文件物理拷贝 | 数据文件拷贝 |
| 备份速度 | 慢,逐条导出 | 快,文件级别 | 最快 |
| 恢复速度 | 慢,逐条执行 | 快 | 最快 |
| 对业务影响 | 低,可在线备份 | 低,可在线备份 | 必须停机 |
| 跨版本迁移 | 支持良好 | 需同版本或兼容 | 一般 |
| 误操作恢复 | 方便 | 较麻烦 | 必须全量回退 |
| 适用场景 | 中小数据量、日常备份 | 大库、企业级备份 | 极端场景 |
再啰嗦一句:冷备份(停库后直接 cp 数据目录)虽然简单粗暴,但你得停 MySQL,线上环境根本不现实。日常定时备份就老老实实用 mysqldump。
另外,备份策略上,我强烈建议你搞一个三层组合:
- 每日全量备份:每天凌晨用 mysqldump 导出全库,压缩后保留 7 天或 30 天。
- Binlog 实时记录:开启 MySQL 的 Binlog,用来做时间点恢复(比如今天中午误删了,拿昨天凌晨的全量 + 今天凌晨到现在 Binlog 可以恢复)。
- 每周异地/离线备份:把备份文件同步一份到另一台机器、对象存储或者移动硬盘,防止服务器本身一起挂掉。
这三层里,第 1 层是本文的主角,第 2、3 层我后面也会展开讲怎么配合。
2. 搭建备份环境:账号、目录、权限一个都不能少
在写脚本之前,先把基础环境准备好。很多人上来就写 crontab,结果第二天发现备份文件是 0 字节,一查是权限不对或者密码没配好,这种坑我踩得太多。
2.1 创建专用备份账号
我强烈建议别用 root 账号做备份,特别是 root 被 MySQL 8.0 默认禁止远程连接之后,你会莫名踩坑。建一个最小权限的账号,只给它 SELECT、LOCK TABLES、SHOW VIEW、EVENT、TRIGGER 相关权限就够。
CREATE USER 'backup_user'@'localhost' IDENTIFIED BY 'StrongPassword2024!'; GRANT SELECT, RELOAD, LOCK TABLES, SHOW VIEW, TRIGGER, EVENT ON *.* TO 'backup_user'@'localhost'; FLUSH PRIVILEGES;其中RELOAD权限是给--single-transaction用 FLUSH TABLES WITH READ LOCK 时用的,EVENT 和 TRIGGER是备份存储过程、定时器、触发器必需的,没有这两个权限,导出来的备份会默认不带这些对象,恢复后你会发现存储过程全丢了。
2.2 准备备份目录
目录规划直接决定后面脚本的简洁程度。我的习惯是:
/data/backup/mysql/ ├── daily/ # 每日全量备份 │ ├── 2024-06-01/ │ ├── 2024-06-02/ │ └── ... ├── weekly/ # 每周归档备份(可选) └── logs/ # 备份日志创建命令:
mkdir -p /data/backup/mysql/{daily,weekly,logs} chown -R mysql:mysql /data/backup/mysql把备份目录属主改成 mysql 用户,防止脚本以 mysql 身份运行(后面我会讲为什么要用 mysql 用户跑 cron)时没有写权限。
2.3 配置免密登录
脚本要放到 crontab 里自动跑,总不能每次都在脚本里写明文密码,更不能手动输入密码。很多人会直接在脚本里写-p'密码',但这样一来,用ps aux查看进程时密码就暴露了,同时密码也会写进 shell 历史记录里。
正确做法是配置 MySQL 的~/.my.cnf文件,自动读取登录凭据。
sudo vi /root/.my.cnf内容如下:
[client] host=localhost user=backup_user password=StrongPassword2024!然后设置文件权限:
chmod 600 /root/.my.cnf这是关键一步!.my.cnf权限必须是 600,如果权限过宽(比如 644),MySQL 会直接忽略这个文件并报错Warning: World-writable config file is ignored,接下来你会百思不得其解为什么 mysqldump 还是要密码。
配置好后测试一下:
mysql -e "SELECT 1;"能正常返回 1 就说明免密登录生效了。
提示:如果你的备份用户和 crontab 执行用户不是 root,记得把
.my.cnf放到对应用户的家目录下,比如 mysql 用户就放/var/lib/mysql/.my.cnf,并且chown给 mysql 用户。
3. mysqldump 核心参数拆解:每个参数都是一条血泪经验
mysqldump 参数很多,但真正生产环境天天用的就那几个。我给你逐个解释清楚,免得你照着网上老旧的教程复制粘贴,备份出来不能用。
3.1 基础导出命令
一条比较完备的全库导出命令长这样:
mysqldump \ --single-transaction \ --quick \ --routines \ --triggers \ --events \ --hex-blob \ --set-gtid-purged=OFF \ --default-character-set=utf8mb4 \ --databases your_database > /data/backup/mysql/daily/$(date +%F)/your_database_$(date +%F).sql逐项拆解一下:
--single-transaction:这是 InnoDB 表的核心参数。它基于事务机制,导出一致性快照,不会锁表。没有这个参数,导出的过程中其他会话的写入操作会导致数据不一致。--quick:逐行读取并立即输出,防止一次性把所有数据加载到内存导致内存暴涨。--routines --triggers --events:备份存储过程、触发器、事件调度器。这三个默认情况下是不备份的,很多人恢复后才发现业务逻辑丢了,欲哭无泪。--hex-blob:二进制字段(BLOB、BINARY)以十六进制形式导出,防止直接输出乱码导致恢复失败。--set-gtid-purged=OFF:MySQL 5.7 及以后,开启 GTID 的实例导出时,如果不加这个参数,备份文件会带上SET @@GLOBAL.GTID_PURGED语句,恢复时容易报错GTID_PURGED can only be set when GTID_EXECUTED is empty。--default-character-set=utf8mb4:MySQL 8.0 默认就是 utf8mb4,但老库可能一直是 latin1 或 utf8,导出时指定字符集,恢复时就不会出现中文乱码。--databases your_database:带上这个参数,导出文件里会包含CREATE DATABASE和USE your_database语句,恢复时不用手动选库。
3.2 MyISAM 表的重大区别
如果你库里有 MyISAM 表,注意!--single-transaction对 MyISAM 表完全不生效。InnoDB 靠事务实现一致性快照,但 MyISAM 不支持事务,mysqldump 会退回使用--lock-tables参数,也就是锁表。
锁表的后果是:备份期间这张表不可写,业务会短暂卡顿。所以我的建议是:
- 如果全库都是 InnoDB,放心用
--single-transaction。 - 如果确实有 MyISAM 表,评估数据量,要么改成 InnoDB(
ALTER TABLE ... ENGINE=InnoDB),要么接受备份期间的短暂锁表。
3.3 压缩:默认必须做
SQL 文本文件的压缩率相当可观,通常能压到原来的 1/5 到 1/10。我习惯用 gzip,压缩率不错,而且所有 Linux 发行版默认都有。
mysqldump [参数] your_database | gzip > /data/backup/mysql/daily/2024-06-01/your_database_2024-06-01.sql.gz注意:用了管道之后,mysqldump 的退出状态码会被 gzip 覆盖,所以后面写脚本判断成败时,需要借助PIPESTATUS数组(Bash)或者set -o pipefail,否则脚本可能漏报备份失败。
3.4 单表备份命令
有时候只想备份某张关键表,比如订单表、用户表,命令是:
mysqldump --single-transaction --hex-blob your_database your_table | gzip > table_2024-06-01.sql.gz4. 完整备份脚本:拿来即用
环境准备就绪后,核心就是写备份脚本了。这里我直接给你一个亲测稳定跑了两年的完整脚本,注释都已加好。
#!/bin/bash #========================================= # MySQL 每日全量备份脚本 # 文件:/usr/local/bin/mysql_backup.sh # 说明:备份所有指定库,压缩后按日期归档,保留 N 天 #========================================= set -o pipefail # ---------- 配置区 ---------- MYSQL_BIN="/usr/bin/mysqldump" BACKUP_DIR="/data/backup/mysql/daily" LOG_DIR="/data/backup/mysql/logs" RETENTION_DAYS=7 DATABASES=("your_database" "another_database") # 要备份的库,可多个 MYSQL_USER="backup_user" MYSQL_HOST="localhost" # ---------------------------- TIMESTAMP=$(date +%Y%m%d_%H%M%S) DATE_STR=$(date +%F) TODAY_DIR="${BACKUP_DIR}/${DATE_STR}" LOG_FILE="${LOG_DIR}/backup_${DATE_STR}.log" # 日志函数 log_info() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*" >> "${LOG_FILE}" } log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >> "${LOG_FILE}" } # 创建今日备份目录 if [ ! -d "${TODAY_DIR}" ]; then mkdir -p "${TODAY_DIR}" fi log_info "备份任务开始" # 检查 mysqldump 是否可用 if [ ! -x "${MYSQL_BIN}" ]; then log_error "找不到 mysqldump,请检查 MYSQL_BIN 路径" exit 1 fi # 对每个库执行备份 for db in "${DATABASES[@]}"; do BACKUP_FILE="${TODAY_DIR}/${db}_${TIMESTAMP}.sql.gz" log_info "开始备份数据库:${db}" ${MYSQL_BIN} \ --single-transaction \ --quick \ --routines \ --triggers \ --events \ --hex-blob \ --set-gtid-purged=OFF \ --default-character-set=utf8mb4 \ --databases "${db}" 2>>"${LOG_FILE}" | gzip > "${BACKUP_FILE}" if [ ${PIPESTATUS[0]} -eq 0 ] && [ -s "${BACKUP_FILE}" ]; then log_info "数据库 ${db} 备份成功,文件大小:$(du -h "${BACKUP_FILE}" | cut -f1)" else log_error "数据库 ${db} 备份失败,请检查!" # 如果有告警机器人,在这里调用 curl 通知 fi done # 清理超过保留天数的备份目录 find "${BACKUP_DIR}" -maxdepth 1 -type d -name "20*" -mtime +${RETENTION_DAYS} -exec rm -rf {} \; log_info "已清理 ${RETENTION_DAYS} 天前的旧备份目录" log_info "备份任务结束" exit 0把脚本保存到/usr/local/bin/mysql_backup.sh,然后赋予执行权限:
chmod +x /usr/local/bin/mysql_backup.sh关于脚本的几个设计细节,值得说道说道:
第一,set -o pipefail很关键。前面说了,mysqldump 走管道给 gzip 后,shell 默认只看最后一个命令(gzip)的退出状态。加了set -o pipefail,只要管道里任何一个命令失败,整个管道的退出状态就是失败。否则你会遇到 mysqldump 中途报错,但脚本依然认为备份成功的情况。
第二,脚本里用了日志函数,每步都写日志。在生产环境里,没有日志的备份脚本等于没写。出问题时你可以直接看今天日志的最后几行,快速定位失败原因。
第三,我用find ... -mtime +${RETENTION_DAYS} -exec rm -rf来清理过期目录,而不是简单粗暴全删。这样即使某天备份失败导致当天目录没生成,也不会误删其他天的数据。
5. 配置 crontab 定时任务:真正实现“无人值守”
脚本写好了,手动跑一次确认没问题,然后配置定时任务。
5.1 crontab 基础语法
Linux 的 crontab 语法格式是五段式:
分 时 日 月 周 命令比如:
30 2 * * * /usr/local/bin/mysql_backup.sh >> /data/backup/mysql/logs/cron.log 2>&1这段的意思是:每天凌晨 2 点 30 分执行备份脚本。
为什么选凌晨 2 点 30 分?一般业务低峰期在凌晨 2 点到 4 点之间,2 点半是个比较合适的窗口。而且最好避开整点,因为可能多个定时任务都挤在整点跑,会造成资源争抢。
5.2 编辑 crontab
crontab -e如果你是 root 用户,默认编辑的是 root 的定时任务列表。如果想让备份脚本以专用用户身份运行(更安全、权限更可控),可以用:
crontab -u mysql -e但注意:如果用 mysql 用户跑 crontab,脚本里所有目录的写权限、.my.cnf的位置都要对应调整,建议还是直接用 root 跑,然后脚本里做好权限控制。
5.3 验证 crontab 是否真的执行了
配置完之后,很多人不验证,第二天才发现根本没跑。这里有三个验证步骤:
查看 crontab 列表确认:
crontab -l查看系统 cron 服务的运行状态:
systemctl status crond # CentOS/RHEL systemctl status cron # Debian/Ubuntu等一天或者手动把系统时间调近测试(不建议生产环境改时间),更推荐的办法是临时缩短间隔,比如先配置成每分钟执行一次,确认能生成备份文件后,再把 crontab 改回凌晨执行。
有个经典坑是:crontab 里的环境变量和交互式 shell 完全不同。比如你在终端里 mysqldump 能跑通,但 crontab 里却报command not found,原因就是 cron 执行时 PATH 环境变量不包含/usr/local/bin等路径。
解决方法是:脚本里要么使用绝对路径(我上面的脚本就是),要么在脚本开头加上:
export PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/mysql/bin6. 恢复演练:不演练的备份等于没备份
这是我在这篇文章里最想强调的一件事。定时备份跑了大半年,数据库真的出问题时,才发现备份文件是坏的或者恢复方式不对,这才是最痛苦的事。
所以我强烈建议,每完成一次备份方案的部署,就立刻做一次全量恢复演练。
6.1 恢复的基本操作
# 解压备份文件 gunzip -c /data/backup/mysql/daily/2024-06-01/your_database_2024-06-01_020001.sql.gz > /tmp/restore.sql # 恢复到 MySQL mysql -u root -p < /tmp/restore.sql如果备份文件里带--databases参数,导出内容自带CREATE DATABASE和USE语句,直接执行这条就能完整恢复。如果没有带这个参数,你需要先手动建库:
mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS your_database DEFAULT CHARSET utf8mb4;" mysql -u root -p your_database < /tmp/restore.sql6.2 恢复演练的正确流程
- 起一台临时 MySQL 实例(Docker 一台或云上开个测试机)。
- 把最近一份备份文件恢复进去。
- 随机抽查几张核心表的数据量、最新记录时间,跟生产库对比。
- 确认无异常后,销毁临时实例。
这个流程每月至少做一次。我见过太多团队,备份文件存在服务器上吃灰,直到真出事才发现 mysqldump 的版本不兼容、表结构不全、数据是半截的——定期演练是验证备份有效性的唯一手段。
6.3 时间点恢复(PITR):误删数据的终极防线
只有每日全量备份还不够,因为你只能恢复到昨天凌晨的状态,今天白天的数据更新会丢失。
要真正做到“精确到某一秒”的恢复,就需要 Binlog。MySQL 开启 Binlog 后,所有变更操作都会记录在二进制日志里。
# my.cnf [mysqld] server-id=1 log-bin=/var/lib/mysql/mysql-bin binlog_format=ROW expire_logs_days=7 max_binlog_size=256M恢复流程是:
- 恢复到最近一次全量备份。
- 使用
mysqlbinlog重放全量备份之后的 Binlog:mysqlbinlog --start-datetime="2024-06-01 02:30:00" --stop-datetime="2024-06-01 14:30:00" \ /var/lib/mysql/mysql-bin.000012 | mysql -u root -p
这样就做到了完整的时间点恢复。Binlog 是 MySQL 高可用和数据恢复的最后一道防线,日常备份必须和 Binlog 配合使用。
7. 常见问题与排查技巧实录
备份脚本跑了一阵子之后,你大概率会遇到下面这些问题。我把我踩过的坑按出现频率排个序,直接给你排查思路。
7.1 crontab 到点没执行
排查步骤:
# 1. 检查 cron 服务 systemctl status crond # 2. 检查 crontab 列表 crontab -l # 3. 检查 cron 日志(CentOS/RHEL) grep CRON /var/log/cron | tail -20 # 4. 检查脚本是否有执行权限 ls -l /usr/local/bin/mysql_backup.sh最常见的三个原因:脚本没有可执行权限、crontab 里写的路径不对、cron 服务的 PATH 环境变量没包含 mysqldump 路径。
7.2 备份文件是 0 字节
这是最让人崩溃的情况,你以为是备份成功了,结果文件是空的。排查方向:
- 手动执行脚本,看有没有报错输出。
- 检查磁盘空间:
df -h,空间满了会导致管道写入失败。 - 检查
.my.cnf权限是不是 600,权限过宽 MySQL 会不认。 - 检查
--single-transaction和LOCK TABLES是否冲突,特别是 MyISAM 表。
7.3 备份文件出现乱码
这个基本是字符集问题。备份时指定--default-character-set=utf8mb4,恢复时如果目标库默认字符集不是 utf8mb4,也容易出现乱码。
建议全链路统一:库表字符集设置成 utf8mb4,备份导出用--default-character-set=utf8mb4,恢复时 MySQL 客户端加--default-character-set=utf8mb4。
7.4 mysqldump 卡死或者太慢
大库用 mysqldump 确实慢,尤其是几千万行的表,导出可能要几十分钟。这时候考虑几个方案:
- 拆库分表备份:每天只备份变更频繁的表,全量备份放到周末做。
- 改用 XtraBackup:物理备份,速度快一个数量级。
- 从从库备份:在只读副本上执行 mysqldump,彻底不影响主库。
7.5 备份文件被磁盘撑爆
备份文件占空间是必然的,所以保留策略必须合理。我给的脚本是保留 7 天全量,大约 7 份备份。
如果你数据量大,考虑差异备份 + 全量备份组合:周日全量备份,周一到周六只备份当天变化的数据(通过 Binlog 或只导出关键表)。这样磁盘占用能从 7 份全量大幅下降到 1 份全量 + 6 份增量。
7.6 备份文件传不到异地
备份文件只在本地磁盘上是远远不够的。本地磁盘可能和数据库一起挂掉。我的建议是备份脚本里加一步同步操作,常见的手段:
- rsync 到另一台服务器:
rsync -avz /data/backup/mysql/daily/ user@remote-server:/data/backup/mysql/ - 上传到对象存储(阿里云 OSS、腾讯云 COS、AWS S3),用官方 CLI 工具即可。
- 如果只是个人用,tar 包发送到另一块硬盘或 NAS 也行。
我在脚本里通常这样加:
# 同步今日备份到异地服务器 rsync -avz --timeout=60 "${TODAY_DIR}" backup@192.168.1.100:/data/backup/mysql/daily/ >> "${LOG_FILE}" 2>&1 if [ $? -eq 0 ]; then log_info "异地同步成功" else log_error "异地同步失败,请检查网络或 rsync 配置" fi这一步加上去,你的备份方案才算真正完整。
8. 补充两个实操中反复出现的细节
备份方案跑久了,还有两个细节值得单独拎出来讲,因为它们直接影响你备份出来的东西能不能用。
8.1 存储过程、触发器到底备份没备份
网上很多教程给的 mysqldump 命令只有简单的-u -p,不带--routines、--triggers、--events。如果你库里有定时任务、存储过程,这一漏,恢复出来的库就像被抽走了灵魂。
怎么验证备份文件里到底有没有?直接搜:
grep -i "PROCEDURE" your_database_20240601.sql.gz | head或者解压后搜索:
gunzip -c your_database_20240601.sql.gz | grep -c "CREATE PROCEDURE"如果返回 0,说明你的备份命令里没带--routines参数,赶紧加上。
8.2 二进制日志(Binlog)占空间怎么办
开启 Binlog 之后,磁盘占用会明显上升。但不要因为占空间就把它关掉——Binlog 是你最后的时间点恢复手段。
合理的做法是:
- 设置
expire_logs_days或 MySQL 8.0 的binlog_expire_logs_seconds,让 Binlog 自动清理。 - 每天备份任务执行后,用
PURGE BINARY LOGS BEFORE命令清理已经备份过的 Binlog。 - 给
/var/lib/mysql所在磁盘多留一些空间,Binlog 增长量大约是数据变更量的几倍是正常的。
9. 最后的实操心得
整套方案你自己动手走一遍下来,其实比我写的还要简单,因为核心就三步:写好 mysqldump 命令、放进脚本、配置 crontab。难的不是工具本身,而是你能不能长期坚持验证备份的有效性。
我个人的习惯是:手机日历上设一个每月的提醒,内容是“恢复演练日”。到了那天就花二十分钟,把最新的备份拉到一台测试机上恢复一次,查几条关键数据,然后清掉。坚持了两年,从来没有因为备份问题在事故中出过丑。
还有一个细节是,每次修改了备份脚本,都要立刻手动执行一遍,确认没有语法错误,再让它进 crontab。不要改完脚本就直接等凌晨自动执行,那样万一有错误,最快也要第二天才能发现,白白损失一天的备份。
最后再补一句:如果你刚接手一套老的备份方案,第一步不是优化它,而是先手动跑一次并把备份文件完整恢复一遍,确认现有方案是真实的、可用的,再来谈改进。连现有方案都没验证过就瞎优化,等于在沙滩上盖楼。