爬虫工程师的自我修养之系统架构

6 minute read

前言

距离写上一篇爬虫工程师的自我修养之基础模块已经过去大半年了,接着来填坑系统架构。现在大多数爬虫工程师似乎都在往逆向方向发展,传统系统架构相关的文章反而销声匿迹了。这里我抛砖引玉,谈一谈我在爬虫系统架构相关的理解

框架

传统框架

scrapy已有10年的历史,pyspider从2014年发布0.1.0版本到现在竟也有7年了。无论什么爬虫框架,都绕不开调度、下载和解析,总体结构上不会有太大的差异,尤其是下载和解析,框架层面可玩的花样很少

  1. 异步的好也“不好”

    异步好在哪自不用说,所谓的“不好”是说什么呢?2021年了,还是能看到有爬虫工程师往scrapy/pyspider的callback解析部分中加io阻塞型代码,问就是不知道会阻塞,问就是这么写简单。而且在请求构造复杂并且基础组件微服务化的情况下,获取cookie、device_id、sign每个都要过一次网络请求,每个都走异步回调确实体验很差。然而阻塞代码从最初的一两行的时候可能没有太大影响,日子久了之后整个爬虫系统开始变得缓慢。另外tornado还好一些,twisted实在难以称为主流

  2. 调度缺失

    现有爬虫框架的调度部分相对比较薄弱,其余提供的核心能力在于异步下载能力。但大型爬虫系统中,我们更需要的是强大的调度能力,如资源分配,优先级控制

  3. 分布式与多爬虫管理

    首先分布式方面,scrapy框架本身是偏向于单机的,虽然有scrapy-redis的加持,但分布式的调度部分功能十分匮乏。再来看多爬虫的管理部分,scrapyd就更是惨不忍睹了,在进程的维度对scrapy进行启停,并且近两年没有大的更新

Celery脚手架

这里我提出一种基于celery的爬虫脚手架。使用celery,就不用每次网络IO都用callback处理,celery在整体上对爬取任务进行加速。另外celery本身就具有分布式的特征,作为专业的分布式任务调度和处理框架,无论是task还是worker层面提供的控制力是足够的

来看一下目录结构

├── base_task.py  # celery.Task派生类,定义超时、重试,实现打点
├── chains  # 链路
├── config.py
├── control.sh
├── sites  # 细分任务
│   ├── toutiao  #  按站点分类,每个站点包含h5端、pc端和app端,common中可以放三端都通用的任务
│   │   ├── common
│   │   │   ├── comment.py
│   │   │   ├── __init__.py
│   │   ├── h5
│   │   │   ├── detail.py
│   │   │   ├── feed.py
│   │   │   ├── __init__.py
│   │   │   ├── user_info.py
│   │   │   └── user_statuses.py
│   │   ├── __init__.py
│   │   ├── pc
│   │   │   ├── feed.py
│   │   │   ├── captcha.py
│   │   │   ├── __init__.py
│   │   │   ├── search_user.py
│   │   │   └── user_statuses.py
│   │   ├── phone_app
│   │   │   ├── detail.py
│   │   │   ├── __init__.py
│   │   │   ├── user_info.py
│   │   │   └── user_statuses.py
│   ├── weibo
│   │   ├── h5
│   │   │   ├── detail.py
│   │   │   ├── __init__.py
│   │   │   ├── user_info.py
│   │   │   └── user_statuses.py
│   │   ├── __init__.py
│   │   └── pc
│   │       ├── comment.py
│   │       └── __init__.py
├── exceptions.py  # 自定义的异常类
├── external_interface  # 与基础模块的交互
│   ├── cookie.py
│   ├── dedup.py
│   ├── device_id.py
│   ├── __init__.py
│   ├── parse.py
│   ├── proxy.py
│   ├── sign.py
│   ├── storage.py
│   └── user_agent.py
│   └── captcha.py
├── monitor  # 监控
│   ├── __init__.py
├── tests  # pytest单元测试
│   ├── __init__.py
│   ├── test_weibo_h5.py
│   └── test_weibo_pc.py
├── utils  # 通用工具函数
│   ├── __init__.py
│   ├── format_date.py
└── worker.py

主要有两个需要关注的点,即链路(chains)和细分任务(sites),这里详细解释一下

业务角度来看,细分任务是指能够独立完成爬取任务的最小单元,例如抓取微博的详情页,抓取头条某篇文章的评论

技术实现而言,细分任务是celery能调度的最小粒度task,有这几个层次,这里以微博详情页task为例

  1. schema层:包含任务的说明;还有入参的定义,是说我需要传入这个task的参数,比如微博详情页的话就是微博ID;输出的定义,是说微博详情页解析出的字段
  2. 机制层:实现了超时、重试、监控打点、单元测试,有一个很简便的实现方式是继承Celery.Task类,定义timeout,max_retries。并且实现一些钩子函数,如on_success,on_failure等
  3. 实现层:包含从基础模块中获取资源,构造请求(ip/user-agent/cookies/device_id/sign),最后下载并解析
  4. 输出层:爬取结果、日志、异常信息

链路是指将细分任务组合起来完成一个完成的爬取需求。例如有个需求是爬取头条h5端feed流中出现的所有作者的文章/视频及评论,链路就可以定义为

toutiao.h5.feed->toutiao.h5.user_info
               ->toutiao.h5.user_statuses->toutiao.phone_app.detail->toutiao.common.comment

另外链路也可以与基础模块交互,实现一些日常任务,比如养号、cookies生产、device_id生产,这里用很常见的处理验证码生产cookie为例

toutiao.pc.captcha.download->external_interface.captcha.recogize->toutiao.pc.captcha.get_cookie->external_interface.cookie.add

调度

我认为好的调度需要为爬虫系统解决以下两个问题

  • 维护者能够快速明了地拆解各种各样的业务需求,翻译成调度的任务参数去执行,而不需要在整个爬虫系统中参杂业务逻辑
  • 结合每个爬虫任务的优先级和资源情况,整体达到最高效的状态

首先来说说第一个问题,业务需求与爬虫系统的解耦

在爬虫脚手架的设计环节,也需要为调度预留出发挥的空间,其中一点就是爬取路径组合的多样性。这一块可以通过链路的方式去实现,只需将需求背后的爬取路径翻译成链路,存储在任务信息中。每个细分任务执行完时判断整个链路是否还有衔接任务,不断传递下去执行直到完成整条爬取链路。

另外必须要做到输出结果字段的统一和全面,也就是说每个类型的爬取结果都能用orm来抽象,有其固定字段列表。同时支持多种输出爬取结果的方式如Kafka、hdfs、API等

再来看第二个效率的问题

之前有看过一篇很有意思的文章,美团智能配送系统的运筹优化实战,他们所遇到的挑战和爬虫的调度系统面对的问题是很相似的

这里抛出几个问题,不展开讲

  1. 分配的资源与目标站点的更新量是否匹配,资源溢出或紧缺能否自动调配?
  2. 爬取周期是否和站点的更新高低峰吻合?

存储

去重

爬取记录的存储设计在整个爬虫系统中至关重要。传统的实现方式是布隆过滤器Bloom filter。但布隆过滤器仅仅有判断元素值是否存在,无法告诉我们元素ID是否存在,是何时被加入的(这在需要反复爬取同一页面的情况下非常重要),并且在删除上存在一些问题。

新设计的去重存储方案是基于redis中的hash结构,在key中存储元素ID,value中存储元素值的hash和时间戳。这个方案有几个优化的关键点

  1. 通过分桶的方式,降低单个hash结构的容量,结合下边的数值压缩,保持hash结构的底层实现为ziplist而非hashlib降低内容占用
  2. 对hash结构中的key value的占用空间优化,key/value在hash中的内部编码有raw、int和embstr,其中int类型空间占用最小

所以我们可以实现三个hash方法实现上述优化

  1. find_bucket:对元素ID进行crc32计算,然后对桶数取余,找到元素ID所在的桶
  2. compress_key:对元素ID使用RKDR hash算法将key转为int
  3. compress_value:对元素值使用RKDR hash算法将key转为int,再拼接上是时间戳

经测试,存储10亿键值对消耗redis内存8GB

热存储

大型爬虫系统日新增和更新的量是非常庞大的,千万级每日的新增量对于mysql来讲是难以完成的任务。Mongodb集群的自动分片特性能够带来更高的并发与存储拓展能力

但一股脑全存进Mongodb集群也不可取,稍微大范围的查询或者建索引对内存和CPU的消耗是灾难性的。其实Mongodb集群很适合存储部分有轻度查询和使用的爬取结果,全量的存储可以放在HBase中

全量存储&检索

全量存储和检索反而是有很通用的解决方案,HBase + Solr/ES。HBase存储全量字段,对于某些我们需要查看其历史值的字段,可以使用拉链的方式存储多版本的值,比如微信公众号文章的点赞量、评论数。检索没什么好选的,ES或者Solr都行,如果有大规模提数或者连表分析的场景也可以考虑加入hive

对外能力

  • 离线任务
  • CMS
  • 报表

架构图

最后附上最终的架构图

Updated: