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 只需承担一组边界清晰的职责:
- 接收一段字节序列;
- 生成对象标识符;
- 为该标识符保留一个不可变对象;
- 根据标识符定位对象;
- 可选地重新哈希返回的字节,对其进行校验。
| 组件 | 职责 | 不负责 |
|---|---|---|
| 哈希器 | 根据对象的全部字节生成身份 | 选择便于人类阅读的名称 |
| 定位器 | 把对象 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 存储量 |
|---|---|---|---|
| 1 | hello | H(hello) | 5 字节 |
| 2 | hello | H(hello) | 0 字节 |
| 3 | hello! | 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 与 manifest | Registry、tag、media type、分发和平台选择 |
| Bazel 远程缓存 | 内容寻址的构建输入与输出 | Action key、action result、执行策略和可复现性假设 |
| IPFS | Block 与有向无环图(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 可以复用哪些更小的区域。
这就引出了下一个设计问题:边界应该落在哪里?固定大小的边界很简单,但一次插入可能让后续所有数据块发生偏移。下一节将介绍内容定义分块,它根据内容选择边界,使字节流能在局部编辑后重新同步。