冷饭新炒:理解布隆过滤器算法的实现原理

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
简介: 这是《冷饭新炒》系列的第六篇文章。本文会翻炒一个用途比较广的算法 - 布隆过滤器算法。

前提



这是《冷饭新炒》系列的第六篇文章。

微信截图_20220513205712.png


本文会翻炒一个用途比较广的算法 - 布隆过滤器算法


布隆过滤器的一些概念



主要包括:

  • 简介
  • 算法
  • 参数
  • 优势和劣势


布隆过滤器简介


布隆过滤器是一种空间高效概率性的数据结构(百科中原文是a space-efficient probabilistic data structure),该数据结构于1970年由Burton Howard Bloom提出,作用是测试一个元素是否某个集合的一个成员。布隆过滤器是可能出现false positive(这个是专有名词"假阳性",可以理解为误判的情况,下文如果用到这个名词会保留英文单词使用)匹配的,换言之,布隆过滤器在使用的时候有可能返回结果"可能存在于集合中"或者"必定不存在于集合中"。


布隆过滤器算法描述


在场景复杂的网络爬虫中,爬取到的网页URL依赖有可能成环,例如在URL-1页面中展示了URL-2,然后又在URL-2中的页面展示了URL-1,这个时候需要一种方案记录和判断历史访问过的URL。这个时候可能会想到下面的方案:


  • 方案一:使用数据库存储已经访问过的URL,例如MySQL表中基于URL建立唯一索引或者使用RedisSET数据类型
  • 方案二:使用HashSet(其实这里不局限于HashSet,链表、树和散列表等数据结构都能满足)存储已经访问过的URL
  • 方案三:基于方案一和方案二进行优化,存储URL的摘要,使用摘要算法如MD5SHA-n算法针对URL字符串生成摘要
  • 方案四:使用Hash函数处理对应的URL生成一个哈希码,再把哈希码通过一个映射函数映射到一个固定容量的BitSet中的某一个比特


对于方案一、方案二和方案三,在历史访问URL数据量极大的情况下,会消耗巨大的存储空间(磁盘或者内存),对于方案四,如果URL100亿个,那么要把冲突几率降低到1%,那么BitSet的容量需要设置为10000亿。

微信截图_20220513205719.png


所以上面的四种方案都有明显的不足之处,而布隆过滤器算法的基本思路跟方案四差不多,最大的不同点就是方案四中只提到使用了一个散列函数,而布隆过滤器中使用了kk >= 1)个相互独立的高效低冲突的散列函数。


一个初始化的布隆过滤器是一个所有比特都设置为0的长度为m的比特数组,也就是认知中的Bit ArrayBit Set或者Redis中的Bit Map概念。然后需要引入k个不同的散列函数,某个新增元素通过这k个散列函数处理之后,映射到比特数组m个比特中的k个,并且把这些命中映射的k个比特位设置为1,产生一个均匀的随机分布。通常情况下,k的一个较小的常数,取决于所需的误判率,而布隆过滤器容量m与散列函数个数k和需要添加元素数量呈正相关。

微信截图_20220513205726.png


当需要新增的所有元素都添加到布隆过滤器之后,那么比特数组中的很多比特都被设置为1。这个时候如果需要判断一个元素是否存在于布隆过滤器中,只需要通过k个散列函数处理得到比特数组的k个下标,然后判断比特数组对应的下标所在比特是否为1。如果这k个下标所在比特中至少存在一个0,那么这个需要判断的元素必定不在布隆过滤器代表的集合中;如果这k个下标所在比特全部都为1,那么那么这个需要判断的元素可能存在于布隆过滤器代表的集合中或者刚好是一个False Positive,至于误差率分析见下文的布隆过滤器的相关参数一节。False Positive出现的情况可以见下图:

微信截图_20220513205733.png


当添加到布隆过滤器的元素数量比较大,并且布隆过滤器的容量设置不合理(过小),容易出现多个元素通过k个散列函数,映射到相同的k个位(如上图的下标139所在的位),这个时候就无法准确判断这k个位由具体那个元素映射而来。其实可以极端一点思考:假设布隆过滤器容量为24,散列函数只有一个,那么添加最多25个不同元素,必定有两个不同的元素的映射结果落在同一个位。


布隆过滤器的相关参数


在算法描述一节已经提到过,布隆过滤器主要有下面的参数:

  • 初始化比特数组容量m
  • 散列函数个数k
  • 误判率ε(数学符号Epsilon,代表False Positive Rate
  • 需要添加到布隆过滤器的元素数量n


考虑到篇幅原因,这里不做这几个值的关系推导,直接整理出结果和关系式。

  • 误判率ε的估算值为:[1 - e^(-kn/m)]^k
  • 最优散列函数数量k的推算值:对于给定的mn,当k = m/n * ln2的时候,误判率ε最低
  • 推算初始化比特容量m的值,当k = m/n * ln2的时候,m >= n * log2(e) * log2(1/ε)


这里贴一个参考资料中m/nkFalse Positive Rate之间的关系图:


网络异常,图片无法展示
|


网络异常,图片无法展示
|


网络异常,图片无法展示
|


这里可以推算一下表格中最大参数所需要的空间极限,假设n10亿,m/n = 32,那么m320亿,而k24,此时的误判率为2.17e-07(0.000000217),需要空间3814.69727m。一般规律是:

  • k固定的时候,m/n越大,误判率越小
  • m/n固定的时候,k越大,误判率越大


通常情况下,k需要固定,而n是无法确定准确值,最好要评估增长趋势预先计算一个比较大的m值去降低误判率,当然也要权衡m值过大导致空间消耗过大的问题。

既然参数的关系式都已经有推导结果,可以基于关系式写一个参数生成器:


import java.math.BigDecimal;
import java.math.RoundingMode;
public class BloomFilterParamGenerator {
    public BigDecimal falsePositiveRate(int m, int n, int k) {
        double temp = Math.pow(1 - Math.exp(Math.floorDiv(-k * n, m)), k);
        return BigDecimal.valueOf(temp).setScale(10, RoundingMode.FLOOR);
    }
    public BigDecimal kForMinFalsePositiveRate(int m, int n) {
        BigDecimal k = BigDecimal.valueOf(Math.floorDiv(m, n) * Math.log(2));
        return k.setScale(10, RoundingMode.FLOOR);
    }
    public BigDecimal bestM(int n, double falsePositiveRate) {
        double temp = log2(Math.exp(1) + Math.floor(1 / falsePositiveRate));
        return BigDecimal.valueOf(n).multiply(BigDecimal.valueOf(temp)).setScale(10, RoundingMode.FLOOR);
    }
    public double log2(double x) {
        return Math.log(x) / Math.log(2);
    }
    public static void main(String[] args) {
        BloomFilterParamGenerator generator = new BloomFilterParamGenerator();
        System.out.println(generator.falsePositiveRate(2, 1, 2));  // 0.3995764008
        System.out.println(generator.kForMinFalsePositiveRate(32, 1)); // 22.1807097779
        System.out.println(generator.bestM(1, 0.3995764009)); // 2.2382615950
    }
}
复制代码


这里的计算没有考虑严格的进位和截断,所以和实际的结果可能有偏差,只提供一个参考的例子。


布隆过滤器的优势和劣势


布隆过滤器的优势:

  • 布隆过滤器相对于其他数据结构在时空上有巨大优势,占用内存少,查询和插入元素的时间复杂度都是O(k)
  • 可以准确判断元素不存在于布隆过滤器中的场景
  • 散列函数可以独立设计
  • 布隆过滤器不需要存储元素本身,适用于某些数据敏感和数据严格保密的场景


布隆过滤器的劣势:

  • 不能准确判断元素必定存在于布隆过滤器中的场景,存在误判率,在km固定的情况下,添加的元素越多,误判率越高
  • 没有存储全量的元素,对于一些准确查询或者准确统计的场景不适用
  • 原生的布隆过滤器无法安全地删除元素


这里留一个很简单的问题给读者:为什么原生的布隆过滤器无法安全地删除元素?(可以翻看之前的False Positive介绍)


布隆过滤器算法实现



著名的Java工具类库Guava中自带了一个beta版本的布隆过滤器实现,这里参考其中的源码实现思路和上文中的算法描述进行一次布隆过滤器的实现。先考虑设计散列函数,简单一点的方式就是参考JavaBeanhashCode()方法的设计:


// 下面的方法来源于java.util.Arrays#hashCode
public static int hashCode(Object a[]) {
    if (a == null)
        return 0;
    int result = 1;
    for (Object element : a)
        result = 31 * result + (element == null ? 0 : element.hashCode());
    return result;
}
复制代码


上面方法的31可以作为一个输入的seed,每个散列函数设计一个独立的seed,并且这个seed值选用素数基于字符串中的每个char进行迭加就能实现计算出来的结果是相对独立的:


import java.util.Objects;
public class HashFunction {
    /**
     * 布隆过滤器容量
     */
    private final int m;
    /**
     * 种子
     */
    private final int seed;
    public HashFunction(int m, int seed) {
        this.m = m;
        this.seed = seed;
    }
    public int hash(String element) {
        if (Objects.isNull(element)) {
            return 0;
        }
        int result = 1;
        int len = element.length();
        for (int i = 0; i < len; i++) {
            result = seed * result + element.charAt(i);
        }
        // 这里确保计算出来的结果不会超过m
        return (m - 1) & result;
    }
}
复制代码


接着实现布隆过滤器:


public class BloomFilter {
    private static final int[] K_SEED_ARRAY = {5, 7, 11, 13, 31, 37, 61, 67};
    private static final int MAX_K = K_SEED_ARRAY.length;
    private final int m;
    private final int k;
    private final BitSet bitSet;
    private final HashFunction[] hashFunctions;
    public BloomFilter(int m, int k) {
        this.k = k;
        if (k <= 0 && k > MAX_K) {
            throw new IllegalArgumentException("k = " + k);
        }
        this.m = m;
        this.bitSet = new BitSet(m);
        hashFunctions = new HashFunction[k];
        for (int i = 0; i < k; i++) {
            hashFunctions[i] = new HashFunction(m, K_SEED_ARRAY[i]);
        }
    }
    public void addElement(String element) {
        for (HashFunction hashFunction : hashFunctions) {
            bitSet.set(hashFunction.hash(element), true);
        }
    }
    public boolean contains(String element) {
        if (Objects.isNull(element)) {
            return false;
        }
        boolean result = true;
        for (HashFunction hashFunction : hashFunctions) {
            result = result && bitSet.get(hashFunction.hash(element));
        }
        return result;
    }
    public int m() {
        return m;
    }
    public int k() {
        return k;
    }
    public static void main(String[] args) {
        BloomFilter bf = new BloomFilter(24, 3);
        bf.addElement("throwable");
        bf.addElement("throwx");
        System.out.println(bf.contains("throwable"));  // true
    }
}
复制代码


这里的散列算法和有限的k值不足以应对复杂的场景,仅仅为了说明如何实现布隆过滤器,总的来说,原生布隆过滤器算法是比较简单的。对于一些复杂的生产场景,可以使用一些现成的类库如Guava中的布隆过滤器APIRedis中的布隆过滤器插件或者RedissonRedis高级客户端)中的布隆过滤器API


布隆过滤器应用



主要包括:

  • Guava中的API
  • Redisson中的API
  • 使用场景


使用Guava中的布隆过滤器API


引入Guava的依赖:


<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>30.1-jre</version>
</dependency>
复制代码


使用布隆过滤器:


import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import java.nio.charset.StandardCharsets;
public class GuavaBloomFilter {
    @SuppressWarnings("UnstableApiUsage")
    public static void main(String[] args) {
        BloomFilter<CharSequence> bloomFilter = BloomFilter.create(Funnels.stringFunnel(StandardCharsets.US_ASCII), 10000, 0.0444D);
        bloomFilter.put("throwable");
        bloomFilter.put("throwx");
        System.out.println(bloomFilter.mightContain("throwable"));
        System.out.println(bloomFilter.mightContain("throwx"));
    }
}
复制代码


构造BloomFilter的最多参数的静态工厂方法是BloomFilter<T> create(Funnel<? super T> funnel, long expectedInsertions, double fpp, BloomFilter.Strategy strategy),参数如下:

  • funnel:主要是把任意类型的数据转化成HashCode,是一个顶层接口,有大量内置实现,见Funnels
  • expectedInsertions:期望插入的元素个数
  • fpp:猜测是False Positive Percent,误判率,小数而非百分数,默认值0.03
  • strategy:映射策略,目前只有MURMUR128_MITZ_32MURMUR128_MITZ_64(默认策略)


参数可以参照上面的表格或者参数生成器的指导,基于实际场景进行定制。


使用Redisson中的布隆过滤器API


高级Redis客户端Redisson已经基于Redisbitmap数据结构做了封装,屏蔽了复杂的实现逻辑,可以开箱即用。引入Redisson的依赖:


<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.15.1</version>
</dependency>
复制代码


使用Redisson中的布隆过滤器API


import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
public class RedissonBloomFilter {
    public static void main(String[] args) {
        Config config = new Config();
        config.useSingleServer()
                .setAddress("redis://127.0.0.1:6379");
        RedissonClient redissonClient = Redisson.create(config);
        RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("ipBlockList");
        // 第一个参数expectedInsertions代表期望插入的元素个数,第二个参数falseProbability代表期望的误判率,小数表示
        bloomFilter.tryInit(100000L, 0.03D);
        bloomFilter.add("127.0.0.1");
        bloomFilter.add("192.168.1.1");
        System.out.println(bloomFilter.contains("192.168.1.1")); // true
        System.out.println(bloomFilter.contains("192.168.1.2")); // false
    }
}
复制代码


Redisson提供的布隆过滤器接口RBloomFilter很简单:

微信截图_20220513205749.png


常用的方法有tryInit()(初始化)、add()(添加元素)和contains()(判断元素是否存在)。相对于Guava的内存态的布隆过滤器实现,Redisson提供了基于Redis实现的分布式布隆过滤器,可以满足分布式集群中布隆过滤器的使用。


布隆过滤器使用场景


其实布隆过滤器的使用场景可以用百科中的一张示意图来描述:

微信截图_20220513205757.png


基于上图具体化的一些场景列举如下:

  • 网站爬虫应用中进行URL去重(不存在于布隆过滤器中的URL必定是未爬取过的URL
  • 防火墙应用中IP黑名单判断(不局限于IP黑名单,通用的黑名单判断场景基本都可以使用布隆过滤器,不存在于布隆过滤器中的IP必定是白名单)
  • 用于规避缓存穿透(不存在于布隆过滤器中的KEY必定不存在于后置的缓存中)


布隆过滤器变体



布隆过滤器的变体十分多,主要是为了解决布隆过滤器算法中的一些缺陷或者劣势。常见的变体如下:


变体名称 变体描述
Counting Bloom Filter 把原生布隆过滤器每个位替换成一个小的计数器(Counter),所谓计数器其实就是一个小的整数
Compressed Bloom Filter 对位数组进行压缩
Hierarchical Bloom Filters 分层,由多层布隆过滤器组成
Spectral Bloom Filters CBF的扩展,提供查询集合元素的出现频率功能
Bloomier Filters 存储函数值,不仅仅是做位映射
Time-Decaying Bloom Filters 计数器数组替换位向量,优化每个计数器存储其值所需的最小空间
Space Code Bloom Filter -
Filter Banks -
Scalable Bloom filters -
Split Bloom Filters -
Retouched Bloom filters -
Generalized Bloom Filters -
Distance-sensitive Bloom filters -
Data Popularity Conscious Bloom Filters -
Memory-optimized Bloom Filter -
Weighted Bloom filter -
Secure Bloom filters -


这里挑选Counting Bloom Filter(简称CBF)变体稍微展开一下。原生布隆过滤器的基础数据结构是位向量,CBF扩展原生布隆过滤器的基础数据结构,底层数组的每个元素使用4位大小的计数器存储添加元素到数组某个下标时候映射成功的频次,在插入新元素的时候,通过k个散列函数映射到k个具体计数器,这些命中的计数器值增加1;删除元素的时候,通过k个散列函数映射到k个具体计数器,这些计数器值减少1。使用CBF判断元素是否在集合中的时候:


  • 某个元素通过k个散列函数映射到k个具体计数器,所有计数器的值都为0,那么元素必定不在集合中
  • 某个元素通过k个散列函数映射到k个具体计数器,至少有1个计数器的值大于0,那么元素可能在集合中

微信截图_20220513205807.png


小结



一句话简单概括布隆过滤器的基本功能:不存在则必不存在,存在则不一定存在。

在使用布隆过滤器判断一个元素是否属于某个集合时,会有一定的误判率。也就是有可能把不属于某个集合的元素误判为属于这个集合,这种错误称为False Positive,但不会把属于某个集合的元素误判为不属于这个集合(相对于False Positive,"假阳性",如果属于某个集合的元素误判为不属于这个集合的情况称为False Negative,"假阴性")。False Positive,也就是错误率或者误判率这个因素的引入,是布隆过滤器在设计上权衡空间效率的关键。


参考资料:


(本文完 c-1-w e-a-20210306)


相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
算法 安全 NoSQL
雪花算法的实现原理
一位工作4年的小伙伴,去某东面试时被问到这样一道题,说请你简述一下雪花算法的实现原理。屏幕前的小伙伴,如果你遇到这个问题,你会怎么回答?
215 0
|
24天前
|
算法 JavaScript UED
Diff 算法的实现原理
【10月更文挑战第18天】Diff 算法是 Vue.js 中实现高效 DOM 更新的核心机制,通过合理的比较和优化策略,能够在保证界面正确性的同时,最大程度地减少 DOM 操作,提高应用的性能和用户体验。
26 2
|
22天前
|
算法 Java
介绍一下CAS算法的实现原理
【10月更文挑战第20天】介绍一下CAS算法的实现原理
10 0
|
4月前
|
算法 Java
详解 Java 限流接口实现问题之固定窗口限流算法的实现原理是什么
详解 Java 限流接口实现问题之固定窗口限流算法的实现原理是什么
|
存储 人工智能 算法
【C++杂货铺】再谈哈希算法:位图 | 布隆过滤器 | 哈希切分
【C++杂货铺】再谈哈希算法:位图 | 布隆过滤器 | 哈希切分
83 0
【C++杂货铺】再谈哈希算法:位图 | 布隆过滤器 | 哈希切分
|
算法 索引
带你读《图解算法小抄》十一、布隆过滤器(1)
带你读《图解算法小抄》十一、布隆过滤器(1)
带你读《图解算法小抄》十一、布隆过滤器(1)
|
6月前
|
存储 缓存 监控
深入理解Java线程池ThreadPoolExcutor实现原理、数据结构和算法(源码解析)
Java线程池的核心组件包括核心线程数、最大线程数、队列容量、拒绝策略等。核心线程数是线程池长期维持的线程数量,即使这些线程处于空闲状态也不会被销毁;最大线程数则是线程池允许的最大线程数量,当任务队列已满且当前线程数未达到最大线程数时,线程池会创建新线程执行任务;队列容量决定了任务队列的最大长度,当新任务到来时,如果当前线程数已达到核心线程数且队列未满,任务将被放入队列等待执行;拒绝策略则定义了当线程池无法处理新任务时的行为,如抛出异常、丢弃任务等。
107 1
|
缓存 算法 Java
亿级数据过滤算法----布隆过滤器
亿级数据过滤算法----布隆过滤器
|
存储 算法
带你读《图解算法小抄》十一、布隆过滤器(2)
带你读《图解算法小抄》十一、布隆过滤器(2)
|
JavaScript 算法 前端开发
常用加密算法及实现原理
常用加密算法及实现原理
110 0