bup备份系统分层异常处理与数据恢复实战
凌晨两点被备份告警叫起来,大概是做数据保护这一行最不想遇到的场景。我手上这套 bup 备份系统跑了三年多,仓库 3TB 出头,四千多个 pack 文件,平时靠定时任务每天做一次增量 save,顺带每周一次 fsck 巡检。某天突然发现bup ls能正常列目录,可一读文件内容就抛error: git cat-file,换个分支又能正常读。这种“一半好一半坏”的表现,恰恰是理解 bup 高可用异常处理机制的入口——它不是一个整体黑盒,而是由若干个可独立损坏、也可独立重建的层叠起来的仓库。bup 备份系统的异常处理设计,说到底就是在回答一个问题:当某一层坏了,怎么用剩下的层把这一层重新造出来,而不是让整个备份作废。这套机制适合谁看?正在用 bup 做长期归档、想搞清楚“坏到什么程度还能救”的人;也适合刚接触这套系统、想提前知道“哪些文件千万别手贱删”的人;更适合那些手上已经有一套备份、但从来没验证过恢复链路的人——毕竟备份系统的可用性,从来不是由写入成功决定的,而是由异常状态下能不能还原决定的。
1. 先把仓库结构摊开看:哪些坏了能救,哪些坏了认命
聊异常处理之前必须先把这个仓库里到底躺着什么讲清楚。很多人用 bup 用了半年,脑子里还是“一堆文件”,一旦出事就只能重装重灌,白白丢掉几 TB 的数据。实际上 bup 的仓库设计思路非常明确:数据本身和查找数据用的索引被彻底分开存放,前者不可再生,后者随时可以扔。
1.1 一个 pack 文件里到底塞了什么
bup 不是把一个文件存成一个文件,而是把很多个文件的内容分块(chunk),然后把一堆块打包进一个大文件,这个打包产物就是objects/pack/pack-<40位十六进制>.pack。每个块在写入时用哈希算出唯一标识,所以同一个内容块在仓库里只会存一份——这就是 bup 能对多个版本的整机做去重、把 3TB 源数据压到 800GB 仓库的根本原因。
关键在于 pack 文件的内部布局。前面是连续的对象数据,每个对象带自己的长度前缀;文件尾部则保存了一张完整的偏移表,包含 fanout 数组、按哈希排序的对象 ID 表、对应的文件偏移和 CRC 校验值。这张尾部表的存在非同小可,它意味着只要 pack 文件本身完整,即使旁边所有的辅助文件全丢光,理论上也能把索引重建出来。这是我判断“能不能救”的第一条基准线。
除了.pack,同目录下还有几类伴随文件:
pack-<hex>.idx:单个包的索引,记录这个包里有哪些对象、在什么位置,是读数据的快速通道。objects/pack/bup.midx:把所有.idx合并成一张总表,避免打开几千个 idx 文件。仓库越大,midx 的价值越明显。objects/pack/bup.bloom:布隆过滤器,用来快速回答“这个对象大概在不在仓库里”。它是概率性的,允许误判为“存在”,不允许误判为“不存在”,所以它只是个加速器。refs/heads/<分支名>:分支指针,内容是一个 commit 的哈希值,指向这次备份的元数据快照。BUP_DIR/index:文件系统扫描索引,记录“上次扫描时哪些文件变了、mtime 是多少、大小是多少”,这是增量备份能跑起来的基础。
再补一句很多人忽略的:objects/pack/下还可能有.par2冗余文件和.tmp临时文件,前者是救命用的校验块,后者是写入中断留下的残骸,两者的处理方式完全相反,第四章会细讲。
1.2 再生能力的分类表:决定你出事时先干什么
我把仓库里的东西按“坏了能不能自己造回来”做了个分类,这张表我直接贴在运维手册第一页,出事的时候照着看,能省掉大量瞎猜的时间。
| 文件类型 | 是否可重建 | 重建代价 | 出事时的第一动作 |
|---|---|---|---|
.pack对象数据 | 不可重建(源端已删就是没了) | 无价 | 立刻停止写入,评估损坏范围 |
pack-<hex>.idx | 可重建(依赖 pack 尾部表) | 中,随包大小线性增长 | 先确认 pack 完整,再重索引 |
bup.midx | 完全可重建 | 低到中,取决于包数量 | 直接删掉重跑bup midx -a |
bup.bloom | 完全可重建 | 中,需要遍历全部 pack | 直接删掉重跑bup bloom -c |
refs/heads/* | 逻辑上可重建(若知道 commit 哈希) | 极低 | 从历史记录或副本里找回哈希 |
BUP_DIR/index | 完全可重建 | 高,要重扫源文件系统 | 删掉重跑bup index -u |
.par2冗余块 | 可重建 | 高,要读全量 pack 重算 | 跑bup fsck -g补生成 |
这张表的核心结论只有一句:唯一真正不可再生的,是 pack 里的对象数据本身。其余全部属于“缓存”或“加速结构”,坏了只是性能问题,不是数据问题。理解了这一点,你在故障现场的心态会完全不一样——不会因为看到一个红色报错就慌了神,而是能冷静判断现在坏的是哪一层。
也正因为这个分层,我给这套系统的异常处理总结了三条防线,从上到下分别是:pack 层的冗余校验修复、索引层的丢弃式自愈、提交层的原子性与回滚。三条防线对应三类完全不同的故障形态,处理思路也完全不同,一条条拆。
2. 第一道防线:pack 层的冗余校验与就地修复
pack 层是唯一承载真实数据的地方,也是唯一需要“提前投资”的地方。这里的异常处理机制不是事发时才启动的,而是在平时写入完之后就悄悄布好了局——靠的就是 par2 冗余块。
2.1 par2 冗余块是怎么算出来的,占多少空间
bup fsck -g(generate)的本质是调用外部的 par2 工具,对每个 pack 文件计算一组恢复块,写到同目录的.par2文件里。它的数学基础是里德-所罗门纠删码:把原文件切成若干个等长的数据块,对每个块算出一个校验值,再通过范德蒙矩阵生成若干“恢复块”。恢复块本身不含原始内容,但只要丢失的数据块数量不超过恢复块数量,就能通过解线性方程组把缺失块反算出来。
举个具体例子。假设有一个 512MB 的 pack,-b参数指定把它切成 1024 个数据块,那么每块正好 512KB。如果冗余比例设成 10%,就会生成 102 个恢复块,理论上能修复任意位置的 102 个数据块,也就是 51MB 的散落损坏。
这里有个非常关键的取舍,很多人栽在这上面:恢复能力取决于“块的数量”,不是“字节的数量”。同样是 10% 冗余,如果块切得很大(比如 16MB 一块),那只有 32 个块,遇到一个 60MB 的连续坏区(比如某段扇区整体失效)需要 4 个块就能补;但如果遇到 100 个小文件各自坏了一点点,散落在 100 个不同的块里,32 个恢复块就不够用了。反过来,块切得小,抗散落损坏的能力强,但数据块数量膨胀会导致 par2 文件头变大、计算变慢。
我自己的经验配置是:单包大小控制在 1GB 以内,-b给到 1024 到 2048 之间,也就是单块 512KB 到 1MB,冗余比例 10%。这套参数在我们的实际介质上扛住过两次磁盘部分坏道,每次都是几十个块散落损坏,全部就地修复,没有触发整包替换。空间代价是仓库体积增加约 10%,我认为完全值得——毕竟一次整包失效意味着从源端重跑全部备份,那是按天算的成本。
实际执行时,bup fsck -g只对还没有 par2 文件的包生成冗余,所以可以安全地反复跑,不用担心重复生成。比较稳妥的做法是把它挂在备份任务后面,新增的包当天就把冗余补上,而不是等攒了半年再统一跑一遍。
# 为所有缺失冗余的包生成 par2,4 路并行 bup fsck -g -j 4 -v # 想调整冗余比例和块数,通过 --par2-options 传给底层工具 bup fsck -g -j 4 --par2-options="-r10 -b1024"注意:
--par2-options的具体可用参数取决于你系统上装的 par2 版本,动手前先跑一次par2 -h确认支持哪些开关。另外这一步是纯 CPU 密集型的,我在 8 核机器上实测吞吐大约 100MB/s,2TB 的仓库跑下来接近 6 小时,务必放在业务低谷期,别和备份任务抢 IO。
顺便说一个容易被误解的点:生成 par2 的耗时和冗余比例关系不大,和仓库总量关系极大。因为计算过程必须把全部原始数据读一遍并算出校验值,10% 和 30% 的区别只在于最后那一步矩阵运算的规模。所以千万别想着“我少设点冗余能跑快点”,省不了多少时间,只是白白降低抗损能力。
2.2 修复现场:从告警到救回数据的完整过程
bup fsck -r(repair)是我最不愿意用到、但这三年里确实用过两次的命令。它的工作流程是:先校验每个 pack 的 CRC 和数据块完整性,发现损坏后尝试调用par2 repair就地修复,修复完重新校验。
第一次遇到的场景我记得很清楚:某块盘的 SMART 报了几个重映射扇区,紧接着bup fsck开始报某个 pack 的 CRC 不匹配。当时的第一反应是“完了,这个包废了”,但跑完-r之后,日志显示repaired 37 blocks,随后校验通过。整个过程大约 20 分钟——注意,修复耗时远小于生成耗时,因为修复只需要读取损坏的那个包以及对应的 par2 文件,不需要全量扫描。
这里有一套我总结出来的处置顺序,照做基本不会出错:
- 立刻停止所有写入任务。这不是保守,是必须。bup 在写入新包时不会去动老包,但如果你同时还在跑
bup fsck -g或者别的维护脚本,可能和修复过程争夺同一批文件句柄,问题会变得难以判断。 - 记录故障包的完整文件名和哈希。这个信息后面要用它判断影响范围,也就是“这个包里到底装的是哪次备份的哪些文件”。
- 先跑一次只读校验,看清有多少个包有问题、每个包坏在什么位置。只读校验不会修改任何东西,是安全的。
- 对损坏包执行修复,一个一个来还是批量来都行,但建议先挑最小的那个包试一次,确认工具链本身没问题。
- 修复后重新校验,通过之后再做一次实际的
bup restore抽样验证。这一步很多人会跳过,但校验通过只代表块结构完整,不代表内容语义正确,抽样恢复一个已知文件是最踏实的确认方式。
# 第一步:只读校验,列出所有有问题的包 bup fsck -v 2>&1 | tee /tmp/fsck-before.log # 第二步:对有问题的包执行修复 bup fsck -r -v -j 2 2>&1 | tee /tmp/fsck-repair.log # 第三步:再次校验确认 bup fsck -v 2>&1 | tee /tmp/fsck-after.log注意:
-r会真实修改磁盘上的文件,跑之前务必确认备份仓库所在文件系统还有足够剩余空间。par2 修复的过程中会生成临时文件,最坏情况下需要预留一整个包大小的空闲空间。
还有一条逃坑经验必须说:修复前先把损坏的 pack 和对应的 par2 文件复制一份出来。听起来很冗余,但我就遇到过一次修复过程中断电,结果原包被部分重写、par2 也被消耗掉一部分,最后是靠着那份副本才救回来。这个习惯后来救过我至少两次,成本只是几十 GB 的临时空间。
2.3 兜底方案:当 par2 也救不回来的时候
必须承认一个现实:par2 不是万能的。以下几种情况它就是束手无策——损坏的数据块数量超过恢复块数量、损坏位置恰好落在 par2 文件自身的头部、或者 pack 尾部那张索引表被整段覆盖。
这时候的兜底思路是缩小损失范围,而不是追求完整恢复。具体做法是:先用bup ls逐层遍历这个包对应的分支目录,看哪些路径还能列出来;再用bup cat-file尝试读取单个对象,把还能读出来的对象先bup join导出成普通文件;最后把那些读不出来的对象的哈希整理成一份清单,拿去和源端的文件清单比对,确定到底丢了哪些文件。
这套操作听起来很土,但在实际场景里非常有用:一个 1GB 的包可能装着几千个文件的内容块,真正损坏的往往只是其中一小部分。我第二次遇到故障时,一个包里 2800 多个对象只坏了 60 多个,通过逐个导出的方式救回了 97% 的内容,剩下的从源端重新做了一次全量。相比整包作废,这个结果好太多了。
3. 第二道防线:索引层的丢弃式自愈
如果说第一道防线是“花钱买保险”,那第二道防线的思路完全相反——根本不设防,坏了就直接扔掉重建。这种设计看起来粗暴,实际上是最省心的,因为它的前提是前面说过的:索引全部是缓存,原始数据在 pack 里。
3.1 为什么索引文件坏了不用慌
先讲清楚这三层索引的关系,很多故障判断失误都是因为没搞清楚谁依赖谁。
最底层是pack-<hex>.idx,它属于单个包,记录这个包内的对象位置。往上一层是bup.midx,它把多个 idx 合并,形成跨包的总查找表。再往上是bup.bloom,布隆过滤器,回答“这个对象在不在仓库里”的概率性问题。三个文件构成了一个从精确到模糊、从慢到快的查找层级。
这里的关键认知是:midx 和 bloom 都是纯派生产物,删除它们不会丢任何数据。你甚至可以在一个正常运行的仓库上直接把bup.midx和bup.bloom删掉,系统会退化到逐个打开 idx 的慢速模式,功能完全正常。这个特性在故障排查时特别有用——遇到“读数据报奇怪的错”,第一招永远是把这两个文件挪走,看问题是不是消失了。如果消失了,说明是索引缓存过期或损坏,重建就行;如果没消失,那问题在更深的 pack 层,得上第一道防线。
我自己给这套逻辑总结了一句话:索引层的自愈策略,就是把“修”换成“删”。因为修一个二进制索引文件的成本,远高于重新生成一遍,而且修的过程本身还可能引入新的不一致。
3.2 midx 与 bloom 的重建步骤与耗时估算
重建操作本身很简单,难点在于判断该在什么时候重建,以及重建需要多久。
bup midx -a会扫描objects/pack/下所有 idx 文件,合并成一张总表。耗时主要取决于包的数量,和仓库总大小关系不大。四千个包的规模,我实测大约 2 到 4 分钟,主要瓶颈在随机读元数据。所以这个操作可以放心地放进日常维护脚本,比如每周跑一次。
bup bloom -c就重多了,它需要遍历所有 pack 里的每一个对象来填充位数组,耗时和对象总数成正比。3TB 仓库的规模,一次完整重建大约 40 分钟到 1 小时。这就不能随便跑了,得挑时间。我一般只在两种情况下重建:一是 bloom 文件本身损坏或缺失,二是仓库经历了大规模的包合并或删除(比如清理了某个已经废弃的分支)。
# 重建 midx,几乎无风险,随时可跑 bup midx -a -v # 重建 bloom,耗时较长,建议低峰期执行 # k 值取 4 到 6 比较均衡,太大哈希开销高,太小误判率上升 bup bloom -c -k 5 -v关于-k这个参数多解释一句。它是布隆过滤器的哈希函数个数,决定了同一个对象会被映射到几个位上。取值过小,位冲突概率高,误判率上升;取值过大,每次查询要算多次哈希,CPU 开销上去了。经验公式是最优值约为0.69 × (位数组长度 / 对象数量),但手工算这个没意义,直接按通用建议取 4 到 6 就行,实测差异不明显。真正的性能瓶颈在随机读 pack,不在哈希计算。
还有一点值得说:bup 支持在写入时自动维护 bloom,通过配置项开启后,每次 save 完成会自动更新,不需要事后完整重建。具体配置项在不同版本里命名略有差异,用bup config -l看当前版本支持哪些键,找到和 bloom 自动维护相关的那一项,设置成启用状态。开启之后,日常几乎不用再手动跑完整重建,省了很多事。
3.3 文件系统索引的重建:最贵但也最彻底
BUP_DIR/index是唯一一个“重建代价可能很高”的索引。它记录的是文件系统层面的扫描结果,重建过程必须重新遍历源端所有文件,读取它们的 mtime、大小、类型等元数据。
在源端是几百万个小文件、又挂在网络存储上的场景里,这一步可能要跑几个小时。所以处理它的时候策略和前面不同——不是“坏了就删”,而是先尝试从备份副本恢复。
实际做法是给BUP_DIR/index单独做一份每日快照。这个文件不大(几百万条目也就几百 MB),成本极低,但能在关键时刻省掉几个小时的完整重扫。恢复之后,只需要跑一次增量bup index -u校验一下改动项,就能接着正常备份。
如果连快照都没有,那就只能老老实实重扫。重扫的时候有个小技巧:bup index -u支持指定子目录,可以按目录分批跑,每跑完一批就落盘一次,避免中途失败前功尽弃。
# 分批重建文件系统索引,降低单次失败的影响面 bup index -u /data/part1 bup index -u /data/part2 bup index -u /data/part3注意:重建文件系统索引期间,不要同时跑
bup save。两者都会读写同一个 index 文件,虽然 bup 内部有文件锁,但锁竞争会导致 save 直接失败退出,白白浪费时间。
4. 第三道防线:提交层的原子性与中断回滚
前两道防线处理的都是“坏了之后怎么修”,这一道处理的是“坏在中间怎么办”。备份系统最怕的不是明确失败,而是写到一半断电、写到一半进程被杀,留下一个状态不明的残局。bup 在这一层的设计思路是:用临时文件加原子重命名,保证要么完整生效,要么完全没发生。
4.1 写入顺序与临时文件命名规则
搞懂 bup 一次 save 的内部顺序,是判断“中断后能不能直接重跑”的前提。整个流程大致分四步:
- 扫描与比对。根据
BUP_DIR/index里的记录,找出与上次相比发生变化的文件,只对变化的文件重新分块。这是增量的来源。 - 写对象到新包。新产生的块追加写进一个新的 pack 文件,写入过程中文件带
.tmp后缀。这一步可能持续很久,是中断最容易发生的阶段。 - 落成正式包。数据写完、尾部索引表刷盘之后,把
.tmp文件重命名为pack-<hex>.pack,同时生成同名的.idx。重命名是原子操作,这一步要么完成要么没完成,不存在中间态。 - 提交元数据。生成一个 commit 对象记录本次备份的目录树,然后把
refs/heads/<分支名>的内容更新为这个 commit 的哈希。同样是先写新文件再原子重命名。
这个顺序设计的精妙之处在于:commit 指针的更新是整个流程的最后一步。也就是说,只要分支指针还没更新,这次备份在逻辑上就等于没发生过,你可以放心地把它全部丢掉重来。反过来,如果指针已经更新,说明数据已经全部落盘,这次备份是完整的。
理解了这一点,中断处置的判断就变得非常清晰——看指针,不看文件数量。很多人看到objects/pack/下多了一堆.tmp就慌了,其实只要分支指针没动,这些残骸全都是垃圾,删掉重跑就行。
4.2 中断后的现场判断:什么该删,什么千万别动
我处理过的最典型的中断场景是:备份跑到第 40 分钟时机器重启,任务被强行打断。第二天上班一看,objects/pack/下多了三个.tmp文件,总共占用 12GB,分支指针还是前一天的。
这种状态该怎么处理?我的操作序列是这样:
第一步,确认分支指针的当前值。直接读refs/heads/<分支名>,拿到那个 40 位哈希,再用bup ls看这个 commit 对应的目录树是不是能正常遍历。能正常遍历,说明上一次备份是完整可用的,中断没有破坏它。
第二步,检查.tmp文件的大小和修改时间。如果大小一直是零或者停在某个很小的值,说明刚开写就被打断,内容毫无价值。如果大小已经到了几百 MB 甚至 GB 级别,说明写了很久,但尾部索引表大概率没有落盘,这种残包是恢复不了的——因为缺了尾部那张表,你根本不知道里面有哪些对象。
第三步,直接删掉所有.tmp文件。这一步不用犹豫。bup 的正式读路径只认.pack和.idx,.tmp文件对读操作完全透明,留着只会占用空间、扰乱后续的容量统计。
第四步,重跑一次同样的 save。因为上一次的 commit 没有提交,index 里的扫描记录可能已经更新了(取决于中断发生在第几步),所以重跑时可能会发现“没东西可备”或者“备少量东西”。这种不确定性是正常的,处理办法是加一个bup index -u强制重新比对一遍。
# 确认分支指针状态 cat "$BUP_DIR/refs/heads/nightly" # 验证该 commit 是否可正常遍历 bup ls nightly/ # 清理所有写入残骸 find "$BUP_DIR/objects/pack" -name "*.tmp" -mtime +0 -delete # 重新比对并重跑 bup index -u /data bup save -n nightly /data注意:清理
.tmp之前一定要先确认没有正在运行的 save 进程。多个备份任务并行运行时,一个任务留下的临时文件可能是另一个任务的活跃写入目标,误删会导致那个任务写出一堆无法识别的东西。用ps加文件句柄检查确认一下,比事后排查省事得多。
还有一种极端情况:.tmp文件被重命名成了正式.pack,但对应的.idx没来得及生成。这种“有包无索引”的状态会导致读该包时直接报错。处理方法不是删包(那样数据就真没了),而是重建索引——这正是第二道防线要解决的问题,也说明了三道防线不是孤立的,实际故障经常需要跨层操作。
4.3 分支指针异常的回退与比对
refs/heads/<分支名>这个文件小到只有 41 个字节,但它是整个备份的逻辑入口。它出问题的方式主要有两种:内容被改成不存在的哈希,或者文件整个消失。
第一种情况往往发生在手工操作失误或者某个异常脚本上。处理办法是去仓库里找最近一次可用的 commit。bup 没有提供像 git 那样的 reflog,但你可以通过遍历包里所有对象来反查——每个 commit 对象的内容里有时间戳和父提交信息,按时间排一下就能找到目标。
第二种情况更少见但更麻烦。如果连分支对应的 commit 对象本身也丢了(比如所在 pack 损坏),那就只能从上一个可用版本开始,重新做一次全量。这时候第一道防线的价值就体现出来了:如果那个包有 par2 冗余并且能修复,你就还能把 commit 捞回来。
实际操作中,我建议给关键分支的指针值做定期记录,比如每次备份成功后把refs/heads/*的内容追加到一个日志文件里。这个操作成本几乎为零,但能让你在指针丢失时立刻知道该恢复到哪个哈希。
# 每次备份成功后记录指针,成本极低 for f in "$BUP_DIR"/refs/heads/*; do echo "$(date -Iseconds) $(basename "$f") $(cat "$f")" done >> "$BUP_DIR/../refs-history.log"提示:这个日志文件务必放在仓库目录之外,否则它会被自己的备份任务扫进去,随着历史不断累积,白白占用仓库空间。
还有一点值得强调:分支指针指向的 commit 对象本身也在 pack 里,所以“指针完好”和“指针指向的内容完好”是两件事。判断后者需要实际跑一次bup ls加bup cat-file,光看指针文件是不够的。这个检查我一般放在每周巡检里,两分钟的事,能提前发现很多潜在问题。
5. 三类故障叠加时的实战恢复顺序
单点故障好处理,麻烦的是三种机制的问题同时出现。我在第二年就遇到过一次:一个 pack 报 CRC 错误,同时bup.midx引用了不存在的包,分支指针指向的 commit 还恰好落在那个坏包里。当时三个报错一起刷屏,很容易判断失误去做一些破坏性的操作。
5.1 从最底层开始,自下而上恢复
这种叠加故障的处理原则只有一条:严格自下而上,先修数据层,再修索引层,最后修提交层。顺序颠倒会做无用功,甚至会把能救的数据搞丢。
具体原因很好理解:索引层依赖数据层,提交层依赖索引层。你如果在数据层还没修好的时候就去重建 midx,重建过程会读到损坏的包,生成一份同样不可靠的索引;而在索引不可靠的情况下修复指针,等于把指针指向一个你验证不了的位置。
那次的实际操作顺序是这样的:
第一步,把所有备份任务停掉,给整个objects/pack目录做一次快照(我们用文件系统级快照,秒级完成)。这一步是保命符,后面无论怎么操作错了都能回退。
第二步,跑bup fsck -v拿到完整的问题清单。那次的结果是 4120 个包里坏了 3 个,其中 2 个只是 CRC 不匹配,1 个是尾部表损坏。
第三步,对那 2 个 CRC 不匹配的包跑bup fsck -r,两个都修复成功。对尾部表损坏的那个包,par2 也无能为力——因为尾部表本身就是靠 par2 恢复的,而恢复块数量不够覆盖整个尾部区域。
第四步,在确认损坏包无法修复之后,把它的.pack、.idx、.par2一起移到隔离目录。注意是移动不是删除,后面可能还要从里面捞对象。
第五步,重建索引。删掉bup.midx和bup.bloom,跑bup midx -a,再跑bup bloom -c。这一步之后,bup ls已经能正常遍历大部分分支了。
第六步,处理指针。那个指向坏包的 commit 已经不可用了,从之前的指针日志里找到上一个可用版本,把refs/heads/<分支名>改回去。
第七步,做抽样恢复验证。随机挑了 20 个路径,覆盖不同的时间点,逐个bup restore出来和源端比对哈希。
整个流程跑了大概 4 个小时,其中 3 小时花在重建 bloom 上。
5.2 恢复后的验证清单
这里要重点说验证,因为它是区分“看起来好了”和“真的好了”的唯一手段。我在那次事故之后整理了一份固定清单,每次异常处理完都照着跑一遍。
| 验证项 | 具体操作 | 判定标准 |
|---|---|---|
| 包结构完整性 | bup fsck -v | 无任何 CRC 或尾部表告警 |
| 索引可读性 | bup ls <分支>/遍历三层目录 | 无报错、条目数量与预期一致 |
| 对象可读性 | bup cat-file -p <哈希>抽样 20 个 | 全部能输出内容 |
| 内容正确性 | bup restore抽样文件后比对哈希 | 与源端一致 |
| 增量可用性 | 跑一次bup index -u加bup save | 正常完成且新增包数量合理 |
| 冗余覆盖 | 检查新生成的包是否有对应.par2 | 无遗漏 |
最后一项特别容易被忽略。故障恢复之后往往急着恢复业务,忘了给新写入的包补冗余,结果下次再出事时发现这些新数据完全没有保护。我的做法是把bup fsck -g直接串在恢复流程的最后一步,不跑完不算收工。
6. 常见问题速查与几条踩过坑的经验
三年下来攒了不少问题记录,挑高频的整理成表,剩下的用文字讲,因为有些坑不解释清楚光看结论没用。
6.1 高频问题速查表
| 现象 | 最可能的原因 | 处置动作 |
|---|---|---|
bup ls正常但读文件报 cat-file 错误 | 单个 pack 数据区损坏 | bup fsck -v定位坏包,再-r修复 |
| 所有操作都变慢,无明显报错 | midx 过期或缺失,退化为逐个开 idx | 跑bup midx -a |
| save 报“对象已存在”但恢复找不到 | bloom 过期,误判为存在 | 删除 bloom 后bup bloom -c重建 |
save 中途退出,留下.tmp文件 | 进程被杀或机器重启 | 确认无活跃任务后删除,重跑 |
| 分支指针指向不存在的 commit | 指针被误改,或对应包已损坏 | 从指针日志回退到上一可用版本 |
| fsck 报大量包 CRC 错误 | 底层存储介质问题,不是软件问题 | 先换盘,再谈修复 |
| 恢复速度极慢 | 仓库过大导致 idx 查找放大 | 建 midx,必要时开启 bloom |
关于最后一条展开说一句:仓库规模上去之后,“慢”本身就是一个可用性问题。备份系统的价值在于能恢复,如果一个 3TB 仓库恢复一个 100MB 目录要跑半小时,那在真实故障场景里是没法接受的。所以索引优化不是可选项,是必做项。
6.2 几条只有踩过才知道的经验
第一条:不要相信“备份任务成功退出”这个信号。我见过太多次任务日志全绿、但实际恢复失败的情况。原因五花八门——写到一半磁盘满了但错误被吞掉、包写完了但 idx 没落盘、指针更新了但 commit 对象不完整。唯一可信的信号是定期的恢复演练。我现在固定每季度抽一个分支做完整恢复,恢复出来的数据跑一遍校验,这个习惯帮我提前发现过两次隐患。
第二条:冗余要跟着数据一起长,不能一次配完就不管。早期我的做法是初始化时对所有包跑一次bup fsck -g,后来新数据不断进来,新包根本没有 par2。直到某次抽查才发现,最近半年的数据完全裸奔。正确做法是把它做成增量任务,每次 save 之后扫一遍看有没有缺冗余的包。
第三条:损坏的包不要急着删。哪怕 fsck 说它彻底没救了,也先移到隔离目录放一段时间。我救回来的数据里,至少有两成是从“已判死刑”的包里,通过逐个对象导出的方式捞出来的。删掉就真没了,留着只占点空间。
第四条:记录比记忆可靠。分支指针、每次备份的包清单、每次 fsck 的结果,全部落成日志。故障现场人是慌的,靠回忆判断“上次是不是也这样”极不可靠。有了日志,很多判断可以在几分钟内做完。
第五条:先确认存储介质,再怀疑软件。我遇到的 pack 损坏里,超过八成最终都追溯到磁盘或阵列的问题。遇到成批的 CRC 错误,第一反应应该是查 SMART、查阵列日志,而不是去翻 bup 的源码。这个顺序搞反了会浪费大量时间。
第六条:fsck 要定期跑,但别在业务高峰期跑。全量校验是纯读操作,看起来无害,实际上会把存储的读缓存全部冲掉,导致同时在跑的业务出现明显的 IO 抖动。我把它固定在凌晨的低峰窗口,而且加了nice和ionice压低优先级。
关于最后这条,补一个具体的调度技巧:bup fsck支持-j并行,但并行度不是越高越好。在机械盘阵列上,超过 4 路并行反而会让磁头来回寻道,总吞吐下降。我在 SSD 上给到 8 路,在机械盘上只用 2 到 4 路,实测差距能到一倍以上。这个参数没有通用最优值,得在自己的存储上实测一遍才能定。
最后分享一个我最近才加上去的小习惯:给每个 pack 文件记一份“出生档案”,也就是它由哪次备份、什么时间、从哪个源目录生成的。这份档案不进仓库,单独放一个小数据库里。用处是在包损坏时能立刻知道“这个包对应的是哪台机器的哪个时段”,从而判断影响范围、决定是从冗余修复还是直接从源端重跑。以前没有这份档案的时候,每次都要靠文件修改时间反推,经常推错。这个成本几乎为零的附加记录,在真实故障里省下的排查时间是以小时计的。