Merkle DAG 与内容寻址存储 — 用 Rust 从零构建版本化去重归档系统
从 Git 的对象模型到 IPFS 的分布式文件系统,从 restic 的安全备份到 Docker 镜像的分层存储,内容寻址存储(Content-Addressable Storage, CAS)是现代基础设施中最被低估却最关键的设计模式之一。本文将深入剖析 Merkle DAG 数据结构、内容定义分块(CDC)算法,并用 Rust 从零实现一个支持版本化和全局去重的归档系统。
一、为什么我们需要内容寻址?
传统文件系统采用位置寻址(Location-Addressable Storage):你通过路径 /home/user/project.tar.gz 访问数据。这种模式下,同样的内容如果被复制多份、改名或在不同路径下存储多份,文件系统无法识别它们是同一份数据,导致存储膨胀。
内容寻址存储彻底颠倒了这一模型:数据的地址就是其内容的密码学哈希值。写入数据时,系统计算 SHA-256(content),将哈希值作为唯一键来索引数据块。读取时,通过哈希值反查数据内容。这一简单变化带来了三个根本性的优势:
- 天然去重:无论数据被引用多少次,相同内容只存储一份。
- 完整性自验证:读取时重新计算哈希,与索引比较,可立即发现数据损坏或篡改。
- 不可变性(Immutability):CAS 中的数据块一旦写入就不可修改,这使得并发访问无需加锁,也让缓存策略变得极端简单——永远不需要失效缓存。
Git 是这个原理的最佳日常例证。每次提交后仓库体积的增长只等于实际变更的量,因为它基于内容寻址的对象存储(blob、tree、commit 都通过 SHA-1 索引)。
二、Merkle DAG:从哈希到结构
单独的 CAS 只能存储无结构的二进制块。要构建有层次的数据结构,需要引入 Merkle DAG(Merkle Directed Acyclic Graph)。
2.1 DAG 而非纯树
Merkle Tree 要求每条路径唯一形成树结构。但在实际场景中,我们希望不同版本的文件共享相同的子节点(增量存储),这就需要 DAG——允许一个节点有多个父节点。
DAG 的核心规则:每个节点由其子节点的哈希值计算自身哈希,形成链式验证关系。任何叶节点的变更都会级联影响所有祖先节点的哈希,因此根哈希就是整个数据集的内容指纹。
2.2 节点结构
我们设计的节点类型包括:
- BlobNode:存储纯字节数据(文件内容分块结果)
- ChunkListNode:存储一个有序的 BlobNode 哈希列表(代表一个文件的拼接结构)
- SnapshotNode:存储根 ChunkListNode 的哈希 + 元数据(创建时间、标签等)
一个文件经过分块后变成多个 BlobNode,其 ChunkListNode 记录了所有块的顺序哈希。Snapshot 指向 ChunkListNode,形成三层 DAG 结构。
三、内容定义分块(CDC)
CAS 系统的关键决策在于如何将变长文件切分为块。固定大小分块(Fixed-Size Chunking)实现简单,但当文件头部插入一个字节时,所有后续块的边界都会偏移,导致完全不同的块集合,去重率暴跌。
内容定义分块(Content-Defined Chunking, CDC) 通过滑动窗口计算数据的"切割点"来实现与内容对齐的分块,插入/删除操作只影响局部块的边界。
3.1 FastCDC 算法
FastCDC 是经典 CDC 算法的优化版本,核心思想是:以滑动窗口(通常 16-48 字节)扫描数据,对窗口内容做滚动哈希(如 Gear Hash),当哈希值满足某个掩码条件时(如 hash % TARGET_SIZE == 0),在此处切割。
关键优化是引入跳进(Skip)机制:文件开头和结尾的块大小不稳定,可以跳过前 1KB 不做切割检测,且最大块大小限制为 64KB、最小为 2KB,保证分块粒度均匀。
四、Rust 实现
下面实现一个完整的 CAS 归档引擎。核心架构分为三层:存储层(FileStore)、分块层(CDC Chunker)、索引层(Merkle DAG Indexer)。
4.1 存储层
存储层负责持久化和检索 Blob 数据。为简化实现,使用本地文件系统的一个目录作为后端,以 Base32 编码的 SHA-256 哈希作为文件名。
use sha2::{Sha256, Digest};
use std::path::{Path, PathBuf};
use std::fs;
use std::io::Write;
/// 内容寻址存储后端
/// 数据以 <root>/<hash_base32> 形式持久化
pub struct FileStore {
root: PathBuf,
}
impl FileStore {
pub fn new(root: PathBuf) -> std::io::Result<Self> {
fs::create_dir_all(&root)?;
Ok(Self { root })
}
/// 写入数据块,返回其内容哈希
/// 如果该哈希已存在(已有相同内容),跳过写入实现去重
pub fn put(&self, data: &[u8]) -> std::io::Result<[u8; 32]> {
let hash = Sha256::digest(data);
let path = self.hash_to_path(&hash);
// 原子写入:先写临时文件再 rename,防止半写状态
if !path.exists() {
let tmp = path.with_extension("tmp");
fs::write(&tmp, data)?;
fs::rename(&tmp, &path)?;
}
Ok(hash.into())
}
/// 通过哈希读取数据块
pub fn get(&self, hash: &[u8; 32]) -> std::io::Result<Vec<u8>> {
let path = self.hash_to_path(hash);
fs::read(path)
}
/// 检查某哈希的数据块是否已存在
pub fn exists(&self, hash: &[u8; 32]) -> bool {
self.hash_to_path(hash).exists()
}
fn hash_to_path(&self, hash: &[u8; 32]) -> PathBuf {
// 使用 base32 编码(小写)作为文件名,避免大小写敏感文件系统问题
let name = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, hash);
// 两层子目录分散文件:前 2 字符 / 次 2 字符 / 完整名
// 避免单目录文件数过亿时 ext4 性能退化
self.root.join(&name[..2]).join(&name[2..4]).join(&name)
}
}
这个实现有两个工程细节值得注意:一是通过 put 返回内容哈希让调用者可以将哈希作为引用存储;二是两层子目录结构避免单目录文件数爆炸时文件系统性能崩溃。
4.2 CDC 分块器
基于 FastCDC 的 Rust 实现。关键在于滚动哈希(Gear Hash)的窗口更新和切割条件的判断:
use std::io::Read;
pub struct CdcChunker {
min_size: usize, // 最小块大小,默认 2KB
avg_size: usize, // 目标块大小,默认 8KB
max_size: usize, // 最大块大小,默认 64KB
mask: usize, // 切割掩码,由 avg_size 推导
}
impl CdcChunker {
pub fn new(min_size: usize, avg_size: usize, max_size: usize) -> Self {
// mask 应使 hash % avg_size == 0 的概率为 1/avg_size
// 即选择 avg_size 最高位的 1 以下的所有位为 1
// 例如 avg_size = 8192 (2^13),mask = 8191 = 0b1111111111111
let mask = avg_size - 1; // 简化:要求 avg_size 是 2 的幂
Self { min_size, avg_size, max_size, mask }
}
/// 从 Reader 流中提取一个数据块
/// 返回 None 表示流结束,Some(data) 表示一个完整块
pub fn next_chunk<R: Read>(&self, reader: &mut R) -> std::io::Result<Option<Vec<u8>>> {
let mut buf = Vec::with_capacity(self.max_size);
let chunk = vec![0u8; 4096];
let mut tmp = chunk.as_slice();
let mut in_min_zone = true;
let mut hash: u64 = 0;
loop {
let n = reader.read(&mut tmp)?;
if n == 0 {
// 流结束:如果有剩余数据则作为最后一块
return if buf.is_empty() {
Ok(None)
} else {
Ok(Some(buf))
};
}
buf.extend_from_slice(&tmp[..n]);
// 前 min_size 字节跳过切割检测
if buf.len() <= self.min_size {
continue;
}
// 滚动哈希:使用简化 Gear Hash
// 实际 FastCDC 会做掩码两阶段的精细判断
if in_min_zone {
// 过渡区:min_size 到 4KB 之间用不同掩码
if buf.len() >= self.min_size + 4096 {
in_min_zone = false;
}
} else {
// 正常切割检测
if buf.len() >= self.max_size {
return Ok(Some(buf)); // 强制切割
}
// 用最后几个字节驱动哈希更新
let tail = &buf[buf.len().saturating_sub(16)..];
hash = Self::gear_hash(tail);
if hash & (self.mask as u64) == 0 {
return Ok(Some(buf));
}
}
}
}
/// 简化的 Gear Hash:用预计算 gear 表做滚动
fn gear_hash(data: &[u8]) -> u64 {
// 实际生产环境使用预计算的 GEAR_TABLE[256] 做滚动
// 这里用 FNV-1a 做简化示意
let mut h: u64 = xcbf8337u64;
for &b in data {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
}
上面的代码包含了一个重要的生产细节:过渡区掩码切换。FastCDC 在 min_size 附近使用较宽松的掩码(避免产生过多过小的块),在正常区域使用标准掩码,在接近 max_size 时随时准备强制切割。
4.3 Merkle DAG 索引层
索引层负责管理 ChunkList 和 Snapshot 的序列化、持久化和遍历:
use serde::{Serialize, Deserialize};
use chrono::Utc;
/// ChunkList 节点:记录一个文件分块后的有序哈希序列
#[derive(Serialize, Deserialize, Clone)]
pub struct ChunkList {
pub total_size: u64,
pub chunks: Vec<[u8; 32]>, // 有序的 BlobNode 哈希列表
}
/// Snapshot 节点:文件系统的某个版本快照
#[derive(Serialize, Deserialize, Clone)]
pub struct Snapshot {
pub hash: [u8; 32], // 自身哈希(用于引用)
pub chunk_list: [u8; 32], // 指向 ChunkList 的哈希
pub created_at: i64,
pub label: String,
pub file_count: u64,
}
pub struct ArchiveEngine {
store: FileStore,
chunker: CdcChunker,
}
impl ArchiveEngine {
pub fn new(store_root: PathBuf) -> std::io::Result<Self> {
Ok(Self {
store: FileStore::new(store_root.join("blobs"))?,
chunker: CdcChunker::new(2048, 8192, 65536),
})
}
/// 将一个文件路径归档,返回 Snapshot
pub fn archive_file(&self, path: &Path, label: &str) -> std::io::Result<Snapshot> {
let data = std::fs::read(path)?;
self.archive_bytes(&data, label)
}
/// 将字节数据归档,返回 Snapshot
pub fn archive_bytes(&self, data: &[u8], label: &str) -> std::io::Result<Snapshot> {
// 1. CDC 分块
let mut chunks: Vec<[u8; 32]> = Vec::new();
let mut cursor = std::io::Cursor::new(data);
// 使用分块器迭代
let chunker_ref = &self.chunker;
loop {
let block = {
// 提取一个块数据(实际需用更高效的流式实现)
let mut block = Vec::new();
let mut buf = [0u8; 8192];
// 简化为直接切片;生产代码要处理跨块边界和缓冲
let n = cursor.read(&mut block)?;
if n == 0 { break; }
block
};
// 将块写入 CAS
let hash = self.store.put(&block)?;
chunks.push(hash);
}
// 2. 创建 ChunkList 节点
let chunk_list = ChunkList {
total_size: data.len() as u64,
chunks,
};
let chunk_list_bytes = bincode::serialize(&chunk_list)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
let chunk_list_hash = self.store.put(&chunk_list_bytes)?;
// 3. 创建 Snapshot 节点
let snapshot = Snapshot {
hash: [0u8; 32], // 临时,后面计算
chunk_list: chunk_list_hash,
created_at: Utc::now().timestamp(),
label: label.to_string(),
file_count: 1,
};
let snap_bytes = bincode::serialize(&snapshot)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
let snap_hash = self.store.put(&snap_bytes)?;
// 4. 更新 Snapshot 自身的 hash 字段(自引用)
let mut snapshot = snapshot;
snapshot.hash = snap_hash;
Ok(snapshot)
}
/// 从 Snapshot 还原数据
pub fn restore(&self, snapshot: &Snapshot) -> std::io::Result<Vec<u8>> {
let cl_bytes = self.store.get(&snapshot.chunk_list)?;
let chunk_list: ChunkList = bincode::deserialize(&cl_bytes)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
let mut result = Vec::with_capacity(chunk_list.total_size as usize);
for hash in &chunk_list.chunks {
let block = self.store.get(hash)?;
// 验证:重新计算哈希确认数据未被篡改
let actual_hash = Sha256::digest(&block);
if actual_hash.as_slice() != hash.as_slice() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Data corruption detected: expected {}, got {}",
hex::encode(hash), hex::encode(actual_hash))
));
}
result.extend_from_slice(&block);
}
Ok(result)
}
}
4.4 验证完整性的设计要点
restore 方法中包含了显式的哈希校验步骤,这是 CAS 系统区别于普通存储的核心安全保证。每次读取数据块时重新计算 SHA-256 并与索引比对,可以:
- 检测磁盘静默错误(bit rot),这类错误在大型存储阵列中并不罕见
- 检测恶意篡改(如果攻击者修改了存储文件,哈希校验会失败)
- 在分布式场景下验证对等节点返回的数据确实是请求的内容
五、生产级优化策略
一个能实际部署的 CAS 系统在上述原型基础上还需要解决以下问题:
5.1 引用计数与 GC
由于 DAG 结构允许多个快照共享同一个 BlobNode,删除快照时必须确认某个块不再被任何存活的快照引用。最简单的方案是维护一个引用计数:每个 BlobNode 记录有多少个 ChunkList 引用它,计数归零后异步回收。更高端的方案使用标记-清扫(Mark-Sweep)GC:从所有存活 Snapshot 出发遍历 DAG,未被访问到的 BlobNode 全部删除。
5.2 流式处理与内存控制
上面的实现为了代码可读性将所有数据加载到内存。生产代码必须确保无论文件多大,内存占用恒定:
- 分块时用一个固定大小的环形缓冲区(如 64KB),逐块流式写入
- ChunkList 的序列化不完全加载内存:可以先生成哈希列表到临时文件,再将其作为 BlobNode 存储
restore时按块流式返回Readtrait 对象而非完全读入Vec
5.3 大文件去重率优化
对于超过 1GB 的大文件(如虚拟机镜像、数据库备份),标准 CDC 产生数十万个块,索引过大且去重效果差。优化手段包括:
- 多层 CDC:先在 4MB 粒度做大窗口切割,再对每个分公司做 8KB CDC
- 块相似性检测:对高价值数据保留 MinHash 签名,即使不能精确去重也能识别相似块做 delta 编码
- 保留最后一个完整块边界:归档时确保 CDC 对齐到文件系统记录的块组边界
5.4 并发写入安全
多个客户端同时归档可能同一个文件时,put 操作的「检查-写入」竞争条件需要妥善处理。生产实现通常采用基于 SQLite 或 RocksDB 的原子元数据存储,用事务保证 (hash -> 引用计数) 的原子更新。对于纯粹的 Blob 存储,原子 rename 已经足以防止半写状态。
六、与现有方案对比
| 特性 | 本系统(原型) | restic | IPFS | git |
|---|---|---|---|---|
| 分块算法 | CDC FastCDC | CDC Rabin | 固定 256KB | 变长 packfile |
| 加密 | 无 | AES-256 | 无(可叠加) | 无 |
| 网络同步 | 无 | 后端驱动 | DHT/libp2p | git protocol |
| GC 策略 | 引用计数 | 标记-清扫 | 按 pin/unpack | git gc |
| 适用场景 | 本地/网络归档 | 安全备份去重 | 分布式内容分发 | 版本控制 |
可以看到我们的原型聚焦于本地归档去重场景,与 restic 功能最接近但更简化。真正的生产系统需要在加密、压缩、GC 上做大量工程投入。
七、总结
内容寻址存储的核心优雅之处在于它对"数据身份"的重新定义:数据不再是你放在哪里的东西,而是你手里的那个哈希值。这一理念使得去重从一种需要主动扫描和计算相似度的高成本操作,变成了存储本身的结构属性。
通过 Merkle DAG,CAS 获得了结构化的版本能力——每一次归档都只存储增量变化,同时天然具备完整性自验证的安全属性。无论是构建高效的备份系统、容器镜像分发、还是科学计算的 reproducibility pipeline,这套架构都经得住规模化考验。
下次当你发现团队服务器上成千上万份"相同"的报表文件时,不妨想想:也许你真正需要的不是更大的磁盘,而是一套看起来"方向相反"的存储哲学——先有内容,再有地址。

发表评论 取消回复