爬虫看起来很简单:下载一个页面,提取它的链接,再下载这些链接,如此循环。写成一个循环,对一千个页面是可行的。到了十亿个页面,它就会以颇具启发性的方式崩溃:用数百个并行请求猛轰小网站,用五十个不同的 URL 下载同一个页面,迷失在无穷无尽的日历里,并把大部分时间花在等待 DNS 上。
本文设计一个既能扩展又守礼貌的爬虫:容量估算、带优先级队列和按主机队列的 URL Frontier、由 RFC 9309 标准化的 robots.txt、URL 与内容去重、爬虫陷阱以及它们周围的组件,并配有一张展示整个循环的图。
需求与估算
目标:每月为搜索或分析索引抓取约十亿个页面,更频繁地刷新重要页面,并且绝不让任何网站过载。
| 指标 | 假设或计算 | 结果 |
|---|---|---|
| 每秒页面数 | 1,000,000,000 / (30 × 86,400 s) | 每秒约 386 个 |
| 带宽 | 386 × 假设每页 100 KB | 约 39 MB/s,约 310 Mbit/s |
| 原始存储 | 10 亿 × 100 KB | 压缩前每月约 100 TB |
| 发现的 URL | 每页几十个链接 | 远多于你能抓取的 URL |
最重要的是最后一行。发现的速度远远超过抓取的速度,所以爬虫始终在选择不抓取什么。优先级排序是核心功能,而不是优化。
抓取循环
- 从 Frontier 中取出下一个 URL。
- 从缓存中检查其主机的 robots.txt。
- 通过本地 DNS 缓存解析主机名。
- 在遵守该主机礼貌延迟的前提下抓取页面。
- 存储原始响应。
- 解析页面,提取链接并对其进行规范化。
- 丢弃已经见过的链接;把其余链接连同优先级加入 Frontier。
- 为内容计算指纹,并在后续处理中跳过近似重复的页面。
第一步之后的每一步,都是朴素的爬虫浪费精力或造成伤害的地方。
URL Frontier
Frontier 是爬虫的心脏:一个必须同时回答两个不同问题的队列。哪个 URL 最重要?现在可以连接哪个主机?
经典答案是 Mercator 的设计,见于《Introduction to Information Retrieval》的 URL Frontier 一章:
- 前端队列负责优先级。 优先级分配器根据页面变化频率、链接热度或所在网站的重要性等信号,把每个新 URL 分配到 F 个队列之一。
- 后端队列负责礼貌。 每个后端队列只保存一个主机的 URL。一个堆为每个主机记录下一次可以连接它的最早时间。
- 抓取线程取出堆顶的主机,从该主机的后端队列中抓取一个 URL,再以新的最早时间把主机放回堆中。后端队列清空时,会从前端队列中补充,并偏向更高的优先级。
Mercator 的设计者建议后端队列的数量约为抓取线程的三倍,这样线程就很少需要等待需要礼让的主机。
在大规模下,按主机对 Frontier 分区:对主机名做哈希来选择 Frontier 分片,这样同一主机的所有 URL 都在同一个地方,礼貌策略可以在本地执行,无需协调。
礼貌策略与 robots.txt
有礼貌的爬虫对每个主机一次只发一个请求,并在请求之间等待。同一章中有一条常用的经验法则:等待时间约为上一次从该主机抓取所用时间的十倍,这样慢的服务器会自动获得更多喘息空间。遇到 429 和 503 响应时进一步退避,并遵守任何 Retry-After 头。
robots.txt 在 2022 年随 RFC 9309 成为标准。爬虫必须正确执行的规则如下:
| 情况 | 爬虫必须怎么做 |
|---|---|
| 成功获取 robots.txt | 遵守针对你的 user agent 的规则,没有时退回 * 组 |
| 状态码 400–499 | 视为没有 robots.txt:允许抓取 |
| 状态码 500–599 或网络错误 | 假定完全禁止 |
| 长时间无法访问,例如 30 天 | 可以视为不可用,或继续使用缓存的副本 |
| 重定向 | 至少跟随五次连续重定向 |
| 缓存 | 除非文件无法访问,否则缓存的副本使用不得超过 24 小时 |
| 文件大小 | 至少解析前 500 KiB |
4xx 与 5xx 之间的不对称常常让人意外:文件不存在意味着“请便”,而服务器错误意味着“别进来”,因为爬虫无法知道规则本来会怎么说。Crawl-delay 不属于 RFC 9309,各个爬虫是否遵守它也不尽相同。请用一个带有联系方式 URL 的描述性 user agent 来标识你的爬虫。
URL 去重
同一个页面藏在许多 URL 背后:HTTP://Example.com:80/a/./b?utm_source=x#top 和 http://example.com/a/b 是同一个页面。在检查一个 URL 是否见过之前,先进行规范化:
- 按照 RFC 3986 第 6 节,把协议和主机转为小写,去掉默认端口,解析
.和..路径段,并规范化百分号编码。 - 去掉片段,它根本不会发送到服务器。
- 谨慎地移除
utm_*这类已知的跟踪参数;其他参数可能会改变内容。 - 抓取后遵守
rel="canonical"提示。
然后检查已见 URL 集合。面对数十亿个 URL,布隆过滤器是常见选择。按十亿个 URL、1% 误判率来设计,大约需要每个 URL 9.6 位,总计约 1.2 GB,使用七个哈希函数。误判意味着跳过一个其实没见过的 URL,对大多数抓取来说,1% 是可以接受的代价;对于站点地图条目这类绝不能漏掉的 URL,要另外维护一个精确的存储。
内容去重
镜像站、打印友好版本和会话参数,会在不同 URL 下产生完全相同或几乎相同的页面。完全重复很好处理:对规范化后的内容做哈希并比较即可。近似重复则需要一种指纹:页面变化很小时,指纹的变化也很小。simhash 是标准技术:一种 64 位指纹,相似文档之间只有少数几位不同。Google 的研究人员在 2007 年报告说,对于一个包含八十亿页面的库,使用以三个不同位为阈值的 64 位 simhash 指纹,是检测近似重复页面的实用设置。
爬虫陷阱
有些网站会生成无限的 URL 空间,而且往往是无意的:永远有“下个月”链接的日历、每个 URL 里都带着的会话 ID、包含所有筛选组合的分面搜索,以及不断加长路径的相对链接。防御措施:
- 限制 URL 长度和路径深度。
- 按主机的重要性,限制每个抓取周期内每个主机的页面数。
- 检测
/a/b/a/b/a/b这样重复出现的路径段。 - 对内容指纹总是与已有页面匹配的 URL 降低优先级。
站点地图则从相反的方向提供帮助:它们列出网站希望被抓取的 URL,每个站点地图文件最多 50,000 个 URL 或 50 MB(未压缩),更多时使用站点地图索引文件。
循环之外:DNS、存储与重新抓取
- DNS 是一个隐藏的瓶颈。每秒数百次抓取时,在抓取器附近部署一个共享的缓存解析器,可以避免把每次查询都发往上游。
- 存储。 把原始响应以大型批量文件的形式写入对象存储,并通过数据流把解析后的文本和链接送往索引。
- 重新抓取。 页面的变化速度各不相同。记录每个页面在以往访问中变化的频率,并据此安排下一次抓取;使用
If-Modified-Since或ETag的条件请求,在内容没有变化时能节省带宽。 - 大量依赖 JavaScript 的页面需要无头浏览器,每页的成本要高得多。只对确实需要的主机进行渲染。
网络爬虫图
图表内容
- URL frontier
- Priority queues (F)
- Per-host queues (B)
- Fetchers
- Caching DNS resolver
- robots.txt cache, 24 h max
- Websites
- Raw pages
- Parser + link extractor
- Seen URLs (Bloom filter)
- Content fingerprints (simhash)
- pages for indexing
- refill by priority
- next host that is due
- resolve
- allowed?
- GET, polite
- raw response
- parse
- new links
- unseen URLs
- simhash
- text + links
这张图的读法如下:
- 框架就是 URL Frontier:优先级队列为按主机划分的队列供给 URL,后者负责执行礼貌策略。
- 抓取器每次对每个主机只取一个 URL,查询 robots.txt 缓存和 DNS 缓存,然后从互联网下载。
- 原始页面进入对象存储库。解析器提取链接和文本。
- 新链接通过已见 URL 过滤器后回到 Frontier,从而闭合循环。内容指纹会标记出近似重复的页面。
- 解析后的页面经由右侧的数据流送往索引。
- Markdown 块列出了容量估算和礼貌规则。
绘制带循环的流水线
- 让循环清晰可见。 爬虫是一个环;把它画成一条直线式的流水线,会掩盖导致陷阱和重复问题的反馈。
- 标明礼貌策略在哪里执行。 把按主机划分的队列画进图里,而不是放在脚注中。
- 把缓存标注为缓存。 robots.txt 缓存和 DNS 缓存会改变施加在外部世界上的负载;标注它们的过期时间。
- 在连线上写上数字:流入的每秒页面数,流出的每页链接数。
通用方法请参阅系统设计图示例。对每个主机的速率控制与限流器系统设计用的是同样的思路,抓取之后的处理则与数据管道架构图类似。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 添加一个名为 URL frontier 的基础节点包中的 框架,并在其中放两个数据节点包中的 队列 节点:优先级队列和按主机划分的队列。
- 为抓取器添加一个云节点包中的 计算实例 节点,添加网络节点包中的一个 DNS 节点和一个 互联网 节点,再为 robots.txt 缓存添加一个数据节点包中的 Redis 节点。
- 为原始页面添加一个数据节点包中的 对象存储库,并为解析器再添加一个 计算实例 节点。
- 为已见 URL 过滤器添加第二个 Redis 节点,为内容指纹添加一个云节点包中的 托管数据库,并为送往索引的页面添加一个数据节点包中的 Kafka 节点。
- 用带标签的连线画出循环,并添加一个基础节点包中的 Markdown 块,写上这些数字。
这个示例共用 13 个图表节点,在 Free 套餐的限制之内。
常见问题
什么是 URL Frontier? 它是决定爬虫下一步抓取什么的数据结构。它把按重要性为 URL 排序的优先级队列,与按主机划分的队列及调度结合起来,防止爬虫过于频繁地连接任何一个主机。
爬虫如何保持礼貌? 每个主机同一时间只保持一个连接,请求之间的延迟随服务器响应时间增长,遇到 429 和 503 响应时退避,并遵守 robots.txt。
如果 robots.txt 返回错误会怎样? 根据 RFC 9309,4xx 状态码意味着爬虫可以抓取该网站,而 5xx 状态码或网络错误意味着在能够读取该文件之前,爬虫必须假定一切都被禁止。
爬虫如何避免重复下载同一个页面? 它们对 URL 进行规范化,并对照已见 URL 集合(通常是布隆过滤器)进行检查;同时用精确哈希和 simhash 为内容计算指纹,找出藏在不同 URL 下的同一页面。
十亿个 URL 的布隆过滤器需要多大? 误判率为 1% 时约 1.2 GB,每个 URL 大约用 9.6 位,并使用七个哈希函数。更低的错误率需要每个条目使用更多的位。