有一种颇具诱惑力的逻辑让不少团队栽了跟头:速率限制是按 IP 执行的,住宅代理池能给你许多 IP,因此速率限制不再适用。前两个前提都是对的,结论却是错的,而这两者之间的落差,正是大量爬虫被封的原因所在。
代理池改变的是限制作用在哪里,而不是限制是否存在。你仍然需要控制节奏,只是轴线和单 IP 方案不同了,而人们往往忘记的那个轴线,恰恰就是让他们被抓住的那个。下面来说说当你的请求分散在一个轮换池中时,限速实际是如何运作的。
三重限制,不是一重
当你通过代理池抓取数据时,至少有三种独立的上限同时存在,而你的吞吐量取决于你先撞上哪一个。
按 IP、按目标。 这是经典的一种。任何单个地址在被目标网站限速或封禁之前,只能向该网站发送有限次数的请求。轮换正是用来避开这一限制的手段,这也正是负载均衡一文中所描述的、将负载分散到代理池中的整体意义所在。
按目标、汇总计算。 这一点最容易让人意外。网站不仅按单个地址计数,它还能看到自己的总入站负载,并能识别出跨多个地址的协同模式,尤其是当这些请求在时间、路径或行为上存在共性时。你的五十个 IP 每秒各发两个请求,加起来就是每秒一百个请求打到同一个源站,无论怎么轮换地址都无法让它看起来像是正常的自然流量。反爬系统正越来越多地在这个层面上进行判断。
你自身的承载能力。 即你实际能维持的并发量:打开的连接数、工作线程数、内存占用,以及住宅代理的延迟高于直连这一事实,这意味着固定的线程数所能带来的每秒请求数会比你预期的更少。
由此带来的实际后果是,你的限速策略需要按目标来设定,而不是全局设定。单一的全局速率限制,要么会让本可以承受更高请求量的其他五十个站点被你限制得太狠,要么会对那个承受不住的站点造成冲击。
从目标出发,而不是从你的期望出发
在写任何限速代码之前,先弄清楚目标实际能容忍什么,因为凭空猜测只会带来两种结果:一个不必要地慢的任务,或者一个被封的任务。
留意目标网站给出的信号。429 明确表示你的速度太快了。Retry-After 响应头则是网站直接告诉你需要等待多久,遵守它不仅是正确的做法,也远比通过反复试错去摸索答案更省钱。有些 API 会在文档中公布限制,或者返回剩余配额的响应头。而如果网站的 robots.txt 中带有 crawl-delay 指令,那也是一种值得尊重的明确偏好。
如果没有任何公开信息,就通过实测来校准:从保守的速率开始,逐步提高,并关注经过验证的成功率,而不是仅看状态码,因为一个承受压力的网站往往会先出现服务降级,而不是直接拒绝服务,一个返回 200 状态码的验证页面,在天真的计数器看来就是成功的请求,这一点在检测被封或虚假内容一文中有详细说明。成功率开始下滑的那个点,才是你真正的上限,你应该让运行速度低于它,而不是恰好卡在它上面。
实现限速
针对每个目标使用一个令牌桶,是标准做法,而且从零实现也足够简单。令牌以你设定的速率补充,每个请求消耗一个令牌,当桶中没有令牌时请求就需要等待。这样能得到一个稳定的平均速率,同时具备可控的突发余量,比死板的“每 N 秒一个请求”式延迟更贴近真实流量的行为方式。
import time, threading
class TargetLimiter:
"""Token bucket, one instance per target host."""
def __init__(self, rate_per_sec, burst=5):
self.rate, self.capacity = rate_per_sec, burst
self.tokens, self.updated = burst, time.monotonic()
self.lock = threading.Lock()
def acquire(self):
while True:
with self.lock:
now = time.monotonic()
self.tokens = min(self.capacity,
self.tokens + (now - self.updated) * self.rate)
self.updated = now
if self.tokens >= 1:
self.tokens -= 1
return
wait = (1 - self.tokens) / self.rate
time.sleep(wait) # sleep outside the lock
LIMITS = { # per target, tuned per target
"shop.example.com": TargetLimiter(2.0),
"search.example.com": TargetLimiter(0.5),
}
def fetch(url, host, proxies):
LIMITS[host].acquire()
return requests.get(url, proxies=proxies, timeout=20)
生产环境中有两点改进值得注意。一是加入抖动,让请求不要落在精确的固定间隔上,因为过于规律的间隔本身就是一种机器特征,而且它还会让你的多个 worker 同步成一波波集中发出请求。二是要让限速器在多个 worker 之间共享,而不是每个进程各自持有一个,否则十个进程各自礼貌地每秒发两个请求,加起来就是每秒二十个请求。在分布式环境中,这意味着需要一个共享的计数器,比如放在 Redis 之类的存储里,这正是跨 Kubernetes 扩展抓取任务一文需要解决的同类问题。
并发是另外一半
速率和并发是两个不同的调节维度,二者都需要设限。速率控制的是你发起请求的频率;并发控制的是同时有多少个请求在途中。一个速率限制适度但并发不受限的任务,一旦目标源站变慢,仍然会瞬间对同一个源站打开数百个并发连接,因为响应变慢会导致请求不断堆积。
用一个信号量按目标限制并发数,并根据目标能容忍的程度来设定大小,而不是根据你的机器能开多少连接来设定。在代理这一侧,并发连接数通常不是你的瓶颈,这也正因此容易让人忘记,目标网站的容忍度才是真正的瓶颈。而这背后隐含的“需要多少个地址”的分布问题,在你到底需要多少代理 IP一文中有详细讨论。
自适应限速优于固定数值
一个静态速率是一种猜测,而猜测会过时。网站会改变自己的容忍度,在负载下变慢,或者在高峰时段收紧限制。更稳健的做法是根据观察结果动态调整,采用类似加法增大、乘法减小的方式:在状态健康时缓慢地提高速率,在出现压力的第一个迹象时则大幅削减。
触发降速的应该是一个综合信号,而不是单一状态码:429、验证页面占比上升、延迟攀升,或经过验证的成功率下降。恢复时也应逐步进行,而不是直接跳回原来的速率,因为被封之后立即恢复到全速,本身就是一种容易被识别的模式。
class AdaptiveRate:
def __init__(self, start=2.0, floor=0.2, ceiling=10.0):
self.rate, self.floor, self.ceiling = start, floor, ceiling
def ok(self): # healthy response
self.rate = min(self.ceiling, self.rate * 1.02) # creep up
def strained(self): # 429, challenge, timeout, latency spike
self.rate = max(self.floor, self.rate * 0.5) # back off hard
限速与轮换、重试的交互
有三处交互值得明确说出来,因为一旦处理错了,就会让限速形同虚设。
不要用轮换来回应速率信号。 429 意味着要放慢速度。为了维持同样的节奏而换成一个新地址,正是把一个被限速的地址变成整个池子都被烧毁的行为,而这恰恰是让流量看起来像是在刻意规避,而不只是热情过头的原因。当信号关乎速率时就等待;当信号关乎地址本身时才轮换。
重试必须遵守限速器。 重试本质上也是一次请求,必须像其他请求一样获取一个令牌,否则你的错误处理路径就会悄悄绕开你精心设计的节奏控制。重试风暴是一个被限速的任务演变成被封任务的最常见途径,这也是重试与退避一文的主题。
粘性会话会集中负载。 在一个多步骤流程中持续使用同一个地址,意味着这个地址要承担整个流程的所有请求,因此在粘性会话中,按 IP 控制节奏比在负载天然分散的轮换任务中更加重要。
讲礼貌其实是为了自身利益
很容易把以上这些都读成一种合规负担,但激励方向其实是一致的。控制节奏能保护你所依赖的地址的信誉,让你的成功率保持在高位,从而不至于把带宽费花在验证页面上,同时也避免了一种升级循环:激进的采集行为会招致更严的防御,这会让目标站点对所有人都变得更难抓取,包括你自己在下个季度。尊重一个网站已声明的限制、它的 robots 指令和它的服务条款,既是正确的做法,也是更省钱的做法。
结论
代理池不会消除速率限制,它只是把限制挪了位置。按目标而不是全局来限速,因为单一数值对你接触的每个站点来说都是错的。从每个目标自身的信号中找出它真实的容忍度,把 Retry-After 和 429 当作指令而不是障碍来遵守,并根据经过验证的成功率而不是状态码来校准。用共享的令牌桶加抖动来实现,把并发和速率分开限制,并让限制具备自适应能力,做到快速降速、缓慢恢复。然后把各种交互处理清楚:永远不要用轮换地址来回应节奏信号,始终让重试经过限速器,并在粘性会话中把节奏控制得更严。做得好的话,限速能让代理池以其真实的承载能力运行,而不是被白白耗尽。
代理池本身,正是给你腾出施展空间的东西:住宅代理能把一个节奏得当的任务分散到大量真实的家庭级地址上,并支持国家和城市级定位,而按 GB 计费则意味着,一个节奏得当、只抓取所需内容的任务,比一个猛冲猛打、不断重试的任务花费更少。