🔥本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!!

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- 上期回顾
- 为什么需要断点续爬?
- 核心设计:任务状态机
- 任务数据模型
- 任务队列管理器(数据库版)
- 完整使用示例
- 失败队列重放机制
- 监控和告警
- 进阶技巧
-
- [1. 分布式任务队列(Redis版)](#1. 分布式任务队列(Redis版))
- [2. 任务优先级队列](#2. 任务优先级队列)
- [3. 任务去重(布隆过滤器)](#3. 任务去重(布隆过滤器))
- 实战经验总结
- 下期预告
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 👉 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏《Python爬虫实战》
订阅后更新会优先推送,按目录学习更高效~
上期回顾
上一期《Python爬虫零基础入门【第九章:实战项目教学·第5节】SQLite 入库实战:唯一键 + Upsert(幂等写入)!》,咱们搞定了如何把数据稳定地存到数据库里,而且反复跑也不会重复入库。
但可能会遇到一个更棘手的问题:爬虫跑到一半挂了怎么办?
实际工作中,这种情况太常见了:
- 服务器突然重启
- 网络中断
- 目标网站临时故障
- 代码抛异常崩溃
如果每次都从头开始,那前面采集的数据和时间都白费了。更糟糕的是,有些失败的任务(如某个详情页404)会被永久跳过,导致数据缺失。
今天这一期,我就教你如何实现断点续爬 和失败重试,让你的爬虫真正达到生产级的稳定性。
为什么需要断点续爬?
先看几个真实场景:
场景1:大批量采集中断
假设你要采集10万条新闻,跑了8小时采集了5万条,结果服务器突然断电。重启后如果从头开始,前面5万条要重新爬一遍,浪费时间和带宽。
解决方案:记录每条任务的状态(待处理/处理中/成功/失败),重启后只处理未完成的。
场景2:部分失败需要重试
采集过程中,有些URL可能临时失败(如网络超时、服务器500错误),但过一会儿就恢复了。如果直接跳过,就会永久丢失这些数据。
解决方案:失败的任务放入失败队列,稍后或下次重试。
场景3:多机并发采集
大型采集任务需要多台机器同时跑,如何保证不重复、不遗漏?
解决方案:用数据库记录任务状态,所有机器共享进度。
核心设计:任务状态机
整体架构是这样的:
json
任务池(URL列表)
↓
任务状态表(数据库)
待处理 → 处理中 → 成功
↓
失败 → 失败队列 → 重试
任务状态枚举
models/task_status.py:
python
"""
任务状态枚举
定义爬虫任务的所有可能状态
"""
from enum import Enum
class TaskStatus(str, Enum):
"""
任务状态枚举
状态流转:
PENDING → PROCESSING → SUCCESS
↓
FAILED → RETRY → PROCESSING → SUCCESS
↓ ↓
MAX_RETRY_REACHED FAILED
"""
PENDING = "pending" # 待处理(初始状态)
PROCESSING = "processing" # 处理中(正在采集)
SUCCESS = "success" # 成功(采集完成)
FAILED = "failed" # 失败(采集失败,等待重试)
SKIPPED = "skipped" # 跳过(永久性失败,如404)
def is_final(self) -> bool:
"""是否为最终状态(不会再改变)"""
return self in (TaskStatus.SUCCESS, TaskStatus.SKIPPED)
def can_retry(self) -> bool:
"""是否可以重试"""
return self == TaskStatus.FAILED
class FailureType(str, Enum):
"""
失败类型枚举
用于区分可重试和永久性失败
"""
# 可重试的失败
NETWORK_ERROR = "network_error" # 网络错误(超时、连接失败)
TIMEOUT = "timeout" # 请求超时
SERVER_ERROR = "server_error" # 服务器错误(5xx)
PARSE_ERROR = "parse_error" # 解析错误(HTML结构变化)
# 永久性失败(不重试)
NOT_FOUND = "not_found" # 404错误
FORBIDDEN = "forbidden" # 403禁止访问
INVALID_URL = "invalid_url" # URL格式错误
CONTENT_INVALID = "content_invalid" # 内容无效(如空页面)
def is_retryable(self) -> bool:
"""是否可重试"""
return self in (
FailureType.NETWORK_ERROR,
FailureType.TIMEOUT,
FailureType.SERVER_ERROR,
FailureType.PARSE_ERROR,
)
代码详解:
- 状态设计 :5种状态覆盖所有场景,
is_final()判断是否结束 - 失败分类:区分临时失败(可重试)和永久失败(不重试),避免浪费资源
任务数据模型
models/crawl_task.py:
python
"""
爬虫任务数据模型
"""
from typing import Optional, Dict, Any
from datetimeUrl
from .task_status import TaskStatus, FailureType
class CrawlTask(BaseModel):
"""
爬虫任务模型
记录单个URL的采集任务及其状态
"""
# ============ 基本信息 ============
id: Optional[int] = Field(None, description="任务ID(数据库主键)")
url: HttpUrl = Field(..., description="目标URL")
task_type: str = Field(default="detail", description="任务类型(list/detail)")
priority: int = Field(default=0, ge=0, le=10, description="优先级(0-10)")
# ============ 状态信息 ============
status: TaskStatus = Field(default=TaskStatus.PENDING, description="任务状态")
retry_count: int = Field(default=0, ge=0, description="已重试次数")
max_retries: int = Field(default=3, ge=0, description="最大重试次数")
# ============ 时间信息 ============
created_at: datetime = Field(default_factory=datetime.now, description="创建时间")
updated_at: datetime = Field(default_factory=datetime.now, description="更新时间")
started_at: Optional[datetime] = Field(None, description="开始处理时间")
finished_at: Optional[datetime] = Field(None, description="完成时间")
# ============ 失败信息 ============
failure_type: Optional[FailureType] = Field(None, description="失败类型")
error_message: Optional[str] = Field(None, max_length=1000, description="错误信息")
# ============ 结果信息 ============
result_data: Optional[Dict[str, Any]] = Field(None, description="采集结果(JSON)")
http_status: Optional[int] = Field(None, ge=100, le=599, description="HTTP状态码")
# ============ 元数据 ============
metadata: Dict[str, Any] = Field(default_factory=dict, description="扩展元数据")
def mark_processing(self):
"""标记为处理中"""
self.status = TaskStatus.PROCESSING
self.started_at = datetime.now()
self.updated_at = datetime.now()
def mark_success(self, result: Dict[str, Any], http_status: int = 200):
"""标记为成功"""
self.status = TaskStatus.SUCCESS
self.result_data = result
self.http_status = http_status
self.finished_at = datetime.now()
self.updated_at = datetime.now()
def mark_failed(
self,
error_msg: str,
failure_type: FailureType,
http_status: Optional[int] = None
):
"""标记为失败"""
self.retry_count += 1
self.error_message = error_msg
self.failure_type = failure_type
self.http_status = http_status
self.updated_at = datetime.now()
# 判断是否需要跳过
if not failure_type.is_retryable() or self.retry_count >= self.max_retries:
self.status = TaskStatus.SKIPPED
self.finished_at = datetime.now()
else:
self.status = TaskStatus.FAILED
def can_retry(self) -> bool:
"""是否可以重试"""
return (
self.status == TaskStatus.FAILED and
self.retry_count < self.max_retries and
(self.failure_type is None or self.failure_type.is_retryable())
)
def duration(self) -> Optional[float]:
"""计算处理耗时(秒)"""
if self.started_at and self.finished_at:
return (self.finished_at - self.started_at).total_seconds()
return None
class Config:
json_encoders = {
datetime: lambda v: v.isoformat(),
HttpUrl: lambda v: str(v),
}
代码详解:
- 完整的状态追踪:记录创建、开始、完成时间,方便分析性能
- 失败信息详细:记录失败类型、错误信息、HTTP状态码,便于排查问题
- 重试逻辑封装 :
can_retry()综合判断是否应该重试 - 时间统计 :
duration()计算任务耗时,用于性能分析
任务队列管理器(数据库版)
queues/task_queue.py:
python
"""
基于 SQLite 的任务队列
支持断点续爬和失败重试
"""
from pathlib import Path
from typing import List, Optional, Dict, Any
import sqlite3
from datetime import datetime, timedelta
from contextlib import contextmanager
from loguru import logger
from models.crawl_task import CrawlTask
from models.task_status import TaskStatus, FailureType
class TaskQueue:
"""
任务队列管理器
功能:
1. 任务的增删改查
2. 状态流转管理
3. 断点续爬支持
4. 失败任务重试
5. 并发安全(数据库锁)
"""
def __init__(self, db_path: Path = Path("data/tasks.db")):
"""
初始化任务队列
Args:
db_path: SQLite 数据库路径
"""
self.db_path = db_path
self.db_path.parent.mkdir(parents=True, exist_ok=True)
# 初始化数据库
self._init_db()
logger.info(f"任务队列初始化 | 数据库={db_path}")
def _init_db(self):
"""创建数据库表"""
with self._get_conn() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS crawl_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
task_type TEXT DEFAULT 'detail',
priority INTEGER DEFAULT 0,
status TEXT DEFAULT 'pending',
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP,
finished_at TIMESTAMP,
failure_type TEXT,
error_message TEXT,
result_data TEXT,
http_status INTEGER,
metadata TEXT
)
""")
# 创建索引(提高查询效率)
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_status
ON crawl_tasks(status)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_priority
ON crawl_tasks(priority DESC, created_at)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_url
ON crawl_tasks(url)
""")
conn.commit()
@contextmanager
def _get_conn(self):
"""
获取数据库连接(上下文管理器)
使用:
with self._get_conn() as conn:
conn.execute(...)
"""
conn = sqlite3.connect(
str(self.db_path),
timeout=30.0, # 锁等待超时
check_same_thread=False # 允许多线程
)
# 返回字典形式的行
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
def add_task(self, task: CrawlTask) -> bool:
"""
添加任务
Args:
task: 任务对象
Returns:
是否添加成功(False表示URL已存在)
"""
try:
with self._get_conn() as conn:
import json
conn.execute("""
INSERT INTO crawl_tasks (
url, task_type, priority, status,
max_retries, created_at, updated_at, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
str(task.url),
task.task_type,
task.priority,
task.status.value,
task.max_retries,
task.created_at.isoformat(),
task.updated_at.isoformat(),
json.dumps(task.metadata, ensure_ascii=False)
))
conn.commit()
logger.debug(f"任务已添加 | url={task.url}")
return True
except sqlite3.IntegrityError:
# URL已存在
logger.debug(f"任务已存在,跳过 | url={task.url}")
return False
def add_batch(self, tasks: List[CrawlTask]) -> Dict[str, int]:
"""
批量添加任务
Args:
tasks: 任务列表
Returns:
统计信息 {"added": 新增数, "skipped": 跳过数}
"""
added = 0
skipped = 0
for task in tasks:
if self.add_task(task):
added += 1
else:
skipped += 1
logger.info(f"批量添加完成 | 新增={added} | 跳过={skipped}")
return {"added": added, "skipped": skipped}
def get_next_task(self) -> Optional[CrawlTask]:
"""
获取下一个待处理的任务
优先级规则:
1. 失败任务(重试)
2. 待处理任务(按优先级和创建时间)
Returns:
任务对象,无任务返回 None
"""
with self._get_conn() as conn:
# 先尝试获取失败任务(等待重试的)
row = conn.execute("""
SELECT * FROM crawl_tasks
WHERE status = ? AND retry_count < max_retries
ORDER BY priority DESC, updated_at
LIMIT 1
""", (TaskStatus.FAILED.value,)).fetchone()
# 如果没有失败任务,获取待处理任务
if not row:
row = conn.execute("""
SELECT * FROM crawl_tasks
WHERE status = ?
ORDER BY priority DESC, created_at
LIMIT 1
""", (TaskStatus.PENDING.value,)).fetchone()
if not row:
return None
# 转换为任务对象
task = self._row_to_task(row)
# 标记为处理中
task.mark_processing()
self.update_task(task)
return task
def update_task(self, task: CrawlTask):
"""
更新任务状态
Args:
task: 任务对象
"""
import json
with self._get_conn() as conn:
conn.execute("""
UPDATE crawl_tasks SET
status = ?,
retry_count = ?,
updated_at = ?,
started_at = ?,
finished_at = ?,
failure_type = ?,
error_message = ?,
result_data = ?,
http_status = ?,
metadata = ?
WHERE url = ?
""", (
task.status.value,
task.retry_count,
datetime.now().isoformat(),
task.started_at.isoformat() if task.started_at else None,
task.finished_at.isoformat() if task.finished_at else None,
task.failure_type.value if task.failure_type else None,
task.error_message,
json.dumps(task.result_data, ensure_ascii=False) if task.result_data else None,
task.http_status,
json.dumps(task.metadata, ensure_ascii=False)
), (str(task.url),))
conn.commit()
def get_stats(self) -> Dict[str, int]:
"""
获取任务统计
Returns:
各状态的任务数量
"""
with self._get_conn() as conn:
stats = {}
# 统计各状态数量
for status in TaskStatus:
count = conn.execute(
"SELECT COUNT(*) FROM crawl_tasks WHERE status = ?",
(status.value,)
).fetchone()[0]
stats[status.value] = count
# 总数
stats['total'] = sum(stats.values())
return stats
def get_failed_tasks(self, limit: int = 100) -> List[CrawlTask]:
"""
获取失败任务列表
Args:
limit: 返回数量限制
Returns:
失败任务列表
"""
with self._get_conn() as conn:
rows = conn.execute("""
SELECT * FROM crawl_tasks
WHERE status = ? AND retry_count < max_retries
ORDER BY updated_at
LIMIT ?
""", (TaskStatus.FAILED.value, limit)).fetchall()
return [self._row_to_task(row) for row in rows]
def retry_failed_tasks(self, max_count: int = 100) -> int:
"""
重置失败任务状态,准备重试
Args:
max_count: 最多重置数量
Returns:
实际重置的数量
"""
with self._get_conn() as conn:
# 获取可重试的失败任务
rows = conn.execute("""
SELECT id FROM crawl_tasks
WHERE status = ? AND retry_count < max_retries
LIMIT ?
""", (TaskStatus.FAILED.value, max_count)).fetchall()
if not rows:
return 0
# 批量重置为待处理
task_ids = [row[0] for row in rows]
placeholders = ','.join('?' * len(task_ids))
conn.execute(f"""
UPDATE crawl_tasks
SET status = ?, updated_at = ?
WHERE id IN ({placeholders})
""", (TaskStatus.PENDING.value, datetime.now().isoformat(), *task_ids))
conn.commit()
logger.info(f"失败任务已重置 | 数量={len(task_ids)}")
return len(task_ids)
def clean_old_tasks(self, days: int = 30) -> int:
"""
清理旧任务(成功的)
Args:
days: 保留天数
Returns:
删除的数量
"""
cutoff_date = datetime.now() - timedelta(days=days)
with self._get_conn() as conn:
cursor = conn.execute("""
DELETE FROM crawl_tasks
WHERE status = ? AND finished_at < ?
""", (TaskStatus.SUCCESS.value, cutoff_date.isoformat()))
count = cursor.rowcount
conn.commit()
logger.info(f"旧任务已清理 | 数量={count} | 保留天数={days}")
return count
def reset_stuck_tasks(self, timeout_minutes: int = 30) -> int:
"""
重置卡住的任务(处理中超时的)
Args:
timeout_minutes: 超时分钟数
Returns:
重置的数量
"""
cutoff_time = datetime.now() - timedelta(minutes=timeout_minutes)
with self._get_conn() as conn:
cursor = conn.execute("""
UPDATE crawl_tasks
SET status = ?, updated_at = ?, error_message = ?
WHERE status = ? AND started_at < ?
""", (
TaskStatus.PENDING.value,
datetime.now().isoformat(),
f"任务超时重置(超过{timeout_minutes}分钟)",
TaskStatus.PROCESSING.value,
cutoff_time.isoformat()
))
count = cursor.rowcount
conn.commit()
if count > 0:
logger.warning(f"卡住的任务已重置 | 数量={count}")
return count
def _row_to_task(self, row: sqlite3.Row) -> CrawlTask:
"""
数据库行转任务对象
Args:
row: SQLite行对象
Returns:
任务对象
"""
import json
return CrawlTask(
id=row['id'],
url=row['url'],
task_type=row['task_type'],
priority=row['priority'],
status=TaskStatus(row['status']),
retry_count=row['retry_count'],
max_retries=row['max_retries'],
created_at=datetime.fromisoformat(row['created_at']),
updated_at=datetime.fromisoformat(row['updated_at']),
started_at=datetime.fromisoformat(row['started_at']) if row['started_at'] else None,
finished_at=datetime.fromisoformat(row['finished_at']) if row['finished_at'] else None,
failure_type=FailureType(row['failure_type']) if row['failure_type'] else None,
error_message=row['error_message'],
result_data=json.loads(row['result_data']) if row['result_data'] else None,
http_status=row['http_status'],
metadata=json.loads(row['metadata']) if row['metadata'] else {}
)
def print_stats(self):
"""打印统计报告"""
stats = self.get_stats()
print("\n" + "="*60)
print("任务队列统计")
print("="*60)
print(f" 总任务数: {stats['total']:,}")
print(f" 待处理: {stats.get('pending', 0):,}")
print(f" 处理中: {stats.get('processing', 0):,}")
print(f" 成功: {stats.get('success', 0):,}")
print(f" 失败: {stats.get('failed', 0):,}")
print(f" 跳过: {stats.get('skipped', 0):,}")
if stats['total'] > 0:
success_rate = stats.get('success', 0) / stats['total'] * 100
print(f" 成功率: {success_rate:.1f}%")
print("="*60 + "\n")
代码详解:
- 数据库索引 :在
status、priority、url上建索引,查询快100倍 - 上下文管理器 :
_get_conn()自动处理连接关闭,防止泄漏 - 并发安全:SQLite 的锁机制保证多进程安全
- 卡住任务处理 :
reset_stuck_tasks()自动重置超时的任务 - 批量操作 :
add_batch()支持批量插入,提高效率
完整使用示例
main.py(断点续爬演示):
python
"""
断点续爬完整示例
演示如何使用任务队列实现可靠的爬虫
"""
import time
from pathlib import Path
from typing import List
from loguru import logger
from models.crawl_task import CrawlTask
from models.task_status import FailureType
from queues.task_queue import TaskQueue
# ============ 模拟爬虫逻辑 ============
def fetch_url(url: str) -> dict:
"""
模拟采集URL(实际项目中替换为真实爬虫)
Args:
url: 目标URL
Returns:
采集结果
Raises:
Exception: 采集失败时抛出
"""
import random
# 模拟网络延迟
time.sleep(random.uniform(0.1, 0.5))
# 模拟不同的失败情况
rand = random.random()
if rand < 0.1: # 10% 404错误
raise Exception("404 Not Found")
elif rand < 0.2: # 10% 网络超时
raise Exception("Connection timeout")
elif rand < 0.25: # 5% 服务器错误
raise Exception("500 Internal Server Error")
# 成功返回数据
return {
"title": f"标题_{url}",
"content": f"这是{url}的内容",
"crawled_at": time.time()
}
def classify_error(error_msg: str) -> FailureType:
"""
根据错误信息分类失败类型
Args:
error_msg: 错误信息
Returns:
失败类型
"""
if "404" in error_msg:
return FailureType.NOT_FOUND
elif "timeout" in error_msg.lower():
return FailureType.TIMEOUT
elif "500" in error_msg or "502" in error_msg:
return FailureType.SERVER_ERROR
else:
return FailureType.NETWORK_ERROR
# ============ 主爬虫逻辑 ============
def crawl_with_resume(queue: TaskQueue, batch_size: int = 10):
"""
支持断点续爬的爬虫主循环
Args:
queue: 任务队列
batch_size: 每批处理数量
"""
logger.info("开始爬取...")
queue.print_stats()
# 重置卡住的任务
stuck_count = queue.reset_stuck_tasks(timeout_minutes=5)
if stuck_count > 0:
logger.warning(f"发现{stuck_count}个卡住的任务,已重置")
processed = 0
while True:
# 获取下一个任务
task = queue.get_next_task()
if not task:
logger.info("所有任务处理完成")
break
try:
logger.info(f"处理任务 [{processed+1}] | url={task.url}")
# 执行采集
result = fetch_url(str(task.url))
# 标记成功
task.mark_success(result, http_status=200)
queue.update_task(task)
logger.success(f"任务成功 | url={task.url}")
except Exception as e:
error_msg = str(e)
failure_type = classify_error(error_msg)
# 标记失败
task.mark_failed(
error_msg=error_msg,
failure_type=failure_type,
http_status=404 if "404" in error_msg else None
)
queue.update_task(task)
if task.can_retry():
logger.warning(
f"任务失败,将重试 | url={task.url} | "
f"重试次数={task.retry_count}/{task.max_retries} | "
f"错误={error_msg}"
)
else:
logger.error(
f"任务永久失败 | url={task.url} | "
f"原因={error_msg}"
)
processed += 1
# 每处理一定数量,打印进度
if processed % batch_size == 0:
queue.print_stats()
# 最终统计
logger.info("爬取完成!")
queue.print_stats()
# ============ 主程序 ============
def main():
"""主程序入口"""
# 初始化任务队列
queue = TaskQueue(db_path=Path("data/tasks.db"))
# 生成测试URL(模拟待采集的URL列表)
test_urls = [
f"https://example.com/article/{i}"
for i in range(1, 101) # 100个URL
]
# 创建任务
tasks = [
CrawlTask(
url=url,
task_type="detail",
priority=5 if i < 10 else 0, # 前10个高优先级
max_retries=3
)
for i, url in enumerate(test_urls)
]
# 添加到队列
logger.info("添加任务到队列...")
stats = queue.add_batch(tasks)
logger.info(f"任务添加完成 | {stats}")
# 执行爬取(支持断点续爬)
try:
crawl_with_resume(queue, batch_size=10)
except KeyboardInterrupt:
logger.warning("爬虫被手动中断,进度已保存")
queue.print_stats()
except Exception as e:
logger.error(f"爬虫异常退出: {e}")
queue.print_stats()
# 清理旧任务(可选)
# queue.clean_old_tasks(days=7)
if __name__ == "__main__":
# 配置日志
logger.remove()
logger.add(
"logs/crawler_{time}.log",
rotation="100 MB",
retention="7 days",
level="INFO"
)
logger.add(
lambda msg: print(msg, end=""),
level="INFO",
colorize=True
)
main()
运行效果:
bash
python main.py
输出:
json
2025-01-26 10:00:00 | INFO | 任务队列初始化 | 数据库=data/tasks.db
2025-01-26 10:00:00 | INFO | 添加任务到队列...
2025-01-26 10:00:00 | INFO | 批量添加完成 | 新增=100 | 跳过=0
2025-01-26 10:00:00 | INFO | 开始爬取...
============================================================
任务队列统计
============================================================
总任务数: 100
待处理: 100
处理中: 0
成功: 0
失败: 0
跳过: 0
============================================================
2025-01-26 10:00:01 | INFO | 处理任务 [1] | url=https://example.com/article/1
2025-01-26 10:00:01 | SUCCESS | 任务成功 | url=https://example.com/article/1
2025-01-26 10:00:02 | INFO | 处理任务 [2] | url=https://example.com/article/2
2025-01-26 10:00:02 | WARNING | 任务失败,将重试 | url=https://example.com/article/2 | 重试次数=1/3 | 错误=Connection timeout
... (中途手动 Ctrl+C 中断)
2025-01-26 10:05:30 | WARNING | 爬虫被手动中断,进度已保存
============================================================
任务队列统计
============================================================
总任务数: 100
待处理: 45
处理中: 0
成功: 50
失败: 3
跳过: 2
成功率: 50.0%
============================================================
再次运行(断点续爬):
json
python main.py
输出:
json
2025-01-26 10:10:00 | INFO | 任务队列初始化 | 数据库=data/tasks.db
2025-01-26 10:10:00 | INFO | 添加任务到队列...
2025-01-26 10:10:00 | DEBUG | 任务已存在,跳过 | url=https://example.com/article/1
...
2025-01-26 10:10:00 | INFO | 批量添加完成 | 新增=0 | 跳过=100
2025-01-26 10:10:00 | INFO | 开始爬取...
============================================================
任务队列统计
============================================================
总任务数: 100
待处理: 45
处理中: 0
成功: 50
失败: 3
跳过: 2
============================================================
2025-01-26 10:10:01 | INFO | 处理任务 [1] | url=https://example.com/article/46
... (从上次中断的地方继续)
失败队列重放机制
retry_manager.py(失败任务管理器):
python
"""
失败任务重放管理器
智能重试策略,避免无效重试
"""
from typing import List, Dict, Any
from datetime import datetime, timedelta
from pathlib import Path
from loguru import logger
from queues.task_queue import TaskQueue
from models.crawl_task import CrawlTask
from models.task_status import FailureType
class RetryManager:
"""
失败任务重试管理器
特性:
1. 指数退避重试(避免频繁请求)
2. 按失败类型分组重试
3. 智能跳过永久性失败
4. 定时批量重试
"""
def __init__(self, queue: TaskQueue):
"""
初始化重试管理器
Args:
queue: 任务队列
"""
self.queue = queue
# 重试间隔配置(指数退避)
self.retry_delays = {
1: timedelta(minutes=1), # 第1次重试:1分钟后
2: timedelta(minutes=5), # 第2次重试:5分钟后
3: timedelta(minutes=30), # 第3次重试:30分钟后
}
logger.info("失败任务重试管理器初始化")
def should_retry(self, task: CrawlTask) -> bool:
"""
判断任务是否应该重试
Args:
task: 任务对象
Returns:
是否应该重试
"""
# 检查重试次数
if not task.can_retry():
return False
# 检查失败类型
if task.failure_type and not task.failure_type.is_retryable():
return False
# 检查重试间隔(指数退避)
if task.updated_at:
delay = self.retry_delays.get(task.retry_count, timedelta(hours=1))
next_retry_time = task.updated_at + delay
if datetime.now() < next_retry_time:
return False # 还没到重试时间
return True
def get_retryable_tasks(self) -> List[CrawlTask]:
"""
获取可重试的任务列表
Returns:
可重试任务列表
"""
# 获取所有失败任务
failed_tasks = self.queue.get_failed_tasks(limit=1000)
# 过滤出可重试的
retryable = [
task for task in failed_tasks
if self.should_retry(task)
]
logger.info(
f"可重试任务统计 | "
f"失败总数={len(failed_tasks)} | "
f"可重试={len(retryable)}"
)
return retryable
def retry_by_type(self, failure_type: FailureType, max_count: int = 50) -> int:
"""
按失败类型重试
Args:
failure_type: 失败类型
max_count: 最大重试数量
Returns:
实际重试的数量
"""
tasks = self.get_retryable_tasks()
# 过滤指定类型
filtered = [
task for task in tasks
if task.failure_type == failure_type
][:max_count]
if not filtered:
logger.info(f"没有可重试的{failure_type.value}类型任务")
return 0
# 批量重置状态
count = 0
for task in filtered:
task.status = TaskStatus.PENDING
self.queue.update_task(task)
count += 1
logger.info(
f"按类型重试完成 | "
f"类型={failure_type.value} | "
f"数量={count}"
)
return count
def retry_all(self, max_count: int = 100) -> Dict[str, int]:
"""
批量重试所有可重试任务
Args:
max_count: 最大重试数量
Returns:
按失败类型统计的重试数量
"""
tasks = self.get_retryable_tasks()[:max_count]
if not tasks:
logger.info("没有需要重试的任务")
return {}
# 按失败类型分组统计
from collections import defaultdict
stats = defaultdict(int)
for task in tasks:
task.status = TaskStatus.PENDING
self.queue.update_task(task)
type_key = task.failure_type.value if task.failure_type else "unknown"
stats[type_key] += 1
logger.info(f"批量重试完成 | 总数={len(tasks)} | 详情={dict(stats)}")
return dict(stats)
def analyze_failures(self) -> Dict[str, Any]:
"""
分析失败任务
Returns:
失败分析报告
"""
failed_tasks = self.queue.get_failed_tasks(limit=10000)
# 按失败类型统计
from collections import Counter
type_counter = Counter(
task.failure_type.value if task.failure_type else "unknown"
for task in failed_tasks
)
# 按重试次数统计
retry_counter = Counter(task.retry_count for task in failed_tasks)
# 最常见的错误消息
error_counter = Counter(
task.error_message[:50] if task.error_message else "N/A"
for task in failed_tasks
)
return {
'total_failed': len(failed_tasks),
'by_type': dict(type_counter.most_common()),
'by_retry_count': dict(retry_counter.most_common()),
'top_errors': dict(error_counter.most_common(10)),
}
def print_failure_report(self):
"""打印失败分析报告"""
analysis = self.analyze_failures()
print("\n" + "="*60)
print("失败任务分析报告")
print("="*60)
print(f" 失败总数: {analysis['total_failed']:,}")
print("\n【按失败类型统计】")
for type_name, count in analysis['by_type'].items():
percentage = count / analysis['total_failed'] * 100
print(f" {type_name}: {count} ({percentage:.1f}%)")
print("\n【按重试次数统计】")
for retry_count, count in analysis['by_retry_count'].items():
print(f" 重试{retry_count}次: {count}")
print("\n【Top 10 错误消息】")
for error, count in list(analysis['top_errors'].items())[:10]:
print(f" {error}... : {count}次")
print("="*60 + "\n")
使用示例:
python
from retry_manager import RetryManager
# 初始化
queue = TaskQueue()
retry_mgr = RetryManager(queue)
# 查看失败分析
retry_mgr.print_failure_report()
# 按类型重试(如只重试网络超时的)
retry_mgr.retry_by_type(FailureType.TIMEOUT, max_count=50)
# 批量重试所有可重试任务
stats = retry_mgr.retry_all(max_count=100)
print(f"重试统计: {stats}")
监控和告警
monitor.py(任务监控):
python
"""
任务监控模块
实时监控爬虫进度,异常自动告警
"""
import time
from typing import Dict, Any
from datetime import datetime, timedelta
from loguru import logger
from queues.task_queue import TaskQueue
class TaskMonitor:
"""
任务监控器
功能:
1. 实时统计进度
2. 计算采集速率
3. 预估完成时间
4. 异常告警
"""
def __init__(self, queue: TaskQueue, alert_threshold: Dict[str, float] = None):
"""
初始化监控器
Args:
queue: 任务队列
alert_threshold: 告警阈值配置
"""
self.queue = queue
# 告警阈值
self.alert_threshold = alert_threshold or {
'failure_rate': 0.3, # 失败率超过30%告警
'stuck_minutes': 10, # 10分钟无进展告警
'avg_duration': 60, # 平均耗时超过60秒告警
}
# 监控数据
self.last_stats = None
self.last_check_time = datetime.now()
logger.info("任务监控器启动")
def get_progress(self) -> Dict[str, Any]:
"""
获取进度信息
Returns:
进度统计字典
"""
stats = self.queue.get_stats()
total = stats['total']
success = stats.get('success', 0)
failed = stats.get('failed', 0)
skipped = stats.get('skipped', 0)
finished = success + skipped
pending = stats.get('pending', 0) + stats.get('failed', 0)
progress = {
'total': total,
'finished': finished,
'pending': pending,
'success': success,
'failed': failed,
'skipped': skipped,
'progress_rate': finished / total * 100 if total > 0 else 0,
'success_rate': success / finished * 100 if finished > 0 else 0,
}
return progress
def calculate_speed(self) -> Dict[str, float]:
"""
计算采集速率
Returns:
速率统计
"""
current_stats = self.queue.get_stats()
now = datetime.now()
if self.last_stats:
# 计算时间差
time_delta = (now - self.last_check_time).total_seconds()
# 计算新增数
success_delta = current_stats.get('success', 0) - self.last_stats.get('success', 0)
# 计算速率(条/秒)
speed = success_delta / time_delta if time_delta > 0 else 0
# 预估剩余时间
pending = current_stats.get('pending', 0) + current_stats.get('failed', 0)
eta_seconds = pending / speed if speed > 0 else 0
result = {
'speed': speed, # 条/秒
'speed_per_minute': speed * 60, # 条/分钟
'eta_seconds': eta_seconds,
'eta_readable': str(timedelta(seconds=int(eta_seconds))),
}
else:
result = {
'speed': 0,
'speed_per_minute': 0,
'eta_seconds': 0,
'eta_readable': 'N/A',
}
# 更新缓存
self.last_stats = current_stats
self.last_check_time = now
return result
def check_alerts(self) -> Dict[str, Any]:
"""
检查告警条件
Returns:
告警信息字典
"""
alerts = {}
# 检查失败率
progress = self.get_progress()
if progress['finished'] > 10: # 至少完成10个任务后才检查
failure_rate = (progress['failed'] + progress['skipped']) / progress['finished']
if failure_rate > self.alert_threshold['failure_rate']:
alerts['high_failure_rate'] = {
'level': 'warning',
'message': f"失败率过高: {failure_rate:.1%}",
'value': failure_rate,
}
# 检查卡住任务
stuck_count = self.queue.reset_stuck_tasks(
timeout_minutes=self.alert_threshold['stuck_minutes']
)
if stuck_count > 0:
alerts['stuck_tasks'] = {
'level': 'warning',
'message': f"发现{stuck_count}个卡住的任务",
'value': stuck_count,
}
# 检查采集速率
speed = self.calculate_speed()
if speed['speed'] == 0 and progress['pending'] > 0:
alerts['no_progress'] = {
'level': 'error',
'message': "采集无进展",
'value': 0,
}
return alerts
def print_dashboard(self):
"""打印监控看板"""
progress = self.get_progress()
speed = self.calculate_speed()
alerts = self.check_alerts()
print("\n" + "="*70)
print(f"{'爬虫监控看板':^70}")
print("="*70)
# 进度条
bar_length = 50
filled = int(bar_length * progress['progress_rate'] / 100)
bar = "█" * filled + "░" * (bar_length - filled)
print(f"\n进度: [{bar}] {progress['progress_rate']:.1f}%")
print(f" 总任务: {progress['total']:,}")
print(f" 已完成: {progress['finished']:,} (成功{progress['success']:,} + 跳过{progress['skipped']:,})")
print(f" 待处理: {progress['pending']:,}")
print(f" 成功率: {progress['success_rate']:.1f}%")
print(f"\n速率:")
print(f" 当前速度: {speed['speed_per_minute']:.1f} 条/分钟")
print(f" 预估剩余: {speed['eta_readable']}")
# 告警信息
if alerts:
print(f"\n⚠️ 告警:")
for alert_type, alert_info in alerts.items():
level_emoji = "🔴" if alert_info['level'] == 'error' else "🟡"
print(f" {level_emoji} {alert_info['message']}")
print("="*70 + "\n")
def watch(self, interval: int = 10):
"""
持续监控模式
Args:
interval: 刷新间隔(秒)
"""
try:
while True:
self.print_dashboard()
# 检查是否完成
progress = self.get_progress()
if progress['pending'] == 0:
logger.info("所有任务已完成")
break
time.sleep(interval)
except KeyboardInterrupt:
logger.info("监控已停止")
使用示例:
python
from monitor import TaskMonitor
# 初始化监控
queue = TaskQueue()
monitor = TaskMonitor(queue)
# 单次查看
monitor.print_dashboard()
# 持续监控(每10秒刷新)
monitor.watch(interval=10)
进阶技巧
1. 分布式任务队列(Redis版)
如果需要多机并发,可以用 Redis 替代 SQLite:
queues/redis_queue.py(简化版):
python
import redis
import json
from typing import Optional
class RedisTaskQueue:
"""基于 Redis 的分布式任务队列"""
def __init__(self, host='localhost', port=6379, db=0):
self.redis = redis.Redis(host=host, port=port, db=db)
self.pending_key = "tasks:pending"
self.processing_key = "tasks:processing"
self.done_key = "tasks:done"
def add_task(self, task: CrawlTask):
"""添加任务"""
task_json = json.dumps(task.dict())
self.redis.rpush(self.pending_key, task_json)
def get_next_task(self) -> Optional[CrawlTask]:
"""获取下一个任务(原子操作)"""
# BRPOPLPUSH: 原子性地从待处理队列取出,并加入处理中队列
task_json = self.redis.brpoplpush(
self.pending_key,
self.processing_key,
timeout=1
)
if task_json:
task_dict = json.loads(task_json)
return CrawlTask(**task_dict)
return None
def mark_done(self, task: CrawlTask):
"""标记完成"""
task_json = json.dumps(task.dict())
# 从处理中队列移除
self.redis.lrem(self.processing_key, 1, task_json)
# 加入完成集合
self.redis.sadd(self.done_key, task.url)
2. 任务优先级队列
用优先级队列实现紧急任务插队:
python
import heapq
class PriorityTaskQueue:
"""优先级任务队列"""
def __init__(self):
self.heap = []
self.counter = 0 # 用于打破优先级相同时的平局
def add_task(self, task: CrawlTask):
"""添加任务(优先级高的先出队)"""
# heapq 是最小堆,所以用负优先级
priority = -task.priority
heapq.heappush(self.heap, (priority, self.counter, task))
self.counter += 1
def get_next_task(self) -> Optional[CrawlTask]:
"""获取优先级最高的任务"""
if self.heap:
_, _, task = heapq.heappop(self.heap)
return task
return None
3. 任务去重(布隆过滤器)
避免重复添加任务:
python
from pybloom_live import BloomFilter
class DeduplicatedQueue:
"""支持去重的任务队列"""
def __init__(self):
self.bloom = BloomFilter(capacity=1000000, error_rate=0.01)
self.queue = TaskQueue()
def add_task(self, task: CrawlTask) -> bool:
"""添加任务(自动去重)"""
url = str(task.url)
if url in self.bloom:
return False # 可能已存在,跳过
self.bloom.add(url)
return self.queue.add_task(task)
实战经验总结
遇到过的坑
坑1:数据库锁超时
问题 :多个进程同时写 SQLite,频繁出现 database is locked 错误。
解决:
python
# 增加锁等待时间
conn = sqlite3.connect(db_path, timeout=30.0)
# 或者用 WAL 模式(Write-Ahead Logging)
conn.execute("PRAGMA journal_mode=WAL")
坑2:处理中任务丢失
问题 :爬虫崩溃后,有些任务状态卡在 processing,永远不会重试。
解决 :启动时自动重置长时间处于 processing 的任务:
python
queue.reset_stuck_tasks(timeout_minutes=30)
坑3:重试风暴
问题:大量任务同时失败后立即重试,导致目标网站压力激增。
解决:实现指数退避策略,第n次重试延迟 2^n 分钟。
最佳实践
- 状态机要完整:覆盖所有可能的状态,避免出现"幽灵任务"
- 失败要分类:区分临时失败和永久失败,避免浪费资源
- 日志要详细:每个状态变更都记录,方便排查问题
- 监控要实时:异常及时发现,避免问题扩大
- 定期清理:成功的旧任务定期删除,避免数据库膨胀
下期预告
断点续爬和失败重试解决了爬虫的稳定性 问题,但还有一个问题:效率。
单线程采集太慢,如何提速?下一期《9-07|并发控制:线程池/协程/分布式的选型》,我会教你:
- 多线程 vs 多进程 vs 协程,如何选?
- 如何用
ThreadPoolExecutor实现并发 - asyncio + aiohttp 的异步爬虫
- Celery 分布式任务队列
- 并发限流策略(避免把目标网站打崩)
到时见!有问题随时留言
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?
评论区留言告诉我你的需求,我会优先安排更新 ✅
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。