自建 IP 轮换方案实战:健康探测 + 熔断剔除 + 粘性会话

在做大规模采集时,单出口 IP 一旦被目标站标记,后续请求就会大量失败。买商业代理按量计费成本不低,而且很多场景其实用不上那么高规格的住宅 IP。本文记录一套自建 IP 轮换池的完整落地过程:用多台服务器作为出口,自己实现健康探测、熔断剔除、轮询分配和粘性会话,并接入 crawl4ai 0.9.1 实测跑通。

一、为什么不自建一个轮换器

先澄清一个常见的坑:"换 IP"不是把代理地址填进去就完事。一套能用的轮换系统至少要回答四个问题:

  1. 哪些出口现在是活的?(健康探测)
  2. 某个出口挂了,要不要从池里踢掉?(熔断 + 剔除)
  3. 每次请求该用哪个出口?(轮询分配)
  4. 同一批需要"稳定出口"的请求,怎么保证不跳 IP?(粘性会话)

其中第 1 点最容易做错:很多人用"TCP 能不能连上"当健康标准,但半死代理(能连上、转发一直超时)照样混在池里。正确的做法是真实发起一次 HTTP 请求,并校验响应内容

二、整体架构

复制代码
┌─────────────────────────────────────────────┐
│  crawl4ai 任务(AsyncWebCrawler)             │
│    CrawlerRunConfig(                         │
│      proxy_rotation_strategy=SelfHosted...,  │
│      proxy_session_id="kb_wiki_daily"        │
│    )                                         │
└──────────────┬──────────────────────────────┘
               │
┌──────────────▼──────────────────────────────┐
│  SelfHostedRotationStrategy(核心)           │
│  ├ 健康探测:真实 HTTP 请求 + 超时 + 回显校验   │
│  ├ 熔断:连续失败 ≥ max_fails → 硬剔除出池      │
│  ├ 轮询:在健康出口中 round-robin              │
│  ├ 粘性会话:session_id 在 TTL 内锁定同一出口   │
│  └ 可观测:pool_stats() 实时池状态              │
└──────────────┬──────────────────────────────┘
               │
     ┌─────────┼─────────┐
     ▼         ▼         ▼
  出口 1     出口 2     出口 N
 (VPS 机房IP)(VPS 机房IP)(VPS 机房IP)

出口池由多台服务器的 HTTP 代理组成,本方案负责调度层,不关心出口底层用什么代理软件。

三、核心实现

3.1 健康状态记录

python 复制代码
class ProxyHealth:
    def __init__(self, server: str):
        self.server = server
        self.ok = 0           # 累计成功次数
        self.fail = 0         # 累计失败次数
        self.consecutive_fails = 0   # 连续失败次数(熔断依据)
        self.last_latency = 0.0      # 最近一次探测延迟
        self.last_checked = None

3.2 健康探测(真实请求 + 回显校验)

关键点:探测走代理发真实 HTTP 请求,目标返回的内容必须能被解释(不是随便一个状态码)。这里用回显服务,返回体里带一个只有代理能打上的标记头,用来证明"这次请求确实经过了出口转发"------这个细节在后面验证环节至关重要。

python 复制代码
async def is_healthy(self, server: str) -> bool:
    h = self._pool.get(server)
    if not h:
        return False
    cfg = self._cfg.get(server)
    if not cfg:
        return False
    try:
        async with aiohttp.ClientSession() as s:
            async with s.get(self.health_url,
                             proxy=f"http://{cfg.server}",
                             timeout=aiohttp.ClientTimeout(total=self.probe_timeout)) as r:
                await r.json()   # 必须能解出 JSON,才算"活着"
        h.ok += 1
        h.consecutive_fails = 0
        h.last_checked = time.time()
        return True
    except Exception:
        self.mark_failed(server)
        return False

3.3 熔断 + 自动剔除

连续失败达到阈值,出口被硬剔除出池(不再是"冷却后自动回来",防止半死出口反复占位):

python 复制代码
def mark_failed(self, server: str):
    h = self._pool.get(server)
    if not h:
        return
    h.consecutive_fails += 1
    h.fail += 1
    if h.consecutive_fails >= self.max_fails:
        self.remove(server)   # 硬剔除

3.4 轮询分配 + 粘性会话

get_next_proxy 在健康出口中按 round-robin 轮询(不是"遍历到第一个健康就返回"------那样永远只用同一个出口)。get_proxy_for_session 为同一 session_id 在 TTL 内锁定同一出口:

python 复制代码
async def get_next_proxy(self) -> Optional[ProxyConfig]:
    async with self._lock:
        n = len(self._pool._pool)
        self._rr_index = (self._rr_index + 1) % max(n, 1)
        return await self.get_next_proxy_unlocked()

async def get_proxy_for_session(self, session_id: str, ttl=None) -> Optional[ProxyConfig]:
    async with self._lock:
        now = time.time()
        # 清理已过期的会话
        expired = [sid for sid, (_, exp) in self._sessions.items() if exp <= now]
        for sid in expired:
            self._pool.mark_failed(self._sessions[sid][0].split('//')[1] if '//' in self._sessions[sid][0] else self._sessions[sid][0])
            del self._sessions[sid]
        # 已有会话:TTL 内复用同一出口
        if session_id in self._sessions:
            server, exp = self._sessions[session_id]
            cfg = self._cfg.get(server)
            if cfg and exp > now:
                return cfg
            else:
                del self._sessions[session_id]
        # 分配新出口
        cfg = await self.get_next_proxy_unlocked()
        if cfg:
            self._sessions[session_id] = (cfg.server, now + (ttl or self.default_ttl))
        return cfg

完整实现文件结构(约 170 行):

复制代码
SelfHostedProxyPool
├── add(cfg)                 # 登记出口
├── is_healthy(server)       # 健康探测
├── mark_failed(server)      # 失败累计
├── is_exhausted(server)     # 是否达到熔断阈值
├── remove(server)           # 剔除
├── available()              # 当前可用出口
└── stats()                  # 池状态

SelfHostedRotationStrategy(ProxyRotationStrategy)
├── add_proxies(proxies)
├── get_next_proxy()         # 轮询
├── get_proxy_for_session()  # 粘性会话
├── release_session()
├── get_active_sessions()
└── pool_stats()

四、端到端验证(真实运行输出)

验证思路最关键的一步:怎么证明"请求真的走了代理" 。本地起一个真正的 HTTP 代理转发器,转发时给上游打上 X-Proxy-Via 标记头(客户端无法伪造),上游回显该头。直连请求没有标记、经代理请求有标记------这就是铁证。

4.1 探针真的走代理了吗

复制代码
=== 1. 探针真的走代理了吗?(标记头铁证)===
  直连   -> proxy_via=None
  经出口A -> proxy_via=19301
  经出口B -> proxy_via=19303
  => 验证通过 ✓  两个出口都可被区分,客户端无法伪造该标记

4.2 健康探测 + 熔断 + 自动剔除

池里放 3 个出口,其中一个是"宕机"的(不监听端口)。连续失败 2 次后被剔除:

复制代码
=== 2. 健康探测 + 熔断 + 自动剔除 ===
  初始池: 3 个出口
  第1次 get_next_proxy -> 127.0.0.1:19303  (池大小=3, 可用=3)
  第2次 get_next_proxy -> 127.0.0.1:19301  (池大小=3, 可用=3)
  第3次 get_next_proxy -> 127.0.0.1:19301  (池大小=2, 可用=2)   ← 坏出口被剔除
  第4次 get_next_proxy -> 127.0.0.1:19303  (池大小=2, 可用=2)
  第5次 get_next_proxy -> 127.0.0.1:19301  (池大小=2, 可用=2)
  => 剔除后池内剩余: ['19301', '19303']

4.3 真正轮询(不是永远挑第一个)

复制代码
=== 3. 轮换器在健康出口中真正轮询 ===
  4 次分配顺序: ['19303', '19301', '19303', '19301']
  => 出现端口 19301: YES   出现端口 19303: YES
  => 坏出口 19302 是否出现: NO(正确)

4.4 粘性会话

复制代码
=== 4. 粘性会话 ===
  kb_wiki_daily #1: 127.0.0.1:19301
  kb_wiki_daily #2: 127.0.0.1:19301  (锁定同一出口: True)   ← 会话期内不换出口
  bank_finance #1:  127.0.0.1:19301
  活跃会话数: 2
  release bank_finance 后活跃会话数: 1

4.5 池状态可观测

复制代码
=== 6. 池状态快照 ===
  总出口=2  可用=2
    127.0.0.1:19301: 成功13 失败1 连续失败0 延迟0.004s
    127.0.0.1:19303: 成功13 失败0 连续失败0 延迟0.003s

五、接入 crawl4ai

python 复制代码
from self_hosted_pool import SelfHostedProxyPool, SelfHostedRotationStrategy
from crawl4ai.async_configs import ProxyConfig

proxies = [
    ProxyConfig(server="http://vps1.example.com:8080"),
    ProxyConfig(server="http://vps2.example.com:8080"),
    ProxyConfig(server="http://vps3.example.com:8080"),
]

pool = SelfHostedProxyPool(
    max_fails=3,          # 连续失败 N 次剔除
    probe_timeout=5.0,    # 探针超时
    health_url="http://ifconfig.me/ip",   # 探针目标
)
strat = SelfHostedRotationStrategy(pool, {})
strat.add_proxies(proxies)

from crawl4ai import AsyncWebCrawler, CrawlerRunConfig
from crawl4ai.async_configs import BrowserConfig

async def crawl_many():
    async with AsyncWebCrawler(config=BrowserConfig()) as crawler:
        for i in range(100):   # 批量采集
            result = await crawler.arun(
                url="https://target.site/page",
                config=CrawlerRunConfig(
                    proxy_rotation_strategy=strat,
                    proxy_session_id=f"batch_{i % 5}",   # 会话粘性
                    proxy_session_ttl=300,
                ),
            )

六、踩坑记录

现象 解决
轮换变成"只挑第一个" 返回"遍历到第一个健康出口",坏出口永远轮不到 改为健康出口 round-robin,维护轮询游标
死锁 持锁方法里再调用取锁方法 拆出无锁内部版本 get_next_proxy_unlocked()
半死代理漏网 TCP 能连但转发超时,混在池里 健康判定用"真实 HTTP 请求 + 响应可解析"
端口 TIME_WAIT 导致 bind 失败 代理服务器启动失败,但无报错 代理服务器开 allow_reuse_address
self.server_address 拿不到 StreamRequestHandler 上要取 self.server.server_address 用 handler 的 self.server 引用

七、生产部署建议

  1. 出口数量:5-10 台不同地区的服务器,每台开一个 HTTP 代理端口。
  2. 探针目标 :用 http://ifconfig.me/ip 这类回显服务,能拿到真实出口 IP 的同时校验响应。
  3. 并发probe_timeout 调低 + 起多个 crawler 实例可提升整体吞吐。
  4. 日志 :把 pool_stats() 定期打点,出口健康度变化有迹可循。

免责声明

本文方案仅用于合法合规的数据采集场景,请遵守目标网站的 robots 协议、服务条款及所在地区法律法规。本文不包含任何付费代理推广信息。

相关推荐
luj_176824 分钟前
合法避税与投资评估实战指南
c语言·开发语言·网络·经验分享·算法
库玛西30 分钟前
Linux网络编程:HTTP/HTTPS协议核心技术全解析
linux·运维·服务器·网络·c++·http·https
暴力求解1 小时前
Linux网络---NAT代理服务、内网穿透
linux·网络·智能路由器
2601_967659891 小时前
大学实训室网络仿真环境搭建:Ranplan 3D射线跟踪双版本技术解析
网络·3d
for_ever_love__1 小时前
HTTP协议及网络请求与响应
网络·网络协议·http
_F_y1 小时前
Linux网络常用指令
linux·运维·网络
段一凡-华北理工大学2 小时前
高炉炉况智能诊断与预警实战~系列文章18:预警提前量问题:过程滞后与预测窗口的权衡
服务器·网络·人工智能·机器学习·高炉智能化·炉况预警·工业预警滞后
csdn_aspnet2 小时前
Modbus RTU 与 Modbus TCP:它们有什么区别?
网络·tcp/ip·tcp·modbus·rtu
零叁柒陆网络科技2 小时前
浉河网站制作从开始到上线一般需要多长时间?
网络