上个月整理硬盘时,我翻出了五个不同日期的同款论文存档,分别是“终稿”“真终稿”“再改就不活了”。那一刻说不上多崩溃,但确实让我认识到:我的所谓备份系统,基本靠 Ctrl+C、Ctrl+V 和手气。我需要的不是再多的网盘,而是一个本地、能回到过去任意时间点、并且可以随时拉起来的关键文件快照工具。于是我用两个晚上写了点小东西,取名“caveman”——穴居人。思路很原始:每次备份都是把当天的文件“扔进洞穴”,然后在石板上刻一行记录。但这个小工具的运行效果,比我花里胡哨的云同步方案稳定得多。这篇文章从头到尾说清楚 caveman 做了什么、怎么用、我踩过的几个坑,以及怎么把一个极简备份工具变得足够可靠。对社群里的开发者、运维,或者只是被文件丢失吓怕了的普通用户,都有参考价值。
1. 为什么我会给自己写一个叫“caveman”的小工具
1.1 痛点:网盘、U 盘和 rsync 都差点意思
先说场景。我的日常产出集中在两类目录:一类是写作文档和笔记,小文件居多但改动频繁;另一类是家目录下的代码工程,node_modules 和构建产物动辄几个 GB,实在不适合整盘同步。网盘的问题是“逻辑删除容易救不回来”,而且同步容易把远端的一个旧版本当成最新版本拉回来,把本地文档直接覆盖成旧稿,U 盘就不用提了,物理老化加上忘记弹出,遇到过两次目录变成乱码,心都在滴血。
rsync 倒是很多人的答案,但 rsync 更适合做镜像,不是做历史快照。你要恢复三天前被删除的那个文件?rsync 就没有这种余量。我真正需要的,是一种介于“完整 Git 仓库”和“手动复制粘贴”之间的东西:不需要提交时写 Commit Message,不需要管理分支合并,也不需要理解 index/stage 的概念,只要我一句话,它就把当前目录的整体状态封存下来,并且保留这一包数据,让我之后能精确地回到任何一个备份点。
caveman 就为这个需求而生。
1.2 起名的逻辑:极简得像穴居人
为什么叫 caveman?因为我设想的功能模型是这样的:我(一个现代人)抱着一堆文件走到洞穴口,扔进去,石壁上多一笔标记;过几天需要旧文件了,就钻进去翻,翻到了拿出来用。没有加密也没关系——洞穴就是一块不会联网的、只属于我的地方。
这名字也时刻提醒我控制边界。我不要权限精细的 ACL,不要复杂的插件系统,也不做增量备份的时候引入一堆缓存状态。从第一步开始,caveman 的所有核心行为就是三件事:
- 读取一个目录,生成一份完整的快照;
- 把快照里面的对象文件写入仓库;
- 在日志里追加一条记录,指向该快照。
听起来像是一个“退了版本号的 tar”,对吧?但正是这种原始、朴素的模型,让我在用了半年之后仍然愿意维护它。代码少,出问题的面就小。
1.3 定位边界:不是 Git 的平替
这里必须说清楚,caveman 不是要替代 Git。Git 对文本文件、代码协作、逐行 diff 这些场景极其强大,但如果你只是一个普通创作者,或者运维想对某个重要的/etc/目录做轻量级回滚,Git 的知识负担就偏重了。你要处理.gitignore、暂存区、分支、合并且不说,还要小心密钥别跟着进仓库。
caveman 的定位是“时间机器,而不是版本控制”。它保存的是文件级快照,也就是某个时间点整棵目录树的完整形态。因为采用内容寻址存储,重复的文件内容只存一份,所以并不会因为做了几十次完整快照就把磁盘塞满。对文档类目录来说,这个成本完全可以接受。
2. 洞穴不是文件夹:caveman 的仓库结构和数据流向
2.1 仓库里到底有什么
我用 caveman 做的第一件事,就是确定仓库布局。它不复杂,但你最好理解它,否则出问题的时候容易慌:
cave/ ├── objects/ # 所有文件内容的压缩对象 │ ├── a1b2c3d4... # 内容哈希即文件名 │ └── f6e5d4c3... ├── refs/ # 每次快照的“石板记录” │ ├── 20241105023820.json │ └── HEAD # 指向最近一次快照 └── config.toml # 忽略规则、压缩选项、保留策略“objects” 目录是一堆二进制文件,每个文件的内容是对原始文件数据进行压缩后的结果,文件名是这段数据的 SHA-256 哈希。这样设计有个天然的好处:如果两个不同路径里放着相同内容的文件,它们在 objects 里不会占两份空间,因为写入对象时先计算哈希,检查仓库里已经有同名对象就直接复用,不再重复存储。
2.2 为什么用内容哈希而不是路径名
刚开始我还想过另一种方案:把每次备份的文件路径原样建目录结构,复制进去,再用时间戳区分。用了一天我就放弃了。最大的问题是重命名和移动:一个文件这次在docs/a.md,下次挪到notes/a.md,那在两个备份里就是两份冗余副本,存储翻倍,而且很难判断它到底是什么时候变的。
内容寻址把“文件内容”和“文件路径”彻底拆开。文件中担心的“路径乱掉”“文件名乱改”,在这个模型里都变成了“某个目录树记录了路径与对象哈希的映射关系”。所以就算你把文件从根目录挪到子目录,它引用的还是同一个对象,只是新快照里的路径绑定换了。恢复的时候,caveman 直接按照最新快照里的映射关系把对象解出来放回对应路径就行,很好用。
2.3 快照记录不是数据库,只是一张追加表
refs 目录下的每个 JSON 文件,本质上就是一次备份动作的元数据。比如这样:
{ "ts": "2024-11-05T02:38:20Z", "root": "/home/user/projects/my_app", "tree": "8f6e1c2b9a3d4f5e6a7b8c9d0e1f2a3b4c5d6e7f8g9h0a1b2c3d4e5f6a7b8c9d", "msg": "before major upgrade" }tree是整个目录树按照自定义路径排序、逐文件名和对象哈希拼成后整体计算的根哈希。相当于将这一棵文件树压缩成一个指纹。只要任何文件内容或路径发生变化,根哈希就会改变。追加 JSON 文件到 refs 目录时,用“先写临时文件、再改名”的原子操作完成,所以就算中途断电,也不会留下半截记录。
之所以不用数据库,是因为这种文本文件格式对故障更友好。如果哪天refs/目录坏了,你还能凭文件名里的时间戳、肉眼扫 JSON 内容判断最后一次快照在哪;换成 SQLite 的话,我这种半吊子心态的人反而更没底气。
3. 把一块石头放进洞里:初始化、备份、恢复的一次完整演练
3.1 初始化一个仓库
假设我现在要把/home/ubuntu/docs纳入管理。我先建一个存放快照的独立目录,名字我习惯叫cave:
caveman init /home/ubuntu/cave注意,caveman 要求仓库目录和待备份目录分离,这么做是有意为之。如果仓库放在待备份目录内部,扫描数据时就会把仓库自身的 objects 也扫进来,导致每次备份都会膨胀一轮。这个坑我后面专门讲,这里先在结构上避免。
初始化之后会生成config.toml,默认内容类似:
compression = "zstd" follow_symlinks = false keep = 30 ignores = []compression我默认用 zstd,因为它压缩率不输 gzip,但速度稳定得多,文档目录几百 MB 备份起来也就几秒钟。follow_symlinks = false是我踩坑之后加上的默认项,后面会细说。
3.2 做一次快照
用法非常简单:
caveman add /home/ubuntu/docs --name "before-holiday"执行时 caveman 会完成这几步:
- 扫描整个
/home/ubuntu/docs,按路径建立文件清单; - 依次读取每个文件,压缩进
objects/,以内容哈希命名; - 生成目录树哈希;
- 在
refs/下追加一条快照记录; - 更新
HEAD。
跑完输出大概长这样:
[info] 1024 files, 384 MiB scanned [info] deduplicated 214 files, saved 96 MiB [info] snapshot 8f6e1c2b... stored [info] HEAD -> 8f6e1c2b...对熟悉 git 的读者来说,“deduplicated” 的意思就是这次没有重复写入已经存在的对象。对普通用户来说,最直观的感知是:同一个文件如果改了几个字,它会产生一个新的对象,但原始对象还会留下这份;如果文件一模一样,则不会多占空间。
3.3 从历史里恢复文件
最早我设计恢复命令时,故意做成类似“复制并摊开”的模式,避免覆盖你当前目录:可以指定一个目标目录,把快照里的文件全部铺到那里去。
caveman restore 8f6e1c2b /tmp/restore-test如果不记得完整哈希,caveman log会列出历史记录:
caveman log --limit 10输出类似:
2024-11-05T02:38:20Z 8f6e1c2b "before-holiday" 2024-10-28T09:12:03Z a1b2c3d4 "monthly-check" 2024-10-15T17:05:44Z c9d0e1f2 "before-rewrite"看到感兴趣的记录后,我再把那个哈希传给restore。恢复时 caveman 会根据目标快照的目录树,把对象逐个解压到指定目录,恢复目录结构时保持原有权限。我在测试中发现,大文件恢复速度要比tar -xzf快不少,因为对象读取和压缩是单线程流水线,没有过多上下文切换。
3.4 对比两次快照,弄清楚谁动了我的文件
我比较怀念的一个功能是caveman diff。它不会像 Git 那样做逐行文本对比,而是输出“哪些文件在两次快照之间发生了增删改”。这就够我用:
caveman diff 8f6e1c2b a1b2c3d4 --name-only常用场景是版本升级前做一个 baseline(基线快照),升级后做第二个,然后看前后差异到底涉及哪些文件。比如有一次我把一个博客主题升级完,半小时后怀疑某个样式坏了,跑一条 diff 命令,几秒钟就锁定是哪个 CSS 文件变了,根本不用去翻浏览器缓存。
3.5 验证完整性,别等灾难来了才后悔
我设计了一个自检命令:
caveman verifyverify会遍历objects/下所有对象,重新计算哈希并和文件名比对,然后把所有快照记录的tree哈希重新计算一遍,确认记录没有损坏。这个命令很慢,尤其是快照多的时候,但我会在每个月或者重大事件之后手动跑一次,这相当于给“洞穴”做一次健康体检。
4. 运行了一年多之后,我踩过的那些真实坑
4.1 备份了自己:递归扫描把仓库也卷了进去
这个问题出现在一个早期队友的配置里。他把仓库目录直接初始化成/home/user/project/.caveman,然后备份目录配置成/home/user/project。第一次备份一切正常,第二次开始,objects里就多出了“第一次备份产生的对象文件”,第三次又会把第二次产生的对象文件也包含进去。这样每跑一次,仓库体积就成倍增长,不夸张,几次之后就能看到磁盘 IO 报警。
解法有两个,要么严格规定仓库目录放在备份范围之外,要么在ignores里把仓库目录排除。我喜欢前者,一劳永逸。在命令行工具里强行提醒,只要检测到仓库目录在待备份目录内部,就直接拒绝执行并提示“caveman refuses to back up its own cave”。
4.2 符号链接把自己不想要的秘密带进了备份
刚发布第一版时,我默认是会跟随符号链接的。当时觉得用户选了符号链接,就应该把它指向的真实文件也带走。结果有次我想备份~/dotfiles,这个目录里有一堆.zshrc和.config,我明显不想备份的那个位置是指向/home全量的符号链接,跟着链接一路走,启动任务后系统突然开始疯狂读盘,才发现它把整个家目录都吞了,包括我的 Git 仓库、下载文件夹和一堆密码管理器缓存。
我对这个事故记忆深刻。现在默认follow_symlinks = false,遇到符号链接时快照只记录链接本身信息,而不是目标文件。也许有人需要跟随链接的备份,所以我留了配置项,但要手动打开并确认。如果你是从旧版本升级上来的,建议立刻检查配置里follow_symlinks是不是 true,是的话赶紧改回来。
4.3 备份到一半断电,会留下一个“半块石头”
有人可能会说,既然是复制文件,为什么怕断电?因为对象写入不存在“一个文件写完再改名”的原子逻辑时,如果正写到一半断电,objects/下就会残留一个不完整、文件名和内容不匹配的坏对象。下次运行verify时它会报错,如果那个坏对象被某个快照引用了,你还得手工删掉快照记录,麻烦得要命。
所以现在的实现里,我每次都先把压缩对象写到objects/.tmp-xxxx,随后再rename成正式对象文件名。rename在同一文件系统内是原子的,意味着任何时刻其他程序都不会看到一个不完整的对象。启动时如果发现objects/下面还有.tmp-开头的文件,直接删掉——它一定不是合法对象的组成部分,最多丢了一次中断的备份,不会污染整个仓库。
4.4 恢复时覆盖了当前目录的新改动,差点二次自伤
有一次我很自信地恢复一个旧快照,然后命令刚好指向了当前工作目录。它不像我想象中那样“只保留旧状态”,而是会把目录下所有文件都对齐到那个旧快照——也就等于把我当天所有新写的文件都覆盖没了。当时手边没有备份,全靠 peer review 的同事帮我从聊天记录里把正文捞回来。
后来我加了一道护栏:恢复之前,caveman 会先对目标目录做一次快速检查,凡是目标目录里存在但快照里不存在、并且内容和新快照不一致的文件,默认拒绝覆盖,除非你显式用--force确认。这样误操作被拦截的几率高了很多。另外,如果你要恢复到一个非空目录,建议先把它改个名,恢复确认没问题后再合入,这是最保守的操作习惯。
4.5 时间戳并不是你想象的那么简单
快照记录里的时间戳如果直接用本地时间,那么在跨时区的机器上恢复历史时,记录排序会乱。比如我的笔记本在东京时区执行了一次备份,挪回北京时区再看 log,同一批记录的时间顺序和实际发生顺序就不一致了。这个问题非常隐蔽,因为看起来一切正常,但哪天你按时间线恢复文件,很容易选到“物理时间更晚但显示时间更早”的记录。
我统一用 ISO 8601 的 UTC 时间记录时间戳:2024-11-05T02:38:20Z。命令行的--name参数里如果带了可读信息,反而更适合用来标记事件,比如--name "after-meeting"。记住,用户友好标签给到命令入口,系统内部逻辑永远用 UTC。
5. 让洞穴更耐住:保留策略、校验巡查和定时任务
5.1 遗忘与空间回收
虽然内容寻址能消除重复对象,但每次修改都会新增对象,时间长了仓库还是会变大。我在 config 里加了keep参数,默认 30,含义是“保留最近 30 份快照对象”。执行:
caveman forget --keep 30 --dry-run它会列出将被删除的快照,以及若执行“遗忘”后能释放多少空间。跑一次后,我确认列表没问题,再真正进行回收:
caveman forget --keep 30这里有一点值得说清楚:forget并不是只要快照数量超过 30 就立刻删掉对象,它需要先清理不再被任何快照引用的对象,这叫 GC(垃圾回收)。如果两次快照之间有共同引用的对象,GC 不会误删。整体的清理成本不大,但这命令更适合每月跑一次,不需要加进每次备份流程。
5.2 用校验巡查盯住仓库健康
上文已经提过caveman verify,当文件数量在十万级、仓库体积几十 GB 时,完整 verify 可能得跑十几分钟。推荐做法是把 verify 放在计划任务里,每个月某个凌晨执行一次,网页服务器上验证/etc这类关键目录时也可以每周跑一次。
实际体验中,verify 最头疼的是占用 IO 导致业务受影响。我也给命令加了--throttle参数,用来控制读取速率上限,避免与跑着的服务抢资源。默认值我是按 50MB/s 设置的,实践下来在单盘 NAS 上很安全。
5.3 用 systemd 定时任务保持每天一记
我只想备份时手敲命令的毛病很严重,如果不盯牢,快照可能隔半个月才做一次。现在我用 systemd timer 固定每天凌晨 2:30 执行备份,省心且不会忘。给你一个参考单元的写法:
[Unit] Description=run caveman snapshot for /home/ubuntu/data After=network-online.target [Service] Type=oneshot ExecStart=/usr/local/bin/caveman add /home/ubuntu/data --name "daily" Nice=19 IOSchedulingClass=idle[Timer] OnCalendar=02:30 Persistent=true Unit=caveman-daily.service启用方式:
sudo systemctl enable caveman-daily.timer sudo systemctl start caveman-daily.timerPersistent=true的值是,如果这台机器在凌晨 2:30 恰好处于休眠或关机状态,它会在重新开机之后立刻补上一次备份,而不是直接跳过当天。对笔记本用户来说尤其重要,否则“每天自动备份”很可能变成“一周四天自动备份”。考虑到现代系统往往原生支持 systemd,我没有再去做 cron 方案,一个小细节是,Nice=19和IOSchedulingClass=idle是为了避免备份进程把系统 IO 打满,导致你用电脑时感觉卡成 PPT。
5.4 我自己的扩展思路:加密、报警与多仓库合并
如果安全要求高,我建议你先把整个仓库放到一个加密盘里,或者在使用前直接对备份目录进行 GPG 加密。这样所有实现都保持简单,不必为了“透明加密”增加复杂度。报警方面,我给项目加了一个 hook:备份结束或 verify 失败时,调用一段自定义脚本,比如通过系统通知服务弹个提醒,或往群里发一条消息。你可以把这段逻辑写死在 timer 单元里:
ExecStartPost=/usr/local/bin/caveman alert "snapshot failed, check immediately"真正让我觉得值得的,是最近加的多仓库合并。我可以把一套文档在两台机器上分别备份到各自洞穴,定期用caveman merge /path/to/other-cave把两台机器的对象合并到一个主仓库里。因为对象是以哈希命名的,所以合并几乎完全不需要改索引,只需要把 objects 目录合并,refs 记录再追加过去就行。这样省去了手动同步多个备份的体力活。
这段扩展经历让我体会到,这一个小工具的边界一旦画好,就很容易通过提供较低层级的“对象仓库”操作接口来长出更多玩法。谁又知道哪天我还会让洞穴支持从一个 HFS+ 盘恢复项目呢?它固然不是万能药,但你只要把它当作一个可靠、原始、本地优先的去重快照工具,这世界上大概没有比它更让你安心的洞穴了。