Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

CAS:内容寻址存储

大多数存储系统根据数据的存储位置为其命名,例如路径名、对象键、URL 或数据库行。内容寻址存储(Content-Addressed Storage,CAS)采用另一条规则:它根据对象的字节内容生成对象名称,再用这个名称存储、读取、复用和校验对象。

这条规则很简单,却改变了存储引用所表达的含义。/docs/report.txt 这样的路径明天可能指向不同的字节;内容地址则始终标识同一组字节。字节一旦改变,地址也随之改变。

本节从第一性原理出发构建一个朴素 CAS,介绍它的架构、核心算法、精确内容去重、实际应用,以及文件级对象为什么需要分块表示。本节暂不讨论内容定义边界算法、LayerFS 对象格式、类型化身份、对象图、准入、pack 或生产级发布。只有先明确 CAS 的基本契约,这些机制的设计动机才容易理解。

1. 地址就是内容

先看一个传统文件系统路径:

/docs/report.txt

路径告诉文件系统去哪里寻找数据,却不会永久标识在那里找到的字节。进程可以在保留路径的同时覆盖文件,也可以把同一个文件移动到其他路径。名称与内容之间的关系是可变的。

内容寻址存储从相反方向出发:

object address = hash(object bytes)

地址由内容生成,而不是脱离内容单独指定。只要内容不变,地址就不变;内容发生变化,地址也会变化。

两种系统的区别并不是一个有位置、另一个没有。任何物理存储最终都必须把字节放在某个位置。真正的区别是公开身份所表达的含义:

命名模型名称标识什么内容变化时内容移动时
位置寻址一个可变位置名称可以不变名称通常改变
内容寻址一组确定的字节序列地址改变地址可以不变

因此,CAS 为不可变数据提供了一套实用的表达方式。两个调用方引用同一个内容地址,就表示它们期待完全相同的字节,即使存储系统后来改变了对象的物理位置也不影响这一点。

查找方式也随之改变。CAS 不会在存储中搜索与请求“相似”的字节。调用方已经持有对象标识符,存储系统会像键值存储使用键一样,直接用该标识符定位对象。区别在于,CAS 的键由值本身生成。

2. 从字节生成身份

x 是对象的字节序列,H 是密码学哈希函数。最小的内容身份可以写成:

id(x) = H(x)

哈希函数处理任意长度的输入,并产生固定长度的摘要。CAS 依赖以下性质:

  • 确定性: 使用同一算法哈希相同字节,会得到相同摘要。
  • 变化敏感性: 输入发生变化时,应产生不相关的摘要。
  • 抗碰撞性: 对于选定算法,寻找两个摘要相同但内容不同的输入在计算上应不可行。
  • 流式计算: 无需把整个对象载入内存,也能增量计算摘要。

摘要并不是“碰撞绝不可能发生”的数学证明,而是一项安全假设:摘要足够大、算法足够强,使意外或恶意碰撞在系统的威胁模型下不可行。长期运行的系统还必须考虑哈希算法老化后的迁移方式;Git 的哈希函数迁移设计说明了为什么不能假设算法选择永远不变。

身份支持内容校验

假设调用方通过 expected_id 请求对象,存储返回 content。调用方或存储重新计算摘要,再与请求的 ID 比较:

actual_id = H(content)

if actual_id != expected_id:
    return integrity_error

如果二者不同,返回的字节就不是所请求的对象。这项检查可以发现数据损坏、截断、错误的定位器,或者存储返回了其他对象。

这是一项完整性检查,而不是完整的安全系统。它不能证明字节由谁创建、内容是否可信、是否经过加密,也不能保证存在另一个副本用于修复。

身份支持精确去重

如果地址为 d 的对象已经存在,再次写入相同对象时便可以直接复用,而不必写入另一份 payload。这就是发生在 CAS 对象边界上的精确内容去重

对象边界很重要。以整个文件为对象的 CAS 可以识别两个完全相同的文件,但一个字节的变化就会让编辑后的文件成为另一个完整对象。若要复用文件内部未变化的区域,系统必须把文件拆成更小的对象。分块是额外的设计选择,并不属于 CAS 的基本定义。

CAS、去重和增量备份之间的区别值得明确说明:

概念首要问题基本机制
内容寻址如何为对象命名?根据内容生成身份
去重哪些重复数据可以共享存储?只保存一个物理实例并复用它
增量备份相比上一次备份发生了什么变化?记录相对较早备份发生变化的数据或文件

这些技术经常一起出现,尤其是在备份产品中,但它们不是同义词。BlinkDisk 的 CAS 概览描述了一套分块备份系统;真正让编辑后文件中的未变化区域得以复用的是分块。其关于去重增量备份的独立介绍进一步说明了这些相关概念。朴素 CAS 即使没有实现分块或增量备份链,也能对完全相同的对象去重。

3. 架构:最小内容寻址存储

最小 CAS 只需承担一组边界清晰的职责:

  1. 接收一段字节序列;
  2. 生成对象标识符;
  3. 为该标识符保留一个不可变对象;
  4. 根据标识符定位对象;
  5. 可选地重新哈希返回的字节,对其进行校验。

组件职责不负责
哈希器根据对象的全部字节生成身份选择便于人类阅读的名称
定位器把对象 ID 解析到物理存储定义对象的含义
对象存储保留不可变的 payload 字节检查点、分支或目录
校验器确认返回字节与所请求 ID 一致修复损坏的对象
客户端或上层保存有意义的名称以及对象间关系修改已安装对象的字节

定位器不一定需要数据库。简单存储可以直接根据摘要生成路径;更大的存储可能因为对象被打包、远程保存、复制,或在不同存储层之间移动而使用索引。两种设计都保留相同的分离关系:

logical identity:  which exact bytes?
physical locator:  where are those bytes currently stored?

在 CAS 之上,应用通常还会维护用户关心的名称与关系。备份系统把快照和路径名映射到已存储对象;Git 把 tree 和 commit 映射到对象,再用可变的 branch 名选择 commit;容器 registry 把 tag 映射到 manifest,而 manifest 中的 descriptor 则通过 digest 标识 blob。上层结构赋予对象含义,朴素 CAS 只存储不透明字节。

这个边界是有意为之。当 CAS 的核心契约不再同时试图扮演文件系统、版本控制系统、备份目录或分布式可用性服务时,它会更容易理解和验证。

4. 核心算法:Put、Get 与 Verify

从概念上看,朴素 CAS 只有两个操作:

put(bytes) -> object_id
get(object_id) -> bytes

核心算法可以写成:

put(bytes):
    id = hash(bytes)

    if id is not already stored:
        publish bytes as the immutable object named by id

    return id

get(id):
    bytes = locate and read the object named by id

    if hash(bytes) != id:
        return integrity error

    return bytes

publish ... as immutable 这句话隐藏了真正的工程工作。生产实现必须正确处理崩溃、并发写入、目标已经存在、部分写入和存储错误,同时不能覆盖可信数据。第 2 章会回到这些机制。现在只需把握逻辑规则:对象一旦成功发布,与其 ID 关联的字节便永不改变。

Put 操作

两条路径都会返回同一个对象 ID,区别只在于是否执行发布工作。

相同输入会产生相同的候选 ID,因此完全重复的对象会汇聚到同一个名称。但可信实现不能仅仅因为预期路径已经存在对象,就盲目信任当前占用者。第 2 章会通过验证准入强化这条复用路径。

Get 操作

调用方提供自己所期待字节的 ID。存储系统解析 ID、读取字节并重新计算摘要。摘要匹配,说明该字节序列与请求的内容身份一致;摘要不匹配则必须报告错误,不能假装读取成功并返回字节。

校验可以发生在不同边界。本地存储可以在每次读取时校验,也可以在准入时校验并依赖可信的不可变介质,还可以让客户端校验远程响应。校验位置会改变成本与信任假设,但不会改变 H(bytes) = requested ID 这条等式。

一个具体例子

假设字符串以 UTF-8 字节存储:

写入输入对象 ID新增 payload 存储量
1helloH(hello)5 字节
2helloH(hello)0 字节
3hello!H(hello!)6 字节

应用总共提交了 16 个逻辑字节,但 CAS 只保留了 11 个唯一 payload 字节。第二次写入 hello 时仍需要识别内容——通常需要读取并哈希这 5 个字节——但不需要再保存一份 payload。

这个小例子同时体现了朴素 CAS 的优势与边界:

  • 完全重复的对象共享同一 ID,因此保留成本很低;
  • 任意字节发生变化,都会产生新的整对象身份;
  • 上层引用必须解释应用为何关心这些对象。

5. 真实系统中的内容寻址

CAS 很少独自构成完整产品。成熟系统把它作为稳定对象层,再增加用于表达对象含义、可达性、策略和高效物理表示的结构。

系统内容寻址提供什么周边系统增加什么
Git不可变对象的稳定身份Blob、tree、commit、branch、tag、历史遍历和 packfile
Venti由摘要命名的不可变归档数据块Root、tree、index、cache 和归档策略
备份系统文件或数据块的精确复用扫描、分块、快照目录、保留策略、加密和恢复工作流
OCI 镜像通过 digest 标识并校验 blob 与 manifestRegistry、tag、media type、分发和平台选择
Bazel 远程缓存内容寻址的构建输入与输出Action key、action result、执行策略和可复现性假设
IPFSBlock 与有向无环图(DAG)的内容标识符分块、codec、路由、传输、可变命名和 pinning

Git 是最常见的教学示例。正如 Git Objects所介绍的,其对象数据库保存内容寻址的 blob、tree、commit 和 tag。main 这样的 branch 本身并不是不可变内容地址,而是一个用于选择 commit 的可变引用。

这种分离让 Git 可以保留稳定的历史对象,同时允许 branch 名不断前进。Git 也说明了逻辑身份与物理编码可以分别演进:packfile可以用 delta 紧凑保存对象,而不改变 commit 与 tree 使用的身份。

Venti是较早且影响深远的网络存储系统,其核心就是由内容寻址的不可变数据块。客户端在块存储之上构建归档数据结构,再次体现了相同边界:CAS 提供稳定数据块,上层提供 root 和解释方式。

容器和构建系统使用内容寻址来支持分发与复用。OCI image specification 的 descriptor携带 digest,用于标识和校验被引用的内容。Bazel 远程缓存把存储文件的 CAS 与 action cache 分开,后者负责将 action 映射到结果。两个例子中,CAS 都只是更大协议中的一个组件。

分布式 CAS 还揭示了另一条边界。IPFS 内容标识符可以独立于具体主机标识内容,但知道 CID 并不保证当前有可达 peer 提供数据。内容身份与内容可用性是两种不同的性质。

备份系统通常会组合前面提到的三个概念:分块选择去重单位,内容寻址为其命名,快照或增量元数据记录可恢复状态。因此,存储节省量和恢复行为属于完整的备份设计,而不是只靠哈希就能获得的性质。

6. CAS 为什么需要分块表示

CAS 的去重单位是对象。如果整个文件是一个对象,两个完全相同的文件可以共享同一身份;但只要改变一个字节,整个文件的身份都会变化。若没有 delta 压缩之类的其他编码方式,编辑后的版本就会再增加一份完整文件 payload。

分块表示把 CAS 的去重单位从整个文件改为更小的字节区域。每个数据块拥有自己的内容身份,一份小型 manifest 记录它们的顺序。局部编辑后,未变化的数据块保留原有身份并继续复用;只有受边界影响的区域和 manifest 需要产生新对象。

变化整文件 CAS分块 CAS
完全复制复用一个整文件对象复用全部数据块,通常也复用同一 manifest
局部覆盖保留另一份整文件 payload只保留受影响的数据块和新 manifest
插入或删除保留另一份整文件 payload复用程度取决于分块边界能否重新同步
增量传输发送发生变化的整文件对象发送缺失的数据块和新 manifest

例如,如果一个 1 GiB 文件作为单个 CAS 对象存储,那么一次单字节修改可能新增 1 GiB payload。使用分块后,新增 payload 只与受影响的分块区域以及一份小型 manifest 成正比。实际大小取决于数据块尺寸和边界行为;分块改善的是复用机会,并不保证每次编辑只改变一个数据块。

CAS 提供身份与精确复用;分块决定 CAS 可以复用哪些更小的区域。

这就引出了下一个设计问题:边界应该落在哪里?固定大小的边界很简单,但一次插入可能让后续所有数据块发生偏移。下一节将介绍内容定义分块,它根据内容选择边界,使字节流能在局部编辑后重新同步。