不少采集程序已经用了ThreadPoolExecutor,但运行日志里的出口IP仍然高度重复。问题通常不在线程池,而在代理仍被写死在全局配置里。

线程只负责并发执行。要让IP随请求轮换,还需要一个独立的代理调度层。

为什么线程数增加了,IP却没有轮换?

增加线程只会提高并发量,不会改变请求使用的代理。

下面三种写法都会造成IP无法正常轮换:

  1. 所有线程共用同一个固定代理。
  2. 程序启动时随机选择一次代理,后续请求一直复用。
  3. 多个线程共享同一个requests.Session,并不断修改其中的代理配置。

第三种情况还可能产生线程安全问题。某个线程刚设置代理A,另一个线程就把配置改成代理B,实际请求使用哪个代理会变得不可控。

多线程IP轮换应遵循一个基本顺序:

领取代理 → 发起请求 → 判断结果 → 归还代理 → 失败时冷却 → 重试时更换代理

多线程IP轮换需要哪些组件?

一个可长期运行的实现至少需要线程池、代理池、线程独立会话和失败恢复四部分。

组件负责什么为什么需要
线程池并发执行采集任务避免频繁创建和销毁线程
代理池轮询分配可用代理防止代理被固定在单个线程上
线程独立会话为每个线程保存连接池避免跨线程修改同一个会话
单代理并发上限控制同一代理同时处理的请求数减少单个出口瞬时压力
失败冷却暂停连续失败的代理防止无效代理被反复调用
有限重试使用其他代理重新请求避免任务因一次连接失败直接结束

对于网站采集器,可将不同页面作为独立任务提交。舆情监测和招投标数据采集也可以使用相同结构,只需替换任务URL及数据解析函数。

完整代码如何实现?

下面的代码可以直接运行,代理文件采用一行一个地址的格式。

先安装依赖:

pip install requests

创建proxies.txt:

http://username:password@192.0.2.10:8000
http://username:password@192.0.2.11:8000
http://192.0.2.12:8000

保存下面代码为ip_rotation.py:

from __future__ import annotations

import argparse
import json
import random
import re
import threading
import time
from collections import deque
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit

import requests
from requests.adapters import HTTPAdapter


@dataclass
class ProxyNode:
    """代理节点及其运行状态。"""

    endpoint: str
    in_flight: int = 0
    fail_streak: int = 0
    cooldown_until: float = 0.0

    @property
    def requests_config(self) -> dict[str, str]:
        return {
            "http": self.endpoint,
            "https": self.endpoint,
        }


class ProxyPool:
    """线程安全的轮询代理池。"""

    def __init__(
        self,
        proxies: list[str],
        max_in_flight: int = 1,
        failure_threshold: int = 2,
        cooldown_seconds: float = 20.0,
    ) -> None:
        if not proxies:
            raise ValueError("代理列表不能为空")

        self._nodes = deque(ProxyNode(endpoint=p) for p in proxies)
        self._max_in_flight = max_in_flight
        self._failure_threshold = failure_threshold
        self._cooldown_seconds = cooldown_seconds
        self._condition = threading.Condition()

    def acquire(self, timeout: float = 15.0) -> ProxyNode:
        """领取一个未超并发上限且不在冷却期的代理。"""

        deadline = time.monotonic() + timeout

        with self._condition:
            while True:
                now = time.monotonic()

                for _ in range(len(self._nodes)):
                    node = self._nodes[0]
                    self._nodes.rotate(-1)

                    available = (
                        node.in_flight < self._max_in_flight
                        and node.cooldown_until <= now
                    )

                    if available:
                        node.in_flight += 1
                        return node

                remaining = deadline - now
                if remaining <= 0:
                    raise TimeoutError("等待可用代理超时")

                ready_times = [
                    max(0.0, node.cooldown_until - now)
                    for node in self._nodes
                    if node.in_flight < self._max_in_flight
                ]

                next_check = min(ready_times, default=0.2)
                wait_seconds = min(
                    remaining,
                    max(0.05, next_check if next_check > 0 else 0.2),
                )
                self._condition.wait(timeout=wait_seconds)

    def release(self, node: ProxyNode, success: bool) -> None:
        """归还代理,并根据请求结果更新失败状态。"""

        with self._condition:
            node.in_flight = max(0, node.in_flight - 1)

            if success:
                node.fail_streak = 0
            else:
                node.fail_streak += 1

                if node.fail_streak >= self._failure_threshold:
                    node.cooldown_until = (
                        time.monotonic() + self._cooldown_seconds
                    )
                    node.fail_streak = 0

            self._condition.notify_all()


_thread_local = threading.local()


def get_session(pool_size: int) -> requests.Session:
    """每个线程创建并复用自己的Session。"""

    if not hasattr(_thread_local, "session"):
        session = requests.Session()
        adapter = HTTPAdapter(
            pool_connections=pool_size,
            pool_maxsize=pool_size,
            max_retries=0,
        )
        session.mount("http://", adapter)
        session.mount("https://", adapter)
        session.headers.update(
            {
                "User-Agent": "Authorized-Data-Collector/1.0",
                "Accept": "application/json,text/html;q=0.9,*/*;q=0.8",
            }
        )
        _thread_local.session = session

    return _thread_local.session


def normalize_proxy(raw_proxy: str) -> str:
    """补全协议并检查代理格式。"""

    proxy = raw_proxy.strip()

    if not proxy:
        raise ValueError("代理地址不能为空")

    if "://" not in proxy:
        proxy = f"http://{proxy}"

    parsed = urlsplit(proxy)

    if not parsed.hostname or not parsed.port:
        raise ValueError(f"代理格式无效:{raw_proxy}")

    return proxy


def mask_proxy(proxy: str) -> str:
    """输出日志前遮盖代理认证信息。"""

    return re.sub(r"//[^/@]+@", "//***:***@", proxy)


def load_proxies(file_path: str) -> list[str]:
    """从文本文件读取代理,自动忽略空行和注释。"""

    path = Path(file_path)

    if not path.is_file():
        raise FileNotFoundError(f"代理文件不存在:{file_path}")

    proxies: list[str] = []

    for line_number, line in enumerate(
        path.read_text(encoding="utf-8").splitlines(),
        start=1,
    ):
        value = line.strip()

        if not value or value.startswith("#"):
            continue

        try:
            proxies.append(normalize_proxy(value))
        except ValueError as exc:
            raise ValueError(
                f"代理文件第{line_number}行格式错误:{exc}"
            ) from exc

    if not proxies:
        raise ValueError("代理文件中没有可用地址")

    return proxies


def extract_preview(response: requests.Response) -> str:
    """提取少量响应内容,用于确认任务结果。"""

    content_type = response.headers.get("Content-Type", "").lower()

    if "application/json" in content_type:
        try:
            data: Any = response.json()
            return json.dumps(data, ensure_ascii=False)[:200]
        except ValueError:
            pass

    return response.text.replace("\n", " ")[:200]


def fetch_with_rotation(
    task_id: int,
    target_url: str,
    proxy_pool: ProxyPool,
    request_timeout: float,
    max_attempts: int,
    pool_size: int,
) -> dict[str, Any]:
    """执行一个任务,失败时重新领取代理。"""

    session = get_session(pool_size)
    last_error = ""

    for attempt in range(1, max_attempts + 1):
        try:
            proxy_node = proxy_pool.acquire(timeout=request_timeout)
        except TimeoutError as exc:
            last_error = str(exc)
            time.sleep(min(0.5 * attempt, 2.0))
            continue

        success = False
        started_at = time.perf_counter()

        try:
            response = session.get(
                target_url,
                proxies=proxy_node.requests_config,
                timeout=request_timeout,
                allow_redirects=True,
            )

            # 403、429及5xx等状态进入更换代理后的重试流程。
            response.raise_for_status()
            success = True

            return {
                "task_id": task_id,
                "success": True,
                "attempt": attempt,
                "status_code": response.status_code,
                "proxy": mask_proxy(proxy_node.endpoint),
                "elapsed_seconds": round(
                    time.perf_counter() - started_at,
                    3,
                ),
                "preview": extract_preview(response),
            }

        except requests.RequestException as exc:
            last_error = f"{type(exc).__name__}: {exc}"

        finally:
            proxy_pool.release(proxy_node, success=success)

        if attempt < max_attempts:
            # 小幅随机退避,避免失败任务在同一时刻再次发起。
            delay = min(0.4 * (2 ** (attempt - 1)), 3.0)
            time.sleep(delay + random.uniform(0.05, 0.25))

    return {
        "task_id": task_id,
        "success": False,
        "attempt": max_attempts,
        "status_code": None,
        "proxy": None,
        "elapsed_seconds": None,
        "error": last_error,
    }


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Python多线程IP轮换示例"
    )
    parser.add_argument(
        "--target",
        required=True,
        help="已获授权的目标URL",
    )
    parser.add_argument(
        "--proxy-file",
        default="proxies.txt",
        help="代理列表文件,默认proxies.txt",
    )
    parser.add_argument(
        "--tasks",
        type=int,
        default=20,
        help="请求任务数,默认20",
    )
    parser.add_argument(
        "--workers",
        type=int,
        default=8,
        help="线程数,默认8",
    )
    parser.add_argument(
        "--per-proxy-concurrency",
        type=int,
        default=1,
        help="单个代理允许的同时请求数,默认1",
    )
    parser.add_argument(
        "--timeout",
        type=float,
        default=10.0,
        help="单次请求及代理等待超时秒数,默认10",
    )
    parser.add_argument(
        "--attempts",
        type=int,
        default=3,
        help="每个任务最多尝试次数,默认3",
    )
    parser.add_argument(
        "--cooldown",
        type=float,
        default=20.0,
        help="连续失败代理的冷却秒数,默认20",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()

    if args.tasks < 1 or args.workers < 1:
        raise ValueError("tasks和workers必须大于0")

    if args.per_proxy_concurrency < 1:
        raise ValueError("per-proxy-concurrency必须大于0")

    proxies = load_proxies(args.proxy_file)

    proxy_pool = ProxyPool(
        proxies=proxies,
        max_in_flight=args.per_proxy_concurrency,
        failure_threshold=2,
        cooldown_seconds=args.cooldown,
    )

    results: list[dict[str, Any]] = []

    with ThreadPoolExecutor(
        max_workers=args.workers,
        thread_name_prefix="collector",
    ) as executor:
        futures = [
            executor.submit(
                fetch_with_rotation,
                task_id,
                args.target,
                proxy_pool,
                args.timeout,
                args.attempts,
                args.workers,
            )
            for task_id in range(1, args.tasks + 1)
        ]

        for future in as_completed(futures):
            result = future.result()
            results.append(result)
            print(json.dumps(result, ensure_ascii=False))

    results.sort(key=lambda item: item["task_id"])

    success_count = sum(
        1 for item in results if item["success"]
    )

    summary = {
        "tasks": len(results),
        "success": success_count,
        "failed": len(results) - success_count,
        "success_rate": (
            f"{success_count / len(results):.2%}"
            if results
            else "0.00%"
        ),
    }

    print("\n任务汇总:")
    print(json.dumps(summary, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

如何运行并验证轮换结果?

先使用少量任务检查代理格式和目标响应,再逐步增加线程数。

运行命令:

python ip_rotation.py \
  --target "https://your-authorized-endpoint.example/check" \
  --proxy-file proxies.txt \
  --tasks 20 \
  --workers 8 \
  --per-proxy-concurrency 1 \
  --attempts 3 \
  --timeout 10

每完成一个任务,程序会输出一条JSON记录:

{
  "task_id": 3,
  "success": true,
  "attempt": 1,
  "status_code": 200,
  "proxy": "http://***:***@192.0.2.11:8000",
  "elapsed_seconds": 0.842,
  "preview": "{\"origin\":\"203.0.113.25\"}"
}

验证时重点看三个字段:

  • proxy是否在不同任务间轮换。
  • attempt是否在请求失败后增加。
  • success_rate是否因线程数提高而明显下降。

如果线程数增加后成功率持续下降,通常说明单代理并发过高、代理池容量不足,或目标端存在更严格的访问频率控制。此时不应继续盲目增加线程。

代码中的轮换机制为什么可靠?

可靠性来自代理调度与线程执行相互独立,而不是简单随机选择。

轮询代替无约束随机

随机选择可能连续多次抽中同一代理。代码使用deque.rotate轮询代理,让可用节点按顺序参与请求。

单代理并发受到控制

in_flight记录代理当前承担的任务数。默认每个代理同时只处理一个请求,适合先验证可用性。确认代理支持更高并发后,再调整--per-proxy-concurrency。

连续失败进入冷却

某个代理连续失败两次后,会暂停20秒。其他线程不会立即再次领取该节点,减少无效重试。

每个线程拥有独立Session

threading.local让每个线程单独维护requests.Session。连接可以复用,但代理配置不会在不同线程之间相互覆盖。

生产环境还需要补充哪些保护?

正式任务不能只看请求是否返回200,还要同时控制业务正确性和访问节奏。

建议补充以下检查:

  1. 业务结果校验:部分响应虽然是200,但正文可能是登录页、提示页或空数据,需要按字段判断是否有效。
  2. 分级失败处理:连接超时可更换代理,参数错误应直接终止,429则应延长等待时间。
  3. 代理质量统计:记录每个代理的成功率、平均延迟和连续失败次数,定期移除低质量节点。
  4. 任务级去重:重试可能导致同一页面被处理多次,写入数据前应使用URL或业务主键去重。
  5. 持久化队列:舆情监测等长期任务可将待处理URL放入消息队列,避免程序退出后丢失进度。
  6. 授权与频率约束:只处理已获授权的数据,并遵守目标端公开规则及合理请求频率。

网站采集器关注页面吞吐,舆情监测关注持续运行,招投标数据采集关注去重和完整性。三类场景都不应把线程数当作唯一性能指标,真正需要监控的是有效请求率、单代理压力和失败恢复能力。

FAQ

Q:一个线程是否应该固定绑定一个IP?

通常不建议长期绑定。固定关系容易让慢代理拖住整个线程,也不利于故障切换。更合适的方式是每个请求领取代理,完成后归还;只有业务明确要求连续会话时,才在一组关联请求内保持同一代理。

Q:随机选择代理和轮询代理有什么区别?

随机选择实现简单,但小规模代理池中可能连续命中同一节点。轮询能让请求分配更均匀。生产环境还可进一步结合成功率和延迟,按代理质量设置领取优先级。

Q:代理数量少于线程数量会发生什么?

代码会根据per-proxy-concurrency限制实际并发。没有可用代理的线程会等待,而不是重复占用同一节点。这样会降低瞬时吞吐,但更容易保持有效请求率。

Q:为什么不让所有线程共用一个Session?

requests.Session适合复用连接,但不适合由多个线程同时修改代理、Cookie和请求头。线程独立Session可以减少状态相互覆盖,同时保留线程内部的连接复用能力。

Q:多久轮换一次IP更合理?

轮换周期取决于任务类型。独立页面可按请求轮换,连续分页或需要保持状态的流程可按任务组轮换。不能只按固定秒数决定,还要结合会话连续性、代理并发能力和失败率。

Q:出现403或429时是否只要更换IP?

不一定。两类状态也可能与权限、请求频率、必要参数或目标端规则有关。代码会将其纳入重试流程,但生产环境还应识别响应内容,并对429采用更长的等待时间。

青果网络代理IP - CTA Banner
点赞(22)
代理IP项目案例复盘:需求分析、接入过程与稳定性优化
代理IP服务商 代理IP 代理稳定性 动态代理IP
2026-10-15

很多代理 IP 项目容易陷入 “加 IP 就能解决不稳定” 的误区。本文以地区公开信息定时采集项目为案例,从前期需求拆解入手,厘清地区覆盖、会话保持、请求节奏与失败判定五大核心判断项;介绍连通验证、统一请求层封装、小流量试运行三段式接入流程。针对上线后任务积压、慢请求占连接、重试风暴等典型故障,讲解分层超时、错误分类重试、阶梯并发的优化思路,并建立请求、任务、数据三层验收标准。说明代理 IP 只是链路一环,稳定采集系统核心在于需求、架构、调度与数据校验的协同配合。

2026 隧道代理哪个平台的成功率高:青果网络、极安代理、快代理、巨量代理……
隧道IP 隧道代理 隧道代理IP 代理服务商 HTTP代理 代理IP 代理IP服务商
2026-10-15

隧道代理没有统一可横向验证的“成功率第一”。选型应优先看请求成功的统计口径、自动换 IP 机制、业务隔离、协议支持、节点覆盖、计费方式和持续服务能力。

2026年海外代理IP市场格局与新兴玩家分析:竞争焦点正在怎么变?
国外代理IP 海外 全球代理IP 代理IP服务商
2026-09-24

2026 年海外代理 IP 市场竞争逻辑已经转变,单纯比拼 IP 池规模、国家覆盖和流量低价的时代逐渐过去。随着 AI 驱动的机器请求激增,目标站点反爬识别持续升级,采购重心从底层网络资源转向有效数据产出。本文将市场划分为资源供给、网络调度、数据交付、治理运营四层,分析新兴玩家切入赛道的四类路径,拆解 AI 检索、跨境监测等新需求带来的产品变革,同时解读合规成为企业采购准入门槛的原因,并预判未来市场五大分化趋势。

2026跨境业务住宅IP怎么选?完整评估框架与决策清单
住宅IP IP地址 全球代理IP 代理IP服务商
2026-09-17

跨境业务选住宅IP,不能只看资源量和国家数。更可靠的顺序是先定义地区、会话和并发需求,再验证网络属性、地区命中、稳定性、合规链路与总成本,最后用真实任务做分阶段测试。

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部