一条首页链接指向 /calendar/0,第二页指向 /calendar/1,后面每天都有“下一天”。抓到一百万页不等于建好了搜索入口:如果抓取进程重启后忘了待访问 URL,或者不同写法的同一 URL 占满队列,站点还没抓完,预算已经耗尽。爬虫首先是有边界的调度系统,其次才是 HTTP 客户端。

本篇只抓进程内搭建的合成站点,不请求真实网站。规模与 SLO 都是教学目标,实验只验证有限队列及失败行为,不测生产爬虫吞吐。

先问允许抓什么

需求是发现站点内页面、保留有用内容供后续索引,同时限制对站点的访问速率。核心不变量有三条:未被策略允许的 URL 不发请求;一个规范化 URL 在本轮最多标成一个待处理任务;被确认完成的任务在恢复后不再作为新任务反复抓取。最后一条并不承诺网络请求恰好一次:响应到达与“已完成”提交之间崩溃,恢复只能重抓,再由索引侧按 URL 和内容版本幂等处理。

教学 SLO:允许的源站请求相邻发起时间间隔至少 30 ms;站点出现暂时性 503 时最多重试两次;调度进程故障后五分钟内恢复已提交的任务状态。实验的请求间隔断言容忍 2 ms 服务端观测偏差,不验证五分钟 RTO。明确排除登录/付费墙、JS 渲染、图片、全互联网域名发现、分布式协调和真实站点礼貌性承诺。爬虫必须另外服从站点条款;robots.txt 不是访问授权或安全边界。RFC 9309规定 robots 排除规则的语义,却不提供普遍的“每站 30 ms”数值;这个数是本文自定的节流预算。

入口 POST /crawl-jobs {origin, seed_urls, max_pages, max_depth} 返回任务 ID;GET /crawl-jobs/{id} 返回已完成、待处理、拒绝计数。内部 frontier(url_canonical PK, state, attempt, next_at, discovered_from) 以主键去重,fetches(url, response_status, fetched_at, content_hash) 留下处理证据。API 在规范化前核对 scheme、host、端口与地址解析结果,防止重定向或 DNS 重绑定绕过站点白名单;示例只接收进程创建的 127.0.0.1 随机端口,且不跟随未知跨站链接。urllib.parse 的解析和查询参数工具见 Python 文档:本实验仅移除 fragment 并解析相对链接,不合并查询参数;参数排序也不是通用等价关系,真实站点的重复参数、签名与参数顺序可能改变语义。

估算队列而非只估算页面

假设允许站点每天新增 240 万个不同 URL,平均 2_400_000 / 86_400 = 27.78 URL/s,短时峰值系数 8,得到 222.2 URL/s。按每个 frontier 状态及 URL 平均 240 B 计,保留七天未完成候选的逻辑上限为 240 万 URL/天 × 240 B/URL × 7 天 = 4.032 GB,三副本为 12.096 GB,不含索引、日志和压缩。假设成功页面平均下载 40 KiB,峰值流量 222.2 × 40 KiB ≈ 8.68 MiB/s,也不含失败重试与 TLS 开销。若 5% 请求需重试一次,额外约 11.1 request/s;一个恶意日历持续产生无限候选时,七天保留公式根本不成立,必须限制深度、参数组合、每站配额。

单站礼貌间隔 30 ms 对应理想上限约 33.3 request/s,因此峰值 222.2 request/s 需要至少七个独立且许可的站点才能消化;把同站任务分到更多 worker 不会改变这个约束。若页面大小从 40 KiB 增到 400 KiB,带宽放大十倍至约 86.8 MiB/s,但单站间隔仍是约 33.3 request/s:瓶颈从访问时钟可能转向带宽或解析。估算不代表真实页面分布或延迟测量。

flowchart LR
    S[种子] --> N[规范化与域名校验]
    N --> F[(持久 frontier 唯一 URL)]
    F --> Q[按站点配额取任务]
    Q --> R[robots 检查]
    R --> H[HTTP 下载器]
    H --> P[解析链接与内容]
    P --> N
    P --> I[(内容索引 幂等写)]
    H -->|503 延迟重试| F

最小版用单进程 SQLite 队列,先读 robots.txt,按主机调度时间领取 URL,再抓取、解析、提交已完成状态。生产候选方案是持久化分区队列加按站点限流的调度器:能让多个站点并行,代价是租约到期、重复领取与分区归属。另一候选是纯内存 FIFO,适合小型一次性、允许从种子全量重来的离线抓取;在五分钟恢复目标和海量候选下丢队列不可接受。Python robotparser提供 can_fetch,它不会替系统处理 DNS/重定向安全、排队公平与整个站点的速率分配。

响应已收到,提交未完成

sequenceDiagram
    participant W as 下载 worker
    participant F as frontier
    participant S as 本地站点
    W->>F: 领取 URL 标为 inflight
    W->>S: GET 页面
    S-->>W: 200 与下一页链接
    Note over W,F: 提交 done 前进程退出
    W->>F: 重启后回收 inflight
    F-->>W: 同一 URL 再次领取
    W->>S: GET 重试
    W->>F: 入队新链接并提交 done

恢复必须先核对任务记录,再将过期 inflight 重新排队;若没收到 HTTP 响应,遵守重试预算及退避,明确失败的 4xx 按策略终止,503 才延迟重试。失败持续时暂停该站点而非让队列无限积压。若 robots 无法取得,不能把“不知道规则”当无限访问许可;在授权不明确时暂停该站,等待人工检查或经风险评估的策略。以上是设计边界,不是本次实验对各种 HTTP 异常的验证结果。

实验运行 python3 -B examples/system-design/labs/23/crawler.py;版本、源码哈希、命令、输出和退出码见 examples/system-design/evidence/23/RUN.md。仅绑定本进程 127.0.0.1 随机端口的服务收到 robots.txt、首页、日历 1–3、一次 /page 和两次 /retry(第一次 503);两个不同 fragment 的 /page 链接合为一项,/private、站外链接和日历 4 没有访问。领取一条任务并写入 running 后,脚本在发送 HTTP 前关掉连接、重开磁盘 SQLite,将其恢复为 queued;恢复数为 1,最终六条 frontier 均为 done。脚本断言服务端最小观测间隔至少 28 ms,实测值在 run.json。这是单进程连接重开的恢复检查,未执行操作系统强杀;上方响应后宕机的时间图描述还需幂等重抓的设计边界。

[PATTERN] 把“发现”和“执行”分开:以 {规范资源 ID, 状态, 最早可重试时间} 持久化候选,执行侧允许重复领取,但提交侧按资源 ID 幂等。它同样适用于网页、文件扫描和异步导入;遇到无限生成器时先对候选施加边界,不能靠去重解决新 URL 无穷多的问题。

面试追问与速查

若两个域名解析到同一 IP,礼貌性预算按域名还是物理源站共享?要结合站点所有权与实际来源给出约束,不能仅按 IP 合并无关租户。若 robots 更新成禁止已缓存页面,如何停止未来访问并处理已保存内容?后者还涉及授权/删除策略,robots 本身不规定索引删除。若队列有一百万个日期 URL,怎样证明不会饿死普通页?提出每站及每路径预算,并给出丢弃率与待处理年龄观测。

触发条件 可迁移处理 代价
重启后任务状态不确定 持久队列、幂等提交 可能重抓
单站过载 按站点节流与延迟重试 新鲜度下降
链接无限增殖 配额、深度和路径规则 可能漏抓

接续阅读:22 限流的共享配额;24 空间网格的候选集。

实验附件:权威实验源码;运行记录;版本与源码哈希;本地原始结果。