代理池健康度监控:怎么判断一条线路该不该踢出
采集跑久了会发现一个问题:池子里明明有几十条线路,实际能用的只有那么几条,剩下的要么一直失败,要么半死不活地拖着整体成功率。
这篇讲怎么给代理池加一层健康度监控,把劣质线路自动识别出来并隔离。
为什么不能只看成功率
最直觉的做法是统计每条 IP 的失败次数,超过阈值就踢。这个做法有两个问题。
第一,样本量不够时不准。 一条线路被用了 3 次,失败 1 次,成功率 67%,看起来很差。但 3 次样本说明不了任何事,可能只是赶上目标站抖了一下。
第二,不同类型的失败要区别对待。 403 是目标站拒绝,可能是这条 IP 被拉黑了;429 是频率超限,换 IP 也没用,该降速;超时可能是线路本身慢,也可能是目标站响应慢。
所以健康度至少要看三个维度:成功率、延迟分布、失败类型。
数据结构设计
每条线路的记录不能只存一个计数器,要存足够支撑判断的信息。
import time
from collections import deque
class ProxyStat:
"""单条线路的运行统计"""
__slots__ = ("addr", "window", "fail_streak", "cool_until",
"total", "ok", "latency_sum")
def __init__(self, addr, window=50):
self.addr = addr
self.window = deque(maxlen=window) # 最近 N 次结果,True/False
self.fail_streak = 0 # 连续失败次数
self.cool_until = 0.0 # 冷却到期时间戳
self.total = 0
self.ok = 0
self.latency_sum = 0.0
def record(self, success, latency=0.0, cooldown=0):
self.total += 1
self.window.append(success)
if success:
self.ok += 1
self.fail_streak = 0
self.latency_sum += latency
else:
self.fail_streak += 1
if cooldown:
self.cool_until = time.time() + cooldown
@property
def recent_rate(self):
"""滑动窗口成功率,比全局成功率更能反映当前状态"""
if not self.window:
return 1.0
return sum(self.window) / len(self.window)
@property
def avg_latency(self):
return self.latency_sum / self.ok if self.ok else 0.0
用 deque(maxlen=N) 做滑动窗口是关键。全局成功率会被历史数据稀释——一条线路前 100 次都成功,最近 20 次全失败,全局成功率还是 83%,但实际已经不能用了。滑动窗口能反映"最近怎么样"。
冷却而不是直接删除
发现一条线路有问题,不要立刻从池子里删掉。网络抖动、目标站临时限流都是会恢复的。
正确的做法是给冷却期,到期后自动放回池子重新测试。
class ProxyPool:
def __init__(self, addrs):
self.stats = {a: ProxyStat(a) for a in addrs}
def acquire(self):
"""取一条可用线路"""
now = time.time()
alive = [s for s in self.stats.values() if s.cool_until <= now]
if not alive:
return None
# 优先选近期成功率高的
alive.sort(key=lambda s: s.recent_rate, reverse=True)
# 在前几名里随机,避免总是用同一条
import random
return random.choice(alive[:max(1, len(alive) // 3)])
def report(self, addr, success, latency=0.0):
st = self.stats.get(addr)
if not st:
return
# 连续失败 3 次,冷却 60 秒
cooldown = 60 if (not success and st.fail_streak >= 2) else 0
st.record(success, latency, cooldown)
# 冷却期随失败次数指数增长,最长 30 分钟
if cooldown:
st.cool_until = time.time() + min(60 * (2 ** st.fail_streak), 1800)
冷却时长用指数退避:连续失败越多,冷却越久。这样避免"失败→冷却 60 秒→恢复→又失败"的循环浪费。
失败类型要分开处理
这一步很多人忽略,但对成功率影响很大。
import requests
def classify(exc=None, status=None):
"""把失败归类,决定要不要换 IP"""
if status == 403:
return "blocked" # 目标站拒绝,换 IP 可能有用
if status == 429:
return "rate_limit" # 频率超限,换 IP 没用,该降速
if status and 500 <= status < 600:
return "server_error" # 目标站问题,换 IP 没用,该重试等
if isinstance(exc, requests.exceptions.Timeout):
return "timeout" # 线路慢或目标站慢,可以先降速试试
if isinstance(exc, requests.exceptions.ProxyError):
return "proxy_dead" # 线路本身挂了,直接冷却
return "unknown"
def should_switch_proxy(kind):
"""只有这几种情况值得换出口"""
return kind in ("blocked", "proxy_dead")
关键判断:不是所有失败都该换 IP。
- 403 → 换,可能这条被拉黑了
- 429 → 不换,换多少条都一样,该降低频率
- 5xx → 不换,目标站自己的问题
- 超时 → 先不换,可能是并发太高
- ProxyError → 换,线路死了
只对 blocked 和 proxy_dead 换出口,其它情况先降速重试。这一条能让成功率提升非常明显,因为大部分"失败"其实是自己把目标站打急了。
退避策略
失败后的重试间隔要随失败次数增长,而且要有随机抖动,避免多个任务同时重试形成尖峰。
import random
def backoff(attempt, base=1.0, cap=60.0, jitter=0.3):
"""指数退避 + 抖动"""
delay = min(base * (2 ** attempt), cap)
return delay * (1 + random.uniform(-jitter, jitter))
没有抖动的话,一批任务同时失败会同时重试,对目标站形成规律的冲击波,很容易触发风控。加了抖动之后重试时间散开,看起来更像正常流量。
怎么接入 Scrapy
在 downloader middleware 里做,不要太早也不要太晚。
from scrapy import signals
class HealthMiddleware:
def __init__(self, pool):
self.pool = pool
@classmethod
def from_crawler(cls, crawler):
mw = cls(crawler.settings.get("PROXY_POOL"))
crawler.signals.connect(mw.spider_closed, signal=signals.spider_closed)
return mw
def process_request(self, request, spider):
if request.meta.get("no_proxy"):
return None
addr = self.pool.acquire()
if not addr:
raise Exception("代理池枯竭,全部线路都在冷却")
request.meta["proxy"] = "http://" + addr
request.meta["_proxy_addr"] = addr
request.meta["_t0"] = time.time()
return None
def process_response(self, request, response, spider):
addr = request.meta.get("_proxy_addr")
if not addr:
return response
latency = time.time() - request.meta.get("_t0", time.time())
kind = classify(status=response.status)
if response.status in (200, 301, 302):
self.pool.report(addr, True, latency)
elif should_switch_proxy(kind):
self.pool.report(addr, False)
# 429 / 5xx 不惩罚线路,交给 retry 中间件
return response
def process_exception(self, request, exception, spider):
addr = request.meta.get("_proxy_addr")
if not addr:
return None
kind = classify(exc=exception)
if should_switch_proxy(kind):
self.pool.report(addr, False)
return None
def spider_closed(self, spider):
# 输出健康度报告
rows = sorted(self.pool.stats.values(), key=lambda s: s.recent_rate)
for s in rows[:10]:
spider.logger.info(
"proxy=%s rate=%.2f avg=%.0fms total=%d",
s.addr, s.recent_rate, s.avg_latency * 1000, s.total)
注意 process_response 里对 429 和 5xx 不做惩罚——这是前面说的"失败分类"的直接应用。
监控指标
跑起来之后,这几个数值得盯着:
| 指标 | 健康范围 | 异常说明 |
|---|---|---|
| 池子可用率 | > 70% | 低于这个说明线路质量差或冷却设置过激 |
| 平均成功率 | > 90% | 低说明目标站风控紧或并发过高 |
| 平均延迟 | < 800ms | 高说明线路调度有问题 |
| 冷却线路占比 | < 30% | 长期高说明池子需要换血 |
如果可用率一直很低,先别急着加线路,检查两件事:冷却时长是不是设太长了,以及是不是把 429 也当成线路问题在惩罚。
大部分"池子不够用"的问题,实际上是惩罚逻辑写错了。
最后
这套东西的价值不在于复杂,在于把"感觉这条线路不行"变成可量化的判断。
用滑动窗口看近期表现,用失败分类决定要不要换,用指数退避控制重试节奏——三件事做完,池子的有效利用率能提升一个档次。
至于线路本身从哪来,那是另一回事。我用的 58无极 的进程代理方案,按进程分流,多个采集任务可以各走各的出口,官网 58wuji.com 上有免费的出口体检工具,可以先测一下自己的环境再决定。
代码可以直接跑,ProxyStat 和 classify 这两个类跟具体供应商无关,换成任何代理服务都能用。
浙公网安备 33010602011771号