写采集脚本的人,大多经历过那个转折点:前几分钟一切顺利,突然之间目标域名的连接全部失败,整个任务中断。这篇文章讲的是限流的机制,以及为什么"能跑通"和"能长期稳定跑"是两回事。
现象
连续抓取历史数据时,抓了大约 250 个对象之后,该域名的连接直接失败,整个数据任务中断。换到密集请求的接口,也出现三种典型症状:
Connection reset by peer
空返回(返回体为空)
HTTP 502 Bad Gateway
更让人困惑的是:加了延迟重试,有时也没用。 重试几次之后依然失败,任务还是断在那里。
根因
这是站点按 IP 限流 / 风控导致的。同一 IP 在短时间内高频连续请求,会触发临时封禁——不是你的请求写错了,而是对方在保护自己。
但让问题变得更难排查的是第二个原因:很多脚本对"限流"这条错误刻意静默。 常见写法是:
try:
resp = fetch(url)
except Exception as e:
if "rate limit" in str(e).lower():
pass # 限流?静默忽略
else:
log.error(e) # 只在非限流错误时才打日志
于是限流发生时没有任何日志痕迹,你看到的只是"连接突然全断了",根本不知道是从哪一条开始被限流的。
解决
从防御和观测两方面同时下手:
import time, random, requests
def fetch_with_backoff(url, max_retries=2):
for attempt in range(max_retries + 1):
try:
resp = requests.get(url, timeout=10)
if resp.status_code == 429 or resp.status_code == 502:
wait = (2 ** attempt) + random.uniform(0, 0.5)
log.warning(f"限流/异常 {resp.status_code}, 退避 {wait:.1f}s: {url}")
time.sleep(wait) # 指数退避
continue
return resp
except requests.ConnectionError as e:
log.warning(f"连接失败: {e}") # 限流也要留痕
time.sleep(1 + attempt)
return None
几个要点:
- 请求间加节流,比如每条之间
time.sleep(0.3~0.5),别让请求打得那么密; - 对限流做 1~2 次指数退避重试(2 秒、4 秒这样递增),别用固定间隔猛捶;
- 每 25 条就落盘一次断点。这一点特别关键——不要每 100 条才存一次,否则任务被超时杀掉时,那 100 条里没存的部分全丢;
- 限流至少要打 WARN 并计数,绝不能静默。
for i, item in enumerate(items):
result = fetch_with_backoff(item["url"])
if result is None:
rate_limit_count += 1
buffer.append(parse(result))
if i % 25 == 0: # 每 25 条落盘
save_checkpoint(buffer, i)
time.sleep(0.3)
延伸与预防
根治限流的办法有两个方向:错峰和本地缓存。
- 错峰:把密集抓取放到每天的低峰期(比如凌晨)执行,避开站点的风控高发时段;
- 预抓取 + 本地缓存:每天低峰期把需要的数据一次性抓下来存成本地文件,白天任务全部读本地缓存。这样既大幅减少请求量,又让白天的任务不再受限于外部接口。
另外,关于"换用更稳的数据源"——如果你的采集目标有多个可用来源,把请求分散到不同来源上,本身就是最有效的限流缓解。但前提是你能确认它们返回的是同一份数据。
还有一点关于"观测"的经验:限流往往有前兆,只是你平时没留意。比如响应时间开始变长、偶发一两个空返回、开始零星出现 502——这些都是"配额快满了"的信号。如果脚本把这些都当噪声忽略,你就只会看到"突然全断"的那一刻。把 429/502/连接重置单独计数并打日志,就能在撞墙之前主动降速。
一句话总结:对"会限流的外部接口",节流、退避、断点、日志,缺一不可。 四样里少任何一样,你都会在某个时刻付出重跑的代价。