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个不同的参数。接下来我们要选取k、m、n的取值。哈希函数个数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
即可完成安装。
Scrapy和BloomFilter
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”删除即可。