- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 学一次,处处可见的模式
- 位置寻址 vs 内容寻址
- 三样免费的东西——去重、完整性、缓存
- 不可变性——为何自然随之而来
- 默克尔树与 DAG——用哈希织成结构
- 现实中的案例——同一个想法的五张面孔
- 阴影——内容寻址的局限
- 结语——知一而识十
- 参考资料
引言 — 学一次,处处可见的模式
长期打交道于系统的人都会有一种奇妙的体验:明明学的是完全不同的工具,某一刻却突然意识到,它们其实是同一个想法的变奏。内容寻址存储(content-addressable storage, CAS)正是这样一种想法。
Git 如何存储提交、Docker 如何共享镜像层、IPFS 如何分发文件、Nix 如何隔离软件包、BitTorrent 如何校验分片——这五者看似互不相关,但其底层铺着完全相同的发想。为数据寻址时,靠的不是它在哪里,而是它是什么。
本文深入这一个想法:为什么用内容而非位置来寻址,能免费带来去重、完整性与缓存;为什么不可变性会自然随之而来;以及这一发想如何扩展为默克尔树与 DAG,支撑起今天的基础设施。
位置寻址 vs 内容寻址
我们处理数据的传统方式是位置寻址(location-addressing)。文件位于/home/user/report.pdf这样的路径,网络资源位于https://example.com/logo.png这样的 URL。这里地址指的是"在哪里"。只要知道地址,你就去那里,取走无论什么内容。
这种方式的根本特性是地址与内容相互脱钩。就算把report.pdf的内容整个换掉,路径也照旧不变。同一个 URL 今天和明天给出的内容也可能不同。地址只是容器的名字,并不保证里面装的是什么。
内容寻址把这层关系颠倒过来:地址由内容计算而来。具体来说,对数据跑一遍密码学哈希函数(如 SHA-256),把得到的哈希值当作这份数据的地址。
位置地址:
"这份数据在 /path/to/file"
(地址指向一个位置 — 内容可以改变)
内容地址:
"这份数据的地址是 hash(数据)"
(地址就是内容的指纹 — 内容一变,地址也跟着变)
这个微小的颠倒之下,接连涌出一串惊人的性质。先简单回顾一下哈希是什么:哈希函数接收任意长度的数据,把它压成固定长度的短值。一个好的密码学哈希具备两条决定性性质——同样的输入永远给出同样的输出(确定性),并且事实上不可能找到两个不同的输入产生相同的输出(碰撞)。这两条性质撑起了 CAS 的全部魔法。想亲手算一算哈希,可以用哈希生成器亲眼确认:同样的输入永远得到同样的哈希,只改一个字符哈希就会彻底不同。
三样免费的东西——去重、完整性、缓存
内容寻址真正的魅力在于,一旦把地址定为内容的哈希,三样有用的性质就会自动跟上——不必额外实现,它们本就是结构本身带出来的。
去重(deduplication)是免费的。 两份数据完全相同,哈希也就相同——也就是说,相同内容拥有相同地址。所以,只要存储里已经有这个地址,就不必再存一遍。哪怕试图把同一个文件存一百次,实际上也只会存一次。去重不需要额外的比较逻辑,只看地址是否已经存在即可。
完整性(integrity)验证是免费的。 收到数据时,只需重新计算一遍哈希,看它是否与请求时的地址一致。一致,这份数据必定是原件;哪怕只有一个比特被篡改,哈希也会不同,对不上地址。不需要额外的校验和字段或签名,地址本身就是完整性的证明。这正是 BitTorrent 从不可信的陌生对等节点接收分片却依然安全的原因。
缓存(caching)是免费的。 内容地址永远不会指向别的内容。hash(X)这个地址永远只意味着X。所以一旦拿到手,就可以无限期缓存,不必担心缓存过期。位置寻址中令人头疼的"缓存失效"问题,在这里根本不会发生——因为地址就是内容,地址相同,内容必定也相同。
之所以反复强调这三样是"免费的",是有原因的。在位置寻址系统里,去重、完整性、缓存失效各自都是需要复杂工程来应对的难题。内容寻址并不是解决了这些问题,而是让它们压根不存在。
不可变性——为何自然随之而来
内容寻址带出的另一项根本性质是不可变性(immutability)。内容寻址存储里的对象永远不会被原地修改,也不可能被修改。
原因很简单:改变数据,哈希就会改变;哈希一变,那就已经是另一个拥有不同地址的对象了。"修改hash(X)处存储的内容"这个概念本身就不成立。把X变成Y,不是对hash(X)的修改,而是创建了一个新对象hash(Y)。旧对象hash(X)原封不动地留着。
这种不可变性在实务中的含义相当大。
- 并发变得容易:对象永远不变,多个进程同时读取也不需要加锁,因为读取过程中内容不会改变。
- 版本管理很自然:新版本不会覆盖旧版本,而是以新地址追加。每个版本都以各自的地址共存。Git 的历史正是这样累积起来的。
- 引用很稳固:指向某个地址的引用,可以保证其对象未被篡改——因为地址就是内容的指纹。
想表达"变更"该怎么办?不是修改不可变对象,而是创建新对象,再把指向它的指针(一个可变的名字标签)挪过去。Git 里分支做的正是这件事。提交对象本身不可变,但main这个分支名是一个可变指针,指向最新的提交,一旦有新提交产生,就挪过去指向它。在不可变数据之上叠加可变指针,这个模式在 CAS 系统里到处重复出现。
默克尔树与 DAG——用哈希织成结构
到目前为止,讲的都是给单份数据寻址。但真实系统处理的往往不是单个文件,而是目录树、提交历史、层栈这类复杂结构。把内容寻址扩展到这类结构上,靠的正是默克尔树(Merkle tree)与默克尔 DAG。
核心想法是这样的:当一个对象引用其他对象时,这个引用不是别的,正是子对象们的哈希。于是子对象的哈希被嵌入到父对象的内容里,父对象的哈希也就依赖于子对象的哈希。
根哈希 (代表整体的一个指纹)
|
+--+--+
| |
哈希A 哈希B <- 这两个哈希被包含在根的内容中
| |
数据 数据
这个结构带出的性质是决定性的。下层数据哪怕只有一处发生变化,那一处的哈希就会变,引用它的父对象的哈希也会变,再往上父对象的哈希也会变,最终一路变到根哈希。反过来说,只要根哈希相同,就能保证其下整棵树,精确到最后一个字节,完全一致。
来看看这为什么强大。
- 用一个哈希概括庞大的结构:由数百万个文件构成的整体状态,可以用一个根哈希打上指纹。两个系统的根哈希相同,整体就相同——这一次比较就能代替海量的校验。
- 部分验证与部分传输:不必接收整棵树,只凭某个片段及其到根的哈希路径,就能证明该片段的真实性。这正是 BitTorrent 在接收完整文件之前,逐片验证的原理。
- 高效的差异计算:两棵树根不同时,顺着子节点往下走,只追踪分叉的枝。哈希相同的枝整段跳过。这是 Git 即使面对庞大历史,也能快速找出变更的秘诀。
默克尔 DAG 在此基础上更进一步。树里每个节点只有一个父节点,但在 DAG(有向无环图)里,多个父节点可以共享同一个子节点。这一点与去重结合起来威力巨大——当多个提交或多个镜像引用同一个下层对象时,该对象只会被存储一次,大家都通过这个哈希共享它。
现实中的案例——同一个想法的五张面孔
现在来看这个想法在真实系统里是如何呈现的。令人惊讶的是,表面完全不同,骨架却一模一样。
Git。 Git 是内容寻址存储的教科书式范例。Git 里的一切——文件内容是 blob,目录是 tree,快照是 commit——都是按其内容的哈希寻址的对象。同一个文件出现在多个提交里,blob 也只会存一次(去重)。提交哈希依赖于该提交的全部内容以及父提交,所以历史上任意一点被篡改,其后所有哈希都会随之改变(完整性)。Git 的提交图,不折不扣就是一个默克尔 DAG。想在浏览器里亲手体验 Git 对象是如何堆叠起来的,Git 实验场是个不错的起点。
Docker 与 OCI 镜像。 容器镜像是层的堆栈,每一层由其内容的哈希(digest)标识。两个镜像若使用相同的基础层,那一层无论在磁盘上还是在镜像仓库里都只会存储和共享一次(去重)。这也是docker pull会跳过已有层的原因。下载完成后重新计算 digest 以验证完整性,若 digest 相同就不再重新拉取(缓存)。代表整个镜像的清单(manifest),同样是由各层 digest 织成的内容寻址结构。
IPFS。 IPFS 是把内容寻址推向 Web 规模的分布式文件系统。文件不是按位置,而是按 CID(Content Identifier)寻址。CID 携带内容的哈希,所以无论从哪个节点接收,都能用 CID 验证完整性。大文件被切成分块,以默克尔 DAG 的形式织在一起,部分传输与去重因此变得自然而然。向网络询问"给我这个 CID",任何持有它的节点都可以响应——因为地址与位置是脱钩的。
Nix。 Nix 包管理器把每个构建产物按其全部构建输入的哈希寻址,隔离存放在/nix/store下。相同的输入产生相同的路径,所以同一个包不会被重复构建;不同的输入产生不同的路径,所以不同版本无冲突共存。这正是 Nix 实现可复现构建与原子回滚的核心——哈希本身就是隔离边界。
BitTorrent。 BitTorrent 把文件切成分片(piece),把每个分片的哈希写入种子元数据。下载者从不可信的陌生对等节点接收分片,但因为按哈希逐片校验,损坏或恶意的分片会被立即过滤掉。无论从谁那里收到都无所谓——只要分片的哈希对得上,它就是真品。这正是内容寻址支撑起零信任分布式传输的典型范例。
把这五者并排放在一起,模式就变得清晰:版本控制、容器、分布式文件系统、包管理、P2P 传输——问题领域完全不同,解法的骨架却是同一个。按内容为数据寻址,再用哈希把它们织成结构。
阴影——内容寻址的局限
这个想法虽然强大,却不是万能药。内容寻址有明确的代价与局限,设计工具时必须了解它们。
- 不适合可变数据:内容地址本质上是为不可变数据准备的。频繁变化的数据每变一次就产生一个新地址,所以想指向"这个名字的最新版本",就需要另外一层可变指针(名字 → 当前哈希)。Git 的分支、IPFS 的 IPNS 正是这一层。也就是说,单靠内容地址无法表达"变更",必须在其上叠加一层命名机制。
- 垃圾回收问题:不可变对象会不断堆积。何时删除一个已经没人引用的对象,是个头疼的问题。需要一套 GC,找出任何可变指针都无法到达的对象并回收它们,而这绝非易事。Git 仓库随时间膨胀,原因也在这里。
- 哈希计算的成本:对所有数据都跑一遍密码学哈希并不是免费的。对大体量数据而言,哈希计算本身就可能成为负担——不过在现代硬件上通常还算可以承受。
- 碰撞与哈希函数的老化:安全性依赖于哈希函数的抗碰撞性。历史上曾经广泛使用的哈希函数,随时间推移变得脆弱的先例并不少见。内容寻址系统一旦所依赖的哈希函数被攻破,完整性保证就会崩塌,而迁移到更强的哈希,在大规模系统里是极其困难的任务——因为已经有数以亿计的地址是用旧哈希刻下的。
- 位置的消失:地址本身不携带位置信息,所以"去哪里获取这份数据"必须另外解决。这也是为什么 IPFS 要单独设一层 DHT(分布式哈希表)来查找"谁持有这个 CID"。内容地址告诉你它是什么,却不会告诉你它在哪里。
综合这些局限来看,内容寻址最适合"不可变、完整性至关重要、重复度高、被广泛缓存与共享"的数据。对于频繁变化的状态,或者位置本身就至关重要的数据,在位置寻址之上部分叠加内容寻址的混合方案才现实。事实上,上面提到的每一个系统,都是在内容地址(不可变对象)之上叠加了一层可变命名机制的混合体。
结语——知一而识十
内容寻址表面上是个朴素的想法:"按数据的哈希为其寻址。"但从这一句话出发,去重、完整性、缓存、不可变性接连涌现,再扩展到默克尔树,又获得了用一个哈希校验庞大结构的能力。仅仅是把寻址依据从位置换成内容这一个视角转换,就让如此多的难题从一开始就不复存在。
一旦把这个想法内化,此后便处处可见。学习新工具时,不妨问一句"这会不会也是内容寻址",出乎意料地经常是。正如 Git、Docker、IPFS、Nix、BitTorrent 所展现的那样,内容寻址的块存储、内容寻址的缓存、内容寻址的制品仓库还会不断出现——挂着不同的名字标签,骨架却相同。
深刻理解系统,归根结底就是识别这些反复出现的想法的能力。看清表面差异之下流淌的共同发想,学习新工具就不再是每次从零开始,而是遇见了一个已经熟悉的模式的另一张面孔。内容寻址正是这类模式中,最优雅也最普及的一个。
参考资料
- Git 内部结构 (Pro Git): https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
- OCI 镜像规范: https://github.com/opencontainers/image-spec
- IPFS 文档 (内容寻址): https://docs.ipfs.tech/concepts/content-addressing/
- Nix store: https://nixos.org/guides/nix-pills/
- 默克尔树 (Wikipedia): https://en.wikipedia.org/wiki/Merkle_tree
- BitTorrent 规范 (BEP 3): https://www.bittorrent.org/beps/bep_0003.html