# 大规模网络爬虫架构：实战指南

几百个页面是一个脚本。数百万则是一个分布式系统。这里是实现这一目标的架构——队列、异步工作者、代理分层、重试、去重和数据质量检查，附带可运行的代码。

抓取几百个页面是一个脚本。抓取数百万则是一个分布式系统。当你的目标数量从“在我的笔记本上过夜运行”变为“需要在一周内完成而不崩溃”时，困难的部分不再是解析，而是围绕它的一切——如何排队工作、分发、避免阻塞、重试失败、存储和检查结果。这是**作为架构的大规模网络爬虫**，而不是一个片段，每个阶段都有可运行的代码。

## 规模是并发，而不是更大的循环

一个数字让它变得具体。以一个有20,000个列表页面的类别为例，每个页面有20个项目——需要获取400,000个页面。以每页2.5秒的现实速度，严格的顺序运行大约需要1,000,000秒，约**11.5天**的等待页面加载时间，然后才能解析一个字段。并行驱动200个页面，这11.5天的时间缩短到**一小时**的实际时间。时间是规模的限制，并发是你找回它的方法。架构中的其他一切都存在于让这种并发可持续。

![抓取 400,000 个页面、每个 2.5 秒的两根柱子：顺序执行约需 11.5 天，200 个页面并行时约一小时；卡片补充，在一百万次请求中，1% 的失败率就是 10,000 个失败页面，因此要对超时和 503 进行带退避的重试，而硬性 404 绝不重试。](https://res.cloudinary.com/dmqliab0t/image/upload/f_auto,q_auto,w_1200/v1785335206/blog/generated/qp-b9-077-diag1.png)
*并发将11.5天的运行时间缩短为一小时——但在一百万次请求中，即使1%的失败率也意味着10,000个页面无法访问。*

## 架构一览

一个能够处理数百万页面的爬虫是一个小型分布式系统，包含几个命名部分，每个部分解决一个只有在大规模时才会出现的问题：

- **一个队列**保存尚未获取的URL，并将发现与工作解耦。
- **异步或分布式工作者**从队列中提取并并发获取——这就是实际时间节省的地方。
- **代理和反机器人层**轮换IP并呈现真实浏览器流量，以便没有单个地址触发速率限制。
- **仅在需要时渲染**，因为无头浏览器是管道中最昂贵的部分。
- **带退避的重试**、**去重**、**存储**和**监控加数据质量检查**使其完整。

## 首先队列：将发现与获取解耦

最重要的结构性决策是将一个队列放在“要抓取的内容”和“进行抓取”之间。一个生产者枚举URL；一组工作者消耗它们。双方都不知道对方的运行速度，并且可以在不触及生产者的情况下添加工作者。在Python中，这可以是Celery或RQ加Redis；在Node中是BullMQ；在更大规模上是RabbitMQ或Kafka。一个文件中的模式：

```python
import asyncio, aiohttp

CONCURRENCY = 50
queue = asyncio.Queue()

async def worker(session):
    while True:
        url = await queue.get()
        try:
            async with session.get(url, timeout=20) as resp:
                await handle(url, await resp.text(), resp.status)
        except Exception as err:
            await on_failure(url, err)
        finally:
            queue.task_done()

async def run(urls):
    for u in urls:
        queue.put_nowait(u)
    async with aiohttp.ClientSession() as session:
        tasks = [asyncio.create_task(worker(session)) for _ in range(CONCURRENCY)]
        await queue.join()
        for t in tasks:
            t.cancel()
```

重要的旋钮是`CONCURRENCY`。太低会浪费使规模成为可能的并行性；太高会压垮目标和你自己的出口。通过观察错误率的上升来找到正确的值——这正是为什么监控是系统的核心部分，而不是事后考虑。

![从队列到存储的大规模抓取流水线：URL 队列、异步 worker、代理和反机器人层、仅在需要时渲染，然后是重试、去重、存储和质量检查；卡片对其进行了划分：数据模型、解析器和检查自己构建，代理、反机器人和渲染层购买现成的。](https://res.cloudinary.com/dmqliab0t/image/upload/f_auto,q_auto,w_1200/v1785335206/blog/generated/qp-b9-077-diag2.png)
*八个命名部分。代理、反机器人和渲染层是最难保持健康的——也是最自然的购买点。*

## 代理层首先崩溃

在低量时，你几乎不会注意到反机器人防御；在规模上，它们首先破坏运行。从一个IP发送几十万次请求，你会被限速，然后被挑战，然后被阻止。解决办法是跨多个地址轮换。分层：便宜的数据中心IP用于宽松的目标和API，[轮换的住宅IP](https://quantumproxies.io/residential-proxies)用于期望真实用户流量的困难商业目标。但仅仅轮换是不够的——现代防御还会读取TLS指纹和头部顺序，所以流量必须看起来像浏览器，而不仅仅是来自一个新的IP。

## 重试：失败是常态

在一百万次请求中，1%的瞬时失败率意味着10,000个失败页面。在这个量级上，失败不是一个边缘情况——它是常态，管道必须将失败的获取视为正常而不是致命的。使用指数退避和上限重试，然后将URL移到死信队列，而不是阻止运行。阅读*为什么*它失败：超时或503值得重试，硬404则不值得。

```python
import asyncio, random

async def fetch_with_retry(session, url, tries=4):
    for attempt in range(tries):
        try:
            async with session.get(url, timeout=20) as r:
                if r.status == 404:
                    return None                # don't retry a hard 404
                if r.status < 400:
                    return await r.text()
        except Exception:
            pass
        await asyncio.sleep(2 ** attempt + random.random())  # backoff + jitter
    await dead_letter(url)                     # give up after the cap
    return None
```

## 去重：不要重复抓取同一页面

大规模发现不断产生重复——同一产品可以通过三条路径访问，跟踪参数使一个页面看起来像十个。在URL进入队列之前进行规范化，然后保留一个已见集合（一个Redis集合，或当集合达到数亿时使用Bloom过滤器）：

```python
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode

seen = set()

def normalize(url):
    s = urlsplit(url.lower())
    q = [(k, v) for k, v in parse_qsl(s.query) if not k.startswith("utm_")]
    return urlunsplit((s.scheme, s.netloc, s.path.rstrip("/"), urlencode(sorted(q)), ""))

def enqueue(url):
    key = normalize(url)
    if key not in seen:
        seen.add(key)
        queue.put_nowait(key)
```

## 存储和数据质量

两个特定于规模的习惯：批量写入以免存储成为瓶颈，并将原始数据与解析数据分开，以便在选择器更改时可以重新解析而无需重新抓取。然后添加大多数团队跳过的一半监控——数据质量检查。一次运行可以报告100%的HTTP成功，但如果布局发生漂移而你的选择器现在匹配不上任何东西，仍然可能产生垃圾。断言必需字段不为空且值合理：

```python
def validate(row):
    assert row.get("title"), "empty title — selector may have drifted"
    price = row.get("price")
    assert isinstance(price, (int, float)) and 0 < price < 1_000_000, "bad price"
    return row

# fail loud on page 5,000, not silently after 5,000,000 empty rows
```

[将代理、反机器人和渲染卸载到一个API](https://quantumproxies.io/scraper-api)

## 仅在必须时渲染

无头浏览器是管道中最昂贵的操作——CPU、内存和每页秒数，在一百万页时占主导地位。许多网站仍然在初始HTML或JSON端点中传输数据；简单的获取加解析器便宜一个数量级。首先尝试便宜的路径，确认字段存在，仅对需要的页面进行渲染。我们对[渲染成本与HTTP](https://quantumproxies.io/blog/headless-browser-vs-http-requests-cost)的分析提供了这个差距的真实数据。

## 构建还是购买困难层

以上所有都是可构建的，所以诚实的问题是哪些部分值得你的工程时间。数据模型、解析逻辑、质量检查和存储模式是项目特定的——只有你能做好。代理池、反机器人处理、无头渲染群和重试与交付队列是通用基础设施，构建昂贵并且随着目标演变而难以保持健康。这就是托管[Scraper API](https://quantumproxies.io/scraper-api)所在的界限：租用对所有人都相同的部分。我们的[构建与购买分析](https://quantumproxies.io/blog/build-vs-buy-scraping-infrastructure)详细说明了维护税，并且[将爬虫作为生产软件运行](https://quantumproxies.io/blog/schedule-monitor-web-scrapers)涵盖了保持整个系统可观察。

## 常见问题解答

### 如何抓取数百万页面？

通过并发，而不是更大的循环。在URL发现和获取之间放置一个队列，用一组异步或分布式工作者来消耗它，轮换IP以避免阻塞，使用退避重试瞬时失败，去重URL，并批量写入存储。顺序执行百万页的运行需要数天；在并发工作者池中完成相同的任务则只需数小时。

### 大规模网络爬虫的最佳架构是什么？

一个队列和工作者的管道：生产者将URL枚举到队列（Redis、RabbitMQ或Kafka），工作者通过轮换代理层并发获取，仅渲染需要JavaScript的页面，将失败重试到死信队列，使用已见集合去重，并分别存储原始和解析数据。用数据质量检查的监控包裹它，以便漂移能早期暴露。

### 你可以并行运行多少请求？

这取决于目标和你的出口，而不是一个固定的数字。爬虫是IO绑定的，所以一个普通的机器可以处理数百个正在进行的请求。以大约50的并发开始，观察错误率，并在失败上升时提高——那就是你的上限。超出一台机器的限制时，添加分布式工作者，而不是更努力地推动单个节点。

### 如何在规模上处理失败？

假设失败——在一百万次请求中，即使1%的错误率也意味着10,000个页面无法访问。使用指数退避和抖动重试瞬时错误（超时、503），限制尝试次数，并将持久性失败移到死信队列，而不是阻止运行。不要重试硬404。随机化收集顺序也会分散失败，以便你不会在每次运行中都失败在相同的页面上。

规模大多是那些不太好玩的部分：轮换IP、反机器人、无头渲染、队列和重试。拥有特定于你数据的部分——模型、解析器、质量检查——并租用对所有人来说都是相同的通用基础设施。首先将队列和并发设置正确；其他一切都是为了让这种并发在面对一百万个真实页面时生存下来。

[用轮换的住宅IP为获取层提供动力](https://quantumproxies.io/residential-proxies)
