知识

住宅代理负载均衡:轮换、并发与重试架构

有了 gateway,池会自己做均衡。真正要紧的架构是你的:轮换单元、按主机的并发,以及一层不会放大封锁的重试。

Chris Collins

Chris Collins

2026年7月17日 · 3 分钟阅读

“代理负载均衡”这个说法,如今的含义已与过去大不相同。在旧模型里,你买一份 IP 列表,然后造一个轮换器:挑一个 IP、追踪它的健康、它死了就摘掉、过一会儿再放回去。团队们出于习惯仍在写那套中间件,而在一个现代 gateway 上,它基本上是死代码。

在一个住宅代理 gateway 上,池会自己做均衡。 一个端点,供应商在服务端按请求或按会话,从数百万个 IP 中分配一个出口。你并不是在均衡 IP。你真正在架构的,是它之上的那一层:工作如何映射到身份、一个目标能容忍多少并发,以及一个请求失败时会发生什么。这三件事做对了,系统就能扩展;做错了,再高的代理质量也救不了你。

这是一篇关于这三层的深入探讨,写给在生产中运行采集管道的团队。

gateway 做什么,以及你的架构从哪里开始

把这个分工说清楚是值得的,因为它决定了你该造什么、不该造什么:

gateway 负责: 从池中挑选出口 IP、地理匹配、单个 IP 的健康,以及轮换机制。你通过用户名(countrycitysidttl)表达意图,它把这个意图解析成一个出口。

你负责: 身份单元(什么东西拿到自己的 IP,以及拿多久)、并发控制(你把每个目标推得多狠)、重试策略(失败时怎么办),以及可观测性。

最常见的架构错误,是造一层与 gateway 重复的代理轮换器。如果你发现自己在维护一张 IP 健康表,停下来,那是供应商的活儿,而且你那个版本对池的视野比他们差。你的工作,是从请求策略这一层才开始的。

第 1 层:轮换架构,选择身份单元

这是其余一切所悬挂的那个设计决策。问题不是”我该多久轮换一次?“,而是**“什么是一个身份,以及什么工作属于它?”**

两个原语:

  • 每请求轮换(省去 sid):每个请求都拿到一个新鲜的出口 IP。IP 多样性最大,零连接复用,适合大量彼此独立、后一个请求不依赖前一个的抓取。
  • 粘性会话sid + ttl):所有带着那个会话 id 的请求共享一个出口 IP,直到 TTL 到期。当一个流程必须看起来像同一个用户时是必需的,而且它也正是让池化连接可复用的前提(见粘性 vs 轮换)。

设计规则:在一个逻辑工作单元的边界上轮换,而不是随意轮换。 一个工作单元,就是那些必须内部保持一致的东西,一个商品的翻页评论、一次搜索流程、一个账号的会话。把它干净地映射:

一个 worker = 一个工作单元 = 一个 sid = 一个出口 IP(在其 TTL 期间)

从工作单元派生会话 id,而不是随机生成,这样行为可复现,你也能有意识地决定一次重试是保持还是更换身份:

def session_id(job_id: str, attempt: int = 0) -> str:
# 同一个 job → 同一个身份。把 `attempt` 加一,就是有意去换一个新 IP。
return f"{job_id}-a{attempt}"
def proxy_for(job_id, attempt=0, country="us", ttl=600):
sid = session_id(job_id, attempt)
user = f"{USER}-country-{country}-sid-{sid}-ttl-{ttl}"
url = f"http://{user}:{PASS}@p.shifter.io:443"
return {"http": url, "https": url}

那一个 attempt 参数在做真正的架构工作:它把”换一个身份去重试”变成了一等的、有意为之的操作,而不是一个意外。

第 2 层:并发架构,以及它为什么是按主机的

并发的约束几乎从来不是你的代理套餐(并发连接通常不是那个限制)。它是目标能容忍什么。 这意味着单一的全局并发上限形状就不对:它会让一个宽松的目标把一个脆弱的目标饿死,也会让一个脆弱的目标掐住你整条管道。

按主机限流,而不是全局:

import asyncio
from collections import defaultdict
# 每个目标主机一个信号量,按那个主机能容忍的量来调
_limits = {"tough-site.example": 4, "open-site.example": 32}
_sems = defaultdict(lambda: asyncio.Semaphore(8)) # 合理的默认值
for host, n in _limits.items():
_sems[host] = asyncio.Semaphore(n)
async def fetch(client, url, host):
async with _sems[host]: # 按主机施加背压
return await client.get(url, timeout=30)

这为你买来两个特性。隔离: 一个慢的或有敌意的目标无法吃掉所有 worker。可调: 你可以对不在乎的目标调高并发,对会封你的那个单独调低。

并且,忍住把数字往上拉的冲动。越过一个目标的容忍度之后,额外的并行买来的不是吞吐量,而是封锁,而封锁在重试上花掉你的,比并发所赢得的更多(降低延迟讲了这个取舍)。

第 3 层:重试架构,成败所系的那部分

大多数管道栽在这里。一个粗放的 for attempt in range(3): retry(),会把一个糟糕的下午变成一场放大封锁、烧掉带宽的重试风暴。一层真正的重试要做四件事。

1. 重试之前先分类。 并非所有失败都一样,对每一种的应对也不同:

失败例子正确的应对
传输超时、连接重置重试,用同一身份没问题
限流429狠狠退避,把整个主机放慢
封锁403、CAPTCHA、带 200 的软封锁新身份重试,别复用那个 IP
永久404、400别重试,记录下来然后继续

注意那个软封锁:一个带 CAPTCHA 页面的 200 就是一次失败。如果你只按状态码分类,你会把封锁记成成功,而你的管道会悄无声息地灌满垃圾(为什么爬虫会被封)。

2. 退避要带抖动(jitter)。 不带抖动的指数退避,会把你的 worker 同步成一群一浪一浪砸向目标的兽群。永远要加上随机性。

3. 在封锁时更换身份。 在同一个粘性 IP 上重试一次封锁,无非是再求一次同样的拒绝。把尝试计数器加一,好让会话 id 改变,gateway 就会给你一个不同的出口。

import random, asyncio
async def fetch_with_policy(client, url, job_id, host, max_attempts=4):
for attempt in range(max_attempts):
proxies = proxy_for(job_id, attempt=attempt) # 每次尝试一个新身份
try:
r = await client.get(url, proxies=proxies, timeout=30)
kind = classify(r) # ok | ratelimit | block | permanent
if kind == "ok":
return r
if kind == "permanent":
return None # 别浪费尝试次数
if kind == "ratelimit":
await slow_down(host) # 把这个主机的节奏放宽
except (asyncio.TimeoutError, ConnectionError):
pass # 传输:重试
backoff = (2 ** attempt) + random.uniform(0, 1) # 抖动,永远要有
await asyncio.sleep(backoff)
await dead_letter(job_id, url) # 把它停到一边,别堵住管道
return None

4. 优雅地放弃。 在 N 次尝试之后,把这个条目停进死信队列,然后继续。无上限的重试救不了一个任务;它只会把一次失败,变成对一个已经在拒绝你的目标的持续压力。

到了规模上还值得再建两个模式:按主机的熔断器(如果成功率崩了,就停发一阵子,而不是继续硬磨),以及幂等的工作条目,好让重试永远是安全的。

参考形态

拼在一起,管道长这样:

工作队列
按主机的限流器(信号量 + 节奏)
worker → 会话 id(sid)→ gateway → 目标
对响应分类
├── ok → 校验内容 → 输出
├── 限流 → 放慢主机 → 退避 + 重新入队
├── 封锁 → 新身份 → 退避 + 重新入队
└── 永久 → 记录,丢弃
↓(N 次尝试之后)
死信队列

每一个箭头,都是一个你若不明写、就会隐式且糟糕地做出的决策。一旦明确写出来,系统就会优雅地降级,而不是崩掉。

可观测性:这东西你没法闭着眼睛运维

至少要埋点:按主机的成功率(用内容校验,而不是状态码)、p50/p95 延迟重试率与失败类型分布,以及每条成功记录的字节数。这四个会告诉你该在哪里动手,某个主机重试率上升,意味着要收紧那个主机的并发;每记录字节数上升,意味着你在拉取自己并不解析的负载(削减带宽成本)。测量方法见如何测试代理的速度、成功率与位置准确度

要避免的反模式

  • 在 gateway 之上再造一个 IP 轮换器。 冗余,而且比供应商自己的均衡掌握的信息更少。
  • 一个全局并发上限。 把毫不相干的目标耦合在一起;要按主机限流。
  • 在同一个粘性 IP 上重试封锁。 同一个 IP,同样的答案。
  • 不带抖动的退避。 把 worker 同步成一浪一浪。
  • 无上限的重试。 放大封锁、烧掉带宽,除了不可避免的结局什么也没推迟。
  • 把 200 当成成功。 软封锁就是 200;校验内容,否则你的数据会悄悄腐坏。
  • 在流程中途轮换。 在一个多步会话里换 IP 是一个检测信号,要在单元边界上轮换。

常见问题

我需要自己造代理负载均衡吗? 在 IP 这一层不需要。gateway 在服务端从池中分配出口,所以你这边的 IP 轮换器或健康表是冗余的。去架构它之上的那一层:身份单元、按主机的并发,以及重试策略。

我该跑多少并发请求? 这取决于目标,不取决于你的套餐。按那个主机能容忍的量,为每个主机设一个上限,并各自独立调优。越过那个点,更多的并行产出的是封锁,不是吞吐量。

一次重试该用同一个 IP 还是新的? 取决于失败类型。传输错误(超时)可以用同一身份重试。封锁和 CAPTCHA 应该换身份重试,也就是改变会话 id,因为被拒绝的正是那个 IP。

我怎么防止重试把封锁问题搞得更糟? 对失败分类、带抖动做指数退避、给尝试次数设上限、把注定不会成功的送进死信,并加一个按主机的熔断器。无上限、不分类的重试,正是一个小的封锁问题变成一个大问题的路径。

在一条代理管道里我该监控什么? 按主机的成功率(带内容校验)、p50/p95 延迟、带失败类型分布的重试率,以及每条成功记录的字节数。这四个几乎能让每一个值得修的问题浮出水面。

底线

在 gateway 上,均衡池的负载不是你的问题,而假装它是,会让团队去造错的东西。真正决定一条采集管道能否扩展的架构,活在三个决策里:什么构成一个身份、以及什么工作属于它;每一个具体目标能容忍多少并发;以及失败如何被分类、退避、换身份,并最终被放弃。把这些做对、给它们埋上点,管道在压力之下就会优雅降级,而不是倒下。

其余的交给 gateway。如果你正在搭这一套,住宅 gateway 通过用户名给你轮换和粘性会话,好让你的架构留在你的代码里,而不是留在一层代理管理中间件里;而池的质量,决定了那条重试路径到底会被触发得多频繁(IP 信誉)。关于大规模的考量,见大规模抓取的最佳住宅代理网络定价页面有按 GB 计费的套餐,可以拿这套设计对着你自己的工作负载去测。

准备好开始了吗?

试用 Shifter 住宅代理,205M+ 个 IP,195+ 个国家,低至 $0.75/GB。

立即开始