在区块链技术中,如何高效、快速地在不下载完整数据的情况下,判断某个交易是否存在于特定的区块中,或者某个合约是否发出了特定的日志事件,是一个核心问题,以太坊为了解决这个问题,巧妙地运用了一种概率性数据结构——Bloom过滤器,本文将深入以太坊的源码,探讨其Bloom过滤器的实现原理、编码细节及其在以太坊网络中的关键作用。

什么是Bloom过滤器

Bloom过滤器是由Burton Howard Bloom于1970年提出的一种空间效率很高的随机数据结构,它利用位数组来表示一个集合,并用于判断一个元素是否可能属于该集合,或一定不属于该集合。

其主要特点:

  1. 空间效率高:相比于存储元素本身,Bloom过滤器只需要很小的存储空间。
  2. 查询速度快:时间复杂度为O(k),k为哈希函数的数量。
  3. 概率性
    • 假阳性(False Positive):可能会判断一个元素在集合中,即使它并不在,这是Bloom过滤器最主要的特性。
    • 假阴性(False Negative):绝对不会发生,如果判断元素不在集合中,那么它一定不在。
  4. 确定性删除困难:标准Bloom过滤器不支持元素的删除(除非使用Counting Bloom Filter等变种)。

在以太坊中,每个区块头都包含一个bloom字段,这是一个由该区块内所有交易产生的日志事件共同构建的Bloom过滤器,节点可以通过查询这个过滤器,快速判断某个地址或主题是否在该区块中有日志,从而决定是否需要下载该区块的完整日志数据。

以太坊Bloom过滤器的设计与源码结构

以太坊的Bloom过滤器实现主要位于其核心代码库的ethcore包中(具体路径可能因版本和客户端实现而异,如Go-Ethereum的core/types包),我们以Go-Ethereum(geth)为例,来剖析其源码。

数据结构定义

core/types/block.go或相关文件中,我们可以找到Bloom类型的定义:

// Bloom represents a 2048 bit bloom filter.
type Bloom [256]byte // 256 bytes = 2048 bits

这是一个长度为256字节的数组,总共2048位,这就是Bloom过滤器的位数组。

Bloom过滤器构建(添加元素)

构建Bloom过滤器的过程,就是将多个元素(在以太坊中主要是日志主题和地址)通过多个哈希函数映射到位数组中的特定位置,并将这些位置的位设置为1。

以太坊使用了3个不同的哈希函数(实际上是SHA3哈希的不同部分)来确保均匀分布和降低假阳性率,这个逻辑通常在一个名为AddBloom的函数中实现(可能在core/bloom或直接在types包内)。

核心步骤如下(伪代码/关键逻辑):

func AddBloom(b *Bloom, data []byte) {
    // 使用3个不同的哈希函数对data进行哈希
    // 在以太坊中,这通常是通过截取SHA3哈希的不同部分来模拟的
    hash1 := sha3.Sum256(data)       // 第一个哈希值
    hash2 := sha3.Sum256(append(data, 0x01)) // 第二个哈希值(通过添加不同后缀区分)
    hash3 := sha3.Sum256(append(data, 0x02)) // 第三个哈希值
    // 从每个哈希值中提取足够的位来定位到2048位中的某一位
    // 通常取哈希的低几位进行组合,确保索引在0-2047之间
    // 
    idx1 := binary.BigEndian.Uint64(hash1[:8]) % 2048
    idx2 := binary.BigEndian.Uint64(hash2[:8]) % 2048
    idx3 := binary.BigEndian.Uint64(hash3[:8]) % 2048
    // 将位数组中对应的位置为1
    setBit(b, idx1)
    setBit(b, idx2)
    setBit(b, idx3)
}
// setBit 将Bloom过滤器指定位设置为1
func setBit(b *Bloom, idx uint64) {
    bytePos := idx / 8
    bitPos := idx % 8
    b[bytePos] |= 1 << (7 - bitPos) // 大端序,高位在前
}

在以太坊的实际源码中,CreateBloom函数会遍历区块中的所有交易,再遍历每个交易中的所有日志,然后对每个日志的地址(Address)和每个主题(Topic)调用类似的AddBloom逻辑,最终将所有这些哈希结果合并到一个大的Bloom过滤器中。

Bloom过滤器查询(检查元素)

查询一个元素是否可能在Bloom过滤器中,过程与添加类似:

  1. 对该元素应用相同的3个哈希函数,得到3个索引值。
  2. 检查这3个索引值在位数组中对应的位是否都为1。
    • 如果有任何一位为0,则该元素一定不在集合中(假阴性不可能)。
    • 如果所有位都为1,则该元素可能在集合中(存在假阳性的可能)。

以太坊源码中对应的查询函数类似:

func BloomLookup(b Bloom, data []byte) bool {
    hash1 := sha3.Sum256(data)
    hash2 := sha3.Sum256(append(data, 0x01))
    hash3 := sha3.Sum256(append(data, 0x02))
    idx1 := binary.BigEndian.Uint64(hash1[:8]) % 2048
    idx2 := binary.BigEndian.Uint64(hash2[:8]) % 2048
    idx3 := binary.BigEndian.Uint64(hash3[:8]) % 2048
    return getBit(b, idx1) && getBit(b, idx2) && getBit(b, idx3)
}
// getBit 获取Bloom过滤器指定位的值
func getBit(b Bloom, idx uint64) bool {
    bytePos := idx / 8
    bitPos := idx % 8
    return (b[bytePos] >> (7 - bitPos)) & 1 == 1
}

特殊的“OR”操作

由于一个区块的Bloom过滤器是由该区块内所有交易的日志Bloom过滤器“或”(OR)运算得到的,以太坊源码中会有一个BloomOr函数,用于将两个Bloom过滤器按位进行OR操作,合并结果。

func BloomOr(b1, b2 Bloom) Bloom {
    var result Bloom
    for i := 0; i < 256; i++ {
        result[i] = b1[i] | b2[i]
    }
    return result
}

这个特性使得构建大型区块的Bloom过滤器变得高效,可以并行处理各个交易的日志Bloom,最后再合并。

源码中的关键细节与优化

  1. 哈希函数的选择:以太坊使用SHA3(Keccak)哈希算法
    随机配图
    的变体来生成哈希值,通过使用不同的输入(如添加后缀0x01, 0x02)来模拟多个哈希函数,既保证了哈希的质量,又避免了实现多个独立哈希函数的复杂性。
  2. 位序与字节序:在设置和获取位时,需要注意大端序(BigEndian)的影响,确保位的位置计算正确,以太坊的实现中,每个字节的最高位(MSB)对应索引的较低位(字节0的bit7对应索引0,bit6对应索引1,.,bit0对应索引7)。
  3. 日志数据的编码:在将地址(Address)和主题(Topic)添加到Bloom过滤器之前,它们会被序列化为特定的格式,地址通常是20字节,主题通常是32字节(对于主题0)或未定义(对于其他主题,会被视为空)。
  4. 区块头中的Bloom字段:区块头中的Bloom [256]byte字段就是由上述过程生成的,它包含了该区块所有日志信息的“指纹”。

Bloom过滤器在以太坊中的应用场景

  1. 轻客户端(Light Clients):轻客户端无法存储完整的区块链数据,它们可以下载区块头,并通过查询区块头的Bloom字段,快速判断某个地址的活动或某个主题的事件是否发生在某个区块中,从而决定是否需要从全节点获取该区块的完整日志或交易数据。
  2. 事件索引与查询:DApp开发者可以通过以太坊的JSON-RPC接口(如eth_getLogs)来查询符合特定条件的日志,节点在处理这类查询时,首先会使用Bloom过滤器进行初步筛选,只处理那些可能包含目标日志的区块,大大提高了查询效率,尤其是在处理大量历史数据时。 3