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

简介: 这篇文章提出了一种基于Redis的任务队列解决方案,增强了爬虫任务的稳定性和可靠性。文章分析了Celery的不足,并设计了一个包含任务状态机、断点续爬、故障转移和代理层的架构。通过checkpoint、心跳和租约机制,以及隧道代理模式,有效解决了爬虫任务执行中的稳定性问题。

先说结论,再说为什么。这篇文章解决的问题是:你的爬虫跑到一半机器挂了,或者代理 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 提取模式。下面的代码两种都给了。

三、代码实现

下面是完整可运行的项目模板。技术栈是 Python 3.9+,Redis 6+,requests 库。代理用的是隧道代理。

3.1 配置文件

这段定义全局配置。实际项目中建议用环境变量管理敏感信息,不要硬编码。

# config.py
"""爬虫任务队列全局配置"""

# Redis 配置
REDIS_HOST = "127.0.0.1"
REDIS_PORT = 6379
REDIS_DB = 0
REDIS_PASSWORD = None  # 生产环境务必设置密码

# 代理(隧道模式)配置
# 替换为亿牛云代理提供的实际参数
PROXY_HOST = "t.16yun.cn"
PROXY_PORT = "31111"
PROXY_USER = "your_username"
PROXY_PASS = "your_password"

# 如果使用 API 提取式代理,配置提取接口
# API 提取地址在亿牛云控制台获取
PROXY_API_URL = "http://ip.16yun.cn:817/myip/pl/?s=YOUR_KEY&u=YOUR_USER&format=json"

# 任务队列参数
TASK_LEASE_TTL = 30          # 任务租约超时时间(秒)
HEARTBEAT_INTERVAL = 5       # worker 心跳间隔(秒)
CHECKPOINT_BATCH_SIZE = 50   # 每处理多少条数据保存一次 checkpoint
MAX_RETRY_COUNT = 3          # 单个任务最大重试次数
RECLAIM_INTERVAL = 10        # 过期任务巡检间隔(秒)

# 请求参数
REQUEST_TIMEOUT = 15         # 单次请求超时(秒)
MAX_PROXY_RETRIES = 3        # 单次请求最大代理重试次数
BACKOFF_BASE = 1.5           # 指数退避基数(秒)

3.2 Redis 任务队列核心

这是整个方案的核心。任务队列、状态机、checkpoint、租约管理都在这里。

# task_queue.py
"""
基于 Redis 的高可用爬虫任务队列

特性:
- 任务状态机:PENDING → RUNNING → COMPLETED / FAILED / DEAD_LETTER
- 断点续爬:每个任务带 checkpoint 字段,崩溃后可从断点恢复
- 故障转移:租约机制 + 心跳续期,worker 挂了任务自动回收
"""

import json
import time
import uuid
import logging
from enum import Enum
from typing import Optional, Any

import redis

from config import (
    REDIS_HOST, REDIS_PORT, REDIS_DB, REDIS_PASSWORD,
    TASK_LEASE_TTL, MAX_RETRY_COUNT, CHECKPOINT_BATCH_SIZE,
)

logger = logging.getLogger(__name__)


class TaskStatus(str, Enum):
    """任务状态枚举"""
    PENDING = "pending"          # 等待处理
    RUNNING = "running"          # 正在处理
    COMPLETED = "completed"      # 已完成
    FAILED = "failed"            # 失败(可重试)
    DEAD_LETTER = "dead_letter"  # 死信(超过最大重试次数)


class TaskQueue:
    """
    Redis 任务队列

    数据结构说明:
    - spider:tasks:pending    → List,待处理任务队列(FIFO)
    - spider:tasks:running    → ZSet,运行中任务(score = 租约过期时间戳)
    - spider:tasks:completed  → Set,已完成任务 ID
    - spider:tasks:dead       → List,死信队列
    - spider:task:{task_id}   → Hash,单个任务的元数据
    - spider:task:{task_id}:checkpoint → String,任务的断点信息
    - spider:worker:{worker_id}:heartbeat → String,worker 心跳(带 TTL)
    """

    # Redis key 前缀
    KEY_PENDING = "spider:tasks:pending"
    KEY_RUNNING = "spider:tasks:running"
    KEY_COMPLETED = "spider:tasks:completed"
    KEY_DEAD = "spider:tasks:dead"
    KEY_TASK_PREFIX = "spider:task:"
    KEY_CHECKPOINT_SUFFIX = ":checkpoint"
    KEY_WORKER_PREFIX = "spider:worker:"
    KEY_HEARTBEAT_SUFFIX = ":heartbeat"

    def __init__(self, redis_client: Optional[redis.Redis] = None):
        self.redis = redis_client or redis.Redis(
            host=REDIS_HOST,
            port=REDIS_PORT,
            db=REDIS_DB,
            password=REDIS_PASSWORD,
            decode_responses=True,
        )

    def push_task(
        self,
        task_type: str,
        url: str,
        priority: int = 0,
        meta: Optional[dict] = None,
    ) -> str:
        """
        推入一个新任务

        参数:
            task_type: 任务类型(如 "detail_page", "list_page")
            url: 目标 URL
            priority: 优先级(数字越小越先执行,0 为默认)
            meta: 附加元数据(如分页参数、分类信息等)

        返回:任务 ID
        """
        task_id = str(uuid.uuid4())
        now = time.time()

        task_data = {
   
            "task_id": task_id,
            "task_type": task_type,
            "url": url,
            "priority": str(priority),
            "status": TaskStatus.PENDING.value,
            "retry_count": "0",
            "created_at": str(now),
            "started_at": "",
            "completed_at": "",
            "meta": json.dumps(meta or {
   }, ensure_ascii=False),
        }

        # 用 pipeline 批量写入,减少网络往返
        pipe = self.redis.pipeline()
        pipe.hset(f"{self.KEY_TASK_PREFIX}{task_id}", mapping=task_data)
        pipe.lpush(self.KEY_PENDING, task_id)
        pipe.execute()

        logger.info("任务已入队: task_id=%s, type=%s, url=%s", task_id, task_type, url)
        return task_id

    def pop_task(self, worker_id: str) -> Optional[dict]:
        """
        取出一个待处理任务

        取出后任务状态变为 RUNNING,同时设置租约。
        如果 worker 处理过程中崩溃,租约过期后任务会被回收。

        参数:
            worker_id: 当前 worker 的唯一标识

        返回:任务字典,没有可用任务时返回 None
        """
        # 从 pending 队列阻塞取出一个任务 ID(最多等 5 秒)
        result = self.redis.brpoplpush(
            self.KEY_PENDING,    # 源队列
            self.KEY_PENDING,    # 临时放回源队列(原子操作,防止丢失)
            timeout=5,
        )
        if not result:
            return None

        task_id = result
        task_key = f"{self.KEY_TASK_PREFIX}{task_id}"

        # 从源队列移除(因为 brpoplpush 会复制到目标队列,这里目标也是源队列)
        self.redis.lrem(self.KEY_PENDING, 1, task_id)

        # 获取任务元数据
        task_data = self.redis.hgetall(task_key)
        if not task_data:
            logger.warning("任务元数据不存在,可能已被清理: %s", task_id)
            return None

        # 更新状态为 RUNNING,记录开始时间
        now = time.time()
        lease_expires = now + TASK_LEASE_TTL

        pipe = self.redis.pipeline()
        pipe.hset(task_key, mapping={
   
            "status": TaskStatus.RUNNING.value,
            "started_at": str(now),
            "worker_id": worker_id,
        })
        # 加入 running 有序集合,score 是租约过期时间戳
        pipe.zadd(self.KEY_RUNNING, {
   task_id: lease_expires})
        pipe.execute()

        # 解析 JSON 字段
        task_data["status"] = TaskStatus.RUNNING.value
        task_data["started_at"] = str(now)
        task_data["worker_id"] = worker_id
        task_data["meta"] = json.loads(task_data.get("meta", "{}"))

        logger.info("任务已取出: task_id=%s, worker=%s", task_id, worker_id)
        return task_data

    def save_checkpoint(self, task_id: str, checkpoint: dict) -> None:
        """
        保存任务的断点信息

        在处理分页数据时,每处理完一批就调用此方法。
        如果 worker 崩溃,重启后可通过 get_checkpoint 恢复进度。

        参数:
            task_id: 任务 ID
            checkpoint: 断点数据(如 {"page": 47, "last_id": "abc123"})
        """
        key = f"{self.KEY_TASK_PREFIX}{task_id}{self.KEY_CHECKPOINT_SUFFIX}"
        self.redis.set(key, json.dumps(checkpoint, ensure_ascii=False))
        logger.debug("断点已保存: task_id=%s, checkpoint=%s", task_id, checkpoint)

    def get_checkpoint(self, task_id: str) -> Optional[dict]:
        """
        获取任务的断点信息

        返回:断点字典,没有断点记录时返回 None
        """
        key = f"{self.KEY_TASK_PREFIX}{task_id}{self.KEY_CHECKPOINT_SUFFIX}"
        data = self.redis.get(key)
        if data:
            return json.loads(data)
        return None

    def renew_lease(self, task_id: str) -> bool:
        """
        续租任务

        worker 在处理任务期间应定期调用此方法刷新租约。
        如果租约已过期(任务可能已被回收),返回 False。

        返回:续租成功返回 True,租约已过期返回 False
        """
        # 检查任务是否还在 running 集合中
        if not self.redis.zscore(self.KEY_RUNNING, task_id):
            logger.warning("任务不在运行中集合,租约可能已过期: %s", task_id)
            return False

        # 刷新租约过期时间
        new_expires = time.time() + TASK_LEASE_TTL
        self.redis.zadd(self.KEY_RUNNING, {
   task_id: new_expires})
        return True

    def complete_task(self, task_id: str) -> None:
        """
        标记任务完成

        清理任务数据,从 running 集合移除,加入 completed 集合。
        checkpoint 数据也会被清理。
        """
        pipe = self.redis.pipeline()
        pipe.hset(f"{self.KEY_TASK_PREFIX}{task_id}", mapping={
   
            "status": TaskStatus.COMPLETED.value,
            "completed_at": str(time.time()),
        })
        pipe.zrem(self.KEY_RUNNING, task_id)
        pipe.sadd(self.KEY_COMPLETED, task_id)
        pipe.delete(f"{self.KEY_TASK_PREFIX}{task_id}{self.KEY_CHECKPOINT_SUFFIX}")
        pipe.execute()

        logger.info("任务已完成: %s", task_id)

    def fail_task(self, task_id: str, error: str) -> None:
        """
        标记任务失败

        如果重试次数未超上限,任务重新放回 pending 队列。
        超过最大重试次数,任务进入死信队列,需人工介入。

        参数:
            task_id: 任务 ID
            error: 错误信息
        """
        task_key = f"{self.KEY_TASK_PREFIX}{task_id}"
        retry_count = int(self.redis.hget(task_key, "retry_count") or "0")
        retry_count += 1

        pipe = self.redis.pipeline()
        pipe.zrem(self.KEY_RUNNING, task_id)
        pipe.hset(task_key, "retry_count", str(retry_count))
        pipe.hset(task_key, "last_error", error)

        if retry_count >= MAX_RETRY_COUNT:
            # 超过最大重试次数,进入死信队列
            pipe.hset(task_key, "status", TaskStatus.DEAD_LETTER.value)
            pipe.lpush(self.KEY_DEAD, task_id)
            logger.error(
                "任务进入死信队列: %s, 重试次数=%d, 错误=%s",
                task_id, retry_count, error,
            )
        else:
            # 重新入队等待重试
            pipe.hset(task_key, "status", TaskStatus.PENDING.value)
            pipe.lpush(self.KEY_PENDING, task_id)
            logger.warning(
                "任务重试: %s, 第 %d 次, 错误=%s",
                task_id, retry_count, error,
            )

        pipe.execute()

    def reclaim_expired_tasks(self) -> int:
        """
        回收过期任务(故障转移核心方法)

        扫描 running 集合中租约已过期的任务,将其重新放回 pending 队列。
        这个方法应该由一个独立的巡检线程定期调用。

        返回:本次回收的任务数量
        """
        now = time.time()

        # 找出所有租约已过期的任务(score < 当前时间戳)
        expired = self.redis.zrangebyscore(self.KEY_RUNNING, 0, now)

        if not expired:
            return 0

        pipe = self.redis.pipeline()
        for task_id in expired:
            task_key = f"{self.KEY_TASK_PREFIX}{task_id}"

            # 检查任务是否已经被其他 worker 完成或回收
            status = self.redis.hget(task_key, "status")
            if status and status != TaskStatus.RUNNING.value:
                # 任务状态已变更,从 running 集合清理即可
                pipe.zrem(self.KEY_RUNNING, task_id)
                continue

            # 租约过期,任务重新入队
            pipe.zrem(self.KEY_RUNNING, task_id)
            pipe.hset(task_key, "status", TaskStatus.PENDING.value)
            pipe.lpush(self.KEY_PENDING, task_id)

            logger.warning("回收过期任务: %s", task_id)

        pipe.execute()
        return len(expired)

    def get_stats(self) -> dict:
        """
        获取队列统计信息(用于监控)

        返回各状态任务数量
        """
        return {
   
            "pending": self.redis.llen(self.KEY_PENDING),
            "running": self.redis.zcard(self.KEY_RUNNING),
            "completed": self.redis.scard(self.KEY_COMPLETED),
            "dead_letter": self.redis.llen(self.KEY_DEAD),
        }

代码不短,但逻辑是线性的。几个关键设计要说一下。

pop_task 方法里用了 brpoplpush 做原子取出,防止 worker 取出任务后还没来得及设置租约就崩溃导致任务丢失。这一步很多人直接用 rpop + zadd 两步操作,中间断了任务就没了。

reclaim_expired_tasks 是故障转移的关键。它扫描 ZSet 里 score(租约过期时间)小于当前时间的任务,把状态重置为 PENDING 重新入队。注意这里检查了任务当前状态,防止重复回收。

3.3 代理封装

代理层单独抽出来,和任务队列解耦。下面封装了一个带自动重试和退避的代理请求方法。

# 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 字典
        """
        proxy_url = f"http://{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.info("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 {
   }

        proxy_url = f"http://{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, attempt, 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,不用你手动管理。

还有一个容易忽略的细节:退避策略要加随机抖动。如果你有 10 个 worker 同时碰到 403,它们会同时退避、同时重试,形成"惊群效应"。加一个 0 到 0.5 秒的随机偏移就把这个问题打散了。

3.4 Worker:心跳、租约、断点续爬

Worker 是把上面三个组件串起来的地方。心跳线程独立运行,处理任务的主循环里定期续租和保存 checkpoint。

# worker.py
"""
爬虫 Worker 进程

职责:
1. 从任务队列取任务
2. 通过代理请求目标页面
3. 处理过程中定期保存 checkpoint(断点续爬)
4. 定期发送心跳续租(故障转移)
5. 任务完成或失败后更新状态

启动方式:
    python worker.py
    python worker.py  # 起多个进程即可横向扩展
"""

import os
import sys
import time
import json
import uuid
import signal
import logging
import threading
from typing import Optional

import redis

from config import (
    REDIS_HOST, REDIS_PORT, REDIS_DB, REDIS_PASSWORD,
    HEARTBEAT_INTERVAL, CHECKPOINT_BATCH_SIZE,
    RECLAIM_INTERVAL, TASK_LEASE_TTL,
)
from task_queue import TaskQueue, TaskStatus
from proxy_manager import ProxyManager

# 日志配置
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("worker")


class SpiderWorker:
    """爬虫 Worker"""

    def __init__(self, proxy_mode: str = "tunnel"):
        """
        初始化 worker

        参数:
            proxy_mode: 代理模式,"tunnel" 或 "api"
        """
        self.worker_id = f"worker-{os.getpid()}-{uuid.uuid4().hex[:8]}"
        self.redis = redis.Redis(
            host=REDIS_HOST,
            port=REDIS_PORT,
            db=REDIS_DB,
            password=REDIS_PASSWORD,
            decode_responses=True,
        )
        self.queue = TaskQueue(self.redis)
        self.proxy = ProxyManager(mode=proxy_mode)

        # 运行状态控制
        self._running = True
        self._heartbeat_thread: Optional[threading.Thread] = None
        self._reclaim_thread: Optional[threading.Thread] = None

        # 当前正在处理的任务 ID(用于心跳续租)
        self._current_task_id: Optional[str] = None
        self._task_lock = threading.Lock()

    def _heartbeat_loop(self):
        """
        心跳线程:定期续租当前任务

        每 HEARTBEAT_INTERVAL 秒执行一次:
        1. 更新 worker 心跳 key 的 TTL
        2. 如果有当前任务,续租
        """
        heartbeat_key = f"spider:worker:{self.worker_id}:heartbeat"

        while self._running:
            try:
                # 刷新 worker 心跳
                self.redis.setex(heartbeat_key, TASK_LEASE_TTL, "alive")

                # 续租当前任务
                with self._task_lock:
                    if self._current_task_id:
                        if not self.queue.renew_lease(self._current_task_id):
                            # 租约续期失败,任务可能已被回收
                            logger.warning(
                                "租约续期失败,任务可能已被回收: %s",
                                self._current_task_id,
                            )

            except Exception as e:
                logger.error("心跳异常: %s", e)

            time.sleep(HEARTBEAT_INTERVAL)

    def _reclaim_loop(self):
        """
        巡检线程:定期回收过期任务

        独立于心跳线程,避免心跳逻辑阻塞巡检。
        在多 worker 环境下,每个 worker 都跑巡检不会有问题,
        因为 reclaim_expired_tasks 是幂等操作。
        """
        while self._running:
            try:
                count = self.queue.reclaim_expired_tasks()
                if count > 0:
                    logger.info("本次回收过期任务: %d 个", count)
            except Exception as e:
                logger.error("巡检异常: %s", e)

            time.sleep(RECLAIM_INTERVAL)

    def _process_task(self, task: dict) -> bool:
        """
        处理单个任务

        这是业务逻辑的核心方法,需要根据你的实际采集需求改写。
        下面是一个分页采集的示例:
        - 检查是否有断点记录
        - 从断点页码开始继续采集
        - 每处理 CHECKPOINT_BATCH_SIZE 条数据保存一次断点

        参数:
            task: 任务字典

        返回:成功返回 True,失败返回 False
        """
        task_id = task["task_id"]
        url = task["url"]
        task_type = task["task_type"]
        meta = task.get("meta", {
   })

        # 读取断点信息(如果之前跑了一半挂了,从这里恢复)
        checkpoint = self.queue.get_checkpoint(task_id)

        if checkpoint:
            logger.info("从断点恢复: task_id=%s, checkpoint=%s", task_id, checkpoint)
            start_page = checkpoint.get("page", 1)
            processed_count = checkpoint.get("processed", 0)
        else:
            start_page = meta.get("start_page", 1)
            processed_count = 0

        # ---- 以下是业务逻辑示例 ----
        # 假设这是一个分页详情页采集任务
        current_page = start_page
        batch_counter = 0

        while self._running:
            # 构造分页 URL
            page_url = f"{url}?page={current_page}"

            # 通过代理发起请求
            resp = self.proxy.request_with_retry("GET", page_url)

            if resp is None:
                # 请求彻底失败(代理重试也救不了)
                return False

            # 解析响应数据(这里用模拟逻辑,实际项目替换为你的解析代码)
            try:
                # 假设目标站点返回 JSON 格式的列表数据
                data = resp.json()
                items = data.get("items", [])

                if not items:
                    # 没有更多数据了,采集完成
                    logger.info("任务采集完成,无更多数据: %s", task_id)
                    break

                # 处理每条数据
                for item in items:
                    # 这里写你的数据处理逻辑
                    # 比如:解析字段、去重、写入数据库等
                    processed_count += 1
                    batch_counter += 1

                    # 每处理一批数据就保存断点
                    if batch_counter >= CHECKPOINT_BATCH_SIZE:
                        self.queue.save_checkpoint(task_id, {
   
                            "page": current_page,
                            "processed": processed_count,
                            "last_item_id": item.get("id", ""),
                        })
                        batch_counter = 0
                        logger.info(
                            "断点已保存: page=%d, processed=%d",
                            current_page, processed_count,
                        )

                current_page += 1

            except (json.JSONDecodeError, KeyError) as e:
                logger.error("解析数据失败: %s, 错误=%s", page_url, e)
                return False

        # 最终再保存一次断点(确保最后的数据不丢)
        self.queue.save_checkpoint(task_id, {
   
            "page": current_page,
            "processed": processed_count,
            "status": "completed",
        })

        return True

    def run(self):
        """Worker 主循环"""
        logger.info("Worker 启动: %s", self.worker_id)

        # 注册信号处理,优雅退出
        def handle_signal(signum, frame):
            logger.info("收到退出信号 %d,准备优雅退出...", signum)
            self._running = False

        signal.signal(signal.SIGINT, handle_signal)
        signal.signal(signal.SIGTERM, handle_signal)

        # 启动心跳线程
        self._heartbeat_thread = threading.Thread(
            target=self._heartbeat_loop, daemon=True,
        )
        self._heartbeat_thread.start()

        # 启动巡检线程
        self._reclaim_thread = threading.Thread(
            target=self._reclaim_loop, daemon=True,
        )
        self._reclaim_thread.start()

        # 主循环:取任务、处理、更新状态
        while self._running:
            try:
                # 从队列取出任务(阻塞式,最多等 5 秒)
                task = self.queue.pop_task(self.worker_id)

                if task is None:
                    continue

                task_id = task["task_id"]

                with self._task_lock:
                    self._current_task_id = task_id

                # 处理任务
                success = self._process_task(task)

                with self._task_lock:
                    self._current_task_id = None

                # 更新任务状态
                if success:
                    self.queue.complete_task(task_id)
                else:
                    self.queue.fail_task(task_id, "处理失败,详见日志")

            except Exception as e:
                logger.error("Worker 主循环异常: %s", e)
                # 异常后短暂等待,避免死循环疯狂报错
                time.sleep(1)

        # 优雅退出:清理心跳 key
        heartbeat_key = f"spider:worker:{self.worker_id}:heartbeat"
        self.redis.delete(heartbeat_key)
        logger.info("Worker 已退出: %s", self.worker_id)


if __name__ == "__main__":
    # 默认使用隧道代理模式
    # 如果需要 API 提取模式,改为 SpiderWorker(proxy_mode="api")
    worker = SpiderWorker(proxy_mode="tunnel")
    worker.run()

这段代码有几个设计决策值得展开说。

心跳和巡检放在独立线程而不是主循环里。原因是任务处理可能比较慢(单个任务跑几分钟),如果心跳逻辑依赖主循环,任务卡住的时候心跳也卡住了,租约过期,任务被别的 worker 抢走,造成重复处理。

_process_task 里的业务逻辑是示例。你需要根据自己的采集场景改写。但框架是固定的:读 checkpoint、处理数据、定期存 checkpoint、处理完返回 True/False。

优雅退出靠信号处理。收到 SIGTERM 时设置 _running = False,主循环下一次检查就会退出。当前正在处理的任务不会立即中断,会处理完当前批次再退出。如果 worker 被直接 kill -9,那当前任务靠租约超时被巡检线程回收,这就是故障转移兜底。

3.5 任务投放入口

写个简单的脚本往队列里投放任务,方便测试。

# seed_tasks.py
"""
任务投放脚本

批量往队列里投放采集任务,用于初始化或补充任务。
"""

import logging
from task_queue import TaskQueue

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger("seed")


def seed_tasks():
    """投放一批测试任务"""
    queue = TaskQueue()

    # 示例:投放 100 个列表页采集任务
    base_url = "https://example.com/api/list"
    total = 100

    for i in range(1, total + 1):
        task_id = queue.push_task(
            task_type="list_page",
            url=base_url,
            meta={
   "start_page": i, "category": "default"},
        )

    logger.info("已投放 %d 个任务", total)

    # 查看队列状态
    stats = queue.get_stats()
    logger.info("当前队列状态: %s", stats)


if __name__ == "__main__":
    seed_tasks()

3.6 完整启动流程

三步走:

# 第一步:启动 Redis(如果还没装)
docker run -d --name redis -p 6379:6379 redis:7

# 第二步:投放任务
python seed_tasks.py

# 第三步:启动 worker(可以开多个终端跑多个实例)
python worker.py
python worker.py
python worker.py

worker 可以横向扩展。起 5 个、10 个都行,Redis 自己扛得住。每个 worker 都带巡检线程,多个 worker 同时巡检也不会冲突,因为 reclaim_expired_tasks 是幂等的。

四、跑起来会踩的坑

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

坑一: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% 以上,够用了。

相关文章
|
1天前
|
人工智能 JSON 安全
|
1天前
|
云安全 人工智能 安全
|
3天前
|
人工智能
Qwen3.8抢先体验!正式版即将发布并开源!
千问Qwen3.8即将开源,参数达2.4T,进化速度以“天”计,实力媲美Fable 5。预览版Qwen3.8-Max已上线阿里Token Plan等平台,限时优惠:日间Credits低至1折,夜间更优,个人/团队版月付仅35元起!
557 20
|
3天前
|
人工智能 自然语言处理 数据挖掘
Qwen3.8-Max 预览版全解析:2.4 万亿参数旗舰模型,Token Plan 限时优惠指南
Qwen3.8-Max-Preview是通义千问Qwen3系列旗舰MoE大模型,参数达2.4万亿,综合推理能力居行业第一梯队。支持思考/快速双模式,擅长大模型五大高难场景。现于阿里云百炼Token Plan、Qoder及QoderWork上线体验,个人版低至39元/月。在阿里云百炼官网:https://t.aliyun.com/U/fPVHqY 免费领取千万Tokens
455 1
Qwen3.8-Max 预览版全解析:2.4 万亿参数旗舰模型,Token Plan 限时优惠指南
|
2天前
|
人工智能 测试技术 语音技术
Qwen-Audio-3.0-TTS 正式发布!AI 语音从 “能说话” 升级到 “会带情绪表达”
阿里云发布Qwen-Audio-3.0-TTS语音合成大模型,支持细粒度标签控制(如[gasp][angry])、freestyle自由风格、16种语言及20种方言,声学鲁棒性强。含Flash(首包延时300ms)和Plus(全球榜单冠军)双版本,已在百炼平台开放调用。在阿里云百炼官网:https://t.aliyun.com/U/fPVHqY 免费领取千万Tokens
485 0
|
9天前
|
缓存 UED 开发者
Codex109天重置23次,明天还要再送一次
Codex近109天完成23次额度重置,7月14日将迎来第24次。Tibo高频响应用户反馈:优化GPT-5.6高消耗问题、补发失效福利、调整重置时间——形成“反馈→回应→修复→补偿”正向闭环,彰显以用户为中心的产品哲学。(239字)
837 12
|
2天前
|
人工智能 自然语言处理 数据挖掘
最新版通义千问(Qwen3.8-Max-Preview)功能介绍
2026年,通义千问正式推出全新旗舰级大模型 **Qwen3.8-Max-Preview 预览版**,作为首款突破万亿参数规格的新一代基座模型,该模型总参数量达到**2.4万亿**,采用全新迭代的MoE混合专家架构,综合推理性能、长文本处理、多模态理解、复杂任务规划能力全面超越前代Qwen3.7-Max版本,整体实力跻身全球第一梯队,可对标海外顶级旗舰模型,是当前面向复杂工程开发、多智能体协同、超长文档解析、专业办公自动化场景的最优国产基座模型。
583 0
|
12天前
|
存储 人工智能 JSON
Qwen 本地部署搭配 ComfyUI 生成 AI 漫剧完整实操指南(小白零基础可落地,零成本无限生成+角色一致性天花板)
2026全网最优本地漫剧流水线:零成本、离线运行、角色统一、低配(8G显卡)可跑。融合Qwen本地大模型+ComfyUI双引擎,实现剧本生成→分镜绘图→动态成片全自动,隐私安全、无审核限流,新手30分钟上手,日更无忧。(239字)