做备份运维的人大多有过这种错觉:任务计划里写着每天凌晨两点跑一次,日志里也有成功记录,可真正要恢复的时候才发现,最近那份快照是残缺的,或者干脆连索引都没写完整。备份系统最难的环节是“中断之后怎么收拾现场”,并非“能不能备上去”。bup 这套基于内容寻址的增量备份工具,因为底层用的是 packfile 结构加 SHA-1 对象命名,它在面对异常时给出的三条自愈路径,和传统全量+增量工具的思路差异很大。我把它拆成中断型、损坏型、资源型三类异常,分别对应它内部三套高可用处理机制,每套机制都讲清楚触发条件、判断方法、恢复动作和代价。这篇适合已经用 bup 跑过一段时间、开始担心“备份能不能真的恢复”的运维和开发同学,也适合正在评估备份方案、想理解内容寻址架构容错设计的人。
1. 备份系统的“高可用”到底在防什么:三类异常的边界划分
很多人一提高可用,脑子里浮现的是双机热备、主从切换那一套。放到备份系统上,这个理解要换一换。备份节点本身挂掉其实是小事,重跑一遍就行,真正麻烦的是备份写到一半的中间状态:对象已经落盘了一部分,索引还没建好,引用指针还没更新,这时候你既不能当它成功了,也不能简单删掉重来,因为删掉意味着几小时的传输白费。
bup 的数据模型决定了它必须认真对待这个中间态。它的仓库结构大致是这样的:objects/pack/下存放着一个一个的 pack 文件,每个 pack 里塞满了压缩后的数据块;配套的.idx文件记录“对象哈希 → 在 pack 里的字节偏移”这张映射表;再往上是refs/heads/里的引用,指向某次备份对应的根对象。写入顺序上,数据先进 pack,pack 写完才生成 idx,最后才更新 ref。这个顺序本身就是一种防御设计,让中断发生在任何一个环节时,受损范围都是可控的。
我把实际会遇到的异常归成三类,后面每一类单独展开:
| 异常类型 | 典型触发场景 | bup 侧的表现 | 处理方向 |
|---|---|---|---|
| 中断型 | 进程被 kill、机器断电、SSH 会话断开、容器被驱逐 | pack 文件存在但无配套 idx,或残留临时文件,ref 未更新 | 识别、隔离、重跑或补建索引 |
| 损坏型 | 磁盘坏道、静默位翻转、备份介质老化、异常掉电导致写入错位 | idx 与 pack 对不上,对象头校验失败 | 交叉校验、par2 冗余修复、截断丢弃 |
| 资源型 | 磁盘写满、目录被挂成只读、句柄耗尽、IO 超时 | 写入返回 ENOSPC / EROFS / EMFILE | 区分可重试与不可重试,退让后重试 |
这个分类看着简单,价值在于它直接对应三种完全不同的处置动作。中断型异常的核心诉求是“别浪费已传数据”,所以处置重点是保留、复用;损坏型异常的核心诉求是“别让坏数据混进可信集合”,处置重点是隔离、校验;资源型异常的核心诉求是“别把一次偶发失败放大成雪崩”,处置重点是退让和限流。搞混了就会出问题,我见过有人把磁盘写满当成中断型处理,脚本无脑重试,结果每轮重试都在仓库里堆一个半成品 pack,磁盘越写越满,彻底陷死。
还有一点必须提前说清楚:bup 的对象是按内容哈希命名的,同一个文件内容不管备份多少次,落盘都只有一份。这个特性让“重跑一遍”成为非常安全的兜底手段,重复的对象会被直接跳过,不会产生额外空间。但它有个边界,每次bup save都会在refs/heads/下生成或更新一个分支引用,如果你用日期做分支名,重跑会覆盖同名分支;如果用时间戳做分支名,重跑就会多出一条快照记录。这个差别在后面讲断点重跑的时候会反复用到。
2. 中断型异常:半成品 pack 文件的识别、隔离与断点重跑
2.1 一次被 kill 掉的备份到底留下了什么
先说清楚现场。假设凌晨两点跑bup save -n daily /data,跑到一半被 OOM killer 干掉,或者运维同学手滑按了 Ctrl-C。这时候objects/pack/目录下会出现两种情况:一种是 pack 文件已经完成 rename,但对应的.idx还没写出来,这个 pack 是完好的,只是“没有目录页”;另一种是写入过程被硬切断,留下.pack内容和.idx都不完整,甚至还有.tmp后缀的中间文件。
这两种情况的处置完全不一样,但它们的表象很像,都表现为“有个 pack 文件孤零零躺在那儿”。如果你直接跑一次bup fsck,它会报一堆 missing idx 的错,看起来像仓库大面积损坏,实际上大多数只是索引缺失,数据本体是好的。我第一次遇到这个场景时也慌了一下,后来才想明白:pack 是自描述的,里面有对象头和长度信息,只要有工具愿意重新扫一遍,索引随时可以重建。
判断当前处于哪种情况,最直接的办法是比对文件名和数量。bup 的 pack 命名规则是pack-<哈希>.pack,配套索引同名换成.idx。写个小脚本扫一遍就能看出来:
#!/bin/bash # 扫描孤立 pack(有 pack 无 idx)与残留临时文件 PACKDIR="${BUP_DIR:-$HOME/.bup}/objects/pack" echo "=== 有 pack 无 idx ===" for p in "$PACKDIR"/pack-*.pack; do [ -e "$p" ] || continue idx="${p%.pack}.idx" [ -f "$idx" ] || echo "孤立: $(basename "$p") $(du -h "$p" | cut -f1)" done echo "=== 残留临时文件 ===" find "$PACKDIR" -maxdepth 1 -name '*.tmp' -o -name 'tmp-*' 2>/dev/null这个脚本没什么技术含量,但它是每次备份失败后第一件该跑的东西。跑完你心里就有数了:孤立 pack 的总大小是多少,值不值得救。
2.2 完整 pack 补索引,残缺 pack 直接丢弃
对于“有 pack 无 idx”且 pack 本身完整的情况,正确做法是重建索引,而不是删掉重传。bup 提供bup index系列命令来处理文件系统索引,对于 pack 层面的索引重建,较新版本可以用bup midx生成合并索引,或者直接用bup fsck的修复模式让它把缺失的 idx 补出来。实际操作中我通常这么走:
# 第一步:先跑一次 fsck,看清楚到底缺什么,不要急着加参数 bup fsck 2>&1 | tee /tmp/fsck-before.log # 第二步:尝试重建索引(针对缺 idx 的完整 pack) bup fsck --repair 2>&1 | tee /tmp/fsck-repair.log # 第三步:如果有多个 idx,合并成 midx 加速后续查找 bup midx -a # 第四步:复查 bup fsck 2>&1 | tee /tmp/fsck-after.log这里有个细节值得说一下为什么这么排。bup fsck默认走的是快速校验模式,只读 pack 文件头部的少量字节做一致性判断,不做全量解压验证,所以它跑得快,适合放在每次备份之后当轻量巡检。但也正因为快,它通过不代表数据没错,这一点在第三节还会展开。而bup midx -a是用来把多个小 idx 合并成一个大的 midx 文件的,合并之后对象查找时的文件打开次数会显著下降,在大仓库上这个差别能达到几倍,所以重建完索引顺手合并一下,属于低成本的性能优化。
至于残缺的 pack,判断标准是 fsck 报告校验失败且无法修复。这种 pack 不要犹豫,直接移出目录而不是 rm,先挪到隔离区观察一晚上,确认没有业务依赖再删除。理由很朴素:万一你的判断错了,隔离区还能捞回来;直接 rm 就真的没了。
注意:所有涉及删除和移动的操作,先在仓库的副本上演练一遍。bup 仓库是强依赖目录结构的,误删
refs/下的东西比误删 pack 更难恢复。
2.3 重跑的代价评估与分支命名的坑
隔离完坏数据,接下来就是重跑。这一步看着最简单,实际上坑最多。
内容寻址带来的好处是重跑时已经上传过的对象会被自动跳过,网络传输量基本只剩没传完的那部分。我用一个 300GB 左右的目录做过实验,第一次跑到 60% 中断,重跑实际新增传输不到 40%,耗时大概是从头跑的 45%。这个比例说明断点重跑的收益是实打实的,不用纠结“是不是干脆全量重来更干净”。
但分支命名这个坑必须提前规避。假设你的备份脚本写的是bup save -n daily-$(date +%F) /data,中断后重跑,同一天内分支名相同,会正常覆盖更新,没问题。可如果脚本写的是bup save -n daily-$(date +%F-%H%M) /data,每次重跑都生成一条新分支,跑三次就有三条快照,前两条是残缺的。后面做保留策略清理的时候,这些残缺快照会误导你,让你以为那天的备份是完整的。
我的做法是把“任务标识”和“快照标识”分开:分支名用稳定的日期或批次号,比如daily-2024-06-01;如果在同一批次内重跑,就让它覆盖。同时在任务调度层记录一个状态文件,只有bup save返回 0 且后续 fsck 通过,才把状态标记为完成,否则标记为待重跑。这样即使半夜断了,第二天早上看一眼状态文件就知道该补哪一天。
STATE=/var/lib/backup/state/daily.state if bup save -n "daily-$(date +%F)" /data; then if bup fsck >/dev/null 2>&1; then echo "ok $(date -Is)" > "$STATE" else echo "fsck-failed $(date -Is)" > "$STATE" exit 2 fi else echo "save-failed $(date -Is)" > "$STATE" exit 1 fi这段脚本不长,但它把“备份成功”这个模糊概念拆成了两个可验证的事实:save 返回成功、fsck 复查通过。两个条件缺一不可,这是我在吃过一次亏之后才加上的,当时 save 明明返回了 0,但仓库索引是坏的,直到一个月后演练恢复才发现。
3. 损坏型异常:idx 与 pack 的交叉校验以及 par2 冗余的代价
3.1 为什么 fsck 通过不等于数据没问题
前面提过,bup fsck默认是快速模式,它读的是 pack 文件头部的少量信息,主要检查这几点:pack 文件长度是否与头部声明一致、对象数量是否对得上、idx 里记录的偏移是否落在 pack 范围内。这些检查能抓住绝大多数结构性损坏,比如文件被截断、索引与 pack 数量不匹配。
但它抓不住静默数据损坏。位翻转是个很现实的问题:盘片上的某个比特从 0 变成 1,文件长度没变,对象数量没变,索引偏移也没变,快速校验完全无感。等到你真要恢复某个文件,解压出来才发现内容对不上,而这时候可能已经过去半年,源头数据早就不在了。
所以校验策略要分层。我的建议是:每次备份后跑快速 fsck,成本低、频率高;每周或每月对最近若干个 pack 跑一次全量校验,成本高、频率低;对于特别关键的 pack,额外生成冗余。这个分层逻辑和数据库的“日常巡检+定期全备校验”是一回事,核心思想是把有限的 IO 预算花在刀刃上。
# 快速巡检:每日备份后执行 bup fsck # 全量校验:每周执行,只针对最近 7 天内新增的 pack find "${BUP_DIR:-$HOME/.bup}/objects/pack" -name 'pack-*.pack' -mtime -7 -print0 \ | xargs -0 -n1 basename \ | sed 's/\.pack$//' \ | xargs -r -I{} bup fsck --full -v {} 2>&1 | tee -a /var/log/bup/fullcheck.log这里要注意--full模式的耗时。全量校验需要把 pack 里的每个对象解压出来重新算一遍哈希,IO 和 CPU 都会吃满。我在一个 800GB 规模的仓库上测过,单线程跑完一轮要将近 4 小时,期间如果再叠一个备份任务,两边都会变得很慢。所以我把全量校验安排在周末的白天窗口,避开备份时段,并且用ionice把它的 IO 优先级压下去,避免影响同一台机器上的其他服务。
3.2 par2 冗余:用多少空间换多少安全感
bup 在设计上给了一个很聪明的选项:可以给 pack 文件生成 par2 冗余校验块。par2 是一种纠删码方案,它把原始数据切成若干块,额外生成一批校验块,当原始数据中有部分块损坏时,用校验块能把这些块重建出来。关键点在于,它不需要提前知道哪块会坏,也不需要完整备份一份,冗余量和容错能力是可以按比例调的。
生成方式很直接:
# 为所有 pack 生成冗余(会调用系统的 par2 工具,需要提前装好) bup fsck -g # 也可以只对指定的 pack 生成 bup fsck -g pack-<哈希>生成之后,objects/pack/目录下会多出.par2后缀的文件。这些文件的大小取决于冗余比例,比例越高,能修复的损坏范围越大,占用的空间也越多。这就引出一个必须算清楚的账:你的仓库总量有多大,愿意拿出多少比例做冗余。
| 冗余比例 | 额外空间占用 | 大致容错能力 | 适用场景 |
|---|---|---|---|
| 5% | 每 100GB 多占 5GB | 可修复少量散点损坏 | 普通业务数据,已有异地副本 |
| 10% | 每 100GB 多占 10GB | 可修复局部连续损坏 | 核心业务数据,恢复窗口紧 |
| 20% | 每 100GB 多占 20GB | 可修复较大范围损坏 | 唯一副本、法规留存数据 |
这张表里的比例不是拍脑袋定的,它来自一个简单的权衡:par2 的容错能力和冗余比例大致成正比,但恢复时的计算开销也随损坏块数上升。20% 冗余在多数场景已经够用,再往上加,空间成本的增长速度会超过它带来的实际收益,不如把钱花在第二份异地副本上。
有个容易被忽略的点:冗余文件本身也会损坏。生成完就不管的 par2 文件,如果和 pack 放在同一块坏盘上,一起坏的概率不低。所以理想做法是让 par2 和 pack 落在不同的物理介质上,或者至少确保你的存储层有底层校验。这一点在很多部署里被直接忽略,因为默认都放在同一个目录下,图省事。
3.3 修复动作的完整执行链路
发现损坏之后的处置,我总结成一条固定链路,按顺序走,不跳步。
第一步是确认损坏范围。跑全量 fsck,把报错的对象和所属 pack 全部记下来。输出里会指明哪个 pack 的哪个偏移出问题,据此能判断是整文件完蛋还是局部几个块坏了。
第二步是先备份坏文件。注意,是备份坏的那个 pack,不是删。因为修复过程本身可能失败,如果没有原始文件,你连“部分恢复”的机会都没有。
PACKDIR="${BUP_DIR:-$HOME/.bup}/objects/pack" mkdir -p /var/lib/backup/quarantine/$(date +%F) cp -a "$PACKDIR"/pack-<哈希>.* /var/lib/backup/quarantine/$(date +%F)/第三步才是执行修复。用bup fsck -r让它调用 par2 尝试重建损坏的块。修复完成后再跑一次全量校验确认。如果修复成功,pack 里的对象就能正常读取了;如果失败,说明损坏范围超过了冗余能力,只能接受这部分数据丢失。
第四步是评估丢失影响。这一点很多人不做,但很关键。bup 的引用结构是链式的,如果某个 pack 里的对象正好是某个快照的根对象或者中间目录对象,那这条快照链就断了,哪怕其它对象都在也没法完整恢复。查的方法是用bup ls和bup restore对着最近的几个快照做抽样恢复,能恢复出来才算数。抽样不要只抽最新那个,往前多抽几个,因为老快照引用的对象分布在更早的 pack 里。
提示:抽样恢复一定要恢复到临时目录,不要覆盖生产路径。恢复演练搞成生产事故的例子,我听说过不止一次。
4. 资源型异常:ENOSPC、只读挂载与句柄耗尽的退让策略
4.1 磁盘写满为什么不能无脑重试
磁盘写满在备份系统里是高频事件,因为备份目录天生就是只增不减的。它触发的错误是 ENOSPC,写操作直接失败,pack 文件写到一半停住。
问题的关键在于,ENOSPC 属于不可通过重试解决的错误。你重试一百次,磁盘还是满的,唯一的结果是仓库里多出一百个半成品 pack,把仅剩的空间也占掉。我见过一个脚本,重试逻辑写得很“健壮”,失败就 sleep 30 秒再试,最多重试 10 次。结果磁盘满的时候,它硬生生把一个 20GB 的剩余空间消耗到 0,最后连日志都写不进去了。
正确的处理逻辑是先分类,再决定动作。错误类型按可重试性分两堆:
| 错误类型 | 典型 errno | 可重试 | 处置动作 |
|---|---|---|---|
| 空间不足 | ENOSPC | 否 | 暂停任务、触发清理、告警 |
| 只读挂载 | EROFS | 否 | 立即告警,检查挂载状态 |
| 权限不足 | EACCES | 否 | 立即告警,检查属主与权限 |
| 句柄耗尽 | EMFILE | 是(有条件) | 增大限制或降低并发后重试 |
| IO 超时 | ETIMEDOUT | 是 | 指数退避重试 |
| 连接重置 | ECONNRESET | 是 | 指数退避重试 |
这个分类的意义在于,它把“失败”从一个笼统状态拆成了可操作的动作。空间不足要做的是清理和扩容,不是重试;IO 超时要做的是等待,不是 overhaul 配置。
清理这块我有个具体做法,用空间水位触发分级动作:低于 20% 时告警,低于 10% 时自动清理最旧的快照链,低于 5% 时暂停所有备份任务并把当前状态写进状态文件。这个分级的好处是不会在还有空间的时候就急着删数据,也不会等到彻底写不进去才手忙脚乱。
THRESHOLD=10 USAGE=$(df --output=pcent "$BUP_DIR" | tail -1 | tr -dc '0-9') if [ "$USAGE" -ge $((100 - THRESHOLD)) ]; then echo "warn: usage ${USAGE}% exceeds threshold" >&2 # 按保留策略清理最旧的快照分支 bup ls -l 2>/dev/null | head -n 5 fi注意df的取值方式,不同系统上字段位置不一样,--output=pcent是 GNU coreutils 的写法,在别的环境要换。这种小细节在跨环境部署时特别容易翻车,我一般会在脚本里加一个环境探测分支。
4.2 只读挂载:最隐蔽的一类失败
只读挂载比磁盘满了更讨厌,因为它经常是“静默”的。文件系统出问题后,内核出于保护目的把分区切成只读,此后所有写操作都返回 EROFS。备份脚本如果只判断退出码,很容易把它和普通失败混淆,重试几次都失败,然后放弃,日志里只留一句“备份失败”,没有任何线索。
识别方法是在失败后立刻查挂载状态:
mount | grep " $(df --output=target "$BUP_DIR" | tail -1) " # 或者更直接 touch "$BUP_DIR/.write-test" 2>/dev/null && rm -f "$BUP_DIR/.write-test" || echo "目录不可写"这个写测试的小技巧很好用,成本接近零,能立刻区分“是备份逻辑的问题”还是“是文件系统的问题”。我把它放在备份脚本的最前面,跑之前先确认目标目录可写,不通过就直接退出并告警,省得跑了两小时才发现根本写不进去。
只读挂载背后的根因通常是磁盘错误或者文件系统损坏,这种情况必须人工介入,脚本层面能做的只有“快速失败、快速告警”。所以我给这类错误的处理原则是:不重试,直接告警,并且把dmesg里最近的磁盘相关日志一起带上,方便定位。这条经验来自一次半夜被叫起来的经历,当时脚本重试了二十多次才报错,等我登录上去,已经到了第二天早上。
4.3 句柄耗尽与并发控制的取舍
备份大目录时,bup 需要同时打开不少文件句柄。目录里文件数量特别多的时候,比如一个目录里有上百万个小文件,句柄耗尽是很容易发生的。错误表现是 EMFILE,写操作失败但磁盘还有空间,很容易被误判成磁盘问题。
排查方法很直接,看进程的句柄数和系统限制:
ulimit -n cat /proc/<pid>/limits | grep "open files" ls /proc/<pid>/fd | wc -l如果确认是句柄不够,有两条路:提高限制,或者降低并发。提高限制简单,ulimit -n 65535或者改 systemd 的LimitNOFILE,但要注意这是有上限的,系统级上限也得跟着调。降低并发则是从设计上缓解,比如把一个大目录拆成几个子目录分批备份,每批跑完再跑下一批。
我更倾向后者,原因是句柄只是表象,根子在于单次任务的处理粒度太粗。拆批之后,每批的失败影响面也小,一批挂了不影响其他批,重跑也只重跑那一批。这个思路和“大事务拆小事务”是一个道理,用任务粒度换容错性。
5. 故障注入实测:用三次人为破坏验证恢复链路是否真的通
5.1 实验设计:三次破坏分别打在三类异常上
纸面上讲机制容易,真正有价值的是验证这套机制在你的环境里到底管不管用。我做了一轮故障注入,用一个大约 40GB 的测试目录,跑了三次破坏,分别对应前面三类异常。
| 轮次 | 注入手段 | 期望触发 | 验证重点 |
|---|---|---|---|
| 第一轮 | 备份进行到中途kill -9进程 | 中断型 | 孤立 pack 能否被识别,重跑是否复用已有对象 |
| 第二轮 | 用 dd 向某个 pack 中间写入随机字节 | 损坏型 | fsck 能否定位,par2 能否修复 |
| 第三轮 | 把备份目录塞到一个 2GB 的小分区填满 | 资源型 | 脚本能否正确分类错误并停止重试 |
三轮都在隔离的测试环境做,不碰任何生产仓库。这一点必须强调,故障注入的破坏力是真实的,我见过有人在生产目录上做演练,本来只是验证,结果真把备份弄没了。
第一轮的执行很简单,起一个备份任务,等到objects/pack/里出现文件后直接 kill。这时候去看目录,能看到一个不完整的 pack 文件,没有对应的 idx。然后按第二节的脚本扫一遍,孤立 pack 被正确识别出来,大小约 6GB。接着重跑同一条命令,观察过程:已经传过的对象被跳过,实际新增写入量明显小于第一次。这个结果符合预期,说明内容寻址的复用逻辑是有效的。
5.2 第二轮的问题:fsck 没报错,但数据已经坏了
第二轮出了个意外,也是这轮实验最大的收获。我用 dd 向一个 pack 文件的中段写了几百个随机字节,然后跑bup fsck。结果是通过。快速校验只看了文件头,中段被改它完全无感。
换成bup fsck --full之后才报出对象哈希不匹配。这个结果很有说服力地证明了前面那个论断:快速校验不能替代全量校验,两者必须组合使用。
接着跑bup fsck -r尝试修复。这里我提前已经给这个 pack 生成过 par2 冗余,冗余比例设的是 10%。修复过程大概跑了十几分钟,最终结果是成功,被改的块从冗余块重建了出来,再跑全量校验通过。这个结果我也预期过,但实际看到还是安心了不少,说明冗余是真的能用的,不是摆设。
第二轮还暴露了一个细节:bup fsck的报错信息里给的是对象哈希,不是文件名。要定位具体影响哪个文件,得用bup ls遍历快照去找引用关系,这个过程比较繁琐。所以如果你的仓库规模大,建议在恢复演练之前先准备好一个“对象到文件”的反查脚本,临时现写会很痛苦。
5.3 第三轮:错误分类是脚本能不能扛住的关键
第三轮我把目标目录挪到一个 2GB 的测试分区上,然后跑备份,等着它把空间吃完。第一版脚本在这里彻底翻车,它的重试逻辑不区分错误类型,ENOSPC 也重试,结果在磁盘满之后又跑了 10 轮,每轮都往仓库里塞一个残缺文件,日志刷了几百行,最后连日志文件都写不进去。
改版之后加了错误分类,逻辑变成这样:捕获退出码和 stderr 关键字,命中 ENOSPC 或 “No space left” 就直接退出并写状态文件,不重试;命中超时类错误才走退避重试;其他错误一律退出并告警。改完之后重新跑,磁盘满的瞬间任务就停了,状态文件里明确写着失败原因,日志干净。
这三轮下来,我的判断是:bup 自身提供的机制是够用的,真正容易出问题的是外面那层调度和监控脚本。工具给你的是能力,能不能在异常发生时正确地用出来,取决于脚本写得多细。这也解释了为什么很多团队备份系统“看起来”跑了好几年,真出事的时候却恢复不出来。
6. 把机制固化成配置:巡检脚本、监控指标与恢复演练节奏
6.1 一份可以照着抄的巡检脚本骨架
机制讲完了,落到日常运维,最重要的是把它变成每天自动跑的东西。我用的巡检脚本结构大致是这样,分三层:轻量巡检、周期性深检、异常后处置。
#!/bin/bash set -uo pipefail BUP_DIR="${BUP_DIR:-$HOME/.bup}" PACKDIR="$BUP_DIR/objects/pack" LOG="/var/log/bup/inspect-$(date +%F).log" exec >> "$LOG" 2>&1 echo "===== $(date -Is) 巡检开始 =====" # 1. 目标目录可写性检查(提前发现只读挂载) touch "$BUP_DIR/.wtest" 2>/dev/null && rm -f "$BUP_DIR/.wtest" \ || { echo "FATAL: 仓库目录不可写"; exit 3; } # 2. 空间水位 USAGE=$(df --output=pcent "$BUP_DIR" | tail -1 | tr -dc '0-9') echo "空间使用率: ${USAGE}%" # 3. 孤立 pack 扫描 ORPHAN=0 for p in "$PACKDIR"/pack-*.pack; do [ -e "$p" ] || continue [ -f "${p%.pack}.idx" ] || { echo "孤立 pack: $(basename "$p")"; ORPHAN=$((ORPHAN+1)); } done echo "孤立 pack 数量: $ORPHAN" # 4. 快速一致性校验 bup fsck || echo "WARN: 快速校验未通过" # 5. 最近一次备份快照可读性抽样 LATEST=$(bup ls 2>/dev/null | tail -1 | awk '{print $1}') if [ -n "$LATEST" ]; then bup ls "$LATEST" >/dev/null 2>&1 \ && echo "快照可读: $LATEST" \ || echo "ERROR: 快照不可读 $LATEST" fi echo "===== $(date -Is) 巡检结束 ====="这段脚本的重点不在代码本身,而在它覆盖的检查点顺序:先看环境能不能写,再看空间够不够,再看有没有明显的结构问题,最后做一次轻量读取验证。这个顺序是从“便宜且能快速失败”到“昂贵且需要时间”排列的,保证出问题时能在最短时间内定位到方向。
深检脚本单独放在周末跑,只针对最近一段时间新增的 pack 做全量校验,避免每次都全仓库扫一遍。异常后处置则是前面第二节那套固定链路,封装成一个函数,失败时手动或半自动调用。
6.2 值得盯的几个指标
监控层面,我关注的东西不多,但都是能提前预警的:
- 仓库总量周增长率:突然变快通常意味着去重失效,比如日志文件格式变了导致内容哈希全变,或者备份路径被误改包含了不该备的东西。
- 孤立 pack 数量:正常应该是 0,持续大于 0 说明有任务在反复中断,需要去看调度日志。
- 单次备份耗时移动平均:耗时突然拉长,可能是磁盘性能下降的前兆,也可能是数据量增长。
- 快照可读性抽样成功率:这是最直接的可用性指标,其它指标都正常但抽样失败,说明问题已经很深了。
那个周增长率指标特别值得说一下。bup 的去重依赖内容哈希,只要文件内容没变,再备份也不会增加存储。所以正常情况下仓库增长应该远小于原始数据量。如果某周突然涨得跟全量备份差不多,基本可以断定去重失效了。我遇到过的一次是因为日志文件每分钟都在追加,导致每天的打包文件内容完全不同,去重率骤降。解决办法是把日志目录从备份范围里排除,单独用轮转策略处理。
6.3 恢复演练该多久做一次
最后说个容易被当成形式主义的环节:恢复演练。我的观点是,没做过恢复演练的备份,只能算“有副本”,不能算“有备份”。
频率上,完整演练一季度一次,抽样演练一个月一次。完整演练是从仓库里恢复一个完整快照到独立环境,验证启动、验证关键文件、验证文件数量和时间戳一致性。抽样演练简单得多,挑几个关键文件恢复出来比对哈希就行,五分钟能做完。
演练的另一个价值是校验你的恢复时间预期。bup 恢复需要先解压对象再拼装文件,大文件场景下的速度并不算快。我在 800GB 规模的仓库上测过,恢复 50GB 数据大概要 20 分钟,这个数字必须提前知道,因为它直接决定了故障时的业务恢复窗口能不能接受。如果你的 RTO 是半小时,而恢复要两小时,那备份方案就是不合格的,无论备份成功率多高。
一个实操小技巧:把演练脚本和巡检脚本放一起,演练产生的临时数据放到独立目录,跑完自动清理,避免演练本身把磁盘占满。这个坑我踩过一次,演练恢复了一份 100GB 的数据忘了删,导致当晚备份失败。
提示:演练脚本跑完后一定要核对恢复文件的数量和总大小,光看“命令成功退出”不够可靠,bup 在部分文件缺失时也可能返回成功。