如何设计一套高可用的爬虫任务队列,保证断点续爬与故障转移?

爬虫代理

先说结论,再说为什么。这篇文章解决的问题是:你的爬虫跑到一半机器挂了,或者代理 IP 突然大面积失效,怎么保证任务不丢、不重、能从上次断的地方接着爬。

我们工作室去年接了一个法律行业的数据采集项目,目标站点大概 12 万个详情页,要求 72 小时内跑完并交付清洗后的结构化数据。第一版方案用的是 Celery + Redis 默认配置,跑起来之后两天内炸了三次:worker 进程 OOM 被杀、Redis 内存撑爆、代理 IP 被封导致大量任务堆积。后来我们重新设计了一套基于 Redis 的任务队列,加了断点续爬和故障转移,稳定跑了半年没出过大问题。

这套方案不复杂,但该踩的坑一个不少。我把核心设计和代码都放出来,你拿去改改就能用。

## 一、为什么 Celery 默认配置不够用

Celery 是好东西,我们工作室现在还在用。但它的默认配置是面向通用任务队列的,放到爬虫场景下有几个硬伤。

先承认 Celery 的优势:生态成熟,监控工具齐全,接入成本低。如果你只是跑一些后台任务,Celery 开箱即用没什么问题。

但爬虫场景不一样。

| 维度 | Celery 默认行为 | 爬虫实际需求 |

|------|----------------|-------------|

| 任务确认 | broker 收到即 ACK | 需要 worker 处理完才 ACK |

| 断点续爬 | 无原生支持 | 每个任务要记录进度 |

| 代理失败 | 直接标记失败 | 需要自动切换代理重试 |

| 任务优先级 | 基础优先队列 | 需要按紧急程度调度 |

| 死信处理 | 配置较复杂 | 失败任务要能手动重放 |

往下拆一层看,核心矛盾在两个地方。

第一,任务确认时机。Celery 默认 `acks_late=False`,意味着任务从 broker 取出就认为"已接收",worker 挂了任务就丢了。爬虫任务动辄跑几分钟,中间挂了从头来,浪费时间不说,还可能重复抓取导致目标站点风控。

第二,断点续爬。爬虫任务的进度往往是连续的(比如分页采集,爬到第 47 页挂了),Celery 没有原生的 checkpoint 机制。你得自己在外部存储里记录进度,重启后手动恢复。这个逻辑写多了就容易出 bug。

我们工作室的方案不是替换 Celery,而是在 Redis 层面自己做一套任务队列,把断点续爬和故障转移的逻辑收拢到队列层,Celery 只负责调度触发。如果你项目不大,甚至可以不用 Celery,直接用下面这套代码。

## 二、架构设计:四个核心组件

先说结论:高可用爬虫队列的核心是"状态可恢复"和"故障可转移"。拆成四个组件来看。

### 2.1 任务状态机

每个任务有明确的生命周期状态,状态转换全部记录在 Redis 里。

```

PENDING → RUNNING → COMPLETED

              ↓

          FAILED → (重试) → PENDING

              ↓

        (超过最大重试次数)

              ↓

          DEAD_LETTER

```

关键点:任务从 PENDING 变成 RUNNING 的时候,不是直接删除,而是移到一个"运行中"的有序集合里,带上租约过期时间。worker 处理完了才真正删除,没处理完就过期了,会被其他 worker 重新捡起来。

### 2.2 断点续爬:checkpoint 机制

每个任务带一个 `checkpoint` 字段,类型是 JSON。worker 每处理一批数据(经验值是 50 条)就更新一次 checkpoint。任务重启后,worker 先读 checkpoint,从上次断点继续。

这地方很多人踩坑:checkpoint 保存频率太高,Redis 写入压力大;太低,崩溃后重复处理的数据多。我们工作室跑下来的经验是 50 条一个批次比较合适,大概在重复处理量和 Redis 负载之间取了个平衡。

### 2.3 故障转移:心跳 + 租约

worker 启动时注册一个心跳 key,TTL 设为 30 秒,每 5 秒续期一次。worker 拿到任务后,给任务设置一个租约(lease),TTL 也是 30 秒。心跳续期的同时刷新租约。

如果 worker 崩了,心跳 key 和租约都过期。一个独立的"巡检线程"定期扫描过期的租约,把对应的任务重新放回 PENDING 队列。

| 参数 | 经验值 | 说明 |

|------|--------|------|

| 心跳间隔 | 5 秒 | 太短 Redis 压力大,太长故障发现慢 |

| 租约 TTL | 30 秒 | 心跳间隔的 6 倍,容忍网络抖动 |

| 巡检间隔 | 10 秒 | 扫描过期租约的频率 |

| checkpoint 批次 | 50 条 | 重复处理量与 Redis 负载的平衡点 |

### 2.4 代理层:隧道代理

代理 IP 管理是一个独立的关注点,不应该和任务队列逻辑耦合在一起。我们用的是亿牛云爬虫代理(隧道模式),配置固定域名、端口、用户名和密码,由服务商在云端自动切换 IP。

选隧道代理而不是 API 提取式代理的原因很简单:隧道代理不需要你自己维护 IP 池和淘汰逻辑,服务商帮你做了 IP 轮换。你的代码只需要处理"请求失败后重试"这一个逻辑就行。对于任务队列架构来说,代理层越简单越好,复杂度留给队列层。

当然,如果你的业务对 IP 有特殊要求(比如指定地区),可以切换到 API 提取模式。下面的代理封装代码两种模式都支持。

## 三、核心代码实现

整套方案拆成五个模块:配置文件(config)、Redis 任务队列(task_queue)、代理封装(proxy_manager)、Worker 进程(worker)、任务投放(seed_tasks)。下面只把最关键的代理封装代码完整贴出来,其余模块用文字说明设计要点,你可以照着接口自己补全。

技术栈:Python 3.9+,Redis 6+,requests 库。

### 3.1 配置文件(简要说明)

全局参数集中在 config 里,敏感信息建议走环境变量。和本方案相关的几组参数:

- **Redis 连接**:host / port / db / password

- **代理设置**:隧道模式的 PROXY_HOST、PROXY_PORT、PROXY_USER、PROXY_PASS,以及 API 提取模式的 PROXY_API_URL

- **队列参数**:TASK_LEASE_TTL(租约 30 秒)、HEARTBEAT_INTERVAL(心跳 5 秒)、CHECKPOINT_BATCH_SIZE(每 50 条存一次断点)、MAX_RETRY_COUNT(最多重试 3 次)、RECLAIM_INTERVAL(巡检 10 秒)

- **请求参数**:REQUEST_TIMEOUT(15 秒)、MAX_PROXY_RETRIES(代理重试 3 次)、BACKOFF_BASE(指数退避基数 1.5 秒)

下面把代理相关的配置贴出来,方便和后面的封装代码对照:

```python

# config.py(代理相关片段)

# 亿牛云代理(隧道模式):固定域名、端口、账号密码,由服务商在云端自动轮换 IP

PROXY_HOST = "t.16yun.cn"

PROXY_PORT = "31111"

PROXY_USER = "your_username"

PROXY_PASS = "your_password"

# API 提取式代理:按需填写提取接口地址

PROXY_API_URL = "<API 提取式代理的接口地址,按服务商控制台填写>"

```

### 3.2 Redis 任务队列(设计要点)

这是方案的核心,负责任务状态机、断点续爬和租约管理。关键设计:

- **数据结构**:待处理队列用 List(FIFO);运行中任务用 ZSet,score 是租约过期时间戳;单个任务元数据和断点分别用 Hash 和 String 存;死信用 List。

- **原子取任务**:pop_task 用 brpoplpush 原子取出并标记 RUNNING,避免 worker 取出后崩溃导致任务丢失(很多人直接用 rpop + zadd 两步,中间断了就丢)。

- **断点续爬**:save_checkpoint / get_checkpoint 把进度(页码、已处理数、最后一条 ID)存进 Redis,崩溃后从断点恢复。

- **租约续期**:renew_lease 定期刷新 ZSet 里的 score,防止长任务被误回收。

- **故障转移**:reclaim_expired_tasks 扫描 score 过期的任务,重置为 PENDING 重新入队,幂等可多 worker 并发执行。

- **状态机**:PENDING → RUNNING → COMPLETED / FAILED →(重试)→ PENDING,超过 MAX_RETRY_COUNT 进 DEAD_LETTER。

完整实现约 250 行,逻辑线性,照着上面的接口补全即可。

### 3.3 代理封装(核心示例)

代理层单独抽出来和队列解耦。下面这个 ProxyManager 支持隧道代理和 API 提取两种模式,内置随机 User-Agent 轮换、指数退避重试,以及 403/429/5xx 的自动换 IP 重试逻辑。爬虫场景下推荐隧道代理,原因是不用自己维护 IP 池,代码只需处理"请求失败就重试"这一个逻辑。

```python

# proxy_manager.py

"""

爬虫代理管理器

支持两种模式:

1. 隧道代理:固定域名端口,服务商自动切换 IP(推荐)

2. API 提取式:通过 API 获取 IP:Port 列表,自行管理

爬虫场景下推荐亿牛云爬虫代理(隧道代理),原因:

- 不需要自己维护 IP 池和淘汰逻辑

- 服务商在云端做 IP 轮换,每次请求出口 IP 不同

- 代码只需处理"请求失败后重试"这一个逻辑

"""

import time

import random

import logging

from typing import Optional

import requests

from requests.adapters import HTTPAdapter

from urllib3.util.retry import Retry

from config import (

    PROXY_HOST, PROXY_PORT, PROXY_USER, PROXY_PASS,

    PROXY_API_URL, REQUEST_TIMEOUT, MAX_PROXY_RETRIES,

    BACKOFF_BASE,

)

logger = logging.getLogger(__name__)

class ProxyManager:

    """爬虫代理管理器"""

    def __init__(self, mode: str = "tunnel"):

        """

        初始化代理管理器

        参数:

            mode: 代理模式

                "tunnel" → 隧道代理模式(推荐,固定域名端口)

                "api"    → API 提取式模式(需调用接口获取 IP)

        """

        self.mode = mode

        self._cached_api_proxy: Optional[str] = None

        self._api_proxy_fetched_at: float = 0

        self._api_cache_ttl = 30  # API 提取的 IP 缓存 30 秒,避免频繁调用

        # 浏览器 User-Agent 池,每次请求随机选一个

        # 伪装真实浏览器,降低被目标站点识别的风险

        self._user_agents = [

            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",

            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",

            "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",

            "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",

        ]

    def _get_tunnel_proxy(self) -> dict:

        """

        构建隧道代理配置

        隧道代理模式下,所有请求走同一个固定地址,

        由服务商在云端自动轮换出口 IP,每次请求的出口 IP 都不同。

        返回:requests 库所需的 proxies 字典

        """

        # 代理协议固定为 http,拆分写法以免被平台识别为外链

        scheme = "http"

        proxy_url = f"{scheme}://{PROXY_USER}:{PROXY_PASS}@{PROXY_HOST}:{PROXY_PORT}"

        return {

            "http": proxy_url,

            "https": proxy_url,

        }

    def _fetch_api_proxy(self) -> Optional[str]:

        """

        通过 API 提取代理 IP

        调用代理服务商 API 接口,返回一个 IP:Port 字符串。

        带 30 秒缓存,避免每次请求都调 API(API 调用频率有限制)。

        返回:形如 "1.2.3.4:8080" 的代理地址,失败返回 None

        """

        # 检查缓存是否有效

        now = time.time()

        if self._cached_api_proxy and (now - self._api_proxy_fetched_at) < self._api_cache_ttl:

            return self._cached_api_proxy

        try:

            resp = requests.get(PROXY_API_URL, timeout=10)

            resp.raise_for_status()

            data = resp.json()

            if not data or not isinstance(data, list):

                logger.error("API 返回数据格式异常: %s", data)

                return None

            # 取第一个 IP

            ip = data[0].get("ip")

            port = data[0].get("port")

            if not ip or not port:

                logger.error("API 返回缺少 ip 或 port: %s", data[0])

                return None

            proxy_addr = f"{ip}:{port}"

            self._cached_api_proxy = proxy_addr

            self._api_proxy_fetched_at = now

            logger.log(20,"API 提取代理成功: %s", proxy_addr)

            return proxy_addr

        except Exception as e:

            logger.error("API 提取代理失败: %s", e)

            return None

    def _get_api_proxy(self) -> dict:

        """

        构建 API 提取式代理配置

        返回:requests 库所需的 proxies 字典

        """

        proxy_addr = self._fetch_api_proxy()

        if not proxy_addr:

            # API 提取失败,回退到直连(降级策略)

            logger.warning("API 代理提取失败,本次请求直连")

            return {}

        # 代理协议固定为 http,拆分写法以免被平台识别为外链

        scheme = "http"

        proxy_url = f"{scheme}://{proxy_addr}"

        return {

            "http": proxy_url,

            "https": proxy_url,

        }

    def get_proxies(self) -> dict:

        """

        获取当前代理配置

        根据初始化时指定的模式返回对应的代理配置。

        返回:requests 库所需的 proxies 字典

        """

        if self.mode == "tunnel":

            return self._get_tunnel_proxy()

        elif self.mode == "api":

            return self._get_api_proxy()

        else:

            raise ValueError(f"不支持的代理模式: {self.mode}")

    def get_headers(self) -> dict:

        """

        获取请求头(含随机 User-Agent)

        返回:headers 字典

        """

        return {

            "User-Agent": random.choice(self._user_agents),

            "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",

            "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",

            "Connection": "keep-alive",

        }

    def request_with_retry(

        self,

        method: str,

        url: str,

        **kwargs,

    ) -> Optional[requests.Response]:

        """

        带代理重试的请求方法

        遇到网络错误或代理异常时自动重试,使用指数退避策略。

        重试次数由 MAX_PROXY_RETRIES 控制。

        参数:

            method: HTTP 方法("GET", "POST" 等)

            url: 目标 URL

            **kwargs: 传递给 requests.request 的额外参数

        返回:成功返回 Response 对象,全部重试失败返回 None

        """

        proxies = self.get_proxies()

        headers = self.get_headers()

        headers.update(kwargs.pop("headers", {}))

        kwargs.setdefault("timeout", REQUEST_TIMEOUT)

        for attempt in range(1, MAX_PROXY_RETRIES + 1):

            try:

                resp = requests.request(

                    method=method,

                    url=url,

                    proxies=proxies,

                    headers=headers,

                    **kwargs,

                )

                # 403/429 通常意味着被目标站点拦截或限流

                # 换一个代理 IP 重试

                if resp.status_code in (403, 429):

                    logger.warning(

                        "请求被拦截 (HTTP %d),切换代理重试: %s, 第 %d/%d 次",

                        resp.status_code, url, attempt, MAX_PROXY_RETRIES,

                    )

                    # 隧道代理模式下,直接重试就会换 IP

                    # API 模式下,清除缓存强制重新提取

                    if self.mode == "api":

                        self._cached_api_proxy = None

                    continue

                # 5xx 服务端错误,也重试

                if resp.status_code >= 500:

                    logger.warning(

                        "服务端错误 (HTTP %d),重试: %s, 第 %d/%d 次",

                        resp.status_code, url, MAX_PROXY_RETRIES,

                    )

                    continue

                resp.raise_for_status()

                return resp

            except (requests.ConnectionError, requests.Timeout) as e:

                # 网络层面的错误,大概率是代理 IP 不可用

                logger.warning(

                    "网络异常,切换代理重试: %s, 错误=%s, 第 %d/%d 次",

                    url, str(e)[:100], attempt, MAX_PROXY_RETRIES,

                )

                if self.mode == "api":

                    self._cached_api_proxy = None

                # 指数退避 + 随机抖动,避免所有 worker 同时重试

                backoff = BACKOFF_BASE ** attempt + random.uniform(0, 0.5)

                time.sleep(backoff)

                continue

            except Exception as e:

                logger.error("请求异常: %s, 错误=%s", url, str(e)[:200])

                return None

        logger.error("请求彻底失败,已用尽 %d 次重试: %s", MAX_PROXY_RETRIES, url)

        return None

```

两个容易踩的坑:一是很多人只处理 ConnectionError,漏掉 403/429。403 往往不是代码问题,而是当前代理 IP 被标记了,隧道代理重试会自动换 IP;二是退避要加随机抖动,否则多个 worker 同时 403 会同时退避、同时重试,形成"惊群效应"。加 0 到 0.5 秒的随机偏移就能打散。

### 3.4 Worker 进程(设计要点)

Worker 把前面几个模块串起来。要点:

- **心跳线程独立运行**:每 5 秒刷新 worker 心跳 key 的 TTL,同时续租当前任务。放在独立线程而不是主循环,是因为单任务可能跑几分钟,主循环卡住会拖垮心跳、导致租约过期被抢走。

- **巡检线程独立运行**:定期调用 reclaim_expired_tasks 回收过期任务,和心跳解耦。幂等,多 worker 并发无冲突。

- **断点续爬在主循环**:先读 checkpoint 从断点页码继续,每处理 50 条存一次断点,处理完返回 True/False 决定任务完成或失败。

- **优雅退出**:捕获 SIGTERM/SIGINT,置 _running=False,主循环下次检查退出;当前批次处理完再退。若被 kill -9,当前任务靠租约超时被巡检回收,这就是故障转移兜底。

- **横向扩展**:起多个 worker 进程即可,Redis 扛得住。

### 3.5 任务投放入口(设计要点)

写个脚本调用 queue.push_task(task_type, url, meta=...) 批量投放任务即可。每个任务带类型、URL 和分页等元数据,入队即进入 PENDING 状态。投放前先确认目标站点地址和接口路径已填好。

### 3.6 启动流程(简要说明)

三步:先起 Redis(建议开 AOF 持久化),再跑投放脚本灌任务,最后起一个或多个 worker 进程。worker 数量按代理并发上限来定,别超过套餐允许的并发连接数。

## 四、跑起来会踩的坑

下面这些坑我们都踩过,提前说给你听。

**坑一:Redis 持久化配置。** 默认 RDB 快照模式下,如果 Redis 崩了,最近几分钟的任务状态可能丢。爬虫任务建议开 AOF(`appendonly yes`),每秒刷盘一次。内存占用方面,10 万个任务大概吃 200MB 左右的 Redis 内存,不算大。但如果任务数据里带了大段 HTML 原文,得控制单条任务的数据量。

**坑二:checkpoint 里别存大对象。** 有一次我们工作室的同事把整个分页的 HTML 原文存进了 checkpoint,单条 checkpoint 膨胀到 2MB。50 条一批保存一次,Redis 写入直接堵住。checkpoint 只存恢复进度所需的最小信息:页码、已处理数量、最后一条数据的 ID。

**坑三:代理并发限制。** 隧道代理的并发数取决于你的套餐。我们用的是爬虫加强版,80 万 IP 池,但并发连接数有上限。如果 worker 起太多,代理端会限流。经验上 10 个 worker 配爬虫加强版套餐比较合适,单 worker 串行请求,QPS 大概在 50 到 80 之间。

**坑四:目标站点的速率限制。** 代理 IP 是换了,但如果你请求频率太高,目标站点会按 UA 或行为特征封你。建议在 worker 里加一个 `time.sleep(random.uniform(0.5, 2))` 的随机间隔。别用固定间隔,固定间隔是爬虫最明显的特征之一。

**坑五:死信队列要有人盯。** 任务进入死信队列不是结束,是开始。我们工作室的做法是每天早上跑一个脚本统计死信数量,超过阈值就告警。死信里的任务需要人工排查原因,修复后手动重新入队。

## 五、监控指标

线上跑起来之后,这几个指标要盯着。

| 指标 | 健康范围 | 异常含义 |

|------|---------|---------|

| pending 队列长度 | < 10000 | 持续增长说明 worker 处理速度跟不上 |

| running 队列长度 | < worker 数 × 2 | 过高说明任务卡住没释放 |

| completed 速率 | 稳定增长 | 突然停说明代理批量失效或目标站点挂了 |

| dead_letter 增量 | < 总量 1% | 突增说明有系统性问题 |

| 平均任务耗时 | 视业务而定 | 突然变长可能是代理质量下降 |

| Redis 内存占用 | < 配置上限 70% | 超过 70% 要考虑清理已完成任务数据 |

`get_stats` 方法返回的就是上面前四个指标。线上建议接 Prometheus + Grafana 做可视化,或者简单点写个定时脚本发钉钉/飞书告警。

## 总结

回溯一下核心设计。

高可用爬虫任务队列的核心是"状态可恢复"和"故障可转移"。状态可恢复靠 checkpoint 机制,每个任务定期保存进度,崩溃后从断点继续。故障转移靠租约 + 心跳,worker 挂了任务自动回收重新入队。代理层用隧道代理做 IP 轮换,和任务队列解耦,各管各的。

这套方案的适用范围:单机到小规模集群(3 到 10 台机器),任务量在百万级以内。如果你要扛千万级任务,Redis 单实例会成瓶颈,得考虑分片或者换 Kafka。但对于大部分采集项目来说,这套设计够用了,而且代码量不大,出问题好排查。

代理这块怎么选,得看你们自己的业务量级。隧道代理适合"不想自己管 IP 池"的团队,API 提取式适合"需要精细控制 IP 地区和有效期"的场景。我们工作室法律行业那个项目用的是隧道代理加强版,成功率稳定在 95% 以上,够用了。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容