API Engineering

在不触发反滥用启发式规则的情况下缓解API速率限制

4 min read Published Updated 808 words

大多数 API 速率限制实现都能被简单的代理轮换轻易绕过——但同样的轮换会在几分钟内触发反滥用启发式规则。真正的挑战不仅在于保持在限制之下,更在于不让自己看起来像机器人。这需要理解两种不同的速率限制范围(按 IP 和按密钥),应用带抖动的退避策略,随机化代理池轮换,并调整请求时序以模拟真实流量。基于代理池的令牌桶模式提供了稳健的基础。

按 IP 与按密钥:速率限制的两个维度

速率限制至少作用于两个独立的维度:源 IP 和 API 密钥(或令牌)。单个密钥可能允许每小时 5,000 次请求(GitHub 的认证限制),但同一密钥来自同一 IP 时,可能会在更低的突发上限被限流。未经认证的请求限制更严格——通常每个 IP 每小时 60 次请求。忽略 IP 维度是触发 429 Too Many Requests(RFC 6585)响应的最快方式。代理池必须将请求分布到多个 IP 上,以避免单个源饱和,但每个 IP 仍然共享同一个密钥。如果密钥的全局限制是 5,000 次/小时,而你有 50 个代理,那么每个代理每小时只能发起 100 次请求,否则密钥级计数器就会触发。在编写任何代码之前,先映射这两个限制。

带抖动的退避:礼貌与可预测之间的区别

不带抖动的指数退避是一种指纹。服务器看到请求以完美的加倍间隔到达,就会将其标记为自动化。解决方案是全抖动:sleep(random.uniform(0, min(cap, base * 2 ** attempt)))。这会将重试分散到时间窗口内,使模式与真实用户的突发流量难以区分。AWS 自己的指数退避文档也正是出于这个原因推荐抖动。以下 Python 代码片段实现了一个令牌桶,它以固定速率补充令牌,在桶为空时带抖动休眠,并在每次成功获取时轮换代理:

import time
import random
from collections import deque

class TokenBucketProxyPool:
    def __init__(self, proxies, rate, burst):
        self.proxies = deque(proxies)
        self.rate = rate          # tokens per second
        self.burst = burst
        self.tokens = burst
        self.last_refill = time.monotonic()

    def refill(self):
        now = time.monotonic()
        elapsed = now - self.last_refill
        self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
        self.last_refill = now

    def acquire(self):
        while True:
            self.refill()
            if self.tokens >= 1:
                self.tokens -= 1
                self.proxies.rotate(1)   # simple rotation, but see section below
                return self.proxies[0]
            else:
                sleep_time = (1 - self.tokens) / self.rate
                jitter = random.uniform(0, sleep_time * 0.5)
                time.sleep(sleep_time + jitter)

# Usage: pool = TokenBucketProxyPool(proxies, rate=2.0, burst=10)
#         proxy = pool.acquire()   # blocks until token available

代理池轮换:随机化,不要轮询

通过代理列表进行顺序轮询是可检测的。如果服务器看到请求依次来自 1.2.3.45.6.7.89.10.11.12,且步调一致,就会跨 IP 关联出模式。相反,应使用有放回的随机选择,并为每个代理添加冷却时间。公共代理目录报告 60–80% 的失败率——返回 429 或连接错误的代理应被降级到死队列中至少 60 秒。实现加权随机选择,让近期成功的代理更有可能被选中。上面的令牌桶为了清晰使用了简单的 rotate(1);在生产环境中,应将其替换为 random.choice() 并跟踪每个代理的失败次数。

请求塑形:模拟均值而不越界

即使拥有完美的令牌桶和代理池,恒定每秒 2.0 次的请求速率也是不自然的。真实用户会暂停、批量处理并改变请求间隔。在每次 acquire() 调用之前添加一个小的随机延迟(例如 time.sleep(random.uniform(0.1, 0.5))),并每隔几分钟将令牌桶的速率变化 ±10%。但是,不要添加超过 API 文档中速率限制窗口的延迟——那会适得其反。目标是在保持在限制内的同时,看起来像一群合法客户端。过度塑形(例如插入类似人类的打字延迟)是徒劳的;API 关心的是请求频率,而不是按键间隔。

道德缓解:知道界限在哪里

这里描述的每一项技术都是合理的优化——直到它违反 API 的服务条款。大多数 ToS 明确禁止“规避速率限制”或“使用自动化方式访问服务”。阅读 ToS 不是可选项。如果 API 允许每个密钥每分钟 100 次请求,那么使用 50 个代理每个代理每分钟发起 2 次请求是合规的。使用 100 个代理每个代理每分钟发起 100 次请求则不是——这是滥用,无论你的抖动多么巧妙。区别在于意图和流量。基于代理池的令牌桶是一种工具;同一工具既可以用于道德地抓取公共目录,也可以用于对小型 API 进行 DDoS 攻击。记录你的速率限制目标,审计日志中的 429 错误,并且永远不要超过文档中规定的每密钥上限。如果你需要更高的吞吐量,请申请更高的限制或购买专用套餐。