鱼喃

听!布鲁布鲁,大鱼又在那叨叨了

StreamSpider爬虫之系统设计

系统设计

系统需求

想要写一个分布式爬虫的想法最初来源于百度第三代 Spider 背后的万亿量级实时数据处理系统这篇文章的介绍,里面介绍了百度采用了流式计算的方式来处理大量的网页。目前互联网上的新增网页速度越来越快,其中的一些如热点新闻等时效性要求比较高,但是基于批处理的搜索系统从抓取到展现不可避免的存在延迟,采用流式计算的方式来处理能够更快的展现数据。

之前也写过一些爬虫脚本,但是都是针对特定网站的、单机的、无限制爬取的。这次想要设计一个更高级别的,一个以搜索引擎爬虫子模块为目标的系统。
需要满足以下特性:

  • 分布式
  • 通用爬虫
  • 爬取频率限制,服务器友好
  • 一定的可定制性
  • 长时间稳定运行
  • 处理Spider Trap
  • 快速
  • 网页过期
  • 支持代理
  • 具有优先级

技术选型

主体框架

当时正在学习Apache Storm,发现Storm非常适合这个系统。首先,Storm是分布式的,最新版本已经支持HA,据了解阿里双十一用Storm做过实时销量统计,稳定性上有保证;而且,Storm是一套流式计算框架,延迟低,属于实时系统;再有,Storm有ack机制,可以保证每个url至少被处理一次,并支持限流;最后,Storm的组件比较简单,只有Spout和bolt,一般来说,简单的设计更加稳定、高效。
所以系统基于Apache Storm来设计,这样我就不需要再自己考虑分布式、稳定性、失败重试了。主要的工作重心就集中到设计一个可用的Storm拓扑了。Storm拓扑是一个有向图,数据被分成一个一个的元组从一个节点发送到另一个节点进行处理。其中根节点(即Spout节点)是负责数据流入系统,子节点(即bolt节点)负责元组的处理,每一个节点处理完之后把处理后的元组发送给下游节点。

其他依赖技术

除了Storm流式计算平台之外,还需要有一个与外部系统交互(发送网页数据)的中间件和一个用于系统内部存储待抓取队列的组件。
考虑采用消息队列RabbitMQ来作为发送网页数据的中间件,其高QPS、可靠性等特性可以保证在保证数据不丢失的情况下高效的处理消息,并且,消息队列的引入可以使得该系统和外部系统存在一定的性能差异。
至于待抓取队列的选择,由于是分布式的系统,将数据存在进程内显然不可行,另外考虑到需要支持优先级和速度要求,消息队列基本可以排除在外,考虑使用内存数据库Redis中的sorted set数据结构来实现待抓取队列。Redis属于内存数据库,性能出众,选用其sorted set既可以实现优先队列、又可以用于待抓取队列的去重,复杂度是O(N),处于可以接受的水平。
另外,Redis的其他数据结构也可以用于系统其他用途,例如用hash结构来存储自定义模式设置、用string来做去重且超时机制可以支持网页过期。

模型设计

系统存在与外部系统交互和数据存储等需求,所以需要对一些数据进行模型设计。

UrlPatternSetting

由于整个互联网太过庞杂,且需要全网抓取的场景较少,将爬取限制在一定范围内更加适合实际应用场景,如抓取电商网站的评论数据等。系统采用白名单的方式来限制抓取范围,具体的方案就是维护一个模式队列,模式用正则表达式书写,每次把新的网页加入到待抓取队列之前会基于正则匹配来过滤网址。借助正则强大的语法,我们可以很容易的写出精细的规则。另外,每一个模式都可以自定义规则,用于限流、控制并发度等。

1
2
3
4
5
6
public class UrlPatternSetting {
private int expire;
private int limitation;
private int interval;
private int parallelism;
}

这是模式的自定义设置,包括四个参数:

  • expire: 网页过期时间,指网页在该段时间内不会被多次抓取,默认是7d。
  • limitation: 周期内最大可抓取次数,为了避免系统对同一服务器进行高并发的抓取,需要考虑对抓取频率进行限制,默认是600,-1表示不进行限制。
  • interval: 重置计数器的时间间隔,默认是300s。
  • parallelism: 并发度,即同一时间内最多有多少个针对该服务器的下载任务,控制采集速度。

全部的模式设置存储在 Map<String, UrlPatternSetting> settings 中,考虑到模式基本固定、尽量减低外部系统请求,引入缓存机制。每次读入一个模式设置后,缓存一定时间(5 min),该时间内直接从内存中读取。

具体的频率限制是通过Redis string并设置超时时间来实现的。考虑到一个模式可能对应多个域名(服务器),把多个服务器放在一起做频率限制没有意义,所以采用的是根据URL提取域名,分域名进行频率限制的方式。为了降低Redis负载,频率限制是一定周期内的次数限制,即平均速度。如果想要做到严格的频率控制,可以将limitation设置为1,interval改成相应的间隔即可。

MQMessage

1
2
3
4
5
6
public class MQMessage {
private String url;
private String html;
private String charset;
private long time;
}

定义发送给消息队列的对象,包括网址、网页文本、网页编码、下载时间。由于消息队列不接受对象,需要把MQMessage对象序列化之后添加到消息队列中,序列化方式采用Gson。

1
String message = new Gson().toJson(msg);

拓扑设计

df7caeaa5ccd9a76826bbc1ca0d59c1e.png

各组件介绍

URLReader:从待抓取队列(优先队列) urls_to_download 取出一个URL,检查最早可抓取时间是否晚于当前时间,符合则将URL发送到到下游BOLT,否则放回队列。如果待抓取队列为空,则等待100ms。tuple形式为 {url}

URLFilter:在系统中相当于调度的角色。从URLReader接收URL并依次进行过滤,包括URL白名单检查、去重、过滤无效的URL(主要是二进制文件)、抓取任务分发、频率控制,在抓取任务分发上,使用Storm topology的fieldsGrouping,field由域名加随机数组成,随机数的范围即模式并行度。tuple形式为{pattern, url}

Downloader:接收URLFilter的URL并执行网页抓取,如果设置了代理,则使用代理下载网页,并将下载完的网页文本同时发送到下游节点URLParser和HTMLSaver。考虑到中文网站存在一些编码的差异,在抓取过程中自动根据标准对网页进行编码自动识别和转码工作,一些网址中含有中文。另外一些网站会做一些反爬虫工作,通过修改User-Agent等操作来绕过。tuple形式为{url, charset, html}

URLParser:负责从网页文本中提取有效的URL(剔除非HTTP协议的网址、补全相对地址),并将新的网址发送到下游节点URLSaver。由于部分网址中含有中文,并且不同的网页编码下中文的16进制编码不一样,需要根据原网页编码来对网址进行编码工作。tuple形式为{url}

HTMLSaver:将接收到的网页文本数据添加到消息队列RabbitMQ中。消息队列不支持直接存储对象,且考虑到不同系统的对象内部形式差异,需要用一种跨语言的序列化方式来将对象序列化成字符串,这里使用Gson。

URLSaver:将接收到的URL加到待抓取队列中。