分布式爬虫架构:基于 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~