专业书籍精读 · System Design Interview 卷一 · 第 9 章
System Design Interview Vol 1 · Ch 9 · Alex Xu · 2020
你在搜索框里敲两个字,半秒钟就跳出上万条结果——那些网页当然不是在你敲字的时候现去读的,而是早就被一个程序一页页抄回来、整理好放着了。这个日夜不停在网上抄书的程序叫爬虫。《System Design Interview》第 9 章就是让你从零把它造出来。
把爬虫想成一个抄书员,手里只有一张地址清单。他去清单上的第一个地址,把那儿的东西整页抄下来;抄的过程中发现文章里提到了另外二十个地址,他就把这二十个补到清单末尾。然后去下一个地址,如此循环。
神奇的地方在于:这张清单永远在变长。他抄得越多,清单越长——整个互联网就是这样被一圈一圈卷进来的。
下载一个网页是最简单的一步,真正难的是决定下一个去哪。清单上排着几百亿个地址,而麻烦有两层:
第一层,清单上的地址会扎堆。一个网页里的链接,大多指向同一家网站。所以老老实实按顺序抄,清单上会连着出现几百个同一家的地址——抄书员一口气冲过去连敲几百次门,等于把人家门铃按爆了。人家一生气,直接把你拉黑,你就再也抄不到了。
第二层,地址不该一视同仁。新闻网站首页一小时就变一次样,某个十年没人动过的静态页面抄一次就够了。清单要是不分轻重,你会把宝贵的时间花在没人看的角落里。
这一章的灵魂就是:把「先去谁」和「别打扰谁」拆成前后两道队伍。
前面一道按重要性排——重要、更新勤的地址排前面,优先被叫号。后面一道按门牌号分:每一家网站单独开一个队,并且一家只派一个人去,两次敲门之间还得等一会儿。这样一来,前面那道保证你不浪费时间,后面那道保证你不惹人厌。两件互相打架的事,被两道队伍分开解决了。
诚实说一句代价:礼貌就是慢。你越守规矩、等得越久,抄完全网需要的时间就越长;想抄得快,只能同时铺开更多家网站,而不是在一家使劲敲。
一是抄到重复的。同一篇文章常挂在好几个地址下。所以抄书员得设两道关卡:出门前问「这个地址我去过没」,抄完后再问「这段内容我有了没」。
二是走进迷宫。有些网站的目录能一层套一层无限生成下去,一头扎进去就出不来。最省事的一刀是限制地址长度,剩下的只能靠人工把已知的迷宫记进黑名单。
爬虫的难点不在「怎么下载」,而在怎么排队:用两层队列把「先抓重要的」和「别把人家网站抓崩」这两件互相冲突的事分开解决;再在下载前和入库前各设一道去重关卡,挡掉去过的地址和抄过的内容。
想进到具体机制、记号和示意图? → 切到精读版
这道题看着像「怎么把网页下下来」,考的其实是调度:URL frontier 用「前队列管优先级、后队列管礼貌」的两层结构,把「先抓谁」和「别把对方服务器抓崩」这两条互相打架的约束拆开来分别满足;其余组件——robots.txt、DNS 缓存、两道去重闸门、陷阱防护——全是围着这条主线的保护装置。裸的广度优先遍历(BFS)在真实网络上活不过一分钟,本章的全部工程量就是把它改造成能上生产的东西。
本章位于卷一 Part II「系统案例」。它是全卷第一道不是「请求—响应」形态的题:前面 Ch 4 限流器到 Ch 8 短网址,对面都坐着一个等结果的用户;这一章没有用户,只有一条自己给自己喂食的循环管道——衡量它好坏的不是 p99 延迟,而是吞吐、覆盖率和「有没有惹毛别人」。它产出的网页正是 Ch 13 搜索自动补全与整条搜索链路的原料。对应现实里的 Googlebot、Bingbot、Internet Archive 的 Heritrix,以及公开数据集 Common Crawl。
照例先把题目变成数字。书里的假设:每月下载 10 亿个网页,页面平均 500 KB,数据保存 5 年。由此:
400 页/秒;峰值按 2 倍算 ≈ 800 页/秒。500 TB/月。30 PB。但这三个数字都不是这题的难点——30 PB 扔进对象存储就完了,400 页/秒对一台机器都不算高。真正的难点藏在一个反直觉的地方:这 400 页/秒该从几百亿个候选里挑谁?
不做这层设计会怎样?把发现的 URL 塞进一个 FIFO 队列、开一百个线程猛拉,第一秒就会对某个域名打出上千请求——对方要么封你的 IP,要么被你打挂(这是一次货真价实的拒绝服务攻击)。这题看着是抓取题,其实是调度题;书里四个设计目标(可扩展、健壮、礼貌、可延展)中,「礼貌」是唯一约束来自系统外部、你还必须无条件服从的。
整个爬虫是一个闭环而不是流水线:种子 URL 进入 URL frontier,被 HTML 下载器取走(下载前先做 DNS 解析),页面交给解析器做校验与清洗;内容过一道「内容去重」再落库;页面里的链接被提取出来,过一道过滤(丢掉不感兴趣的文件类型与黑名单域名)和一道「URL 去重」,剩下的回流进 frontier——环就此闭合。
把网页看成图、超链接看成边,遍历只有两条路。深度优先(DFS)不可用:链接深度可能极大甚至无穷,一头扎下去就出不来。广度优先(BFS)——一层层往外扩、用一个 FIFO 队列实现——是所有真实爬虫的默认选择。但书里紧接着点破它的两个致命缺陷。
第一,同页链接高度同源:一个页面里的外链绝大多数指向同一主机(导航栏、侧栏、相关文章),于是队列里会连续出现几百个同域 URL,一批 worker 同时拉,等于做了一次小型 DoS。第二,不区分优先级——所有 URL 一视同仁,而现实里网页的价值差着几个数量级。
反过来算一笔账最直观:假设对每个主机守「一秒最多一个请求」这条常见礼貌线,那么要撑住 400 页/秒的平均吞吐,你在任何一刻都得同时有 ≥400 个不同主机在被抓。也就是说,爬虫的吞吐不是靠「在一个站上加线程」堆出来的,而是靠横向铺开更多主机——这一句话,就把整个 frontier 的结构决定了。
解法是把两个诉求拆成上下两层,中间用一张主机 → 队列的映射表连起来。
上层「前队列」管优先级。一个优先级打分器(prioritizer)给每个 URL 打分——依据是 PageRank、站点流量、更新频率等;分数决定它被放进 f1…fn 中的哪一条前队列。取号时,前队列选择器随机挑一条,但带偏向:高优先级队列被选中的概率更大。用「带偏向的随机」而不是「严格按优先级」是有意的——严格排序会让低优先级 URL 永远饿死。
下层「后队列」管礼貌。从前队列出来的 URL 交给后队列路由器,它查那张映射表:这个主机已经有专属队列就进去,没有就新建一条。关键性质是——每条后队列只装同一个主机的 URL,且只有一个 worker 线程服务它。这个线程处理完一个 URL 后强制等待一段时间才取下一个。于是「同一主机同一时刻至多一个在途请求,且请求之间有间隔」这条礼貌规则,被队列结构本身保证了,不需要任何额外的加锁或全局计数——这是本章最漂亮的一手。
还有两件事挂在 frontier 上。新鲜度(freshness):已抓过的 URL 要按历史更新频率重新排期——日更的新闻页值得反复回访,多年不动的页面重爬就是浪费。存储:几亿条 URL 全塞内存太贵、全放磁盘太慢,实际做法是混合:绝大部分排在磁盘上,入队 / 出队各留一个内存缓冲定期同步。
刹车是 robots.txt——下载任何页面前,先读该站的 robots.txt,尊重它声明的禁止路径。书里强调的一点很实际:这个文件要缓存下来定期刷新,否则每抓一页就多一次请求,反而更不礼貌。
三个加速点:
10–200 ms,而标准解析接口是同步阻塞的。算笔账就知道有多要命:按 100 ms 一次算,一个线程一秒只能解析 10 个域名——要撑 400 页/秒,光 DNS 就得压上约 40 个线程干等着。做一层域名→IP 的本地缓存、定时刷新,几乎是白捡的性能。健壮性上书里另点了三件:用一致性哈希分配下载器以便增减机器(见 Ch 5);抓取状态定期存盘,崩了能断点续爬而不是从种子重来;异常处理与数据校验,别让一个畸形页面拖垮整个 worker。
初学者最常把两者混为一谈,实际它们守着完全不同的成本:
URL 去重(URL Seen?)在下载之前,挡住「这个地址我已经抓过或已在队列里」的 URL,省下的是带宽和抓取预算。几百亿条精确哈希表放不下,典型做法是布隆过滤器:内存小几个数量级,代价是有极小概率误判「见过」而永久漏掉一页——这是被明确接受的取舍,漏掉一页远比多抓一亿页便宜。
内容去重(Content Seen?)在入库之前,挡住内容已有的页面(同一篇文章挂多个地址、镜像站、带跟踪参数的同页),省下的是存储与索引成本,做法是对正文算校验和比对。书里引一项研究称约 29% 的网页是重复内容——这是接近三成的存储与索引开销。
爬虫陷阱是能无限生成 URL 的结构——最典型的是「下一个月」永远点得下去的日历页,或无限套下去的目录。书给的对策很朴素:限制 URL 最大长度(一刀切、最省事,代价是误伤合法深层链接),再配一份人工维护的陷阱黑名单。这是本章少有的「没有优雅解、只能靠运营」之处。数据噪音——广告、代码片段、垃圾页——则在解析阶段直接排除。
表 1 · 三种遍历策略:为什么最终是「BFS + 两层队列」
| 深度优先 DFS | 裸广度优先 BFS | BFS + 两层 frontier | |
|---|---|---|---|
| 实现 | 栈 / 递归 | 一个 FIFO 队列 | 前队列(优先级)+ 后队列(主机) |
| 致命问题 | 深度可能无穷 | 同页链接同源 → 对单一主机狂轰 | 无——两个诉求各由一层解决 |
| 优先级 | 无 | 无,一视同仁 | 按 PageRank / 流量 / 更新频率打分 |
| 礼貌 | 无 | 无,需额外机制 | 由队列结构保证,无需加锁 |
| 代价 | — | — | 组件多、状态大,frontier 要落盘 |
表 2 · 礼貌的三档实现:越守规矩,全网抓完得越久
| 不限速 | 全局限速 | 按主机队列 + 延时 | |
|---|---|---|---|
| 做法 | 取到就抓,能开多少线程开多少 | 整体固定 QPS 上限 | 一主机一队列一线程,请求间强制等待 |
| 对方感受 | 等同于 DoS,几分钟内被封 IP | 仍可能集中砸向一个站 | 单站压力恒定可控 |
| 吞吐怎么加 | 加线程(然后被封) | 调高全局上限(治标) | 横向铺开更多主机:400 页/秒需 ≥400 个活跃主机 |
| 代价 | 抓不了几天就寸步难行 | 参数难定,粗粒度 | 慢:小站天然抓得慢;队列与映射表的状态开销 |
| 谁在用 | — | 玩具爬虫 | Googlebot、Heritrix 等生产爬虫 |
这道题的价值在于:它是少有的、答案不由你单方面决定的系统设计题。前几章你可以随便加机器加缓存,这一章你必须对「别人的服务器」让步——能不能主动讲出 robots.txt、按主机限速、以及「吞吐靠铺开主机而不是加线程」,几乎是区分「读过爬虫」和「想过爬虫」的分水岭。
这套骨架就是 Googlebot、Bingbot、Heritrix 与 Common Crawl 的公共形状;今天它还多了一层现实意义——大模型训练语料的采集,用的正是同一套 frontier + 礼貌 + 去重的机器。
delayFactor / minDelayMs / maxDelayMs 控制两次请求之间的间隔——一个跑了二十年的生产爬虫,独立印证了书里「一队一主机、一队一线程、请求之间加延时」的后队列结构。Heritrix3 Wiki · Politeness parameters ↗500 KiB;并明确声明 robots.txt 不是安全措施(写进去的路径反而被公开暴露)。RFC 9309 · Robots Exclusion Protocol, 2022 ↗27.4 亿 个网页、468 TiB 未压缩内容、覆盖 4750 万 个主机,其中只有 8.38 亿 个 URL 是此前历次抓取从未访问过的——印证了两件事:主机数量级远超一台机器的队列容量;URL 去重闸门挡下的是绝大多数流量。Common Crawl Blog · April 2025 Crawl Archive ↗① 容量画边界:10 亿页/月 → 400 页/秒(峰值 800);页均 500 KB → 500 TB/月;存 5 年 30 PB。但这些数字都不是难点。
② 难点是调度:几百亿候选里挑哪 400 个。四个设计目标里,礼貌是唯一来自系统外部、必须无条件服从的约束。
③ DFS 不可用(深度无穷),BFS 是基础,但裸 BFS 有两个致命缺陷:同页链接高度同源导致对单主机狂轰,以及完全没有优先级。
④ 核心解法是 URL frontier 的两层队列:前队列按优先级分流(带偏向的随机选择,避免低优先级饿死),后队列按主机分流(一队一主机、一队一线程、请求间强制延时)——礼貌由结构本身保证。
⑤ 吞吐的公式是活跃主机数 × 每主机速率:守「每主机 1 请求/秒」时,400 页/秒需要同时有 ≥400 个主机在抓。加线程无用,铺开主机才有用。
⑥ 下载器一个刹车三个加速:robots.txt(要缓存);分布式抓取、DNS 缓存(一次解析 10–200 ms 且同步阻塞)、就近部署 + 短超时。
⑦ 两道去重守不同的成本:URL 去重在下载前省带宽(布隆过滤器,容忍极小概率永久漏页);内容去重在入库前省存储(书引研究称约 29% 网页重复)。
⑧ 陷阱只有笨办法:限制 URL 长度 + 人工黑名单;frontier 要落盘、抓取状态要存盘,才能崩了续爬而不是从种子重来。