一、为什么短效代理的调度逻辑搬到静态 IP 上会失效
在动态代理池中,IP 是消耗品。轮询、随机、或基于健康分的 ZSET 弹出一个 IP,即使调度策略粗糙,短效 IP 很快会被替换,系统天然具备"自愈"能力。静态长效 IP 的资源模型完全不同:IP 数量有限,每个 IP 的生命周期以天或月计,更换成本高,且往往绑定着特定的业务身份(店铺账号、广告账户、社媒 profile)。这意味着调度逻辑必须同时回答三个短效代理无需关心的问题:
-
公平性:有限的 IP 在多个 Worker / 多个爬虫任务之间如何分配,避免某个 IP 被过度透支而提前触发目标站点风控。
-
状态语义的细粒度:一次超时可能只是目标站点抖动,一次 403 可能是业务侧拦截而非代理故障。静态 IP 不能"一次失败就踢掉"。
-
租期感知:IP 是有到期时间的。调度器必须在 IP 过期前主动介入,而不是等到请求失败才发现"代理已死"。
下面展开的架构,正是围绕这三个约束来设计的。
二、分层架构与数据结构
1.三层职责划分
参考 Scrapy-Redis 集群的代理调度实践,将系统拆分为三层:
-
代理池层(Redis) :持有所有静态 IP 的元数据、健康分、租约状态与冷却队列。它是唯一的事实来源。
-
调度中间件(Downloader Middleware) :运行在每个 Scrapy Worker 内,负责"借用"一个代理、写入
request.meta['proxy']、在响应/异常发生时归还并更新状态。不做全局决策,只执行池层下发的可用性判断。 -
巡检进程(独立守护进程) :定时对所有 IP 发起轻量级 HEAD 探测,更新健康状态机,并对临近到期的租约生成预警。与 Worker 解耦,避免巡检阻塞采集主链路。
2.Redis 键设计
| 键 | 类型 | 用途 |
|---|---|---|
proxy:pool:zset |
ZSET | 健康分排序,Score 越高越优先被借用 |
proxy:meta:{ip} |
Hash | 存储 success_count、consecutive_failures、last_latency、state(OK/SUSPECT/DEAD) |
proxy:lock:{ip}:{domain} |
String | IP + 目标域名维度的借出锁,防止同一 IP 在同一站点上被并发复用 |
proxy:quarantine |
ZSET | 冷却队列,Score 为冷却到期时间戳 |
proxy:lease:expires |
ZSET | 租期索引,Score 为 IP 的订单到期时间戳,用于巡检进程扫描临期 IP |
锁的粒度选 ip + domain 而非仅 ip,原因在于:同一个静态 IP 同时访问两个不同目标站点是合理的,但如果两个 Worker 同时用同一个 IP 访问同一个站点,目标站看到的并发量会翻倍,这是静态 IP 场景下必须避免的"自我暴露"。
三、调度中间件:借出、隔离与公平打分
1.借用逻辑:从高分区取候选,加锁成功才算借到
不能直接从 ZSET 顶部"弹出"一个 IP 就使用,因为弹出即移除,无法表达"暂时不可用但未死亡"的冷却状态。更稳妥的做法是读取高分候选列表,逐个尝试加锁,加锁成功才视为借出:
python
# middlewares.py
import time
import redis
from scrapy.exceptions import IgnoreRequest
class StaticProxyMiddleware:
def __init__(self, redis_url, business_key="default", lock_ttl=60):
self.rdb = redis.Redis.from_url(redis_url, decode_responses=True)
self.pool_key = "proxy:pool:zset"
self.meta_prefix = "proxy:meta:"
self.lock_ttl = lock_ttl # 静态IP请求可能较慢,超时给足
@classmethod
def from_crawler(cls, crawler):
return cls(
redis_url=crawler.settings.get("REDIS_URL"),
business_key=crawler.settings.get("PROXY_BUSINESS_KEY", "default"),
lock_ttl=crawler.settings.getint("PROXY_LOCK_TTL", 60),
)
def process_request(self, request, spider):
domain = self._extract_domain(request.url)
proxy = self._borrow(domain, candidate_size=20)
if not proxy:
raise IgnoreRequest(f"[{domain}] 无可用静态代理")
request.meta["proxy"] = f"http://{proxy}"
request.meta["_proxy_addr"] = proxy
request.meta["_proxy_domain"] = domain
request.meta["_proxy_borrow_at"] = time.time()
def _borrow(self, domain, candidate_size=20):
candidates = self.rdb.zrevrange(self.pool_key, 0, candidate_size - 1)
for addr in candidates:
# 跳过已知 DEAD 的 IP,即使它仍在 ZSET 中
if self.rdb.hget(f"{self.meta_prefix}{addr}", "state") == "DEAD":
continue
lock_key = f"proxy:lock:{addr}:{domain}"
if self.rdb.set(lock_key, "1", ex=self.lock_ttl, nx=True):
return addr
return None
def _extract_domain(self, url):
from urllib.parse import urlparse
return urlparse(url).netloc
候选列表大小 candidate_size 需要根据池子规模与 Worker 数量调整。静态 IP 池通常不会很大(几十到几百条),20 是一个合理的起点;如果 Worker 很多,可以适当放大到 30~50,避免高分区锁竞争过于激烈。
2.响应与异常处理:区分"软失败"与"硬失败"
静态 IP 的扣分必须分级。将 429 / 403 一律按硬失败处理,会在目标站点临时限流时把一个健康的 IP 误判为不可用,而静态 IP 的补充成本很高:
python
def process_response(self, request, response, spider):
self._release_and_update(request, response, spider)
return response
def process_exception(self, request, exception, spider):
self._release_and_update(request, None, spider)
def _release_and_update(self, request, response, spider):
proxy = request.meta.get("_proxy_addr")
domain = request.meta.get("_proxy_domain")
if not proxy:
return
# 释放锁
self.rdb.delete(f"proxy:lock:{proxy}:{domain}")
if response is not None and 200 <= response.status < 400:
# 成功:加分,恢复 OK
self.rdb.zincrby(self.pool_key, 2, proxy)
self.rdb.hset(f"{self.meta_prefix}{proxy}", mapping={
"state": "OK",
"consecutive_failures": 0,
"last_latency": time.time() - request.meta.get("_proxy_borrow_at", time.time()),
})
return
# 失败:分软/硬
soft = response is not None and response.status in (429, 403)
delta = -5 if soft else -15
self.rdb.zincrby(self.pool_key, delta, proxy)
consecutive = self.rdb.hincrby(f"{self.meta_prefix}{proxy}", "consecutive_failures", 1)
if soft:
# 不立即隔离,但标记 SUSPECT,巡检会介入
self.rdb.hset(f"{self.meta_prefix}{proxy}", "state", "SUSPECT")
elif consecutive >= 3:
# 连续硬失败达到阈值,进入冷却队列(不直接删除 ZSET)
cooldown_until = int(time.time()) + 300
self.rdb.zadd("proxy:quarantine", {proxy: cooldown_until})
self.rdb.hset(f"{self.meta_prefix}{proxy}", "state", "DEAD")
spider.logger.warning(f"静态 IP {proxy} 连续硬失败 {consecutive} 次,隔离 5 分钟")
状态机 简化为三态:OK → SUSPECT(出现软失败或单次硬失败)→ DEAD(连续硬失败≥3,进入冷却)。巡检进程可以在冷却结束后将 DEAD 重新评估为 OK 或维持隔离。这一设计避免了"一次失败就踢掉",也防止死代理长期占坑。
四、健康巡检:独立守护进程,而不是压在中间件里
把巡检放在 Scrapy Worker 的中间件中,会让探测请求与业务请求争抢并发与连接池。静态 IP 数量有限,巡检频率可以很低(每 5~10 分钟一次),完全适合由一个独立的 Python 脚本承担。巡检使用 aiohttp 对每个 IP 发起 HEAD 请求,目标选择一个稳定的、不涉及风控的端点(例如 https://httpbin.org/ip 或目标站点首页)。
python
# health_check.py ------ 独立进程运行
import asyncio
import time
import aiohttp
import redis
CHECK_URL = "https://httpbin.org/ip"
HEADERS = {"User-Agent": "Mozilla/5.0 HealthProbe/1.0"}
async def probe(session, ip, timeout=10):
proxy = f"http://{ip}"
start = time.monotonic()
try:
async with session.head(
CHECK_URL, proxy=proxy, timeout=timeout, headers=HEADERS
) as resp:
latency = time.monotonic() - start
return ip, resp.status, latency
except Exception:
return ip, None, None
async def check_batch(ips, rdb):
async with aiohttp.ClientSession() as session:
results = await asyncio.gather(*(probe(session, ip) for ip in ips))
for ip, status, latency in results:
meta_key = f"proxy:meta:{ip}"
if status is not None and 200 <= status < 400:
rdb.hset(meta_key, mapping={
"state": "OK",
"consecutive_failures": 0,
"last_latency": latency,
})
# 巡检成功可以轻微加分,但不改变锁状态
rdb.zincrby("proxy:pool:zset", 1, ip)
else:
# 巡检失败:只标记,不直接 DEAD
rdb.hset(meta_key, "state", "SUSPECT")
rdb.hincrby(meta_key, "consecutive_failures", 1)
async def main():
rdb = redis.Redis(host="localhost", port=6379, decode_responses=True)
while True:
# 跳过冷却中的 IP,巡检其余全部
now = int(time.time())
cooling = {p for p in rdb.zrangebyscore("proxy:quarantine", now, "+inf")}
all_ips = [
ip for ip in rdb.zrevrange("proxy:pool:zset", 0, -1)
if ip not in cooling
]
if all_ips:
await check_batch(all_ips, rdb)
await asyncio.sleep(300) # 5分钟一轮
if __name__ == "__main__":
asyncio.run(main())
巡检的并发度需要根据 IP 数量和本地出站带宽控制。asyncio.gather 一次性打出去在 IP 数量较少(<100)时是可行的;如果池子更大,应引入信号量限制并发探测数,避免巡检本身造成网络拥塞。巡检与中间件的状态写入存在并发,但由于两者操作的都是 Redis 哈希字段和 ZSET 的增量,且不会删除对方的锁,冲突风险很低;如果需要更强的顺序性,可以把 consecutive_failures 的读取-判断-写入改成 Lua 脚本。
五、订单周期感知:静态 IP 池独有的"租期调度"
这是与动态代理池差别最大的部分。静态 IP 的 Redis 数据中必须包含订单到期时间。巡检进程在完成健康检测之后,顺带扫描租期索引 proxy:lease:expires,对剩余时间不足 3 天的 IP 生成预警事件------写入 Redis List、触发 Webhook、或调用服务商 API 检查是否已开启自动续费。静态住宅代理服务商的通行规则是:到期前 3 天或 1 天发送提醒,过期未续则 IP 被回收且通常不可恢复。如果你的调度器不知道这个时间边界,它会在 IP 即将过期的窗口里继续分配请求,然后在某次续费失败后整体断流。
python
# lease_guard.py ------ 可与巡检合并,也可独立
def scan_lease_expiry(rdb, warning_days=3):
now = time.time()
threshold = now + warning_days * 86400
# 只扫描即将到期或已过期的部分
expiring = rdb.zrangebyscore("proxy:lease:expires", "-inf", threshold)
alerts = []
for ip in expiring:
expires_at = rdb.zscore("proxy:lease:expires", ip)
remaining_h = (expires_at - now) / 3600
if remaining_h <= 0:
# 已过期:立即从可用池中移除,标记 DEAD
rdb.zrem("proxy:pool:zset", ip)
rdb.hset(f"proxy:meta:{ip}", "state", "DEAD")
alerts.append((ip, "EXPIRED"))
else:
alerts.append((ip, f"EXPIRING_{int(remaining_h)}h"))
return alerts
在生产环境中,scan_lease_expiry 返回的预警应当推送到告警通道(钉钉、飞书、PagerDuty)。如果你的采集任务是 7×24 运行的,理想情况下还应在预警触发时调用服务商 API 确认自动续费状态,而不是仅仅"提醒人去续费"。静态 IP 的业务连续性依赖于订单连续性,调度器必须把订单时间当作一等公民来对待。
六、 性能调优要点
锁 TTL 与静态 IP 的请求时长匹配 。动态代理的锁 TTL 通常设 30 秒就够,但静态住宅 IP 访问某些站点(尤其带 JavaScript 渲染或大文件响应的场景)可能更慢。如果锁在请求完成前过期,另一个 Worker 可能拿到同一个 IP + 域名组合,造成并发翻倍。建议锁 TTL 不低于 下载超时 + 10 秒,并在 process_response / process_exception 中确保释放。
ZSET 分数上下限。静态 IP 数量少,分数漂移的影响更大。成功 +2、硬失败 -15 的梯度在长期运行中会导致分数分布逐渐拉大,高分区可能只剩少数几个 IP,锁竞争加剧。可以在巡检或定时任务中对分数做线性回拉(例如每次巡检将所有分数向 100 收缩 5%),或设置上限 120、下限 0,防止单一异常事件永久性压低一个健康 IP 的调度优先级。
冷却队列的扫描频率 。proxy:quarantine 中的 IP 在冷却到期后不会自动回到可用状态,需要巡检或定时任务显式检查。如果巡检间隔 5 分钟,冷却到期后最多延迟 5 分钟才被重新评估------对于静态 IP 场景,这个延迟通常可以接受;如果业务对 IP 可用数量极其敏感,可以将冷却检查合并到每次 _borrow 失败后的兜底逻辑中:当候选列表为空时,扫描一次冷却队列,将已到期的 IP 恢复到 ZSET 并重置为 SUSPECT 状态。
七、总结
静态长效 IP 池的调度难点不在于"取一个 IP 塞进 meta['proxy']",而在于在资源有限、租期固定的约束下,维持一个对业务可信、对故障有耐心、对订单周期有感知的调度状态。本文给出的三个模块------基于锁与分级的中间件、独立异步巡检、租期预警------分别对应公平性、故障判定与断档预防。代码层面保持最小可行,实际落地时还需根据池子规模、目标站点风控强度和订单管理流程做参数调整。
如果你正在长周期业务(店铺运营、广告验证、社媒矩阵)中使用 Scrapy,并且发现动态代理的健康分调度总是把请求浪费在"刚判死又复活"的节点上,那么静态 IP + 上述调度架构会是一个更匹配业务节奏的选择。