天天看點

關于scrapy分布式爬蟲請求去重和指紋過期的兩種方法——思路

2019.2.23 更新:

其實第一種方法,在寫完這篇博文後沒幾天我就給實作了。本來想過幾天就專門寫一篇博文對實作方法、代碼作介紹的,但我懶啊,就一直拖到現在還沒寫。。。先把實作的位址放上吧,相關博文有空了再說(一般來說,有空了再搞 等于 不搞了?=。=)。

項目 github:scrapy_redis_expiredupefilter

位址:https://github.com/AaronJny/scrapy_redis_expiredupefilter

emmm,名字起得有點low。畢竟起名苦手= =!

PS:這篇博文主要讨論思路、方法,有細節僞代碼,但沒有完整實作代碼。如果有時間,後面會專門寫一篇實作的博文,附上完整代碼。

轉載請注明出處:https://blog.csdn.net/aaronjny/article/details/84899262

scrapy應該算是當下最流行、也最受歡迎的python爬蟲架構了。利用scrapy,爬蟲工程師可以快速開發高效的爬蟲程式。

scrapy預設是單機模式,但可以借助scrapy_redis,通過簡單的配置,實作單機scrapy程式到分布式爬蟲程式的轉化。其内在原理可以簡單了解為:借助公用的redis資料庫,實作請求隊列和去重集合的共享,進而使各主機上分布式部署的爬蟲程式協調工作。

這裡就不深入讨論了,網上相關的資料有很多,這也不是本文讨論的重點,不再多做贅述。

當分布式爬蟲開發完成後,有一個問題就被擺在我們面前:如何讓請求指紋在指定時間後自動過期?

如果不是很了解這個問題的話,我們舉個例子。

假設,我們接到一個需求,需要采集某網站的博文資訊。具體的需求是這樣的:

  • 1.網站有一個博文清單的入口,可以翻頁,我們的爬蟲定時運作,每次采集前30頁的資料。需要注意的是,這個清單的排序不是按照釋出時間排列的,并且博文的更新或者點贊等其他操作都有可能使博文的排序提升。
  • 2.一個博文在一個月内隻采集一次,當月再次遇到它時不會二次采集,一個月後再碰到會進行采集。

在scrapy中,當一個請求被spider發起時,它會先經過去重器校驗,校驗的過程大緻如下:

  • 1.對發起的請求的相關資訊,通過特定的算法,生成一個請求指紋
  • 2.判斷這個指紋是否存在于指紋集合中
  • 3.如果在指紋集合,則表示此請求曾經執行過,舍棄它
  • 4.如果不在,則表示此為第一次執行,将指紋加入到指紋集合中,并将請求加入到請求隊列中,等待排程

我們可以在scrapy中找到對應源碼,我加上了注釋:

def request_seen(self, request):
        # 計算request的指紋
        fp = self.request_fingerprint(request)
        # 判斷指紋是否已經存在
        if fp in self.fingerprints:
            # 已存在
            return True
        # 不存在,加入到指紋集合中
        self.fingerprints.add(fp)
           

但scrapy的去重集合是單機的,是以scrapy_redis為了實作分布式,重寫了去重器,這是其中一部分代碼:

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
           

代碼中的server是各爬蟲都能通路到的redis資料庫連接配接,各個爬蟲中的所有請求指紋都被寫入到同一個redis資料庫的同一個鍵中,進而使各爬蟲共享了去重集合。

這樣,我們隻需要配置爬蟲,讓它不再主動清空去重集合,redis就會一直儲存我們的曆史請求指紋。放到舉例的需求中來看,我們已經實作了"一個博文在一個月内隻采集一次,當月再次遇到它時不會二次采集"的需求,但這個請求會一直儲存,是以無法實作"一個月後再碰到會進行采集"的需求。

也許有朋友會說:“redis不是可以設定過期時間的嘛?直接給去重集合對應的鍵設定過期時間為一個月不就行了嘛?”

這裡值得注意的是:

redis中的過期時間設定,是隻針對redis的鍵的,而不能針對裡面的值。

為鍵設定過期時間,當過期時間到達時,會删除這個鍵和鍵對應的所有資料。

也就是說,我們隻能對去重集合設定過期時間,而不能針對于具體的某一個指紋。如果我們為去重集合設定了過期時間,當時間到達時,整個去重隊列都會被删除,并不能滿足我們的需求。

那麼,怎麼辦?我想了很久,也沒想到太好的解決方案,這裡提供兩種思路,勉強可以解決問題。如果大佬們有更簡單|優雅|好用的方法,請務必留言告知我,萬分感激!

PS:下面的所有讨論都是基于scrapy_redis的分布式爬蟲的,是以所有去重器的設計也都是基于redis考慮和實作的。

方案一: 使用redis的散清單,儲存指紋=>過期時間的映射

redis中存在散清單資料結構,不清楚的同學可以了解為類似于python中的dict的資料結構。它儲存了鍵=>值的映射。

2019/8/23 補充說明:
這個是最開始的考慮,打算用散清單,後來在實作的時候,選用了更合适的zset。下面這部分的讨論都是從散清單的考慮出發的,但基本原理是一緻的。
           

那麼,我們有一個很簡單的方法,大緻描述如下:

  • 1.在redis中,我們将去重集合的資料結構,由集合替換為散清單,儲存指紋=>該指紋過期時間的映射(原來的集合隻儲存指紋)
  • 2.當一個新的請求到來時,我們判斷它是否存在散清單中
  • 3.如果在,則判斷該指紋對應的過期時間,是否小于目前時間。如果小于,則說明指紋的有效期已過,我們可以重新發起請求了,這時,更新指紋的過期時間,并将請求加入隊列;如果不小于,說明這個請求近期内已經處理過,不需要再次采集,直接放棄。
  • 4.如果不在,将 指紋=>過期時間 寫入到去重散清單中,并将請求加入到請求隊列中。

如果上面說的不夠清楚的話,我們來用僞代碼(注意,是僞代碼,不能直接運作的啊)描述一下,關鍵部分是這樣的:

import time
# 過期時間,真正實作中應該寫在settings部分,并通過`from_crawler`方法擷取,這裡為了說明友善,就随意一點了
# 過期時間設定成了30天
expire_time=60*60*24*30

def request_seen(self, request):
        # 計算request的指紋
        fp = self.request_fingerprint(request)
        # 嘗試擷取redis中該指紋的過期時間
        fp_expire_time=self.server.hget(self.key,fp)
        # 如果指紋在散清單中并且已經過期,或者不在散清單中
        if (fp_expire_time is None) or (int(fp_expire_time)<int(time.time())):
            self.server.hset(self.key,fp,int(time.time())+expire_time)
            return False
        else:
            return True
           

有一點需要注意的是,上面的實作是有缺陷的,這個操作并不具備原子性,因為這裡的查詢、判斷、寫入是分别進行的。但在這個使用場景裡沒必要苛求,影響不大,已經可以滿足需求了。

現在,我們來說說這個方法的優缺點。

優點:實作簡單,定制靈活,可以随意指定任意過期時間,并且能夠保證所有相同連結兩次請求的時間間隔都大于等于指定的過期時間。

缺點:使用這種方法,就沒辦法使用bloomfilter(布隆過濾器)壓縮記憶體了,在記憶體占用上比标準的bloomfilter要大不少(假設bloomfilter要求誤判幾率最大為萬分之一,并且,這裡指的是單獨一個bloomfilter,不是下面說的組合使用的情況)。

還有一個問題時,當出現有些url被請求一次後,在未來的很久内都沒有被請求的情況時,這些url的指紋就會一直被緩存在去重隊列中。當這樣的請求很多時,就會逐漸積壓,占用過多的記憶體。是以,還需要編寫額外的腳本定時清理redis中的過期指紋,不能完全依賴于我們在去重器中實作的更新機制。

方案二: 使用按時間排列的多個bloomfilter

bloomfilter,中文名為布隆過濾器,是面對海量資料去重時常用的一種資料結構。它的特點大緻如下:

  • 1.非常節省記憶體,在保證誤判幾率不大于萬分之一的情況下,百萬級資料隻占用幾M記憶體,億級的資料也隻占用百M記憶體左右。
  • 2.效率很高,能很快判斷資料是否在bloomfilter中。
  • 3.機率型資料結構,有一定幾率出現誤判。當一個資料不在bloomfilter中時,bloomfilter可能會誤判它存在;當一個資料在bloomfilter中時,bloomfilter的判斷一定準确。

bloomfilter的細節和更多内容不是本文讨論的重點,且相關内容網絡上非常多,感興趣的同學可以自行搜尋。

我的思路大緻如下:

1.首先,決定時間序列的粒度,根據需求,選擇天或者月,并決定在該粒度下,要使用的bloomfilter的數量。

這樣說可能不太清楚,我舉例解釋一下:

假如說去重的周期是7天,比較合适的選擇是粒度選擇“天”,bloomfilter的數量為7。

去重的周期是半年,也就是6個月,比較适合的選擇是粒度選擇“月”,bloomfilter的數量為6。

去重的周期是一個月時,可以選擇粒度為“天”,bloomfilter的數量為30(按每個月30天算,實際上就是去重周期為30天,粒度選擇“天”的結果);也可以極端一點,選擇粒度為“月”,bloomfilter的數量為1。不同選擇在某些情況下差别挺明顯的,後面在優缺點部分會具體讨論,這裡先不細說。

2.根據粒度和數量,建立bloomfilter的清單。

所謂的建立,實際上是連接配接到redis中對應的鍵上,建立的是python對象,不是redis中的鍵(這是對應于鍵已經存在的情況下,如果不存在還是會建立的),不會清空原有資料,而是在其基礎上繼續操作。

并且,在建立的同時,會為每個鍵聲明過期時間。

比如,當粒度為天,數量為7(也代表了去重周期是七天),今天是2018年12月8日時,過期時間大緻如下:

鍵名 過期時間
rediskey:20181202 1544284800(實際上就是20181202的時間戳1543680000+七天時間86400*7)
rediskey:20181203 1544371200
rediskey:20181208 1544803200

這麼做的目的是,當去重周期過去時,對應bloomfilter的内容會自動清空,釋放記憶體,不需要再手動删除。

3.每當有新請求需要去重時,對清單中的所有bloomfilter都進行檢查,判斷是否存在:

如果有任何一個存在,則說明該請求在去重周期内,已經處理過了,不需要再采集,直接放棄。

如果所有過濾器中都不存在該指紋,則将指紋加入到當天對應的過濾器中,并将請求加入到請求隊列中。

下面,用僞代碼來描述一下(另外,代碼是我直接在部落格上敲得,是以可能會有手誤或不合文法的地方,請見諒,不影響閱讀):

class BloomFilter(object):
    """
    布隆過濾器用戶端,具體的我就不實作了,可以百度。
    網絡上有很多基于redis的實作,稍作修改就可以用于此場景,下面假設幾個接口
    """
    
    def exist(self,fp,server,key):
    """
    fp: 請求指紋
    server: redis用戶端連接配接
    key: bloomfilter對應的redis中的鍵名
    判斷給定的指紋是否在布隆過濾器中。
    有一點需要注意,我們的傳參相較于一般的bloomfilter多了server和key,因為我們使用的server和key是不固定的,經常變化。
    而一般的實作中server和key基本是不變的,直接作為類屬性存儲。
    如果存在,傳回True,否則,傳回False
    """
        pass
    
    def insert(self,fp,server,key):
    """
    參數含義和exist一緻
    将給定請求指紋加入到指定布隆過濾器中
    """
        pass
    

class RFPDupeFilter(BaseDupeFilter):
    """
    大部分跟這個實作不相關的内容我就省略了,
    具體參考scrapy_redis的源碼
    """
    
    """
    假設我們已經利用from_crawler中擷取了相關資料:
    # bloomfilter粒度,'d'表示天,'m'表示月
    self.filter_granularity='d'
    # 去重周期,同時也是bloomfilter的數量
    self.filter_num=7
    
    并且初始化了bloomfliter
    self.bloomfilter=BloomFilter()
    """
    
    def get_bloomfilter_keys(self):
        """
        根據目前時間,産生一個bloomfilter key的清單
        假設目前時間為20181208,filter_granularity='d',filter_num=7,則傳回['xxx:20181202','xxx:20181203',...,'xxx:20181208'],其中,'xxx'代表RFPDupeFilter的key,一般是 項目名:dupefilter 的形式
        假設目前時間為20181208,filter_granularity='m',filter_num=6,則傳回['xxx:201807','xxx:201808',...,'xxx:201812']
        不難了解,就不實作了
        """
        pass

        
    def _exist(self,fp,key):
        """
        判斷給定指紋是否存在于給定key對應的bloomfilter中
        """
        return self.bloomfilter.exsit(fp,self.server,key)
        
    def exist(self,fp):
        """
        判斷給定指紋是否在bloomfilter中
        """
        # 為了應對日期變化,比如從12.08到12.09過度之類的,需要每次都重新計算目前有效的bloomfilter的鍵名清單
        bloomfilter_keys=self.get_bloomfilter_keys()
        for key in bloomfilter:
            # 任何一個存在就說明已經請求過,就可以退出了
            if self._exist(self,fp,key):
                return True
        # 全都不存在,傳回false
        return False
        
    def insert(self,fp):
        """
        将給定指紋寫入到布隆過濾器中
        """
        # 擷取當天時間對應的bloomfilter鍵名
        key=self.get_bloomfilter_keys()[-1]
        # 寫入
        self.bloomfilter.insert(fp,self.server,key)
        
        
    def request_seen(self, request):
        # 計算request的指紋
        fp = self.request_fingerprint(request)
        # 判斷指紋是否已經存在
        if self.exist(fp):
            # 已存在
            return True
        # 不存在,加入到指紋集合中
        self.insert(fp)
        
    
           

PS:在實作上,沒有真的建立bloomfilter清單,而是建立了一個公用bloomfilter對象。每次處理指紋時會計算各個bloomfilter的keys,通過給bloomfilter對象傳遞不同的key來操作不同的bloomfilter。

PS2:沒有實作對bloomfilter設定過期時間的僞代碼,不難,但很繁瑣,就先不寫了。大緻思路是,在類裡儲存上次計算的keys,當兩次keys不一樣就進行一次設定操作,這樣可以盡量減少與redis的網絡互動,并且保證新的bloomfilter一定能被設定過期時間。

來說說這種方法的優缺點。

缺點:

  • 1.當每個bloomfilter上資料分布不均勻時(比如每天采集的資料量差距較大),需要按最大的資料量設計bloomfilter,可能會造成更多的記憶體占用。
  • 2.各bloomfilter之間的誤差是累積計算的,不是單獨計算,是以在使用多bloomfilter組合後,可能需要使各bloomfilter的誤判率設計得比預期的誤判率更低,才能保證組合後不高于預期的誤判率。帶來的直接影響也是更多的記憶體占用。
  • 3.不能保證所有相同請求之間的間隔都至少大于過期時間。比如去重周期一個月,以"天"為粒度,所有相同請求之間能保證至少間隔29天,但以"月"為粒度,就隻能按月份去看了,11月30日采集過的資料,到12月1日再次遇到還是會被采集。

優點:

  • 1.當資料分布均勻時,更加節省記憶體。
  • 2.對比于方案一,不需要額外實作對過期指紋進行處理的單獨腳本。

總結

這篇博文主要讨論思路、方法,沒有給出完整實作代碼。實作起來沒什麼難度,就是有些繁瑣,故沒有直接實作。有了這些思路後,有興趣的同學可以自行實作,應該不存在問題。

這兩種方法我會抽時間去實作,屆時會上傳完整的代碼。

關于兩種方法的比較,我認為 方案一 一般情況下就夠用了,而且使用靈活,實作簡單。雖然從理論上來說, 方案二 更節省記憶體,但從bloomfilter的尺寸、誤判率的妥協上來考慮, 方案二 實際上的記憶體占用不見得很樂觀。

最後,關于這方面的内容,可能有很多方面我考慮的不夠深入、全面,思路、邏輯上或許存在漏洞和bug。如果大佬們發現這方面的問題,請務必告知于我,非常感激!

另外,如果大佬們有更好的思路、方法,也請勞煩告知,拜謝!

繼續閱讀