系列二:MySQL 备份从”落盘再搬”到”流式直备”

Cold 发布于 21 天前 26 次阅读 2205 字 预计阅读时间: 10 分钟 运维笔记


上一篇讲应用侧假死取证,这一篇讲同一批服务器上的另一件事:MySQL 备份。

原来每天凌晨三个库各自 dump 成 .sql,再 tar 成 .tar.gz,再删掉 .sql。数据盘 200G 已用掉 104G,某天发现最坏的那一刻,盘上同时存在未压缩的 .sql 和正在写的 .tar.gz——等于两份数据。三个库还错开 10 分钟串着跑,前一个没结束后一个就开始了。

改造的目标很朴素:数据库服务器上不要再产生任何临时文件。最后落地成一条管道,文中 IP、主机名、密码均已脱敏。

一、先看清一件事:备份本来就该落在应用服务器

翻旧脚本才发现,凌晨还有一个 scp 步骤,把整个 /data/back 拷到应用服务器。也就是说:

spawn scp -r /data/back cold@app-host:/resources/mysqlbak/`date +%Y%m%d`

应用服务器本来就是备份的最终落点。 那么数据库服务器上的 .sql、.tar.gz 全是白受罪——数据被落盘一次、压缩一次、再整目录拷一次。

这一点很关键:先搞清楚"数据最终要存在哪",再决定中间该不该落盘。很多"临时文件撑爆磁盘"的问题,根子在于链路设计绕了一圈。

顺带发现两个问题:

  • scp -r 拷的是整个目录,不是当天的新文件。当天只新增 3 个文件,却要把 4 天共 12 个全量重传一遍。
  • 密码用 expect 明文写在脚本里(send "密码\r"),任何能读这个文件的人都能拿到。

二、改成一条管道

ssh root@${DB_HOST} \
  "mysqldump --defaults-extra-file=${CNF} --single-transaction --quick \
   --routines --triggers --events --set-gtid-purged=OFF --no-tablespaces \
   --hex-blob --default-character-set=utf8mb4 ${db} | gzip -1" \
  > "${DEST}/${db}-${stamp}.sql.gz.part"

数据流变成:

数据库服务器:  mysqldump ──┐
                          ├─ 管道(只在内存)
                          └─ gzip ──┐
                                    └─ ssh ──> 应用服务器 xxx.sql.gz.part

数据库服务器从头到尾没有任何文件落地,/data 不再参与备份。顺带还少了"写完再读一遍去压缩"和"整目录再拷一遍"两轮磁盘 IO,--single-transaction 的持有时间也更短。

另外把三个库改成一个脚本串行跑,带 flock 防叠加。原来"错开 10 分钟"看着稳妥,其实更危险:前一个没跑完,后一个就开始了,两个大文件同时写。

三、四道校验,一道都不能省

流式传输最大的风险是"看起来有文件、实际是半个"。所以一律先写 .part,四道关都过才改名:

# 1) ssh 层失败
[ "${rc}" -ne 0 ] && fail "ssh/管道返回 ${rc}"

# 2) 远端 stderr 非空 —— mysqldump 的报错全从这里回来
[ -s "${err}" ] && fail "远端有错误输出"

# 3) 压缩流完整性(传输被截断会在这里暴露)
gzip -t "${part}" || fail "压缩流校验不通过"

# 4) 体积下限
size=$(stat -c %s "${part}")
[ "${size}" -lt $((MIN_SIZE_KB * 1024)) ] && fail "体积过小"

mv -f "${part}" "${out}"

第 2 条是个容易漏的陷阱:mysqldump | gzip 的退出码是 gzip 的,mysqldump 失败它照样返回 0。所以不能看 $?,只能把 stderr 抓下来判断非空。备份宁可误判一次失败,也不要留一个"能解开但缺表"的文件。

成功的备份同时删掉那个 .err(正常是 0 字节),只有失败时才保留当证据——否则目录里会堆一堆空文件,看目录的人分不清哪些是历史失败残留。

四、参数与版本:MySQL 8 的四个必须

  1. 密码不能放命令行。-p密码 会出现在 ps 里,同机任何用户可见。改成 600 的 --defaults-extra-file,并且这个参数必须是 mysqldump 的第一个参数,放后面会被静默忽略然后报 Access denied。

  2. 凭据文件不要写 host=。 走 socket 一定匹配 root@localhost;写成 host=127.0.0.1 就变成 TCP,一旦对方开了 skip_name_resolve,认证会去找 root@127.0.0.1,直接拒绝。

  3. --set-gtid-purged=OFF 是必须的。 8.0 默认 AUTO,若服务器开了 GTID,dump 里会写入 SET @@GLOBAL.GTID_PURGED,恢复到非 GTID 实例时直接报 ERROR 3546。备份是为了"能恢复回去",不是做复制源。

  4. --no-tablespaces 降低权限要求。 不导出表空间信息,以后换非 root 备份账号时不必额外给 PROCESS 权限。

顺带确认一个不存在的坑:如果两端都是 8.0,不需要 --column-statistics=0——那个参数是给"8.0 客户端连 5.7 服务端"用的。版本不一致时才会踩到。

五、体积阈值不能跨库一刀切

最初用"低于 1MB 判失败"来做空库检测,结果把一个正常的库拦了下来。实测三个库压缩后的体积:

库 单个备份体积
bpmapp88 3.6G
bpmou88 47M
bpmdef88 889KB

差了 4000 倍。结构库天生就小,用同一个门槛必然误判。最后改成两档:

MIN_SIZE_KB=10        # 硬下限:低于才判失败(空库/权限错/连错实例)
WARN_SIZE_KB=1024     # 软告警:低于只提示,不拦

判断"备份是否变小"要跟同一个库的历史比,不能跨库比。这句后来写进了运维手册。

六、cron 切换与三个静默失效

切换本身不复杂:数据库服务器上停掉原来的 5 条(三个 dump、一个清理、一个 scp),应用服务器上加 1 条。但有三个地方会静默失效:

6.1 属主不同的清理会失效

应用服务器上原本有一个清理脚本,以普通用户身份跑 rm -rf 删除过期日期目录。新备份脚本以 root 身份建目录(root:root 755),普通用户就删不掉了——删文件需要对目录本身有写权限。

它只会在 cron 邮件里报 Permission denied,而 ls 看目录还在、"看起来仍在清理",实际已经停摆。两个选择:

  • 清理职责交给新脚本(推荐,规则只在一处)
  • 新脚本末尾 chown -R 把目录交还原来的属主,旧清理脚本继续用

不能两边都不管。

6.2 cron 的 PATH 缺 /usr/sbin

脚本开头显式补全:

export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

之前监控脚本就因为 cron 下 PATH 不全而找不到命令,误报过一次故障。

6.3 两个 crontab,别改错

同一台服务器上 root 和业务用户各有一份 crontab。备份装进 root 的,而旧的清理脚本在业务用户名下。改的时候必须带 -u:

crontab -u appuser -l | sed 's|^30 3 \* \* \* /path/cleanup.sh|#&|' | crontab -u appuser -

用 sed 而不是 crontab -e 的好处是:只动目标行,另外三条业务任务(含每分钟的监控任务)一字不改。改完必须回读确认。

七、本篇复盘清单

  • 先想清楚落点,再设计链路。 本例中数据库服务器根本不需要落盘,绕的那一圈就是为了"搬到应用服务器"。
  • 管道里没有退出码。 a | b 只能拿到 b 的状态,要判断 a 必须看 stderr 或 set -o pipefail + PIPESTATUS。
  • 备份一律先写 .part。 校验通过再改名,绝不出现"看起来有文件、实际半个"。
  • 阈值要能说明"为什么是这个数"。 拍一个 1MB 上去,就会把正常的小库拦下来。
  • cron 环境的差异要显式消除。 PATH、属主、crontab 归属,三样任一不对都会静默失败。
  • 清理脚本必须跟着数据的属主走。 否则最先失效的就是它,而且没人会发现。

八、两篇的共同点

回到系列一那句话:系统会在"看起来正常"的状态下失效。进程在、端口在、脚本返回 0、清理没报错——每一层都在说"没事",但偏差一点点累积到了临界点。

对付这类问题,靠的不是更复杂的工具,而是把每一次判断都落到可验证的证据上:线程栈、字节偏移、文件大小、时间戳、/proc 里的实际参数、ls -l 的属主和日期。这些手段都不需要装包、不需要改配置、不需要重启,却足以把"猜"变成"知道"。

文中脚本为脱敏骨架,实际使用时请替换主机、路径与凭据,并先在测试环境验证。

和喜欢的一切在一起~
最后更新于 2026-10-21