布隆过滤器(bloom filter)介绍以及php和redis实现布隆过滤器实现方法

引言

在介绍布隆过滤器之前我们首先引入几个场景。

场景一

在一个高并发的计数系统中,如果一个key没有计数,此时我们应该返回0。但是访问的key不存在,相当于每次访问缓存都不起作用了。那么如何避免频繁访问数量为0的key而导致的缓存被击穿?

有人说, 将这个key的值置为0存入缓存不就行了吗?这是确实是一种解决方案。当访问一个不存在的key的时候,设置一个带有过期时间的标志,然后放入缓存。不过这样做的缺点也很明显:浪费内存和无法抵御随机key攻击。

场景二

在一个黑名单系统中,我们需要设置很多黑名单内容。比如一个邮件系统,我们需要设置黑名单用户,当判断垃圾邮件的时候,要怎么去做。比如爬虫系统,我们要记录下来已经访问过的链接避免下次访问重复的链接。

在邮件很少或者用户很少的情况下,我们用普通数据库自带的查询就能完成。在数据量太多的时候,为了保证速度,通常情况下我们会将结果缓存到内存中,数据结构用hash表。这种查找的速度是O(1),但是内存消耗也是惊人的。打个比方,假如我们要存10亿条数据,每条数据平均占据32个字节,那么需要的内存是64G,这已经是一个惊人的大小了。

一种解决思路

能不能有一种思路,查询的速度是O(1),消耗内存特别小呢?前辈门早就想出了一个很好的解决方案。由于上面说的场景判断的结果只有两种状态(是或者不是,存在或者不存在),那么对于所存的数据完全可以用位来表示。数据本身则可以通过一个hash函数计算出一个key,这个key是一个位置,而这个key所对的值就是0或者1(因为只有两种状态),如下图:

布隆过滤器原理

上面的思路其实就是布隆过滤器的思想,只不过因为hash函数的限制,多个字符串很可能会hash成一个值。为了解决这个问题,布隆过滤器引入多个hash函数来降低误判率。

下图表示有三个hash函数,比如一个集合中有x,y,z三个元素,分别用三个hash函数映射到二进制序列的某些位上,假设我们判断w是否在集合中,同样用三个hash函数来映射,结果发现取得的结果不全为1,则表示w不在集合里面。

布隆过滤器处理流程

布隆过滤器应用很广泛,比如垃圾邮件过滤,爬虫的url过滤,防止缓存击穿等等。下面就来说说布隆过滤器的一个完整流程,相信读者看到这里应该能明白布隆过滤器是怎样工作的。

第一步:开辟空间

开辟一个长度为m的位数组(或者称二进制向量),这个不同的语言有不同的实现方式,甚至你可以用文件来实现。

第二步:寻找hash函数

获取几个hash函数,前辈们已经发明了很多运行良好的hash函数,比如BKDRHash,JSHash,RSHash等等。这些hash函数我们直接获取就可以了。

第三步:写入数据

将所需要判断的内容经过这些hash函数计算,得到几个值,比如用3个hash函数,得到值分别是1000,2000,3000。之后设置m位数组的第1000,2000,3000位的值位二进制1。

第四步:判断

接下来就可以判断一个新的内容是不是在我们的集合中。判断的流程和写入的流程是一致的。

误判问题

布隆过滤器虽然很高效(写入和判断都是O(1),所需要的存储空间极小),但是缺点也非常明显,那就是会误判。当集合中的元素越来越多,二进制序列中的1的个数越来越多的时候,判断一个字符串是否在集合中就很容易误判,原本不在集合里面的字符串会被判断在集合里面。

数学推导

布隆过滤器原理十分简单,但是hash函数个数怎么去判断,误判率有多少?

假设二进制序列有m位,那么经过当一个字符串hash到某一位的概率为:\(\frac{1}{m}\)。也就是说当前位被反转为1的概率:\(p(1)=\frac{1}{m}\)。那么这一位没有被反转的概率为:\(p(0)=1-\frac{1}{m}\)。

假设我们存入n各元素,使用k个hash函数,此时没有被翻转的概率为:\(p(0)=(1-\frac{1}{m})^{nk}\)。那什么情况下我们会误判呢,就是原本不应该被翻转的位,结果翻转了,也就是 \(p(误判)=1-(1-\frac{1}{m})^{nk}\)

由于只有k个hash函数同时误判了,整体才会被误判,最后误判的概率为 \(p(误判)=(1-(1-\frac{1}{m})^{nk})^k\)。

要使得误判率最低,那么我们需要求误判与m、n、k之间的关系,现在假设m和n固定,我们计算一下k。可以首先看看这个式子:\((1-\frac{1}{m})^{nk}\)。

由于我们的m很大,通常情况下我们会用2^32来作为m的值。上面的式子中含有一个重要极限 \(\lim\limits _{x\to\infty} (1+\frac{1}{x})^x = e\)。

因此误判率的式子可以写成 \(p(误判) = (1-(e)^{-nk/m})^k\)。

接下来令\(t = -n/m\),两边同时取对数,求导,得到:\(p^{’} \frac{1}{p} = ln(1-e^{tk}) + \frac{ k lne^{t} (-e^{tk}) } {1-e^{tk}} \)

让\(p{’}=0\),则等式后面的为0,最后整理出来的结果是 \((1-e^{tk}) ln(1-e^{tk}) = e^{tk}lne^{tk}  \)

计算出来的k为\(ln2\frac{m}{n}\),约等于\(0.693\frac{m}{n}\),将k代入p(误判),我们可以得到概率和m、n之间的关系,最后的结果 \((1/2)^{ln2 \frac{m}{n}}\),约等于\(0.6185^{m/n}\)

以上我们就得出了最佳hash函数个数以及误判率与mn之前的关系了。

下表是m与n比值在k个hash函数下面的误判率

m/nkk=1k=2k=3k=4k=5k=6k=7k=8
21.390.3930.400      
32.080.2830.2370.253     
42.770.2210.1550.1470.160    
53.460.1810.1090.0920.0920.101   
64.160.1540.08040.06090.05610.05780.0638  
74.850.1330.06180.04230.03590.03470.0364  
85.550.1180.04890.03060.0240.02170.02160.0229 
96.240.1050.03970.02280.01660.01410.01330.01350.0145
106.930.09520.03290.01740.01180.009430.008440.008190.00846
117.620.08690.02760.01360.008640.00650.005520.005130.00509
128.320.080.02360.01080.006460.004590.003710.003290.00314
139.010.0740.02030.008750.004920.003320.002550.002170.00199
149.70.06890.01770.007180.003810.002440.001790.001460.00129
1510.40.06450.01560.005960.0030.001830.001280.0010.000852
1611.10.06060.01380.0050.002390.001390.0009350.0007020.000574
1711.80.05710.01230.004230.001930.001070.0006920.0004990.000394
1812.50.0540.01110.003620.001580.0008390.0005190.000360.000275
1913.20.05130.009980.003120.00130.0006630.0003940.0002640.000194
2013.90.04880.009060.00270.001080.000530.0003030.0001960.00014
2114.60.04650.008250.002360.0009050.0004270.0002360.0001470.000101
2215.20.04440.007550.002070.0007640.0003470.0001850.0001127.46e-05
2315.90.04250.006940.001830.0006490.0002850.0001478.56e-055.55e-05
2416.60.04080.006390.001620.0005550.0002350.0001176.63e-054.17e-05
2517.30.03920.005910.001450.0004780.0001969.44e-055.18e-053.16e-05
26180.03770.005480.001290.0004130.0001647.66e-054.08e-052.42e-05
2718.70.03640.00510.001160.0003590.0001386.26e-053.24e-051.87e-05
2819.40.03510.004750.001050.0003140.0001175.15e-052.59e-051.46e-05
2920.10.03390.004440.0009490.0002769.96e-054.26e-052.09e-051.14e-05
3020.80.03280.004160.0008620.0002438.53e-053.55e-051.69e-059.01e-06
3121.50.03170.00390.0007850.0002157.33e-052.97e-051.38e-057.16e-06
3222.20.03080.003670.0007170.0001916.33e-052.5e-051.13e-055.73e-06

php+Redis实现的布隆过滤器

由于Redis实现了setbit和getbit操作,天然适合实现布隆过滤器,redis也有布隆过滤器插件。这里使用php+redis实现布隆过滤器。

首先定义一个hash函数集合类,这些hash函数不一定都用到,实际上32位hash值的用3个就可以了,具体的数量可以根据你的位序列总量和你需要存入的量决定,上面已经给出最佳值。

class BloomFilterHash
{
	/**
	 * 由Justin Sobel编写的按位散列函数
	 */
	public function JSHash($string, $len = null)
	{
    	$hash = 1315423911;
    	$len || $len = strlen($string);
    	for ($i=0; $i<$len; $i++) {
    		$hash ^= (($hash << 5) + ord($string[$i]) + ($hash >> 2));
    	}
		return ($hash % 0xFFFFFFFF) & 0xFFFFFFFF;
	}

	/**
	 * 该哈希算法基于AT&T贝尔实验室的Peter J. Weinberger的工作。
	 * Aho Sethi和Ulman编写的“编译器(原理,技术和工具)”一书建议使用采用此特定算法中的散列方法的散列函数。
	 */
	public function PJWHash($string, $len = null)
	{
		$bitsInUnsignedInt = 4 * 8; //(unsigned int)(sizeof(unsigned int)* 8);
    	$threeQuarters = ($bitsInUnsignedInt * 3) / 4;
    	$oneEighth = $bitsInUnsignedInt / 8;
    	$highBits = 0xFFFFFFFF << (int) ($bitsInUnsignedInt - $oneEighth);
    	$hash = 0;
    	$test = 0;
    	$len || $len = strlen($string);
    	for($i=0; $i<$len; $i++) {
			$hash = ($hash << (int) ($oneEighth)) + ord($string[$i]); } $test = $hash & $highBits; if ($test != 0) { $hash = (($hash ^ ($test >> (int)($threeQuarters))) & (~$highBits));
    	}
		return ($hash % 0xFFFFFFFF) & 0xFFFFFFFF;
	}

	/**
	 * 类似于PJW Hash功能,但针对32位处理器进行了调整。它是基于UNIX的系统上的widley使用哈希函数。
	 */
	public function ELFHash($string, $len = null)
	{
		$hash = 0;
		$len || $len = strlen($string);
    	for ($i=0; $i<$len; $i++) {
        	$hash = ($hash << 4) + ord($string[$i]); $x = $hash & 0xF0000000; if ($x != 0) { $hash ^= ($x >> 24);
        	}
        	$hash &= ~$x;
    	}
		return ($hash % 0xFFFFFFFF) & 0xFFFFFFFF;
	}

	/**
	 * 这个哈希函数来自Brian Kernighan和Dennis Ritchie的书“The C Programming Language”。
	 * 它是一个简单的哈希函数,使用一组奇怪的可能种子,它们都构成了31 .... 31 ... 31等模式,它似乎与DJB哈希函数非常相似。
	 */
	public function BKDRHash($string, $len = null)
	{
    	$seed = 131;  # 31 131 1313 13131 131313 etc..
    	$hash = 0;
    	$len || $len = strlen($string);
    	for ($i=0; $i<$len; $i++) {
        	$hash = (int) (($hash * $seed) + ord($string[$i]));
    	}
		return ($hash % 0xFFFFFFFF) & 0xFFFFFFFF;
	}

	/**
	 * 这是在开源SDBM项目中使用的首选算法。
	 * 哈希函数似乎对许多不同的数据集具有良好的总体分布。它似乎适用于数据集中元素的MSB存在高差异的情况。
	 */
	public function SDBMHash($string, $len = null)
	{
		$hash = 0;
		$len || $len = strlen($string);
		for ($i=0; $i<$len; $i++) {
			$hash = (int) (ord($string[$i]) + ($hash << 6) + ($hash << 16) - $hash);
		}
		return ($hash % 0xFFFFFFFF) & 0xFFFFFFFF;
	}

	/**
	 * 由Daniel J. Bernstein教授制作的算法,首先在usenet新闻组comp.lang.c上向世界展示。
	 * 它是有史以来发布的最有效的哈希函数之一。
	 */
	public function DJBHash($string, $len = null)
	{
		$hash = 5381;
		$len || $len = strlen($string);
		for ($i=0; $i<$len; $i++) {
			$hash = (int) (($hash << 5) + $hash) + ord($string[$i]);
		}
		return ($hash % 0xFFFFFFFF) & 0xFFFFFFFF;
	}

	/**
	 * Donald E. Knuth在“计算机编程艺术第3卷”中提出的算法,主题是排序和搜索第6.4章。
	 */
	public function DEKHash($string, $len = null)
	{
		$len || $len = strlen($string);
		$hash = $len;
		for ($i=0; $i<$len; $i++) {
			$hash = (($hash << 5) ^ ($hash >> 27)) ^ ord($string[$i]);
		}
		return ($hash % 0xFFFFFFFF) & 0xFFFFFFFF;
	}

	/**
	 * 参考 http://www.isthe.com/chongo/tech/comp/fnv/
	 */
	public function FNVHash($string, $len = null)
	{
		$prime = 16777619; //32位的prime 2^24 + 2^8 + 0x93 = 16777619
		$hash = 2166136261; //32位的offset
		$len || $len = strlen($string);
		for ($i=0; $i<$len; $i++) {
			$hash = (int) ($hash * $prime) % 0xFFFFFFFF;
			$hash ^= ord($string[$i]);
		}
		return ($hash % 0xFFFFFFFF) & 0xFFFFFFFF;
	}
}

接着就是连接redis来进行操作

/**
 * 使用redis实现的布隆过滤器
 */
abstract class BloomFilterRedis
{
	/**
	 * 需要使用一个方法来定义bucket的名字
	 */
	protected $bucket;

	protected $hashFunction;

	public function __construct($config, $id)
	{
		if (!$this->bucket || !$this->hashFunction) {
			throw new Exception("需要定义bucket和hashFunction", 1);
		}
		$this->Hash = new BloomFilterHash;
		$this->Redis = new YourRedis; //假设这里你已经连接好了
	}

	/**
	 * 添加到集合中
	 */
	public function add($string)
	{
		$pipe = $this->Redis->multi();
		foreach ($this->hashFunction as $function) {
			$hash = $this->Hash->$function($string);
			$pipe->setBit($this->bucket, $hash, 1);
		}
		return $pipe->exec();
	}

	/**
	 * 查询是否存在, 如果曾经写入过,必定回true,如果没写入过,有一定几率会误判为存在
	 */
	public function exists($string)
	{
		$pipe = $this->Redis->multi();
		$len = strlen($string);
		foreach ($this->hashFunction as $function) {
			$hash = $this->Hash->$function($string, $len);
			$pipe = $pipe->getBit($this->bucket, $hash);
		}
		$res = $pipe->exec();
		foreach ($res as $bit) {
			if ($bit == 0) {
				return false;
			}
		}
		return true;
	}

}

上面定义的是一个抽象类,如果要使用,可以根据具体的业务来使用。比如下面是一个过滤重复内容的过滤器。

/**
 * 重复内容过滤器
 * 该布隆过滤器总位数为2^32位, 判断条数为2^30条. hash函数最优为3个.(能够容忍最多的hash函数个数)
 * 使用的三个hash函数为
 * BKDR, SDBM, JSHash
 *
 * 注意, 在存储的数据量到2^30条时候, 误判率会急剧增加, 因此需要定时判断过滤器中的位为1的的数量是否超过50%, 超过则需要清空.
 */
class FilteRepeatedComments extends BloomFilterRedis
{
	/**
	 * 表示判断重复内容的过滤器
	 * @var string
	 */
	protected $bucket = 'rptc';

	protected $hashFunction = array('BKDRHash', 'SDBMHash', 'JSHash');
}

你可能还喜欢下面这些文章

iterm2 使用 rz、sz 的方法

如果没有额外的设置,iterm2 使用 rzsz 的时候会卡在这个时候就需要使用iterm2提供的trigger来实现rzsz的功能。第一步:本机安装rzsz使用rzsz之前本地也需要安装如果没有安装brew,请先安装brew,mac必备的包管理器!第二步:创建发送和接收脚本发送文件的脚本如下,可以复制下面的内容,保存在 /usr/local/bin/iterm2-send-zmodem.sh中。接收文件的脚本如下,同样可以复制保存在/usr/local/bin/iterm2-recv-zmodem.sh第三步:设置Triggerteigger需要设置两个,一个实发送文件的trigger,一个

使用expect之后无法使用rz和sz的解决方法

在机器太多的时候,我们会使用expect来自动化登录,然而使用expect之后就不能使用rz和sz了。经过一番寻找之后,发现有一个解决方案,在脚本之前增加一个export LC_CTYPE=en_US注意,这个语句放到登录脚本里面就可以了,不要放到.bash_profile里面,如果放到bash_profile里面可能你当前的终端语言都变了,中文可能会乱码。这个缺点是远程机器里面的中文可能会乱码了,如果有更好的解决方案,我会在这里更新。

linux文件描述符介绍

文件描述符是什么文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。习惯上,标准输入(standard input)的文件描述符是 0,标准输出(standard output)是 1,标准错误(standard error)是 2。尽管这种习惯并非Unix内核的特性,但是因为一些 shell 和很多应用程序都使用这种习惯,因此,如

设计模式:装饰器模式介绍和应用

简短描述当前有一个功能完善的对象,如果我们想要给这个对象添加一个新的职责,那么我们可以用一个新的类去装饰它来实现对原有对象职责的扩展。新的类称为“装饰者”,原有的对象称为“被装饰者”。这种模式被称为装饰器模式。现实生活中的装饰器模式装饰器模式在现实生活中的例子简直太多了。比如我开了一个奶茶店,卖的是普通的奶茶。现在我想引入一个叫珍珠奶茶的商品,我要怎么做呢?我是不是需要升级一下我的制作奶茶的机器,让它支持珍珠奶茶的做法?但这种成本估计比较高,说不定还没原来做奶茶的机器好用呢!实际上我只需要买一个能做“珍珠”的机器就行!把“珍珠”放进奶茶不就成了珍珠奶茶。原有的做奶茶的机器依然可以稳定地制作做奶

设计模式:观察者模式介绍与应用场景

简短描述在观察者模式中,有一个主题和几个观察者,主题状态改变之后会通知到所有的观察者。现实中的观察者模式观察者模式和现实世界很好对应。比如我和我的朋友们购买了今年一年的报纸,每次报社印刷了今天的报纸之后就会往我这里送。在这个系统里面,“报社“是一个主题,“我和我的朋友们”就是一个观察者,当“报社”生产报纸之后,“我和我的朋友们”都能收到这份报纸。根据上面的描述,我们很容易写出一个观察者模式。首先定义一个报社作为主题,然后定义一些用户作为观察者。不过在此之前,我们先定义主题和观察者的接口,毕竟我们是面向接口编程。定义一个主题接口namespace ds\observer\contract;/**

赞赏

微信赞赏支付宝赞赏

《布隆过滤器(bloom filter)介绍以及php和redis实现布隆过滤器实现方法》有16条评论

  1. 我 使用 new BloomFilterRedis()->add(‘200’)过去,使用exists(‘200’)的时候,为什么返回值都是 false? 另外,既然布隆过滤器使用的是 hash 转化再使用 setBit与getBit存取,如果我直接标记的是数字,是不是完全不需要布隆过滤器,直接使用setBit存,使用 getBit取就可以

    1. 如果你的数字是在某个小范围内,比如(0,65535),可以不用hash,直接使用bit存储判断即可。如果范围太大,消耗的存储也会比较大。bloomfilter的精髓就在于误差范围内使用较少的存储判断某数据在大量数据集内。

    2. 返回false 是因为第一个算法得到的offset 大于setybit的最大范围2^32.所以会失败。换个算法就好了
      但是不懂换算法对结果有什么样的影响。

  2. 有四种类型的号码要做布隆选择器 $bucket 在第四个的时候 会出现错误:
    OOM command not allowed when used memory > ‘maxmemory’
    请问这个是什么原因 菜鸟表示很头疼

  3. “查询是否存在, 存在的一定会存在, 不存在有一定几率会误判”
    不应该是存在的不一定存在,不存在的一定不存在吗?

    1. 不,是存在的一定会存在,不存在的可能会被误判存在。
      通过n个hash函数映射到的位置一直都是固定的,写入之后再从特定的位置是一定能读取到的,所以如果存在,那么一定能判断位存在。
      不存在的有可能会误判,是因为hash函数会出现碰撞,比如恰好有一个值通过n个hash函数映射的位置和已经存在的某个值相同,那么这个值原本不存在也会被判断为存在

      1. 你搞错了吧 是判断时存在不一定会存在 ,因为hash碰撞,可能会产生相同的值,但是不存在一定不存在

        1. 没搞错,你对我的话有误解。“存在的一定会存在,不存在的可能会被误判存在” 这句话和你说的话所站的角度不一样。我想表达的意思是如果曾经写进去一个值,那么这个存在的值查询的时候一定是返回存在的,如果没有写进去这个值,那么这个不存在的值也有可能会被误判为存在。

          你说的存在不一定会存在,不存在一定不存在是站在判断的角度上说的,这个也没错。你可以看我的代码,如果都没找到,才会返回false,其他的都会返回true,和你的逻辑是一致的。

          1. 没有问题,就是布隆过滤的时候, 如果多个hash函数对应的位有一个是0的话,那这个值一定不是存在于value中的。

回复 madao 取消回复

您的电子邮箱地址不会被公开。 必填项已用*标注