网络爬虫是一个在整个互联网上做图遍历的引擎:从一组种子 URL 出发、下载每个页面、抽取它包含的链接、再递归跟进——永不停止。数据结构概念上很简单(对一个有向图做广度优先搜索),但在规模上工程极其残酷。你必须抓取数十亿页面而不压垮任何单个 web 服务器、通过一个比你的抓取循环慢几个数量级的 DNS 层解析数亿个主机名、对 URL 和内容去重以免把同一页面爬上千遍、遵守每个站点的 robots.txt 规则、在生成无限 URL 的蓄意恶意爬虫陷阱中存活、把工作分散到一个机器机群上同时保持礼貌性状态一致,并足够频繁地重爬页面让索引保持新鲜而不必每天重新下载占网络 90% 的静态部分。每一个都是真实的系统设计问题,而且它们以微妙的方式相互作用。
- URL frontier 是核心——Mercator 两层设计:front queue 按优先级(重要性 + 新鲜度)排序,back queue 按主机划分并带 crawl-delay 闸门,这样一个慢站点永不阻塞整个机群。
- 布隆过滤器已见集合——10 亿+ URL 在 1% 假阳性率下装进 ~1.2 GB;O(1) 成员检查让去重不碰磁盘。假阳性会跳过少数新 URL;从不会假阴性。
- DNS 是隐藏瓶颈——每次抓取一个同步解析调用会卡住吞吐;每主机解析一次并激进缓存。
- robots.txt 是强制的——每主机抓取并缓存,遵守
Disallow和crawl-delay;无视它会被封 IP 且在道德上是错的。 - 内容去重用哈希 + SimHash——精确重复用内容哈希;近重复(镜像、样板页)用 SimHash 的 Hamming 距离。
- 爬虫陷阱需要硬性边界——最大深度、每主机最大页数、URL 长度上限,以及对日历和 session ID 的模式检测。
- worker 按主机名分区——
hash(host)→ worker 分片,把一个主机的全部礼貌性状态保持在本地,每次抓取无需跨 worker 协调。 - 自适应重爬保新鲜——重爬率 ∝ 观察到的变更频率 × 页面重要性;新闻每小时,静态页每月。
网络爬虫是对网络的分布式 BFS。核心组件是 URL frontier——一个两层的优先级 + 礼貌性队列(Mercator)。通过激进缓存解析 DNS、每主机遵守 robots.txt、用布隆过滤器对 URL 去重、用 SimHash 对内容去重、用深度和每主机上限约束爬虫陷阱、按主机名分区 worker 使礼貌性本地化、把原始 HTML 存进对象存储而元数据进宽列存储,并基于每个页面的变更率和重要性自适应重爬。
第 1 步 — 澄清需求
"设计一个网络爬虫"隐藏着巨大的范围跨度。一个针对单域名的聚焦爬虫是周末项目;一个为搜索引擎供料的通用爬虫是数年工程。在画第一个框之前先钉死范围。
功能需求
- 给定一组种子 URL,下载所引用的页面并递归跟进其链接。
- 抽取并存储页面内容(原始 HTML)和链接图(哪个页面链到哪个)。
- 遵守
robots.txt指令和站点声明的任何crawl-delay。 - 去重:绝不把同一页面内容存两次,并检测近重复/镜像内容。
- 周期性重爬页面,让存储内容保持新鲜。
- 按内容类型过滤——通常是 HTML,可选 PDF、图片或其他媒体。
非功能需求
- 可扩展性——爬取数十亿页面;横向扩展到跨多机的数千并发抓取线程。
- 礼貌性——绝不压垮单个主机;遵守 crawl-delay,且每主机同时最多保持一个打开连接。
- 健壮性——在畸形 HTML、重定向循环、慢或死的服务器,以及蓄意恶意的爬虫陷阱中存活而不崩溃或卡死。
- 新鲜度——频繁重爬高变动页面,同时不在静态页面上浪费带宽。
- 可扩展(extensibility)——可插拔的解析器和协议处理器,以便日后加入新内容类型。
开场就澄清两件事,因为它们主导整个设计。第一:这是通用(Googlebot 规模)爬虫、针对单站/单主题的聚焦爬虫,还是搜索索引供料器?第二:你必须渲染 JavaScript 吗?抓取静态 HTML 很便宜;跑无头浏览器渲染客户端 SPA 在 CPU 和内存上贵 10–100×,并改变你的整个容量模型。陈述你的假设然后继续。
第 2 步 — 容量估算
粗略数字锚定带宽、存储和内存决策。假设目标为每月 10 亿页面——一个现实的中型爬取。
吞吐
- 10 亿页面 / 月 ÷ (30 × 86,400 s) ≈ 386 页/秒持续;按 2× 峰值因子,设计到 ~800 页/秒。
- 平均 HTML 页面 ~100 KB时,下载带宽 ≈ 400 页/秒 × 100 KB ≈ 40 MB/秒 ≈ 320 Mbps持续入站——爬虫本质上受带宽约束,而非 CPU 约束(除非你渲染 JS)。
- 每次抓取涉及:一次 DNS 查找(缓存)、一次 TCP + TLS 握手、HTTP GET、解析和链接抽取。要达到数百页/秒,你需要数千并发连接(异步 I/O 或大线程池),因为每次抓取的延迟由网络往返主导,而非本地工作。
存储
- 原始 HTML:10 亿页 × 100 KB = ~100 TB/月未压缩;gzip 对 HTML 压缩 ~4:1,所以存储 ~25 TB/月。一年爬取下来就是数百 TB——对象存储的地盘,不是 SQL 数据库。
- URL 元数据(URL、状态、上次爬取时间、内容哈希、ETag、下次重爬时间):~500 字节/URL × 10 亿 ≈ ~500 GB,随爬取 frontier 增长。
- 已见 URL 集合:布隆过滤器在 1% 假阳性率下需 ~9.6 位/元素 ≈ 1.2 字节/URL → 10 亿 URL ~1.2 GB,100 亿 ~12 GB。装进内存,跨几个节点分片。
主导资源是网络带宽,主导约束是礼貌性——你不能简单地对一个热门主机开 10,000 个连接。真实爬取吞吐受你能并行抓取多少个不同主机的限制,这正是为什么 frontier 的每主机排队(第 4 步)是成败组件,而非原始机器数。
第 3 步 — 高层架构
爬虫是一条由中央工作队列(frontier)供料的协作组件流水线。每个抓取线程跑同一个循环:取出一个 URL、解析 DNS、抓取、解析、抽取链接、去重、存储,并把新 URL 推回 frontier。
种子 URL ──► URL Frontier(优先级 + 礼貌性队列)
│ 出队(主机就绪的 URL)
▼
DNS 解析器(缓存)──► IP
▼
robots.txt 缓存 ──► 允许?
▼ 是
HTML 抓取器(线程池,条件 GET)
▼ 原始 HTML
内容去重 ──► content-hash + SimHash
│ 新内容?
▼ 是
解析器 / 链接抽取器
│ 规范化 + 过滤链接
▼
URL 去重(布隆过滤器已见集合)
│ 未见?
▼ 是 ▼
推入 Frontier 存储:原始 HTML(对象存储)
(分配优先级) + 元数据(宽列)
+ 链接图(邻接表)
▲
重爬调度器按变更率重新注入到期 URL
frontier、DNS 解析器、去重结构和存储都是被每个抓取器消费的共享服务。藏在这张简单图里的两个难题是 frontier(如何排序和定速抓取)和去重(如何在数十亿条目上廉价地做)。其余是管道——重要,但已被充分理解。
第 4 步 — URL Frontier
frontier 是持有待爬取 URL 的数据结构。它不是简单的 FIFO 队列——它必须同时满足两个相互竞争的目标:优先级(先爬重要和时间敏感的页面)和礼貌性(绝不猛敲单个主机)。经典解法是 Mercator 两层队列设计。
front queue——优先级
一个优先级分配器(prioritizer)基于页面重要性(PageRank、入链数)、更新频率,以及存储副本有多陈旧等信号,给每个到来的 URL 分配 1 到 n 的优先级。每个优先级层级有一个 FIFO front queue;分配器把每个 URL 入队到匹配其优先级的队列。更高优先级的页面插到静态长尾之前。
back queue——礼貌性
一个 back-queue 路由器保证某个主机的每个 URL 恰好落进一个 back queue,且每个 back queue 恰好持有一个主机的 URL。一个 worker 线程绑定到一个 back queue,并对每主机最多保持一个打开连接。一个以每个队列下次抓取时间戳为键的最小堆强制 crawl-delay:从主机 X 抓取后,该队列的下次抓取时间被往后推一个延迟,所以堆在礼貌性间隔过去前不会再浮现它。
# 前侧:优先级
prioritize(url) -> p in [1..n] # PageRank、新鲜度、陈旧度
front_queues[p].push(url) # 每个优先级一个 FIFO
# 后侧:礼貌性(每个 back queue 一个主机)
back_queues[i] # URL 的 FIFO,全是同一主机
host_to_queue[host] = i # 路由表
# 堆按 back queue 下次可抓取的时间排序
heap = min_heap_of (next_fetch_time, queue_id)
def worker_loop():
next_fetch_time, qid = heap.pop() # 最快就绪的主机
sleep_until(next_fetch_time)
url = back_queues[qid].pop()
page = fetch(url) # 对此主机一个连接
delay = crawl_delay_for(url.host) # robots.txt 或默认 1–2 s
if back_queues[qid].empty():
refill_from_front_queues(qid) # 拉下一个主机的 URL
else:
heap.push(now() + delay, qid) # 重置礼貌性计时器
这个设计优雅地解耦了两个关切:front queue 决定接下来什么值得爬,back queue 决定什么时候爬它才礼貌,而堆把"挑最快就绪的主机"变成一个 O(log n) 操作。在规模上 frontier 是分片的——见第 10 步——但每分片的结构正是这个。因为 frontier 持久化在途工作,它常被一个像 Kafka 这样的持久队列或一个磁盘结构支撑,这样一个 worker 崩溃永不丢失 URL。
第 5 步 — DNS 解析
DNS 是爬虫里最被低估的单一瓶颈。每次抓取都需要主机的 IP,而一次冷 DNS 查找可能花从几毫秒到几秒不等——远比抓取循环其余部分慢。一个在每次抓取前同步调用 getaddrinfo() 的朴素爬虫会把大部分时间花在阻塞于 DNS 上,而 OS 解析器通常是单线程的、会串行化请求。
两个缓解措施必不可少。第一,跑一个专用的多线程 DNS 解析器池,使 DNS 查找并行发生且永不阻塞抓取线程。第二,维护一个激进的 DNS 缓存:每个主机名解析一次并把 IP 复用到该主机的所有后续 URL,遵守记录的 TTL。因为 frontier 已经按主机分组 URL(第 4 步),绝大多数抓取命中一个热缓存条目。对于很大的爬取,团队常在抓取器附近跑一个本地缓存 DNS 服务器(或自定义解析器),而缓存本身的行为像任何其他读密集的缓存——定容到持有活跃主机的热工作集。
不要为"省查找"而永久缓存 DNS——站点会换 IP,陈旧条目会把你的流量送到错误服务器或死主机。遵守 TTL,但也按主机数量约束缓存并驱逐最久未用的主机。正确的缓存命中率来自按主机分组的抓取,而非无视 TTL。
第 6 步 — 抓取与 robots.txt
在抓取某主机上任何 URL 之前,爬虫必须抓取并遵守该主机的 robots.txt(机器人排除协议)。该文件每主机抓取一次并以自己的 TTL 缓存(通常 24 小时);它声明 Disallow 路径前缀、可选的 Allow 例外、可选的 crawl-delay,以及常有的一个 Sitemap 指针,用站点自己的 URL 列表给 frontier 播种。
- 严格遵守它——抓取禁止路径会让你的爬虫 IP 被封、被反爬服务列入黑名单,且在道德上有时在法律上无可辩护。礼貌性路径在每次抓取前都查 robots 缓存。
- 重定向——跟进但限制链长(如 5 跳)以避免重定向循环;把最终 URL 当作规范的并对它去重。
- HTTP 卫生——发送一个带联系 URL 的描述性
User-Agent、设连接/读取超时、请求 gzip,并限制响应体大小以防御数 GB 的"压缩炸弹"页面。 - 状态处理——2xx 存储;3xx 跟进;404/410 标记已删;429/503 退避并稍后用指数延迟重试;持续 5xx 降级该主机的优先级。
第 7 步 — 去重:URL 与内容
没有去重,爬虫会淹死:网络上到处是被多条路径到达的同一 URL,以及在多个 URL 下提供的同一内容。这里有两个不同的去重问题。
URL 去重——已见集合
在把一个 URL 加进 frontier 前,检查它是否已被见过。先规范化(canonicalize) URL——小写主机、移除默认端口、剥离片段(#…)、排序查询参数、丢弃已知跟踪参数、解析相对路径——使琐碎不同的字符串映射到一个键。然后在布隆过滤器已见集合里测成员:
- 布隆过滤器说"未见"(某位未设)→ URL 一定是新的——加进 frontier 并设它的位。
- 布隆过滤器说"可能见过"(所有位已设)→ 几乎肯定是重复;跳过它,或若完整性关键,对照一个后备 key-value 存储确认。
布隆过滤器用大约一 GB 装下十亿 URL,并以 O(1) 回答而不碰磁盘。它唯一的错误模式是假阳性——误跳过一小部分真正新的 URL——这是换取内存节省的可接受代价。它从不产生假阴性,所以它绝不会重爬一个已被它过滤的页面。
内容去重——精确与近重复
两个不同 URL 可以提供相同或近乎相同的内容(镜像站、转载文章、打印友好版本)。用内容哈希——规范化 HTML 的 MD5 或 SHA-256——抓精确重复,并跳过存储一个哈希已存在的页面。用相似度指纹抓近重复:SimHash(或 MinHash)把文档归约为一个 64 位签名,使相似文档的签名在小 Hamming 距离内。相差 ~3 位以内的页面当作重复。这正是阻止爬虫索引来自单个内容农场的一万个近乎相同模板页的东西。
第 8 步 — 解析与 URL 抽取
页面被抓取并确认为新之后,解析器抽取出站链接和其他内容。步骤:
- 把 HTML 解析成 DOM,要容错——真实世界的 HTML 时刻畸形,所以用一个宽容的解析器(libxml2、lxml、jsoup),它对坏标记从不抛异常。
- 抽取锚点——收集
<a>标签的href值,外加 canonical-link 和 sitemap 提示。 - 解析为绝对 URL——把相对链接相对于页面 base URL 转换,然后跑已见集合用的同一规范化。
- 过滤——只保留可爬的 scheme(http/https)、按 robots 丢弃禁止路径、丢弃不想要的内容类型,并应用陷阱边界(第 9 步)。
- 入队——把幸存的、未见的 URL 带一个新分配的优先级推回 frontier。
若目标站点是 JavaScript 重度的单页应用,静态 HTML 解析会漏掉大多数链接,因为 DOM 在客户端构建。那时你需要一个无头浏览器(经 Puppeteer/Playwright 的无头 Chrome)在抽取前渲染页面——贵得多,所以大多数爬虫选择性渲染,只对已知需要它的主机渲染。
第 9 步 — 爬虫陷阱与礼貌性
爬虫陷阱是站点的一个区域,它无界地生成 URL,要么意外要么恶意。若不设防,一个陷阱就能耗尽你整个 frontier 和带宽。常见陷阱:
- 无限日历——永远走下去的"下个月"链接(
/calendar?date=2099-12→ 2100-01 → …)。 - URL 里的 session ID——每次访问铸造一个新
?sid=…,所以同一页面看起来像无限多个 URL。 - 分面搜索爆炸——过滤参数的每个组合都是一个不同 URL,产生组合式膨胀。
- 无限深路径——配置错误的服务器,把任意深度的
/a/a/a/a/…都解析到同一页面。
防御是一组分层硬性边界:限制 URL 深度(路径段数和离种子的爬取距离)、限制每主机页数、限制 URL 长度和查询参数数量,并检测重复的路径模式。关键的是,内容去重(第 7 步)是兜底——即便 URL 边界没拦住,一个生成近乎相同页面的陷阱也会在命中几次后被 SimHash 过滤掉。
礼貌性不只是 crawl-delay。适应主机的健康:看到 429(请求过多)或 503 时退避,且若一个主机的响应时间上升就自动放慢——这是你在给它施压的信号。一个好的爬虫对待每个目标服务器,就像客人对待主人的家:一次一个请求、间隔开,并在被要求离开的那一刻就走。
第 10 步 — 分布式 worker 与协调
单台机器爬不动每月十亿页面。工作分散到一个机群上,而分区方案是关键决策,因为它决定礼貌性如何被强制。
按主机名分区。对每个 URL 的主机哈希并分配到一个爬虫节点:node = hash(host) % N。example.com 的每个 URL 去同一节点,所以该主机的全部礼貌性状态——它的 back queue、它的 crawl-delay 计时器、它的 robots 缓存、它的 DNS 条目——都住在一台机器上。抓取热路径上无需跨节点协调,这正是让设计可扩展的原因。frontier 和布隆过滤器已见集合按同一主机名键分片,这样一个节点拥有自洽的一片爬取。
节点成员关系和主机到节点的分配由一个协调器(ZooKeeper 或 etcd)用一致性哈希管理,这样当一个节点加入或死亡时只重映射它那份主机,而非整个键空间。frontier 由一个持久的、分区的日志支撑,这样在途 URL 在 worker 崩溃后存活,并能被继承该主机的任意节点重放。
第 11 步 — 存储
爬虫产出三种数据,各有不同的访问模式和存储:
| 数据 | 存储 | 为什么 |
|---|---|---|
| 原始 HTML(页面正文) | 对象存储(S3 / GCS / HDFS),gzip 压缩,以内容哈希为键 | PB 级、写一次读很少、每 GB 便宜;内容哈希键给免费精确去重 |
| URL 元数据 | 宽列 / key-value 存储(Bigtable、Cassandra),以 URL 哈希为键 | 数十亿小行,"我爬过这个吗、何时爬的"的点查找,高写吞吐 |
| 链接图 | 同一宽列存储里的邻接表,或专用图存储 | 驱动 PageRank 和 frontier 优先级;抽取时作为边写入 |
原始页面正文进对象存储,因为它们大、不可变且读得不频繁——正是对象存储最便宜的工作负载。给每个 blob 以其内容哈希为键意味着相同内容自动折叠为一个对象。元数据表持有关于每个 URL 的小而频繁更新的事实(上次爬取时间、HTTP 状态、ETag、内容哈希、下次重爬时间),是调度器扫描以决定重爬什么的那张表。
第 12 步 — 新鲜度与重爬
爬一次不够——网络会变。但页面以差异极大的速率变化:一个新闻首页每几分钟更新,一篇归档博文从不改变。按固定计划重爬一切会把大部分带宽浪费在重新下载未变页面上,所以新鲜度必须是自适应的。
- 条件 GET——发送
If-Modified-Since和If-None-Match(带存储的 ETag)。若页面未变,服务器返回304 Not Modified且无正文,在确认新鲜度的同时省下几乎全部带宽。 - 估计变更频率——从观察到的变更历史给每个页面的更新建模(Poisson 过程是经典估计器)。一个在最近 5 次访问中变了 3 次的页面比一个从不变的更早被重爬。
- 按率 × 重要性安排——把重爬间隔设为与估计变更频率成反比,并按页面重要性(PageRank)加权。高变动、高价值的新闻页每小时重爬;静态低价值页每月重爬。
- 经 frontier 重新注入——调度器扫描元数据表找
下次重爬时间已到的 URL,并以合适的优先级把它们推回 frontier。重爬不是独立管道;它只是给同一个 frontier 供料。
第 13 步 — 扩展与容错
在每月十亿页面时,爬虫必须穿过机器故障、慢主机和流量峰值持续运行,而不丢失工作或违反礼貌性。
扩展
抓取线程是无状态的、横向扩展——加机器以加带宽和并发。frontier 按主机分片、布隆过滤器按主机分片、DNS 缓存分布式,所以没有共享结构成为全局瓶颈。真正的天花板是出/入站带宽和礼貌性:无论机器多少,你都不能比"不同主机数 × 它们允许的速率"爬得更快。
容错
- worker 崩溃——协调器经一致性哈希把死节点的主机重新分配给存活节点;因为 frontier 是持久分区日志,在途 URL 被重放而非丢失。
- 幂等抓取——崩溃后重抓一个页面无害:内容哈希去重意味着重新处理同一页面不产生重复存储。这让 frontier 的至少一次(at-least-once)处理安全,所以你永不需要昂贵的恰好一次语义。
- 已见集合持久性——周期性给布隆过滤器做检查点;重启时重载检查点。检查点到崩溃之间少量的重爬可接受,得益于内容去重。
- 毒 URL——一个反复让 worker 崩溃的 URL(畸形的 GB 级页面、解析器炸弹)被一个重试计数器检测到并隔离到死信队列,而非永远重试。
第 14 步 — 关键取舍
| 决策 | 选择 | 接受的取舍 |
|---|---|---|
| frontier 设计 | Mercator 优先级 + 礼貌性 | 更多活动部件 vs 公平排序和每主机礼貌性而不饿死吞吐 |
| URL 去重 | 布隆过滤器已见集合 | 微小的假阳性 URL 损失 vs 把数十亿 URL 装进 ~1 GB 内存 |
| 内容近重复 | SimHash 指纹 | 偶尔错误合并相似但不同的页面 vs 廉价抓住镜像/内容农场 |
| 渲染 | 仅静态 HTML(选择性 JS) | 漏掉 JS 重度 SPA 里的链接 vs 全量无头渲染贵 10–100× |
| 分区 | 按主机名 | 热主机倾斜(一个巨型站点)vs 完全本地的礼貌性、每次抓取无协调 |
| 存储 | 对象存储 + 宽列元数据 | 最终一致性 vs 廉价 PB 级和免费内容哈希去重 |
网络爬虫看似简单的 BFS,结果是一个在行星级规模上做定速和去重的练习。frontier——决定爬什么的优先级、决定何时爬的礼貌性——是成败设计的组件;其余一切(DNS 缓存、robots.txt、布隆过滤器去重、SimHash、主机名分区、自适应重爬)都为高效给那个 frontier 供料并表现得像网络的好公民而存在。了解为什么礼貌性卡住吞吐、为什么布隆过滤器是对的已见集合、以及自适应重爬如何在不重下载静态网络的情况下保持索引新鲜——你就能为图上的每个框辩护。
URL frontier 怎么在不饿死吞吐的情况下保证礼貌性?用 Mercator 两层设计:front queue 按优先级(重要性和新鲜度需求)给 URL 排序,而 back queue 按主机划分——一个主机恰好映射到一个 back queue,只有一个在途连接,并由一个以“下次抓取时间”为键的最小堆强制 crawl-delay 间隔。礼貌性是按主机的,所以整体吞吐保持高,因为成千上万个不同主机被并行抓取。
为什么用布隆过滤器做已见 URL 集合,风险是什么?十亿级以上的 URL 装不进内存里的哈希集合;布隆过滤器在 1% 假阳性率下用约 1.2 GB 装下 10 亿 URL,成员检查 O(1)。风险是假阳性——一小部分真正新的 URL 被误当作“已见”而跳过(从不会假阴性)。若完整性要紧,在阳性时查一个后备的 key-value 存储确认。
怎么检测重复和近重复内容?用规范化 HTML 的内容哈希(MD5 或 SHA-256)抓精确重复。用相似度指纹抓近重复——镜像站和模板化样板页:SimHash 或 MinHash 计算一个指纹并比较 Hamming 距离,把相差几位以内的页面当作重复跳过。
怎么避免爬虫陷阱?给爬取设界:限制 URL 深度、每主机页数、URL 长度和查询参数数量,并检测日历、session-id 循环的模式。把这些限制和内容去重结合,这样一个生成近乎相同页面的无限陷阱在命中几次后就被过滤掉。
怎么在不重爬全部的情况下保持内容新鲜?自适应重爬。用 ETag 和 Last-Modified 加上观察到的 diff 跟踪每个页面的变更频率,然后按变更率和页面重要性(PageRank)成比例地安排重爬间隔。高频变动的新闻页每小时重爬,而静态页每月重爬——frontier 优先级编码了这一点,且返回 304 的条件 GET 节省带宽。