在做大规模采集时,单出口 IP 一旦被目标站标记,后续请求就会大量失败。买商业代理按量计费成本不低,而且很多场景其实用不上那么高规格的住宅 IP。本文记录一套自建 IP 轮换池的完整落地过程:用多台服务器作为出口,自己实现健康探测、熔断剔除、轮询分配和粘性会话,并接入 crawl4ai 0.9.1 实测跑通。
一、为什么不自建一个轮换器
先澄清一个常见的坑:"换 IP"不是把代理地址填进去就完事。一套能用的轮换系统至少要回答四个问题:
- 哪些出口现在是活的?(健康探测)
- 某个出口挂了,要不要从池里踢掉?(熔断 + 剔除)
- 每次请求该用哪个出口?(轮询分配)
- 同一批需要"稳定出口"的请求,怎么保证不跳 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 引用 |
七、生产部署建议
- 出口数量:5-10 台不同地区的服务器,每台开一个 HTTP 代理端口。
- 探针目标 :用
http://ifconfig.me/ip这类回显服务,能拿到真实出口 IP 的同时校验响应。 - 并发 :
probe_timeout调低 + 起多个 crawler 实例可提升整体吞吐。 - 日志 :把
pool_stats()定期打点,出口健康度变化有迹可循。
免责声明
本文方案仅用于合法合规的数据采集场景,请遵守目标网站的 robots 协议、服务条款及所在地区法律法规。本文不包含任何付费代理推广信息。