【爬虫框架-4】统计的用法

分布式爬虫架构:基于 Redis 的任务树追踪与完成判断

一、源码分析

今天来讨论一下怎么做统计,先看一下设计的框架源码:

plain 复制代码
        def consumer_function(task_info: dict, extra: dict = None):
            """Funboost 消费函数 - 内部调用 Worker 实例"""
            fct_context_obj = fct.function_result_status.get_status_dict()
            queue_name = fct_context_obj.get('queue_name', 'N/A')
            try:
                # 使用单例 Worker 实例
                result = self._worker.process_task(task_info, extra)

                # 在这里可以打点:任务执行成功
                if self.stats_manager:
                    self.stats_manager.emit("task",
                                              tags={"status": "success"},
                                              fields={"count": 1})
                return result
            except Exception as e:
                if self.stats_manager:
                    self.stats_manager.emit("task",
                                              tags={"status": "failed"},
                                              fields={"count": 1, "exception": type(e).__name__})
                logger.error(f"Consumer function error: {e}")
                raise

在执行函数中,就包括了请求,process_request , downlaod, process_response ,以及最后的parse 函数 ,和一些打点信息,最终返回的也是**当前队列的请求统计 (只包括当前这次请求分发数量统计,不包括下层 ,如果有返回了 说明当前请求周期任务完成,到了子请求了 ),**大概如下:

plain 复制代码
    def process_task(self, task_info: Dict[str, Any], extra: Optional[Dict[str, Any]] = None) -> Any:
        """
        处理 Funboost 任务 - 主入口方法
        Args:
            task_info: 任务信息,包含 payload 和 target_method_name
            extra: 额外参数

        Returns:
            处理结果
        """
        # 获取当前任务 ID(从 funboost 上下文)
        fct_context_obj = fct.function_result_status.get_status_dict()
        current_task_id = fct_context_obj.get('task_id', '')
        
        self.stats['total_requests'] += 1
        logger.debug(f"Processing task with extra: {extra}")

        try:
            # 解析任务信息
            payload = task_info.get('payload', {})
            target_method_name = task_info.get('target_method_name', 'process_request')
            
            # 获取 trace 相关信息
            meta = payload.get('meta', {})
            trace_id = meta.get('trace_id', '')
            parent_task_id = meta.get('parent_task_id', '')

            # 直接使用 engine 持有的 spider 实例(闭包方式)
            spider_instance = self.engine.spider
            
            # 创建任务节点
            if current_task_id and trace_id:
                self.task_manager.create_task(current_task_id, parent_task_id, trace_id)

            # 根据 target_method_name 调用不同的处理方法
            if target_method_name == 'process_request':
                # 处理请求任务(最常见的情况)
                self._process_request_task(spider_instance, payload)
            else:
                # 处理其他类型的任务(如果有的话)
                self._process_custom_task(spider_instance, target_method_name, payload)

            self.stats['success_requests'] += 1
            
            # 标记任务完成
            if current_task_id:
                is_tree_completed = self.task_manager.mark_completed(current_task_id)
                if is_tree_completed:
                    logger.info(f"任务树全部完成!task_id={current_task_id}")
            
            return self.get_stats()

        except Exception as e:
            logger.error(f"任务处理失败: {e}")
            self.stats['failed_requests'] += 1
            
            # 标记任务失败
            if current_task_id:
                self.task_manager.mark_failed(current_task_id, str(e))
            
            return self.get_stats()

当然,这个方案可能不是最优解,但是目前还没有想出其他思路办法。

二、思路分析

那么言归正传,在开发大规模分布式爬虫时,我们经常面临一个棘手的问题:"如何判断整个采集任务彻底结束了?"简单的爬虫可能是一个循环跑完 URL 列表就结束了。但在复杂的采集场景中,任务往往是裂变的:

  • 我们只有一个种子 URL(Root)。
  • 种子页解析出了 10 个列表页(Level 1)。
  • 每个列表页又解析出 100 个详情页(Level 2)。
    这些任务被分发到不同的 Worker 节点异步执行。

这就导致了一个问题:Root 任务执行完时,子任务才刚刚产生。我们不能简单地看 Root 是否完成,也不能单纯看队列是否为空(因为可能有网络延迟导致的任务生成间隙)。

1.核心设计思路

我们要把离散的爬虫任务看作一棵树(Tree)。

  • 状态存储:使用 Redis 记录每个任务的状态(Running/Completed)。
  • 父子关联:当任务裂变时,在 Redis 中记录 Parent -> Children 的映射关系(使用 Redis Set)。
  • 递归检查:这是核心逻辑。每当一个子任务完成时,它不仅标记自己完成,还会检查它的兄弟节点是否都完成了。如果都完成了,就意味着父任务的"后续工作"也结束了,从而触发对父任务的完成回调,并逐级向上递归。

2.精简版代码实现

为了演示核心逻辑,我剥离了复杂的监控统计和数据库写入功能,保留了最核心的任务追踪逻辑。

python 复制代码
import redis
import time
from typing import Optional, List

class SimpleTaskTracker:
    """
    分布式任务追踪器 (精简版)
    利用 Redis 实现任务树的状态管理和完成检测
    """

    def __init__(self, redis_client: redis.Redis, expire_time=3600):
        self.redis = redis_client
        self.expire_time = expire_time

    def create_task(self, task_id: str, parent_id: Optional[str] = None):
        """
        创建任务节点,如果有父节点,自动建立关联
        """
        # 初始化任务状态
        task_key = f"task:{task_id}"
        mapping = {
            "status": "running",
            "parent_id": parent_id if parent_id else "",
            "created_at": time.time()
        }
        self.redis.hset(task_key, mapping=mapping)
        self.redis.expire(task_key, self.expire_time)

        # 如果有父节点,将自己加入父节点的子任务集合中
        if parent_id:
            children_key = f"task:{parent_id}:children"
            self.redis.sadd(children_key, task_id)
            self.redis.expire(children_key, self.expire_time)

    def mark_completed(self, task_id: str) -> bool:
        """
        核心方法:标记任务完成,并递归检查父任务是否全部结束
        Returns: True 表示整棵任务树(从根节点开始)都已完成
        """
        task_key = f"task:{task_id}"

        # 标记自身完成
        if not self.redis.exists(task_key):
            return False
        self.redis.hset(task_key, "status", "completed")

        # 获取父任务 ID
        parent_id = self.redis.hget(task_key, "parent_id")
        if parent_id:
            parent_id = parent_id.decode()
            # 检查父任务是否"彻底"完成(父任务自身完成 + 所有子任务完成)
            if self._check_parent_fully_completed(parent_id):
                print(f"🔄 子任务 {task_id} 完成,触发父任务 {parent_id} 递归检查...")
                # 递归向上
                return self.mark_completed(parent_id)
            return False
        else:
            # 没有父节点,说明是根节点
            # 检查根节点是否有子节点还在运行(防止根节点刚完成,子节点还没跑完的情况)
            if self._all_children_completed(task_id):
                print(f"🎉 根任务 {task_id} 及其所有子任务全部完成!")
                return True
            return False

    def _check_parent_fully_completed(self, parent_id: str) -> bool:
        """检查父任务是否具备"完结"条件"""
        # 条件1: 父任务自身状态必须是 completed
        parent_status = self.redis.hget(f"task:{parent_id}", "status")
        if not parent_status or parent_status.decode() != "completed":
            return False

        // 条件2: 父任务的所有子任务必须都是 completed
        return self._all_children_completed(parent_id)

    def _all_children_completed(self, task_id: str) -> bool:
        """检查某个任务名下的所有子任务是否都已完成"""
        children_key = f"task:{task_id}:children"
        children_ids = self.redis.smembers(children_key)

        if not children_ids:
            return True # 没有子任务,视为完成

        for child in children_ids:
            child_key = f"task:{child.decode()}"
            status = self.redis.hget(child_key, "status")
            // 如果子任务不存在(过期)或者状态不是completed,则未完成
            if not status or status.decode() != "completed":
                return False
        return True

# 使用演示
if __name__ == "__main__":
    r = redis.Redis(decode_responses=False) # 模拟连接
    tracker = SimpleTaskTracker(r)

    // 场景模拟:Root -> [Child_A, Child_B] -> Child_A 产生 [Sub_A1]

    print("1. 创建根任务...")
    tracker.create_task("Root")

    print("2. 根任务裂变出 Child_A, Child_B...")
    tracker.create_task("Child_A", parent_id="Root")
    tracker.create_task("Child_B", parent_id="Root")
    tracker.mark_completed("Root") // Root 自身跑完了代码,但子任务还在跑

    print("3. Child_A 裂变出 Sub_A1...")
    tracker.create_task("Sub_A1", parent_id="Child_A")
    tracker.mark_completed("Child_A") // Child_A 跑完了,但它儿子没跑完

    print("4. Child_B 完成...")
    tracker.mark_completed("Child_B") // 此时 Root 还没完全结束,因为 Sub_A1 还在跑

    print("5. Sub_A1 完成 (触发连锁反应)...")
    tracker.mark_completed("Sub_A1")
    // 预期输出:Sub_A1 完成 -> 发现 Child_A 全部完成 -> 发现 Root 全部完成 -> 🎉

相比于简单的"计数器"(Redis Incr/Decr),这种任务树方案有明显的优势:

  • 容错性强:计数器方案如果中间某次 Decr 失败,计数器永远不归零,任务永远无法结束。而任务树基于状态检查,任何一步重试都只是幂等更新状态。
  • 可视化支持:因为我们在 Redis 里保留了完整的树状结构(Parent-Children),我们可以很轻松地写一个脚本,把整棵采集树画出来,看到到底是哪个分支卡住了。
  • 部分重试:如果发现某个子分支失败了,我们可以只重置该分支下的任务状态,而不需要重跑整个爬虫。

三、总结

当然,这个做法在当前看来也不是最优解,也希望能有幸和读到的人一起探讨。我也结合了influxdb 入库打点了一些信息,统计实时的 请求,解析,下载等,比如任务下发时:

plain 复制代码
              # 在这里可以打点:任务执行成功
                if self.stats_manager:
                    self.stats_manager.emit("task",
                                              tags={"status": "success"},
                                              fields={"count": 1})

后续还需要根据监控的四大黄金指标不断完善。

更多文章,敬请关注gzh:零基础爬虫第一天

next~

相关推荐
想个名字太难12 小时前
网络爬虫入门程序
java·爬虫·maven
Data_agent15 小时前
1688按图搜索1688商品(拍立淘)API ,Python请求示例
爬虫·python·算法·图搜索算法
深蓝电商API16 小时前
爬虫+大模型结合:让AI自动写XPath和清洗规则
人工智能·爬虫
任子菲阳20 小时前
学Java第五十三天——IO综合练习(1)
java·开发语言·爬虫
sheji341620 小时前
【开题答辩全过程】以 基于python爬虫的网易云音乐可视化分析与推荐为例,包含答辩的问题和答案
爬虫
绝不收费—免费看不了了联系我21 小时前
学术论文爬虫项目
爬虫
深蓝电商API1 天前
爬虫限速与并发控制:令牌桶、漏桶、动态调整全解析
爬虫
爱打代码的小林1 天前
网络爬虫基础
爬虫·python
B站计算机毕业设计之家1 天前
大数据:基于python唯品会商品数据可视化分析系统 Flask框架 requests爬虫 Echarts可视化 数据清洗 大数据技术(源码+文档)✅
大数据·爬虫·python·信息可视化·spark·flask·唯品会