使用BloomFilter最佳化scrapy-redis去重
1. 背景 做爬蟲的都知道,scrapy是一個非常好用的爬蟲架構,但是scrapy吃記憶體非常的厲害。其中有個很關鍵的點就在於去重。 “去重”需要考慮三個問題:去重的速度和去重的資料量大小,以及持久化儲存來保證爬蟲能夠續爬。
去重的速度:為了保證較高的去重速度,一般是將去重放到記憶體中來做的。例如python內建的set( ),redis的set資料結構。但是當資料量變得非常大,達到千萬級億級時,因為記憶體有限,就需要用“位”來去重了, 因此BloomFilter應運而生,將去重工作由字串直接轉到bit位上,大大降低了記憶體佔有率。
去重的資料量大小:當資料量較大時,我們可以使用不同的密碼編譯演算法,壓縮演算法(例如md5,hash)等,將長字串壓縮成16/32 長度的短字串。然後再使用set等方式來去重。
持久化儲存:scrapy預設是開啟去重的,而且提供了續爬設計,在爬蟲終止時,會記錄一個狀態檔案記錄爬取過的request和狀態。scrapy-redis的去重工作交給了redis,去重隊列放到了redis中,而redis可以提供持久化儲存。Bloomfilter是將去重對象映射到幾個記憶體“位”,通過幾個位的 0/1值來判斷一個對象是否已經存在。Bloomfilter運行在一台機器的記憶體上,並不方便持久化,爬蟲一旦終止,資料就丟失了。 如上所述,對於scrapy-redis分布式爬蟲來說,使用Bloomfilter來最佳化,必然會遇到兩個問題:
第一,要想辦法讓Bloomfilter能持久化儲存下來。 第二,對於scrapy-redis分布式爬蟲來說,爬蟲分布在好幾台不同的機器上。而Bloomfilter是基於記憶體的,如何讓各個不同的爬蟲機器能夠共用到同一個Bloomfilter,來達到統一去重。 綜上所述,
將Bloomfilter掛載到redis上,持久化儲存以及讓各爬蟲共用去重隊列,這兩個問題就都解決了。
2. 環境 系統:win7 scrapy-redis redis 3.0.5 python 3.6.1
3. Bloom Filter基本概念以及原理 詳情請參考文章:http://blog.csdn.net/jiaomeng/article/details/1495500
簡單來說,Bloom Filter是: Bloom Filter 是一種空間效率很高的隨機資料結構,利用位元組表示一個集合,並能判斷一個元素是否屬於這個集合。 Bloom Filter的這種高效是有一定代價的:在判斷一個元素是否屬於某個集合時,有可能會把不屬於這個集合的元素誤認為屬於這個集合(false positive)。當然,如果這個元素屬於這個集合,是一定不會被誤判為不存在這個集合。 Bloom Filter不適合那些“0錯誤”的應用場合。
為了能理解Bloom Filter的原理,必須熟悉以下幾個基本元素的概念: 3.1. Bloom Filter位元組 Bloom Filter是用位元組表示集合的。初始狀態時,Bloom Filter是一個包含m位的位元組 { 1, …, m },每一位都置為0。表現形式可以是一段空白的記憶體,長字串,任意一種佔用記憶體空間的資料結構……
3.2. 待去重元素 對爬蟲來說,也就是request隊列,我們記為 S = { R1, R2, …, Rn } 這樣一個n個元素的集合。 3.3. k個相互獨立的雜湊函數 Bloom Filter使用k個相互獨立的雜湊函數,我們記為 H = { H1( ), H2( ), …, Hk( ) }。利用這些hash函數,對集合S = { R1, R2, …, Rn } 中的每個元素進行處理,映射到Bloom Filter開闢記憶體{ 1, …, m }的某一位上。這樣,對於R1來說,映射的結果就是{ H1( R1 ), H2( R1 ), …, Hk( R1 ) }
需要注意的是,如果一個位置多次被置為1,那麼只有第一次會起作用,後面幾次將沒有任何效果。
從這一點就可以看出為什麼會有誤判,有可能會把不屬於這個集合的元素誤認為屬於這個集合,就是因為這個元素被映射後的集合上那些位上,可能已經被置成1了。 3.4. 錯誤率 Bloomfilter演算法會有漏失機率,即不存在的字串有一定機率被誤判為已經存在。這個機率的大小與seeds的數量、申請的記憶體大小、去重對象的數量有關。下面有一張表,m表示記憶體大小(多少個位),n表示去重對象的數量,k表示seed的個數。例如我代碼中申請了256M,即1<<31(m=2^31,約21.5億),seed設定了7個。看k=7那一列,當漏失率為8.56e-05時,m/n值為23。所以n = 21.5/23 = 0.93(億),表示漏失機率為8.56e-05時,256M記憶體可滿足0.93億條字串的去重。同理當漏失率為0.000112時,256M記憶體可滿足0.98億條字串的去重。
4. redis的setbit功能 4.1. 官方說明
# SETBIT key offset value :設定或清除該位在儲存在鍵的字串值位移對 key 所儲存的字串值,設定或清除指定位移量上的位(bit)。位的設定或清除取決於 value 參數,可以是 0 也可以是 1 。當 key 不存在時,自動產生一個新的字串值。字串會進行伸展(grown)以確保它可以將 value 儲存在指定的位移量上。當字串值進行伸展時,空白位置以 0 填充。offset 參數必須大於或等於 0 ,小於 2^32 (bit 映射被限制在 512 MB 之內)。
4.2. 使用案例
在redis中,字串都是以二級制的形式進行儲存的。
第一步:設定一個 key-value ,字串 testStr = ‘ab’
我們知道 ‘a’ 的ASCII碼是 97, 轉換為二進位是:01100001
‘b’的的ASCII碼是 98,轉換為二進位是:01100010。
‘ab’轉換成二進位就是:0110000101100010
第二步:設定位移
offset代表位移,從0開始,從左往右計數的,也就是從高位往低位 。
比如我們想將 011000010110001 0 (ab)置成 011000010110001 1(ac) ,也就是將第15位由0置成1,此時b變成了c
setbit完之後,會有有一個(integer) 0或者(integer)1的傳回值,這個是在進行setbit 之前,該offset位的位元值。
這就是redis 中 “SETBIT” 的基本用法。
redis還有一個與此相關的功能:bitcount,就是統計字串的二級制編碼中有多少個’1’。 所以這裡
bitcount testStr 得到的結果就是 7
5. 詳細部署 結合上面Bloom Filter和redis的setbit功能,我們就知道如何將Bloom Filter掛載在redis上了。沒錯,就是一個大的字串。 下面是在scrapy-redis分布式爬蟲中掛入Bloom Filter的詳細步驟: 5.1. 編寫Bloom Filter演算法。
# 檔案:Bloomfilter.py# encoding=utf-8import redisfrom hashlib import md5# 根據 開闢記憶體大小 和 種子,產生不同的hash函數# 也就是構造上述提到的:Bloom Filter使用k個相互獨立的雜湊函數,我們記為 **H = { H1( ), H2( ), ..., Hk( ) }**class SimpleHash(object): def __init__(self, bitSize, seed): self.bitSize = bitSize self.seed = seed def hash(self, value): ret = 0 for i in range(len(value)): # print(f"value[i] = {value[i]}, ord(value[i]) = {ord(value[i])}") ret += self.seed * ret + ord(value[i]) # 控制hashValue的值在這個記憶體空間的範圍 hashValue = (self.bitSize - 1) & ret # print(f"value = {value}, hashValue = {hashValue}") return hashValue# 在redis中初始化一個大字串,也可以認為是在redis中開闢了一塊記憶體空間# 需要指定資料庫名, 比如這兒用到的就是db2# 指定使用資料區塊個數,也就是開闢幾個這樣的大字串。# 當資料達到非常大時,512M肯定是不夠用的,可能每個位都被置為1了,所以需要開闢多個大字串# 大字串名name = (key + int)class BloomFilter(object): def __init__(self, host='localhost', port=6379, db=2, blockNum=1, key='bloomfilter'): """ :param host: the host of Redis :param port: the port of Redis :param db: witch db in Redis :param blockNum: one blockNum for about 90,000,000; if you have more strings for filtering, increase it. :param key: the key's name in Redis """ self.server = redis.Redis(host=host, port=port, db=db) # 2^31 = 256M # 這是一個限制值,最大為256M,因為在redis中,字串值可以進行伸展,伸展時,空白位置以0填充。 self.bit_size = 1 << 31 # Redis的String類型最大容量為512M,現使用256M self.seeds = [5, 7, 11, 13, 31, 37, 61] self.key = key self.blockNum = blockNum self.hashfunc = [] for seed in self.seeds: # 根據seed 構造出 k=7 個獨立的hash函數 self.hashfunc.append(SimpleHash(self.bit_size, seed)) # 判斷元素是否在集合中 def isContains(self, str_input): if not str_input: return False m5 = md5() m5.update(str_input.encode('utf-8')) # 先取目標字串的md5值 str_input = m5.hexdigest() ret = True name = self.key + str(int(str_input[0:2], 16) % self.blockNum) for f in self.hashfunc: loc = f.hash(str_input) ret = ret & self.server.getbit(name, loc) return ret # 將str_input映射的結果,寫入到大字串中,也就是置上相關的標誌位 def insert(self, str_input): m5 = md5() m5.update(str_input.encode('utf-8')) str_input = m5.hexdigest() name = self.key + str(int(str_input[0:2], 16) % self.blockNum) for f in self.hashfunc: loc = f.hash(str_input) # print(f"name = {name}, loc = {loc}") self.server.setbit(name, loc, 1)if __name__ == '__main__': # 第一次運行時會顯示 not exists, 之後再運行會顯示 exists bf = BloomFilter() if bf.isContains('http://www.sina.com.cn/'): # 判斷字串是否存在 print('url exists!') else: print('url not exists!') bf.insert('http://www.sina.com.cn/')
5.2. 修改scrapy-redis的去重演算法。
5.2.1. 分析源碼調度過程。
# 調度過程:1. 第一步,調度檔案: scheduler.pyopen() ——> self.df = load_object(self.dupefilter_cls) ——> dupefilter_cls=defaults.SCHEDULER_DUPEFILTER_CLASS ——> SCHEDULER_DUPEFILTER_CLASS = 'scrapy_redis.dupefilter.RFPDupeFilter' # 加入調度隊列def enqueue_request(self, request): if not request.dont_filter and self.df.request_seen(request): self.df.log(request, self.spider) return False if self.stats: self.stats.inc_value('scheduler/enqueued/redis', spider=self.spider) self.queue.push(request) return True可見是使用dupefilter檔案中的RFPDupeFilter類的 request_seen 方法來進行去重的。2. 第二步,去重檔案:dupefilter.pydef request_seen(self, request): """Returns True if request was already seen. Parameters ---------- request : scrapy.http.Request Returns ------- bool """ fp = self.request_fingerprint(request) # This returns the number of values added, zero if already exists. added = self.server.sadd(self.key, fp) return added == 0可以知道scrapy_redis是利用set資料結構來去重的,去重的對象是request的fingerprint。def request_fingerprint(request, include_headers=None): """ Return the request fingerprint. The request fingerprint is a hash that uniquely identifies the resource the request points to. For example, take the following two urls: http://www.example.com/query?id=111&cat=222 http://www.example.com/query?cat=222&id=111 Even though those are two different URLs both point to the same resource and are equivalent (ie. they should return the same response). Another example are cookies used to store session ids. Suppose the following page is only accesible to authenticated users: http://www.example.com/members/offers.html Lot of sites use a cookie to store the session id, which adds a random component to the HTTP Request and thus should be ignored when calculating the fingerprint. For this reason, request headers are ignored by default when calculating the fingeprint. If you want to include specific headers use the include_headers argument, which is a list of Request headers to include. """ 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]從request_fingerprint可以看出,fingerprint到底是什麼,,其實就是用hashlib.sha1()對request對象的某些欄位資訊進行壓縮,用調試也可以看到,其實fp就是request對象加密壓縮後的一個字串(40個字元,0~f) 總結,從上面的調度過程我們就可以看出,修改點就在於
dupefilter.request_seen()函數。
5.2.2. 修改源碼。 原始檔案
dupefilter.py
# 原始檔案 .\Lib\site-packages\scrapy_redis\dupefilter.pyimport loggingimport timefrom scrapy.dupefilters import BaseDupeFilterfrom scrapy.utils.request import request_fingerprintfrom . import defaultsfrom .connection import get_redis_from_settingslogger = logging.getLogger(__name__)# TODO: Rename class to RedisDupeFilter.class RFPDupeFilter(BaseDupeFilter): """Redis-based request duplicates filter. This class can also be used with default Scrapy's scheduler. """ logger = logger def __init__(self, server, key, debug=False): """Initialize the duplicates filter. Parameters ---------- server : redis.StrictRedis The redis server instance. key : str Redis key Where to store fingerprints. debug : bool, optional Whether to log filtered requests. """ self.server = server self.key = key self.debug = debug self.logdupes = True @classmethod def from_settings(cls, settings): """Returns an instance from given settings. This uses by default the key ``dupefilter:<timestamp>``. When using the ``scrapy_redis.scheduler.Scheduler`` class, this method is not used as it needs to pass the spider name in the key. Parameters ---------- settings : scrapy.settings.Settings Returns ------- RFPDupeFilter A RFPDupeFilter instance. """ server = get_redis_from_settings(settings) # XXX: This creates one-time key. needed to support to use this # class as standalone dupefilter with scrapy's default scheduler # if scrapy passes spider on open() method this wouldn't be needed # TODO: Use SCRAPY_JOB env as default and fallback to timestamp. key = defaults.DUPEFILTER_KEY % {'timestamp': int(time.time())} debug = settings.getbool('DUPEFILTER_DEBUG') return cls(server, key=key, debug=debug) @classmethod def from_crawler(cls, crawler): """Returns instance from crawler. Parameters ---------- crawler : scrapy.crawler.Crawler Returns ------- RFPDupeFilter Instance of RFPDupeFilter. """ return cls.from_settings(crawler.settings) def request_seen(self, request): """Returns True if request was already seen. Parameters ---------- request : scrapy.http.Request Returns ------- bool """ fp = self.request_fingerprint(request) # This returns the number of values added, zero if already exists. added = self.server.sadd(self.key, fp) return added == 0 def request_fingerprint(self, request): """Returns a fingerprint for a given request. Parameters ---------- request : scrapy.http.Request Returns ------- str """ return request_fingerprint(request) def close(self, reason=''): """Delete data on close. Called by Scrapy's scheduler. Parameters ---------- reason : str, optional """ self.clear() def clear(self): """Clears fingerprints data.""" self.server.delete(self.key) def log(self, request, spider): """Logs given request. Parameters ---------- request : scrapy.http.Request spider : scrapy.spiders.Spider """ if self.debug: msg = "Filtered duplicate request: %(request)s" self.logger.debug(msg, {'request': request}, extra={'spider': spider}) elif self.logdupes: msg = ("Filtered duplicate request %(request)s" " - no more duplicates will be shown" " (see DUPEFILTER_DEBUG to show all duplicates)") self.logger.debug(msg, {'request': request}, extra={'spider': spider}) self.logdupes = False 修改後的檔案:
dupefilter.py
# 修改後的檔案 .\Lib\site-packages\scrapy_redis\dupefilter.pyimport loggingimport timefrom scrapy.dupefilters import BaseDupeFilterfrom scrapy.utils.request import request_fingerprintfrom . import defaultsfrom .connection import get_redis_from_settingsisUseBloomfilter = Falsetry: from .Bloomfilter import BloomFilterexcept Exception as e: print(f"there is no BloomFilter, used the default redis set to dupefilter.")else: isUseBloomfilter = Truelogger = logging.getLogger(__name__)# TODO: Rename class to RedisDupeFilter.class RFPDupeFilter(BaseDupeFilter): """Redis-based request duplicates filter. This class can also be used with default Scrapy's scheduler. """ logger = logger def __init__(self, server, key, debug=False): """Initialize the duplicates filter. Parameters ---------- server : redis.StrictRedis The redis server instance. key : str Redis key Where to store fingerprints. debug : bool, optional Whether to log filtered requests. """ self.server = server self.key = key self.debug = debug self.logdupes = True # 使用 Bloonfilter 來對url去重 if isUseBloomfilter == True: self.bf = BloomFilter() @classmethod def from_settings(cls, settings): """Returns an instance from given settings. This uses by default the key ``dupefilter:<timestamp>``. When using the ``scrapy_redis.scheduler.Scheduler`` class, this method is not used as it needs to pass the spider name in the key. Parameters ---------- settings : scrapy.settings.Settings Returns ------- RFPDupeFilter A RFPDupeFilter instance. """ server = get_redis_from_settings(settings) # XXX: This creates one-time key. needed to support to use this # class as standalone dupefilter with scrapy's default scheduler # if scrapy passes spider on open() method this wouldn't be needed # TODO: Use SCRAPY_JOB env as default and fallback to timestamp. key = defaults.DUPEFILTER_KEY % {'timestamp': int(time.time())} debug = settings.getbool('DUPEFILTER_DEBUG') return cls(server, key=key, debug=debug) @classmethod def from_crawler(cls, crawler): """Returns instance from crawler. Parameters ---------- crawler : scrapy.crawler.Crawler Returns ------- RFPDupeFilter Instance of RFPDupeFilter. """ return cls.from_settings(crawler.settings) def request_seen(self, request): """Returns True if request was already seen. Parameters ---------- request : scrapy.http.Request Returns ------- bool """ if isUseBloomfilter == True: # 使用 Bloomfilter 來對url去重 fp = self.request_fingerprint(request) if self.bf.isContains(fp): # 如果已經存在 return True else: self.bf.insert(fp) return False else: # 使用redis預設的set進行去重 fp = self.request_fingerprint(request) # This returns the number of values added, zero if already exists. added = self.server.sadd(self.key, fp) return added == 0 def request_fingerprint(self, request): """Returns a fingerprint for a given request. Parameters ---------- request : scrapy.http.Request Returns ------- str """ return request_fingerprint(request) def close(self, reason=''): """Delete data on close. Called by Scrapy's scheduler. Parameters ---------- reason : str, optional """ self.clear() def clear(self)<