利用 Merkle Tree 高效检测数据变更

在当今数字世界中,如何高效地验证大量数据的完整性是一个重要挑战。无论是云存储同步、区块链交易验证,还是 P2P 文件分享,都需要一个可靠且高效的方案。这就是我们今天要介绍的主角 ------ Merkle Tree(默克尔树)。

从哈希函数说起

我们都知道哈希函数可以接受任何输入,不管是单一文本还是一整个文件,都能生成一个唯一的输出,这个输出我们称之为「哈希值」或者「摘要」,它是一个由字符或数字组成的固定长度字符串。

例如,我们有一个叫做 foo.js 的文件,我们可以使用哈希函数 SHA1 生成一个值,如:5f44557c8c615183ddfc42e82544945ce01f3c2a。但只要对文件有一丁点的更改,哈希值也会变化,比如我们加入一个空格,它就会变成 490be46b3ce0259122c266f500919022d5046cf0。我们可以用哈希函数来验证两个文件是否相同,或者文件是否被篡改过。

一个文件当然很简单,但是如果我们有一大批文件呢,单独计算每个文件的哈希值会非常低效,这时有一种数据结构能高效的帮我们搞定这个问题,那就是 Merkle Tree,这种神奇的数据结构能帮我们高效精准地发现一批文件中的变更。

在本文中,我将为大家简单介绍 Merkle Tree 的基础概念和原理,提供一个代码实现,并给出一些应用。

什么是 Merkle Tree?

Merkle Tree(默克尔树)又叫哈希树,通常被实现为二叉树,广泛应用于区块链、文件校验和数据同步等领域,Git 中也使用了类似 Merkle Tree 的结构来存储和验证数据 ,其由以下几个部分构成:

  • 一个根节点
  • 一组中间节点
  • 一组叶子节点

看上去平平无奇,但是一会儿你就能理解这么分的意义所在了。

我们的 Merkle Tree 是由哈希函数自底向上构建出来的。

以一个目录下的所有文件来构建 Merkle Tree 为例,我们首先计算每一个文件的哈希值,将其作为 Merkle Tree 的一组叶子节点。真实的目录结构其实对我们构建 Merkle Tree 没有什么帮助,我们只需要将叶子节点两两配对(如果无法配对,为了保持二叉树结构,最后一个节点会被复制一次),创建父节点作为「中间节点」或者「分支」就好,让其保存根据子节点的值计算哈希值即可。就这样,按照之前的规则不断向上构建,我们会得到一个根节点。

如下图所示,假设我们有数据块 L1、L2、L3、L4,我们可以构建得到一个这样的 Merkle Tree。

如何,现在你应该能够理解为何我们要将 Merkle Tree 的结构拆解为根节点、中间节点和叶子节点了吧。

Merkle Tree 应用

数据同步 与 Merkle Tree

我们想象一个云端同步的例子,我正在使用一款网盘应用,同步我的本地目录和云端目录:

Plain 复制代码
/sync_folder/
    ├── file1.txt
    ├── file2.txt
    ├── file3.txt
    └── file4.txt

此时,我的本地目录和云端目录是同步的,构建的 Merkle Tree 结构为:

Plain 复制代码
            ROOT[7ab2c4e]
            /            \
    [45d31a9]         [88f7d55]
    /        \        /        \
[aaf4c61] [cc628cd] [23a1f22] [9a7c54b]
    |         |         |         |
  File1     File2     File3     File4

但后续我在本地对 file2 进行了修改:

Plain 复制代码
/sync_folder/
    ├── file1.txt
    ├── file2.txt(已修改)
    ├── file3.txt
    └── file4.txt

云盘这时想要发现哪些文件是不不同步的,就可以重新计算 Merkle Tree (红色表示发生变化的哈希值):

Plain 复制代码
             ROOT[🔴e9f8d2c]
            /            \
    [🔴b7a3c1]         [88f7d55]
    /        \        /        \
[aaf4c61] [🔴d4e5f6] [23a1f22] [9a7c54b]
    |         |         |         |
  File1     File2     File3     File4
          (已修改)

云盘可以通过一层层的比较 Merkle Tree,发现哪些文件和之前是不同的,从而后续对这些文件进行增量同步。

你也许发现了这就是一个二分查找的过程,其时间复杂度为 O(log n),相比一个查找对比要高效很多很多:

Plain 复制代码
文件数量    传统方式     Merkle Tree
10         10次比较     ≤ 4次比较
100        100次比较    ≤ 7次比较
1,000      1000次比较   ≤ 10次比较
10,000     10000次比较  ≤ 14次比较
100,000    100000次比较 ≤ 17次比较

我们可以实现一下这个同步检测的例子,大家可以试试:

js 复制代码
const crypto = require('crypto');

class MerkleNode {
    constructor(hash, filename = '') {
        this.hash = hash;
        this.filename = filename;
        this.left = null;
        this.right = null;
    }
}

class MerkleTree {
    constructor() {
        this.root = null;
    }

    // 计算数据的哈希值
    static hash(data) {
        return crypto.createHash('sha256').update(data).digest('hex');
    }

    // 构建 Merkle Tree
    buildTree(files) {
        // 创建叶子节点
        const leaves = Object.entries(files).map(([filename, content]) => {
            const hash = MerkleTree.hash(content);
            return new MerkleNode(hash, filename);
        });

        // 构建树
        this.root = this.buildTreeFromNodes(leaves);
        return this.root;
    }

    // 从节点数组构建树
    buildTreeFromNodes(nodes) {
        if (nodes.length === 0) return null;
        if (nodes.length === 1) return nodes[0];

        const parents = [];

        // 每次取两个节点组合
        for (let i = 0; i < nodes.length; i += 2) {
            const left = nodes[i];
            const right = i + 1 < nodes.length ? nodes[i + 1] : null;

            // 计算父节点的哈希值
            const combinedHash = right 
                ? MerkleTree.hash(left.hash + right.hash)
                : left.hash;

            const parent = new MerkleNode(combinedHash);
            parent.left = left;
            parent.right = right;

            parents.push(parent);
        }

        // 递归构建上层节点
        return this.buildTreeFromNodes(parents);
    }

    // 查找不同的文件
    findDifferences(otherTree) {
        const differences = [];
        
        const compare = (node1, node2) => {
            // 如果哈希值相同,说明这个分支下的所有文件都相同
            if (!node1 || !node2 || node1.hash === node2.hash) {
                return;
            }

            // 如果是叶子节点(有文件名),说明这个文件不同
            if (node1.filename) {
                differences.push(node1.filename);
                return;
            }

            // 递归比较左右子树
            compare(node1.left, node2.left);
            compare(node1.right, node2.right);
        };

        compare(this.root, otherTree.root);
        return differences;
    }
}

// 使用示例
function main() {
    // 模拟初始文件内容
    const originalFiles = {
        'file1.txt': 'Hello',
        'file2.txt': 'World',
        'file3.txt': 'Merkle',
        'file4.txt': 'Tree'
    };

    // 模拟修改后的文件内容(file2.txt 被修改)
    const modifiedFiles = {
        'file1.txt': 'Hello',
        'file2.txt': 'JavaScript',  // 修改了这个文件
        'file3.txt': 'Merkle',
        'file4.txt': 'Tree'
    };

    // 构建原始文件的 Merkle Tree(模拟云端)
    const cloudTree = new MerkleTree();
    cloudTree.buildTree(originalFiles);

    // 构建修改后文件的 Merkle Tree(模拟本地)
    const localTree = new MerkleTree();
    localTree.buildTree(modifiedFiles);

    // 查找差异
    const differences = localTree.findDifferences(cloudTree);
    console.log('需要同步的文件:', differences);

    // 打印验证过程
    console.log('\n验证过程:');
    console.log('根节点哈希值比较:');
    console.log('本地:', localTree.root.hash);
    console.log('云端:', cloudTree.root.hash);
}

main();

区块链与 Merkle Tree

在区块链网络中,每个区块都包含了大量的交易信息。为了让轻节点(如手机钱包)能够高效地验证交易,区块链使用了 Merkle Tree 结构。

如果不使用 Merkle Tree,我们将不得不下载整个区块来获取所有的交易数据,这会占用大量的带宽和存储空间。

假设我们的这个区块中包含四笔交易:

js 复制代码
          [ROOT]
         /      \
      [A]        [B]
    /    \      /    \
 [T1]   [T2]  [T3]  [T4]
  │      │     │      │
交易1   交易2  交易3   交易4

如果用户进行了「交易1」,那他的轻节点中包含数据:

  • 交易1 的全部数据
  • 经过 PoW 验证的可信的区块头(包含 Merkle Root)

我们进行「交易1」的验证:

  1. 向全节点请求 Merkle 证明路径(T2 和 B 的哈希值)
  2. 用本地的「交易1」计算 T1
  3. 用 T1 和获取的 T2 计算 A
  4. 用 A 和获取的 B 计算 ROOT
  5. 将计算得到的 ROOT 与区块头中存储的 Merkle Root 进行比对
  6. 如果比对一致,就证明这笔交易确实被区块链网络接受了

P2P 与 Merkle Tree

在 BitTorrent 等 P2P 网络中,大文件会被分割成多个小块进行传输。为了确保下载的文件块是完整且未被篡改的,需要一个高效的验证机制,这就是 Merkle Tree 的用武之地。

文件分享

首先,如果用户想分享一个文件,要先生成一个种子文件:

先对文件分块:

Plain 复制代码
大文件 ──切分──> [Block1] [Block2] [Block3] [Block4]
                 256MB   256MB   256MB   256MB

再构建 Merkle Tree:

Plain 复制代码
                  [ROOT: abc123...]
                  /              \
          [H1: def456]     [H2: ghi789]
          /          \     /          \
[B1: aaa111] [B2: bbb222] [B3: ccc333] [B4: ddd444]

这样种子文件就创建好了:

js 复制代码
{
    "info": {
        "name": "ubuntu-20.04.iso",
        "piece_length": 262144,  // 256MB
        "pieces": [
            "aaa111...", "bbb222...", 
            "ccc333...", "ddd444..."
        ],
        "merkle_root": "abc123...",
        "length": 1073741824     // 1GB
    },
    "announce": "http://tracker.example.com",
    "nodes": [
        ["node1.example.com", 6881],
        ["node2.example.com", 6881]
    ]
}

文件下载

因为文件被分块了,所以在下载时,我们可以并行下载文件的不同分块:

Plain 复制代码
Peer A ──> Block1
Peer B ──> Block2
Peer C ──> Block3
Peer D ──> Block4

当我们下载完成其中一个块时:

Plain 复制代码
                      [ROOT: abc123...]  ←── 种子文件中的根哈希
                      /              \
              [H1: def456]     [H2: ghi789]
              /          \     /          \
    [B1: aaa111] [B2: bbb222] [B3: ccc333] [B4: ddd444]
         ↑
    刚下载完的块

这时,我们本地有:

  • B1 的哈希值
  • 根哈希值

我们可以向其他 P2P 节点请求验证 B1 的路径,即:B2 和 H2 的哈希值,这样我们就可以在本地走一遍 Merkle Tree 的验证,从而确定 B1 没有被篡改,可以接受,从而进行后续下载。如果验证失败,会尝试从其他 P2P 节点下载。

最终当所有块下载完成后,会再次验证每个块的哈希值,尝试构建完整的 Merkle Tree,验证 Root Hash。

写在最后

Merkle Tree 的设计体现了计算机科学中「分而治之」的智慧 ------ 将大规模数据验证的复杂问题,通过树形结构分解为可以高效处理的子问题。在当今数据量爆炸的时代,这种数据结构的价值将会越发凸显。

希望这篇文章能帮助你理解 Merkle Tree 的原理和应用,也欢迎大家指出文中存在的问题。

参考资料

相关推荐
金创想42 分钟前
衡量算法效率的方法:时间复杂度、空间复杂度
算法·时间复杂度·空间复杂度·大o函数
有时间要学习1 小时前
专题十四——BFS
算法
c的s1 小时前
朴素贝叶斯方法
python·算法·机器学习
winner88811 小时前
当算法遇到线性代数(四):奇异值分解(SVD)
线性代数·算法·奇异值分解·svd
&白帝&2 小时前
Java数据类型
java·python·算法
工一木子2 小时前
【Leecode】Leecode刷题之路第99天之恢复二叉搜索树
java·算法·leetcode·二叉树·中序遍历
帅到爆的努力小陈2 小时前
进制转换(蓝桥杯)
java·数据结构·算法
Smark.2 小时前
(leetcode算法题)371. 两整数之和
算法·leetcode
KpLn_HJL2 小时前
leetcode - 1769. Minimum Number of Operations to Move All Balls to Each Box
算法·leetcode·职场和发展
格林威3 小时前
Baumer工业相机堡盟LXT工业相机如何升级固件使得相机具有RDMA功能
人工智能·数码相机·算法·计算机视觉·c#