← 返回博客

多服务器分布式解析器:无冲突和封禁的代理池划分

我们分析了多个服务器上的分布式解析器架构——如何合理分配代理池,避免从同一IP重复请求,并最小化封禁。

📅2026年10月21日

当解析器从一台服务器迁移到由多个工作节点组成的集群时,会出现一个新问题——多台机器开始同时使用同一个代理池。如果没有协调,这将导致请求中IP的重复、单个地址的限制超标以及大规模封禁。在本文中,我们将探讨如何正确地在服务器之间分片代理,哪些分配方案效果最好,以及如何通过Python和Redis在实践中实现这一点。

为什么代理在分布式解析器中发生冲突

假设一个典型的情况:您有一个500个代理的池和三台服务器,每台服务器运行50个并行的Wildberries、Ozon或Avito解析工作节点。如果每台服务器独立地从公共列表中获取代理而没有协调,两个在不同机器上的工作节点可能会同时从同一个IP向同一个域发送请求。对于反机器人系统来说,这看起来像是来自同一地址的请求频率异常高——IP会在几分钟内被封禁。

第二个问题是负载不均衡。如果代理只是“随意”分配,一台服务器可能会获取到最快和最新的IP,而另一台则得到已经受损的IP。最终,服务器的解析速度将相差甚远,错误率也会不同。

第三个问题是状态丢失。如果一个服务器上的代理被封禁,其他服务器不会知道,并会继续使用它,直到也被封禁。没有集中管理代理状态的机制,每台服务器都在“重新发现美国”,浪费请求。

解决这三个问题的办法是引入统一的协调机制:要么通过集中状态存储(Redis、PostgreSQL、Consul),要么通过事先确定的分片方式,确保服务器之间的范围不重叠。

分布式解析器架构

在分配代理之前,需要确定集群的总体架构。主要有两种基本方法。

静态方案(shared-nothing)。 代理池在启动时一次性分配给服务器。每台服务器获得自己不重叠的段,并独立工作,不与其他服务器交互。优点是最大程度的简单性和容错性:一台服务器的故障不会影响其他服务器。缺点是,如果一台服务器上有一半的代理被封禁,它无法向邻居“借用”新的IP。

动态方案(shared-state)。 所有服务器访问一个公共存储(通常是Redis),该存储保存每个代理的状态,并在请求时通过锁定机制提供。优点是池的均匀利用和对封禁的即时反应。缺点是存储成为单点故障,需要单独的逻辑来处理其不可用的情况。

在实践中,针对市场和社交网络的解析,通常选择混合方案:基于Redis的代理代理在短时间内(TTL)提供代理,并在Redis不可用时,服务器临时切换到本地备用池。这种方法结合了静态方案的可靠性和动态方案的灵活性。

在服务器之间分配代理池的方法

让我们考虑一些可以应用于分配池的具体算法。

简单的模数分配(round-robin sharding)。 将代理编号从0到N-1,编号为i的服务器获取所有index % 服务器数量 == i的代理。简单,但在添加或删除服务器时表现不佳——当机器数量变化时,几乎整个池都会重新分配。

一致性哈希。 每个服务器和每个代理在哈希环上分配一个位置(通常是根据IP地址或服务器ID的哈希)。代理绑定到环上最近的服务器。添加或删除服务器时,只有一小部分代理会重新分配,而不是整个池——这是动态扩展集群(例如,Kubernetes中的自动扩展)的关键优势。

集中式代理代理与锁定。 所有服务器请求公共服务(Redis、RabbitMQ)中的“空闲”代理,该服务以临时锁定的方式提供代理。使用后,代理返回到池中。这是最灵活的选项,特别是在服务器负载不均时。

按域或任务分配。 如果解析器同时处理多个来源(例如,Wildberries、Ozon和Avito),那么不仅在服务器之间分配代理,还有必要在来源之间分配代理——以避免同一IP同时在多个平台上出现。这降低了IP进入反欺诈系统的公共黑名单的风险,如果这些平台之间共享数据。

表格:代理分片方法比较

方法 复杂性 扩展性 何时使用
模数分配 低 差 固定数量的服务器
一致性哈希 中等 好 集群自动扩展
集中式代理代理 中高 优秀 负载不均、高流量
按域分配 低 中等 同时解析多个平台

基于Redis的代理代理实现

对于大多数分布式解析器来说,最实用的选项是基于Redis的集中式代理代理。Redis适合的原因是它快速,支持原子操作和TTL“开箱即用”。其思想很简单:所有可用的代理存储在Redis集合中,当将代理分配给特定工作节点时,它会通过带TTL的键被锁定一段时间。

下面是一个使用redis-py库的Python代理代理的工作示例。它实现了以锁定方式分配空闲代理、使用后释放代理以及将代理标记为被封禁。

import redis
import time
import random

r = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)

PROXY_POOL_KEY = "proxy:pool"       # 所有可用代理的集合
LOCK_PREFIX = "proxy:lock:"         # 特定代理的锁定前缀
BANNED_PREFIX = "proxy:banned:"     # 封禁标记前缀
LOCK_TTL = 30                       # 锁定代理的秒数
BAN_TTL = 3600                      # 认为代理被封禁的秒数

def load_pool(proxy_list):
    """在集群启动时一次性加载代理列表到Redis中。"""
    if proxy_list:
        r.sadd(PROXY_POOL_KEY, *proxy_list)

def get_free_proxy(server_id, max_attempts=20):
    """每个服务器调用此函数以获取空闲代理。"""
    candidates = list(r.smembers(PROXY_POOL_KEY))
    random.shuffle(candidates)

    for proxy in candidates[:max_attempts]:
        if r.exists(BANNED_PREFIX + proxy):
            continue
        lock_key = LOCK_PREFIX + proxy
        # 原子设置锁定,如果代理是空闲的
        acquired = r.set(lock_key, server_id, nx=True, ex=LOCK_TTL)
        if acquired:
            return proxy
    return None  # 没有找到空闲代理,需要后备方案

def release_proxy(proxy):
    """在成功请求后释放代理。"""
    r.delete(LOCK_PREFIX + proxy)

def mark_banned(proxy):
    """将代理标记为被封禁,持续BAN_TTL秒,移除锁定。"""
    r.set(BANNED_PREFIX + proxy, 1, ex=BAN_TTL)
    r.delete(LOCK_PREFIX + proxy)
    r.srem(PROXY_POOL_KEY, proxy)  # 暂时从池中移除

def return_to_pool_if_recovered():
    """定期任务:返回已过期封禁的代理。"""
    for proxy in r.scan_iter(match=BANNED_PREFIX + "*"):
        clean_proxy = proxy.replace(BANNED_PREFIX, "")
        if not r.exists(BANNED_PREFIX + clean_proxy):
            r.sadd(PROXY_POOL_KEY, clean_proxy)

这里的关键点是使用SET ... NX EX在Redis中。这条命令是原子的:如果两台服务器同时尝试锁定同一个代理,只有其中一台会成功,另一台会失败并继续寻找列表中的下一个候选者。正是这一点排除了服务器在并行访问公共池时的冲突。

在解析器工作节点中使用代理的示例:

import requests

def fetch_with_proxy(url, server_id):
    proxy = get_free_proxy(server_id)
    if not proxy:
        raise RuntimeError("池中没有空闲代理")

    proxies = {"http": f"http://{proxy}", "https": f"http://{proxy}"}
    try:
        response = requests.get(url, proxies=proxies, timeout=10)
        if response.status_code in (403, 429):
            mark_banned(proxy)
        else:
            release_proxy(proxy)
        return response
    except requests.exceptions.RequestException:
        mark_banned(proxy)
        raise

这种方案易于扩展——可以添加10台或50台服务器,它们将访问同一个Redis实例,并且不会出现IP冲突,因为锁定是在存储层面上是原子的,而不是在单个服务器层面上。

粘性会话和代理与任务的绑定

对于某些任务,仅仅提供“任何空闲代理”是不够的。如果解析器模拟用户行为——例如,顺序浏览商品卡片、添加到购物车、在目录页面之间切换——重要的是,所有同一会话的请求都来自同一个IP。否则,网站会在会话中间看到地理位置或设备的突然变化,并将其视为可疑而阻止。

为此,引入了粘性会话的概念:代理绑定到特定任务或用户会话,而不是单个请求,持续其生命周期。在Redis中,这通过类似session:{task_id} -> proxy的单独键实现,TTL等于预期的会话持续时间。

SESSION_PREFIX = "proxy:session:"
SESSION_TTL = 600  # 会话持续10分钟

def get_sticky_proxy(task_id, server_id):
    existing = r.get(SESSION_PREFIX + task_id)
    if existing:
        return existing
    proxy = get_free_proxy(server_id)
    if proxy:
        r.set(SESSION_PREFIX + task_id, proxy, ex=SESSION_TTL)
    return proxy

对于需要长会话的任务——例如,模拟真实买家的Wildberries目录浏览——使用尽可能像普通用户的代理至关重要。此时,住宅代理非常适合,因为它们绑定到真实的家庭IP,并且与数据中心地址相比,更少引起市场反机器人系统的怀疑。

封禁处理和集群中的轮换

在分布式系统中,代理封禁的信息必须立即对所有服务器可用,而不仅仅是获得该封禁的服务器。在上面的示例中,mark_banned函数负责这一点——一旦一台服务器从目标网站记录到403或429,代理就会从Redis的公共池中移除,其他服务器会自动停止获取它。

为了确定封禁,不仅要分析HTTP响应代码,还要检查页面内容——一些网站返回200 OK,但内容中包含验证码或占位符,而不是实际内容。封禁检测的逻辑通常如下:

  • HTTP代码403、429、503——明确的封禁或速率限制标志。
  • 重定向到验证码或授权页面——请求后检查URL。
  • 响应大小急剧减少或缺少预期的CSS选择器——占位符的标志。
  • 连接超时——通常意味着代理完全不再响应。

在记录封禁后,最好不是将代理永久删除,而是将其放入“隔离区”,设置TTL(如示例中的BAN_TTL),在此之后代理会自动返回到池中。许多市场和社交网络的封禁是临时的,并在30-60分钟后解除。

如果即使在负载均衡的情况下,封禁发生得太频繁,通常表明代理池的质量低或大小不足以满足请求量。在这种情况下,通过使用数据中心代理来扩展池,以应对不太敏感的任务(例如,检查页面可用性),并仅在关键会话中使用更昂贵的住宅IP,有助于降低整体封禁率,而不会增加整体流量的成本。

代理池健康监测

在多台服务器的集群中,很容易失去整体情况:当前有多少代理处于活动状态,多少被封禁,每个代理的平均响应速度是多少。没有监测,很难理解解析速度为何变慢——是因为一台服务器崩溃,还是因为没有可用的代理。

最小的指标集合,应该在Redis或单独的数据库中收集:

  • 当前池中活动代理的数量。
  • 被封禁代理的数量和恢复的平均时间。
  • 每个代理在最近N个请求中的平均响应时间。
  • 每台服务器和整个池的成功请求百分比(成功率)。
  • 负载分配——每台服务器在一段时间内处理的请求数量。

为了可视化,方便将这些指标通过简单的导出器存储到Prometheus中,该导出器每分钟从Redis读取数据并以Grafana可理解的格式提供。这使得快速查看特定地理位置或代理类型的成功率下降,并及时将新IP添加到池中,避免解析器完全停止。

另外,值得监测在集群中启动新服务器时的封禁高峰——如果在将服务器数量从3扩展到6时,封禁比例急剧上升,问题很可能出在分片上:新服务器开始与旧服务器的代理范围重叠。这是需要从模数分配转向一致性哈希的第一个迹象。

分布式解析器启动检查清单

在将解析器部署到多台服务器之前,请检查以下几点:

  • 选择了代理池的协调机制:静态分配、一致性哈希或基于Redis的集中式代理。
  • 实现了原子锁定代理,避免两个服务器同时使用同一IP。
  • 设置了锁定和封禁的TTL,以防代理在工作节点崩溃时“永久挂起”。
  • 对于需要模拟用户会话的任务,设置了整个任务期间的粘性代理绑定。
  • 设置了封禁检测,不仅根据HTTP代码,还根据响应内容。
  • 启用了整个集群的成功率、活动和被封禁代理的监测。
  • 根据任务类型分配代理池:数据中心IP用于不太敏感的请求,住宅或移动代理用于需要最大程度模拟真实用户的会话。
  • 检查了状态存储(Redis)的负载——在大量服务器和高请求频率的情况下,可能需要Redis集群而不是单个实例。

结论

解析器迁移到多台服务器时冲突的主要原因是缺乏关于代理池状态的单一真实来源。一旦出现具有原子锁定的集中式代理(在Redis或类似存储中),IP重复和封禁的不同步问题就会自动消失。接下来,只需选择适合您集群规模的分片策略:对于固定数量的服务器,简单的分配就足够了,而对于动态扩展的基础设施,最好立即考虑一致性哈希。

如果您的分布式解析器与市场或社交网络合作,这些平台对单个会话的流量真实性要求很高,值得根据任务类型分配代理池:对于大规模目录扫描,便宜的数据中心代理是合适的,而对于模拟用户行为的会话,使用住宅代理,这些代理绑定到真实的家庭IP,可以显著降低在对欺诈敏感系统中的封禁率。