布隆过滤器 BlommFiter 原理分析与 Java 实现

357 阅读6分钟

布隆过滤器 BloomFilter 是一种概率型数据结构,主要用于判断某个元素是否存在于集合中。

核心思想

布隆过滤器的目标是解决大规模数据存在性问题,在节省空间的同时,允许一定程度的误判。

  • 肯定不存在:如果布隆过滤器判定一个元素不存在,那么它一定不存在。
  • 可能存在:如果布隆过滤器判定一个元素存在,它有可能是误判,实际上不存在。

基本原理

数据结构

布隆过滤器由两部分组成:

位数组:大小为 m ,初始化时全为 0,只存储 0 或 1。

哈希函数:k 个独立的哈希函数,将输入映射到位数组的索引上(范围 0 到 m-1 )。

操作过程

添加
  1. 使用 k 个哈希函数对元素 x 进行哈希运算,得到 k 个哈希值。
  2. 将对应位数组的 k 个位置设为 1。
查找
  1. 使用 k 个哈希函数对元素 x 进行哈希运算,得到 k 个哈希值。
  2. 如果位数组中的 k 个位置均为 1,则返回“可能存在”;若有一个为 0,则返回“一定不存在”。

示例

假设位数组大小为 m = 10,有 3 个哈希函数。

元素 x,哈希值分别为 h1(x) = 1, h2(x) = 2, h3(x) = 3。

元素 y,哈希值分别为 h1(y) = 1, h2(y) = 2, h3(y) = 3。

元素 z,哈希值分别为 h1(z) = 3, h2(z) = 6, h3(z) = 9。

x 和 y 具有相同的哈希值,用来演示误判。

添加 x 后,位数组状态:

索引0123456789
0111000000

查找 x,位数组中 1,2,3 位置均为 1 ,返回可能存在。

查找 y,位数组中 1,2,3 位置均为 1 ,返回可能存在,但实际上并没有添加 y。

查找 z,位数组中 6 的位置为 0 ,返回不可能存在。

误判原因

  1. 哈希冲突:不同元素的哈希值可能映射到相同的位数组位置。
  2. 位数组共享:多个元素可能映射到相同的位数组位置,查询时无法区分。

误判率优化

误判率

e 为自然对数的底数,约等于 2.71828。如 ln(2) = x,也就是 ex = 2, x ≈ 0.693 。

k 为哈希函数的数量。

m 为位数组的大小。

n 为需要添加的元素。

  • 随着 n 的增加,扩容位数组大小 m可以减少哈希碰撞的机会,从而降低误判率,但也会增加存储空间。计算公式
  • 调整哈希函数的数量 k,如果 k 太小,会有较高的误判率;如果 k 太大,哈希冲突增加,会导致性能下降,计算公式

优缺点和应用场景

优点

  1. 插入和查询的时间复杂度为 O(k),速度快。
  2. 相对于存储完整数据,布隆过滤器更节省空间。
  3. 适合处理大规模数据。

缺点

  1. 存在误判,但不会漏判真实存在的元素。
  2. 一旦元素添加进去,就无法删除。
  3. 需要设计高效的哈希函数,否则可能增加误判率。
  4. 不适用于需要删除或更新元素的场景

应用场景

  • 数据去重:比如内容推荐系统,用布隆过滤器来标记用户浏览过的内容,避免重复推荐。无需存储完整的浏览记录,节省空间且查询高效。
  • 缓存穿透防护:比如高并发环境下,使用布隆过滤器避免 Redis 缓存穿透,如果布隆过滤器表明某个数据不存在,则不用再去查询数据库,降低数据库负载,从而提高性能。
  • 文件内容相似度检测:比如在文件系统中,布隆过滤器可用于快速匹配文件的哈希值,判断是否有重复内容。

扩展

计数布隆过滤器

在标准布隆过滤器中,一旦被设置为 1,就无法修改。而在某些应用场景中,可能需要删除元素。为了解决这个问题,可以使用计数布隆过滤器,它为每个位置存储一个计数值,从而允许对已插入的元素进行删除,

双重布隆过滤器

双重布隆过滤器是通过组合两个布隆过滤器来减少误判率。首先用第一个布隆过滤器检查元素是否存在,如果第一个布隆过滤器判断元素存在,则使用第二个布隆过滤器进行进一步确认。

Java 实现

在一些开源工具包中,如 Google Guava 中, 使用了 MurmurHash 算法,并且支持多线程并发操作。

下面的示例中,只是实现一个简单的 String 布隆过滤器,不考虑多线程并发等问题。

主要是利用加密中的 MD5 算法当做哈希函数,BitSet 当做位数组。


import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.BitSet;

/**
 * 布隆过滤器
 * <br>
 * 元素存储基于 BitSet Hash 函数基于 MD5 算法
 *
 * @author sheng
 */
public class BloomFilter {

	/*位数组*/
	private final BitSet bitSet;
	/*位数组的大小*/
	private final int bitSetSize;
	/*哈希函数数量*/
	private final int hashCount;

	/**
	 * @param elements          预期元素数量
	 * @param falsePositiveRate 误判率
	 */
	public BloomFilter(int elements, double falsePositiveRate) {
		// 计算位数组的大小
		this.bitSetSize = (int) Math.ceil(
			(-elements * Math.log(falsePositiveRate)) / (Math.pow(Math.log(2), 2)));
		// 计算哈希函数的数量
		this.hashCount = (int) Math.ceil((this.bitSetSize / (double) elements) * Math.log(2));
		// 初始化位数组
		this.bitSet = new BitSet(this.bitSetSize);
	}


	/*MD5 哈希算法*/
	private byte[] md5(String element) {
		try {
			MessageDigest md = MessageDigest.getInstance("MD5");
			return md.digest(element.getBytes(StandardCharsets.UTF_8));
		} catch (NoSuchAlgorithmException e) {
			throw new RuntimeException("MD5 计算异常", e);
		}
	}

	/*计算多个哈希值对应的索引位置*/
	private int[] getIndexes(String element) {

		int[] indexes = new int[hashCount];

		byte[] md5Bytes = md5(element);

		/*
		 * MD5 结果为 16 字节 128 位,int 类型 32 位,所以每 4 个字节作为一个 hash 值,充当一个哈希函数的计算结果。
		 * 如果 hashCount > 4 则会循环上面的操作
		 * hash << 8,左移 8 位,为下一个字节腾出位置
		 * & 0xFF 等于 & 1111 1111,结果不会改变,byte 是有符号位的数据类型,转为无符号的位操作
		 * */
		for (int i = 0; i < hashCount; i++) {
			int hash = 0;
			for (int j = 0; j < 4; j++) {
				hash = (hash << 8) | (md5Bytes[(i * 4 + j) % md5Bytes.length] & 0xFF);
			}
			/*映射到位数组索引位置*/
			indexes[i] = Math.abs(hash % bitSetSize);
		}
		return indexes;
	}

	/**
	 * 添加元素
	 *
	 * @param element 元素值
	 */
	public void add(String element) {
		int[] indexes = getIndexes(element);
		for (int index : indexes) {
			// 将指定的索引位置设为true,也就是1
			bitSet.set(index);
		}
	}

	/**
	 * 判断元素是否存在
	 *
	 * @param element 元素值
	 * @return false 一定不存在,true 可能存在
	 */
	public boolean contains(String element) {
		int[] indexes = getIndexes(element);
		for (int index : indexes) {
			// 如果某个索引位置为 0,表示一定不包含该元素
			if (!bitSet.get(index)) {
				return false;
			}
		}
		// 如果所有索引位置都是 1,则可能包含该元素
		return true;
	}

    public int size() {
		return size;
	}

	public int hashCount() {
		return hashCount;
	}

}

测试:

public class BloomFilterTest {

	public static void main(String[] args) {

		BloomFilter bloomFilter = new BloomFilter(100, 0.05);
		System.out.printf("布隆过滤器:位数组长度=%s,哈希计算次数=%s%n",
			bloomFilter.size(),
			bloomFilter.hashCount());

		bloomFilter.add("hello");

		System.out.printf("hello 查找结果:%s%n", bloomFilter.contains("hello"));
		System.out.printf("world 查找结果:%s%n", bloomFilter.contains("world"));
	}
}

结果:

布隆过滤器:位数组长度=624,哈希计算次数=5
hello 查找结果:true
world 查找结果:false

误判率直接影响位数组长度和哈希计算次数,误判率越低,位数组越长,哈希次数越多。