Go 语言实现布隆过滤器:解决无效查询导致的缓存穿透问题

479 阅读6分钟

在缓存设计中,我们常常遇到一个令人头疼的问题:大量的“无效查询”让数据库压力倍增。比如,当用户请求一个不存在的数据时,我们通常会去数据库查询,而数据库返回“未找到”。如果这样的请求很多,数据库就会忙于处理这些无意义的查询,影响系统性能。那么,有没有办法在查询前就知道数据是否“可能存在”?这时候,布隆过滤器就派上用场了。


1. 传统缓存场景遇到的问题

设想这样一个场景:

  • 我们的系统有一个 缓存层(Redis)和一个 数据库层(MySQL)。
  • 当用户请求某个数据时,我们先查询缓存,如果命中缓存就直接返回。
  • 如果缓存中没有,我们查询数据库,如果数据库也没有,就返回“未找到”给用户。

乍一看,这样的设计很合理。但实际运行过程中,可能会出现大量的无效查询:

用户请求了 ID=99999999 的数据
    -> 缓存中没有
    -> 数据库查询 ID=99999999
    -> 数据库返回:未找到

如果用户多次请求完全不存在的数据,我们就会:

  • 缓存不起作用:每次都要查询数据库,缓存层无意义。
  • 数据库压力过大:大量无效请求会拖慢数据库响应。

一个常见的优化方案是提前过滤掉无效查询,让那些我们已经确定是不存在的数据直接返回,而不去查数据库。这就是布隆过滤器的用武之地。


2. 什么是布隆过滤器?

布隆过滤器(Bloom Filter)是一种高效的集合查询算法,它能快速判断某个值是否可能存在,或一定不存在。简单来说:

  • 如果布隆过滤器告诉你“不存在”,那就真的不存在,可以直接返回错误,不用查询数据库。
  • 如果布隆过滤器告诉你“可能存在”,那就查数据库确认一下

布隆过滤器的底层逻辑很简单:

  • 多个哈希函数把数据映射到一个固定大小的 bit 位数组
  • 查询时,把目标数据用同样的哈希函数计算,检查对应的 bit 位是否都为 1
  • 如果某些 bit 位为 0,说明数据一定不存在
  • 如果所有 bit 位都是 1,数据可能存在(但有一定误判率)。

优点:

  • 省内存,比传统哈希表占用空间更少。
  • 快速查询,时间复杂度接近 O(1)。
  • 高效过滤,减少数据库压力。

缺点:

  • 可能误判(但可以调整哈希函数数量降低误判率)。
  • 无法删除数据(一般用于大数据流或缓存场景)。

3. 布隆过滤器的数据结构

布隆过滤器的核心数据结构有两个:

  1. Bit 数组(bit array):用于存储是否存在某个值,初始时全是 0。
  2. 多个哈希函数:用于计算数据对应的 bit 位位置。

示例:

image.png

插入 "kAI",哈希计算后映射到 bit 数组的 2、5、8 位置:
索引:  0  1  2  3  4  5  6  7  8  9
值:    0  0  1  0  0  1  0  0  1  0

查询 "kAI" 时:

  • 计算哈希值,发现 2、5、8 位置都是 1,说明可能存在。
  • 计算 "bar",发现某些 bit 位是 0,直接返回“不存在”。

4. 用 Go 语言实现布隆过滤器

下面是一个使用 Go 语言实现的布隆过滤器

  • BloomFilter 结构体。
  • 构造函数 NewBloomFilter,它根据预期的元素数量和可接受的误报率来自动计算最佳的位数组大小 (m) 和哈希函数数量 (k)。
  • 构造函数 NewBloomFilterWithMK,允许你直接指定 m 和 k。
  • Add 方法用于添加元素。
  • MightContain 方法用于检查元素是否可能存在(可能会有误报,但绝不会漏报)。
  • getHashes 内部方法,使用双重哈希来生成 k 个哈希值。我们这里用 FNV-1a 哈希算法的两个变体作为基础哈希。

(1) 布隆过滤器结构

package main

import (
	"fmt"
	"hash/fnv"
)

// 布隆过滤器结构体
type BloomFilter struct {
	m    uint64 // 位数组的大小 (number of bits)
	k    uint64 // 哈希函数的数量 (number of hash functions)
	bits []byte // 位数组 (bit array)
}

// 创建布隆过滤器
func NewBloomFilter(expectedN uint64, falsePositiveRate float64) *BloomFilter {
	m, k := estimateParameters(expectedN, falsePositiveRate)
	if m == 0 || k == 0 {
		panic("Invalid parameters for Bloom filter: m or k is zero")
	}
	return NewBloomFilterWithMK(m, k)
}

// estimateParameters 根据预期的元素数量n和误报率p计算最佳的m和k
// m = - (n * ln(p)) / (ln(2))^2
// k = (m / n) * ln(2)
func estimateParameters(n uint64, p float64) (m uint64, k uint64) {
	if n == 0 || p <= 0 || p >= 1 {
		return 1000, 10 
	}
	mFloat := -(float64(n) * math.Log(p)) / (math.Ln2 * math.Ln2)
	kFloat := (mFloat / float64(n)) * math.Ln2

	m = uint64(math.Ceil(mFloat))
	k = uint64(math.Ceil(kFloat))

	if k < 1 {
		k = 1
	}
	return
}

// NewBloomFilterWithMK 使用指定的m和k创建布隆过滤器
func NewBloomFilterWithMK(m, k uint64) *BloomFilter {
	if m == 0 || k == 0 {
		panic("Invalid parameters for Bloom filter: m or k is zero")
	}
	numBytes := (m + 7) / 8
	return &BloomFilter{
		m:    m,
		k:    k,
		bits: make([]byte, numBytes),
	}
}

// getHashes 使用双重哈希为数据生成k个哈希值
func (bf *BloomFilter) getHashes(data []byte) []uint64 {
	hashes := make([]uint64, bf.k)

	// 使用 FNV-1a 的两个不同版本(或种子)作为基础哈希函数
	h1 := fnv.New64()
	h1.Write(data)
	hash1Val := h1.Sum64()

	h2 := fnv.New64a() 
	h2.Write(data)
	hash2Val := h2.Sum64()

	for i := uint64(0); i < bf.k; i++ {
		if hash2Val == 0 && i > 0 {
			hash2Val = 1
		}
		hashes[i] = (hash1Val + i*hash2Val) % bf.m
	}
	return hashes
}

(2) 插入数据

// 插入数据到布隆过滤器
func (bf *BloomFilter) Add(data []byte) {
	hashes := bf.getHashes(data)
	for _, h := range hashes {
		byteIndex := h / 8                     // 找到字节索引
		bitOffset := h % 8                     // 找到在该字节内的位偏移
		bf.bits[byteIndex] |= (1 << bitOffset) // 将对应的位置1
	}
}

(3) 查询数据

// 查询数据是否可能存在
func (bf *BloomFilter) MightContain(data []byte) bool {
	hashes := bf.getHashes(data)
	for _, h := range hashes {
		byteIndex := h / 8
		bitOffset := h % 8
		if (bf.bits[byteIndex] & (1 << bitOffset)) == 0 {
			// 如果任何一个哈希对应的位是0,则元素肯定不存在
			return false
		}
	}
	// 所有哈希对应的位都是1,元素可能存在
	return true
}

(4) 重置布隆过滤器

// Reset 清空布隆过滤器 (所有位置0)
func (bf *BloomFilter) Reset() {
	for i := range bf.bits {
		bf.bits[i] = 0
	}
}

(5) 测试代码

func main() {
	// 示例:预期1000个元素,误报率1%
	expectedN := uint64(1000)
	falsePositiveRate := 0.01

	m, k := estimateParameters(expectedN, falsePositiveRate)
	fmt.Printf("Estimated parameters: m = %d, k = %d\n", m, k)

	bf := NewBloomFilter(expectedN, falsePositiveRate)
	// 或者 bf := NewBloomFilterWithMK(m,k)

	// 添加一些元素
	item1 := []byte("apple")
	item2 := []byte("banana")
	item3 := []byte("cherry")

	bf.Add(item1)
	bf.Add(item2)

	fmt.Printf("MightContain 'apple': %t\n", bf.MightContain(item1))           // true
	fmt.Printf("MightContain 'banana': %t\n", bf.MightContain(item2))          // true
	fmt.Printf("MightContain 'cherry': %t\n", bf.MightContain(item3))          // false (应该为false,因为没添加)
	fmt.Printf("MightContain 'grape': %t\n", bf.MightContain([]byte("grape"))) // false (也应该为false)

	
	bf.Reset()
	fmt.Println("After Reset:")
	fmt.Printf("MightContain 'apple': %t\n", bf.MightContain(item1)) // false
}


5. 小结

布隆过滤器可以在缓存系统中帮助我们:

  • 减少无效数据库查询:先判断数据是否可能存在,避免直接访问数据库。
  • 节省存储空间:比哈希表更节省内存,适用于大规模数据。
  • 提高查询效率:O(1) 时间复杂度的查询,无需遍历整个数据集。

我们利用 Go 语言 实现了一个简单的布隆过滤器,结合缓存场景来优化数据库压力。