我试过不少备份方案。rsync、tar、restic、borgbackup轮着用,每个都能干,但总差一点意思——不是配置重,就是概念多,要么恢复的时候还得先复习一遍文档。后来我干脆自己写了一个极简工具,名字就叫caveman。起这个名字没别的意思,就是想表达“原始、直接、不绕弯”:它不做加密、不做网络同步、不依赖数据库,只有一个本地目录,几条命令,两个小时之内能跑通全部逻辑。
说白了,我的需求其实特别朴素——手头几台开发机、几个项目目录,偶尔手滑删了文件,能把它捞回来就行。如果你也和我一样,受够了给一个“每日备份”的需求维护一套复杂的备份系统,那caveman这种“内容寻址 + 硬链接”的极简快照思路,很可能会让你重新找回掌控感。这篇文章把我从最初构思、核心原理、命令设计,到使用中踩进去的几层坑全部分享出来,里面所有代码和目录结构都是我在真实机器上跑过的,你可以直接照着搭一个自己的版本。
1. 为什么我会写一个叫“caveman”的备份工具
1.1 我的需求其实非常简单
在决定自己造轮子之前,我先把自己真正的需求列了个清单,发现它远比我想象中简单:
- 我想给
~/code、~/docs这类目录每天留一份快照,保留最近一个月,误删的时候能打开快照把文件捞回来。 - 快照要能直观浏览,我不想为了恢复一个文件先启动某个服务,也不想记一串仓库地址和口令。
- 增量备份要省空间,因为每天全量复制实在是太浪费磁盘了,尤其是我那些代码仓库,前后两天改动往往只有几个文件。
- 坚决不引入要常驻后台的服务,最好连安装都只是拉一份代码。
需求列到这里,我意识到一件尴尬的事:市面上做得最好的备份工具,对我这种需求来说都“超纲”了。它们不是不好,而是它们的安全模型、远程同步模型、加密体系都很完善,代价是概念层比别人厚。我只有一台本机、一个目录、一个“每天留个影”的诉求,却要为chunk、仓库密码、缓存策略这些概念买单。这种感觉就像为了让冰箱里的牛奶不坏,专门去买了一套完整的冷链物流系统。
1.2 现成工具的复杂度评估
我把自己用过的工具按“概念复杂度”和“恢复成本”摆了一张表,越看越觉得我的需求其实落在现有工具最不重视的角落里:
| 工具 | 历史版本 | 去重方式 | 概念复杂度 | 恢复成本 | 依赖情况 |
|---|---|---|---|---|---|
| rsync + link-dest | 手动维护链 | 硬链接 | 中 | 中等,要理清目录链关系 | 单二进制 |
| tar 全量 + 增量包 | 全量/差分包 | 无 | 低 | 较高,要按序解包 | 单二进制 |
| restic | 快照 | 内容分块去重 | 高 | 低,但要恢复密钥 | Go二进制 |
| borgbackup | 快照 | 分块去重 + 压缩 | 高 | 低,但要恢复密钥 | C依赖 |
| caveman | 快照 | 文件级硬链接去重 | 低 | 极低,直接是普通目录 | Python标准库 |
注意,我说的是“恢复成本”而不是“部署成本”。restic和borg的恢复成本其实很低,但前提是你把手册读透了,并且记住了仓库密码。对于个人开发机场景,我经常半年才恢复一次,半年后我根本记不住自己当时设了什么参数、用了哪个仓库、密码是什么。而“快照就是一个普通目录”这种设计,哪怕我三年后再打开,也不会忘记怎么用。
这就是caveman诞生的直接原因:我想要一个恢复成本为零的备份工具——不需要专门软件,不需要密码,不需要仓库名,用ls就能看见历史,用cp就能找回文件。我开始盘算,内心深处那个“穴居人”式的诉求最朴素,却也最被现代工具忽略。
2. caveman的核心设计:内容寻址加硬链接
2.1 一次快照本质上就是一个普通目录
我把整个存储设计成两部分,结构非常简单:
~/.caveman/ ├── objects/ │ ├── 3f/ │ │ └── 3f9a9a1b... (内容文件,按 BLAKE2 哈希前两位分桶) │ └── b1/ │ └── b1d5a5c7... └── snapshots/ ├── 2024-01-15T10-00-00/ │ └── code/ │ ├── README.md (硬链接到 objects 里的内容文件) │ └── src/ └── 2024-01-16T10-00-00/ └── code/snapshots/2024-01-15T10-00-00这个目录,就是我在2024年1月15日上午10点那一刻给源目录留下的完整副本。它看起来、用起来都和普通文件目录一模一样,但目录里的文件并不是复制出来的实体,而是指向objects目录的硬链接。
每次做快照,我会在snapshots下新建一个带时间戳的目录,把当时源目录里每个文件对应的硬链接都组织进去。这样历史版本天然是“按时间拍的快照”,而不是“一堆差异补丁”。你想看一周前的src/main.py长什么样,直接打开那个时间戳目录,一路找到对应路径即可,所有编辑器、命令行工具都能直接读取。
2.2 内容寻址让重复内容只落一份
真正让“每天快照但不爆磁盘”的机制,是objects目录。这个目录里的文件名不是项目里的文件名,而是文件内容的哈希值。也就是说,两个不同目录里如果存在内容完全相同的文件,它们在objects里只会有一份实体,不同的快照目录通过硬链接指向同一个对象。
这套思路和Git的对象库很像,但是去掉了索引、提交、分支这些概念。Git的哈希作用在“提交树”这个层级上,而caveman的哈希作用在“单个文件”上。对备份场景来说,这个粒度已经足够:同一个包管理器缓存文件、同一张图片素材、同一段公共代码,只要内容出现过一次,以后所有快照都不会为它重复占用磁盘空间。
需要强调的是,文件级去重的粒度比restic、borg的分块去重要“粗”很多。文件内容哪怕只改了一个字节,caveman也会把它当作新对象完整存一份。但我的目标是源码、文档、配置这些以文本为主的数据,单个文件通常只有几KB到几百KB,每次真正改动的文件数量也很有限,所以这个粒度造成的浪费可以接受。我后来在几千个文件、累计几个GB的目录上跑过,第一次快照占满空间后,之后每天的增量通常只有几MB到几十MB,完全在我的预期内。
2.3 快照生成的核心代码逻辑
caveman最早的原型只有几十行Python,用纯标准库实现,核心逻辑浓缩成下面这一段:
import hashlib import os import shutil from pathlib import Path def file_digest(path: Path, chunk_size=1 << 20) -> str: h = hashlib.blake2b(digest_size=32) with open(path, "rb") as f: for chunk in iter(lambda: f.read(chunk_size), b""): h.update(chunk) return h.hexdigest() def snapshot(src: Path, snap_dir: Path, objects_dir: Path): snap_dir.mkdir(parents=True, exist_ok=False) for root, _, files in os.walk(src): for name in files: full = Path(root) / name rel = full.relative_to(src) digest = file_digest(full) obj = objects_dir / digest[:2] / digest[2:] if not obj.exists(): obj.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(full, obj) target = snap_dir / rel target.parent.mkdir(parents=True, exist_ok=True) os.link(obj, target)在这版教学代码里我做了一些简化:每次备份都会对文件做全量哈希计算,对于大目录还不够高效。真实版本里我会在快照索引中缓存每个文件的mtime、size、inode,只有当这三个值有变化时才对文件重新计算哈希。这一层优化让后续快照在没有文件改动时,往往一两秒就能跑完。
有个设计细节值得多说一句:我用了BLAKE2b而不是MD5或SHA-1。Python标准库的hashlib.blake2b性能很好,输出长度可以自由选择,我固定成32字节(256位),在这个碰撞概率下基本可以认为“哈希一致即内容一致”。为了更稳妥,我还在真实版本的对象文件名里加了文件长度前缀,比如3f9a..._4096,这样即使哈希出现理论上的碰撞,长度字段也能拦截绝大多数误判。
2.4 为什么硬链接而不是复制
我一开始也纠结过这个问题:为什么不直接把文件复制进快照目录?
如果直接复制,每次快照都会产生一份完整的实体副本,占用空间和全量备份没有区别,我的“每天快照”设想立刻破产。而如果只保存元数据不保存内容,恢复时又太麻烦。硬链接恰好卡在中间:创建快照时几乎不消耗空间,每个文件实体在磁盘上只有一份数据;同时快照目录本身却是一个完整、自洽、可浏览的目录树,不需要任何专用程序就能读取。
打个比方:硬链接相当于给同一个文件实体多起了几个“门牌号”,所有门牌号指向同一个房子。你从任意一个门牌号进去,看到的都是同一套房子的内容;只要至少还有一个门牌号存在,房子就不会被拆掉。这套机制是文件系统原生提供的,不需要后台服务、不需要额外索引,是我能找到的最符合“穴居人哲学”的技术。
3. 命令行接口与数据布局:把想法变成能用的工具
3.1 三条命令撑起所有日常操作
设计命令接口的时候,我给自己定了一条规矩:任何一个新命令,如果我在30天后还需要翻文档才能用出来,那这个命令就不配存在。最终caveman只保留了下面几个操作:
caveman snapshot <src> [--tag 名字] # 创建一份新快照 caveman list [--limit 30] # 列出已有快照 caveman restore <快照名> <src> <dest> # 从快照恢复 caveman prune --keep 30 # 清理旧快照,保留最近30份一次典型的备份会话长这样:
$ caveman snapshot ~/code --tag before-refactor created: 2024-01-15T10-00-00 (before-refactor) $ caveman list 2024-01-15T10-00-00 before-refactor 2024-01-14T10-00-00 daily 2024-01-13T10-00-00 daily $ caveman restore 2024-01-15T10-00-00 ~/code /tmp/code-recovery restored to /tmp/code-recovery--tag参数纯粹是为人类记忆服务的别名。工作目录里换一次分支、重构一遍模块,我给对应的快照打一个before-refactor的标签,比搜时间戳直观得多。标签的实现也很土:在快照目录下放一个caveman.tag小文件,里面写一行文本。没有数据库,没有超链接,就是一个文件。
3.2 数据目录布局与权限约定
存放在哪个位置,我纠结过一阵子。最终我采用了更贴近系统惯例的路径,把数据目录放在~/.local/share/caveman,而不是直接在~/.caveman下面堆一堆东西。这样做的考虑是:XDG目录规范会把“应用数据”和“随机配置”分开,之后如果我想把数据目录单独迁移到另一块硬盘,也只需要移动一个文件夹。
在权限上我做了两个约定:
objects目录在创建后整体设为只读,避免任何应用进程误写。- 快照目录同样设为只读,想要修改快照内容必须先复制出来。
这两个约定看着简单,却是后面我避开大坑的关键。备份数据最忌讳的就是“被当成普通数据顺手改了”。只读权限把犯错成本抬高了一截,每次你试图修改快照时,文件系统都会先问一句“你确定吗?”
3.3 和 cron 结合实现定时快照
我自己的机器上,定时任务就一行cron,每天凌晨1点跑一次snapshot:
0 1 * * * caveman snapshot ~/code && caveman snapshot ~/docs这里我没有用systemd timer,也没有上容器编排,理由和整个项目保持一致:cron已经能做到运行一条命令然后静默退出,足够满足需求。退一万步说,就算哪天cron配置出了问题,我顶多少了一份当天的快照,第二天看到列表少了一行也能立刻发现,风险可控。
3.4 restore 的实现细节
恢复命令的逻辑在代码上非常薄:因为快照本身就是普通目录,真正恢复的动作就是一次目录复制。实现上我用的是:
cp -a "$SNAP/$SRC_REL/." "$DEST/"cp -a会保留权限、时间戳、符号链接,并且把硬链接关系也尽量还原。由于快照里的文件本来就是通过硬链接组织的,这个复制行为在语义上恰好正确——还原到DEST的是一个物理上独立的文件副本,不再依赖objects。也就是说,恢复出去的目录和caveman没有半点关系,你把~/.local/share/caveman整个删了,恢复出去的副本依然完好。
我在恢复前还会检查一个细节:源路径参数必须和快照创建时一致或能找到对应相对路径,这样就不会出现“把A项目的文件恢复到B项目的目录”这种低级错误。
4. 踩坑实录:硬链接、时间戳和误删对象的意外之战
4.1 跨文件系统:硬链接不是哪里都灵
第一次把caveman推上“生产”就翻车了。我那时候想,数据目录放系统盘上占空间,干脆挂到外置SSD上。结果第一次执行snapshot就报错:
OSError: [Errno 18] Invalid cross-device link我盯着这条报错想了半天才反应过来:硬链接不能跨文件系统创建。源目录/home/me/code在系统盘上,而objects目录在外置SSD上,两者不是同一个文件系统,os.link()直接拒绝执行。
排查过程也不复杂,用df -T看了一下就明白了两边的挂载点不同。这事的教训是:caveman的数据目录必须和你要备份的源目录位于同一个文件系统,硬链接才有意义。
后来我把逻辑改成自适应:先比较os.stat(src).st_dev和os.stat(objects_dir).st_dev,如果设备号不同就自动降级为复制模式,每个快照直接复制文件实体。这样做数据还是安全的,只是失去了去重能力,磁盘占用会线性增长。所以在caveman的文档里我写了一条最显眼的提示:数据目录和源目录尽量放同一块盘。这个提醒是我自己在疼痛中总结出来的第一条经验。
4.2 共享 inode 的隐忧:一次 chmod 引发的连锁反应
另一个让我记忆深刻的坑,和硬链接共享同一inode的特性有关。
有一天我忽然发现,源目录里所有文件的权限都变成了700。我从不记得自己主动改过整个目录的权限,于是开始往回翻操作记录。结果发现,前一天晚上我为了省空间,对某个旧快照目录执行了chmod -R 700 snapshots/2024-01-15。这个操作本意是“减少某些目录的可读范围”,按理说只影响快照目录才对,但硬链接的权限和其他元数据一样,存储在inode上,而inode是被源文件和快照文件共享的。我改一次权限,等于同时改了所有指向该inode的路径的权限。
排查链路是这样的:
- 先用
ls -li对比源文件和快照文件的inode编号,确认它们指向同一个inode。 - 用
stat -c %h查看链接计数,发现一个inode有多个路径引用。 - 确认chmod是元数据修改,元数据属于inode,不属于目录项。
这种问题的危害不在于权限本身,而在于你根本意识不到它会发生。你本意是保护某个旧快照,结果连最新数据的行为也一起改了,而且改完过了很久才被察觉。从那以后,我给自己立了一条规矩:任何涉及修改快照目录的操作,都必须先复制出来,在副本上做;原始快照当作只读档案对待。这也是为什么caveman在创建快照时会顺手把权限改成只读——它用文件系统的能力强制约束了人的手误。
4.3 只看 mtime 会漏掉“内容变了但时间没变”的文件
为了让增量快照跑得更快,我在早期版本里做过一个优化:只有文件的mtime或size变了,才重新计算哈希。正常使用下这个策略完全没有问题,但有一次tab补全时手滑,对一个目录跑了touch -r 某旧文件 某新文件,把某个文件的时间戳重置成了几天前的样子。结果下一次快照时,mtime和size看起来都没变,caveman就跳过哈希计算,继续把旧的objects硬链接放进快照,导致快照里存的是“内容已经变了、但时间戳被伪装成没变”的文件。
这个问题比较刁钻,但它暴露了一个本质:mtime只是一个提示,不是内容摘要。任何基于“元数据没变就认为内容没变”的增量策略,本质上都在赌没人会刻意伪装时间戳。为了兼顾速度和正确性,最终方案是这样:
- 索引文件记录每个路径对应的
mtime、size、inode三元组。 - 三元组任何一个发生变化,就全量重算哈希。
- 提供
caveman snapshot --force-hash开关,强制忽略三元组缓存,对所有文件做完整哈希。
为什么把inode也加入比较?因为很多程序保存文件时是“写临时文件再rename”,这样的操作会改变inode,即使mtime和size被拷贝成原值,inode的变化也能暴露文件其实已经被替换过。上线这个三元组策略后,我分别在真实项目目录里做了几次故意篡改实验,都没有再出现过漏备份。
4.4 快照过程中源文件变动造成的不一致
还有一个坑来自运行时一致性,而且它不能靠改进文件遍历逻辑解决。
我在给一个带本地数据库的应用目录做快照时,发现快照目录里出现了“一半是旧文件、一半是新文件”的混乱状态。原因很直接:snapshot遍历目录需要时间,而备份期间源目录里的数据库文件还在持续写入,导致遍历到某个文件时它可能处于中间状态,或者同一个目录树的先后顺序里包含了不同时间点的内容。
严格来说,这不算bug,而是文件系统快照的宿命——caveman只是遍历时做了“瞬时”记录,并没有真正冻结磁盘状态。我的解决方式是在caveman里增加了一个--pre-cmd钩子,允许在备份前执行任意命令:
caveman snapshot ~/code --pre-cmd "pg_dump -U me mydb > /tmp/mydb.sql"备份数据库类目录时,通过这个钩子先做逻辑导出,再备份导出的文件,就能拿到一份一致的数据。对普通的源码目录,这个钩子基本用不上,但它的存在提醒了我:一个备份工具,无论写得多优雅,都不能替代应用层对“什么才是一致状态”的判断。
4.5 误删 objects:内容寻址仓库不能人肉清理
最后这个坑,是我自己给自己挖的,差点把整个备份库毁掉。
事情起因是我总觉得objects目录里堆了很多“没用的旧哈希文件”,于是打算手工删一删。我当时的思路是:旧快照已经用prune删掉了,那objects里这些曾经被旧快照引用的对象应该也没用了。于是我写了一段清理脚本,把那些不在“当前快照列表”里出现的对象文件全删了。
结果跑完我才发现,最新快照的一批文件变成了断链。排查之后我明白了问题所在:判断一个对象是否该删,不能看“它是否还在任一快照的引用集合里”,而要看“它当前还有没有硬链接指向它”。一个对象文件可能在旧快照和最新快照中被同时引用,我删掉了旧快照,但最新快照依然在用它。我的错误是统计引用来源时只统计了未过期的快照,遗漏了“对象并非只属于旧快照”这一情况。
正确的prune逻辑其实非常简单,两步走:
- 先删除过期快照目录,此时所有过期快照里指向对象的硬链接会被系统自动解除。
- 再统计剩余所有有效快照中引用到的对象哈希集合,删除不在集合里的对象。
统计方式甚至不需要维护引用计数,因为硬链接数本身就告诉我们答案:被任意快照引用的对象,其链接数一定大于1(至少还有objects目录里这一条加上快照里的链接),而真正的孤儿的链接数等于1。用find ~/.local/share/caveman/objects -type f -links 1就能列出一批候选人,再人工确认后清理。
这段经历给我最大的教训是:内容寻址存储的正确清理只能基于“引用计数”或“全量引用集合”,绝不能靠“我猜这个没人用了”的直觉。从那以后,caveman的prune命令内置了两阶段清理逻辑,并且把第一阶段(删快照)和第二阶段(清对象)分开执行,让我在删除前有一次肉眼确认的机会。
5. 从“caveman”里得到的最大收获:极简工具的正确打开方式
5.1 什么时候该写一个自己的极简工具
写完caveman之后,我在很多场合被问到同一个问题:你为什么不用现成的?这种问题很合理,但我现在能给出更明确的判断标准——在下面这些条件下,自己写一个极简工具不是重复造轮子,而是真正合理的决策:
- 需求边界极其清晰,你只需要一个“列表里的一个元素”而不是一整套功能矩阵。
- 使用范围完全可控,只跑自己的机器,不需要考虑跨平台分发、团队协作、权限审批。
- 数据规模可预期,文件数量和总体积都在你能拍着胸脯说“就算全量存一份也亏不了多少”的范围内。
- 你非常清楚数据的生命周期,删什么、留什么、什么时候清理,都由你来定。
反过来,如果需要加密、需要远程灾备、需要多人共享、需要合规审计,这些场景里我还是老老实实推荐restic和borgbackup。我自己也没有因为写了caveman就扔掉restic——恰恰相反,在公司或者涉及重要数据的机器上,我用的依然是成熟工具。这没什么好羞耻的,一个工具解决一类问题,本来就是工程上最朴素的原则。
5.2 内容寻址加硬链接这套思路的迁移价值
caveman体积很小,但它教会我的内容寻址加硬链接这套范式,在很多完全不相关的地方都派上了用场。
- 笔记系统:我自己维护一个纯Markdown笔记目录,同样每天做快照,历史版本随手可查,草稿写坏了直接回滚昨晚版本。
- 图片素材库:按内容哈希对素材去重,相同图片只存一份,批量导入时空间占用降了一大截。
- 软件包缓存:类似地,用哈希做文件名,天然支持多版本共存和重复数据剔除。
这套范式的核心其实只有一句话:用内容本身作为寻址的钥匙,用文件系统的硬链接作为多路径引用的零成本通道。理解了这句话,你就不会再为“怎么给一堆文件做版本历史”发愁——先用哈希做对象存储,再用硬链接组织快照目录,剩下的事都只是业务逻辑。
5.3 最后的个人体会
写一个叫“穴居人”的工具,这件事本身有点自嘲的意味:在我塞满了tmux、zsh、各种终端增强工具的开发环境里,一个连加密都没有的备份工具居然活得挺好。但它给了我一个很实际的价值——每次误删文件后,两三秒内就能找回,不需要念密码、不需要等仓库解锁、不需要背命令参数。这种“原始”的确定性,比我用过的大部分花哨工具都让人觉得踏实。
后来我又陆续给caveman加了一些小功能,比如快照差异对比、按标签过滤列表,但核心的目录结构和两条核心命令始终没有变。它现在完整地躺在我的GitHub上,注释写得很全,如果有人也想给自己那台开发机做个“穴居人”式的快照工具,拿去改改就能用。毕竟,这世界上的备份方案再复杂,落到最终诉求上,也不过就是:我需要的时候,能立刻回到昨天。