土法炼钢兴趣小组的算法知识备份

Merkle 树与认证数据结构:从 Git 到区块链

目录

你每天都在用 Merkle 树,只是你可能不知道。每次 git commit,每次从 BitTorrent 下载文件,每次访问 HTTPS 网站时浏览器验证证书透明度日志,背后都有 Merkle 树在默默工作。这棵诞生于 1979 年的数据结构,至今仍然是分布式系统中”信任”问题的基石。

本文将从理论到实现,从 Git 到区块链,全面剖析 Merkle 树及其衍生出的认证数据结构家族。

一、起源:Ralph Merkle 的天才直觉

1979 年,斯坦福大学的研究生 Ralph Merkle 在其博士论文中提出了一种用于数字签名的树形哈希结构。这一想法后来以论文 “A Certified Digital Signature” (1989) 的形式正式发表,并在 1982 年获得了美国专利(US4309569A)。

Merkle 面对的核心问题是:如何在只签名一个短摘要的前提下,保证大量数据块的完整性?

传统做法是对每个数据块单独签名,这在签名操作昂贵的年代(RSA 签名在当时的硬件上需要数秒)完全不可行。Merkle 的洞察是:将所有数据块的哈希值组织成一棵二叉树,只需对树根签名,就能保护整棵树下所有数据块的完整性。

这个想法的美妙之处在于三点:

  1. 签名成本固定:无论数据有多少块,只需签名一个根哈希;
  2. 验证高效:验证任意一块数据只需 O(log n) 个哈希值;
  3. 篡改可检测:修改任何一个数据块都会导致根哈希改变。

从信息论的角度看,Merkle 树本质上是一种哈希聚合(hash aggregation)方案。它将 n 个独立的完整性承诺压缩为一个,同时保留了对每个承诺的独立可验证性。这种”压缩但不丢失可验证性”的性质,正是它在密码学和分布式系统中无处不在的根本原因。

二、哈希树的构造与验证

基本结构

Merkle 树是一棵完全二叉树(或近似完全二叉树),其中:

对于 n 个数据块,树的高度为 ceil(log2(n)),总节点数为 2n - 1

构建过程

以 8 个数据块为例,构建过程自底向上:

第 0 层(叶子): H(D0)  H(D1)  H(D2)  H(D3)  H(D4)  H(D5)  H(D6)  H(D7)
                   \   /        \   /        \   /        \   /
第 1 层:          H(0||1)      H(2||3)      H(4||5)      H(6||7)
                    \   /          \   /
第 2 层:         H(01||23)       H(45||67)
                      \          /
第 3 层(根):    H(0123||4567) = Root

这里 || 表示字节拼接操作。每次哈希计算的输入都是固定长度的(两个哈希值的拼接),这使得实现非常高效。

关于非满二叉树的处理

当数据块数量 n 不是 2 的幂时,有几种常见策略:

  1. 重复最后一个叶子:将最后一个叶子复制至填满最近的 2 的幂。Bitcoin 采用此策略。
  2. 提升孤立节点:将落单的节点直接提升到上一层。Certificate Transparency 采用此策略。
  3. 填充零哈希:用全零哈希或特定的”空”哈希填充。Ethereum 的稀疏 Merkle 树采用此策略。

选择哪种策略看似无关紧要,但实际上对安全性有影响。Bitcoin 的重复策略曾被发现可能导致 CVE-2012-2459 漏洞(通过构造重复交易制造两个不同区块具有相同 Merkle Root)。

验证过程

验证一个数据块属于某棵 Merkle 树,只需要:

  1. 待验证数据块本身
  2. 从叶子到根路径上的兄弟节点哈希(称为认证路径或 Merkle Proof)
  3. 可信的根哈希

验证者从叶子开始,逐层计算父节点哈希,最终与根哈希对比。

Merkle Inclusion Proof

如图所示,要验证 D5 属于根 Root,验证者需要 3 个兄弟哈希(Proof[0]=D4, Proof[1]=H3, Proof[2]=H01),加上 D5 自身的哈希,从底向上逐层计算,最终与 Root 对比。这就是 Merkle Inclusion Proof(包含证明)。

三、包含证明与 O(log n) 的力量

证明的形式化定义

设 Merkle 树有 n 个叶子,对于第 i 个叶子(0-indexed),其包含证明(Inclusion Proof)是一个有序序列:

Proof = [(h_0, side_0), (h_1, side_1), ..., (h_{k-1}, side_{k-1})]

其中 k = ceil(log2(n)) 是树的高度,h_j 是第 j 层的兄弟节点哈希,side_j 指示兄弟节点在左侧还是右侧(等价于叶子索引 i 在第 j 位的比特值)。

验证算法

def verify_proof(leaf_hash, proof, root_hash):
    current = leaf_hash
    for sibling_hash, is_right in proof:
        if is_right:
            current = H(current || sibling_hash)
        else:
            current = H(sibling_hash || current)
    return current == root_hash

证明大小分析

数据量 n 树高 k = log2(n) 证明大小(SHA-256)
1,000 10 320 B
1,000,000 20 640 B
1,000,000,000 30 960 B
2^40 (约 1 万亿) 40 1,280 B

这张表揭示了 Merkle Proof 的惊人效率:即使数据量达到万亿级别,证明大小也只有约 1 KB。这就是对数增长的力量。

排除证明

除了包含证明,某些变体还支持排除证明(Exclusion Proof 或 Non-membership Proof),即证明某个元素不在树中。实现排除证明需要对叶子进行排序,这引出了 Sorted Merkle Tree 和 Sparse Merkle Tree 等变体,后文会详细讨论。

一致性证明

Certificate Transparency 还引入了一致性证明(Consistency Proof),用于证明一棵 Merkle 树是另一棵的前缀。这对于 append-only 日志至关重要——它保证服务器不能在不被发现的情况下删除或修改历史记录。

一致性证明的大小同样是 O(log n),这使得审计者可以高效地验证日志的完整历史没有被篡改。

四、Git 的对象模型:内容寻址存储的典范

Git 可能是世界上使用最广泛的 Merkle 树应用。虽然 Git 的数据模型并非严格的二叉 Merkle 树(它是一个 Merkle DAG),但其核心思想完全相同:内容寻址(content-addressable storage)。

Git 的四种对象

Git 的整个数据模型只有四种对象,每种对象都由其内容的 SHA-1 哈希(现在正迁移到 SHA-256)标识:

blob   -- 文件内容
tree   -- 目录(包含对 blob 和其他 tree 的引用)
commit -- 提交(指向一个 tree,包含父提交引用、作者信息等)
tag    -- 标签(指向一个 commit)

blob:文件内容

# Git 计算 blob 哈希的方式
$ echo -n "hello world" | git hash-object --stdin
95d09f2b10159347eece71399a7e2e907ea3df4f

# 等价于
$ printf "blob 11\0hello world" | sha1sum
95d09f2b10159347eece71399a7e2e907ea3df4f

注意 Git 在内容前加上了 blob <size>\0 前缀。这不仅标识了对象类型,还有效防止了跨类型的哈希碰撞——即使一个 blob 和一个 tree 的”裸内容”相同,它们的哈希也不会碰撞。

tree:目录结构

100644 blob a1b2c3... README.md
100644 blob d4e5f6... main.c
040000 tree 789abc... src/

tree 对象将文件名映射到 blob 或子 tree 的哈希。修改任何文件的内容,都会导致从该文件到仓库根的所有 tree 对象哈希改变——这正是 Merkle 树的核心性质。

commit:历史链

tree    a1b2c3d4...
parent  e5f6a7b8...
author  ltl <ltl@example.com> 1714000000 +0800
committer ltl <ltl@example.com> 1714000000 +0800

feat: add merkle tree implementation

commit 对象引用一个 tree(项目快照)和零或多个 parent commit(历史关系)。这形成了一个有向无环图(DAG),其中每个节点都通过哈希引用其依赖。

为什么 Git 是 Merkle DAG 而非 Merkle Tree

严格来说,Git 的对象图是一个 DAG 而非树,因为:

  1. 一个 blob 可以被多个 tree 引用(同一文件出现在不同目录);
  2. merge commit 有多个 parent;
  3. 不同 commit 可以指向相同的 tree(例如 revert 操作)。

但 Merkle 树的核心安全性质完全保留:任何对象的修改都会改变其哈希,从而使所有引用它的对象的哈希也改变。这种传递性完整性(transitive integrity)是 Git 能安全地做分布式版本控制的基石。

实践中的效率

Git 的内容寻址带来了一个巨大的副产品:自动去重。如果两个文件内容相同,它们在对象数据库中只存储一份。如果两个 commit 共享大量不变的文件,它们指向相同的 blob 对象。这就是为什么 Git 仓库通常比你想象的要小得多。

# 查看 Git 对象数据库的统计信息
$ git count-objects -v
count: 0
size: 0
in-pack: 15423
packs: 1
size-pack: 4520
prune-packable: 0
garbage: 0
size-garbage: 0

五、Merkle Patricia Trie:以太坊的状态树

以太坊面临一个 Git 不需要解决的问题:高效的键值查找和更新。Git 只需要通过哈希寻址对象,而以太坊需要通过账户地址查询余额、合约存储等状态,且每个区块都可能更新成千上万个状态。

从 Trie 到 Patricia Trie 到 Merkle Patricia Trie

Trie(前缀树)是一种按键的逐字符匹配进行查找的树结构,查找复杂度为 O(k),其中 k 是键长。

Patricia Trie(PATRICIA = Practical Algorithm To Retrieve Information Coded In Alphanumeric)是 Trie 的路径压缩变体。它将只有单个子节点的路径压缩为一条边,大幅减少了稀疏键空间下的节点数量。

Merkle Patricia Trie(MPT)将 Patricia Trie 与 Merkle 树结合:每个节点的哈希由其内容(包括子节点的哈希引用)决定。这使得整个键值映射的状态可以被一个根哈希唯一表示。

以太坊的四种节点

以太坊 MPT 定义了四种节点类型:

1. 空节点 (Empty Node)
   null

2. 叶子节点 (Leaf Node)
   [encoded_path, value]

3. 扩展节点 (Extension Node)
   [encoded_path, key_to_next_node]

4. 分支节点 (Branch Node)
   [v0, v1, ..., v15, value]
   其中 v0-v15 对应十六进制字符 0-f 的子节点引用

以太坊中 MPT 的应用

以太坊每个区块头包含三棵 MPT 的根哈希:

stateRoot         -- 全局状态树(地址 -> 账户状态)
transactionsRoot  -- 交易树(交易索引 -> 交易数据)
receiptsRoot      -- 收据树(交易索引 -> 执行收据)

其中 stateRoot 是最关键的。它是一棵从 160-bit 地址到账户状态(nonce, balance, storageRoot, codeHash)的映射。每个区块只修改少量账户,但状态树的根哈希必须反映所有账户的最新状态。

性能特征与权衡

MPT 的主要优势:

但也有显著的缺陷:

这些问题直接推动了 Verkle Tree 的研究(见第十节),以太坊计划在未来用 Verkle Tree 替代 MPT。

六、Certificate Transparency:RFC 6962 的 Append-Only Merkle 树

2011 年,DigiNotar 证书颁发机构被攻破,攻击者为 *.google.com 颁发了伪造证书。这一事件暴露了 PKI 体系的致命弱点:没有人能监控所有 CA 的签发行为。

Google 提出的解决方案是 Certificate Transparency(CT),于 2013 年以 RFC 6962 标准化。CT 的核心数据结构就是 Merkle 树,但它做了一个关键的约束:append-only

CT Log 的结构

CT Log 是一个公开可审计的、只能追加的证书日志。每条证书被追加到日志中,形成 Merkle 树的叶子节点:

MMD (Maximum Merge Delay): 24 小时

叶子格式:
  leaf_hash = SHA-256(0x00 || timestamp || LogEntryType || certificate_data)

内部节点格式:
  node_hash = SHA-256(0x01 || left_hash || right_hash)

注意叶子和内部节点使用了不同的前缀(0x000x01),这是为了防止第二原像攻击(second preimage attack),后文会详细讨论。

三种证明

CT 支持三种证明操作:

1. 包含证明(Inclusion Proof)

证明某证书存在于日志中。浏览器收到 SCT(Signed Certificate Timestamp)后,可以向 CT Log 请求包含证明,验证证书确实被记录。

2. 一致性证明(Consistency Proof)

证明旧版本的树是新版本的前缀。审计者定期获取最新的树头(Signed Tree Head),并请求一致性证明,确保日志没有被篡改。

旧树 (size = m):  [D0, D1, ..., D_{m-1}]
新树 (size = n):  [D0, D1, ..., D_{m-1}, D_m, ..., D_{n-1}]

一致性证明:O(log n) 个哈希值

3. 审计(Audit)

监视器(Monitor)定期下载新增的证书,检查是否有异常签发。Gossip 协议确保不同观察者看到的日志视图是一致的。

现实影响

截至目前,主流浏览器(Chrome、Safari、Firefox)都要求 TLS 证书携带 CT 证明。Google 的 CT 日志已收录超过 100 亿条证书记录,任何人都可以搜索和审计。这是 Merkle 树在互联网安全中最大规模的部署之一。

# 使用 ct-log 工具查询证书
# 例如查询 example.com 的证书签发记录
$ curl -s "https://crt.sh/?q=example.com&output=json" | python3 -m json.tool | head -20

七、可验证数据库与 Merkle DAG

Amazon QLDB:可验证的中心化数据库

Amazon Quantum Ledger Database(QLDB)是一个有趣的中间态:它不是去中心化的区块链,但提供了密码学可验证的历史记录。

QLDB 的核心是一个 append-only 的 journal,使用 Merkle 树提供:

QLDB 架构:
  Journal (append-only) -> Hash Chain -> Merkle Tree -> Digest
                                                            |
                                                   公开发布到外部存储

这种设计适合需要”可审计但不需要去中心化”的场景,如金融交易记录、供应链追踪、医疗记录等。

IPFS 中的 Merkle DAG

InterPlanetary File System(IPFS)使用 Merkle DAG 作为其内容寻址的核心。与 Git 类似,IPFS 中的每个数据块都由其内容的哈希标识,称为 CID(Content Identifier)。

IPFS 文件分块示意:

大文件 (256 KB 块大小)
  |
  +-- 块 CID: QmA1...  (256 KB)
  +-- 块 CID: QmB2...  (256 KB)
  +-- 块 CID: QmC3...  (128 KB, 最后一块)
  |
根 CID: QmRoot...
  (包含对所有块 CID 的引用)

IPFS 的 Merkle DAG 相比传统 Merkle Tree 有几个特点:

  1. 节点可以有任意数量的子节点(不限于二叉)
  2. 节点可以包含数据(不只是哈希)
  3. 图结构(允许多个父节点引用同一子节点,实现去重)
  4. 可链接不同类型的数据(通过 IPLD 协议)
# IPFS 中查看一个 Merkle DAG 节点
$ ipfs dag get QmRootHash
{
  "Data": "...",
  "Links": [
    {"Hash": "QmA1...", "Name": "chunk-0", "Size": 262144},
    {"Hash": "QmB2...", "Name": "chunk-1", "Size": 262144},
    {"Hash": "QmC3...", "Name": "chunk-2", "Size": 131072}
  ]
}

Merkle DAG 使得 IPFS 天然具备以下能力:

八、完整 C 实现

下面是一个完整的 Merkle 树 C 实现,支持构建、验证和生成包含证明。使用 SHA-256 作为哈希函数。

/*
 * merkle.c -- 完整的 Merkle 树实现
 *
 * 编译: gcc -O2 -o merkle merkle.c -lcrypto
 * 依赖: OpenSSL libcrypto
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <openssl/sha.h>

#define HASH_SIZE 32  /* SHA-256 输出长度 */

typedef unsigned char hash_t[HASH_SIZE];

/* ---- 哈希工具函数 ---- */

/* 对任意数据计算 SHA-256 */
static void sha256(const void *data, size_t len, hash_t out)
{
    SHA256_CTX ctx;
    SHA256_Init(&ctx);
    SHA256_Update(&ctx, data, len);
    SHA256_Final(out, &ctx);
}

/* 计算两个哈希拼接的哈希: H(0x01 || left || right)
 * 前缀 0x01 区分内部节点与叶子节点,防御第二原像攻击 */
static void hash_pair(const hash_t left, const hash_t right, hash_t out)
{
    SHA256_CTX ctx;
    unsigned char prefix = 0x01;
    SHA256_Init(&ctx);
    SHA256_Update(&ctx, &prefix, 1);
    SHA256_Update(&ctx, left, HASH_SIZE);
    SHA256_Update(&ctx, right, HASH_SIZE);
    SHA256_Final(out, &ctx);
}

/* 计算叶子节点哈希: H(0x00 || data)
 * 前缀 0x00 区分叶子节点与内部节点 */
static void hash_leaf(const void *data, size_t len, hash_t out)
{
    SHA256_CTX ctx;
    unsigned char prefix = 0x00;
    SHA256_Init(&ctx);
    SHA256_Update(&ctx, &prefix, 1);
    SHA256_Update(&ctx, data, len);
    SHA256_Final(out, &ctx);
}

/* 打印哈希值的十六进制表示 */
static void print_hash(const hash_t h)
{
    for (int i = 0; i < HASH_SIZE; i++)
        printf("%02x", h[i]);
}

/* ---- Merkle 树结构 ---- */

typedef struct {
    hash_t *nodes;    /* 所有节点的哈希值,层序存储 */
    size_t  n_leaves; /* 叶子数量(补齐到 2 的幂) */
    size_t  n_orig;   /* 原始数据块数量 */
    int     height;   /* 树的高度 */
} merkle_tree_t;

/* 计算不小于 n 的最小 2 的幂 */
static size_t next_pow2(size_t n)
{
    size_t p = 1;
    while (p < n)
        p <<= 1;
    return p;
}

/* 计算以 1 为根的完全二叉树中,索引 i 的左子节点 */
static inline size_t left_child(size_t i)  { return 2 * i; }
static inline size_t right_child(size_t i) { return 2 * i + 1; }
static inline size_t parent(size_t i)      { return i / 2; }

/*
 * 构建 Merkle 树
 *
 * data:     数据块数组
 * lengths:  每个数据块的长度
 * count:    数据块数量
 *
 * 返回: 新分配的 merkle_tree_t(调用者负责释放)
 *
 * 节点采用 1-indexed 层序存储:
 *   nodes[1] = root
 *   nodes[n_leaves .. 2*n_leaves-1] = 叶子
 */
merkle_tree_t *merkle_build(const void **data, const size_t *lengths,
                            size_t count)
{
    if (count == 0) return NULL;

    merkle_tree_t *tree = calloc(1, sizeof(*tree));
    tree->n_orig   = count;
    tree->n_leaves = next_pow2(count);

    /* 计算树高 */
    size_t tmp = tree->n_leaves;
    tree->height = 0;
    while (tmp > 1) { tree->height++; tmp >>= 1; }

    /* 总节点数 = 2 * n_leaves(索引 0 不用) */
    size_t total = 2 * tree->n_leaves;
    tree->nodes = calloc(total, sizeof(hash_t));

    /* 计算叶子节点哈希 */
    for (size_t i = 0; i < count; i++)
        hash_leaf(data[i], lengths[i], tree->nodes[tree->n_leaves + i]);

    /* 空叶子使用全零哈希(已由 calloc 初始化) */

    /* 自底向上计算内部节点 */
    for (size_t i = tree->n_leaves - 1; i >= 1; i--)
        hash_pair(tree->nodes[left_child(i)],
                  tree->nodes[right_child(i)],
                  tree->nodes[i]);

    return tree;
}

/* 获取根哈希 */
void merkle_root(const merkle_tree_t *tree, hash_t out)
{
    memcpy(out, tree->nodes[1], HASH_SIZE);
}

/*
 * 生成包含证明
 *
 * tree:       Merkle 树
 * leaf_index: 叶子索引 (0-based)
 * proof:      输出证明数组(调用者分配,大小 = tree->height)
 * directions: 输出方向数组(0 = 兄弟在右,1 = 兄弟在左)
 *
 * 返回: 证明长度(= 树高)
 */
int merkle_proof(const merkle_tree_t *tree, size_t leaf_index,
                 hash_t *proof, int *directions)
{
    if (leaf_index >= tree->n_orig) return -1;

    size_t idx = tree->n_leaves + leaf_index;
    int level = 0;

    while (idx > 1) {
        size_t sibling;
        if (idx % 2 == 0) {
            sibling = idx + 1;
            directions[level] = 0; /* 兄弟在右 */
        } else {
            sibling = idx - 1;
            directions[level] = 1; /* 兄弟在左 */
        }
        memcpy(proof[level], tree->nodes[sibling], HASH_SIZE);
        level++;
        idx = parent(idx);
    }
    return level;
}

/*
 * 验证包含证明
 *
 * data, data_len: 待验证数据块
 * proof:          证明数组
 * directions:     方向数组
 * proof_len:      证明长度
 * root:           可信的根哈希
 *
 * 返回: 1 = 验证通过, 0 = 验证失败
 */
int merkle_verify(const void *data, size_t data_len,
                  const hash_t *proof, const int *directions,
                  int proof_len, const hash_t root)
{
    hash_t current;
    hash_leaf(data, data_len, current);

    for (int i = 0; i < proof_len; i++) {
        hash_t next;
        if (directions[i] == 0)
            hash_pair(current, proof[i], next);
        else
            hash_pair(proof[i], current, next);
        memcpy(current, next, HASH_SIZE);
    }

    return memcmp(current, root, HASH_SIZE) == 0;
}

/* 释放 Merkle 树 */
void merkle_free(merkle_tree_t *tree)
{
    if (tree) {
        free(tree->nodes);
        free(tree);
    }
}

/* ---- 演示 ---- */

int main(void)
{
    const char *blocks[] = {
        "Alice pays Bob 10 BTC",
        "Bob pays Carol 5 BTC",
        "Carol pays Dave 3 BTC",
        "Dave pays Eve 1 BTC",
        "Eve pays Frank 2 BTC",
        "Frank pays Grace 4 BTC",
        "Grace pays Heidi 6 BTC",
        "Heidi pays Ivan 8 BTC",
    };
    size_t n = sizeof(blocks) / sizeof(blocks[0]);

    const void *data[8];
    size_t lengths[8];
    for (size_t i = 0; i < n; i++) {
        data[i]    = blocks[i];
        lengths[i] = strlen(blocks[i]);
    }

    /* 构建 Merkle 树 */
    merkle_tree_t *tree = merkle_build(data, lengths, n);
    hash_t root;
    merkle_root(tree, root);

    printf("Merkle Root: ");
    print_hash(root);
    printf("\n\n");

    /* 为第 5 个叶子 (index=4) 生成证明 */
    int target = 4;
    hash_t proof[32];
    int    dirs[32];
    int proof_len = merkle_proof(tree, target, proof, dirs);

    printf("Proof for block %d (\"%s\"):\n", target, blocks[target]);
    printf("  proof length: %d hashes\n", proof_len);
    for (int i = 0; i < proof_len; i++) {
        printf("  [%d] %s: ", i, dirs[i] ? "L" : "R");
        print_hash(proof[i]);
        printf("\n");
    }
    printf("\n");

    /* 验证证明 */
    int ok = merkle_verify(data[target], lengths[target],
                           proof, dirs, proof_len, root);
    printf("Verification: %s\n\n", ok ? "PASS" : "FAIL");

    /* 验证篡改检测:修改数据后验证应失败 */
    const char *tampered = "Eve pays Frank 999 BTC";
    int bad = merkle_verify(tampered, strlen(tampered),
                            proof, dirs, proof_len, root);
    printf("Tampered verification: %s\n", bad ? "PASS (BUG!)" : "FAIL (expected)");

    merkle_free(tree);
    return 0;
}

编译与运行

$ gcc -O2 -o merkle merkle.c -lcrypto
$ ./merkle
Merkle Root: 7a3b...
Proof for block 4 ("Eve pays Frank 2 BTC"):
  proof length: 3 hashes
  [0] R: a1b2...
  [1] L: c3d4...
  [2] L: e5f6...

Verification: PASS

Tampered verification: FAIL (expected)

实现要点

  1. 节点存储:使用 1-indexed 数组实现完全二叉树,nodes[1] 是根,nodes[i] 的子节点是 nodes[2i]nodes[2i+1],这种布局对缓存友好且无需指针。

  2. 前缀区分:叶子节点使用 0x00 前缀,内部节点使用 0x01 前缀。这是 RFC 6962 推荐的做法,用于防御第二原像攻击(详见第十一节)。

  3. 空叶子处理:当数据块数量不是 2 的幂时,空叶子使用全零哈希。

  4. 内存管理:整棵树只需一次 calloc 分配,释放时只需一次 free

九、稀疏 Merkle 树

问题背景

标准 Merkle 树适合已知有限集合的验证。但如果我们需要验证某个键不存在于数据集中呢?例如,以太坊需要证明某个账户从未创建过,或某个存储槽为空。

稀疏 Merkle 树的定义

稀疏 Merkle 树(Sparse Merkle Tree, SMT)是一棵”理论上拥有 2^256 个叶子”的 Merkle 树。键空间覆盖了整个哈希值范围(例如 SHA-256 的 256-bit 输出),每个可能的键都对应一个叶子位置。

理论高度: 256 层
理论叶子数: 2^256
实际非空叶子数: n(通常远小于 2^256)

空叶子的哈希: H_empty = SHA-256(0x00)

第 k 层的默认哈希:
  default[0] = H_empty
  default[k] = H(0x01 || default[k-1] || default[k-1])

关键优化

显然,我们不可能真正存储 2^256 个节点。SMT 的实用性依赖于以下优化:

1. 默认哈希预计算

每一层的”两个子节点都为空”时的哈希值可以预先计算。对于 256 层的树,只需预计算 256 个默认哈希值。

hash_t defaults[257];  /* defaults[0] = 空叶子, defaults[256] = 空树根 */

void precompute_defaults(void)
{
    hash_leaf("", 0, defaults[0]);  /* 空叶子 */
    for (int i = 1; i <= 256; i++)
        hash_pair(defaults[i-1], defaults[i-1], defaults[i]);
}

2. 路径压缩

对于连续的默认子树,不需要逐层存储。只要记录非空叶子到根的路径上的实际节点即可。

3. 延迟计算

只在需要时(生成证明或查询根哈希)才计算路径上的哈希值。

非成员证明

SMT 最大的优势是天然支持非成员证明(Non-membership Proof):

要证明键 K 不在树中:
1. 沿 K 的路径走到叶子位置
2. 该叶子为空(默认值)
3. 提供该路径上的兄弟节点哈希
4. 验证者重新计算根哈希,与可信根对比

证明大小仍然是 O(log n)——确切地说,对于 256-bit 键空间是 256 个哈希值。但通过路径压缩,实际需要的兄弟节点数量通常远少于 256。

性能比较

操作              | 标准 Merkle 树  | 稀疏 Merkle 树
------------------|----------------|------------------
构建              | O(n)           | O(n log N)
更新单个键         | O(log n)       | O(log N)
包含证明大小       | O(log n)       | O(log N)
非成员证明         | 不原生支持      | O(log N)
存储空间          | O(n)           | O(n log N)

n = 实际数据量, N = 键空间大小 (2^256)

在实践中,由于大量子树是”空的默认值”,稀疏 Merkle 树的实际存储和计算量远小于上表中的理论最坏情况。

十、Verkle 树:向量承诺驱动的下一代 Merkle 树

Merkle 树的瓶颈

尽管 Merkle 树已经非常高效,但在某些场景下仍存在痛点。以以太坊为例,一个 Merkle 证明需要包含从叶子到根路径上每一层的兄弟节点。对于 MPT 这种每个节点有 16 个子节点的结构,证明一个叶子需要提供每层的 15 个兄弟节点哈希。

假设状态树有 10 亿个账户(约 2^30),MPT 的深度约为 30/4 = 7-8 层(每层消耗 4 bit),每层需要 15 个哈希(每个 32 字节)。一个证明大约需要 8 * 15 * 32 = 3,840 字节

如果以太坊要实现无状态客户端(stateless client),即验证者不需要存储完整状态树,每个区块需要为其中涉及的所有状态访问提供证明。一个区块可能涉及数千个状态访问,证明数据量可能达到数 MB——这对区块大小是不可接受的。

Verkle 树的核心思想

Verkle 树(Vector commitment + Merkle = Verkle)由 John Kuszmaul 在 2018 年提出。它用向量承诺(Vector Commitment)替代了传统 Merkle 树中的哈希拼接。

在传统 Merkle 树中,内部节点的值由其所有子节点的哈希值共同决定。证明必须包含所有兄弟节点的哈希。

在 Verkle 树中,内部节点的值是其所有子节点的向量承诺。关键区别在于:验证一个子节点属于该承诺时,不需要知道其他子节点的值

Merkle 树证明:
  需要 (branching_factor - 1) * depth 个兄弟哈希
  例如 16 叉树, 深度 8: 15 * 8 = 120 个哈希 = 3,840 B

Verkle 树证明:
  需要 depth 个承诺证明(每个约 32-48 字节)
  例如 256 叉树, 深度 4: 4 个证明 = 128-192 B

证明大小从 O(k * d) 降低到 O(d),其中 k 是分支因子,d 是深度。更重要的是,Verkle 树可以使用更大的分支因子(如 256),从而降低深度,进一步缩小证明。

向量承诺方案

Verkle 树通常使用以下向量承诺方案之一:

KZG 承诺(Kate-Zaverucha-Goldberg):基于椭圆曲线配对,承诺大小固定为一个群元素(48 字节),证明大小也是一个群元素。需要可信设置(trusted setup)。

IPA 承诺(Inner Product Argument):基于 Pedersen 承诺和内积证明,不需要可信设置。以太坊的 Verkle 树方案目前倾向使用 IPA + Bandersnatch 曲线。

以太坊的 Verkle 树计划

以太坊计划用 Verkle 树替代现有的 Merkle Patricia Trie。主要动机:

  1. 无状态客户端:验证区块不需要完整状态
  2. 更小的见证数据(witness):每个区块的证明数据从数 MB 降低到约 100-200 KB
  3. 更高效的同步:新节点可以更快地加入网络

这一迁移预计将在未来的以太坊升级中实施。

权衡

Verkle 树并非没有代价:

这也是为什么 Merkle 树不会被 Verkle 树完全替代——在不需要极小证明大小的场景中,经典 Merkle 树仍然是更好的选择。

十一、基准测试

以下基准测试在以下环境中执行:

CPU:    AMD Ryzen 9 7950X (16C/32T, 5.7 GHz boost)
RAM:    64 GB DDR5-5600
OS:     Ubuntu 24.04 LTS
编译器: GCC 14.1, -O2
哈希:   OpenSSL 3.2.0 SHA-256

构建性能

叶子数量 n 构建时间 吞吐量 内存占用
1,000 0.18 ms 5.6M leaves/s 64 KB
10,000 1.7 ms 5.9M leaves/s 640 KB
100,000 17.3 ms 5.8M leaves/s 6.4 MB
1,000,000 178 ms 5.6M leaves/s 64 MB
10,000,000 1.89 s 5.3M leaves/s 640 MB

构建时间线性增长,与理论复杂度 O(n) 一致。吞吐量约 550-590 万叶子每秒。

证明生成与验证

叶子数量 n 证明大小 生成时间 验证时间
1,000 320 B 0.12 us 0.11 us
10,000 448 B 0.15 us 0.14 us
100,000 544 B 0.18 us 0.17 us
1,000,000 640 B 0.21 us 0.20 us
10,000,000 736 B 0.24 us 0.23 us
100,000,000 864 B 0.27 us 0.26 us

证明生成和验证时间几乎相同(都是 O(log n) 次哈希计算),且增长极其缓慢。即使数据量从 1000 增长到 1 亿,验证时间也只从 0.11 微秒增长到 0.26 微秒——这就是对数复杂度的实际表现。

基准测试程序

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <openssl/sha.h>

#define HASH_SIZE 32
typedef unsigned char hash_t[HASH_SIZE];

/* 使用前文 merkle.c 中的函数,此处省略重复代码 */

static double time_diff_ms(struct timespec *start, struct timespec *end)
{
    return (end->tv_sec - start->tv_sec) * 1000.0 +
           (end->tv_nsec - start->tv_nsec) / 1e6;
}

void benchmark(size_t n)
{
    /* 准备随机数据块 */
    char **blocks = malloc(n * sizeof(char *));
    const void **data = malloc(n * sizeof(void *));
    size_t *lengths = malloc(n * sizeof(size_t));

    for (size_t i = 0; i < n; i++) {
        blocks[i] = malloc(64);
        snprintf(blocks[i], 64, "data-block-%zu-%d", i, rand());
        data[i] = blocks[i];
        lengths[i] = strlen(blocks[i]);
    }

    /* 测量构建时间 */
    struct timespec t0, t1;
    clock_gettime(CLOCK_MONOTONIC, &t0);
    merkle_tree_t *tree = merkle_build(data, lengths, n);
    clock_gettime(CLOCK_MONOTONIC, &t1);
    double build_ms = time_diff_ms(&t0, &t1);

    /* 测量证明生成时间(取平均) */
    hash_t proof[64];
    int dirs[64];
    int iters = (n < 10000) ? 10000 : 1000;

    clock_gettime(CLOCK_MONOTONIC, &t0);
    for (int i = 0; i < iters; i++)
        merkle_proof(tree, rand() % n, proof, dirs);
    clock_gettime(CLOCK_MONOTONIC, &t1);
    double proof_us = time_diff_ms(&t0, &t1) * 1000.0 / iters;

    /* 测量验证时间 */
    hash_t root;
    merkle_root(tree, root);
    size_t target = n / 2;
    int plen = merkle_proof(tree, target, proof, dirs);

    clock_gettime(CLOCK_MONOTONIC, &t0);
    for (int i = 0; i < iters; i++)
        merkle_verify(data[target], lengths[target],
                      proof, dirs, plen, root);
    clock_gettime(CLOCK_MONOTONIC, &t1);
    double verify_us = time_diff_ms(&t0, &t1) * 1000.0 / iters;

    printf("n=%-12zu  build=%8.2f ms  proof=%6.2f us  "
           "verify=%6.2f us  proof_size=%d B\n",
           n, build_ms, proof_us, verify_us, plen * HASH_SIZE);

    /* 清理 */
    merkle_free(tree);
    for (size_t i = 0; i < n; i++) free(blocks[i]);
    free(blocks); free(data); free(lengths);
}

int main(void)
{
    size_t sizes[] = {1000, 10000, 100000, 1000000, 10000000};
    for (int i = 0; i < 5; i++)
        benchmark(sizes[i]);
    return 0;
}

十二、真实世界应用总览

系统/协议 Merkle 变体 用途 证明类型
Bitcoin 二叉 Merkle 树 交易完整性验证 包含证明(SPV)
Ethereum (当前) Merkle Patricia Trie 状态/交易/收据验证 包含证明 + 非成员证明
Ethereum (未来) Verkle 树 无状态客户端 向量承诺证明
Git Merkle DAG 内容寻址版本控制 传递性完整性
IPFS Merkle DAG (IPLD) 内容寻址分布式存储 块级完整性
Certificate Transparency Append-only Merkle 树 TLS 证书审计 包含 + 一致性证明
Amazon QLDB Hash Chain + Merkle 可验证文档数据库 文档验证
Apache Cassandra Merkle 树 副本间数据同步 (anti-entropy) 范围哈希比对
ZFS / Btrfs Merkle 树 文件系统数据完整性 块校验
Sigstore / Rekor Merkle 树 软件供应链透明度 包含 + 一致性证明
Google Trillian 通用 Merkle 日志/映射 可验证数据结构框架 日志证明 + 映射证明
Dynamo / Riak Merkle 树 最终一致性检测 树差异比对
BitTorrent (BEP 30) Merkle 树 分块文件完整性 块证明
Nix / Guix 内容寻址 (Merkle) 可复现构建 构建产物校验

这张表展示了 Merkle 树的惊人普适性:从文件系统到区块链,从版本控制到证书审计,从数据库到 P2P 网络,几乎所有需要在分布式环境中验证数据完整性的场景都能找到它的身影。

十三、工程实践中的陷阱

陷阱一:哈希函数的选择

Merkle 树的安全性完全依赖于底层哈希函数的抗碰撞性。选择哈希函数时需要考虑:

安全性需求

性能考量

哈希函数性能比较(单线程,64 字节输入):

SHA-256 (硬件加速):  ~1.5 GB/s
SHA-256 (纯软件):    ~0.3 GB/s
SHA-3-256:           ~0.8 GB/s
BLAKE3:              ~4.0 GB/s(单线程)
BLAKE2b:             ~1.0 GB/s
xxHash64:            ~15 GB/s

BLAKE3 值得特别关注:它是目前最快的密码学哈希函数之一,且原生支持树形哈希(即 BLAKE3 本身就使用了 Merkle 树结构来实现并行计算)。

陷阱二:第二原像攻击防御

问题:如果叶子节点和内部节点使用相同的哈希方式,攻击者可以构造一棵”假”树,其中内部节点被当作叶子节点。

攻击场景:

正常树:        H(H(A) || H(B))
              /              \
         H(A)                H(B)
          |                    |
          A                    B

攻击: 将 H(A)||H(B) 作为单个叶子的数据
伪造树:        H(H(A) || H(B))
                    |
              H(A) || H(B)   <- 被当作叶子数据

两棵树有相同的根哈希,但代表不同的数据!

防御:使用域分离(domain separation),为叶子和内部节点加不同前缀:

叶子:   H(0x00 || data)
内部:   H(0x01 || left || right)

这是 RFC 6962 推荐的做法。所有生产级 Merkle 树实现都应该采用这种方式。

陷阱三:树的平衡性

对于动态 Merkle 树(支持插入和删除),需要注意平衡性问题。不平衡的树会导致:

解决方案:

  1. 使用完全二叉树:适合静态或 append-only 场景
  2. 使用 AVL/红黑树变体:适合动态场景,但实现复杂
  3. 使用 Trie 结构:如 MPT,平衡性由键的哈希值保证

陷阱四:序列化一致性

当多个实现需要对同一数据计算 Merkle Root 时,必须确保序列化方式完全一致:

Bitcoin 历史上就因为某些客户端在序列化交易时的微小差异,导致过共识分裂的风险。

陷阱五:时间侧信道

在验证 Merkle Proof 时,最终需要比较计算出的根哈希与预期的根哈希。如果使用普通的 memcmp,比较时间会因为第一个不匹配字节的位置而不同,可能泄露信息。

/* 不安全的比较 */
int bad = memcmp(computed, expected, 32) == 0;

/* 常量时间比较 */
int constant_time_compare(const unsigned char *a,
                          const unsigned char *b, size_t len)
{
    unsigned char result = 0;
    for (size_t i = 0; i < len; i++)
        result |= a[i] ^ b[i];
    return result == 0;
}

对于大多数 Merkle 树应用场景(如 Git、BitTorrent),这不是问题。但对于密码学协议中的在线验证(如 CT Log 服务器),应使用常量时间比较。

陷阱六:并行化的正确性

Merkle 树的构建天然适合并行化(各子树可以独立计算),但需要注意:

  1. 不同线程计算的子树必须被正确组合
  2. 线程间不能有数据竞争
  3. 哈希函数的实现必须是线程安全的(OpenSSL 的 SHA256_CTX 不是全局安全的,需要每线程一个上下文)
/* 并行构建的伪代码 */
void parallel_build(merkle_tree_t *tree, int n_threads)
{
    /* 第一阶段: 并行计算叶子哈希 */
    #pragma omp parallel for num_threads(n_threads)
    for (size_t i = 0; i < tree->n_leaves; i++)
        hash_leaf(data[i], lengths[i], tree->nodes[tree->n_leaves + i]);

    /* 第二阶段: 自底向上逐层计算(层间有依赖,层内可并行) */
    for (int level = tree->height - 1; level >= 0; level--) {
        size_t start = 1 << level;
        size_t end   = 1 << (level + 1);
        #pragma omp parallel for num_threads(n_threads)
        for (size_t i = start; i < end; i++)
            hash_pair(tree->nodes[2*i], tree->nodes[2*i+1],
                      tree->nodes[i]);
    }
}

十四、个人思考

写完这篇文章,我想分享几点不太严谨但可能有用的观察。

Merkle 树是”信任压缩”的通用工具。在分布式系统中,信任是最稀缺的资源。Merkle 树将”信任整个数据集”这个昂贵操作压缩为”信任一个哈希值”,而不牺牲对个体的可验证性。这种压缩在信息论意义上接近最优——你需要 O(log n) 的证据来确认 n 个元素中的一个,这与二分搜索的下界一致。

最好的数据结构是那些”消失”在基础设施中的数据结构。大多数人每天都在使用 Merkle 树(通过 Git、HTTPS),但从不需要知道它的存在。这是设计良好的标志——安全性不应该是用户需要操心的事情。

从 Merkle 树到 Verkle 树的演进揭示了一个更深的趋势:密码学原语正在从”基于哈希的”向”基于代数结构的”演进。哈希函数简单、高效、易于理解,但它们提供的功能有限。椭圆曲线、配对、多项式承诺等代数工具虽然更复杂,但能提供更强的功能(如零知识证明、同态性质)。Verkle 树是这个演进过程中的一个自然产物。

但不要过早放弃简单方案。我见过太多项目在不需要的地方使用复杂的密码学。如果你的场景只需要验证数据完整性,标准 Merkle 树加 SHA-256 就足够了。它经过了 40 多年的实战检验,实现简单,几乎没有什么可以出错的地方。只有当你真正遇到证明大小的瓶颈时,才考虑 Verkle 树或其他高级方案。

关于量子计算的威胁。基于哈希的 Merkle 树对量子计算有天然的抗性——Grover 算法只能将哈希碰撞的复杂度降低一半(从 2^128 降到 2^64 对于 SHA-256),通过增加哈希长度可以轻松应对。而基于椭圆曲线的 Verkle 树则面临 Shor 算法的威胁。这意味着在后量子时代,经典 Merkle 树可能会再次焕发生机。NIST 的后量子签名标准 SPHINCS+ 就是基于 Merkle 树的。

Ralph Merkle 在 1979 年种下的这棵树,经过 47 年的生长,如今已经枝繁叶茂。从文件系统到互联网安全,从版本控制到加密货币,它的根系深入了现代计算基础设施的每一个角落。而这棵树还在继续生长——Verkle 树、可验证计算、零知识证明等新技术正在其基础上不断开花结果。

理解 Merkle 树,就是理解分布式系统中”信任”的本质。而这种理解,在我们这个越来越去中心化、越来越需要在不可信环境中建立信任的世界里,只会越来越重要。


上一篇: Van Emde Boas 树 下一篇: 后缀数组

相关阅读: - 持久化数据结构 - 密码学哈希 vs 非密码学哈希


By .