详解布隆过滤器(Bloomfilter)+scrapy分布式持久化去重

前提

网上大部分python实现的布隆过滤器库如:pybloomfilter、pybloom 但都是基于py2且哈希函数用的都是sha1类、md5类,效率不如mmh3.所以决定自己实现,

git地址:https://github.com/Sssmeb/BloomFilter

觉得有帮助的可以star~~ 也欢迎讨论、指教!!

Bloom Filter(布隆过滤器)

布隆过滤器是一种多哈希函数映射的快速查找算法,通常应用在一些需要快速判断某个元素是否属于集合,但并不严格要求100%正确的场合。

本质上是一种数据结构,比较巧妙的概率型数据结构。

布隆过滤器可能会出现误判,但不会漏判。即,如果过滤器判断该元素不在集合中,则元素一定不在集合中,但如果过滤器判断该元素在集合中,有一定的概率判断错误(在合适的参数情况下,误判率可以降低到0.000级别甚至更低)。

因此,Bloom Filter不适合那些“零错误”的应用场合。而在能容忍低错误率的应用场合下,Bloom Filter相比于其他常见的算法极大节省了空间(相较于直接存储,可节省上千倍的空间)。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是存在误识别率和删除困难

应用

常见适用的场景主要利用布隆过滤器减少磁盘io或网络请求等:

  1. 黑名单

    • 例如 邮件黑名单过滤器,判断邮件地址是否存在黑名单中
  2. 网络爬虫去重

  3. K-V系统快速判断某个key是否存在

    • 例如 Hbase每个Region都包含一个BloomFilter,用于快速判断某个key在该region中是否存在
  4. 缓解缓存穿透

    • 大量查询不存在数据的请求,越过redis缓存后,全部打到数据库中

    • 可以在服务器内存中搭建一个布隆过滤器缓解

去重背景

一般将数据存储用做去重判断的方法有:

  1. 将数据直接存储到数据库中

  2. 用HashSet(字典结构)/redis的set 将数据存储起来,实现O(1)时间复杂度的查询

  3. 经过MD5 或 SHA-1等 单向哈希后再保存到HashSet或数据库

  4. Bit-Map。建立一个BitSet,将每份数据通过哈希函数映射到某一位(bit)。

当数据量较小时,前3种方法都是不错的选择。但是当数据量非常大时(几G、甚至几十G)会出现存储瓶颈。

当数据量较大时,上述四种方法的表现:

  1. 查询效率非常低,每检查一个数据是否存在时都需要扫描全表。

  2. 占用大量的内存空间(内存较昂贵)

  3. 由于字符串经过MD5或SHA-1处理后,长度只有128bit或160bit,所以当数据本身长度较大时,比方法2节省内存。

  4. 消耗内存少,但单一哈希函数发生冲突的概率太高。若要冲突率降到1%,就要将Bitset的长度设置为数据个数的100倍。

Bloom Filter原理

基于以上的背景,可以看到:当数据量非常大时,方法4是较好的选择。但该较大的问题是冲突率高,为了降低冲突,Bloom Filter使用多个哈希函数,而不是一个。

总结BloomFilter的核心思想:

  1. 多个hash,增大随机性,减少hash碰撞的概率

  2. 扩大数组范围,使hash值均匀分布,进一步减少hash碰撞的概率

算法实现

  1. 创建一个m位的BitSet,先将所有位初始化为0
    image
  2. 插入数据流程:

    1. 加入字符串,经过k个哈希函数,分别计算出k个范围是0 - m-1的值

    2. 将k个值对应的BitSet位 置1

image

检查流程:

  1. 将数据经过k个哈希函数,分别计算出k个值

  2. 若k个位都为1,则判断存在。(可能误判)

  3. 有任意1位是0,则肯定不存在

通过上述流程也得,布隆过滤器需要提前预定位数组的大小。

删除操作?

经典的布隆过滤器可以支持 add 和 isExist操作。但是不支持delete操作

例如,有两个值共同覆盖了一个位,当需要删除其中一个值时,会导致另一个值的该位也被删除,最终导致错判

可以使用计数删除解决这个问题。即不再使用bit位,而存储一个数值。插入操作时不再是置1,而是加1操作。判断时不再判断0、1,而是判断是否大于0。但是这种做法明显增大了占用的内存,这里不展开。

参数选择

哈希函数选择

简单总结经典哈希函数的5个特点:

  1. 输入域无穷

  2. 输出域有固定范围

  3. 相同的输入,输出一定相同

  4. 不同的输入,可能相同

    • 产生哈希碰撞的原因
  5. 数据足够多的情况下,输出域近乎均匀

    • 离散性

    • 用来评判哈希函数优劣的关键。哈希函数越好,离散性越好(输出值分布越均匀)。

    • 将其返回值对m取余(%m),得到的返回值可以认为也会均匀的分布在0~m-1位置上

哈希函数的选择对性能影响较大,一个好(离散性高)的哈希函数能近似等概率的将字符串映射到各个bit。选择k个不同的哈希函数比较麻烦,一种简单的方法是选择一个哈希函数,然后送入k个不同的参数

哈希函数个数和位数组大小的确定

显然,哈希函数个数越少、位数组越小误报率就越高,效率越低。

image

取自:https://www.jianshu.com/p/2104d11ee0a2

哈希函数的个数k、位数组大小m、加入的字符串数量n、误报率p 的关系。

通过简单的数学推导可以得出以下结论:

image
image

哈希函数个数k取10,位数组大小m设为字符串个数n的20倍时,false positive发生的概率是0.0000889 ,即10万次的判断中,会存在9次误判,对于一天1亿次的查询,误判的次数为9000次。可见在参数良好的情况下,误报率在可接受的范围内。

公式推导

哈希函数的个数k、位数组大小m、加入的字符串数量n、误报率p 的关系。

在已得误报率p、数据量的情况下(通过用户输入),我们来建立关于p的表达式。

k 次哈希函数某一 bit 位未被置为 1 的概率为:
image

插入n个元素后依旧为 0 的概率和为 1 的概率分别是:
image
image

标明某个元素是否在集合中所需的 k 个位置都按照如上的方法设置为 1,但是该方法可能会使算法错误的认为某一原本不在集合中的元素却被检测为在该集合中(False Positives),该概率由以下公式确定

image

利用一点高数变化,当m很大时
image

则,上式得
image
image

取自:https://blog.csdn.net/wh_springer/article/details/52193110

进阶优化

性能很低的哈希函数不是个好选择,推荐 MurmurHash、Fnv 这些。

Redis 因其支持 setbit 和 getbit 操作,且纯内存性能高等特点,因此天然就可以作为布隆过滤器来使用。可以通过redis实现分布式的持久化去重。但是需要注意redis的bitmap是用字符串来实现的,而redis规定字符串最长为512MB(40多亿位),因此生产环境中建议对体积庞大的布隆过滤器进行拆分

Bloom Filter具体实现(redis、python)

限于文章篇幅,以下仅使用简单实现说明。具体实现代码:

https://github.com/Sssmeb/BloomFilter/tree/master

求星星求start!! 也非常欢迎讨论、指点~

python实现

基于以上分析,通过python实现一个简单的版本,核心函数add和contains都很好理解。初始化参数仅是数组大小和哈希函数个数。常见的实现是误判率(根据误判率来调整函数的个数)。

取自:https://blog.csdn.net/happytofly/article/details/80124542

    from bitarray import bitarray

    # 3rd party
    import mmh3


    class BloomFilter(set):

        def __init__(self, size, hash_count):
            super(BloomFilter, self).__init__()
            self.bit_array = bitarray(size)
            self.bit_array.setall(0)
            self.size = size
            self.hash_count = hash_count

        def __len__(self):
            return self.size

        def __iter__(self):
            return iter(self.bit_array)

        def add(self, item):
            for ii in range(self.hash_count):
                index = mmh3.hash(item, ii) % self.size
                self.bit_array[index] = 1

            return self

        def __contains__(self, item):
            out = True
            for ii in range(self.hash_count):
                index = mmh3.hash(item, ii) % self.size
                if self.bit_array[index] == 0:
                    out = False

            return out

哈希函数 - Murmur hash3

murmur hash是一种非加密型哈希函数,适用于一般的哈希检索操作。对于规律性较强的key,murmurhash的随机分布特征表现更良好。

redis在实现字典时用到了两种不同的哈希算法,murmur hash就是其中一种(另一种是djb)。
redis中数据库、集群、哈希键、阻塞操作等功能都用到了这个算法。

相比于md5,murmur hash在万次测试中,性能高4-5倍。

redis

简单的实现把数据放在本地内存中,无法实现布隆过滤器的共享,我们可以把数据放在redis中,用redis实现布隆过滤器。

思路是将布隆过滤器的位数组用redis的bitmap代替,由于redis最大申请空间为512MB,可以通过多个键来扩充位数组。

由于redis自带setbit、getbit,所以实现起来更加便捷。

具体实现参看git:https://github.com/Sssmeb/BloomFilter/tree/master

scrapy中的去重

scrapy自带了去重的功能,主要是通过fingerprint(指纹)标志过滤,用set实现去重功能。

在源码中的实现

class RFPDupeFilter(BaseDupeFilter):
    def __init__(self, path=None, debug=False):
        self.file = None
        self.fingerprints = set()   # 集合
        xxx # 省略
    
    # 通过request_fingerprint计算出请求的fp
    # 根据是否存在于fingerprints集合中判断
    def request_seen(self, request):
        fp = self.request_fingerprint(request)
        if fp in self.fingerprints:
            return True
        self.fingerprints.add(fp)
        if self.file:
            self.file.write(fp + os.linesep)

request_fingerprint方法用于计算请求的指纹(fp)。去重指纹是sha1(method + url + body + header)


    # 计算请求fp函数
    def request_fingerprint(request, include_headers=None):
        # 判断是否带请求头信息
        if include_headers:
            include_headers = tuple([h.lower() for h in sorted(include_headers)])
        # 获取该请求的缓存
        cache = _fingerprint_cache.setdefault(request, {}) 
        # 如果是新请求头信息
        if include_headers not in cache:
              # sha1算法
              fp = hashlib.sha1()
              fp.update(request.method)
              fp.update(canonicalize_url(request.url))
              fp.update(request.body or '') 
              if include_headers:
                for hdr in include_headers:
                      if hdr in request.headers:
                        fp.update(hdr)
                        for v in request.headers.getlist(hdr):
                              fp.update(v)
              cache[include_headers] = fp.hexdigest()
        return cache[include_headers]

如果想自定义Filter,可以通过继承,重写request_seen

from scrapy.dupefilter import RFPDupeFilter
class SeenURLFilter(RFPDupeFilter):
      """A dupe filter that considers the URL"""
      def __init__(self, path=None):
        self.urls_seen = set()
        RFPDupeFilter.__init__(self, path)
      def request_seen(self, request):
        if request.url in self.urls_seen:
              return True
        else:
              self.urls_seen.add(request.url)


# 修改settings设置
DUPEFILTER_CLASS ='scraper.custom_filters.SeenURLFilter'

scrapy-redis中的去重策略

scrapy-redis的策略基本和scrapy相同,只是所用的数据结构不同。

去重结构使用的是redis中的集合,键名为XX:dupefilter。该结构中存储了已爬取的请求。

另外, 请求队列使用的是redis中的有序集合, 键名为XX:request, 存储了待爬取的请求
        items数据使用的是redis中的列表, 键名为XX:items, 存储了爬取到的数据

存在的问题

redis是内存数据库,也就是说以上的三块数据:所有待爬取的请求、爬取到的items数据、去重的集合,都会存在内存中。

请求队列会随着爬取的进行,动态的出入,不会无限的叠加。爬取到的items数据一般会转移到其他的数据库中(mysql、mongodb),也不会无限的叠加。但是去重集合会随着爬取的进行,添加新的指纹,导致占用的内存空间越来越大,最终可能成为运行瓶颈。

改用布隆过滤器流程

以下只介绍修改流程,布隆过滤器实现见git:https://github.com/Sssmeb/BloomFilter/tree/master

1. 加入文件

(可以先复制一份scrapy_redis源码文件到当前scrapy工作目录下)将自己编写的bloomfilter.py文件加入scrapy_redis源码中

2. 修改源码,加入布隆过滤器

dupefilter.py(去重相关)文件中 导入布隆过滤器文件

from .bloomfilter import BloomFilter

在init函数中,加入实例化

self.bf = BloomFilter(server, key)

修改request_seen方法的去重规则

fp = self.request_fingerprint(request)
if self.bf.is_exist(fp):
    return True
else:
    self.bf.add(fp)
    return False
3. 修改配置

像正常使用scrapy_redis一样修改即可。

DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
SCHEDULER = "scrapy_redis.scheduler.Scheduler"

如果想标识这个特别的scrapy_redis,可以修改scrapy_redis目录名称,在导入时修改对应的文件名即可

DUPEFILTER_CLASS = "scrapy_redis_blommfilter.dupefilter.RFPDupeFilter"
SCHEDULER = "scrapy_redis_blommfilter.scheduler.Scheduler"

引用

https://piaosanlang.gitbooks.io/spiders/content/09day/section9.1.html
https://blog.csdn.net/xinzhongtianxia/article/details/81294922
https://zhuanlan.zhihu.com/p/50587308

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,509评论 6 504
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,806评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,875评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,441评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,488评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,365评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,190评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,062评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,500评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,706评论 3 335
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,834评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,559评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,167评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,779评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,912评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,958评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,779评论 2 354

推荐阅读更多精彩内容