BloomFilter在Python爬虫中的使用

BloomFilter

BloomFilter(布隆过滤器)是由Bloom在1970年提出的一种多哈希函数映射的快速查找算法。BloomFilter是一种空间效率很高的随机数据结构,它利用位数组很简洁地表示一个集合,并能判断一个元素是否属于这个集合。BloomFilter的这种高效是有一定代价的:在判断一个元素是否属于某个集合时,有可能会把不属于这个集合的元素误认为属于这个集合(false positive)。因此,BloomFilter不适合那些“零错误”的应用场合。而在能容忍低错误率的应用场合下,BloomFilter通过极少的错误换取了存储空间的极大节省。

原理:创建一个m位的位数组,先将所有位初始化为0。然后选择k个不同的哈希函数。第i个哈希函数对字符串str哈希的结果记为h(i,str),且h(i,str)的范围是0到m-1。如图15-1所示,将 一个字符串经过k个哈希函数映射到m位数组中。

从图中我们可以看到,字符串经过哈希函数映射成介于 0 m-1 之间的数字,并将m 位位数组中下标等于这个数字的那一位置为 1 ,这样就将字符串映射到位数组中的k 个二进制位了。
如何判断字符串是否存在过呢?只需要将新的字符串也经过 h 1 ,str), h 2 str ), h 3 str ), ... h k str )哈希映射,检查每一个映射所对应m 位位数组的值是否为 1 。若其中任何一位不为 1 则可以判定str 一定没有被记录过。但是若一个字符串对应的任何一位全为 1 ,实际上是不能100% 的肯定该字符串被 BloomFilter 记录过,这就是所说的 低错误率。

以上即BloomFilter的原理,那如何选择BloomFilter参数呢?

首先哈希函数的选择对性能的影响应该是很大的,一个好的哈希函数要能近似等概率地将字符串映射到各个位。选择k个不同的哈希函数比较麻烦,一种常用的方法是选择一个哈希函数,然后送入k个不同的参数。接下来我们要选取kmn的取值。哈希函数个数k、位数组大小m、加入的字符串数量n的关系,如表15-1所示。

               

15-1 所示的是 m n k 不同的取值所对应的漏失概率,即不存在的字符串有一定概率被误判为已经存在。m 表示多少个位,也就是使用内存的大小,n 表示去重字符串的数量, k 表示哈希函数的个数。例如申请了256M 内存,即 1<<31 ,因此 m=2^31 ,约 21.5 亿。将 k 设置为 7 ,并查询表中k=7 的那一列。当漏失率为 8.56e-05 时, m/n 值为 23 。所以n=21.5/23=0.93(亿),表示漏失概率为 8.56e-05 时, 256M 内存可满足0.93亿条字符串的去重。如果大家想对 m n k 之间的关系有更深入的 了解,
推荐一篇非常有名的文献: http://pages.cs.wisc.edu/~cao/papers/summary-cache/node8.html

Python实现BloomFilter

在GitHub中有一个开源项目 python-bloomfilter ,这个项目不仅仅实现了BloomFilter,还实现了一个大小可动态扩展的 ScalableBloomFilter
 
1. 安装 python-bloomfilter
       从 https://github.com/qiyeboy/python-bloomfilter 中下载源码,进入源码目录,使用Python setup.py install 即可完成安装。
 
 

ScrapyBloomFilter

 
Scrapy 自带了去重方案,同时支持通过 RFPDupeFilter 来完成去重。 在RFPDupeFilter 源码中依然是通过  set()  进行去重。部分源码如下:
 
class RFPDupeFilter(BaseDupeFilter): 
    """Request Fingerprint duplicates filter""" 
    def __init__(self, path=None, debug=False):     
        self.file = None self.fingerprints = set() 
        self.logdupes = True 
        self.debug = debug 
        self.logger = logging.getLogger(__name__) 
        if path:
              self.file = open(os.path.join(path, 'requests.seen'), 'a+') 
              self.file.seek(0) 
              self.fingerprints.update(x.rstrip() for x in self.file)
继续查看源代码,可以了解到 Scrapy 是根据  request_fingerprint 方法实现过滤的,将Request 指纹添加到 set()  中。部分源码如下:
def request_fingerprint(request, include_headers=None):
    if include_headers: 
         include_headers = tuple(to_bytes(h.lower()) for h in sorted(include_headers)) 
    cache = _fingerprint_cache.setdefault(request, {}) 
    if include_headers not in cache: 
         fp = hashlib.sha1() 
         fp.update(to_bytes(request.method)) 
         fp.update(to_bytes(canonicalize_url(request.url))) 
         fp.update(request.body or b'') 
         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]
从代码中我们可以看到,去重指纹为sha1(method+url+body+header),对这个整体进行去重,去重比例太小。
下面我们根据URL进行去重,定制过滤器。代码如下:
from scrapy.dupefilter import RFPDupeFilter

class URLFilter(RFPDupeFilter):
     """根据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)
但是这样依旧不是很好,因为 URL 有时候会很长导致内存上升,我们可以将URL 经过 sha1 操作之后再去重,改进如下:
from scrapy.dupefilter import RFPDupeFilter 
from w3lib.util.url import canonicalize_url 

class URLSha1Filter(RFPDupeFilter): 
      """根据urlsha1过滤""" 
     def __init__(self, path=None): 
         self.urls_seen = set()
         RFPDupeFilter.__init__(self, path) 
     def request_seen(self, request): 
         fp = hashlib.sha1() 
         fp.update(canonicalize_url(request.url)) 
         url_sha1 = fp.hexdigest() 
         if url_sha1 in self.urls_seen:
             return True 
         else: 
             self.urls_seen.add(url_sha1)
这样似乎好了一些,但是依然不够,继续优化,加入 BloomFilter 进行去重。改进如下:
class URLBloomFilter(RFPDupeFilter): 
    """根据urlhash_bloom过滤""" 
    def __init__(self, path=None): 
        self.urls_sbf = ScalableBloomFilter(mode=ScalableBloomFilter.SMALL_SET_ GROWTH) 
        RFPDupeFilter.__init__(self, path) 
    def request_seen(self, request): 
        fp = hashlib.sha1()
        fp.update(canonicalize_url(request.url)) 
        url_sha1 = fp.hexdigest() 
        if url_sha1 in self.urls_sbf: 
            return True 
        else: 
            self.urls_sbf.add(url_sha1)
经过这样的处理,去重能力将得到极大提升,但是稳定性还是不够,因为是内存去重,万一出现服务器宕机的情况,内存数据将全部消失。
如果能把Scrapy、 BloomFilter Redis 这三者完美地结合起来,才是一个比较稳定的选择。 有一点一定要注意,代码编写完成后,去重组件是无法工作的,需要在
settings 中设置 DUPEFILTER_CLASS 字段,指定过滤器类的路径,比如:
DUPEFILTER_CLASS = "test.test.bloomRedisFilter. URLBloomFilter"

scrapy_redis中如何实现的RFPDupeFilter

关键代码如下:

def request_seen(self, request): 
    fp = request_fingerprint(request)
    added = self.server.sadd(self.key, fp) 
    return not added
scrapy_redis 是将生成的 fingerprint 放到 Redis set 数据结构中进行去重的。接着看一下fingerprint 是如何产生的,进入 request_fingerprint 方法中
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:
        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]
从代码中看到依然调用的是 scrapy 自带的去重方式,只不过将fingerprint的存储换了个位置。上面我们提到过这是一种比较低效的去重方式,更好的方式是将Redis BloomFilter 结合起来。
推荐一个开源项目: https://github.com/qiyeboy/Scrapy_Redis_Bloomfilter
它是在 scrapy-redis的基础上加入了 BloomFilter 的功能。使用方法如下:
git clone https:// github.com/qiyeboy/Scrapy_Redis_Bloomfilter
将源码包 clone 到本地,并将 BloomfilterOnRedis_Demo 目录下的scrapy_redis文件夹拷贝到 Scrapy 项目中 settings.py 的同级文件夹,以 demoCrawl项目为例,在 settings.py 中增加如下几个字段:
FILTER_URL=None 
FILTER_HOST='localhost' 
FILTER_PORT=6379
FILTER_DB=0 ·SCHEDULER_QUEUE_CLASS='yunqiCrawl.scrapy_redis.queue.Spide
将之前使用的官方 SCHEDULER 替换为本地目录的 SCHEDULER
SCHEDULER = "yunqiCrawl.scrapy_redis.scheduler.Scheduler"
最后将 DUPEFILTER_CLASS=“scrapy_redis.dupefilter.RFPDupeFilter”删除即可。

猜你喜欢

转载自blog.csdn.net/weixin_42277380/article/details/112346457
今日推荐