kp-017
礼貌抓取:限速、重试与超时工程
前置知识
本文基于模型知识整理,建议核对官方文档(见 参考资料)。
一句话定义
礼貌抓取 = 把「请求多快、失败怎么办、多久算超时」变成显式参数与算法(限速器 + 指数退避 + 分层超时),既保护目标系统也保护自己的任务。
为什么重要
无节制的采集是 kp-004 伦理红线的直接触发器,也是任务自身崩溃的第一原因(429 雪崩、连接堆积)。限速与退避是爬虫工程化(kp-018 起)的底层素养,写进每一个采集脚本。
前置知识
kp-006(请求骨架与 timeout)。
核心概念
- 限速:控制单位时间请求数;固定间隔太规律,应加随机抖动。
- 令牌桶:以恒定速率 r 生成令牌、桶容量 b 的经典限速算法,允许小幅突发。
- 指数退避 + 抖动:失败后按
wait = base × 2^attempt × random(0.5, 1.5)递增等待。 - Retry-After:服务器在 429/503 中给出的建议等待秒数,必须优先尊重。
- 并发上限:单主机同时 1–2 个在途请求是默认礼貌水位。
原理与机制
令牌桶模型:桶以速率 r 补令牌,每个请求消耗 1 枚,空桶则等待。平均速率 = r,同时容忍 b 枚突发,平滑又不失吞吐。
容量 b=5, 速率 r=2/s 的行为:
t=0s 桶满5枚 → 可瞬间发5个(突发)
t=0.5s 补1枚 → 又可发1个
持续速率: 2 req/s, 任何时刻在途请求数受桶约束
可直接复用的限速退避组件:
import time, random
from collections import deque
class TokenBucket:
def __init__(self, rate: float, capacity: int = 1):
self.rate, self.cap = rate, capacity
self.tokens, self.t = capacity, time.monotonic()
def take(self):
while True:
now = time.monotonic()
self.tokens = min(self.cap, self.tokens + (now - self.t) * self.rate)
self.t = now
if self.tokens >= 1:
self.tokens -= 1
return
time.sleep((1 - self.tokens) / self.rate)
bucket = TokenBucket(rate=1.0) # 单主机 1 QPS 起步
def polite_sleep():
bucket.take()
time.sleep(random.uniform(0.5, 1.5)) # 抖动打散节奏
def backoff(attempt: int, retry_after: str | None = None):
if retry_after and retry_after.isdigit():
return int(retry_after) # 优先尊重服务器
return min(60, 2 ** attempt) * random.uniform(0.5, 1.5)
实例或案例
对比实验:同一 200 页任务,无限速直跑在约 120 页时收到 429 并被临时封禁 10 分钟(总耗时反而更长);加 1 QPS + 抖动后一次跑完。慢而稳的总时长经常优于快而断,这是新手最难内化的工程直觉。
常见误区
- 误区一:
sleep(1)当限速。 固定间隔极易被识别且不处理突发;令牌桶/抖动才是合格实现。 - 误区二:忽略 Retry-After 与 429。 429 后继续请求会把临时限流升级为封禁。
- 误区三:只限速不限并发。 10 个线程各 sleep 1 秒仍是 10 QPS;并发与速率必须同时约束。
自测题
- 令牌桶的 r 与 b 各控制什么?
答:r 控制长期平均速率;b 控制允许的瞬时突发量。
- 指数退避为什么要乘随机抖动?
答:避免失败请求同步重试形成「重试风暴」;抖动把重试打散。
- 单主机的默认礼貌水位是多少?
答:并发 1–2 个在途请求、低个位数 QPS 起步,并参考 robots.txt 的 Crawl-delay。
与其他知识点的关系
kp-018 的 Scrapy 内建 AUTOTHROTTLE 与重试中间件是本篇的框架化实现;kp-021 的调度同样以限速为约束;kp-016 的第 3 层防御正是本篇的对偶面。
延伸阅读
Scrapy 官方文档「AutoThrottle」;MDN「HTTP 429」。