在网络爬虫开发中,限速与并发控制是保障爬虫稳定性、合法性及目标网站友好性的核心技术。不合理的请求频率可能导致 IP 被封禁、服务器拒绝响应,甚至引发法律风险;而过度保守的控制则会大幅降低爬取效率。本文将深入解析爬虫领域最常用的三种限速与并发控制方案 ------ 令牌桶算法、漏桶算法、动态调整策略,结合原理、实现场景与实战代码,帮助开发者构建高效且安全的爬虫系统。
一、核心问题:为什么必须做限速与并发控制?
在讨论具体算法前,我们需要明确限速与并发控制的核心价值,避免陷入 "为了控制而控制" 的误区:
- 合规性要求 :多数网站的
robots.txt协议会明确限制爬虫的请求频率,违反协议可能被认定为恶意爬取,面临法律追责; - 反爬机制规避:高频次、规律性的请求是网站反爬的核心检测指标,限速可降低 IP 封禁、验证码拦截的概率;
- 服务器负载保护:即使目标网站未明确限制,过度并发也可能给服务器带来额外负载,违背 "友好爬虫" 原则;
- 爬取稳定性保障:合理控制并发数可避免本地线程 / 进程拥堵、网络超时等问题,提升数据爬取的完整性。
限速与并发控制的本质是:在 "爬取效率" 与 "风险控制" 之间寻找平衡点,根据目标网站的承受能力动态调整请求策略。
二、经典算法:令牌桶与漏桶的原理与实现
令牌桶和漏桶是工业界广泛应用的流量控制算法,二者核心逻辑不同,适用于不同的爬虫场景。
(一)令牌桶算法:允许突发请求的柔性控制
1. 核心原理
- 系统以固定速率(如 10 个 / 秒)向 "令牌桶" 中放入令牌,桶内令牌数量有上限(如最大 50 个);
- 爬虫发起请求前,必须从桶中获取 1 个令牌,获取成功则执行请求,否则等待或拒绝;
- 若桶内令牌已满,新生成的令牌会被丢弃;若爬虫请求频率低于令牌生成速率,桶内令牌会累积,允许后续突发高并发请求。
2. 适用场景
- 目标网站对突发请求容忍度较高,如电商平台、新闻资讯网站;
- 爬虫需要灵活调整并发,如遇到分页较多的列表页时,可利用累积的令牌快速爬取。
3. Python 实战实现(基于threading)
python
运行
import time
import threading
from typing import Optional
class TokenBucket:
def __init__(self, rate: float, capacity: int):
"""
初始化令牌桶
:param rate: 令牌生成速率(个/秒)
:param capacity: 令牌桶最大容量
"""
self.rate = rate # 令牌生成速率
self.capacity = capacity # 桶最大容量
self.tokens = capacity # 当前令牌数
self.last_refill_time = time.time() # 上次补充令牌时间
self.lock = threading.Lock() # 线程安全锁
def _refill(self):
"""补充令牌(线程安全)"""
now = time.time()
# 计算距离上次补充的时间差,生成对应数量的令牌
time_passed = now - self.last_refill_time
new_tokens = time_passed * self.rate
if new_tokens > 0:
self.tokens = min(self.capacity, self.tokens + new_tokens)
self.last_refill_time = now
def acquire(self, timeout: Optional[float] = None) -> bool:
"""
获取1个令牌
:param timeout: 超时时间(秒),None表示无限等待
:return: 是否获取成功
"""
start_time = time.time()
while True:
with self.lock:
self._refill()
if self.tokens >= 1:
self.tokens -= 1
return True
# 未获取到令牌,等待后重试
if timeout is not None and (time.time() - start_time) >= timeout:
return False
time.sleep(0.001) # 短暂休眠,减少CPU占用
# 爬虫中使用令牌桶限速
import requests
token_bucket = TokenBucket(rate=5, capacity=20) # 5个/秒,最大20个令牌
urls = [f"https://example.com/page{i}" for i in range(100)]
def crawl_url(url):
if token_bucket.acquire(timeout=5):
try:
response = requests.get(url)
print(f"成功爬取:{url},状态码:{response.status_code}")
except Exception as e:
print(f"爬取失败:{url},错误:{str(e)}")
else:
print(f"获取令牌超时,放弃爬取:{url}")
# 多线程并发爬取
threads = []
for url in urls:
t = threading.Thread(target=crawl_url, args=(url,))
threads.append(t)
t.start()
for t in threads:
t.join()
(二)漏桶算法:严格限制速率的刚性控制
1. 核心原理
- 把爬虫请求比作 "水流","漏桶" 是固定容量的缓冲池;
- 请求进入漏桶后,以固定速率(如 5 个 / 秒)被 "漏出" 并执行,超出桶容量的请求会被丢弃或排队;
- 无论输入流量是否突发,输出速率始终保持恒定,严格限制请求频率。
2. 适用场景
- 目标网站反爬严格,对请求频率稳定性要求高,如政府网站、API 接口;
- 避免爬虫因突发请求导致服务器压力骤增,如爬取小型网站或个人博客。
3. Python 实战实现(基于queue和threading)
python
运行
import time
import threading
import queue
from typing import Optional
class LeakyBucket:
def __init__(self, rate: float, capacity: int):
"""
初始化漏桶
:param rate: 请求漏出速率(个/秒)
:param capacity: 漏桶最大容量
"""
self.rate = rate # 漏出速率
self.capacity = capacity # 桶最大容量
self.queue = queue.Queue(maxsize=capacity) # 存储待执行请求的队列
self.thread = threading.Thread(target=self._leak, daemon=True)
self.thread.start()
def _leak(self):
"""以固定速率漏出请求并执行"""
while True:
if not self.queue.empty():
task = self.queue.get()
task() # 执行爬取任务
self.queue.task_done()
# 控制漏出速率
time.sleep(1 / self.rate)
def submit(self, task, timeout: Optional[float] = None) -> bool:
"""
提交请求任务到漏桶
:param task: 爬取任务(函数)
:param timeout: 超时时间(秒),None表示无限等待
:return: 是否提交成功
"""
try:
self.queue.put(task, timeout=timeout)
return True
except queue.Full:
return False
# 爬虫中使用漏桶限速
import requests
leaky_bucket = LeakyBucket(rate=3, capacity=10) # 3个/秒,最大缓存10个请求
urls = [f"https://example.com/page{i}" for i in range(100)]
def create_crawl_task(url):
def task():
try:
response = requests.get(url)
print(f"成功爬取:{url},状态码:{response.status_code}")
except Exception as e:
print(f"爬取失败:{url},错误:{str(e)}")
return task
# 提交任务到漏桶
for url in urls:
task = create_crawl_task(url)
if leaky_bucket.submit(task, timeout=3):
print(f"任务提交成功:{url}")
else:
print(f"漏桶已满,放弃提交:{url}")
# 等待所有任务执行完毕
leaky_bucket.queue.join()
(三)令牌桶与漏桶的核心区别
| 特性 | 令牌桶算法 | 漏桶算法 |
|---|---|---|
| 速率控制方式 | 柔性控制,允许突发请求 | 刚性控制,速率严格恒定 |
| 令牌 / 请求处理逻辑 | 先拿令牌再请求,令牌可累积 | 先入桶再漏出,请求不累积 |
| 适用场景 | 对突发请求容忍度高的网站 | 反爬严格、速率敏感的网站 |
| 资源占用 | 低(无需缓存大量请求) | 高(需缓存超出速率的请求) |
| 实现复杂度 | 中等(需处理令牌补充逻辑) | 简单(基于队列 + 固定休眠) |
三、高级策略:动态调整的智能控制方案
经典算法的速率的是固定的,但实际爬取场景中,目标网站的负载、反爬策略可能动态变化(如高峰期限流更严格、低峰期可提升速率)。动态调整策略通过实时监控系统状态,自动优化限速与并发参数,实现 "智能爬取"。
(一)动态调整的核心依据
- 响应状态码:频繁出现 429(请求过多)、503(服务不可用)时,降低请求速率;状态码持续 200 时,可适度提升速率;
- 响应时间:响应时间延长(如从 100ms 增至 500ms),说明服务器负载升高,需降低并发;响应时间稳定且较短时,可增加并发;
- IP 封禁风险:检测到验证码、重定向到登录页时,立即降低速率或暂停爬取;
- 本地资源状态:监控 CPU、内存、网络带宽使用率,避免本地资源耗尽导致爬虫崩溃。
(二)动态调整的实现逻辑
- 初始化基础速率(如
base_rate=5)和并发数(如base_concurrency=10); - 爬取过程中,实时统计状态码分布、平均响应时间等指标;
- 设定调整阈值(如:429 状态码占比 > 10%,则速率降低 20%;响应时间 < 200ms 且无 429,则速率提升 10%);
- 加入防抖机制(如:每次调整后间隔 30 秒再判断,避免频繁波动);
- 设定速率 / 并发数的上下限(如:最小速率 = 1,最大并发 = 50),防止极端情况。
(三)Python 实战实现(结合令牌桶的动态调整)
python
运行
import time
import threading
import requests
from collections import defaultdict
class DynamicTokenBucket:
def __init__(self, base_rate: float, max_rate: float, min_rate: float, capacity: int):
"""
动态调整令牌桶
:param base_rate: 基础令牌生成速率(个/秒)
:param max_rate: 最大速率
:param min_rate: 最小速率
:param capacity: 令牌桶最大容量
"""
self.base_rate = base_rate
self.max_rate = max_rate
self.min_rate = min_rate
self.rate = base_rate # 当前速率(动态调整)
self.capacity = capacity
self.tokens = capacity
self.last_refill_time = time.time()
self.lock = threading.Lock()
# 统计指标
self.stats = defaultdict(int) # 状态码统计:{200: 100, 429: 5...}
self.response_times = [] # 响应时间列表
self.last_adjust_time = time.time() # 上次调整时间
self.adjust_interval = 30 # 调整间隔(秒)
def _refill(self):
"""补充令牌"""
with self.lock:
now = time.time()
time_passed = now - self.last_refill_time
new_tokens = time_passed * self.rate
if new_tokens > 0:
self.tokens = min(self.capacity, self.tokens + new_tokens)
self.last_refill_time = now
def acquire(self, timeout: float = None) -> bool:
"""获取令牌"""
start_time = time.time()
while True:
self._refill()
with self.lock:
if self.tokens >= 1:
self.tokens -= 1
return True
if timeout and (time.time() - start_time) >= timeout:
return False
time.sleep(0.001)
def update_stats(self, status_code: int, response_time: float):
"""更新统计指标"""
self.stats[status_code] += 1
self.response_times.append(response_time)
# 限制响应时间列表长度,避免内存占用
if len(self.response_times) > 100:
self.response_times.pop(0)
# 检查是否需要调整速率
self._adjust_rate()
def _adjust_rate(self):
"""动态调整令牌生成速率"""
now = time.time()
# 未到调整间隔,跳过
if now - self.last_adjust_time < self.adjust_interval:
return
with self.lock:
total_requests = sum(self.stats.values())
if total_requests < 20: # 样本不足,不调整
return
# 计算429状态码占比
too_many_requests = self.stats.get(429, 0)
too_many_ratio = too_many_requests / total_requests
# 计算平均响应时间
avg_response_time = sum(self.response_times) / len(self.response_times)
# 调整逻辑
if too_many_ratio > 0.1:
# 429占比过高,降低20%速率
self.rate = max(self.min_rate, self.rate * 0.8)
print(f"429状态码占比过高({too_many_ratio:.1%}),速率调整为:{self.rate:.1f}")
elif avg_response_time < 0.2 and too_many_ratio == 0:
# 响应时间短且无429,提升10%速率
self.rate = min(self.max_rate, self.rate * 1.1)
print(f"响应时间正常({avg_response_time:.3f}s),速率调整为:{self.rate:.1f}")
# 其他情况,保持当前速率
# 重置统计指标和调整时间
self.stats.clear()
self.response_times.clear()
self.last_adjust_time = now
# 动态调整爬虫实战
dynamic_bucket = DynamicTokenBucket(
base_rate=5, max_rate=20, min_rate=1, capacity=30
)
urls = [f"https://example.com/page{i}" for i in range(200)]
def crawl_url(url):
if dynamic_bucket.acquire(timeout=10):
start_time = time.time()
try:
response = requests.get(url, timeout=10)
response_time = time.time() - start_time
# 更新统计指标
dynamic_bucket.update_stats(response.status_code, response_time)
print(f"爬取成功:{url},状态码:{response.status_code},响应时间:{response_time:.3f}s")
except Exception as e:
print(f"爬取失败:{url},错误:{str(e)}")
# 多线程爬取
threads = []
for url in urls:
t = threading.Thread(target=crawl_url, args=(url,))
threads.append(t)
t.start()
for t in threads:
t.join()
四、实战优化:限速与并发控制的关键技巧
- 结合代理 IP 池:即使做了限速,单一 IP 的请求频率仍可能触发反爬,搭配代理 IP 池(如动态换 IP),可进一步提升爬取效率;
- 尊重
robots.txt协议 :在爬取前解析目标网站的robots.txt,提取Crawl-delay(爬取延迟)参数,以此作为基础速率的参考; - 分场景控制:对不同页面采取不同策略(如:列表页速率高、详情页速率低;静态页并发高、动态接口并发低);
- 异常处理机制:遇到 429 时,除了降低速率,还可加入随机休眠(如 10-30 秒),或切换代理 IP 后重试;
- 使用成熟框架 :实际开发中可直接使用
Scrapy的AutoThrottle扩展、aiohttp的TCPConnector并发控制,无需重复造轮子。
五、总结
爬虫的限速与并发控制是一门 "平衡的艺术",令牌桶、漏桶算法解决了 "固定速率控制" 的基础问题,而动态调整策略则实现了 "自适应场景变化" 的高级需求。在实际开发中,需根据目标网站的反爬强度、自身爬取需求,灵活选择或组合使用这些方案:
- 简单场景:直接使用漏桶算法,快速实现刚性限速;
- 灵活场景:使用令牌桶算法,兼顾效率与突发请求;
- 复杂场景:基于令牌桶 / 漏桶扩展动态调整逻辑,结合监控指标实现智能爬取。
同时,必须始终坚守 "友好爬虫" 的原则,尊重网站的 robots 协议和服务条款,避免给目标服务器带来不必要的压力。只有在合规、安全的前提下,才能实现爬虫的长期稳定运行。