⚠️ 法律与用途声明:本文讨论 Conan 2 缓存、硬链接快照与定时治理的架构设计,基于开源工具链(Conan 2 / Docker / systemd),用于技术学习与工程研究。所有企业名、组织名、域名、路径前缀、具体数值与脚本命名均已脱敏。读者须遵守所在地区法律法规。

一、上一篇留下的悬念
NO.A2 讲三层模型时留下了一条铁律:即时容器不直接可写挂载模板缓存,而是从它"快照"一份到自己的 run 目录。但这句"快照"藏着一个关键的工程问题——
一次交叉编译的 Conan 缓存动辄几个 GB(工具链包加一堆第三方库)。如果每次 CI 都把这几个 GB 从模板缓存全量复制到即时构建目录,那"即时容器"的启动就变成了一场漫长的复制,CI 排队照样排到怀疑人生。三层模型解决了污染隔离,却可能把代价转嫁到了启动耗时上。
这一篇就拆开"快照"这两个字:怎么让 GB 级缓存的启动降到秒级,可变状态怎么断链避免写穿,构建和晋升怎么用读写锁并发,以及即时构建目录这个大文件缓存怎么被定时自动治理、却绝不碰真源。

二、hardlink 快照:让 GB 级缓存秒级就绪
核心机制是硬链接(hardlink)。它不是复制文件内容,而是为模板缓存里的每个文件,在本次 run 目录里建一个硬链接——两个目录名指向同一份磁盘块(同一个 inode),不占额外空间,瞬间完成。
这个设计的精妙在于:它把"复制缓存"这件事从数据级操作(搬几个 GB 的字节)降级成了元数据级操作(建一批目录项)。数据级操作的耗时正比于数据量,元数据级操作的耗时只正比于文件数量。所以 GB 级缓存的快照能在秒级完成,这就是即时容器能"用完即毁"还保持快速启动的关键。
工程上还要配一条优雅降级:硬链接不是总能成功(系统可能开启硬链接保护、或跨用户所有权限制)。所以快路径失败时,自动退回全量复制,并把本次标记为 fallback 模式。这是"快路径优先、慢路径兜底"的典型工程姿态——常态走最快的,异常有确定的后路。
三、可变状态断链:按"可变性"给文件分类
hardlink 看似完美,但有一个致命陷阱:硬链接是双向写穿的。如果 run 目录里的某个文件是模板缓存那份的硬链接,而本次构建又往这个文件里原地写了内容(比如数据库追加记录),那就会直接改穿公共模板缓存——上一节说的污染隔离就破功了。
不过这里有个精确的区分:硬链接只对"覆盖写、原地改"写穿,对"只读"完全安全。于是解法不是放弃 hardlink,而是按文件的可变性分类处理:
- 只读的包二进制:工具链包、第三方库的编译产物,构建时只读取不修改。这类文件共享硬链接,省空间、省时间。
- 会写变的索引与配置:Conan 的数据库、包索引、profile、设置、远程仓库配置、包元数据。这类文件本次构建会往里写,必须断开硬链接,成为 run 目录里的独立副本。

这条"断链清单"是整套隔离设计的精华。它的本质是:用"可变性"这个维度给文件分类,给每一类配最合适的存储策略——只读的共享、可变的独立。这和数据库领域的 MVCC(多版本并发控制)、文件系统的 CoW(copy-on-write)是同一个思想:把"读"和"写"在存储层就隔离开。
四、读写锁:构建并发,晋升串行
模板缓存是公共加速层,会被两类操作访问:构建时读、晋升时写。如果都用同一把锁,要么构建互相阻塞、要么写的时候改了别人正在读的缓存。
解法是经典的读写锁:
- 构建(读):持共享读锁。多个 CI job 可以同时持读锁快照,互不阻塞。
- 晋升(写):持独占写锁。写的时候排斥所有读和其他写,保证缓存更新是原子的。

写入侧还有几条加固原则:不删除已有包(避免误删别的包)、不覆盖数据库文件(用 cache save/restore 增量合并)、每次晋升用独立的临时工作区(避免并发晋升互相踩)、所有写入行为留审计日志。这是"读写分离"在缓存层的落地——读廉价且并发,写昂贵但串行、可追溯。
五、强制构建当前包:别让旧缓存骗过编译
有个很容易踩的坑:你改了算法代码、触发 CI,结果 CI 发现缓存里已经有一个同名同版本的旧二进制包,于是"命中缓存、跳过编译"——你改的代码根本没被编译进去,构建"成功"了却是个假象。
脚本用两道防线堵这个口子:
- 构建前从本次缓存移除当前包,避免旧版本误导判定为"已存在"。
- 强制只对当前包从源码编译,依赖包继续走缓存。
注意这个粒度:只强制构建"当前包",不强制构建所有依赖。否则又回到全量从源码编译的慢路径。这是"正确性"和"速度"的精准平衡——只有你改的那个包必须重新编译,它的依赖该用缓存就用缓存。
六、三种 cache 模式:默认快路径 + 排障 + 冷启动验证
构建脚本支持三种缓存模式,各有用途:
| 模式 | 行为 | 用途 |
|---|---|---|
| 硬链接(默认) | 共享磁盘块,启动最快 | 生产 CI 主路径 |
| 全量复制 | 独立副本,启动慢 | 怀疑读写隔离异常时排障 |
| 冷启动 | 不带缓存 | 验证包能否完全从真源解析 |
这套设计体现了一个运维哲学:默认路径要快,但要留出"慢而确定"的排障路径。当线上出现"缓存命中却构建失败"的灵异现象时,切到全量复制或冷启动模式复现,能快速定位是缓存污染还是包本身的问题。
每次构建都会记录本次的缓存行为(用了哪种模式、是否强制构建),这个记录就是缓存行为的"黑匣子"——出了问题第一个看它。
七、即时构建目录治理:为什么敢大胆清理
即时构建目录是天然的"大文件垃圾桶"。每次 CI 都往里写几个 GB 的缓存、编译中间产物,时间一长,累积到海量规模毫不意外。
但 NO.A2 讲过一条根本哲学:即时构建目录是排障现场,不是制品真源,也不是公共缓存。它的所有重缓存都可以从真源(Artifactory)重建。所以治理策略可以很激进——敢删,是因为删了能重建。
治理由三个角色协同:

- 统计(report):只读,看总占用、最大目录、状态分布;要能区分"逻辑占用""可回收量""共享硬链接"——因为删一个含共享硬链接的目录,不一定真的释放磁盘。
- 清理(prune):真实删除,但默认只预演,必须显式确认才动手。
- 调度(schedule):把清理固化成每日定时任务,不靠人记得住。
八、定时治理:低峰自动、错过不补
手动清理靠人记得住不现实,所以用 systemd timer 固化成每日任务。这里有两个值得说的设计判断:
低峰触发。清理跑在业务低峰时段,避免清理的磁盘 IO 撞上白天的构建高峰。
错过不补跑。如果触发时刻服务器不可用,开机后不补跑清理。这看起来"漏了一次",但实际是避免清理任务在白天业务高峰被补触发——一次漏清的代价,远小于一次高峰期清理把 CI 拖垮。这是"宁可漏一次、不可错杀"的稳健取舍。
清理策略按 run 的保留价值分级:
- 已成功且已晋升的 run:价值最低(产物已进真源),最早清。
- 失败的 run:可能还要排障,留久一点。
- 最老的 run:统一兜底清理。
这个分级背后的判断是信息价值——已经固化进真源的现场意义最小,先清;还没排障完的现场多留一会。
九、删留边界:可重建 vs 不可重建
治理最见功夫的地方,是用一张精确的清单划定了"可重建"和"不可重建"的分界线:
会删(可重建):包缓存、编译缓存、晋升临时工作区、缓存临时目录。这些都能从真源重拉或重新生成。
会留(不可重建):构建结果记录、产物包、日志、元数据报告。这些是这次构建的"历史档案",删了就找不回来了。
绝不碰:
- 制品真源(Artifactory):清理即时目录不会动真源一个字节。
- 公共加速层(模板缓存):清理即时目录不会写或删模板缓存。
还有一条容易误解的细节:如果目录里有共享硬链接文件,删单个 run 目录不一定释放磁盘——那部分磁盘块被模板缓存或别的 run 共享着。所以统计脚本要单独标出"共享硬链接"的量,避免你以为删了 N GB 就一定多出 N GB。
这条"删留边界"的本质,是用"可重建性"给数据分级:能重建的大胆删,不能重建的坚决留,真源和加速层绝对不碰。这是缓存治理能长期自动化运行的根本——它清楚知道自己的边界在哪。
十、cache_status 对账:让清理可观测
清理之后,怎么知道某次构建的缓存还在不在?靠一个三态生命周期给每次构建打标签:

- active:run 目录还在,且重缓存仍在,可直接复现。
- pruned:run 目录还在,但重缓存已被清,排障元数据仍可查。
- missing:有构建记录,但 run 目录已不存在。
清理脚本在删完重缓存后,会把对应构建记录更新为 pruned。这样构建登记表上每一行都能如实反映"产物还在不在、缓存还能不能直接复现"——清理不再是黑箱,而是可观测、可对账的日常运维。
十一、架构哲学:加速层可丢失 + 读写分离 + 可观测
这一篇落地了三条互相支撑的哲学:
加速层可丢失。整套缓存治理敢激进清理,根因是 NO.A2 那条"真源唯一"——Artifactory 是源,模板缓存和即时构建目录都是影。影丢了能从源重建,所以治理可以大胆。如果缓存本身是真源,谁都不敢删,磁盘早晚爆炸。
读写分离。模板缓存的访问用读写锁切分:构建只读、廉价、并发;晋升独占写、串行、可追溯。文件按可变性分类存储:只读二进制共享硬链接、可变索引独立副本。这是用"访问模式"和"可变性"两个维度共同驱动存储策略。
可观测是治理的前提。每次构建记录缓存行为(黑匣子),每次构建有 cache_status 三态反映缓存生命周期,每次晋升有审计日志。没有这些,清理就是一个不敢碰的黑箱;有了它们,清理才是可控、可追溯、可对账的日常。
这三条合起来,回答了开头的悬念:GB 级缓存能秒级就绪、能并发不污染、能大胆清理不爆炸——靠的不是某个神奇的脚本,而是"真源唯一 + 读写分离 + 可观测"这套架构约束。脚本只是这套约束的执行者。
下一篇,我们把视角从执行层拉回到调度层:声明式构建矩阵——一份配置文件是怎么被展开成跨平台 CI 矩阵、让"在哪里构建"变成一句声明的。

参考链接:
[1] Conan 2 缓存命令(cache save / restore) https://docs.conan.io/2/reference/commands/cache.html
[2] Linux 硬链接与 inode 机制 https://www.kernel.org/doc/html/latest/admin-guide/sysctl/fs.html
[3] systemd timer(定时任务与 Persistent 语义) https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html
[4] flock(文件锁,读写锁实现基础) https://man7.org/linux/man-pages/man1/flock.1.html
[5] Conan 2 build 策略(--build=missing / 强制构建) https://docs.conan.io/2/reference/commands/create.html
[6] JFrog Artifactory CE(制品真源) https://jfrog.com/open-source/