1. 为什么需要多进程爬虫架构
当我们需要从网站上抓取大量数据时,单线程爬虫的效率往往难以满足需求。以一个简单的例子来说,假设我们要抓取一个电商网站上的10万件商品信息,如果使用单线程爬虫,每个请求需要500ms(包括网络请求和数据处理时间),那么总共需要约14小时才能完成。而如果使用4个进程并行处理,理论上可以将时间缩短到3.5小时左右。
多进程爬虫的核心优势在于:
- 充分利用多核CPU的计算能力
- 避免Python的GIL(全局解释器锁)对多线程爬虫的性能限制
- 单个进程崩溃不会影响其他进程的运行
- 更容易实现任务的分片和负载均衡
注意:虽然多线程爬虫也能实现并发,但在CPU密集型任务中(如HTML解析、数据清洗),多进程架构通常能提供更好的性能表现。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 多进程爬虫的核心组件设计
2.1 任务队列系统
任务队列是多进程爬虫的中枢神经系统。我推荐使用Redis作为任务队列后端,原因如下:
- 支持原子操作,避免任务被多个worker重复获取
- 持久化能力,防止任务丢失
- 高性能,轻松支持数万QPS
一个典型的任务队列实现如下:
python复制import redis
class TaskQueue:
def __init__(self, redis_host='localhost', redis_port=6379):
self.redis = redis.StrictRedis(
host=redis_host,
port=redis_port,
decode_responses=True
)
def add_task(self, queue_name, task_data):
"""添加任务到队列"""
return self.redis.lpush(queue_name, task_data)
def get_task(self, queue_name, timeout=30):
"""从队列获取任务"""
return self.redis.brpop(queue_name, timeout=timeout)
2.2 进程管理模块
Python的multiprocessing模块提供了强大的进程管理能力。在实际项目中,我通常会创建一个进程池来管理工作进程:
python复制from multiprocessing import Pool, cpu_count
import time
def worker(task):
"""工作进程函数"""
print(f"Processing task: {task}")
time.sleep(1) # 模拟处理时间
return f"Processed {task}"
if __name__ == '__main__':
tasks = [f"task_{i}" for i in range(10)]
# 建议使用CPU核心数作为进程数
with Pool(processes=cpu_count()) as pool:
results = pool.map(worker, tasks)
print(results)
2.3 数据存储模块
多进程爬虫需要特别注意数据存储的线程安全问题。以下是几个常见方案:
-
数据库方案:
- MySQL/PostgreSQL:适合结构化数据
- MongoDB:适合半结构化数据
- 使用连接池管理数据库连接
-
文件存储方案:
- 每个进程写入独立文件,最后合并
- 使用文件锁确保写入安全
python复制import sqlite3
from contextlib import contextmanager
@contextmanager
def get_db_connection():
conn = sqlite3.connect('crawler.db', check_same_thread=False)
try:
yield conn
finally:
conn.close()
3. 实战:构建一个完整的多进程爬虫
3.1 项目结构设计
code复制multi_process_crawler/
├── config.py # 配置文件
├── crawler.py # 爬虫核心逻辑
├── db_handler.py # 数据存储处理
├── main.py # 主入口
├── requirements.txt # 依赖文件
└── utils.py # 工具函数
3.2 核心代码实现
python复制# crawler.py
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
import time
import random
class BaseCrawler:
def __init__(self, base_url, max_retry=3):
self.base_url = base_url
self.max_retry = max_retry
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
def fetch_page(self, url, params=None):
"""获取页面内容"""
for _ in range(self.max_retry):
try:
resp = self.session.get(url, params=params, timeout=10)
resp.raise_for_status()
return resp.text
except Exception as e:
print(f"Error fetching {url}: {e}")
time.sleep(random.uniform(1, 3))
return None
def parse_links(self, html):
"""解析页面中的链接"""
soup = BeautifulSoup(html, 'html.parser')
links = set()
for a in soup.find_all('a', href=True):
link = urljoin(self.base_url, a['href'])
if self.is_valid_link(link):
links.add(link)
return links
def is_valid_link(self, link):
"""验证链接是否有效"""
return link.startswith(self.base_url)
3.3 多进程调度实现
python复制# main.py
from multiprocessing import Process, Queue
from crawler import BaseCrawler
import time
import random
def worker(task_queue, result_queue, crawler_class):
"""工作进程"""
crawler = crawler_class()
while True:
task = task_queue.get()
if task is None: # 终止信号
break
try:
result = crawler.process_task(task)
result_queue.put(result)
except Exception as e:
print(f"Error processing task {task}: {e}")
task_queue.put(task) # 重新放回队列
time.sleep(random.uniform(1, 5))
class MultiProcessCrawler:
def __init__(self, crawler_class, num_workers=4):
self.crawler_class = crawler_class
self.num_workers = num_workers
self.task_queue = Queue()
self.result_queue = Queue()
self.workers = []
def start(self, initial_tasks):
"""启动爬虫"""
# 初始化任务队列
for task in initial_tasks:
self.task_queue.put(task)
# 启动工作进程
for _ in range(self.num_workers):
p = Process(
target=worker,
args=(self.task_queue, self.result_queue, self.crawler_class)
)
p.start()
self.workers.append(p)
# 添加终止信号
for _ in range(self.num_workers):
self.task_queue.put(None)
# 等待所有工作进程完成
for p in self.workers:
p.join()
4. 高级优化与常见问题解决
4.1 性能优化技巧
-
连接复用:
- 使用
requests.Session()保持HTTP连接 - 设置合理的连接池大小
- 使用
-
智能限速:
python复制class RateLimiter: def __init__(self, max_calls, period): self.max_calls = max_calls self.period = period self.timestamps = [] def __call__(self): now = time.time() self.timestamps = [t for t in self.timestamps if t > now - self.period] if len(self.timestamps) >= self.max_calls: time.sleep(self.period - (now - self.timestamps[0])) self.timestamps.append(time.time()) -
内存优化:
- 使用生成器而非列表处理大量数据
- 定期将数据写入磁盘释放内存
4.2 反爬虫策略应对
-
User-Agent轮换:
python复制USER_AGENTS = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', # 更多User-Agent... ] def get_random_user_agent(): return random.choice(USER_AGENTS) -
IP代理池:
- 使用付费代理服务
- 自建代理服务器集群
- 实现代理健康检查机制
-
请求间隔随机化:
python复制time.sleep(random.uniform(0.5, 3)) # 随机等待0.5-3秒
4.3 错误处理与容灾
-
任务重试机制:
- 记录失败任务
- 指数退避重试
- 设置最大重试次数
-
进程监控:
- 使用
multiprocessing.Manager共享状态 - 实现心跳检测机制
- 使用
-
断点续爬:
- 定期保存爬取进度
- 实现任务检查点(checkpoint)
python复制class Checkpoint:
def __init__(self, file_path):
self.file_path = file_path
def save(self, data):
with open(self.file_path, 'w') as f:
json.dump(data, f)
def load(self):
try:
with open(self.file_path) as f:
return json.load(f)
except FileNotFoundError:
return None
5. 实际项目中的经验分享
在开发多进程爬虫的实践中,我总结了一些宝贵的经验教训:
-
进程数不是越多越好:
- 最佳进程数通常为CPU核心数的1-2倍
- 过多的进程会导致上下文切换开销增加
- 可以通过压力测试找到最优进程数
-
任务分配策略:
- 大任务拆分为小任务,避免负载不均衡
- 动态任务分配优于静态分配
- 实现工作窃取(work stealing)算法提高效率
-
日志记录至关重要:
- 每个进程应有独立日志文件
- 记录详细的错误信息便于排查
- 实现日志轮转防止日志文件过大
python复制import logging
from logging.handlers import RotatingFileHandler
def setup_logger(name, log_file, level=logging.INFO):
"""配置日志记录器"""
formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
handler = RotatingFileHandler(
log_file, maxBytes=10*1024*1024, backupCount=5
)
handler.setFormatter(formatter)
logger = logging.getLogger(name)
logger.setLevel(level)
logger.addHandler(handler)
return logger
-
测试策略:
- 单元测试:测试单个页面解析逻辑
- 集成测试:测试完整爬取流程
- 压力测试:模拟高并发场景
- 异常测试:模拟网络故障等情况
-
法律与道德考量:
- 遵守网站的robots.txt规则
- 尊重版权和隐私
- 控制请求频率避免对目标网站造成负担
