爬虫工程师的自我修养之系统架构
前言
距离写上一篇爬虫工程师的自我修养之基础模块已经过去大半年了,接着来填坑系统架构。现在大多数爬虫工程师似乎都在往逆向方向发展,传统系统架构相关的文章反而销声匿迹了。这里我抛砖引玉,谈一谈我在爬虫系统架构相关的理解
框架
传统框架
scrapy已有10年的历史,pyspider从2014年发布0.1.0版本到现在竟也有7年了。无论什么爬虫框架,都绕不开调度、下载和解析,总体结构上不会有太大的差异,尤其是下载和解析,框架层面可玩的花样很少
-
异步的好也“不好”
异步好在哪自不用说,所谓的“不好”是说什么呢?2021年了,还是能看到有爬虫工程师往scrapy/pyspider的callback解析部分中加io阻塞型代码,问就是不知道会阻塞,问就是这么写简单。而且在请求构造复杂并且基础组件微服务化的情况下,获取cookie、device_id、sign每个都要过一次网络请求,每个都走异步回调确实体验很差。然而阻塞代码从最初的一两行的时候可能没有太大影响,日子久了之后整个爬虫系统开始变得缓慢。另外tornado还好一些,twisted实在难以称为主流
-
调度缺失
现有爬虫框架的调度部分相对比较薄弱,其余提供的核心能力在于异步下载能力。但大型爬虫系统中,我们更需要的是强大的调度能力,如资源分配,优先级控制
-
分布式与多爬虫管理
首先分布式方面,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为例
- schema层:包含任务的说明;还有入参的定义,是说我需要传入这个task的参数,比如微博详情页的话就是微博ID;输出的定义,是说微博详情页解析出的字段
- 机制层:实现了超时、重试、监控打点、单元测试,有一个很简便的实现方式是继承Celery.Task类,定义timeout,max_retries。并且实现一些钩子函数,如on_success,on_failure等
- 实现层:包含从基础模块中获取资源,构造请求(ip/user-agent/cookies/device_id/sign),最后下载并解析
- 输出层:爬取结果、日志、异常信息
链路是指将细分任务组合起来完成一个完成的爬取需求。例如有个需求是爬取头条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等
再来看第二个效率的问题
之前有看过一篇很有意思的文章,美团智能配送系统的运筹优化实战,他们所遇到的挑战和爬虫的调度系统面对的问题是很相似的
这里抛出几个问题,不展开讲
- 分配的资源与目标站点的更新量是否匹配,资源溢出或紧缺能否自动调配?
- 爬取周期是否和站点的更新高低峰吻合?
存储
去重
爬取记录的存储设计在整个爬虫系统中至关重要。传统的实现方式是布隆过滤器Bloom filter。但布隆过滤器仅仅有判断元素值是否存在,无法告诉我们元素ID是否存在,是何时被加入的(这在需要反复爬取同一页面的情况下非常重要),并且在删除上存在一些问题。
新设计的去重存储方案是基于redis中的hash结构,在key中存储元素ID,value中存储元素值的hash和时间戳。这个方案有几个优化的关键点
- 通过分桶的方式,降低单个hash结构的容量,结合下边的数值压缩,保持hash结构的底层实现为ziplist而非hashlib降低内容占用
- 对hash结构中的key value的占用空间优化,key/value在hash中的内部编码有raw、int和embstr,其中int类型空间占用最小
所以我们可以实现三个hash方法实现上述优化
- find_bucket:对元素ID进行crc32计算,然后对桶数取余,找到元素ID所在的桶
- compress_key:对元素ID使用RKDR hash算法将key转为int
- compress_value:对元素值使用RKDR hash算法将key转为int,再拼接上是时间戳
经测试,存储10亿键值对消耗redis内存8GB
热存储
大型爬虫系统日新增和更新的量是非常庞大的,千万级每日的新增量对于mysql来讲是难以完成的任务。Mongodb集群的自动分片特性能够带来更高的并发与存储拓展能力
但一股脑全存进Mongodb集群也不可取,稍微大范围的查询或者建索引对内存和CPU的消耗是灾难性的。其实Mongodb集群很适合存储部分有轻度查询和使用的爬取结果,全量的存储可以放在HBase中
全量存储&检索
全量存储和检索反而是有很通用的解决方案,HBase + Solr/ES。HBase存储全量字段,对于某些我们需要查看其历史值的字段,可以使用拉链的方式存储多版本的值,比如微信公众号文章的点赞量、评论数。检索没什么好选的,ES或者Solr都行,如果有大规模提数或者连表分析的场景也可以考虑加入hive
对外能力
- 离线任务
- CMS
- 报表
架构图
最后附上最终的架构图