抓取几百个页面是一个脚本。抓取数百万个是一个系统。一旦目标数量从"在我的笔记本上跑一夜"跨越到"需要在本周内完成而不把机器烧坏",难点就不再是解析本身,而变成了围绕它的一切:如何对任务排队、如何把它分发到各工作节点、如何避免被封锁、如何重试失败任务,以及如何存储和校验结果。这是把大规模网络抓取当作一种架构而非代码片段的旗舰式讲解。

本文范围限定于大体量采集的公开数据:商品列表、价格、搜索结果、公开主页等。无论数据源如何,工作的形态都是相同的,因此本文的重点是管道以及各阶段的权衡,并在适当之处指出你不应该自己构建的部分。

大规模网络抓取究竟意味着什么

大规模网络抓取是从数百万个页面中提取数据的实践,跨越一个超大型站点,或同时跨越数千个较小站点。从常规抓取的跃升不只是一个更大的数字,一个数字就能让它变得具体。设想一个有 20,000 个列表页、每页 20 个条目的品类,于是有 400,000 个页面要获取。按现实的每页 2.5 秒计算,严格串行的一次运行大约是 1,000,000 秒,也就是在你解析出第一个字段之前,要花约 11.5 天等待页面加载。从这里开始这些数字仅作说明,但它们的数量级是对的。这个数字正是本文存在的全部理由:在大规模下,时间是约束,而并发是你把时间买回来的方式。让 200 个页面并行运行,那 11.5 天就坍缩到约一小时的真实墙钟时间。

架构一览

一个能撑过数百万页面的抓取器,是一个由少数几个具名部件组成的小型分布式系统。每一个都解决一个只在大体量下才出现的问题。

  • 一个队列保存仍待获取的 URL,并把发现与工作解耦,使生产者和消费者各按自己的节奏运行。
  • 异步或分布式工作节点从队列中拉取并并发执行获取。墙钟时间的节省正来自于此。
  • 一个代理与反爬层轮换 IP,并呈现目标读起来像真实浏览器的流量,使任何单个地址都不会触发限速。
  • 仅在需要时渲染,为 JavaScript 繁重的页面运行一个无头浏览器,并对静态页面跳过它,因为渲染是你能做的最昂贵的事。
  • 带退避的重试捕获在这种体量下必然出现的瞬时失败。
  • 去重阻止你两次获取或存储同一个 URL。
  • 存储把解析出的行放到某个可查询的地方。
  • 监控与数据质量检查告诉你这次运行是健康的,输出是可信的。

下面各节按顺序逐一展开。贯穿它们的主线是控制权与运维负担之间的一种权衡:要么每一层都自己构建,要么把最难的那几层(代理、反爬、渲染、重试)交给一个受管层,把你的时间花在数据上。

队列优先:把发现与获取解耦

最重要的单一结构性决定,是在"抓取什么"和"执行抓取"之间放一个队列。一个生产者枚举 URL(来自站点地图、一次搜索结果爬取,或一个 ID 数据库),把它们推入队列;一个工作节点池从中拉取。两边都无需知道对方跑得多快,而且你可以在不动生产者的情况下增加工作节点。

在 Python 中这通常是基于 Redis 的 Celery 或 RQ;在 Node 中是基于 Redis 的 Bull 或 BullMQ 队列;在更大规模下是 RabbitMQ 或 Kafka 这样的真正消息代理。一个极简的工作节点示意能让这个模式变得具体。

python
import asyncio
import aiohttp

CONCURRENCY = 50
queue = asyncio.Queue()

async def worker(name, session):
    while True:
        url = await queue.get()
        try:
            async with session.get(url) as resp:
                html = await resp.text()
                parse_and_store(url, html)
        except Exception as err:
            print(f'failed {url}: {err}')
        finally:
            queue.task_done()

async def run(urls):
    for u in urls:
        queue.put_nowait(u)
    async with aiohttp.ClientSession() as session:
        workers = [asyncio.create_task(worker(i, session)) for i in range(CONCURRENCY)]
        await queue.join()
        for w in workers:
            w.cancel()

这就是整套想法浓缩在一个文件里:一个有界的并发工作节点池,排空一个共享队列。真正重要的旋钮是 CONCURRENCY。太低会浪费让规模成为可能的那份并行;太高会同时压垮目标和你自己的出口带宽。你通过观察错误率攀升来找到合适的值,这正是为什么监控是这个系统的一等公民。

异步与分布式

异步并发(一台机器,许多在途请求)与分布式工作节点(许多机器)解决的是不同的天花板。异步以低成本把你从一次只能发一个请求的地板上拉起来。分布式工作节点带你越过单台机器的极限:渲染所需的 CPU、解析所需的内存,以及出站带宽。大多数大型作业两者并用:每个工作节点内部用异步,跨机器部署许多工作节点。

代理轮换与反爬:最先崩溃的那一环

在低体量下你几乎注意不到反爬防御;在大规模下它们最先让运行崩溃。从一个 IP 发出几十万个请求,你会先被限速,再被挑战,最后被封锁。修复方法是轮换:把请求分散到许多地址,使任何单个地址都不显得滥用。

用哪种代理很重要。数据中心 IP 便宜又快,但容易被指纹识别并被批量封锁。住宅代理经由真实的消费者连接路由,读起来像普通用户,这正是难缠的商业目标所期待的。对大多数大型作业,正确的默认选择是一个轮换住宅代理池,每个请求或每个短会话都从一个全新的真实用户 IP 出口。如果你自己组装这套,把轮换逻辑做对(在站点需要时保持粘性会话,在不需要时换用全新 IP)就是大部分工作;参见如何使用轮换代理

轮换是必要的,但并不充分。现代防御还会读取 TLS 指纹、请求头顺序和浏览器行为。一个像 Crawlbase Smart AI Proxy 这样的受管层,把轮换和指纹处理折叠进一个端点:你把现有的 HTTP 客户端指向一个代理 URL,它就在背后管理 IP 池、请求头,以及被封时的重试。要了解完整的防御打法,参见如何在不被封锁的情况下抓取网站

只在不得不时才渲染

在无头浏览器中渲染一个页面,是管道里最昂贵的操作:它消耗 CPU、内存,以及每页若干秒,而在百万页面下,这些秒数压倒一切。所以只在数据确实需要时才渲染。

许多站点仍然把数据放在初始 HTML 中,或通过页面调用的一个 JSON 端点暴露它。对于这些站点,一次普通的 HTTP 获取加一个解析器,比浏览器便宜一个数量级。把渲染留给那些在客户端构建内容、原始获取只返回一个空壳的页面。这条纪律很简单:先试便宜的路径,确认字段都在,仅对需要的页面升级到渲染。把两者混在一次运行里(目录页用静态获取,少数 JS 繁重的详情页用渲染)是常态,也正是节省所在。

受管的规模层:Crawling API 与异步 Crawler

代理、反爬和渲染是最难构建并保持健康的三层,而它们恰恰是 Crawlbase 替你管理的部分。Crawling API 是一次调用,它在轮换住宅 IP 背后获取一个 URL,处理反爬挑战,可选地用一个真实浏览器渲染页面,并返回完成的 HTML。你通过添加一个 JavaScript token 来按请求决定是否渲染;静态页面保持便宜,而 JS 繁重的页面获得一个浏览器。

python
from crawlbase import CrawlingAPI

api = CrawlingAPI({ 'token': 'YOUR_CRAWLBASE_TOKEN' })

options = {
    'ajax_wait': 'true',
    'page_wait': 3000,
    'country': 'US',
}

resp = api.get('https://www.example.com/products?page=42', options)
if resp['status_code'] == 200:
    parse_and_store(resp['body'])

同步调用非常适合放进上面的工作节点池:每个工作节点调用 api.get,而 API 吸收代理、反爬和渲染这些麻烦。但对于真正大型的作业,有一个更好的模式。异步 Crawler 反转了流程:你不再在每个页面被获取时保持一条连接打开,而是把 URL 推送给它,它按自己的节奏爬取,然后把每个完成的页面 POST 回你控制的一个 webhook 端点。你给 Crawling API 调用加上两个参数,&callback=true&crawler=YourCrawlerName,Crawler 就接管排队、调度和重试。

python
from crawlbase import CrawlingAPI

api = CrawlingAPI({ 'token': 'YOUR_CRAWLBASE_TOKEN' })

# Push as many URLs as you like; the Crawler queues and crawls them async,
# then POSTs each finished page to the webhook on your registered crawler.
for url in urls_to_crawl:
    api.get(url, {
        'callback': 'true',
        'crawler': 'my-products-crawler',
    })

异步模型对于数百万页面是正确的选择,因为它移除了系统中你本来要时时照看的那部分。你不再保持连接打开、不再运行一支渲染机队、也不再管理一个重试队列;你只推送,然后接收。Crawler 甚至会监控你的 webhook:如果你的端点宕机,它会暂停、通知你、重试失败的投递,并在你的服务器恢复后自动续上。这就是把排队、调度、重试和投递可靠性作为一个受管层来处理,而这几乎就是本文其余部分让你去构建的全部内容。

Crawlbase Crawling API + 异步 Crawler

规模大多是那些构建起来毫无乐趣的部分:轮换住宅 IP、反爬、无头渲染、队列和重试。Crawling API 把前三项折叠进一次调用,而异步 Crawler 接过你推送的 URL,按自己的节奏爬取它们,并带自动重试地把完成的页面 POST 到你的 webhook。先在免费档位上把它指向一个公开目标。

重试与退避:失败才是常态

在一百万个请求里,1% 的瞬时失败率就是 10,000 个失败页面。在这种体量下失败不是边缘情况;它是常态,而你的管道必须把一次失败的获取当作例行公事而非致命错误来对待。这个模式是带指数退避和上限的重试:稍等一会儿,再多等,再多等,几次尝试之后把这个 URL 移到死信队列,而不是阻塞整次运行。

微妙之处在于读懂一个请求为什么失败,因为不是每个失败都值得重试。超时或 503 值得重试;硬性的 404 不值得。在代理流量下,你还会拿到代理专属的状态信号,告诉你该退避、轮换,还是升级 IP 档位;把这些当作信号而非噪声,能让一次长时间运行保持健康。完整的映射参见如何解决代理状态错误码。受管层会在内部重试封锁,但你自己的逻辑和存储的重试仍然由你负责。

去重:不要两次爬取同一个页面

大规模下的发现会不断产生重复:同一个商品可从三条品类路径到达、让一个页面看起来像十个的跟踪参数、循环往复的分页。没有去重,你会浪费预算重复获取页面,并用重复的行污染你的数据集。

有两层来处理它。第一,在 URL 进入队列之前对其规范化:剥除跟踪参数、把主机名转为小写、对查询键排序、把相对链接解析为一种规范形式。第二,保存一个已见集合(一个 Redis 集合,或对于超大型运行用一个布隆过滤器),并跳过任何已经在其中的 URL。布隆过滤器用极小的假阳性率换取巨大的内存节省,当已见集合达到数亿规模时,这正是正确的权衡。也要对输出去重:以一个稳定的标识符作为行的键,使一个被获取两次的页面不会变成两条记录。

存储:让存储匹配访问模式

数据落到哪里,取决于你接下来用它做什么。平面文件(CSV、JSONL)或对象存储适合追加为主的归档和廉价的批量处理。关系型数据库适合你需要查询、连接并更新行的场景。文档存储适合形态因来源而异的半结构化记录。错误在于把一切都塞进其中某一种,仅仅因为它顺手。

有两个与规模相关的习惯很重要。批量写入,而不是每个请求写一行,使存储不成为你的瓶颈;工作节点应当缓冲并刷写。并把原始数据与解析后的数据分开:保留原始 HTML(或对它的一个引用),以便在选择器变化或你发现一个新字段时,能在不重新爬取的情况下重新解析。Crawlbase 可以把页面直接投递到 Cloud Storage 或你的 webhook,从你这边彻底移除接入管道。

监控与数据质量

一次大型运行如果没有埋点就是不透明的。你需要对已获取页面数、成功率、按类型分的错误率、队列深度和吞吐量的实时计数器,这样你就能在一场封锁风暴或一个停滞的队列正在发生时看到它,而不是在明天那个空空的数据集里。队列深度上升而吞吐量下降,意味着工作节点卡住了;挑战激增意味着该退避或更狠地轮换了。

数据质量是各团队跳过的那一半监控,也是决定数据是否可用的那一半。一次运行可以报告 100% 的 HTTP 成功,却仍然产出垃圾,如果布局变了、而你的选择器现在什么都匹配不到的话。加入廉价、持续的检查:断言必填字段非空、价格能解析为合理范围内的数字、每页的行数大致符合你的预期。当一项检查在许多页面上同时失败时,标记漂移了,你的解析器需要关注。在第 5,000 个页面就抓住这一点,好过在你已经存了五百万个空行之后。

在哪里划自建与外购的界线

上面的一切都是可构建的,所以诚实的问题是哪些部分值得你的工程时间。数据模型、解析逻辑、质量检查和存储模式是你项目特有的,只有你才能把它们做好。代理池、反爬处理、无头渲染机队,以及异步的重试与投递队列,是通用基础设施,构建昂贵,且随着目标演化而难以保持健康。这正是受管规模层所处的界线:用 Crawling API 或异步 Crawler 来负责获取、渲染和反爬;用 Smart AI Proxy 来保留你自己的客户端并换入一个受管的轮换端点;或者用 Crawling API 从受支持的站点获取已解析的 JSON,从而完全跳过选择器。把你的时间花在数据上;对人人都一样的部分则去租用。

回顾

核心要点

  • 规模就是并发,而不是一个更大的循环。串行的一次百万页面运行要花数天;一个给异步或分布式工作节点供料的队列把它坍缩到数小时。
  • 代理和反爬最先崩溃。在住宅 IP 间轮换并呈现真实浏览器的流量,或者让一个受管层替你处理轮换和指纹。
  • 只在不得不时才渲染。无头渲染是最昂贵的一步;先试静态获取,仅对客户端渲染的页面升级。
  • 失败才是常态。用退避和死信队列重试瞬时错误,对 URL 和行去重,并把代理状态码当作信号来读。
  • 在高端,异步胜过同步。把 URL 推送给 Crawler 并在一个 webhook 上接收结果,于是排队、调度、重试和投递都替你处理好了。
  • 监控成功率与数据质量。100% 的 HTTP 成功但字段为空仍然是一次失败的运行;对数据做断言,而不只是对状态码。

常见问题

什么算大规模网络抓取?

大致而言,任何大到单个串行脚本不再可行的作业,实际上意味着数十万到数百万个页面,跨越一个大站点或许多较小站点。决定性的特征不是数量,而是你现在需要并发、代理轮换、重试和监控,才能在合理时间内完成而不被封锁。在那个阈值以下,一个简单的循环就够了;在它以上,你就在运行一个小型分布式系统。

我如何在不被封锁的情况下抓取数百万个页面?

把请求分散到许多 IP,使任何单个地址都不显得滥用,对难缠的目标优先使用轮换住宅代理,呈现读起来像真实浏览器的流量,控制你的请求节奏,并在出现挑战时退避。自己构建这一切是相当大的工作量,所以大多数团队会经由一个像 Crawling API 或 Smart AI Proxy 这样的受管层路由,它在一个端点背后处理轮换、指纹和挑战求解。

在大规模下我该用同步还是异步抓取?

异步,对于任何真正大型的作业都是如此。同步获取每个页面都保持一条连接打开,并占住一个工作节点直到每个请求完成。异步 Crawler 模型让你推送 URL,并在一个 webhook 回调上接收完成的页面,于是排队、调度和重试都在服务器端发生,你的应用不会被阻塞地等待。你推送,然后接收,这要更容易扩展和运维得多。

大规模抓取我总是需要一个无头浏览器吗?

不需要,并且能避免就避免。渲染是每页最昂贵的一步,所以把它留给那些在客户端构建内容、并对普通获取返回一个空壳的站点。许多站点在初始 HTML 中就提供了可用数据,或暴露了一个 JSON 端点,两者获取起来都便宜得多。把廉价的静态路径与仅对需要的页面渲染混用,是划算的默认做法。

在数百万个请求中我如何处理失败和重复?

把两者都当作例行公事。用带上限的指数退避重试瞬时失败(超时、503),然后把那些顽固的发到死信队列,而不是阻塞运行;不要重试硬性的 404。对于重复,在排队之前规范化 URL,保存一个已见集合或布隆过滤器以跳过已获取的 URL,并以一个稳定的标识符作为存储行的键,使一个被获取两次的页面不会变成两条记录。

大型抓取得来的数据我该存到哪里?

让存储匹配你将如何使用数据:廉价归档用对象存储或 JSONL,需要查询和连接时用关系型数据库,形态多变的记录用文档存储。批量写入而非每个请求一行,使存储不成为瓶颈,并把原始 HTML 与解析后的输出放在一起,以便在选择器变化或你新增一个字段时,能在不重新爬取的情况下重新解析。

开始构建

大规模爬取任何站点,无需与基础设施对抗。

Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。

自助开通 · 无需销售通话 · 提供企业级爬取量