← 返回总目录

Day 192 — 代理池:IP 轮换、健康检查与出口调度

综合实战 652 行 4 个可运行脚本

Day 192 — 代理池:IP 轮换、健康检查与出口调度

阶段:Phase 12 — 综合实战
项目:AI 赋能反爬数据平台 · 第 2/5 天
子主题:出口模型 · 健康检查 · 评分 · 熔断冷却 · 每出口限速 · 粘滞会话 · CONNECT 隧道
代码:1 个可复用实验台 + 3 个可独立运行脚本,全部带 `--self-test`
实测环境:Linux 7.0.0-34-generic / Python 3.12.3 / 2026-10-22 06:00–06:30 CST


今天在项目里的位置

```text
Day 191 引擎骨架 → 队列、限速、重试、落库:先让引擎能稳定跑 ✅
Day 192 代理池 → IP 轮换、健康检查、按目标分配出口 ← 今天
Day 193 浏览器与验证码 → JS 渲染、指纹、识别
Day 194 AI 增强 → 反爬策略预测、内容归类、数据管道
Day 195 运维与交付 → 调度、加密、鉴权、Docker + CI/CD
```

Day 191 的 Fetcher 只会说一句「把 URL 变成 HTML 或一个分类过的错误」。
今天要做的事,就是给这句话加一个前提:用哪个出口去变。

代理池不是"代理列表 + 随机取一个"。它是一套带状态的调度器:
每个出口都有健康度、成功率、延迟、冷却时间,而这些值全部来自真实请求的结果,
不是配置文件里写死的。


概念速览

完整词典见 concepts.md,图解见 diagrams/README.md。

概念 一句话 为什么重要
出口(exit) 目标站看到的那个 IP 代理池调度的最小单位,不是"代理 URL"
透明/匿名/高匿 目标站能否看到你在用代理 透明代理会把 `X-Forwarded-For` 带过去,等于自报家门
正向代理 客户端主动把请求交给代理转发 爬虫用的就是这种;反向代理是服务端的事
CONNECT 让代理建立 TCP 隧道,用于 HTTPS 不支持 CONNECT 的代理,HTTP 能通、HTTPS 全废
绝对 URI `GET http://host/path HTTP/1.1` 正向代理的请求行格式,和普通请求不同
407 Proxy Authentication Required 代理自己的鉴权,不是目标站的 401
健康检查 真发一次请求,验证"从它出去了" TCP 端口能连 ≠ 能上网 ≠ 目标站可达
出口指纹 出口 IP + ASN + 延迟 + 成功率 单看 IP 会漏掉"同 C 段整批被封"
评分 成功率² / (1 + EWMA 延迟) 让好出口多干活,慢出口自然退场
EWMA 指数加权移动平均 只看最近表现,不被历史成绩永久绑架
熔断 连续失败 N 次 → 暂停使用 坏代理不该被每个 worker 各试一遍
冷却(cooldown) 暂停一段时间后再放一个探针 立刻重试 = 把 429 变成 429×N
Retry-After 服务端告诉你多久后再来 尊重它比任何指数退避都有效
半开(half-open) 冷却结束先放一个请求试探 直接满速恢复会把刚恢复的出口再打死
每出口令牌桶 每个 IP 一个速率阀门 轮换不等于提速,目标站按 IP 计数
粘滞会话 同一域固定同一出口 换 IP 会丢 cookie、触发二次验证
出口池水位 可用出口数 / 总出口数 水位低于 30% 就该告警,而不是继续硬跑

概念解释

1. 为什么"代理列表"不够,必须是"池"

一个列表只回答"有哪些地址";一个池要回答四个问题:

```text
┌──────────────────────────────────────────────┐
│ ProxyPool │
│ │
注册 → │ ┌────────┐ ┌────────┐ ┌────────────┐ │ → 分配
│ │ 健康检查│ → │ 评分排序│ → │ 冷却/熔断 │ │
│ └────────┘ └────────┘ └────────────┘ │
│ ▲ ▲ ▲ │
└───────┼──────────────┼─────────────┼────────┘
│ │ │
定时探活 每次请求结果 429/403/超时
```

为什么要这四块:

少任何一块,池子都会退化成"带随机数的列表"。

2. 健康检查:三种"通"不是一回事

```text
TCP 握手成功 代理进程活着 ≠ 能访问外网
HTTP 200(任意页面) 代理能转发 ≠ 转发的是你的目标
200 + 预期标记 真的从该出口出去了 ← 唯一可信的判据
```

本课实验台的做法:代理给每个转发请求注入 `X-Exit-Id`,靶站原样回显。
于是「这个响应到底是不是从 p2 出来的」有密码学级别的确定性——不,是协议级别的确定性。

为什么不用 `https://httpbin.org/ip`:

  1. 生产环境常常没有外网出口(内网爬虫走专线);
  2. 第三方站点会限流你的健康检查,把"检查太频繁"误判成"代理全死";
  3. 它只告诉你出口 IP,不告诉你延迟分布和目标站可达性。

自己起一个 `/health` 靶站,成本几乎为零,可控性高一个数量级。

3. 评分:为什么是"成功率平方"

```python
score = success_rate ** 2 / (1 + ewma_latency)
```

冷启动问题:所有出口初始分数相同,纯 `argmax` 会永远选字典里第一个。
实测(见翻车实验 4)p2/p3 的 `req=0`,坏代理一次都没被探测到。
所以必须先给每个新出口一个探针请求,再进入评分竞争。

4. 冷却与熔断:为什么"立刻换一个"是错的

```text
请求 → 429
├─ 错误做法:立刻换出口,同一页继续打
│ → 3 个出口在 1 秒内全部 429(实测 30 次请求打出 18 个 429)
└─ 正确做法:该出口冷却 max(Retry-After, base)
→ 冷却期内不分配;到期放一个探针(半开);成功才恢复权重
```

为什么冷却要指数增长:一个出口如果每次冷却结束都立刻失败,说明它不是"临时抖动",
而是"已经进了目标站的黑名单"。冷却 1s → 2s → 4s → 8s,能把探测成本压到最低。

为什么必须尊重 Retry-After:服务端已经告诉你"2 秒后再来",
你 0.2 秒后换 IP 再来,在风控看来就是"同一批人在换马甲"。这是行为特征,不是 IP 特征。

5. 每出口限速:轮换 ≠ 提速

这是本课最容易被忽略的一条。

```text
全局 3 req/s,3 个出口:
错误理解:总量 3/s,每个出口 1/s ← 只有你主动按出口分配时才成立
实际写法:每个 worker 都 sleep(1/3)
→ 每个出口 3/s,总 9/s
→ 目标站按出口看到 3/s,立刻 429
```

正确模型:

```text
allowed_total = Σ (每个出口的允许速率)
```

轮换只是把风险摊开,不会让目标站放宽限制。
加出口能提高总吞吐,前提是每个出口都守自己的规矩。

6. 粘滞会话:为什么同一域不能随便换 IP

目标站识别爬虫,不只看 IP,还看一致性:

所以调度器要支持两种模式:

模式 适用 实现
轮换(rotating) 无状态列表页、图片、详情页 每次取当前最高分
粘滞(sticky) 登录、下单、分页表单 `domain → exit` 固定映射,出口冷却时才迁移

粘滞的失效条件只有一个:该出口进入冷却。这时必须迁移,并把"迁移次数"记进指标——
迁移频繁说明出口池太小。

7. CONNECT:HTTPS 代理的分水岭

```text
HTTP (明文) HTTPS (TLS)
────────────── ──────────────
客户端 → 代理: GET http://a/b 客户端 → 代理: CONNECT a:443
代理 → 目标: GET /b 代理 → 目标: TCP 连接
代理 读得到明文 代理 只转发密文,看不到内容
```

所以:一个只实现了绝对 URI 转发的代理,HTTP 健康检查 200,
HTTPS 业务却会报:

```text
URLError:
```

你会以为是目标站封了你,其实是代理不会打隧道。
健康检查必须覆盖业务真正使用的协议。

8. 代理本身的安全边界


定义与使用方法

ProxyPool 核心 API(本课实现)

方法 参数 返回 说明
`check_all()` — `dict[name, bool]` 并发/串行探活,判据是 200 + 标记
`acquire(domain, sticky)` `domain: str`, `sticky: bool` `ProxyState | None` 取一个可用出口,内部处理限速与冷却
`report(proxy, ok, latency, status, retry_after)` 见左 None 回填结果;429/403 触发冷却
`metrics()` — `dict` 总数、成功率、p50/p95、每出口状态
`proxy.score()` — `float` `success_rate² / (1 + ewma_latency)`

常见状态码的处置

状态 含义 正确处置 错误处置
200 成功 记成功、更新延迟 —
301/302 重定向 跟随,但限制次数 当成失败重试
403 目标站拒绝 出口冷却,换出口 立刻用同一出口重试
407 代理鉴权失败 该代理配置错误,永久摘除 当成目标站封禁
429 限流 按 `Retry-After` 冷却 立刻换出口继续打
502/504 代理上游坏 连续 1–2 次即冷却 无限重试
超时 出口慢/黑洞 计失败,超过阈值冷却 无限等

urllib 的代理写法(标准库,不引第三方)

```python
import urllib.request

显式代理:必须自己 build_opener,别依赖全局默认 opener

opener = urllib.request.build_opener(
urllib.request.ProxyHandler({"http": "http://127.0.0.1:8080",
"https": "http://127.0.0.1:8080"})
)

显式直连(清空环境变量影响)

direct = urllib.request.build_opener(urllib.request.ProxyHandler({}))
```


图解

请求在池子里的完整生命周期

```text
┌─────────────────────┐
task.get(url) ─────►│ 选择出口 │
│ 1. 过滤冷却中的 │
│ 2. 冷启动优先探针 │
│ 3. 粘滞域优先复用 │
│ 4. 否则取最高分 │
└──────────┬──────────┘
│ proxy
▼
┌─────────────────────┐
│ 每出口令牌桶 acquire │ ← 等在这里,不占用出口
└──────────┬──────────┘
▼
┌─────────────────────┐
│ fetch(url, proxy) │
└──────────┬──────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
200 429/403 超时/5xx
│ │ │
成功+延迟 冷却+退避 计失败,N 次熔断
└─────────────┴─────────────┘
▼
metrics / 告警
```

冷却状态机

```mermaid
stateDiagram-v2
[*] --> Healthy
Healthy --> Cooling: 429 / 403 / 连续失败
Cooling --> HalfOpen: cooldown 到期
HalfOpen --> Healthy: 探针成功
HalfOpen --> Cooling: 探针失败(冷却翻倍)
Cooling --> Cooling: 期间请求被拒绝分配
```

两种调度模式对比

```text
轮换(rotating) 粘滞(sticky)
req1 → p1 shop:req1 → p1
req2 → p2 shop:req2 → p1
req3 → p3 shop:req3 → p1
req4 → p1 news:req1 → p2
cookie 每次都变 同域 cookie 稳定
适合:公开列表 / 图片 适合:登录 / 下单 / 表单
```


实战代码案例

四个文件,全部只用标准库,全部可离线运行:

文件 作用 自证
`code/00-proxy-lab.py` 真实靶站 + 真实正向代理(支持 407 / CONNECT / 限流 / 延迟 / 502) `8/8`
`code/01-basic-proxy-rotation.py` 健康检查 + 轮询 + 摘除死出口 `7/7`
`code/02-pitfall-connect-and-env.py` CONNECT、环境变量、轮换绕过限速三个翻车现场 `6/6`
`code/03-production-proxy-pool.py` 评分 + 熔断 + 冷却 + 粘滞 + 指标 `9/9`

代码里的实验台不是 mock:它监听真实端口、收发真实 HTTP 报文、真的会 `Connection refused`。
不这样做,407、501、超时这些坑一个都复现不出来。


实战实验手册

所有实验都在 127.0.0.1 上,不访问外网(只有实验 4 的 1c 需要外网,已标注)。
运行环境:Linux 7.0.0-34-generic / Python 3.12.3 / 2026-10-22 06:00–06:30 CST。

实验 0:实验台自证

目的:确认靶站、正向代理、407、CONNECT、限流、kill 端口都按预期工作。

环境:Python 3.12.3,无第三方依赖。

准备 + 执行:

```bash
cd days/day-192-proxy-pool/code
python3 00-proxy-lab.py --self-test
```

预期结果:8 项全部 PASS。

实际结果(本次真机输出):

```text
[PASS] 直连靶站 200 -> {'ok': True, 'exit': 'direct', 'origin': 'lab'}
[PASS] 经 p1 出口标识正确 -> {'ok': True, 'exit': 'p1', 'origin': 'lab'}
[PASS] p1 确实收到 1 个请求 -> {'requests': 1, 'bytes_out': 43, '407': 0, '502': 0}
[PASS] 无 CONNECT 时代理拒绝 HTTPS -> URLError:
[PASS] 不带凭证得到 407 -> 407 {'raw': 'proxy auth required'}
[PASS] 带对凭证得到 200 -> 200 {'ok': True, 'exit': 'p3', 'origin': 'lab'}
[PASS] 同一出口超过窗口得到 429 -> [200, 200, 200, 429, 429]
[PASS] kill 后连接被拒 -> URLError:

8/8 项通过
```

结论:实验台的"出口标识注入"可证明请求真的经过了指定代理,而不是直连。
`kill 后连接被拒` 说明端口是真的关了,不是假死。

清理:脚本退出时 `Lab.stop()` 关闭所有监听端口;可再一次运行确认端口不冲突。


实验 1:健康检查 + 轮询

目的:验证"死代理会被摘除,活着的均分流量",以及全死时的安全返回。

执行:

```bash
python3 01-basic-proxy-rotation.py
```

预期结果:9 个请求 3/3/3;杀掉 p2 后 6 个请求 3/0/3;全死后返回 None。

实际结果:

```text
── 代理池状态(请求结束后)──
出口 健康 延迟ms 成功/失败 最近错误
p1 ❌ 1.7 8/1 URLError: <urlopen error [Errno 111] Con
p2 ❌ 3.8 4/2 URLError: <urlopen error [Errno 111] Con
p3 ❌ 3.9 8/1 URLError: <urlopen error [Errno 111] Con

轮换分布: {'p1': 3, 'p2': 3, 'p3': 3} | p2 死后: {'p1': 3, 'p2': 0, 'p3': 3}
靶站实际收到请求: 20 次

── 断言 ──
[PASS] 三个代理都健康
[PASS] 健康检查真的发了请求
[PASS] 轮询分布均匀 3/3/3
[PASS] 死掉的 p2 被摘除
[PASS] 剩余两个均分 3/3
[PASS] 全死时 acquire 返回 None
[PASS] 全死时 get 也安全返回 None
7/7 项通过
```

结论:`靶站实际收到请求 = 20` 与池子的 20 次分配完全一致,
说明没有"偷偷直连"的旁路——这是健康检查可信的前提。

清理:进程退出即释放端口。


实验 2:健康检查用 HTTP,业务走 HTTPS

目的:复现"HTTP 绿灯、HTTPS 全灭"的假健康。

执行(需要外网;无外网用 `--offline` 跳过 1b/1c):

```bash
python3 02-pitfall-connect-and-env.py
```

实际结果:

```text
[1a] HTTP 健康检查 -> 200 {'ok': True, 'exit': 'p1', 'origin': 'lab'} ← 绿灯,骗过你
[1b] HTTPS 经同一代理 -> URLError:
[1c] 打开 CONNECT 后重试 -> HTTP 200 首字节=b'<!doctype html>'
```

结论:同一台代理,HTTP 200、HTTPS 501。健康检查选错协议 = 整池误判。
1c 证明问题在代理能力,不在网络,也不在目标站。

清理:无残留;外网请求只发往 example.com,不携带任何本地信息。


实验 3:环境变量劫持

目的:证明"显式传 proxy"不等于"显式 opener"。

实际结果:

```text
[2a] 默认 opener -> URLError:
[2b] ProxyHandler({}) -> 200 exit=direct
```

结论:`build_opener()` 不带参数时,`ProxyHandler()` 会读 `http_proxy`。
实验把环境变量指向一个"刚申请就关闭"的端口,于是默认 opener 必然失败,
而显式 `ProxyHandler({})` 直连成功。生产容器里那个看不见的 `http_proxy`
就是"整池代理突然全死"的头号嫌疑。

清理:实验在 `finally` 中恢复原始环境变量(包括原本不存在的键)。


实验 4:轮换绕过自己的限速

目的:量化"裸轮换"与"每出口令牌桶 + 冷却"的差距。

环境:靶站策略 = 每个出口 3 秒窗口内最多 6 次(实验 3a 用 4 次)。

执行:

```bash
python3 02-pitfall-connect-and-env.py --self-test
```

实际结果:

```text
[3a] 裸轮换 30 次 -> 200=12 429=18
前 15 个状态码: [200 ×12, 429, 429, 429]
[3b] 每出口限速+冷却 -> 200=30 429=0 用时=10.61s (2.83 req/s)
```

结论:3 个出口 × 0.9 req/s 理论总速率 2.7/s,实测 2.83/s(开销约 5%)。
裸轮换虽然"每个出口看起来只打了 10 次",但全挤在 1 秒内,直接触发窗口限流。

清理:进程退出释放端口;无临时文件。


实验 5:评分、熔断、粘滞、429 冷却

目的:验证生产级调度器的四个核心行为。

执行:

```bash
python3 03-production-proxy-pool.py --self-test
```

实际结果(真机完整输出):

```text
实验 A:盲轮询 vs 评分选择(p2 慢 120ms,p3 永远 502)
盲轮询 : 成功 24/36 失败 12 分布 {'p1': 12, 'p2': 12, 'p3': 12}
评分池 : 成功 35/36 失败 1 分布 {'p1': 34, 'p2': 1, 'p3': 1}
评分池指标: p50=2.2ms p95=15.7ms 冷却次数=1
p1: req=34 ok=34 fail=0 ewma=2.4ms score=0.943 cooldown=0
p2: req=1 ok=1 fail=0 ewma=122.3ms score=0.396 cooldown=0
p3: req=1 ok=0 fail=1 ewma=0.0ms score=0.074 cooldown=1

实验 B:粘滞会话(同一域必须固定出口)
粘滞 : {'shop': ['p1'], 'news': ['p2']}
盲轮询 : {'shop': ['p1', 'p2', 'p3'], 'news': ['p1', 'p2', 'p3']}

实验 C:429 冷却与恢复
p1 被打爆后: 429=2 冷却次数=2 原因=429
冷却期间 6 个请求: [('p2', 200), ('p3', 200), ('p2', 200), ('p2', 200), ('p2', 200), ('p2', 200)]
冷却期间落到 p1 的次数: 0
冷却结束后恢复: True,下一个请求出口=p2

9/9 项通过
```

结论:

清理:无外部服务;进程退出后端口全部释放。


翻车实验(真实踩坑记录)

翻车 1:`importlib` 加载带 `@dataclass` 的脚本直接崩

现象:

```text
File ".../00-proxy-lab.py", line 136, in
@dataclass
AttributeError: 'NoneType' object has no attribute 'dict'
```

假设:dataclass 与 Python 3.12 不兼容。

验证:单独运行 `00-proxy-lab.py` 正常;只在被 `spec.loader.exec_module` 加载时崩。
查 `dataclasses._is_type` 源码:它执行 `sys.modules.get(cls.module).dict`。

真实根因:用 `module_from_spec` 创建的模块不会自动注册进 `sys.modules`,
而 `exec_module` 期间 dataclass 要按模块名反查自己。`.get()` 返回 `None` → 崩。

修复:

```python
spec = importlib.util.spec_from_file_location("proxy_lab", path)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module # ← 必须先注册
spec.loader.exec_module(module)
```

复测:01/02/03 三个脚本都能加载实验台,`--self-test` 全绿。


翻车 2:令牌桶"等完了却不扣令牌",实测速率 3.62/s(配置 2.7/s)

现象:配置每出口 0.9 req/s、3 个出口,30 个请求却只用 8.28 秒(3.62 req/s),
并打出 7 个 429。

假设:靶站的限流窗口算错了。

验证:直接算日志——429 出现在第 13 个请求附近,说明实际发送速率确实超了。
检查 `take()`:

```python
def take(self):
if self.tokens >= 1:
self.tokens -= 1
return 0.0
return (1 - self.tokens) / self.rate # ← 返回等待时间,但没扣令牌
```

调用方 `time.sleep(wait)` 后再发请求,令牌数原地不动。
于是每次"等完"都白嫖一个请求,速率被抬高约 34%。

修复:等待与扣减必须在同一个方法里完成:

```python
def acquire(self):
self._refill()
if self.tokens < 1:
time.sleep((1 - self.tokens) / self.rate)
self._refill()
self.tokens -= 1
```

复测:30 个请求 10.61s(2.83 req/s),429 = 0。

教训:限速器的正确性只能靠"实测总速率",不能靠"代码看起来在 sleep"。


翻车 3:纯 `argmax` 评分把其他出口饿死

现象:第一版评分池 36 个请求全部打到 p1,
`p2: req=0, p3: req=0`,坏代理 p3 一次都没被探测到,`冷却次数=0`。

假设:p3 的失败没有被 `report` 记录。

验证:打印每出口 `requests` —— p3 就是 0 次。不是没记录,是根本没被选中。

真实根因:p1 第一次成功后 EWMA 延迟极低,分数 0.44;
未使用的 p2/p3 用 0.5s 的默认延迟,分数只有 0.33。`max()` 每次都选 p1,
形成"强者恒强"的马太效应,坏出口永远没有样本。

修复:冷启动强制探针——`requests == 0` 的出口优先各用一次,再进入评分竞争。

复测:分布变成 p1=34、p2=1、p3=1;p3 的 502 触发熔断,后续 0 流量。

教训:没有探索的评分系统不是"聪明",是"偏见"。


翻车 4:代理配置在闭包里只算一次

现象:实验台启动后修改 `cfg.username`,407 鉴权不生效,永远 200。

根因:`make_proxy_handler` 在创建 handler 类时把 `expected_auth` 算成闭包常量,
之后修改配置读不到。

修复:把凭证计算放进 `_check_auth()`,每次请求重算。
代价可忽略(一次 base64),换来运行期可轮换凭证。


思考题

  1. 健康检查该多快一次? 如果 100 个出口、每 10 秒检查一次,检查流量本身会不会触发目标站限流?
    你会怎么设计"分层探活"(快探 TCP、慢探业务)?
  2. 出口 IP 相同但端口不同,算一个出口还是两个? 从目标站风控视角看,答案是什么?
  3. 429 冷却期间,队列里的任务该怎么办? 阻塞等待、换出口、还是降级返回缓存?
    三种选择分别适合什么业务?
  4. 粘滞会话的迁移次数上升,说明池子的什么指标在恶化?你会先加出口还是先降速?
  5. 免费代理把你当肉鸡:如果代理运营方记录了你的 Cookie,你的"反反爬"还有意义吗?
    从威胁模型角度说明为什么"自建出口"在某些场景是唯一解。

收尾卫生

本次实验全部在 127.0.0.1 的临时端口上完成,脚本退出即关闭监听。
已确认:

复现命令:

```bash
cd days/day-192-proxy-pool/code
for f in 00-proxy-lab.py 01-basic-proxy-rotation.py 02-pitfall-connect-and-env.py 03-production-proxy-pool.py; do
echo "== $f =="; python3 "$f" --self-test || exit 1
done
```