Go语言实现布谷鸟过滤器的方法

转载请声明出处哦~,本篇文章发布于luozhiyun的博客: https://www.luozhiyun.com/archives/453

介绍

在我们工作中,如果遇到如网页 URL 去重、垃圾邮件识别、大集合中重复元素的判断一般想到的是将集合中所有元素保存起来,然后通过比较确定。如果通过性能最好的Hash表来进行判断,那么随着集合中元素的增加,我们需要的存储空间也会呈现线性增长,最终达到瓶颈。

所以很多时候会选择使用布隆过滤器来做这件事。布隆过滤器通过一个固定大小的二进制向量或者位图(bitmap),然后通过映射函数来将存储到 bitmap 中的键值进行映射大大减少了空间成本,布隆过滤器存储空间和插入/查询时间都是常数 O(K)。但是随着存入的元素数量增加,布隆过滤器误算率会随之增加,并且也不能删除元素。

想要体验布隆过滤器的插入步骤的可以看看这里: https://www.jasondavies.com/bloomfilter/

于是布谷鸟过滤器(Cuckoo filter)华丽降世了。在论文《Cuckoo Filter: Practically Better Than Bloom》中直接说到布隆过滤器的缺点:

A limitation of standard Bloom filters is that one cannot remove existing items without rebuilding the entire filter.

论文中也提到了布谷鸟过滤器4大优点:

  • It supports adding and removing items dynamically;
  • It provides higher lookup performance than traditional Bloom filters, even when close to full (e.g., 95% space utilized);
  • It is easier to implement than alternatives such as the quotient filter; and
  • It uses less space than Bloom filters in many practical applications, if the target false positive rate is less than 3%.

翻译如下:

  1. 支持动态的新增和删除元素;
  2. 提供了比传统布隆过滤器更高的查找性能,即使在接近满的情况下(比如空间利用率达到 95% 的时候);
  3. 更容易实现;
  4. 如果要求错误率小于3%,那么在许多实际应用中,它比布隆过滤器占用的空间更小

实现原理

简单工作原理

可以简单的把布谷鸟过滤器里面有两个 hash 表T1、T2,两个 hash 表对应两个 hash 函数H1、H2。

具体的插入步骤如下:

  • 当一个不存在的元素插入的时候,会先根据 H1 计算出其在 T1 表的位置,如果该位置为空则可以放进去。
  • 如果该位置不为空,则根据 H2 计算出其在 T2 表的位置,如果该位置为空则可以放进去。
  • 如果T1 表和 T2 表的位置元素都不为空,那么就随机的选择一个 hash 表将其元素踢出。
  • 被踢出的元素会循环的去找自己的另一个位置,如果被暂了也会随机选择一个将其踢出,被踢出的元素又会循环找位置;
  • 如果出现循环踢出导致放不进元素的情况,那么会设置一个阈值,超出了某个阈值,就认为这个 hash 表已经几乎满了,这时候就需要对它进行扩容,重新放置所有元素。

下面举一个例子来说明:

如果想要插入一个元素Z到过滤器里:

  • 首先会将Z进行 hash 计算,发现 T1 和 T2 对应的槽位1和槽位2都已经被占了;
  • 随机将 T1 中的槽位1中的元素 X 踢出,X 的 T2 对应的槽位4已经被元素 3 占了;
  • 将 T2 中的槽位4中的元素 3 踢出,元素 3 在 hash 计算之后发现 T1 的槽位6是空的,那么将元素3放入到 T1 的槽位6中。

当 Z 插入完毕之后如下:

布谷鸟过滤器

布谷鸟过滤器和上面的实现原理结构是差不多的,不同的是上面的数组结构会存储整个元素,而布谷鸟过滤器中只会存储元素的几个 bit ,称作指纹信息。这里是牺牲了数据的精确性换取了空间效率。

上面的实现方案中,hash 表中每个槽位只能存放一个元素,空间利用率只有50%,而在布谷鸟过滤器中每个槽位可以存放多个元素,从一维变成了二维。论文中表示:

With k = 2 hash functions, the load factor α is 50% when the bucket size b = 1 (i.e., the hash table is directly mapped), but increases to 84%, 95% or 98% respectively using bucket size b = 2, 4 or 8.

也就是当有两个 hash 函数的时候,使用一维数组空间利用率只有50%,当每个槽位可以存放2,4,8个元素的时候,空间利用率就会飙升到 84%,95%,98%。

如下图,表示的是一个二维数组,每个槽位可以存放 4 个元素,和上面的实现有所不同的是,没有采用两个数组来存放,而是只用了一个:

说完了数据结构的改变,下面再说说位置计算的改变。

我们在上面简单实现的位置计算公式是这样做的:

p1 = hash1(x) % 数组长度
p2 = hash2(x) % 数组长度

而布谷鸟过滤器计算位置公式可以在论文中看到是这样:

f = fingerprint(x);
i1 = hash(x);
i2 = i1 ⊕ hash( f);

我们可以看到在计算位置 i2 的时候是通过 i1 和元素 X 对应的指纹信息取异或计算出来。指纹信息在上面已经解释过了,是元素 X 的几个 bit ,牺牲了一定精度,但是换取了空间。

那么这里为什么需要用到异或呢?因为这样可以用到异或的自反性: A ⊕ B ⊕ B = A ,这样就不需要知道当前的位置是 i1 还是 i2,只需要将当前的位置和 hash(f) 进行异或计算就可以得到另一个位置。

这里有个细节需要注意的是,计算 i2 的时候是需要先将元素 X 的 fingerprint 进行 hash ,然后才取异或,论文也说明了:

If the alternate location were calculated by “i⊕fingerprint” without hashing the fingerprint, the items kicked out from nearby buckets would land close to each other in the table, if the size of the fingerprint is small compared to the table size.

如果直接进行异或处理,那么很可能 i1 和 i2 的位置相隔很近,尤其是在比较小的 hash 表中,这样无形之中增加了碰撞的概率。

除此之外还有一个约束条件是布谷鸟过滤器强制数组的长度必须是 2 的指数,所以在布谷鸟过滤器中不需要对数组的长度取模,取而代之的是取 hash 值的最后 n 位。

如一个布谷鸟过滤器中数组的长度2^8即256,那么取 hash 值的最后 n 位即: hash & 255 这样就可以得到最终的位置信息。如下最后得到位置信息是 23 :

代码实现

数据结构

const bucketSize = 4
type fingerprint byte
// 二维数组,大小是4
type bucket [bucketSize]fingerprint

type Filter struct {
	// 一维数组
	buckets []bucket
	// Filter 中已插入的元素
	count  uint
	// 数组buckets长度中对应二进制包含0的个数
	bucketPow uint
}

在这里我们假定一个指纹 fingerprint 占用的字节数是 1byte ,每个位置有 4 个座位。

初始化

var (
	altHash = [256]uint{}
	masks = [65]uint{}
)

func init() {
	for i := 0; i < 256; i++ {
  // 用于缓存 256 个fingerprint的hash信息
		altHash[i] = (uint(metro.Hash64([]byte{byte(i)}, 1337)))
	}
	for i := uint(0); i <= 64; i++ {
  // 取 hash 值的最后 n 位
		masks[i] = (1 << i) - 1
	}
}

这个 init 函数会缓存初始化两个全局变量 altHash 和 masks。因为 fingerprint 长度是 1byte ,所以在初始化 altHash 的时候使用一个 256 大小的数组取缓存对应的 hash 信息,避免每次都需要重新计算;masks 是用来取 hash 值的最后 n 位,稍后会用到。

我们会使用一个 NewFilter 函数,通过传入过滤器可容纳大小来获取过滤器 Filter:

func NewFilter(capacity uint) *Filter {
 // 计算 buckets 数组大小
	capacity = getNextPow2(uint64(capacity)) / bucketSize
	if capacity == 0 {
		capacity = 1
	}
	buckets := make([]bucket, capacity)
	return &Filter{
		buckets: buckets,
		count:  0,
  // 获取 buckets 数组大小的二进制中以 0 结尾的个数
		bucketPow: uint(bits.TrailingZeros(capacity)),
	}
}

NewFilter 函数会通过 getNextPow2 将 capacity 调整到 2 的指数倍,如果传入的 capacity 是 9 ,那么调用 getNextPow2 后会返回 16;然后计算好 buckets 数组长度,实例化 Filter 返回;bucketPow 返回的是二进制中以 0 结尾的个数,因为 capacity 是 2 的指数倍,所以 bucketPow 是 capacity 二进制的位数减 1。

插入元素

func (cf *Filter) Insert(data []byte) bool {
	// 获取 data 的 fingerprint 以及 位置 i1
	i1, fp := getIndexAndFingerprint(data, cf.bucketPow)
	// 将 fingerprint 插入到 Filter 的 buckets 数组中
	if cf.insert(fp, i1) {
		return true
	}
	// 获取位置 i2
	i2 := getAltIndex(fp, i1, cf.bucketPow)
	// 将 fingerprint 插入到 Filter 的 buckets 数组中
	if cf.insert(fp, i2) {
		return true
	}
	// 插入失败,那么进行循环插入踢出元素
	return cf.reinsert(fp, randi(i1, i2))
}

func (cf *Filter) insert(fp fingerprint, i uint) bool {
 // 获取 buckets 中的槽位进行插入
	if cf.buckets[i].insert(fp) {
  // Filter 中元素个数+1
		cf.count++
		return true
	}
	return false
}

func (b *bucket) insert(fp fingerprint) bool {
 // 遍历槽位的 4 个元素,如果为空则插入
	for i, tfp := range b {
		if tfp == nullFp {
			b[i] = fp
			return true
		}
	}
	return false
}
  • getIndexAndFingerprint 函数会获取 data 的指纹 fingerprint,以及位置 i1;
  • 然后调用 insert 插入到 Filter 的 buckets 数组中,如果 buckets 数组中对应的槽位 i1 的 4 个元素已经满了,那么尝试获取位置 i2 ,并将元素尝试插入到 buckets 数组中对应的槽位 i2 中;
  • 对应的槽位 i2 也满了,那么 调用 reinsert 方法随机获取槽位 i1、i2 中的某个位置进行抢占,然后将老元素踢出并循环重复插入。

下面看看 getIndexAndFingerprint 是如何获取 fingerprint 以及槽位 i1:

func getIndexAndFingerprint(data []byte, bucketPow uint) (uint, fingerprint) {
 // 将 data 进行hash
	hash := metro.Hash64(data, 1337)
 // 取 hash 的指纹信息
	fp := getFingerprint(hash)
	// 取 hash 高32位,对 hash 的高32位进行取与获取槽位 i1
	i1 := uint(hash>>32) & masks[bucketPow]
	return i1, fingerprint(fp)
}
// 取 hash 的指纹信息
func getFingerprint(hash uint64) byte {
	fp := byte(hash%255 + 1)
	return fp
}

getIndexAndFingerprint 中对 data 进行 hash 完后会对其结果取模获取指纹信息,然后再取 hash 值的高 32 位进行取与,获取槽位 i1。masks 在初始化的时候已经看过了, masks[bucketPow] 获取的二进制结果全是 1 ,用来取 hash 的低位的值。

假如初始化传入的 capacity 是1024,那么计算到 bucketPow 是 8,对应取到 masks[8] = (1 << 8) - 1 结果是 255 ,二进制是 1111,1111 ,和 hash 的高 32 取与 得到最后 buckets 中的槽位 i1 :

func getAltIndex(fp fingerprint, i uint, bucketPow uint) uint {
	mask := masks[bucketPow]
	hash := altHash[fp] & mask
	return i ^ hash
}

getAltIndex 中获取槽位是通过使用 altHash 来获取指纹信息的 hash 值,然后取异或后返回槽位值。需要注意的是,这里由于异或的特性,所以传入的不管是槽位 i1,还是槽位 i2 都可以返回对应的另一个槽位。

下面看看循环踢出插入 reinsert:

const maxCuckooCount = 500

func (cf *Filter) reinsert(fp fingerprint, i uint) bool {
 // 默认循环 500 次
	for k := 0; k < maxCuckooCount; k++ {
  // 随机从槽位中选取一个元素
		j := rand.Intn(bucketSize)
		oldfp := fp
  // 获取槽位中的值
		fp = cf.buckets[i][j]
  // 将当前循环的值插入
		cf.buckets[i][j] = oldfp

		// 获取另一个槽位
		i = getAltIndex(fp, i, cf.bucketPow)
		if cf.insert(fp, i) {
			return true
		}
	}
	return false
}

这里会最大循环 500 次获取槽位信息。因为每个槽位最多可以存放 4 个元素,所以使用 rand 随机从 4 个位置中取一个元素踢出,然后将当次循环的元素插入,再获取被踢出元素的另一个槽位信息,再调用 insert 进行插入。

上图展示了元素 X 在插入到 hash 表的时候,hash 两次发现对应的槽位 0 和 3 都已经满了,那么随机抢占了槽位 3 其中一个元素,被抢占的元素重新 hash 之后插入到槽位 5 的第三个位置上。

查询数据

查询数据的时候,就是看看对应的位置上有没有对应的指纹信息:

func (cf *Filter) Lookup(data []byte) bool {
 // 获取槽位 i1 以及指纹信息
	i1, fp := getIndexAndFingerprint(data, cf.bucketPow)
 // 遍历槽位中 4 个位置,查看有没有相同元素
	if cf.buckets[i1].getFingerprintIndex(fp) > -1 {
		return true
	}
 // 获取另一个槽位 i2
	i2 := getAltIndex(fp, i1, cf.bucketPow)
 // 遍历槽位 i2 中 4 个位置,查看有没有相同元素
	return cf.buckets[i2].getFingerprintIndex(fp) > -1
}

func (b *bucket) getFingerprintIndex(fp fingerprint) int {
	for i, tfp := range b {
		if tfp == fp {
			return i
		}
	}
	return -1
}

删除数据

删除数据的时候,也只是抹掉该槽位上的指纹信息:

func (cf *Filter) Delete(data []byte) bool {
 // 获取槽位 i1 以及指纹信息
	i1, fp := getIndexAndFingerprint(data, cf.bucketPow)
 // 尝试删除指纹信息
	if cf.delete(fp, i1) {
		return true
	}
 // 获取槽位 i2
	i2 := getAltIndex(fp, i1, cf.bucketPow)
 // 尝试删除指纹信息
	return cf.delete(fp, i2)
}

func (cf *Filter) delete(fp fingerprint, i uint) bool {
 // 遍历槽位 4个元素,尝试删除指纹信息
	if cf.buckets[i].delete(fp) {
		if cf.count > 0 {
			cf.count--
		}
		return true
	}
	return false
}

func (b *bucket) delete(fp fingerprint) bool {
	for i, tfp := range b {
  // 指纹信息相同,将此槽位置空
		if tfp == fp {
			b[i] = nullFp
			return true
		}
	}
	return false
}

缺点

实现完布谷鸟过滤器后,我们不妨想一下,如果布谷鸟过滤器对同一个元素进行多次连续的插入会怎样?

那么这个元素会霸占两个槽位上的所有位置,最后在插入第 9 个相同元素的时候,会一直循环挤兑,直到最大循环次数,然后返回一个 false:

如果插入之前做一次检查能不能解决问题呢?这样确实不会出现循环挤兑的情况,但是会出现一定概率的误判情况。

由上面的实现我们可以知道,在每个位置里设置的指纹信息是 1byte,256 种可能,如果两个元素的 hash 位置相同,指纹相同,那么这个插入检查会认为它们是相等的导致认为元素已存在。

事实上,我们可以通过调整指纹信息的保存量来降低误判情况,如在上面的实现中,指纹信息是 1byte 保存8位信息误判概率是0.03,当指纹信息增加到 2bytes 保存16位信息误判概率会降低至 0.0001。

Reference

Cuckoo Filter: Practically Better Than Bloom https://www.cs.cmu.edu/~dga/papers/cuckoo-conext2014.pdf

Cuckoo Hashing Visualization http://www.lkozma.net/cuckoo_hashing_visualization/

Cuckoo Filter https://github.com/seiflotfy/cuckoofilter

到此这篇关于Go语言实现布谷鸟过滤器的方法的文章就介绍到这了,更多相关Go 布谷鸟过滤器内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • 详解django中自定义标签和过滤器

    想要实现自定义标签和过滤器需要进行准备工作: 准备(必需)工作: 1  在某个app下创建一个名为templatetags(必需,且包名不可变)的包.假设我们在名为polls的app下创建了一个templatetags的包,并在该包下创建了一个名为mytags的文件.那么目录结构看起来应该就像这样: polls/ __init__.py models.py templatetags/ __init__.py mytags.py views.py 2  settings文件中的INSTALLD_A

  • 深入理解Django的自定义过滤器

    前言 本文主要给大家介绍了关于Django自定义过滤器的相关内容,分享出来供大家参考学习,下面话不多说了,来一起看看详细的介绍: 过滤器与函数 django过滤器的本质是函数,但"函数"太多了,为了显示自己的与众不同,设计者们想了个名字"过滤器"... django有一些内置的过滤器,但和"新手赛车"不多(把字母转成小写,求数组长度,从数组中取一个随机值),功能很基础(不够强大)... 抱着一种"研究琢磨"的心态,试着自己动手

  • 在Django框架中自定义模板过滤器的方法

    自定义过滤器就是有一个或两个参数的Python函数: (输入)变量的值 参数的值, 可以是默认值或者完全留空 例如,在过滤器 {{ var|foo:"bar" }} 中 ,过滤器 foo 会被传入变量 var 和默认参数 bar. 过滤器函数应该总有返回值. 而且不能触发异常,它们都应该静静地失败. 如果出现错误,应该返回一个原始输入或者空字符串,这会更有意义. 这里是一些定义过滤器的例子: def cut(value, arg): "Removes all values o

  • 详解Django中的过滤器

    就象本章前面提到的一样,模板过滤器是在变量被显示前修改它的值的一个简单方法. 过滤器使用管道字符,如下所示: {{ name|lower }} 显示的内容是变量 {{ name }} 被过滤器 lower 处理后的结果,它功能是转换文本为小写. 过滤管道可以被* 套接* ,既是说,一个过滤器管道的输出又可以作为下一个管道的输入,如此下去. 下面的例子实现查找列表的第一个元素并将其转化为大写. {{ my_list|first|upper }} 有些过滤器有参数. 过滤器的参数跟随冒号之后并且总是

  • Go语言实现布谷鸟过滤器的方法

    转载请声明出处哦~,本篇文章发布于luozhiyun的博客: https://www.luozhiyun.com/archives/453 介绍 在我们工作中,如果遇到如网页 URL 去重.垃圾邮件识别.大集合中重复元素的判断一般想到的是将集合中所有元素保存起来,然后通过比较确定.如果通过性能最好的Hash表来进行判断,那么随着集合中元素的增加,我们需要的存储空间也会呈现线性增长,最终达到瓶颈. 所以很多时候会选择使用布隆过滤器来做这件事.布隆过滤器通过一个固定大小的二进制向量或者位图(bitm

  • 布隆过滤器(bloom filter)及php和redis实现布隆过滤器的方法

    引言 在介绍布隆过滤器之前我们首先引入几个场景. 场景一 在一个高并发的计数系统中,如果一个key没有计数,此时我们应该返回0,但是访问的key不存在,相当于每次访问缓存都不起作用了.那么如何避免频繁访问数量为0的key而导致的缓存被击穿? 有人说, 将这个key的值置为0存入缓存不就行了吗?确实,这是一个好的方案.大部分情况我们都是这样做的,当访问一个不存在的key的时候,设置一个带有过期时间的标志,然后放入缓存.不过这样做的缺点也很明显,浪费内存和无法抵御随机key攻击. 场景二 在一个黑名

  • go语言base64加密解密的方法

    本文实例讲述了go语言base64加密解密的方法.分享给大家供大家参考.具体实现方法如下: 复制代码 代码如下: package main import (     "encoding/base64"     "fmt" ) const (     base64Table = "123QRSTUabcdVWXYZHijKLAWDCABDstEFGuvwxyzGHIJklmnopqr234560178912" ) var coder = base6

  • go语言实现AES加密的方法

    本文实例讲述了go语言实现AES加密的方法.分享给大家供大家参考.具体实现方法如下: 复制代码 代码如下: package main import ( "fmt" "crypto/aes" "strings" ) func main(){ rb:=[]byte {1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6}; b:=make([]byte,16); strings.NewReader("123456789012345

  • go语言实现文件分割的方法

    本文实例讲述了go语言实现文件分割的方法.分享给大家供大家参考.具体实现方法如下: 复制代码 代码如下: import (  // "bufio"  "flag"  "fmt"  "io"  "os" ) import "strconv" var infile *string = flag.String("f", "Null", "ple

  • JQuery的常用选择器、过滤器、方法全面介绍

    1.jQuery对象转换成DOM对象 a. var $cr = $("#cr"); //jQuery对象 var cr=$cr[0]; //DOM对象 alert(cr.checked); //检测checkbox是否被选中了 b. var $cr=$("#cr"); //jQuery对象 var cr=$cr.get(0); //DOM对象 alert(cr.checked); //检测checkbox是否被选中了 2.DOM对象转换成jQuery对象 var c

  • Zend Framework实现自定义过滤器的方法

    本文实例讲述了Zend Framework实现自定义过滤器的方法.分享给大家供大家参考,具体如下: 创建自定义的过滤器 代码: <?php require_once 'Zend/Filter/Interface.php'; class MyFilter implements Zend_Filter_Interface{ public function filter($value){ $badlist = array("梨","草莓","苹果"

  • 使用go语言解析xml的实现方法(必看篇)

    操作系统: CentOS 6.9_x64 go语言版本: 1.8.3 问题描述 现有一个自动报障程序,如果服务出错会自动给指定人发送邮件,配置文件内容如下(default.xml): <?xml version="1.0" encoding="UTF-8"?> <config> <smtpServer>smtp.163.com</smtpServer> <smtpPort>25</smtpPort&g

  • Go语言中普通函数与方法的区别分析

    本文实例分析了Go语言中普通函数与方法的区别.分享给大家供大家参考.具体分析如下: 1.对于普通函数,接收者为值类型时,不能将指针类型的数据直接传递,反之亦然. 2.对于方法(如struct的方法),接收者为值类型时,可以直接用指针类型的变量调用方法,反过来同样也可以. 以下为简单示例: 复制代码 代码如下: package structTest    //普通函数与方法的区别(在接收者分别为值类型和指针类型的时候)  //Date:2014-4-3 10:00:07    import ( 

  • Go语言通过smtp发送邮件的方法

    本文实例讲述了Go语言通过smtp发送邮件的方法.分享给大家供大家参考.具体实现方法如下: 复制代码 代码如下: package main import (  "net/smtp"  "fmt"  "strings" ) /*  * user : example@example.com login smtp server user  * password: xxxxx login smtp server password  * host: smt

随机推荐