日均上亿条数据采集,这套架构撑住了

1 阅读16分钟

从爬虫的本质推导架构

爬虫的本质是什么?是按照既定流程获取数据的服务

既然是服务,就需要有人告诉它"该做什么"——这就是任务调度。调度器负责生成任务、分发任务,爬虫节点只管执行。

进一步想:如果调度器统一管理任务状态(分发、确认、重试),爬虫节点就不需要关心"做到哪了",它只需要 "拿任务→执行→报告结果" 。这意味着节点是无状态的——挂了重启就能继续消费,随时可以加机器水平扩容。

但调度器和爬虫节点之间不能直接通信,否则调度器就要维护所有节点的连接、感知节点上下线,耦合太重。解决方案是在中间加一层消息队列:调度器往队列里投任务,节点从队列里竞争消费,彼此完全解耦。

最后,爬虫节点采集到的数据不能直接写数据库。高并发下几十个节点同时写库,数据库很容易成为瓶颈。所以需要一层数据流缓冲:节点把结果投到数据流中间件,下游消费者异步落库。

把这些推导串起来,就是整套架构:

img_v3_02vu_8a2b4a89-4fb6-4b1d-bc3d-9da2af24336g.png

接下来逐一展开每个模块的设计和实现。

任务调度

任务调度器的角色是生产者,负责将需要请求的目标 URL 等信息推送到消息队列。推送策略的设计也有讲究,比如按站点拆分、按页码分片、按优先级排序等,后面会详细展开。

具体的调度实现可以使用 Celery,也可以自己写一个轻量调度服务,实现起来比较简单,这里不做过多展开。

任务格式参考:

{
  "id": "019c9e4a-7fcc-7c13-a317-e5d365eb5d4c",
  "timestamp_utc": "2026-01-01T08:00:00.000Z",
  "platform": "social",
  "platform_type": "profile",
  "platform_id": "000001",
  "version": "V1",
  "other": {},
  "trace": [],
  "data": {}
}

字段说明:id 推荐 UUID7(自带时间排序);platform / platform_type 用于下游路由到不同的爬虫方法;trace 是链路追踪字段,后文详解。

消息队列(RabbitMQ)—— 任务调度

为什么用 RabbitMQ 而不是 Kafka 做任务调度?

消息队列都支持消息确认,这不是 RabbitMQ 的独特优势。真正让 RabbitMQ 更适合任务调度的原因在于:

(1)消息级别的精确控制

RabbitMQ 的 ACK/NACK 是逐条消息粒度的:消费者处理完一条任务,确认一条;处理失败,NACK 让这条消息重新入队,其他消费者可以立即接手。

Kafka 的确认机制是基于 offset 的:消费者提交一个 offset,代表"这个位置之前的消息我都处理完了"。如果第 5 条失败了但第 6 条成功了,你没法只回退第 5 条——要么都回退,要么跳过。对于任务调度场景,这种粗粒度的确认不够灵活。

(2)分层 Exchange 路由,从根本上避免任务分流出错

RabbitMQ 的 Exchange 支持 Exchange-to-Exchange(E2E)绑定,可以构建多层路由。调度器只需往一个主 Exchange 发消息,routing_key 携带完整路径,分流全靠绑定规则:

  • 第一层(主 Topic Exchange):按平台路由,social.# 转发到 social 子 Exchange,news.# 转发到 news 子 Exchange
  • 第二层(子 Topic Exchange):按资源池路由,social.ippool1.* 绑定到 queue_social_ippool1social.userpool1.* 绑定到 queue_social_userpool1

调度器发送时,routing_key 是具体的,比如 social.ippool1.000123(最后一段是业务 ID),消息会自动经过两层 Exchange 分流到对应队列。新增平台或资源池只需添加 Exchange 绑定,已有代码不需要改动。

这一点非常关键。做复杂路由这种任务,RabbitMQ 天然就是能手。

img_v3_02vu_b038f5f5-11e9-4314-a7bd-43b2801cc76g.png

Kafka 没有这种路由能力,消息分配是 partition 级别的,无法按消息内容做路由。如果用 Kafka 实现同样的分流逻辑,需要在消费端自己写一套路由系统——要么拆分成大量 topic,要么在消费者内部做 if-else 过滤再转发,工程量反而比用 RabbitMQ Exchange 大得多,而且容易出错。

(3)镜像体积与扩容成本

爬虫节点是需要频繁扩容的组件。使用 RabbitMQ 的客户端 aio_pika(纯 Python 实现),Docker 镜像可以基于 alpine 构建,体积只有几十 MB。

而 Kafka 的 Python 客户端 aiokafka / confluent-kafka 依赖 librdkafka(C 库),想尽可能正确完成服务构建最好用 glibc 环境,只能用 bookworm 等完整镜像,体积动辄几百 MB。对于需要频繁拉起新节点的爬虫场景,镜像大小直接影响扩容速度,而且整体对磁盘的成本是比较高的。

为什么不用其他消息队列?

(1)NATS:轻量高性能,但设计哲学是"at most once",Exchange 路由和消息级 ACK 不是它的强项。适合微服务轻量通信,不适合精确任务调度。

(2)RocketMQ:功能全面,但偏向 Java 生态,Python 客户端成熟度不够。而且并不是 RabbitMQ 的 Exchange - Queue 模型。更像 Kafka,如果这样维护的话,从功能角度来说不如直接用 Kafka 解决。

(3)Pulsar:架构先进(存算分离),但需要同时管理 Broker + BookKeeper + ZooKeeper,小团队运维成本太高。

综合来看,RabbitMQ 在路由灵活性、消息级确认、运维成本、Python 生态成熟度上是最均衡的选择,且有成熟的云托管方案(CloudAMQP、AWS Amazon MQ、阿里云 RabbitMQ Serverless 等),落地门槛低。特别是阿里云的 RabbitMQ 是可以按消息计费的,一天的规模,其实也才几块钱。包括本地部署的方案,也很便宜。

Python 连接 RabbitMQ

封装了一个异步 RabbitMQ 客户端类,支持连接、发送、消费,完整代码参考《附录 A: RabbitMQ 客户端代码》。

使用示例

调度器只往主 Exchange 发消息,routing_key 格式为 {platform}.{pool}.{id},消息经过多层 Exchange 自动分流到对应队列:

# 调度器(Server A):routing_key = social.ippool1.000001
async with RabbitMQ("amqp://guest:guest@192.168.10.10:5672/", exchange="spider_dispatch") as mq:
    await mq.send("social.ippool1.000001", {
        "id": "019c9e4a-7fcc-7c13-a317-e5d365eb5d4c",
        "timestamp_utc": "2026-01-01T08:00:00.000Z",
        "platform": "social",
        "platform_type": "html",
        "platform_id": "000001",
        "version": "V1",
        "other": {},
        "trace": [],
        "data": {},
    })
# IP池1 处理器(Server B):只消费 queue_social_ippool1
async with RabbitMQ("amqp://guest:guest@192.168.10.10:5672/") as mq:
    async for task, msg in mq.receive("queue_social_ippool1"):
        await spider_social(task)
        await msg.ack()  # 处理失败则 nack() 重新入队
# 账号池2 处理器(Server C):只消费 queue_social_userpool2
async with RabbitMQ("amqp://guest:guest@192.168.10.10:5672/") as mq:
    async for task, msg in mq.receive("queue_social_userpool2"):
        await spider_social(task)
        await msg.ack()

Exchange 和队列的绑定关系在 RabbitMQ 管理界面或初始化脚本中配置。

调度器只关心 routing_key,处理器只关心自己的队列——新增一个资源池,只需要新建队列并绑定到子 Exchange,不需要改任何已有代码。

无状态爬虫

爬虫真的能无状态吗?

现实中,很多爬虫其实是有状态的。比如:一个账号只能绑定一个 IP 去访问,换 IP 会导致账号异常甚至封号;某些平台的 Cookie/Session 和指纹强绑定,换环境就失效。这些都是状态。

但我们需要在架构层面抽象出一种无状态化的设计。思路是:把有状态的部分收敛到一起,上游统一按无状态对待,然后基于状态来分流

这正是前面 RabbitMQ Topic Exchange 分层路由的价值所在:

(1)调度器发送 social.ippool1.000123,Exchange 自动将任务路由到 queue_social_ippool1

(2)消费 queue_social_ippool1 的处理器,内部绑定了对应的 IP 池和账号状态

(3)对于调度器和架构上游来说,它不需要关心哪个节点绑了什么 IP、什么账号——它只管往 Exchange 扔任务,分流由绑定规则保证不会出错

有状态的部分被封装在"队列 + 处理器"这个单元内部,对外暴露的是无状态的接口。

核心设计

整个架构的核心是:一个任务类型绑定一个队列,一个处理器监听一个队列

处理器只需要监听自己的队列,拿到的一定是属于自己的任务。服务崩了怎么办?重启后继续从队列消费即可——未被 ACK 的消息会自动重新投递,处理器用自己关联的状态(IP 池、账号等)继续处理,不需要额外的恢复逻辑。

这种设计带来两个好处:

(1)水平扩容:同一类型的处理器可以部署多个实例竞争消费同一个队列(比如 3 台机器都消费 queue_social_ippool1,共享同一个 IP 池)

(2)故障隔离:不同类型的处理器之间完全隔离,IP 池 1 的节点挂了不影响 IP 池 2 的采集

由于节点完全无状态,如果后续使用 K8s 进行部署,可以一口气拉起成千上万个爬虫服务节点,真正实现弹性伸缩。

可观测链路思想

这里介绍的是一个简易版的链路追踪实现,帮助理解 trace 的核心思路。生产环境推荐直接使用 OpenTelemetry,它为 Python 服务中常用的库(aio_pika、aiokafka、httpx、asyncio 等)提供了自动埋点的 instrumentation,完全不需要自己设计 trace 结构,配置好就能自动采集链路数据。阿里云也提供了托管的 OpenTelemetry 服务,可以参考《使用 OpenTelemetry 上报 Python 应用的 Trace 数据》快速接入。

对每个任务的完整生命周期做记录,出了问题能快速定位到哪个环节。主要关注三个维度:

(1)入参:任务从队列取出时的原始数据,记录"拿到了什么"

(2)内部状态:请求耗时、重试次数、HTTP 状态码、代理 IP 等,记录"过程中发生了什么"

(3)出参:最终采集结果或错误信息,记录"产出了什么"

这也是前面任务结构里 trace 字段的用途——每经过一个环节就往里追加一条记录,最终形成完整链路。重试次数不需要单独记录,len(task["trace"]) 即可得出。

示例代码:

task["trace"].append({
    "node": "Server-C",
    "step": "spider_social_html",
    "proxy": "http://192.168.10.10:8080",
    "status": 200,
    "duration_ms": 320,
    "start_timestamp": "2026-01-01T00:00:01.000Z",
    "response": {},
    "error": None,
})

实战排查:一条任务失败了怎么定位?

假设某个任务最终落库时发现数据为空,通过 Kafka 中存储的完整任务数据,查看 trace 字段:

{
  "id": "019c9e4a-7fcc-7c13-a317-e5d365eb5d4c",
  "trace": [
    {
      "node": "Server-B",
      "step": "spider_social_html",
      "status": 403,
      "duration_ms": 120,
      "proxy": "http://proxy-01:8080",
      "error": null,
      "response": "Access Denied"
    },
    {
      "node": "Server-D",
      "step": "spider_social_html",
      "status": 200,
      "duration_ms": 580,
      "proxy": "http://proxy-02:8080",
      "error": null,
      "response": {}
    }
  ]
}

从 trace 中可以直接看出:

(1)len(trace) = 2,说明这个任务经历了一次重试

(2)第一次在 Server-B 被 403 拦截(proxy-01 可能被封了)

(3)第二次在 Server-D 换了代理后 200 成功,但 response 为空——说明页面返回了 200 但内容为空,需要检查解析逻辑

不需要翻日志、不需要登录节点,一条 trace 就能还原完整经过。

采集数据的处理 —— Kafka 数据流

到这里,爬虫节点已经能稳定地拿到数据了。接下来的问题是:采集到的原始数据应该怎么处理?

爬虫本质上是一个获取原始数据的工具。它的使命是保障数据采集在再庞大的规模下都能稳定运行——而不是去做数据清洗、去重、格式转换这些事情。

所以爬虫节点只需要做一件事:将原始数据带上必要的元数据(platform、platform_type、timestamp 等),推送到 Kafka 中。Kafka 在这里充当的角色是大数据架构中 ODS(Operational Data Store)层的入口——爬虫把数据投进去,后续由 Flink 等流处理引擎从 Kafka 消费,进入数据清洗、转换、入仓的标准流程。

爬虫的使命到这一步就结束了。

为什么用 Kafka 作为这层缓冲?

(1)天然对接大数据生态

Kafka 是 Flink、Spark Streaming 等流处理框架的标准数据源。爬虫数据写入 Kafka 后,ODS 层可以直接消费,不需要额外的适配层。

(2)一份数据,多个下游各自独立消费

同一份爬虫数据可能同时需要进 ODS 入仓、做实时监控告警、触发其他业务流程。Kafka 的 Consumer Group 机制天然支持这种模式:不同 group 各自独立消费同一份数据,互不影响。

(3)Offset 回溯,数据不怕丢

Kafka 的消息消费后不会删除,可以通过 offset 随时回溯。比如下游 Flink 任务处理逻辑有 bug,修复后 reset offset 重新消费即可,不需要重新爬取。这对于采集成本高的数据(需要消耗代理 IP、账号配额等)尤其重要。

(4)Partition 并行,吞吐量线性扩展

Kafka 的 topic 可以分成多个 partition,不同消费者并行消费不同 partition,吞吐量随 partition 数线性增长。日均上亿条数据的写入量,Kafka 扛得住。

Python 连接 Kafka

封装了异步 Kafka 生产者和消费者,完整代码参考《附录 B: Kafka 客户端代码》。

使用示例

# 爬虫节点:原始数据 + 元数据写入 Kafka(ODS 层入口)
kafka = Kafka("192.168.10.10:9092")
await kafka.start()
await kafka.send("spider.result.social", result)

# 下游 Flink / 消费者从 Kafka 读取,进入数据清洗、入仓流程
consumer = KafkaConsumer("192.168.10.10:9092", "spider.result.social")
await consumer.start()
async for data in consumer.consume():
    await process_to_ods(data)

本地 Docker Compose 部署配置

前提需要安装 Docker,RabbitMQ 和 Kafka 的完整 Docker Compose 配置见附录 C

几个关键点:

(1)RabbitMQ 使用 rabbitmq:4-management 镜像,自带管理界面(15672 端口)。

(2)Kafka 使用 KRaft 模式(不依赖 ZooKeeper),搭配 kafka-ui 做可视化管理。

(3)Kafka 配置了内外网双 Listener,内部走 9092,外部走 9094。

总结

回顾整条链路:调度器将任务推送到 RabbitMQ,多个无状态爬虫节点竞争消费、执行采集,通过 trace 记录完整链路,最终将结果写入 Kafka 由下游异步落库。

两个消息队列各司其职:RabbitMQ 负责任务调度(消息级 ACK、Exchange 路由、轻量客户端),Kafka 负责数据流(partition 并行、offset 回溯、持久化堆积)。不是"用了两个队列",而是两个场景的需求本质不同,用最合适的工具解决各自的问题。爬虫的使命在数据写入 Kafka 时就结束了——后续的清洗、入仓、分析,交给 Flink 等流处理引擎在 ODS 层完成。而爬虫的本质就是稳定地为 ODS 提供数据。

这套架构的核心思路就三点:用消息队列解耦、用无状态实现弹性扩缩、用链路追踪保障可观测。理解了这些原理,换成任何技术栈都能搭出类似的分布式爬虫系统。

展望

最终实现的效果是:想爬取什么任务,只需要往 Exchange 打上合理的 routing_key,就能确保这个任务被爬取到,并进入 ODS 中。

至于 ODS 之后基于什么策略处理数据,这个留到下一篇再展开。有些场景可以做到全流式处理——比如链上数据,从出块到处理完成可以控制在 200ms 以内。有些特殊需求会追求更快,但这个"快"还受制于爬虫本身的网络性能,这里就不再额外展开了。

附录

附录 A: RabbitMQ 客户端完整代码

import aio_pika
from aio_pika import IncomingMessage
from pydantic import BaseModel
from typing import TypeVar, Any
from collections.abc import AsyncIterator
import orjson

_T = TypeVar("T", bound=BaseModel)


class RabbitMQ:
    """异步 RabbitMQ 客户端,封装连接、发送、消费三大核心操作"""

    def __init__(self, url: str, exchange: str | None = None):
        self._url = url
        self._exchange_name = exchange
        self._connection: aio_pika.RobustConnection | None = None
        self._channel: aio_pika.RobustChannel | None = None
        self._exchange: aio_pika.RobustExchange | None = None

    async def start(self):
        self._connection = await aio_pika.connect_robust(self._url)
        self._channel = await self._connection.channel()

        if self._exchange_name:
            self._exchange = await self._channel.get_exchange(self._exchange_name)
        else:
            self._exchange = self._channel.default_exchange

    async def close(self):
        if self._channel and not self._channel.is_closed:
            await self._channel.close()
        if self._connection and not self._connection.is_closed:
            await self._connection.close()

    async def __aenter__(self):
        await self.start()
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        await self.close()

    async def send(
        self,
        queue: str,
        message: dict | BaseModel | str | bytes,
        delivery_mode: int | None = None
    ):
        """发送消息,delivery_mode: 1=非持久化, 2=持久化"""
        if isinstance(message, dict):
            data = orjson.dumps(message)
        elif isinstance(message, BaseModel):
            data = orjson.dumps(message.model_dump())
        elif isinstance(message, bytes):
            data = message
        elif isinstance(message, str):
            data = message.encode()
        else:
            data = orjson.dumps(message)

        return await self._exchange.publish(
            aio_pika.Message(
                body=data,
                content_type="application/json",
                content_encoding="utf-8",
                delivery_mode=delivery_mode
            ),
            routing_key=queue
        )

    async def receive(
        self,
        queue: str,
        model: type[_T] | type[dict] | type[str] | type[bytes] = dict,
        prefetch_count: int = 16,
        strict: bool | None = None,
        context: dict[str, Any] | None = None,
    ) -> AsyncIterator[tuple[_T | dict | str | bytes, IncomingMessage]]:
        """异步迭代消费,返回 (解析后的数据, 原始消息) 元组"""
        async with self._connection.channel() as channel:
            await channel.set_qos(prefetch_count=prefetch_count)
            queue_obj = await channel.get_queue(queue, ensure=True)
            async with queue_obj.iterator() as queue_iter:
                if model is dict:
                    async for message in queue_iter:
                        yield orjson.loads(message.body), message
                elif model is str:
                    async for message in queue_iter:
                        yield message.body.decode(), message
                elif model is bytes:
                    async for message in queue_iter:
                        yield message.body, message
                else:
                    async for message in queue_iter:
                        yield model.model_validate_json(message.body, strict=strict, context=context), message

附录 B: Kafka 客户端完整代码

import orjson
from aiokafka import AIOKafkaProducer, AIOKafkaConsumer
from pydantic import BaseModel


class Kafka:
    """异步 Kafka 生产者"""

    def __init__(self, kafka_url: str):
        self.producer = AIOKafkaProducer(
            bootstrap_servers=kafka_url,
            value_serializer=lambda v: orjson.dumps(v),
        )

    async def start(self):
        await self.producer.start()

    async def close(self):
        await self.producer.stop()

    async def send(self, topic: str, message: dict | BaseModel):
        if isinstance(message, BaseModel):
            message = message.model_dump()
        await self.producer.send(topic, message)


class KafkaConsumer:
    """异步 Kafka 消费者"""

    def __init__(self, kafka_url: str, topic: str, group_id: str = "Consumer-A", auto_offset_reset: str = "earliest"):
        self.consumer = AIOKafkaConsumer(
            topic,
            bootstrap_servers=kafka_url,
            group_id=group_id,
            value_deserializer=lambda v: orjson.loads(v),
            auto_offset_reset=auto_offset_reset,
            enable_auto_commit=True,
            session_timeout_ms=60000,
            heartbeat_interval_ms=10000,
            max_poll_interval_ms=600000,
        )
        self.topic = topic

    async def start(self):
        await self.consumer.start()

    async def stop(self):
        await self.consumer.stop()

    async def consume(self):
        async for msg in self.consumer:
            yield msg.value

附录 C: 本地 Docker Compose 部署配置

前提需要安装 Docker:

root@spider ~# docker --version
Docker version 29.2.1, build a5c7197
root@spider ~# docker compose version
Docker Compose version v5.0.2

RabbitMQ:

services:
  rabbitmq:
    image: "rabbitmq:4-management"
    container_name: rabbitmq
    hostname: rabbitmq
    restart: always
    environment:
      RABBITMQ_DEFAULT_USER: admin
      RABBITMQ_DEFAULT_PASS: your_password
    volumes:
      - /var/lib/rabbitmq:/var/lib/rabbitmq
      - /var/log/rabbitmq:/var/log/rabbitmq
    ports:
      - "15672:15672"  # 管理界面
      - "5672:5672"    # AMQP 协议

Kafka:

services:
  kafka:
    image: apache/kafka:latest
    container_name: kafka
    restart: always
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_LISTENERS: INTERNAL://:9092,EXTERNAL://:9094,CONTROLLER://:9093
      KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:9092,EXTERNAL://your_ip:9094
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT
      KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
      KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
      KAFKA_NUM_PARTITIONS: 3
    ports:
      - "127.0.0.1:9092:9092"
      - "127.0.0.1:9093:9093"
      - "9094:9094"
    volumes:
      - /var/lib/kafka:/var/lib/kafka
    networks:
      - kafka-net

  kafka-ui:
    image: provectuslabs/kafka-ui:latest
    container_name: kafka-ui
    restart: always
    ports:
      - "18080:8080"
    environment:
      KAFKA_CLUSTERS_0_NAME: dev_cluster
      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092
      KAFKA_CLUSTERS_0_KRAFT_MODE: "true"
      AUTH_TYPE: LOGIN_FORM
      SPRING_SECURITY_USER_NAME: admin
      SPRING_SECURITY_USER_PASSWORD: your_password
    depends_on:
      - kafka
    networks:
      - kafka-net

networks:
  kafka-net:
    driver: bridge