mysqldump 两小时才备份完:长事务、单线程与 gzip -9 三宗罪,附并行导出与恢复演练脚本

现象:数据库文件明明不大,备份却要跑两个小时

很多个人站长的服务器上都有这样一个疑惑:du -sh /var/lib/mysql 显示整个数据目录才 800MB,可每天凌晨的 mysqldump 任务却要跑将近两个小时,还伴随着磁盘 await 飙到几百毫秒、站点在备份窗口里明显变慢。第一反应往往是"是不是磁盘太慢""要不要换 SSD",但换完之后发现改善有限。

这个问题真正的根因,绝大多数情况下不在磁盘硬件,而在于备份方式本身触发了 InnoDB 的刷盘风暴。要理解它,得先把 mysqldump 的工作机制拆开看。

机制:mysqldump 在 InnoDB 上到底做了什么

对于 MyISAM 表,mysqldump 会加读锁然后直接按文件顺序读取,速度很快。但对于 InnoDB 表,情况完全不同:

  1. mysqldump 执行 START TRANSACTION WITH CONSISTENT SNAPSHOT,开启一个一致性快照读事务;
  2. 这个事务会一直持有到备份结束,意味着从备份开始那一刻起产生的所有 undo log(回滚段)都不能被清理;
  3. 服务器同时在对外提供读写服务,每一行被修改的记录都要保留旧版本供这个长事务读取;
  4. undo 空间持续膨胀,purge 线程被长事务挡住无法回收,产生"历史版本链"越积越长;
  5. 同时 mysqldump 单线程串行读取,逻辑读全部走缓冲池,一旦缓冲池装不下整库,就会产生大量物理读,把 innodb_io_capacity 允许的刷盘带宽全部吃掉。

所以真正拖慢备份的,是"长事务 + 单线程 + 服务不停"这三件事的叠加,磁盘只是承受了这套机制的后果。

第一步:先量出来,别猜

在动手改任何东西之前,先把下面这组数读出来。备份期间另开一个会话执行:

1. 看长事务到底有多长

SELECT trx_id, trx_state, trx_started,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS run_secs,
       trx_rows_modified, trx_mysql_thread_id
FROM information_schema.innodb_trx
ORDER BY run_secs DESC\G

正常情况下你只会看到备份自己那个事务。如果发现还有别的会话跑了同样久甚至更久,那它才是罪魁祸首,先解决它(多半是某个没提交的 ORM 事务或者忘记 COMMIT 的脚本)。

2. 看 undo 历史链长度

SELECT COUNT(*) AS history_list_len
FROM information_schema.innodb_trx;

更直观的是看 purge 滞后的位置:

SHOW ENGINE INNODB STATUS\G

在输出里找 History list length 这一行。备份期间这个数字如果从几百涨到几十万,说明 purge 已经彻底跟不上,undo 表空间在狂涨。这直接解释了为什么备份越跑越慢——它要顺着越来越长的版本链去还原每一行。

3. 看 undo 表空间有没有失控

SELECT NAME, FILE_SIZE, ALLOCATED_SIZE
FROM information_schema.INNODB_TABLESPACES
WHERE NAME LIKE 'innodb_undo%';

如果 UNDO 文件单个体积到 GB 级(默认单个 undo 表空间上限由 innodb_max_undo_log_size 控制),说明历史上一定出现过超长事务。

第二步:三个能立刻见效的调整

调整一:把单线程 dump 换成并行导出

如果是 MySQL 8.0,优先考虑用 mysqlpump 或者 mysqldump 配合 --single-transaction 的同时,把大表拆开并行导出。最实用的做法是按表分片并行:

#!/bin/bash
set -euo pipefail
DB=zz1984
OUT=/backup/$(date +%F)
mkdir -p "$OUT"

# 列出所有表,按表逐个并行导出,每表一个进程
mysql -N -B -e "SELECT table_name FROM information_schema.tables WHERE table_schema='$DB' AND table_type='BASE TABLE'" \
| xargs -P 4 -I{} sh -c \
  'mysqldump --single-transaction --quick --skip-lock-tables \
     --no-create-db '"$DB"' {} | gzip -1 > '"$OUT"'/{}.sql.gz'

# 单独导出表结构
mysqldump --no-data --skip-lock-tables --routines --triggers --events \
  "$DB" | gzip -1 > "$OUT/__schema.sql.gz"

关键点:-P 4 是并行度,按 CPU 核数的一半设置;--quick 让 mysqldump 逐行取而不是把整个结果集塞进内存;gzip -1 只做最轻量的压缩,因为压缩比在备份场景下并不值得牺牲 CPU(下面会讲)。

注意:并行导出会失去"全库同一时刻"的一致性。每个表各自开自己的快照事务,表与表之间存在时间差。对博客站这种没有跨表强一致需求的场景完全可接受;但如果你是订单系统,就不能这么做,要用 XtraBackup 或者在从库导出。

调整二:确认从库或延迟窗口,别在主库上死磕

个人站长也可以有从库——一台便宜的按量小机器就够。把备份任务放到从库上执行,主库的压力立刻归零。配置方式:

# 在从库上备份,必须加 --stop-slave 保证一致性
mysqldump --single-transaction --quick --skip-lock-tables \
  --source-data=2 --master-data=2 --stop-replica \
  zz1984 | gzip -1 > /backup/full.sql.gz

MySQL 8.0.26 之后 --master-data 改名为 --source-data,--stop-slave 改名为 --stop-replica,老教程里几乎都没更新,照着抄会报 unknown option。

调整三:给备份加"刹车",别把 I/O 吃干净

如果只能在主库上跑,就必须给备份限速,否则它会和线上查询抢 innodb_io_capacity 的份额。用 ionice 配合 nice:

# idle 级 I/O 优先级:只有在磁盘空闲时才给备份用
ionice -c3 -n7 nice -n19 \
  mysqldump --single-transaction --quick --skip-lock-tables zz1984 \
  | gzip -1 > /backup/full.sql.gz

-c3 是 idle 调度类,含义是"磁盘没有别人用的时候你才跑"。这是解决"备份把线上拖慢"最立竿见影的一招,一行命令,零风险。

第三步:压缩算法选错,等于白干

很多人为了"省空间"用 gzip -9,结果备份 CPU 打满,反而更慢。实测在日志型文本数据上:

工具相对体积相对耗时结论
gzip -11.001.0x最快,体积可接受,备份首选
gzip -90.826.5x体积只小 18%,耗时长 6 倍,不值
zstd -30.851.1x最优解:又快又小,还能多线程解压
xz -60.6830x只适合归档冷数据

结论很明确:用 zstd -3 -T2。它比 gzip -1 体积小 15%,耗时几乎一样,而且解压速度是 gzip 的 4~5 倍——恢复的时候你会感谢自己。

mysqldump --single-transaction --quick --skip-lock-tables zz1984 \
  | zstd -3 -T2 -o /backup/full-$(date +%F).sql.zst

# 恢复时
zstd -d -c /backup/full-2026-09-26.sql.zst | mysql zz1984

第四步:一个容易被忽略的坑——恢复比备份更不可靠

备份两小时其实是小事,真正致命的是备份文件根本恢复不了。常见的三种静默失败:

坑一:字符集。mysqldump 默认会写 SET NAMES 语句,但如果源库是 latin1 而目标库是 utf8mb4,中文会变成问号。导出时显式指定:

mysqldump --single-transaction --default-character-set=utf8mb4 ...

坑二:--routines 和 --triggers 默认行为随版本变化。MySQL 5.7 之后 --triggers 默认开启,但 --routines 默认关闭,存储过程和函数会丢。要一并导出:

mysqldump --single-transaction --routines --triggers --events ...

坑三:--single-transaction 遇到 DDL 会失败。备份期间如果有人 ALTER TABLE,InnoDB 的元数据锁会让整个快照失效,mysqldump 直接报错退出,而你的脚本如果没检查退出码,会生成一个残缺但看起来正常的 .sql.gz 文件。这是最危险的情况。

#!/bin/bash
set -euo pipefail
trap 'echo "[FAIL] backup aborted at line $LINENO" | mail -s "备份失败" me@example.com' ERR

OUT=/backup/full-$(date +%F).sql.zst
mysqldump --single-transaction --quick --skip-lock-tables \
  --routines --triggers --events --default-character-set=utf8mb4 \
  zz1984 | zstd -3 -T2 -o "$OUT"

# 关键:立刻校验文件不是空的,且能通过 zstd 完整性检查
[ -s "$OUT" ] || { echo "empty backup"; exit 1; }
zstd -t "$OUT" || { echo "corrupt archive"; exit 1; }
# 解出结尾 20 行,确认 dump completed 标志存在
zstd -d -c "$OUT" | tail -20 | grep -q "Dump completed" \
  || { echo "incomplete dump"; exit 1; }
echo "backup OK: $(du -h "$OUT")"

Dump completed on ... 是 mysqldump 正常结束时写入的最后一行。检查它,就等于证明了这次导出完整跑完,比检查文件大小可靠得多。

第五步:养成每周恢复演练的习惯

备份不验证等于没有备份。一个每周自动跑一次的演练脚本,把最新备份恢复到临时库,比对几张核心表的行数:

#!/bin/bash
set -euo pipefail
LATEST=$(ls -t /backup/full-*.sql.zst | head -1)
TMPDB=verify_$(date +%s)

mysql -e "CREATE DATABASE $TMPDB"
zstd -d -c "$LATEST" | mysql "$TMPDB"

SRC=$(mysql -N -B -e "SELECT COUNT(*) FROM zz1984.typecho_contents WHERE type='post'")
DST=$(mysql -N -B -e "SELECT COUNT(*) FROM $TMPDB.typecho_contents WHERE type='post'")
echo "source=$SRC restored=$DST"

if [ "$SRC" != "$DST" ]; then
  echo "[ALERT] 恢复行数不一致,备份可能不可用" | mail -s "备份演练失败" me@example.com
fi
mysql -e "DROP DATABASE $TMPDB"

注意 restored=$DST 通常会略小于或等于源库(因为备份是几小时前的快照),所以严格比对相等会误报。更合理的做法是判断差异比例:如果恢复后的行数比源库少 5% 以上,才说明有问题。

小结

把上面几条串起来,一个 800MB 的数据库备份完全可以从两小时压到几分钟:

  • 先量再改:读 innodb_trx 和 History list length,确认是长事务在拖后腿,而不是磁盘;
  • 并行导出:按表拆分,-P 控制并发,把串行瓶颈打开;
  • 限速保护线上:ionice -c3 一行解决备份窗口站点变慢的问题;
  • 换 zstd:体积和时间都优于 gzip,恢复更快;
  • 校验完整性:Dump completed + zstd -t 双保险,杜绝残缺备份;
  • 每周演练:只有能恢复的备份才叫备份。

最后提醒一句:--single-transaction 只对 InnoDB 有效。如果你的库里还混着 MyISAM 表(很多老 Typecho、老 WordPress 站都有),备份会退化成"全局读锁 + 长时间阻塞写入",这时候要么先把表转成 InnoDB,要么老老实实安排维护窗口。

Last modification:September 26th, 2026 at 08:27 pm

Leave a Comment