目录

AaronJny

诗酒繁华,书剑天涯。

X

关于scrapy分布式爬虫请求去重和指纹过期的两种方法——思路

撰写于 2018 年 12 月 8 日,由我的 csdn blog 个上迁移而来。


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。如果大佬们发现这方面的问题,请务必告知于我,非常感激!

另外,如果大佬们有更好的思路、方法,也请劳烦告知,拜谢!


标题:关于scrapy分布式爬虫请求去重和指纹过期的两种方法——思路
作者:AaronJny
地址:https://aaronjny.com/articles/2019/09/06/1567750114093.html