目录

从爬虫策略谈到十二因素应用

这篇文章主要根据我对一个千万数据量爬虫的不断的重构的过程,谈谈理论与工程(稳定可靠)的结合,以及在重构过程中折射的十二因素应用(SaaS应用的方法论)思想。

爬虫背景简述

假设我们需要爬的网页是这样的:

  1. 拥有高级搜索功能,可以根据关键词、时间筛选
  2. 搜索结果每页显示20条数据,可以翻页
  3. 搜索结果页面中,每条数据都有一个链接,点击可以进入详情页。详情页的内容是固定的

基本的爬取策略也很简单,先用高级搜索结合翻页得到详情页链接,再获取详情页内容,最后入库。 (有一点需要注意,如果想爬取全量的数据,肯定不能通过关键词搜索,因为这样无法遍历出所有的关键词,要通过日期或者我们能够获取全集筛选条件的方式来遍历。)

本文主要讨论的是大规模数据量下的工程细节。

爬虫 1.0

最初我是采用的 Scrapy 框架,它是一个比较重但功能很全的天然支持异步的爬虫框架。

在这个版本中,我的策略是这样的:

  1. 使用高级搜索每次都传入一个日期,得到当天的搜索结果的第一页
  2. 根据第一页获取总条数/页数,并进行翻页,得到当天的所有搜索结果(即所有的详情页链接)
  3. 根据详情页链接获取详情页内容,入库

同时利用了 Scrapy 的异步特性,这上面的每一步都是一个异步的函数,一个函数执行完毕后会立即把结果传给下一个函数,并以多线程的方式执行。 比如,第1个在获取到1月1日的第一页链接的时候,第2个函数立即翻页,同时第1个函数继续获取第2页的第一页链接;第3个函数的详情页内容获取也是如此。

看起来似乎很美好,似乎只要 run 一次,就能把整个网站所有内容爬下来,全部都是自动化的。但这也是本篇文章要重点论述的,即完美的理论和实际工程的取舍、结合。

但这中间需要解决很多问题,主要就是错误处理、断点续爬、稳定性等。

光是可能失败的断点,就有以下几种类型(本文举的例子还是简化过的,实际上更多):

  • 在获取第一页链接的时候就失败了
  • 在翻页的时候失败
  • 在获取详情页链接的时候失败
  • 在数据解析的时候失败
  • 在数据入库的时候失败
  • 程序异常中止等等

而且 Scrapy 还是全流程异步的,也就是说,可能一个函数是在获取2月3日的第2页链接的时候失败了;但同时1月26号的所有链接的详情页还没爬完;以及1月24号的内容还没入库。

真的发生错误的话,该如何处理断点呢?

这要分种情况:

  1. 一种是非程序异常中止引发的:只要设计不同的错误类型,好好记录就行。比如一个表记录失败的日期(该天所有的链接都失败了),一个表记录失败的链接(该链接的详情页失败了)等等。
  2. 一种是程序异常中止引发的:这种时候比较棘手,由于异步的原因,我们很难知道到底哪个日期的所有链接确保获取完了。比如在获取到2月6号的第2页链接的时候,程序异常中止了,但是一共有10个线程,能保证2月5号的数据已经全部拿到了吗?

我相信你已经看麻了,我也不想再讲下去了。这实际上是我的本科毕业论文的一部分,实际的爬虫结构更复杂,我还设计了流程控制、链接获取、页面解析、错误重爬等等模块。

1.0 版本的爬虫,我写过一篇很详细的博客来介绍,有兴趣可以看看:《Scrapy知网爬虫(一)整体理论篇》。流程非常复杂,也导致了当时的爬虫我大部分的精力都用在了设计断点续爬的策略上。

所以理想很丰满,工程很骨感,我们尝试分析一下出现灾难的原因:

  1. 为什么断点续爬这么复杂?是因为断点非常多。而断点多就是由于爬虫所有的步骤,都写在同一个项目里,环环相套,一旦中间一环出问题,就需要往上 fallback。
  2. 还有一个原因是,Scrapy 的级联函数调用,使我自然而然地把状态通过函数的参数来传递,也就是状态都在内存里。

爬虫 2.0

一年多后,发现数据量有缺失,于是不得不重构爬虫。

最糟糕的是,并不知道缺失的数据是哪一天的,以及缺了哪些数据。究其原因,都是因为把爬虫的状态都放在内存里,没有做持久化。

欲速则不达,我们先用最笨的办法来重构爬虫策略。

  1. 遍历日期,搜索,翻页,获取所有的详情链接
  2. 第1步完成后,再启动第2步,遍历所有的详情链接,获取详情页内容,入库

看起来技术比1.0还low了很多,但这样带来了很多好处:

  1. 把相乘的出错率变成了相加的。(如果原来的爬虫,每步的稳定性是90%,那么叠加三次就是0.9^3,大概是0.7;而一步步来,稳定性是0.8)。
  2. 类比于微服务,这两步可以由不同的人、不同的语言开发。
  3. 中间状态持久化,比如所有的详情页链接/网页内容都被记录下来了。以后不需要从0开始爬,可以从中间步骤开始。(比如页面解析的时候有个对网页结构的分析有误,那么只需要重做第二步)

我们还可以对其优化:

  1. 第一步爬到的详情页链接,实时存入数据库,同时使用Redis去重。
  2. 第二步与第一步同时开始,不断地从数据库中取出详情页链接,爬取详情页内容,入库。并标记该链接已爬取。

采用了分步(分治)、中间件解耦,带来的好处是不可估量的:

  1. 第二步的程序可以打包成镜像,部署任意数量、任意平台,并挂上代理。
  2. 第一步和第二步的程序数量可以不同,第一步请求一次翻一页可以得到20个链接,而第二步请求一次只能处理一个详情页,所以可以按1:20的比例部署。
  3. 第二步变成了无状态的应用,可以随时启动、随时停止,直接就不需要考虑任何的错误处理。因为每一个操作都是原子性的:从数据库拿出一个链接,爬取详情页,入库,标记该链接已爬取。如果出错,那么这个链接会被另一个worker再次取出来爬取。)

采取这种方式,十二因素应用的理念就基本都涉及了:

  • 基准代码(一份基准代码,多份部署): 第2步的代码是一致的,部署多次,多个worker之间不需要手动分隔任务集,直接从数据库中取出未爬取的链接即可。
  • 依赖(显式声明依赖关系):Docker打包时已经把依赖打包进去了。
  • 配置(在环境中存储配置):Docker镜像中已经包含了数据库的配置,用于获取链接、内容入库。
  • 后端服务(把后端服务当作附加资源):数据库就是后端服务,第一步和第二步都需要用到。同时我还想谈的是,把应用云原生化,其实很关键的一点就是,把有状态的部分(比如数据)独立出来。
  • 构建,发布,运行(严格的分离构建和运行):基于Docker构建,同时把数据库等信息独立到配置文件中,以及每次 Build 镜像的时候打 tag。
  • 进程(以无状态进程运行):第二步的程序是无状态的。
  • 端口绑定(通过端口暴露服务):(这个其实没体现,不过可以加一个状态监控的接口)
  • 并发(通过进程模型实现弹性伸缩):第二步的程序可以并发。
  • 易处理(快速启动,优雅终止):第二步的程序可以瞬间开启或停止、在面对突然死亡时也不怕。
  • 开发环境与线上环境等价(尽可能的保持开发,预发布,线上环境相同): 环境基本是一致的,测试的时候使用不同的数据库中间件、代理设置即可。(配置放到配置文件中,与代码独立)
  • 日志(把日志当作事件流): 上文没提到,不过可以这么做:采用结构化(如json)的形式输出日志,而且直接输出到标准输出,由Fluentd等工具统一收集,最后接入日志分析系统。
  • 管理进程(后台管理任务当作一次性进程运行): 未涉及

爬虫 3.0

爬虫2.0其实还是不完美的,主要有以下几点:

  1. 第一步想要部署多个实例,需要拆分任务。比如1号实例在配置文件中设置只爬取2021年的所有链接,2号爬2022年的。。。
  2. 第一步和第二步的代码不同,能不能也做成一样的

换句话说,我们能不能做一个通用的爬虫,适应各种网页结构,就比如搜索引擎爬虫?(我没看到搜索引擎爬虫的基本策略,纯猜测,实际搜索引擎爬虫肯定比这复杂多多了)

以下是我的策略:

爬虫3.0 - 爬虫代码

  1. 整个爬虫系统只有一份代码,或者说每个爬虫实例的行为都一致
  2. 每个爬虫实例都能解析任何网页结构的网页数据(也不是说一定要解析得非常好,比如只解析网站的标题,文本内容,图片,应该挺简单的。参考各种稍后读软件对原网页的解析)

爬虫3.0 - 中间件

设立「爬虫子任务」的中间件(可以是数据库、消息队列等),每条记录有这样几个字段:

  • 网页链接
  • 是否已经爬取成功
  • 失败次数
  • 任务深度
  • (可能还有时间,用于检测网站更新啊,保存快照等等,本文先不考虑)

同时使用 Redis 记录已经成功解析的网页链接,用于去重(也可以直接在上面的子任务库里设置个 unique index)

爬虫3.0 - 整体流程

  1. 在「爬虫子任务」中插入一条任务,即要爬取的网站首页链接
  2. 爬虫程序启动,取出一条子任务,提取网页内容,入库,标记该任务为已完成。同时把该页面下的所有链接加入「爬虫子任务」库中,将该链接的任务深度设为母链接+1(注意去重)
  3. 所有爬虫程序重复以上操作

值得注意的是,还可以设置各种爬虫策略:比如在取出爬虫子任务的时候,肯定要设数据库排序,如果优先取出「任务深度」小的子任务,就是广度优先;反之是深度优先。