深入理解爬虫去重原理

理解爬虫去重原理

一.简介

对于爬虫来说,去重可以避免网络之间的环路,增加爬取效率,避免重复数据反复请求,降低了被识别的风险,减少磁盘 IO,减轻了存储负担,去重后的数据,才具有可用性。

二.目前常用的去重方式以及原理

1.Set集合去重

1.1.如何对一个列表进行去重?

ids = [1,2,3,3,4,6,3,4,5,1]
news_ids = []
for id in ids:
if id not in news_ids:
news_ids.append(id)
print(news_ids)
这种方法是通过构建一个空列表,然后判断元素是否存在于这个列表中,如果不存在则追加到列表尾部,最终得到一个新的去重后的列表。

ids = [1,2,3,3,4,6,3,4,5,1]
ids = list(set(ids))
print(ids)
输出:[1, 2, 3, 4, 5, 6]
这种方法是直接调用set函数,利用集合元素的唯一性,进行去重。
集合set特性:无序,元素唯一,接受可哈希对象作为其成员。如果成员是不可哈希的,那么就会报错:print({[1,2,3],(1,2),1}) #TypeError: unhashable type: ‘list’

1.2.set方法的去重原理
class Foo:
    def __init__(self,name,count):
        self.name = name
        self.count = count
    def __hash__(self):
        print("%s调用了哈希方法"%self.name)
        return hash(self.count)
    def __eq__(self, other):
        print("%s调用了eq方法"%self.name)
        print(self.__dict__)
        print(other.__dict__)
        return self.__dict__ == other.__dict__

f1 = Foo('f1',1)
f2 = Foo('f2',1)
f3 = Foo('f3',3)
ls = [f1,f2,f3]
print(set(ls))
输出:
f1调用了哈希方法
f2调用了哈希方法
f1调用了eq方法
{'name': 'f1', 'count': 1}
{'name': 'f2', 'count': 1}
f3调用了哈希方法
{<__main__.Foo object at 0x04589C30>, <__main__.Foo object at 0x04589FF0>, <__main__.Foo object at 0x04589E30>}

可以看到,python中的set去重时候会调用__hash__这个魔法方法,如果传入的变量 不可哈希,会直接抛出异常,如果返回的哈希值相同,又会调用__eq__这个魔法方法。

结论:set的去重是通过两个函数__hash__和__eq__结合实现的。
(1)、当两个变量的哈希值不相同时,就认为这两个变量是不同的
(2)、当两个变量哈希值一样时,调用__eq__方法,当返回值为True时认为这两个变量是同一个,应该去除一个。返回FALSE时,不去重。

1.3. Set方法去重效率

Python的set去重优点是去重速度快,但它要将去重的对象同时都加载到内存,然后进行比较判断,返回去重后的集合对象,虽然底层也是哈希后来判断的,但还是比较消耗内存,而且set()方法并不是判断元素是否存在集合中的方式。

我们可以利用redis的缓存数据库当中的set类型,来判断元素是否存在集合中的方式,它的底层实现原理与python的set类似。Redis的set类型有sadd()方法与sismember()方法,如果redis当中不存在这条记录sadd则添加进去,sismember返回False,如果存在这条记录,sadd不添加,sismember返回True。我们可以计算一下这种方式对内存的消耗情况:
如果可用内存为1G,哈希取摘要后的每条记录占32个字节,那么根据进制转换(1GB=1024MB=10241024KB=102410241024B=102410241024Bit),在不考虑哈希冲突的情况下:1102410241024/32= 33554432,1G内存即可以去重3300万条记录;考虑到哈希表存储效率通常小于50%(哈希冲突),1G内存可去重1650万条记录,因此对达到上亿条或者十亿条的数据,采用这种去重方式就会产生内存不够用的情况。
这个时候,布隆过滤器(Bloom Filter)就派上用场了。

2.bloomfilter布隆过滤器

2.1. 简单了解哈希函数

哈希函数的概念是:将任意大小的数据转换成特定大小的数据的函数,转换后的数据称为哈希值或哈希编码。下面是一幅示意图:
在这里插入图片描述
可以明显的看到,原始数据经过哈希函数的映射后称为了一个个的哈希编码,数据得到压缩。哈希函数是实现哈希表和布隆过滤器的基础。

2.2.布隆过滤器的实现原理

布隆过滤器(Bloom Filter)的核心实现是一个超大的位数组和几个哈希函数。以下图为例:
在这里插入图片描述
具体的操作流程:假设集合里面有3个元素{x, y, z},哈希函数的个数为3。首先将位数组进行初始化,将里面每个位都设置位0。对于集合里面的每一个元素,将元素依次通过3个哈希函数进行映射,每次映射都会产生一个哈希值,这个值对应位数组上面的一个点,然后将位数组对应的位置标记为1。查询W元素是否存在集合中的时候,同样的方法将W通过哈希映射到位数组上的3个点。如果3个点的其中有一个点不为1,则可以判断该元素一定不存在集合中。反之,如果3个点都为1,则该元素可能存在集合中。注意:此处不能判断该元素是否一定存在集合中,可能存在一定的误判率。可以从图中可以看到:假设某个元素通过映射对应下标为3,4,5这3个点。虽然这3个点都为1,但是很明显这3个点是不同元素经过哈希得到的位置,因此这种情况说明元素虽然不在集合中,也可能对应的都是1,这是误判率存在的原因。
我们来做一个抽象化:创建一个m位的位数组,全部初始化为0,选择k个不同的哈希函数,记第i个哈希函数对字符串str哈希的结果为h(i,str),h(i,str)范围是0到m-1。字符串经过哈希函数映射为k个介于0到m-1的数字,将m位数组中下标为这k个数的位置1。这样便将字符串映射到位数组中的k个二进制位了。若判断字符串是否存在,只需将新的字符串进行哈希,检查每一个映射对应的m位位数组的值是否为1,若任何1位不为1,则一定没被记录过。若一个字符串对应的任何一位全为1,但不能说明这个字符串肯定被记录过,因为有一定的低错误率。因此布隆过滤器适用于能容忍低错误率的场合。

2.3. 如何选择Bloom Filter的参数

首先哈希函数的选择对性能的影响是很大的,一个好的哈希函数要能近似等概率地将字符串映射到各个位。选择k个不同的哈希函数比较麻烦,一种常用的方法是选择一个哈希函数,然后送入k个不同的参数。下面我们看一下k,m,n的取值的关系,如图:
在这里插入图片描述
上图所示的是m,n,k不同的取值对应的漏失概率,即不存在的字符串有一定的概率被误判为已经存在。m表示位数组大小,也就是使用内存的大小,k表示哈希函数个数,n表示加入的字符串数量。例如申请了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亿条字符串的去重[1],可以发现布隆过滤器对内存占用要比set小很多。

2.4. 布隆过滤器动态扩容

布隆过滤器他在创建的时候就确定了容量以及错误率(false postive),是不可变的,但如果布隆过滤器容量确实不够了,该怎么办呢?或者如果要每个月都删除几个月前的去重数据,该如何处理呢?
布隆过滤器扩容:因为布隆过滤器的不可逆,没法重新建一个更大的布隆过滤器然后去把数据重新导入。采取的扩容的方法是,保留原有的布隆过滤器,建立一个更大的,新增数据都放在新的布隆过滤器中,去重的时候检查所有的布隆过滤器。用一个新的布隆过滤器和多个老的布隆过滤器共同组成一个新的过滤器,提供相同的接口。
附带时效的布隆过滤器:使用布隆过滤器对url去重,但是每五个月要重新爬取一次。这边介绍一种循环的布隆过滤器,类似于之前的思路,由多个布隆过滤器组成,每个月都清空最早的那个过滤器。[3]
上面的方法操作不慎都容易出现问题,因此可以考虑设计之初就使用动态的布隆过滤器[2]。

class ScalableURLFilter(RFPDupeFilter):
    """A dupe filter that according to urlhash_bloom"""
    def __init__(self, path=None):
        self.urls_sbf = ScalableBloomFilter(initial_capacity=100, error_rate=0.001,mode=ScalableBloomFilter.SMALL_SET_GROWTH)
        RFPDupeFilter.__init__(self, path)
    def request_seen(self, request):
        if request.url in self.urls_sbf:
            return True
        else:
            self.urls_sbf.add(request.url)
2.5.布隆过滤器总结

在计算机科学中,我们常常会碰到时间换空间或者空间换时间的情况,即为了达到某一个方面的最优而牺牲另一个方面。Bloom Filter在时间空间这两个因素之外又引入了另一个因素:错误率。在使用Bloom Filter判断一个元素是否属于某个集合时,会有一定的错误率。也就是说,有可能把不属于这个集合的元素误认为属于这个集合(False Positive),但不会把属于这个集合的元素误认为不属于这个集合(False Negative)。在增加了错误率这个因素之后,Bloom Filter通过允许少量的错误来节省大量的存储空间。
布隆过滤器的要点如下:
Burton Bloom在70年代提出;
一个很长的二进制向量 (位数组);
一系列随机函数 (哈希);
空间效率和查询效率高;
有一定的误判率(哈希表是精确匹配);
广泛用于拼写检查,网络去重和数据库系统中。

三.去重在爬虫当中的应用

3.1.去重方案

(1)关系数据库去重:需要将url存入到数据库,每来一个url就启动一次查询,数据量大的时候,查询效率低,不推荐。
(2)缓存数据库去重: 如Redis,使用其中的Set数据类型,它可将内存中的数据持久化,应用广泛,推荐。
(3)内存去重:如将url直接存到HashSet中,也就是python中的set,这种方式也很消耗内存,不推荐;更进一步将url经过MD5或SHA-1等哈希算法生成摘要,再存到HashSet中。MD5处理后摘要长度128位,SHA-1摘要长度160位,这样占用内存比直接存小很多。或者采用Bit-Map方法,建立一个BitSet,每个url经过一个哈希函数映射到一位,消耗内存最少,但是会发生冲突,产生误判,bloom filter就是对bit-map的扩展。
综上,比较好的方式为:内存去重第二种方式+缓存数据库。基本可以满足大多数中型爬虫需要。数据量上亿或者几十亿时,用BloomFilter算法了。

3.2.scrapy_redis当中的去重

Scrapy_redis分布式爬虫,源代码使用redis做持久化存储,关键代码如下:

class RFPDupeFilter(BaseDupeFilter):
    def __init__(self, path=None, debug=False):
        self.fingerprints = 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]

可以发现,RFPDupeFilter类中初始化默认采用redis的set()存储方法,request_fingerprint方法中,去重指纹是sha1(method + url + body + header),所以,实际能够去掉重复的比例并不大。如果我们需要自己提取去重的finger,需要自己实现Filter,并配置上它。如下,可以考虑用url做指纹去重:

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)

我们也可以结合redis数据库自定义布隆,这样更加具有灵活性。

从之前的分析可以看到set底层用的哈希表是精确匹配,bloomfilter底层是位数组与多个哈希函数,会有漏失,也就是错误率。还有一种去重技术—simhash,它是Google用来处理海量文本去重的算法,判断两个文档是否相似。将一个文档,最后转换成一个64位的字节,可称之为特征字,然后判断重复只需要判断他们的特征字的距离是不是<n(根据经验这个n一般取值为3),来判断两个文档是否相似。

参考链接:
[1]. http://pages.cs.wisc.edu/~cao/papers/summary-cache/node8.html
[2]. https://github.com/jaybaird/python-bloomfilter
[3].https://gaoconghui.github.io/2018/07/布隆过滤器扩容以及删除过期数据/
[4].https://blog.csdn.net/jiaomeng/article/details/1495500

  • 2
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值