Python代理IP采集怎样减少无效请求?6步优化脚本效率

简介: 本文剖析代理采集失败率反升的根源:脚本缺乏“识别→判断→重试”闭环。提出六步改造法(预检、分层超时、分级重试、Session复用、并发约束、IP退池),每步配可运行代码,精准消除对应无效请求,助你将失败率可控降至5%以内。

采集脚本接入代理后失败率不降反升,是个很常见的现象。原因通常不是代理质量差,而是脚本缺少"识别失败类型 → 判断处理动作 → 针对性重试"这条闭环。本文按改造顺序拆成六步,每一步给出可运行代码,并说明它消除的是哪一类无效请求。


环境与前提

代码在以下环境验证:

  • Python 3.11、requests 2.31、aiohttp 3.9、urllib3 2.x
  • 目标端:自建静态站点(用于可控压测)+ 若干公开数据接口
  • 代理:极安代理,测试中用到两种接入形态
    • 短效代理:单 IP 存活 1–15 分钟(五档可选),到期自动失效
    • 隧道代理:脚本侧固定一个入口地址,出口 IP 由服务端轮换

后文第 4 步的 Session 复用粒度、第 6 步的冷却期取值,都是从上面这两个参数推出来的。换成其他供应商时,需要按各自的 IP 存活时长和接入方式重新标定这两个数——这也是本文把环境写在前面的原因。

合规提醒:以下方法只适用于公开数据采集。执行前请确认目标站点的 robots.txt 与服务条款,遵守其中声明的 Crawl-delay,不采集个人信息、不绕过登录态与付费墙、不对目标造成可感知的负载压力。


0. 先把失败分类,再谈优化

绝大多数"优化"失败,是因为改造前后只有一个总失败率数字,无法判断哪一步起了作用。所以第一件事是打点,把失败拆成可归因的类别。

import threading
from collections import Counter

import requests


class FailureStats:
    """线程安全的失败分类计数器。"""

    def __init__(self):
        self._counter = Counter()
        self._lock = threading.Lock()

    def record(self, category: str) -> None:
        with self._lock:
            self._counter[category] += 1

    def report(self) -> dict:
        with self._lock:
            total = sum(self._counter.values())
            if not total:
                return {
   }
            return {
   
                k: {
   "count": v, "ratio": round(v / total, 4)}
                for k, v in self._counter.most_common()
            }


def classify(status: int | None = None, exc: BaseException | None = None) -> str:
    """把一次请求结果归到一个可决策的类别上。"""
    if status is not None:
        if 200 <= status < 300:
            return "ok"
        if status in (401, 403, 451):
            return "target_reject"
        if status == 404:
            return "not_found"
        if status == 429:
            return "rate_limited"
        if 500 <= status < 600:
            return "target_5xx"
        return f"http_{status}"

    # 注意判断顺序:ProxyError / SSLError / ConnectTimeout
    # 都是 ConnectionError 或 Timeout 的子类,先具体后笼统。
    if isinstance(exc, requests.exceptions.ProxyError):
        return "proxy_error"
    if isinstance(exc, requests.exceptions.SSLError):
        return "ssl_error"
    if isinstance(exc, requests.exceptions.ConnectTimeout):
        return "connect_timeout"
    if isinstance(exc, requests.exceptions.ReadTimeout):
        return "read_timeout"
    if isinstance(exc, requests.exceptions.ConnectionError):
        return "conn_error"
    return "unknown"

有了这张分布表,后面每一步改造带来的变化才是可验证的:预检生效,proxy_error 占比应当下降;分级重试生效,target_reject 的重试次数应当归零。

六步改造与它们各自消除的失败类别:

步骤 改造点 主要消除
1 入池前连通性预检 proxy_errorconnect_timeout
2 超时分层 长尾 read_timeout 拖慢吞吐
3 按类别分级重试 target_reject / not_found 的无意义重试
4 Session 与连接池复用 握手开销、连接数膨胀
5 并发上限约束 rate_limitedtarget_5xx
6 失败 IP 退池与冷却 同一坏 IP 的重复命中(雪崩)

1. 入池前的连通性预检

预检要回答的是"这个 IP 此刻能不能用",不是"以后能不能用"。所以它必须足够便宜:单次、不重试、短超时、并发受控。

from concurrent.futures import ThreadPoolExecutor

import requests

# 建议换成自建的 echo 端点,公共服务本身会限速,
# 预检结果会被它的限速污染。
PRECHECK_URL = "https://your-own-echo.example.com/ip"


def precheck(proxy: str) -> str | None:
    try:
        resp = requests.head(
            PRECHECK_URL,
            proxies={
   "http": proxy, "https": proxy},
            timeout=(2, 3),
            allow_redirects=False,
        )
        return proxy if resp.status_code < 400 else None
    except requests.RequestException:
        return None


def precheck_batch(proxy_list: list[str], workers: int = 20) -> list[str]:
    with ThreadPoolExecutor(max_workers=workers) as pool:
        return [p for p in pool.map(precheck, proxy_list) if p]

几个容易踩的点:

  • HEAD 而非 GET,只验通路不拉正文;
  • allow_redirects=False,跳转本身不是可用性信号;
  • 预检不重试。预检里重试等于把"筛选"变成了"抢救",成本会翻倍;
  • 预检并发别开太高,20 上下即可,否则预检自己会成为瓶颈。

短效 IP 的场景下,预检和提取应该合并成一步:提取后立刻过一遍连通性再入池,中间不要有队列积压,否则 IP 在排队时就已经过期了。


2. 超时分层

timeout=10 这种写法会把"慢但可用"和"根本连不上"当成同一件事处理。requests 支持二元组,把两者拆开:

resp = requests.get(
    target_url,
    proxies={
   "http": proxy, "https": proxy},
    timeout=(3, 15),   # (connect_timeout, read_timeout)
    headers=default_headers,
)
  • 连接超时:3 秒内建不上连接,基本可以判定这条链路有问题,快速失败换 IP,不必等待;
  • 读取超时:15 秒是留给慢链路的窗口,目标站点响应慢和代理不可用是两回事。

经验区间:连接超时 2–5 秒,读取超时 10–20 秒。把 timeout 统一写成 30 秒是常见反模式——坏代理不会被及时淘汰,还会长时间占住 worker,整体吞吐反而更低。

aiohttp 侧对应的写法粒度更细:

import aiohttp

timeout = aiohttp.ClientTimeout(
    total=30,
    sock_connect=3,
    sock_read=15,
)

3. 按失败类别分级重试

不是所有失败都值得重试。403、404 重试一百次结果一样,而 502、连接中断换个 IP 往往就通了。

类别 动作 最大次数 说明
ok 接受 记录成功
target_reject(401/403/451) 跳过 0 目标明确拒绝,换 IP 也无效
not_found 跳过 0 资源不存在
rate_limited(429) 延时后重试 1 优先读 Retry-After
target_5xx 原 IP 短延时重试 2 目标侧故障,与代理无关
proxy_error / conn_error 换 IP 重试 3 链路问题
connect_timeout / read_timeout 换 IP 重试 2 代理慢或失联
ssl_error 换 IP 重试 1 多为中间设备劫持

落成配置表而不是 if-else 链,后续调参只改数据:

import random
from enum import Enum


class Action(str, Enum):
    ACCEPT = "accept"
    SKIP = "skip"
    RETRY_SAME = "retry_same"
    RETRY_SWITCH = "retry_switch"
    DELAY_RETRY = "delay_retry"


POLICY: dict[str, tuple[Action, int]] = {
   
    "ok":              (Action.ACCEPT,       0),
    "target_reject":   (Action.SKIP,         0),
    "not_found":       (Action.SKIP,         0),
    "rate_limited":    (Action.DELAY_RETRY,  1),
    "target_5xx":      (Action.RETRY_SAME,   2),
    "proxy_error":     (Action.RETRY_SWITCH, 3),
    "conn_error":      (Action.RETRY_SWITCH, 3),
    "connect_timeout": (Action.RETRY_SWITCH, 2),
    "read_timeout":    (Action.RETRY_SWITCH, 2),
    "ssl_error":       (Action.RETRY_SWITCH, 1),
    "unknown":         (Action.RETRY_SWITCH, 1),
}


def decide(category: str, attempt: int) -> Action:
    action, max_attempts = POLICY.get(category, (Action.RETRY_SWITCH, 1))
    if action in (Action.ACCEPT, Action.SKIP):
        return action
    return action if attempt < max_attempts else Action.SKIP


def backoff(attempt: int, base: float = 1.0, cap: float = 30.0) -> float:
    """指数退避 + 抖动,避免多协程在同一时刻集中重试。"""
    exp = min(cap, base * (2 ** attempt))
    return exp * (0.5 + random.random() * 0.5)

统一按"失败就重试 3 次"处理的脚本,重试请求里有相当大一部分是打在硬拒绝上的——这些请求全部属于无效请求,且会额外消耗 IP 配额。分级之后,重试预算才会花在真正可能成功的失败上。

429 的处理还有一个细节:优先读响应头里的 Retry-After,服务端已经给出了等待时长,自己拍一个 60 秒既可能不够也可能浪费。

def retry_after_seconds(resp, fallback: float = 60.0) -> float:
    value = resp.headers.get("Retry-After")
    if not value:
        return fallback
    try:
        return float(value)
    except ValueError:
        return fallback   # HTTP-date 格式,按需再解析

4. Session 与连接池复用

每次 requests.get() 都会新建连接,重复付出 DNS 解析 + TCP 握手 + TLS 握手的成本。代理链路下这段开销更明显,因为握手要多走一跳。

import threading

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry


class SessionFactory:
    """按代理入口地址复用 Session,退池时同步关闭释放连接。"""

    def __init__(self, pool_connections: int = 10, pool_maxsize: int = 20):
        self._sessions: dict[str, requests.Session] = {
   }
        self._lock = threading.Lock()
        self._pool_connections = pool_connections
        self._pool_maxsize = pool_maxsize

    def _build(self, proxy: str) -> requests.Session:
        session = requests.Session()
        session.proxies = {
   "http": proxy, "https": proxy}
        adapter = HTTPAdapter(
            pool_connections=self._pool_connections,
            pool_maxsize=self._pool_maxsize,
            # 底层重试全部关掉,重试策略由上层的 POLICY 统一决定,
            # 否则两套重试会叠乘,实际请求数远超预期。
            max_retries=Retry(total=0, backoff_factor=0, status_forcelist=[]),
        )
        session.mount("http://", adapter)
        session.mount("https://", adapter)
        return session

    def get(self, proxy: str) -> requests.Session:
        with self._lock:
            if proxy not in self._sessions:
                self._sessions[proxy] = self._build(proxy)
            return self._sessions[proxy]

    def drop(self, proxy: str) -> None:
        with self._lock:
            session = self._sessions.pop(proxy, None)
        if session is not None:
            session.close()

两个关键点:

一、底层重试必须关掉。 HTTPAdapter 默认的 Retry 和第 3 步的分级重试是两套独立机制,同时开启时实际请求数是两者相乘,日志上会表现为"明明只配了 3 次重试,抓包却有 9 次"。

二、退池时要 close() 只从字典里删掉引用,底层连接池要等 GC 才释放,长跑任务里会看到 socket 数持续上涨。

复用粒度取决于接入形态:

  • 独立 IP 列表:Session 按 IP 建,一个 IP 一个池;
  • 隧道形态:脚本只固定一个入口地址,出口 IP 由服务端轮换,此时整个进程可以共用一个 Session,握手成本被摊薄到接近零——这是隧道接入在工程上最实际的收益。

5. 并发上限约束(附一个高频误用)

并发不是越高越好。单 IP 对同一目标的并发超过阈值就会触发限速,rate_limitedtarget_5xx 会同时上升,整体吞吐反而下降。

定档依据:

  • robots.txt 声明了 Crawl-delay 的,按声明执行,不做加速;
  • 无声明的公开数据,单 IP 并发 1–3,总并发随可用 IP 数线性扩展;
  • 页面重、服务端渲染慢的目标,单 IP 并发压到 1。

一个非常容易写错的地方:Semaphore 必须在循环外创建。写成下面这样是无效的——

# ❌ 错误:每个 task 各自持有一个新信号量,等于没有限流
for i, url in enumerate(urls):
    sem = asyncio.Semaphore(MAX_PER_IP)
    tasks.append(fetch(sessions[i % len(sessions)], url, sem))

正确写法是按代理各建一个,并叠加一个全局信号量:

import asyncio

import aiohttp

MAX_PER_IP = 2
MAX_TOTAL = 30
TIMEOUT = aiohttp.ClientTimeout(total=30, sock_connect=3, sock_read=15)


async def fetch(session, url, ip_sem, total_sem):
    async with total_sem, ip_sem:
        try:
            async with session.get(url, timeout=TIMEOUT) as resp:
                if resp.status >= 400:
                    return None, classify(status=resp.status)
                return await resp.text(), "ok"
        except asyncio.TimeoutError:
            return None, "read_timeout"
        except aiohttp.ClientError as exc:
            return None, classify(exc=exc)


async def run_batch(urls: list[str], sessions: dict[str, aiohttp.ClientSession]):
    total_sem = asyncio.Semaphore(MAX_TOTAL)
    ip_sems = {
   proxy: asyncio.Semaphore(MAX_PER_IP) for proxy in sessions}

    keys = list(sessions)
    tasks = [
        fetch(sessions[keys[i % len(keys)]], url,
              ip_sems[keys[i % len(keys)]], total_sem)
        for i, url in enumerate(urls)
    ]
    return await asyncio.gather(*tasks)

并发上限的另一半价值是保护本机。单进程 1000+ 并发时,asyncio 事件循环调度本身会成为新瓶颈,此时提高并发数只会让 P99 延迟继续恶化。


6. 失败 IP 退池与冷却

坏 IP 不退池,会被后续请求反复命中,形成雪崩:失败率上升 → 重试增多 → 又打到同一批坏 IP。

import threading
import time
from collections import defaultdict


class IPPool:
    def __init__(self, proxies, fail_threshold: int = 3, cooldown: int = 300):
        self._available = list(proxies)
        self._failed: dict[str, float] = {
   }
        self._fail_count: dict[str, int] = defaultdict(int)
        self._cursor = 0
        self._fail_threshold = fail_threshold
        self._cooldown = cooldown
        self._lock = threading.Lock()

    def acquire(self) -> str | None:
        """轮询取用,避免总是命中列表头部的同一个 IP。"""
        with self._lock:
            self._recover_locked()
            if not self._available:
                return None
            self._cursor = (self._cursor + 1) % len(self._available)
            return self._available[self._cursor]

    def mark_success(self, proxy: str) -> None:
        with self._lock:
            self._fail_count[proxy] = 0

    def mark_fail(self, proxy: str) -> None:
        with self._lock:
            self._fail_count[proxy] += 1
            if self._fail_count[proxy] >= self._fail_threshold:
                if proxy in self._available:
                    self._available.remove(proxy)
                    self._cursor = 0
                self._failed[proxy] = time.time()

    def _recover_locked(self) -> None:
        now = time.time()
        for proxy, failed_at in list(self._failed.items()):
            if now - failed_at > self._cooldown:
                self._failed.pop(proxy)
                self._fail_count[proxy] = 0
                self._available.append(proxy)

    @property
    def stats(self) -> dict:
        with self._lock:
            return {
   "available": len(self._available), "cooling": len(self._failed)}

几个设计取舍:

  • 失败 3 次才退池,而不是 1 次。单次失败可能来自目标侧抖动,一次就退会误伤大量好 IP;
  • mark_success 要清零计数,否则长跑任务里所有 IP 的失败次数只增不减,最终全池退空;
  • 回收在 acquire 时顺带做,不额外起线程,主流程零等待。

冷却期怎么定,取决于 IP 的存活时长。 固定长效 IP 用 300 秒是合理的;但短效场景下 IP 本身只存活 1–15 分钟(极安代理短效代理为五档可选、到期自动失效),冷却 300 秒等于让 IP 在冷却期内直接过期,回收动作没有意义。这类场景应把冷却压到 60 秒以内,重心从"等它恢复"改为"立即换下一个"。隧道形态则不需要 IPPool 这一层,退池逻辑由服务端承担,脚本侧只保留失败计数用于告警。


整合:一个最小可运行实现

把六步串起来:

import time

import requests


def fetch_with_policy(
    url: str,
    pool: IPPool,
    factory: SessionFactory,
    stats: FailureStats,
    max_rounds: int = 5,
) -> str | None:
    attempt = 0
    proxy = pool.acquire()

    for _ in range(max_rounds):
        if proxy is None:
            stats.record("pool_exhausted")
            return None

        session = factory.get(proxy)
        try:
            resp = session.get(url, timeout=(3, 15))
            category = classify(status=resp.status_code)
        except requests.RequestException as exc:
            category = classify(exc=exc)
            resp = None

        stats.record(category)

        if category == "ok":
            pool.mark_success(proxy)
            return resp.text

        action = decide(category, attempt)
        attempt += 1

        if action is Action.SKIP:
            return None
        if action is Action.DELAY_RETRY:
            time.sleep(retry_after_seconds(resp) if resp is not None else 60.0)
            continue
        if action is Action.RETRY_SAME:
            time.sleep(backoff(attempt))
            continue
        if action is Action.RETRY_SWITCH:
            pool.mark_fail(proxy)
            factory.drop(proxy)
            proxy = pool.acquire()
            time.sleep(backoff(attempt, base=0.5))
            continue

    return None

改造后还剩多少无效请求?

FailureStats.report() 接到日志或监控上,改造前后各跑一轮同样的 URL 集合,对比分布,例如:

失败类别 改造前占比 改造后占比 主要归因
proxy_error 显著下降 第 1、6 步
connect_timeout 显著下降 第 1、2 步
rate_limited 明显下降 第 5 步
target_reject 上的重试量 归零 第 3 步
target_5xx 基本不变 目标侧因素,不可控

具体数值与目标站点、代理形态、IP 数量强相关,建议直接跑自己的基线数据,不要照搬别人的百分比。

无效请求率归零是不可能的:目标站点的瞬时故障、DNS 抖动、路由波动都会产生不可避免的失败。经验上,5% 以内属于良好,10% 以内可接受,持续超过 15% 说明脚本或链路存在需要回查的问题。


超标时的回查顺序

按维度逐层收敛,不要一上来就换代理:

  1. 看错误码分布 —— 是否集中在某一两个类别?集中在 proxy_error 查链路,集中在 rate_limited 查并发,集中在 target_reject 查请求指纹(UA、Header 顺序、Cookie);
  2. 看时间分布 —— 是否集中在某个时段?可能是目标站点的高峰期或定时策略,也可能是自己的定时任务撞在了一起;
  3. 看目标分布 —— 是否集中在某几个 URL 或域名?那多半是目标侧规则变化,与代理无关;
  4. 看 IP 分布 —— 是否集中在少数 IP?说明预检强度不够,或退池阈值设得太宽松。

四个维度定位到具体原因后再动手,比盲目调参有效得多。


小结

代理只解决"出口 IP"这一个环节。真正决定无效请求率的,是脚本对失败的处理逻辑:能不能分清失败类型、会不会在无意义的地方重试、坏 IP 能不能及时退出。这六步的顺序也是有意义的——先有度量,再做筛选(1)、控制单次成本(2、4)、约束速率(5),最后才是失败后的处置(3、6)。跳过第 0 步直接改代码,最后往往说不清是哪一步起了作用。

相关文章
|
7天前
|
云安全 人工智能 运维
阿里云联动百位企业安全专家,共识Agent防御最佳实践
当Agent成为新员工,你的安全边界在哪里?
1924 6
阿里云联动百位企业安全专家,共识Agent防御最佳实践
|
1天前
|
编解码 人工智能 安全
2核4G/4核8G/8核16G阿里云服务器如何选择实例?经济型e、通用算力型u2i与计算型c9i选哪个?
本文介绍了阿里云2核4G、4核8G、8核16G三档主流配置下经济型e、通用算力型u2i和计算型c9i三种实例的最新活动价格与适用场景。同配置下三者价差显著,以2核4G为例,经济型e低至599.93元/年,计算型c9i则高达1742.08元/年。文章详细解析了各实例的性能定位:经济型e适合轻负载入门场景,u2i兼顾稳定算力与性价比,c9i凭借第9代至强处理器与芯片级安全能力支撑高性能业务。同时提示用户可叠加满减优惠券享受折上折,建议根据业务负载与预算综合决策。
488 111
|
6天前
|
存储 人工智能 关系型数据库
阿里云AI产品与云产品最新组合套餐:Token Plan、AI coding及云服务器和建站等组合优惠价
阿里云推出全新“算力+模型+应用”一站式云与AI组合套餐活动,覆盖从个人开发者到中大型企业的全场景需求。核心亮点为分三档定价的Token Plan订阅服务,支持Qwen3.8-Max-Preview大模型调用,错峰时段最低可享0.2折优惠。活动同步推出AI Coding、智能体部署、云电脑托管、0代码建站等十余类场景化组合,搭配99元/年的普惠云服务器、88元/年的入门数据库等经典特惠产品,还为企业提供1V1定制化AI转型方案,大幅降低了不同用户群体拥抱AI的技术门槛与采购成本。
663 111
|
15天前
|
人工智能 JSON 安全
Fastjson远程代码执行漏洞,阿里云AI安全为您保驾护航
阿里云AI安全产品联动防御Fastjson攻击
2587 13
Fastjson远程代码执行漏洞,阿里云AI安全为您保驾护航
|
14天前
|
人工智能 前端开发 Linux
Codex 桌面版安装 + CC Switch 接入第三方 API 完整教程(2026 最新)
2026最新教程:手把手教你安装Codex桌面版,通过CC Switch v3.17.0一键接入Fenno等国产API(兼容OpenAI Responses格式),跳过账号登录,完整启用代码审查、多步任务与上下文感知功能。零基础友好,全程图文实操。(239字)
1769 2
|
2天前
|
人工智能 程序员 API
Codex 接入 DeepSeek-V4-Flash:还能补上识图,提供两套方案
Codex 接入 DeepSeek-V4-Flash 怎么配?本文覆盖 CLI 与桌面端,再用 qwen3-vl-flash 补识图,两套方案可直接照做
|
16天前
|
人工智能 自然语言处理 数据挖掘
Qwen3.8-Max-Preview深度全解析:2.4万亿参数旗舰MoE模型+Token Plan限时优惠完整落地指南
2026年7月,全新旗舰级混合专家大模型Qwen3.8-Max-Preview正式开放抢先体验,作为通义千问Qwen3系列规格最高、综合推理能力顶尖的新一代模型,该模型总参数量达到2.4万亿(2.4T),是当前线上可调用的原生多模态旗舰模型,综合推理水准对标海外顶级Fable 5模型,在复杂工程开发、长文档深度分析、多步骤智能体自治、跨境多语言创作、海量数据挖掘五大高难度业务场景实现跨越式性能提升。
1441 2
|
3天前
Qoder 一周年 × Qwen3.8-Max 正式上线,多重好礼限时领
8月3日,Qwen3.8-Max 正式上线Qoder,迎来Qoder一周年。新老用户可领800次免费调用,下单再赠2000次;夜间(22:00–08:00)调用5折;邀请好友双方得积分与调用额度。
273 0