从爬虫的本质推导架构
爬虫的本质是什么?是按照既定流程获取数据的服务。
既然是服务,就需要有人告诉它"该做什么"——这就是任务调度。调度器负责生成任务、分发任务,爬虫节点只管执行。
进一步想:如果调度器统一管理任务状态(分发、确认、重试),爬虫节点就不需要关心"做到哪了",它只需要 "拿任务→执行→报告结果" 。这意味着节点是无状态的——挂了重启就能继续消费,随时可以加机器水平扩容。
但调度器和爬虫节点之间不能直接通信,否则调度器就要维护所有节点的连接、感知节点上下线,耦合太重。解决方案是在中间加一层消息队列:调度器往队列里投任务,节点从队列里竞争消费,彼此完全解耦。
最后,爬虫节点采集到的数据不能直接写数据库。高并发下几十个节点同时写库,数据库很容易成为瓶颈。所以需要一层数据流缓冲:节点把结果投到数据流中间件,下游消费者异步落库。
把这些推导串起来,就是整套架构:
接下来逐一展开每个模块的设计和实现。
任务调度
任务调度器的角色是生产者,负责将需要请求的目标 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_ippool1,social.userpool1.*绑定到queue_social_userpool1
调度器发送时,routing_key 是具体的,比如 social.ippool1.000123(最后一段是业务 ID),消息会自动经过两层 Exchange 分流到对应队列。新增平台或资源池只需添加 Exchange 绑定,已有代码不需要改动。
这一点非常关键。做复杂路由这种任务,RabbitMQ 天然就是能手。
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