Day 8: 分布式爬虫架构
学习目标
- 理解分布式爬虫的概念和架构
- 掌握 Scrapy-Redis 的使用
- 了解消息队列在爬虫中的应用
- 学会设计分布式任务调度
技术原理
8.1 分布式爬虫概念
分布式爬虫是将爬虫任务分布到多台机器上执行的系统。
主要优势:
- 提高爬取速度
- 提高系统容错能力
- 支持大规模数据采集
8.2 分布式架构
核心组件:
- 任务队列: 存储待爬取的 URL
- Worker 节点: 执行爬取任务
- 数据存储: 存储爬取结果
- 调度器: 分配任务给 Worker
8.3 Scrapy-Redis
Scrapy-Redis 是 Scrapy 的分布式扩展,使用 Redis 实现分布式任务调度。
主要功能:
- 去重过滤
- 请求队列
- 任务调度
8.4 消息队列
消息队列用于在分布式系统中传递消息:
常见消息队列:
- Redis: 轻量级,适合小规模
- RabbitMQ: 功能完善,适合企业级
- Kafka: 高吞吐量,适合大数据
案例
案例1:Scrapy-Redis 基础配置
python
# settings.py
# 启用 Redis 调度器
SCHEDULER = 'scrapy_redis.scheduler.Scheduler'
# 启用 Redis 去重
DUPEFILTER_CLASS = 'scrapy_redis.dupefilter.RFPDupeFilter'
# Redis 连接配置
REDIS_URL = 'redis://localhost:6379/0'
# 队列不清理(允许暂停和恢复)
SCHEDULER_PERSIST = True
# 并发设置
CONCURRENT_REQUESTS = 16
CONCURRENT_REQUESTS_PER_DOMAIN = 8案例2:Redis Spider
python
from scrapy_redis.spiders import RedisSpider
class DistributedSpider(RedisSpider):
"""分布式爬虫"""
name = 'distributed'
redis_key = 'distributed:start_urls'
def parse(self, response):
"""解析响应"""
# 提取数据
title = response.css('h1::text').get()
yield {
'url': response.url,
'title': title,
}
# 提取新链接
for link in response.css('a::attr(href)').getall():
yield response.follow(link, callback=self.parse)应用场景
1. 大规模数据采集
- 电商全站商品
- 新闻网站全量文章
- 社交媒体数据
2. 高可用爬虫系统
- 需要 7x24 小时运行
- 需要故障自动恢复
3. 多源数据聚合
- 从多个网站采集数据
- 统一数据格式
代码案例
案例3:完整的分布式爬虫
python
# items.py
import scrapy
class ProductItem(scrapy.Item):
"""商品 Item"""
name = scrapy.Field()
price = scrapy.Field()
url = scrapy.Field()
spider_name = scrapy.Field()
crawl_time = scrapy.Field()
# spiders/product_spider.py
from scrapy_redis.spiders import RedisSpider
from myproject.items import ProductItem
from datetime import datetime
class ProductSpider(RedisSpider):
"""分布式商品爬虫"""
name = 'product'
redis_key = 'product:start_urls'
# 自定义设置
custom_settings = {
'DOWNLOAD_DELAY': 1,
'CONCURRENT_REQUESTS': 8,
}
def parse(self, response):
"""解析商品列表"""
products = response.css('.product-item')
for product in products:
item = ProductItem()
item['name'] = product.css('.name::text').get()
item['price'] = product.css('.price::text').get()
item['url'] = product.css('a::attr(href)').get()
item['spider_name'] = self.name
item['crawl_time'] = datetime.now().isoformat()
yield item
# 跟踪下一页
next_page = response.css('.next-page::attr(href)').get()
if next_page:
yield response.follow(next_page, callback=self.parse)
# pipelines.py
import json
from datetime import datetime
class RedisPipeline:
"""Redis 管道"""
def __init__(self, redis_url):
self.redis_url = redis_url
@classmethod
def from_crawler(cls, crawler):
return cls(
redis_url=crawler.settings.get('REDIS_URL', 'redis://localhost:6379/0')
)
def open_spider(self, spider):
"""爬虫启动"""
import redis
self.redis_client = redis.from_url(self.redis_url)
self.key = f'{spider.name}:items'
def process_item(self, item, spider):
"""处理 Item"""
# 将 Item 转换为 JSON 并存储到 Redis
data = json.dumps(dict(item), ensure_ascii=False)
self.redis_client.rpush(self.key, data)
return item案例4:分布式任务调度器
python
import redis
import json
from typing import List, Dict
class TaskScheduler:
"""分布式任务调度器"""
def __init__(self, redis_url='redis://localhost:6379/0'):
self.redis_client = redis.from_url(redis_url)
self.task_queue = 'task_queue'
self.result_queue = 'result_queue'
def add_task(self, task_data: Dict):
"""添加任务"""
self.redis_client.rpush(self.task_queue, json.dumps(task_data))
def add_tasks(self, tasks: List[Dict]):
"""批量添加任务"""
pipeline = self.redis_client.pipeline()
for task in tasks:
pipeline.rpush(self.task_queue, json.dumps(task))
pipeline.execute()
def get_task(self) -> Dict:
"""获取任务"""
data = self.redis_client.lpop(self.task_queue)
if data:
return json.loads(data)
return None
def add_result(self, result: Dict):
"""添加结果"""
self.redis_client.rpush(self.result_queue, json.dumps(result))
def get_result(self) -> Dict:
"""获取结果"""
data = self.redis_client.lpop(self.result_queue)
if data:
return json.loads(data)
return None
def get_task_count(self) -> int:
"""获取任务数量"""
return self.redis_client.llen(self.task_queue)
def get_result_count(self) -> int:
"""获取结果数量"""
return self.redis_client.llen(self.result_queue)
# 使用示例
scheduler = TaskScheduler()
# 添加任务
urls = [f'https://example.com/page/{i}' for i in range(100)]
tasks = [{'url': url, 'priority': 1} for url in urls]
scheduler.add_tasks(tasks)
print(f'待处理任务: {scheduler.get_task_count()}')
# Worker 处理任务
while True:
task = scheduler.get_task()
if not task:
break
# 处理任务
result = {
'url': task['url'],
'status': 'success',
'data': '...'
}
# 保存结果
scheduler.add_result(result)
print(f'处理完成,结果数量: {scheduler.get_result_count()}')案例5:分布式 Worker
python
import requests
import json
import time
from concurrent.futures import ThreadPoolExecutor
class DistributedWorker:
"""分布式 Worker"""
def __init__(self, worker_id, redis_url='redis://localhost:6379/0'):
self.worker_id = worker_id
self.redis_client = redis.from_url(redis_url)
self.task_queue = 'task_queue'
self.result_queue = 'result_queue'
self.running = False
def fetch(self, url):
"""获取URL内容"""
try:
response = requests.get(url, timeout=10)
return {
'url': url,
'status': response.status_code,
'content': response.text[:1000]
}
except Exception as e:
return {
'url': url,
'error': str(e)
}
def process_task(self, task_data):
"""处理单个任务"""
task = json.loads(task_data)
url = task['url']
# 执行爬取
result = self.fetch(url)
result['worker_id'] = self.worker_id
result['task'] = task
# 保存结果
self.redis_client.rpush(self.result_queue, json.dumps(result))
return result
def run(self, max_workers=5):
"""运行 Worker"""
self.running = True
print(f'Worker {self.worker_id} 启动')
with ThreadPoolExecutor(max_workers=max_workers) as executor:
while self.running:
# 获取任务
task_data = self.redis_client.lpop(self.task_queue)
if task_data:
# 处理任务
executor.submit(self.process_task, task_data)
else:
# 没有任务,等待
time.sleep(1)
def stop(self):
"""停止 Worker"""
self.running = False
print(f'Worker {self.worker_id} 停止')
# 使用示例
if __name__ == '__main__':
worker = DistributedWorker(worker_id='worker-1')
try:
worker.run(max_workers=5)
except KeyboardInterrupt:
worker.stop()案例6:监控和管理
python
import redis
import json
from datetime import datetime
class CrawlerMonitor:
"""爬虫监控"""
def __init__(self, redis_url='redis://localhost:6379/0'):
self.redis_client = redis.from_url(redis_url)
def get_stats(self):
"""获取统计信息"""
stats = {
'pending_tasks': self.redis_client.llen('task_queue'),
'completed_results': self.redis_client.llen('result_queue'),
'timestamp': datetime.now().isoformat(),
}
# 获取最近的结果
recent_results = []
for i in range(min(10, self.redis_client.llen('result_queue'))):
data = self.redis_client.lindex('result_queue', i)
if data:
recent_results.append(json.loads(data))
stats['recent_results'] = recent_results
return stats
def get_worker_stats(self):
"""获取 Worker 统计"""
workers = {}
# 扫描所有结果,统计每个 Worker 的处理数量
results = self.redis_client.lrange('result_queue', 0, -1)
for data in results:
result = json.loads(data)
worker_id = result.get('worker_id', 'unknown')
workers[worker_id] = workers.get(worker_id, 0) + 1
return workers
def clear_queues(self):
"""清空队列"""
self.redis_client.delete('task_queue')
self.redis_client.delete('result_queue')
# 使用示例
monitor = CrawlerMonitor()
# 查看统计
stats = monitor.get_stats()
print(f'待处理任务: {stats["pending_tasks"]}')
print(f'已完成结果: {stats["completed_results"]}')
# 查看 Worker 统计
worker_stats = monitor.get_worker_stats()
for worker_id, count in worker_stats.items():
print(f'{worker_id}: {count} 个任务')案例7:Redis 去重过滤器
python
import hashlib
from urllib.parse import urlparse
class URLDedup:
"""URL 去重器"""
def __init__(self, redis_client, key='url_dedup'):
self.redis_client = redis_client
self.key = key
def _get_fingerprint(self, url):
"""获取 URL 指纹"""
# 标准化 URL
parsed = urlparse(url)
normalized = f'{parsed.scheme}://{parsed.netloc}{parsed.path}'
# 计算 MD5
return hashlib.md5(normalized.encode()).hexdigest()
def is_duplicate(self, url):
"""检查是否重复"""
fingerprint = self._get_fingerprint(url)
return self.redis_client.sismember(self.key, fingerprint)
def add_url(self, url):
"""添加 URL"""
fingerprint = self._get_fingerprint(url)
self.redis_client.sadd(self.key, fingerprint)
def get_count(self):
"""获取去重数量"""
return self.redis_client.scard(self.key)
# 使用示例
import redis
redis_client = redis.from_url('redis://localhost:6379/0')
dedup = URLDedup(redis_client)
urls = [
'https://example.com/page/1',
'https://example.com/page/2',
'https://example.com/page/1', # 重复
]
for url in urls:
if dedup.is_duplicate(url):
print(f'重复: {url}')
else:
dedup.add_url(url)
print(f'新增: {url}')
print(f'去重后数量: {dedup.get_count()}')课后练习
练习1:搭建分布式爬虫环境
安装 Redis 和 Scrapy-Redis,搭建分布式爬虫环境。
练习2:实现分布式图片爬虫
实现一个分布式图片爬虫,支持多台机器同时下载。
练习3:实现任务优先级调度
实现一个支持任务优先级的分布式调度器。
常见问题
Q1: 分布式爬虫需要多少台机器?
A: 取决于爬取规模,一般 3-5 台机器可以处理大多数场景。
Q2: 如何保证数据一致性?
A: 使用 Redis 的原子操作,或使用数据库事务。
Q3: 如何处理节点故障?
A: 设置任务超时,实现任务重试机制,使用心跳检测。