每个爬虫都需要重试逻辑,而且大多数都是无意中长出来的:这里一个 try 块,那里一个 time.sleep,再加上某周出问题时补上的尝试计数器。这样写出来的东西能用,直到某一天目标网站状态不好,这时重试路径造成的损害比原始故障本身还大。为什么会发生这种情况,论证过程在重试与退避中,这篇是具体实现。
核心思路是:重试是基于失败分类做出的决策,而不是包在请求外面的一个循环。把分类做对,其余的自然就顺了。
先分类
每次失败都属于四类之一,每一类都有且仅有一种正确的应对方式。
| 类别 | 示例 | 应对方式 |
|---|---|---|
| 瞬时性 | 超时、连接重置、502、503、504 | 退避后重试 |
| 限速 | 429、存在 Retry-After | 按指示等待,保持同一路由 |
| 身份 | 挑战页面、持续的 403、封锁页面 | 新建会话,再重试 |
| 终结性 | 404、400、401、407、解析失败 | 不重试,直接抛出 |
其中有两类是人们最容易搞错的。限速信号是关于节奏的指示,所以正确的应对是等待而不是换地址,因为为了维持同样的请求速度而轮换,恰恰是把限速升级为封锁的行为。终结性失败绝不应该重试:407 意味着凭证或用户名标志格式错误,第五次尝试时依然会是错的,而解析失败是你代码里的一个 bug,重试只会忠实地把它永远重复下去。
第五种情况根本不会出现在任何状态码里:一个看起来正常、实际上并不正常的响应。挑战页面、空结果集、或者以 200 状态返回的截断列表,都属于身份类别,但前提是你能注意到它,这意味着在做出任何判断之前都要先校验响应体。这项校验是整个系统的基础,详见检测被封锁或伪造的内容。
代码中的分类
import random, time, requests
TRANSIENT = {502, 503, 504}
def classify(resp, exc, is_valid):
if exc is not None:
return "transient" # timeout, reset, DNS
if resp.status_code == 429:
return "rate"
if resp.status_code in TRANSIENT:
return "transient"
if resp.status_code in (403, 401) or looks_like_challenge(resp):
return "identity"
if resp.status_code == 200 and not is_valid(resp.text):
return "identity" # soft block: 200 but not our data
if resp.ok:
return "ok"
return "terminal" # 404, 400, 407, everything else
looks_like_challenge 是按目标网站定制的,通常是一份简短的标记列表:某个验证码脚本引用、已知的过渡页标题、明显短于真实页面的响应体。每个目标网站把这份定义集中放在一处,让重试路径和监控使用同一套定义。
带抖动的退避,以及尊重 Retry-After
对于瞬时性类别,延迟应当指数增长,并且必须随机化。固定延迟会让你的多个工作进程同步,导致一百个同时失败的请求又同时重试,这个突发流量就穿过了退避机制存活下来。
def backoff(attempt, base=1.0, cap=60.0):
ceiling = min(cap, base * (2 ** attempt))
return random.uniform(0, ceiling) # full jitter
对于限速类别,目标网站可能会明确告诉你要等多久,这个指示优先于你自己的计划:
def wait_for(resp, attempt):
ra = resp.headers.get("Retry-After")
if ra:
try:
return min(float(ra), 300) # honour it, but cap it
except ValueError:
pass # HTTP-date form, fall through
return backoff(attempt, base=2.0) # rate signals start slower
整合起来
MAX_ATTEMPTS = 4
def fetch(url, country, is_valid, session_id=None):
sid = session_id
for attempt in range(MAX_ATTEMPTS):
proxies = build_proxies(country, sid) # sid=None means rotate
resp = exc = None
try:
resp = requests.get(url, proxies=proxies, timeout=20)
except requests.RequestException as e:
exc = e
kind = classify(resp, exc, is_valid)
if kind == "ok":
return resp
if kind == "terminal":
raise TerminalError(url, getattr(resp, "status_code", None))
if kind == "identity":
sid = new_session_id() if sid else None # retire the session
time.sleep(backoff(attempt))
elif kind == "rate":
time.sleep(wait_for(resp, attempt)) # wait, do not rotate
else:
time.sleep(backoff(attempt))
raise Exhausted(url)
这里有三个细节比结构本身更重要。这个函数在遇到终结性错误时会抛出异常,而不是返回 None,这样调用方就不会把失败误认为空数据。身份类失败会替换会话而不是复用它,因为原来那个会话已经被目标网站识别了。而限速类失败刻意不动会话,保持同一路由的同时放慢速度。
防止重试本身变成问题的护栏
单次请求的逻辑本身还不够,因为它看不到整个系统的全貌。有三项补充措施承担了大部分保护作用。
重试预算将重试限制在对某个目标总流量的一个比例之内,比如百分之十。正常运行时你永远不会接近这个上限。但当某个目标网站大范围出问题时,预算会立刻耗尽,多余的重试根本不会发生,这正是你想要的行为,因为重试对孤立故障有帮助,而在大范围中断期间反而有害。
每个目标网站的熔断器在故障率超过某个阈值时完全停止发送请求,等待一段冷却期,再放一小部分流量进去测试是否恢复。这既保护了目标网站不被你的堆积请求压垮,也保护了你的地址不会因为对一个谁都得不到响应的网站不断累积失败记录。
**一个连重试也要经过的共享限速器。**如果重试绕过了你的节奏控制,你的错误路径就会在目标网站最没有能力承受的时候变成一股不受限制的洪流。让每一次尝试都经过同一个限速器,详见限速与请求节流。
这三项都应该属于那个已经能看到所有请求的组件,这也是为什么应该用代理管理器而不是每个任务各自写重试代码的理由。
幂等性与死信队列
有两个实际问题很容易被忽略。
重试的前提是这个操作可以安全地重复执行。对于抓取来说这几乎总是成立的,因为把一个页面抓两次除了带宽之外没有别的代价。如果你的流水线中有任何部分在抓取时产生写入这种副作用,就要让这个写入变成幂等的,并以某个稳定的键为依据,这样重试就不会创建重复记录。
而当一个请求耗尽了所有尝试次数时,不要直接丢弃它。把它推送到死信队列,过一段时间再处理,比如放到下一次运行,或者经过一段较长的冷却期之后,而不是在当前这波突发流量里硬处理。大多数在事故期间失败的条目,一个小时后自己重试就能成功,而队列能以零成本把一次硬失败变成一次延后处理。
关注重试率
重试是你能得到的最早的预警信号。重试比率,也就是每个目标网站的重试请求占总请求的比例,会在成功率下降之前先上升,因为一个靠重试硬凑出看似正常结果的流水线,其实是在掩盖问题而不是解决问题。而且它在按带宽计费的产品上纯粹是成本,所以它同时是一个健康指标和一个支出指标。把它放在仪表盘上,和已校验的成功率并列,详见代理相关的关键指标和监控你的流水线。
结论
重试逻辑是一个分类器加上四种应对方式,而不是一个带计数器的循环。校验响应体,让软封锁被正确分类为失败;遇到限速信号只等待不轮换;身份类失败时淘汰会话;瞬时性失败时带抖动退避;终结性错误绝不重试。限定尝试次数和延迟上限。然后加上单次请求视角无法提供的护栏:重试预算,防止大范围中断演变成流量洪流;每个目标网站的熔断器;以及一个连重试也要遵守的共享限速器。把耗尽尝试次数的请求送入死信队列留待以后处理,并把重试比率当作事情正在变化的最早信号来关注。
这套方案中”故障转移”的那一半,也就是要有一个干净的地方可以重试过去,正是住宅代理所提供的:一个由真实家庭级地址组成的庞大池子,让被淘汰的会话得到替换而不是被迫复用,配合按 GB 计费的定价,让守规矩的重试比不守规矩的重试直接更便宜。