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),将哈希值作为唯一键来索引数据块。读取时,通过哈希值反查数据内容。这一简单变化带来了三个根本性的优势:

  1. 天然去重:无论数据被引用多少次,相同内容只存储一份。
  2. 完整性自验证:读取时重新计算哈希,与索引比较,可立即发现数据损坏或篡改。
  3. 不可变性(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 并与索引比对,可以:

  1. 检测磁盘静默错误(bit rot),这类错误在大型存储阵列中并不罕见
  2. 检测恶意篡改(如果攻击者修改了存储文件,哈希校验会失败)
  3. 在分布式场景下验证对等节点返回的数据确实是请求的内容

五、生产级优化策略

一个能实际部署的 CAS 系统在上述原型基础上还需要解决以下问题:

5.1 引用计数与 GC

由于 DAG 结构允许多个快照共享同一个 BlobNode,删除快照时必须确认某个块不再被任何存活的快照引用。最简单的方案是维护一个引用计数:每个 BlobNode 记录有多少个 ChunkList 引用它,计数归零后异步回收。更高端的方案使用标记-清扫(Mark-Sweep)GC:从所有存活 Snapshot 出发遍历 DAG,未被访问到的 BlobNode 全部删除。

5.2 流式处理与内存控制

上面的实现为了代码可读性将所有数据加载到内存。生产代码必须确保无论文件多大,内存占用恒定:

  • 分块时用一个固定大小的环形缓冲区(如 64KB),逐块流式写入
  • ChunkList 的序列化不完全加载内存:可以先生成哈希列表到临时文件,再将其作为 BlobNode 存储
  • restore 时按块流式返回 Read trait 对象而非完全读入 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,这套架构都经得住规模化考验。

下次当你发现团队服务器上成千上万份"相同"的报表文件时,不妨想想:也许你真正需要的不是更大的磁盘,而是一套看起来"方向相反"的存储哲学——先有内容,再有地址。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部