并发Web爬虫系统设计

Python并发编程专题 · 高并发数据采集的完整解决方案

专题:Python并发编程系统学习

关键词:Python, 并发编程, Web爬虫, 并发爬虫, aiohttp, Scrapy, 反爬虫, 速率限制

一、爬虫的并发需求

Web爬虫是典型的网络I/O密集型应用,其性能瓶颈主要在于网络请求的等待时间。当爬虫需要采集大量URL时,如果采用单线程串行方式逐个请求,大量的时间都浪费在等待网络响应上,CPU资源几乎处于空闲状态。例如,采集10,000个网页,每个请求平均耗时500ms,串行执行需要近1.4小时,而使用并发技术可以将时间缩短到几分钟甚至几十秒。

并发爬虫的核心思路是:在等待一个请求响应的同时,发起其他请求。Python提供了多种并发方案,包括多线程(threading)、异步协程(asyncio)和多进程(multiprocessing),每种方案都有其适用场景和权衡。对于I/O密集型任务,多线程和异步协程是最常使用的方案;对于CPU密集型任务(如页面解析、数据清洗),多进程或异步+进程池的组合更加合适。

核心原则:网络I/O密集型任务优先选择异步协程(最高并发数);需要调用同步库时选择多线程(简单易用);CPU密集型解析任务使用多进程或进程池。

二、多线程爬虫实现

多线程爬虫是入门并发爬虫最自然的方案。Python的concurrent.futures.ThreadPoolExecutor提供了高层次的线程池接口,配合requests库可以快速实现一个并发爬虫。线程池的最大工作线程数通常设置为CPU核心数的4-8倍,因为线程主要是在等待I/O,可以适当增加线程数量。

在实际爬虫项目中,还需要使用queue.Queue来管理URL队列,采用生产者-消费者模式:生产者线程不断发现新的URL并将其放入队列,消费者线程从队列中取出URL并发送请求。这种方式可以很好地控制爬取节奏,避免内存溢出,同时也便于实现断点续爬功能。

from concurrent.futures import ThreadPoolExecutor import requests def fetch(url): resp = requests.get(url, timeout=10) return resp.text urls = ["https://example.com/page1", "https://example.com/page2"] with ThreadPoolExecutor(max_workers=16) as exe: results = list(exe.map(fetch, urls))

注意:Python的GIL(全局解释器锁)在多线程场景下对CPU密集型任务有限制,但对I/O密集型爬虫任务影响很小,因为网络请求时会释放GIL。

三、异步协程爬虫实现

异步协程是Python并发爬虫的终极方案。使用aiohttp替代requests,配合asyncio事件循环,可以在单线程内管理成千上万个并发连接。与多线程相比,协程的上下文切换开销极小(微秒级),不需要线程切换的内核态开销,因此可以轻松支持数千甚至上万个并发请求。

在实现时,asyncio.Semaphore用于控制最大并发数,避免对目标服务器造成过大压力。asyncio.gather用于收集所有协程的执行结果。异步爬虫的性能通常比等量线程的多线程方案高出5-10倍,内存占用也更低,是生产环境的首选方案。

import asyncio import aiohttp semaphore = asyncio.Semaphore(100) async def fetch(session, url): async with semaphore: async with session.get(url) as resp: return await resp.text() async def main(urls): async with aiohttp.ClientSession() as session: tasks = [fetch(session, url) for url in urls] results = await asyncio.gather(*tasks) return results results = asyncio.run(main(urls))

四、速率限制与礼貌爬虫

并发爬虫虽然效率高,但如果不对请求速率加以控制,很容易对目标服务器造成负担,甚至触发DDoS防护机制。礼貌爬虫(Polite Crawler)的核心原则包括:遵守robots.txt规则、控制请求频率、设置合理的User-Agent标识、在非高峰时段爬取。

速率限制的常用实现方式有固定间隔法(每次请求后等待固定时间)和令牌桶算法(Token Bucket)。令牌桶算法允许短时间的突发请求,但长期平均速率受到限制,更加灵活。此外,asyncio.sleep可以精确控制异步协程的请求间隔,而time.sleep用于多线程场景。

import asyncio class RateLimiter: def __init__(self, rate): self.rate = rate # 每秒请求数 self.tokens = 0.0 self.last_check = asyncio.get_event_loop().time() async def acquire(self): now = asyncio.get_event_loop().time() self.tokens += (now - self.last_check) * self.rate self.last_check = now if self.tokens > self.rate: self.tokens = self.rate if self.tokens < 1: await asyncio.sleep(1 / self.rate) self.tokens = 0 else: self.tokens -= 1

五、URL去重与调度策略

在大规模爬虫系统中,URL去重是一个关键问题。同一个URL被重复抓取不仅浪费资源,还可能被服务器封禁。最基本的去重方式是使用Python的set集合,但当URL数量达到千万级别时,内存占用会变得不可接受。此时需要引入布隆过滤器(Bloom Filter),它使用位数组和多个哈希函数,以极低的内存占用实现高效的去重判断,代价是存在极小的误判率(False Positive)。

爬虫调度策略决定了爬取顺序。广度优先(BFS)搜索先抓取当前层级的所有链接,再进入下一层,适合需要全面覆盖的场景;深度优先(DFS)搜索沿着一条链接链深度挖掘,适合垂直领域的深入采集。实际系统中通常采用优先级队列(Priority Queue),结合URL的深度、域名、更新时间等多维指标进行调度。URL标准化也是重要一环,需要处理协议、路径、查询参数等规范化问题,避免同一页面因URL格式不同而被重复抓取。

from pybloom_live import BloomFilter # 布隆过滤器:1000万容量,0.001%误判率 bloom = BloomFilter(capacity=10_000_000, error_rate=0.00001) def is_visited(url): if url in bloom: return True bloom.add(url) return False

六、反爬策略应对

现代网站普遍部署了各种反爬虫机制,一个健壮的爬虫系统必须具备相应的应对策略。最常见的反爬手段包括:IP限制(同一IP短时间内大量请求被限流或封禁)、User-Agent检测(非浏览器请求被拒绝)、Cookie/Session验证(需要登录或维持会话)、JavaScript渲染(内容由JS动态加载)、验证码(CAPTCHA)等。

针对这些反爬机制,常用的应对策略有:构建User-Agent轮换池,模拟不同浏览器和操作系统;搭建代理IP池,通过住宅代理或数据中心代理分散请求来源;使用requests.Sessionaiohttp.ClientSession维持会话,处理Cookie;对于需要JavaScript渲染的页面,集成Playwright或Selenium进行动态渲染;对于验证码,可以接入第三方打码平台或使用OCR技术。需要注意的是,所有反爬应对手段都应在合法合规的框架内使用,尊重目标网站的使用条款。

import random USER_AGENTS = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0", "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) Safari/17.1", "Mozilla/5.0 (X11; Linux x86_64) Firefox/121.0", ] headers = { "User-Agent": random.choice(USER_AGENTS), "Accept": "text/html,application/xhtml+xml", "Accept-Language": "zh-CN,zh;q=0.9", "Referer": "https://www.google.com/", }

七、分布式爬虫架构

当单机爬虫的性能达到瓶颈(网络带宽、内存、CPU),或者需要采集海量数据时,分布式爬虫架构就成为了必然选择。分布式爬虫的核心思想是将爬取任务拆分到多台机器上并行执行,通过一个中央协调者来管理任务分配和状态同步。Redis因其高性能的内存数据结构,成为分布式爬虫任务队列的首选组件。

Scrapy框架配合Scrapy-Redis插件是最流行的Python分布式爬虫解决方案。Scrapy-Redis使用Redis替换了Scrapy原生的调度器和去重过滤器,实现了多台爬虫机器共享同一个URL队列和去重集合。每台爬虫从Redis中获取任务,下载页面并提取数据,然后将新发现的URL放回Redis队列。采集结果可以通过消息队列(如Kafka、RabbitMQ)汇聚到中央存储系统,实现数据的统一处理。

# Scrapy-Redis 配置示例 # settings.py SCHEDULER = "scrapy_redis.scheduler.Scheduler" DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter" REDIS_URL = "redis://redis-host:6379" SCHEDULER_PERSIST = True # 爬虫中断后不丢失队列 CONCURRENT_REQUESTS = 64 # 分布式爬虫节点自动从Redis获取URL,无需额外配置

架构决策:小规模采集(千级URL)用异步协程单机即可;中等规模(万级到百万级)可考虑多机异步协程+Redis队列;超大规模(千万级以上)需要完整的分布式架构,包括任务调度、数据存储、监控告警等配套设施。