爬虫遇到一个响应缓慢的情况,于是重试。重试也失败了,于是它立刻再次重试,而恰好在同一时刻碰壁的其他每个工作进程也都在这样做。几秒钟之内,你就向一个已经在告诉你”请减少请求”的网站发送了一大波流量,而原本只是临时限流,现在已经变成了对所涉及的每个地址的硬性封锁。网站并没有升级处置手段,是你的重试循环升级了它。
这是设计良好的爬虫最常见的自我伤害方式,而且是完全可以避免的。重试逻辑是一种负载削减机制,而不是一种坚持机制,这两个概念之间的差异,就是一个任务能够优雅降级还是最终被封禁之间的差异。更广泛的卫生规范已在避免封锁中介绍过;本文关注的是重试路径本身的机制。
为什么简单粗暴的重试会让情况变得更糟
三种动态因素会叠加,而且是共同叠加的。
第一是,重试恰好会在负载本身就是问题的时候增加负载。429 或缓慢的响应本质上是在要求你减少发送请求,而用更多请求来回应它,恰恰是把这个信号反过来用了。第二是同步化。在同一时刻失败并等待相同固定间隔的工作进程,也会在同一时刻重试,于是它们不但没有分散开,反而作为一次协同的突发流量到达,而这波突发中的每一轮,又会重新同步下一轮。第三是,重试通常是按地址计入你的账目的,因此从同一个 IP 反复轰炸目标,会让该地址从”被限流”变成”被标记”,这是一个会比事故本身持续更久的信誉问题,并且会跟随该地址进入你的下一个任务。
综合起来,一个简单粗暴的循环会把一个本可恢复的状况,变成反机器人系统正是为了捕捉而构建的那种流量形态。同样的失败,如果处理得克制一些,本来是会自行化解的。
重试之前先分类
第一条规则是:并非每一次失败都值得重试,而值得重试的那些,也应该用不同的方式对待。把响应分成三类。
有些失败是暂时性的,值得重试:连接重置、超时、502、503、504 以及 429。这些代表的是一个暂时无法处理而非拒绝处理的系统,而 429 尤其是一条关于节奏的明确指令,而不是一种拒绝。有些失败是终结性的,绝不应该重试:404、400、401、在多个地址上都持续出现的 403,以及一个解析干净但根本不包含你想要内容的页面。重试这些只会白白消耗带宽和信誉,却换不来任何可能改变的结果,而解析失败其实是代码本身的缺陷,重试只会忠实地把它一遍遍重现下去。
第三类是最危险的一类:那些看起来成功、实际却并非如此的响应。验证页面、通用或空白的结果、被截断的列表、或跳转到落地页,这些都可能带着 200 状态码到达,而一个只信任状态码的爬虫会欣然把它们记录为数据。在把响应计为成功之前,先验证正文内容,这正是检测被封锁或伪造的内容一文的核心内容。软封锁是一种可重试的失败,但前提是你要先意识到它是一次失败。
指数退避,以及为什么抖动不是可选项
对于可重试的那一类,两次尝试之间的延迟应该逐渐增长,标准形式是指数式的:等待一秒,然后两秒、四秒、八秒。增长很重要,因为它能给一个正在挣扎的目标越来越多的喘息空间,而不是持续不变的敲打节奏。
但仅靠指数退避是不够的,而这正是大家常常忽略的部分。如果一百个工作进程同时失败,并且都恰好等待一秒,那么它们会在一秒后同时重试。退避的时间是增长了,但突发流量依然存在,你只是把同样的峰值沿着时间线往后挪了一下。解决办法是抖动:随机化每一次延迟,而不是直接使用计算出来的数值。完全抖动(full jitter),即在零到当前上限之间随机选取一个等待时间,能把一次同步的失败打散成平滑分布的重试。这只是两行代码的改动,却是本文中最有效的单项措施。
还有两条相关规则。当网站发送 Retry-After 时要遵守它,因为这个响应头是目标在明确告诉你应该等待多久,无视它而坚持自己的节奏,既失礼又适得其反。同时要给延迟和尝试次数都设上限,因为一个已经失败五次的请求,第六次也不会成功,而无限制的重试,只不过是对一件已经注定失败的事情,用一种缓慢的方式永不放弃罢了。
import random, time
import requests
PROXY = "http://customer-USERNAME-country-us:PASSWORD@p.shifter.io:443"
PROXIES = {"http": PROXY, "https": PROXY}
TRANSIENT = {429, 502, 503, 504}
MAX_ATTEMPTS = 5
BASE, CAP = 1.0, 60.0
def fetch(url):
for attempt in range(MAX_ATTEMPTS):
try:
r = requests.get(url, proxies=PROXIES, timeout=20)
except requests.RequestException:
pass # transient: fall through to backoff
else:
if r.status_code == 200 and is_valid(r.text):
return r.text # validate the body, not just the code
if r.status_code not in TRANSIENT:
return None # terminal: do not retry
after = r.headers.get("Retry-After")
if after:
time.sleep(min(float(after), CAP)) # the target told you the answer
continue
ceiling = min(CAP, BASE * (2 ** attempt))
time.sleep(random.uniform(0, ceiling)) # full jitter, not a fixed delay
return None
轮换还是等待:重试通常会做错的那个决定
对于住宅代理来说,还存在第二个维度。一次失败可以用时间来应对,也可以用更换地址来应对,或者两者兼用,而选错了就会浪费其中一种手段。
当信号关乎速率时,应该等待。429 或 Retry-After 是目标在说你的节奏太快了,而如果换成一个新地址却保持同样的节奏,这恰恰是看起来像是在规避检测的行为,会导致整个代理池被封,而不只是一条线路。这种情况下应该放慢速度。
当信号关乎地址时,应该轮换。一个封锁页面、一个持续出现的 403、或者在同一条线路上反复出现的验证挑战,意味着这个特定地址已经不再被信任,等待也无法让它恢复。此时应该淘汰这条线路,换一个干净的继续,这就是故障转移模式,而要注意,替换地址的信誉才是决定这次重试是否真正有效的关键。唯一不适合轮换的情况是会话进行中的工作:如果某个流程依赖于一个被保持住的身份,更换地址会破坏它,因此在粘性会话中出现的失败,应该是在新会话上重新开始整个流程,而不是在现有会话下悄悄更换地址。
超时介于两者之间,值得单独诊断而不是条件反射式地处理,因为请求超时的原因可能包括目标本身缓慢、线路不健康,以及你自己的并发量过高。
预算与断路器
单个请求层面的重试规则本身并不足够,因为它们看不到整个系统的全貌。有两种机制能给你这种全局视角。
重试预算把重试次数限制在总流量的一个比例之内,例如允许重试量最多占对某个目标请求总量的百分之十。在正常情况下,这个预算永远不会被用完。而当出现大范围故障时,预算会立刻耗尽,多余的重试就干脆不会发生了,这正是你想要的特性:重试对孤立的失败有帮助,而在大范围中断期间则是彻头彻尾有害的,而预算正是能够自动区分这两者的机制。
断路器则更进一步。跟踪每个目标的失败率,一旦超过阈值,就在一段冷却期内完全停止向该目标发送请求,而不是继续用涓涓细流般注定失败的请求去试探它。冷却期结束后,放行少量请求,如果它们成功了,就恢复正常发送。这既保护了目标不被你的请求叠加冲击,也保护了你的地址不会在一个当前对任何人都没有响应的网站上不断积累失败记录。这两种机制都应该是针对单个目标的,而不是全局的,因为一个网站出问题,绝不应该让一个正在从另外五十个网站采集数据的任务停摆。
重试既花钱又会隐藏在你的数据里
有两个值得明确指出的后果。
每一次重试都是一个要花钱的请求。在按带宽计费的住宅代理上,一场重试风暴会成为账单上实实在在的一项,而一个悄悄对一个整个下午都宕机的网站重试五次的任务,会白白消耗掉惊人数量的数据,这正是削减代理带宽成本一文中,成本构成里不那么明显的一个来源。
而重试率是应该出现在你的仪表盘上的一个先行指标。某个目标上重试率的上升,是其防御机制发生变化或你的节奏出现偏移的最早预警信号,它出现的时间远早于成功率的崩溃,因此监控采集管道时应该跟踪尝试次数和结果,而不只是最终结果。一个通过悄悄重试而维持出看起来正常的成功率的爬虫,其实是在掩盖问题,而不是在解决问题。
最后,当一个请求耗尽了所有尝试次数时,不要直接丢弃它。把它推送到一个死信队列中,稍后再重试,可以是在下一次运行时,或者在一段较长的冷却期之后,而不是在当前这一波突发中重试。大多数在故障期间失败的条目,一小时后自行重试往往就会成功,而队列正是把一次硬失败变成一次延后处理的手段。
结论
重试逻辑存在的意义是吸收暂时性的失败,而不是死磕到底。行动之前先对失败进行分类,只重试那些真正属于暂时性的失败,并验证响应正文,从而把软封锁当作它本质上就是的那种失败来对待。要以指数方式退避,并且始终加上抖动,遵守 Retry-After,同时对延迟和尝试次数都设置上限。要区分关乎速率的信号(应该等待)和关乎地址的信号(应该轮换),绝不要用循环更换地址的方式去回应一次限流,从而维持同样的请求节奏。加入重试预算和针对单个目标的断路器,这样大范围的中断就不会演变成一场自找的洪流。做到这些之后,那些团队常常归咎于”反机器人系统升级处理”的封禁,大多数其实就不会再发生了,因为它们从一开始就根本不是什么升级处置,而是自己造成的。
另一半的解决方案,是要有地方可以故障转移过去,这正是住宅代理所提供的:一个由真实家庭级 IP 组成的庞大代理池,让一条被淘汰的线路能够被一条干净的线路取代,而不是让同一个地址再试一次。按 GB 计费的定价也正是自律的重试策略能够物有所值的原因,因为你只需为你实际发出的请求付费,而一场你从未发送出去的风暴,也就是一份你永远不必购买的带宽。