爬虫限速与并发控制:令牌桶、漏桶、动态调整全解析

在网络爬虫开发中,限速与并发控制是保障爬虫稳定性、合法性及目标网站友好性的核心技术。不合理的请求频率可能导致 IP 被封禁、服务器拒绝响应,甚至引发法律风险;而过度保守的控制则会大幅降低爬取效率。本文将深入解析爬虫领域最常用的三种限速与并发控制方案 ------ 令牌桶算法、漏桶算法、动态调整策略,结合原理、实现场景与实战代码,帮助开发者构建高效且安全的爬虫系统。

一、核心问题:为什么必须做限速与并发控制?

在讨论具体算法前,我们需要明确限速与并发控制的核心价值,避免陷入 "为了控制而控制" 的误区:

  1. 合规性要求 :多数网站的robots.txt协议会明确限制爬虫的请求频率,违反协议可能被认定为恶意爬取,面临法律追责;
  2. 反爬机制规避:高频次、规律性的请求是网站反爬的核心检测指标,限速可降低 IP 封禁、验证码拦截的概率;
  3. 服务器负载保护:即使目标网站未明确限制,过度并发也可能给服务器带来额外负载,违背 "友好爬虫" 原则;
  4. 爬取稳定性保障:合理控制并发数可避免本地线程 / 进程拥堵、网络超时等问题,提升数据爬取的完整性。

限速与并发控制的本质是:在 "爬取效率" 与 "风险控制" 之间寻找平衡点,根据目标网站的承受能力动态调整请求策略。

二、经典算法:令牌桶与漏桶的原理与实现

令牌桶和漏桶是工业界广泛应用的流量控制算法,二者核心逻辑不同,适用于不同的爬虫场景。

(一)令牌桶算法:允许突发请求的柔性控制

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 实战实现(基于queuethreading

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()

(三)令牌桶与漏桶的核心区别

特性 令牌桶算法 漏桶算法
速率控制方式 柔性控制,允许突发请求 刚性控制,速率严格恒定
令牌 / 请求处理逻辑 先拿令牌再请求,令牌可累积 先入桶再漏出,请求不累积
适用场景 对突发请求容忍度高的网站 反爬严格、速率敏感的网站
资源占用 低(无需缓存大量请求) 高(需缓存超出速率的请求)
实现复杂度 中等(需处理令牌补充逻辑) 简单(基于队列 + 固定休眠)

三、高级策略:动态调整的智能控制方案

经典算法的速率的是固定的,但实际爬取场景中,目标网站的负载、反爬策略可能动态变化(如高峰期限流更严格、低峰期可提升速率)。动态调整策略通过实时监控系统状态,自动优化限速与并发参数,实现 "智能爬取"。

(一)动态调整的核心依据

  1. 响应状态码:频繁出现 429(请求过多)、503(服务不可用)时,降低请求速率;状态码持续 200 时,可适度提升速率;
  2. 响应时间:响应时间延长(如从 100ms 增至 500ms),说明服务器负载升高,需降低并发;响应时间稳定且较短时,可增加并发;
  3. IP 封禁风险:检测到验证码、重定向到登录页时,立即降低速率或暂停爬取;
  4. 本地资源状态:监控 CPU、内存、网络带宽使用率,避免本地资源耗尽导致爬虫崩溃。

(二)动态调整的实现逻辑

  1. 初始化基础速率(如base_rate=5)和并发数(如base_concurrency=10);
  2. 爬取过程中,实时统计状态码分布、平均响应时间等指标;
  3. 设定调整阈值(如:429 状态码占比 > 10%,则速率降低 20%;响应时间 < 200ms 且无 429,则速率提升 10%);
  4. 加入防抖机制(如:每次调整后间隔 30 秒再判断,避免频繁波动);
  5. 设定速率 / 并发数的上下限(如:最小速率 = 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()

四、实战优化:限速与并发控制的关键技巧

  1. 结合代理 IP 池:即使做了限速,单一 IP 的请求频率仍可能触发反爬,搭配代理 IP 池(如动态换 IP),可进一步提升爬取效率;
  2. 尊重robots.txt协议 :在爬取前解析目标网站的robots.txt,提取Crawl-delay(爬取延迟)参数,以此作为基础速率的参考;
  3. 分场景控制:对不同页面采取不同策略(如:列表页速率高、详情页速率低;静态页并发高、动态接口并发低);
  4. 异常处理机制:遇到 429 时,除了降低速率,还可加入随机休眠(如 10-30 秒),或切换代理 IP 后重试;
  5. 使用成熟框架 :实际开发中可直接使用ScrapyAutoThrottle扩展、aiohttpTCPConnector并发控制,无需重复造轮子。

五、总结

爬虫的限速与并发控制是一门 "平衡的艺术",令牌桶、漏桶算法解决了 "固定速率控制" 的基础问题,而动态调整策略则实现了 "自适应场景变化" 的高级需求。在实际开发中,需根据目标网站的反爬强度、自身爬取需求,灵活选择或组合使用这些方案:

  • 简单场景:直接使用漏桶算法,快速实现刚性限速;
  • 灵活场景:使用令牌桶算法,兼顾效率与突发请求;
  • 复杂场景:基于令牌桶 / 漏桶扩展动态调整逻辑,结合监控指标实现智能爬取。

同时,必须始终坚守 "友好爬虫" 的原则,尊重网站的 robots 协议和服务条款,避免给目标服务器带来不必要的压力。只有在合规、安全的前提下,才能实现爬虫的长期稳定运行。

相关推荐
爱打代码的小林4 小时前
网络爬虫基础
爬虫·python
B站计算机毕业设计之家4 小时前
大数据:基于python唯品会商品数据可视化分析系统 Flask框架 requests爬虫 Echarts可视化 数据清洗 大数据技术(源码+文档)✅
大数据·爬虫·python·信息可视化·spark·flask·唯品会
Data_agent14 小时前
1688获得1688店铺详情API,python请求示例
开发语言·爬虫·python
是有头发的程序猿16 小时前
如何设计一个基于类的爬虫框架
爬虫
小尘要自信21 小时前
爬虫入门与实战:从原理到实践的完整指南
爬虫
sugar椰子皮21 小时前
【爬虫框架-0】从一个真实需求说起
爬虫
月光技术杂谈1 天前
基于Python+Selenium的淘宝商品信息智能采集实践:从浏览器控制到反爬应对
爬虫·python·selenium·自动化·web·电商·淘宝
sugar椰子皮1 天前
【爬虫框架-2】funspider架构
爬虫·python·架构
APIshop1 天前
用“爬虫”思路做淘宝 API 接口测试:从申请 Key 到 Python 自动化脚本
爬虫·python·自动化