LVM 快照备份实战:给运行中的 MySQL 建秒级一致性快照,COW 空间耗尽的成因与 merge 回滚演练

为什么备份 MySQL 总是让人提心吊胆

做个人站长久了,早晚会遇到这个场面:数据库跑在没有 LVM 的普通目录上,mysqldump 一跑就是几十分钟,导出期间整张表被读锁压着,网站访问卡顿甚至超时;如果不用 --single-transaction,还会出现备份文件里数据对不上的情况。更糟的是,磁盘 IO 被 dump 占满,前端 Nginx 的请求队列堆起来,用户直接看到 502。

很多人只知道 LVM 能在线扩容,却不知道 LVM 还藏着一个非常适合做备份的特性:快照(snapshot)。它的核心价值不是"复制数据",而是"在几秒钟内冻结一个时间点,让你可以慢慢从这个冻结的时间点里读数据"。对于运行中的数据库,这意味着你可以把"备份窗口"从几十分钟压缩到几秒钟,剩下的复制工作随便什么时候做。

这篇文章不讲 LVM 基础的 pv/vg/lv 创建,那是另一篇的事。这里只聚焦快照备份这一件事:原理是什么、怎么给运行中的 MySQL 做一致性快照、为什么 COW 空间会突然耗尽、快照坏了怎么回滚、以及生产环境里那几条必须遵守的纪律。

快照的本质:COW 和你开的一个"零成本假象"

LVM 快照创建瞬间几乎是零耗时的,因为它不复制任何数据。它做的是:新建一个逻辑卷当作COW 空间(Copy-On-Write),然后记录"当前所有数据块的位置"。之后只要你没有改动某个数据块,快照就直接去原始逻辑卷读;一旦原始卷上某个数据块被写,LVM 会先把那个块的旧内容复制到 COW 空间,再让写入落到原始卷。于是快照始终看到的是"创建那一刻"的视图。

这里就埋下了快照最大的坑:COW 空间的大小决定了快照能活多久。原始卷上被改动的数据块总量一旦超过 COW 空间容量,快照就失效(Invalid),直接无法挂载,里面的数据等于丢失。所以快照不是"备份",它只是"给备份争到了一段安全窗口"。真正的备份永远是从快照里再读出来、复制到别的地方。

查看当前卷组还有多少空间可用

# 先看卷组剩余空间,快照只能从卷组的空闲空间里划
vgs

# 输出示例:
#   VG   #PV #LV #SN Attr   VSize   VFree
#   vg0    1   3   0 wz--n- 100.00g  20.00g
#                                                  ^^^^^^ 这是能给 COW 用的上限

# 看某个逻辑卷的实际数据量,估算快照需要多大
lvs -o lv_name,lv_size,data_percent,lv_attr

很多教程张口就说"给 10% 容量就够了",这是不负责任的。10% 适用于"创建快照后马上开始复制、且业务写入量很小"的场景。给运行中的 MySQL 做快照,如果你打算在快照上跑一个完整 dump 或直接挂载到备用机,写入量取决于业务活跃度。经验值:低活跃站点给 15%~20%,高活跃或写入密集的站点给 30% 以上,并在快照存活期间盯着 COW 使用率。

第一步:确认数据在 LVM 上,并给 MySQL 做一次刷盘

快照要一致,前提是被快照的文件系统上,数据已经落到磁盘。MySQL 默认把变更先写进 InnoDB redo log 和内存 buffer pool,文件的"逻辑视图"和磁盘上的物理块在某一瞬间并不完全一致。对文件系统来说最简单的做法是:在创建快照前,让 MySQL 把所有脏页刷到磁盘并短暂停止写入。

# 1. 确认数据目录确实在 LVM 逻辑卷上
df -hT /var/lib/mysql
lsblk
# 输出里应该能看到 /var/lib/mysql 挂载在 /dev/mapper/vg0-mysql 之类的设备上

# 2. 用 FLUSH TABLES WITH READ LOCK 短暂冻结写入
#    注意:这条命令会加全局读锁,手动执行的窗口要保持极短
mysql -uroot -p -e "FLUSH TABLES WITH READ LOCK; SYSTEM sleep 2; UNLOCK TABLES;"

但手工执行有两个问题:一是锁窗口难以精确控制,二是容易忘记 UNLOCK。更稳的做法是把"刷盘 + 加锁 + 创建快照 + 解锁"写成一个脚本,用 mysqladmin 配合真正的一次性会话。下面是一个可直接用的备份脚本骨架。

可用的快照备份脚本

#!/bin/bash
set -euo pipefail

VG=vg0
LV=mysql
SNAP_NAME=mysql_snap
SNAP_SIZE=20G
MNT=/mnt/mysql_snap
STAMP=$(date +%Y%m%d_%H%M%S)
DEST=/backup/mysql_snap_${STAMP}.tar.gz

cleanup() {
  # 无论成功失败都要解挂并删除快照,避免 COW 空间被持续占用
  umount "${MNT}" 2>/dev/null || true
  lvremove -f "/dev/${VG}/${SNAP_NAME}" 2>/dev/null || true
}
trap cleanup EXIT

# 冻结写入:在同一会话里加锁、建快照、解锁
mysql -uroot -p"${MYSQL_PWD}" <

这段脚本的关键设计是:加锁期间只做"建快照 + 挂载"这两件毫秒级的事,真正耗时的 tar 打包是在 UNLOCK TABLES 之后进行的。这样线上的写锁窗口通常只有 1~2 秒,用户几乎无感。

第二步:快照创建了,但别急着删

有一个反直觉的现象值得专门说:只要快照存在,原始卷的每一次写操作都要先拷一份旧块到 COW,这就给线上带来了额外的写放大。也就是说,快照活着的每一分钟,都在拖慢你的数据库,同时悄无声息地吃掉 COW 空间。所以快照的纪律是:

  • 用完立刻删,不要"留着以后再看看"。留着不会省钱,只会烧性能。
  • 复制工作要快,如果 COW 使用率涨到 70% 以上就该警觉。
  • 绝对不要对快照做长期挂载,更不要把快照当成异地备份的最终形态。

监控 COW 使用率的命令:

# 实时查看快照的 data_percent(COW 使用率)与 metadata_percent
lvs -o lv_name,lv_size,data_percent,metadata_percent,snap_percent

# 用 watch 盯着
watch -n 5 "lvs -o lv_name,data_percent,metadata_percent"

data_percent 是数据区使用率,metadata_percent 是元数据区使用率。这两个都会涨,而且元数据区满了同样会让快照失效——这一点比数据区更容易被忽略,因为元数据区默认很小,在大量小块随机写的场景下会先满。

COW 空间耗尽后的真实表现

当 COW 空间被写满,内核会把快照标记为 Invalid,此时会发生:

# dmesg 里会看到这类信息
#   device-mapper: snapshots: Invalidating snapshot: Unable to allocate exception.
#   Buffer I/O error on dev dm-3, logical block 0, async page read

dmesg -T | grep -i snapshot

更麻烦的是如果这个快照仍然挂载着,文件系统会因为底层设备返回 IO 错误而直接变成只读甚至触发 panic,进程进入 D 状态无法 kill。处理办法只有一条:立刻 umount,然后 lvremove 掉这个坏快照,重新做一次。数据不会丢(原始卷没动),但你这次备份窗口白费了。所以前面说别把快照长期挂载,正是为了不让这个故障把线上拖下水。

第三步:用快照回滚(merge)——这是最后手段,不是日常操作

LVM 快照还能反向使用:把快照合并回原始卷,实现"回到快照创建那一刻"。语法很简单,但杀伤力极大。

# 危险操作:把快照状态合并回原始卷,原始卷此后会变成快照当时的样子
# 合并期间原始卷不可用(除非内核支持快照合并时不阻塞),务必先停业务
umount /var/lib/mysql
umount /mnt/mysql_snap          # 快照必须先卸掉
lvconvert --merge /dev/vg0/mysql_snap

# 如果是根卷这类不能卸载的场景,可以让它排到下次激活时执行:
#   lvconvert --merge /dev/vg0/mysql_snap
#   然后重启,重启后自动合并

# 合并完成后确认
lvs

几个必须记住的点:

  • 合并是单向的、破坏性的:快照之后写入原始卷的所有数据会被丢弃,无法用 lvconvert 撤销。
  • 合并期间原始卷不可访问,所以线上必须停机,或者至少保证没有任何进程在读写该卷。
  • 合并失败不会自动回退,如果中途断电,重启后可能停在半合并状态,需要人工介入。Merger 的进度可以用 lvs -a -o lv_name,copy_percent 或 dmsetup status 观察。
  • 它只能回滚到"快照创建那一刻",回滚粒度完全取决于你多久做一次快照。要更细的恢复点,应该用 binlog 做点恢复,而不是靠快照。

换句话说,LVM 快照回滚适合的场景是"我刚刚因为误操作删了一张表 / 升级失败导致数据库不一致,我需要回到半小时前的整体状态"。它不是 PITR(时间点恢复)的替代品,粒度太粗。

常见误解与踩坑清单

误解真相
快照就是备份快照和原始卷在同一个物理卷上,磁盘坏了两个一起没。它只是备份的"稳定源",不是备份本身。
快照创建是瞬间的,所以没有开销创建瞬间确实无开销,但快照存活期间每次写原始卷都要拷旧块,是持续的写放大。
COW 给 10% 就够了取决于快照存活时长与写入量。活跃库给 30% 更稳,并且必须监控 data_percent。
只看 data_percent 就行metadata_percent 满了同样会让快照失效,小块随机写场景下它先满。
快照可以一直挂着用挂着的坏快照会引发设备 IO 错误和 D 状态进程,处理起来极其痛苦。
merge 可以反复来回切merge 是单向破坏性的,快照合并后即消失,回不去。
有了快照就不需要 mysqldump快照回滚粒度粗,逻辑备份对单表/单库恢复、跨版本迁移仍然不可替代。两者互补。

给个人站长的落地建议

如果你只有一台 VPS,磁盘没有 LVM,那这篇的第一步就卡住了。现实的建议是:装系统时就规划 LVM。云厂商的镜像大多支持自定义分区,把数据盘放到 LVM 上,以后扩容、快照、回滚都留了后路。已经装好没 LVM 的,虽然不能原地转换,但可以把数据库目录搬到新加的 LVM 数据盘上,再把 /var/lib/mysql 通过 bind mount 指过去,成本也不高。

日常执行上,我推荐的分层是:

  1. 每天一次:LVM 快照 + 从快照读出的逻辑备份,复制到异地对象存储。这是主力。
  2. 每周一次:完整备份 + 恢复演练,验证备份真的能还原。备份没验证过等于没有。
  3. 危险操作前:手工建一个快照,操作完立刻删。这是"后悔药"。
  4. binlog 常开:真正细粒度的恢复靠它,快照只提供粗粒度基线。

常见问题(FAQ)

Q1:快照的空间可以事后调整吗?

可以用 lvextend -L +5G /dev/vg0/mysql_snap 在快照存续期间扩容 COW 空间,前提是卷组还有空闲空间。但注意这只能救"还没满"的快照,已经 Invalid 的没法救活。

Q2:为什么不直接在快照上跑 mysqldump,而要先 tar?

两者都可以。直接挂在快照目录上跑 mysqldump 的好处是产出的逻辑备份便于单表恢复,代价是 dump 期间快照必须一直活着、COW 持续增长。tar 物理打包更快结束快照生命周期,但恢复时是整个目录替换。建议物理快照做全量、逻辑 dump 做补充。

Q3:快照会影响 MySQL 的 redo log 吗?

不直接影响。快照是块设备层的机制,MySQL 完全不知道它的存在。它能拿到一致视图的原因,是你在创建快照前用 FTWRL 让所有脏页落盘了。如果你跳过这一步直接对运行中的数据库做快照,拿到的很可能是崩溃不一致状态——虽然 InnoDB 启动时能靠 redo 恢复,但恢复过程需要时间且不保证覆盖所有场景。

Q4:thin provisioning 的快照和传统快照有什么区别?

传统 LVM 快照需要预留完整 COW 空间;thin pool 里的快照是另一套实现,共享 thin pool 空间,扩容更灵活,但仍然会因为 thin pool 被写满而失效。原理风险相同,纪律也一样。

Q5:怎么知道快照是不是一致可用的?

最可靠的方式是做恢复演练:把快照挂到测试机上,起一个 MySQL 实例,跑 mysqlcheck 和几条业务查询。没有演练过的快照只能算"可能可用"。

写在最后

LVM 快照不是什么高深技术,它就是一个"用空间换时间窗口"的块设备技巧。用好它的关键不在命令本身,而在纪律:短窗口加锁、快照用完即删、监控 COW 两个使用率、把快照当成备份的起点而不是终点。真正让我踩过最狠的坑,不是语法写错,而是"快照挂着忘了删",第二天发现数据库莫名其妙变慢,查了半天才发现是 COW 写放大。把这些纪律写进脚本和监控里,比记住命令重要得多。

Last modification:September 27th, 2026 at 09:24 pm

Leave a Comment