大规模网络爬虫架构:实战指南
几百个页面是一个脚本。数百万则是一个分布式系统。这里是实现这一目标的架构——队列、异步工作者、代理分层、重试、去重和数据质量检查,附带可运行的代码。
抓取几百个页面是一个脚本。抓取数百万则是一个分布式系统。当你的目标数量从“在我的笔记本上过夜运行”变为“需要在一周内完成而不崩溃”时,困难的部分不再是解析,而是围绕它的一切——如何排队工作、分发、避免阻塞、重试失败、存储和检查结果。这是作为架构的大规模网络爬虫,而不是一个片段,每个阶段都有可运行的代码。
规模是并发,而不是更大的循环
一个数字让它变得具体。以一个有20,000个列表页面的类别为例,每个页面有20个项目——需要获取400,000个页面。以每页2.5秒的现实速度,严格的顺序运行大约需要1,000,000秒,约11.5天的等待页面加载时间,然后才能解析一个字段。并行驱动200个页面,这11.5天的时间缩短到一小时的实际时间。时间是规模的限制,并发是你找回它的方法。架构中的其他一切都存在于让这种并发可持续。

架构一览
一个能够处理数百万页面的爬虫是一个小型分布式系统,包含几个命名部分,每个部分解决一个只有在大规模时才会出现的问题:
- 一个队列保存尚未获取的URL,并将发现与工作解耦。
- 异步或分布式工作者从队列中提取并并发获取——这就是实际时间节省的地方。
- 代理和反机器人层轮换IP并呈现真实浏览器流量,以便没有单个地址触发速率限制。
- 仅在需要时渲染,因为无头浏览器是管道中最昂贵的部分。
- 带退避的重试、去重、存储和监控加数据质量检查使其完整。
首先队列:将发现与获取解耦
最重要的结构性决策是将一个队列放在“要抓取的内容”和“进行抓取”之间。一个生产者枚举URL;一组工作者消耗它们。双方都不知道对方的运行速度,并且可以在不触及生产者的情况下添加工作者。在Python中,这可以是Celery或RQ加Redis;在Node中是BullMQ;在更大规模上是RabbitMQ或Kafka。一个文件中的模式:
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。太低会浪费使规模成为可能的并行性;太高会压垮目标和你自己的出口。通过观察错误率的上升来找到正确的值——这正是为什么监控是系统的核心部分,而不是事后考虑。

代理层首先崩溃
在低量时,你几乎不会注意到反机器人防御;在规模上,它们首先破坏运行。从一个IP发送几十万次请求,你会被限速,然后被挑战,然后被阻止。解决办法是跨多个地址轮换。分层:便宜的数据中心IP用于宽松的目标和API,轮换的住宅IP用于期望真实用户流量的困难商业目标。但仅仅轮换是不够的——现代防御还会读取TLS指纹和头部顺序,所以流量必须看起来像浏览器,而不仅仅是来自一个新的IP。
重试:失败是常态
在一百万次请求中,1%的瞬时失败率意味着10,000个失败页面。在这个量级上,失败不是一个边缘情况——它是常态,管道必须将失败的获取视为正常而不是致命的。使用指数退避和上限重试,然后将URL移到死信队列,而不是阻止运行。阅读为什么它失败:超时或503值得重试,硬404则不值得。
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过滤器):
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成功,但如果布局发生漂移而你的选择器现在匹配不上任何东西,仍然可能产生垃圾。断言必需字段不为空且值合理:
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
仅在必须时渲染
无头浏览器是管道中最昂贵的操作——CPU、内存和每页秒数,在一百万页时占主导地位。许多网站仍然在初始HTML或JSON端点中传输数据;简单的获取加解析器便宜一个数量级。首先尝试便宜的路径,确认字段存在,仅对需要的页面进行渲染。我们对渲染成本与HTTP的分析提供了这个差距的真实数据。
构建还是购买困难层
以上所有都是可构建的,所以诚实的问题是哪些部分值得你的工程时间。数据模型、解析逻辑、质量检查和存储模式是项目特定的——只有你能做好。代理池、反机器人处理、无头渲染群和重试与交付队列是通用基础设施,构建昂贵并且随着目标演变而难以保持健康。这就是托管Scraper API所在的界限:租用对所有人都相同的部分。我们的构建与购买分析详细说明了维护税,并且将爬虫作为生产软件运行涵盖了保持整个系统可观察。
常见问题解答
如何抓取数百万页面?
通过并发,而不是更大的循环。在URL发现和获取之间放置一个队列,用一组异步或分布式工作者来消耗它,轮换IP以避免阻塞,使用退避重试瞬时失败,去重URL,并批量写入存储。顺序执行百万页的运行需要数天;在并发工作者池中完成相同的任务则只需数小时。
大规模网络爬虫的最佳架构是什么?
一个队列和工作者的管道:生产者将URL枚举到队列(Redis、RabbitMQ或Kafka),工作者通过轮换代理层并发获取,仅渲染需要JavaScript的页面,将失败重试到死信队列,使用已见集合去重,并分别存储原始和解析数据。用数据质量检查的监控包裹它,以便漂移能早期暴露。
你可以并行运行多少请求?
这取决于目标和你的出口,而不是一个固定的数字。爬虫是IO绑定的,所以一个普通的机器可以处理数百个正在进行的请求。以大约50的并发开始,观察错误率,并在失败上升时提高——那就是你的上限。超出一台机器的限制时,添加分布式工作者,而不是更努力地推动单个节点。
如何在规模上处理失败?
假设失败——在一百万次请求中,即使1%的错误率也意味着10,000个页面无法访问。使用指数退避和抖动重试瞬时错误(超时、503),限制尝试次数,并将持久性失败移到死信队列,而不是阻止运行。不要重试硬404。随机化收集顺序也会分散失败,以便你不会在每次运行中都失败在相同的页面上。
规模大多是那些不太好玩的部分:轮换IP、反机器人、无头渲染、队列和重试。拥有特定于你数据的部分——模型、解析器、质量检查——并租用对所有人来说都是相同的通用基础设施。首先将队列和并发设置正确;其他一切都是为了让这种并发在面对一百万个真实页面时生存下来。