采集限速与合规:别把对方站点打挂

几年前做过一个比价的小项目,目标站不大,我压了 200 并发。当天下午对方站点开始 502,第二天我的整段 IP 被机房封了,任务停了三天。那次之后我才认真去想一个问题:代理解决的是"我是谁",限速解决的是"我有多克制"——这两件事都得做,缺一个都不行。

换 IP 绕开的是限流,不是容量

很多人有一种错觉:只要 IP 池够大,就能无限提速。实际上目标站面对的不只是"单 IP 限流",还有数据库连接数、带宽、后端服务承载能力。你把请求摊到 500 个 IP 上,对方的机器该崩还是崩,只不过日志里看不到同一个来源 IP 而已。

而一旦把对方服务搞出故障,后果通常比被封 IP 麻烦得多:可能是法务函,也可能是整个 IP 段被拉黑,连带你的其他业务一起受影响。

单站限速:先定一个能自我约束的数字

我的默认起点是:

  • 对同一域名,总并发 不超过 10,QPS 不超过 5;
  • 对方站点规模小(个人站、老系统)就降到并发 2~3;
  • 夜间可以适当放宽,白天业务高峰期收紧。

注意"总并发"是跨 IP 汇总的。加了代理之后,很多人只盯着单 IP 的并发,结果 50 个 IP 各开 5 并发,对目标站就是 250 并发——等于没限速。

用 Redis 做全局令牌桶

多进程、多机器的时候,本地计数器各算各的,汇总起来就失控了。把限速器放到 Redis 里,全局共用一份配额:

import time

import redis

TOKEN_BUCKET_LUA = """
local key = KEYS[1]
local rate = tonumber(ARGV[1])        -- 每秒补充多少令牌
local burst = tonumber(ARGV[2])       -- 桶容量
local now = tonumber(ARGV[3])

local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or burst
local ts = tonumber(data[2]) or now

tokens = math.min(burst, tokens + (now - ts) * rate)
if tokens < 1 then
    redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
    return 0
end
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
return 1
"""

client = redis.Redis(decode_responses=True)
script = client.register_script(TOKEN_BUCKET_LUA)


def allow(domain, rate=5, burst=10):
    """对 domain 这个站点申请一个令牌,拿到才能发请求。"""
    ok = script(keys=["rl:%s" % domain],
                args=[rate, burst, time.time()])
    return bool(ok)

用法就是发请求前先问一句 if not allow(domain): time.sleep(0.2),简单但有效。桶的粒度按域名算,别按 URL 算,否则等于没限。

退避重试:指数 + 抖动

被限流时立刻重试是最糟的反应,它会把"偶发限流"变成"持续被封"。正确姿势是指数退避,并且加随机抖动——否则几百个任务会在同一时刻一起重试,形成新的流量尖峰:

import random
import time


def backoff_sleep(attempt, base=1.0, cap=60.0):
    """第 attempt 次失败后的等待时间:1s, 2s, 4s ... 最多 60s,并加随机抖动。"""
    delay = min(cap, base * (2 ** attempt))
    delay = delay * (0.5 + random.random() * 0.5)   # 抖动,避免同时重试
    time.sleep(delay)

另外要区分错误类型:403/429 这种明确被拒的,退避要长;超时这种可能只是线路抖动,退避可以短。一刀切的重试策略基本都是错的。

缓存与增量:最省流量的优化

比"抓得更快"更有效的是"抓得更少"。三个做法按性价比排序:

  1. 列表页做增量:用上次抓到的最大时间戳/ID 做条件,别每次全量翻页;
  2. 详情页走缓存:URL 级别的响应缓存(带 TTL),重复的 URL 直接读本地;
  3. 用条件请求:带上 If-Modified-Since / ETag,对方返回 304 时几乎没有代价。

我在一个日更的任务里加完这三条,每天的请求量从 40 万降到 6 万,目标站的响应时间也明显变好了。

合规这条线要自己心里有数

  • robots.txt 是底线不是上限。它没禁止,不等于你可以在对方高峰时段压满它。
  • 别采个人隐私数据。手机号、身份证、住址这类信息,即使页面上有也别落库,风险远大于收益。
  • 别绕过付费墙。用技术手段拿到本该付费的内容,属于典型的侵权场景。
  • 标明身份。给爬虫起个能联系到你的 User-Agent,真有纠纷时对方更愿意先联系你而不是直接封禁。
  • 公开数据也注意频率。有公开 API 的优先用 API,通常比爬页面稳定得多。

小结:代理管"我是谁",限速管"我有多克制",缓存管"我能不能少抓点"。三者配上,采集才能长期稳定地跑下去——被封一次停三天,比一开始慢一点代价大得多。

顺便说下代理的角色:把请求分散到多个出口 IP,本质是为了让单 IP 的请求频率保持在"正常用户"的量级,而不是为了把并发堆到对方身上去。我用的是 星月代理,短效 IP 会自动轮换,配合上面这套全局限速用起来比较稳。具体的池子实现和 18 种语言示例都在 github-xydaili-examples 里。