一、引言
在数据采集领域,定时任务调度是一个常见且关键的需求。特别是对于需要定期从平台抓取数据的项目来说,稳定、灵活、可配置的调度系统尤为重要。本文将详细介绍如何基于 Python 的 APScheduler 库构建一个功能完备的爬虫调度系统,并分享在实际项目中的应用经验。
二、调度系统架构设计
2.1 系统架构概述
我们设计的调度系统基于以下核心组件:
- 调度器核心:APScheduler 提供的 BackgroundScheduler
- 任务注册表:集中管理所有可被调度的爬虫任务
- 配置管理:支持 INI 格式的配置文件和动态参数配置
- API 接口:基于 FastAPI 提供 HTTP 接口用于任务管理
系统架构图如下:
scss
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ 配置文件 │ │ 任务注册表 │ │ HTTP API │
│ (jobs_temp.ini) │────>│(function_registry) │<────│ (FastAPI) │
└───────────────────┘ └───────────┬───────┘ └───────────────────┘
│
▼
┌───────────────────┐
│ CronScheduler │
│ 调度器核心 │
└───────────┬───────┘
│
▼
┌───────────────────┐
│ 爬虫任务执行 │
│ (各类数据抓取) │
└───────────────────┘
2.2 核心设计原则
- 可扩展性:易于添加新的爬虫任务
- 灵活性:支持多种触发方式和配置方式
- 可靠性:提供任务执行容错和日志记录
- 可视化管理:通过 HTTP API 实现远程任务管理
三、核心代码实现
3.1 CronScheduler 类实现
CronScheduler 是整个调度系统的核心,它封装了 APScheduler 的功能,并提供了更友好的接口:
python
# utils/scheduler_util.py
import time
import logging
import configparser
import ast
from datetime import datetime, timedelta
from typing import Any
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.base import BaseTrigger
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.date import DateTrigger
from apscheduler.executors.pool import ThreadPoolExecutor
class CronScheduler:
def __init__(self, function_registry):
# 配置线程池,使用单个线程避免浏览器资源竞争
executors = {'default': ThreadPoolExecutor(1)}
# 配置任务默认参数,设置20分钟容错时间
job_defaults = {'misfire_grace_time': 60*20}
# 初始化调度器,设置时区为上海
self.scheduler = BackgroundScheduler(
executors=executors,
timezone="Asia/Shanghai",
job_defaults=job_defaults
)
self.function_registry = function_registry
def _get_function_by_name(self, name):
"""从注册表中获取函数对象"""
func = self.function_registry.get(name)
if not func:
raise ValueError(f"函数 '{name}' 未在注册表中找到")
if not callable(func):
raise TypeError(f"'{name}' 不是一个可调用的函数")
return func
def add_job_internal(self, func, trigger, args, kwargs, job_id):
"""添加任务到调度器的内部方法"""
try:
self.scheduler.add_job(
func,
trigger=trigger,
args=args,
kwargs=kwargs,
id=job_id,
replace_existing=True
)
logging.info(f"成功添加任务 '{job_id}',触发器: {trigger}")
except Exception as e:
logging.error(f"添加任务 '{job_id}' 失败: {e}")
def start(self):
"""启动调度器"""
try:
self.scheduler.start()
logging.info("调度器已启动。按 Ctrl+C 退出。")
except (KeyboardInterrupt, SystemExit):
self.scheduler.shutdown()
logging.info("调度器已关闭。")
# 其他方法实现...
3.2 多类型触发器支持
系统支持六种不同类型的触发器,以满足各种调度需求:
ini
# 在CronScheduler类中
def load_jobs_from_config(self, filepath):
"""从配置文件加载任务"""
config = configparser.ConfigParser()
config.read(filepath, encoding='utf-8')
scheduler_tz = self.scheduler.timezone
for section in config.sections():
# 获取任务配置
job_config = config[section]
job_id = job_config.get('id', section)
func_name = job_config.get('func_name')
trigger_type = job_config.get('trigger_type')
config_str = job_config.get('trigger_config')
# 解析参数
trigger_config = ast.literal_eval(config_str)
args = ast.literal_eval(job_config.get('args', '[]'))
kwargs = ast.literal_eval(job_config.get('kwargs', '{}'))
func_to_call = self._get_function_by_name(func_name)
# 处理不同类型的触发器
if trigger_type == 'cron':
# Cron触发器:按时间表执行
trigger = CronTrigger(**trigger_config, timezone=scheduler_tz)
self.add_job_internal(func_to_call, trigger, args, kwargs, job_id)
elif trigger_type == 'interval':
# 间隔触发器:按固定时间间隔执行
trigger = IntervalTrigger(**trigger_config, timezone=scheduler_tz)
self.add_job_internal(func_to_call, trigger, args, kwargs, job_id)
elif trigger_type == 'date':
# 一次性触发器:在指定时间执行一次
# 支持绝对时间和相对时间两种方式
run_date_str = trigger_config.get('run_date')
if run_date_str:
trigger = DateTrigger(run_date=run_date_str, timezone=scheduler_tz)
self.add_job_internal(func_to_call, trigger, args, kwargs, job_id)
else:
# 计算相对时间
delay = timedelta(
seconds=trigger_config.get('delay_seconds', 0),
minutes=trigger_config.get('delay_minutes', 0),
hours=trigger_config.get('delay_hours', 0),
days=trigger_config.get('delay_days', 0)
)
if delay.total_seconds() > 0:
run_date = datetime.now(scheduler_tz) + delay
trigger = DateTrigger(run_date=run_date, timezone=scheduler_tz)
self.add_job_internal(func_to_call, trigger, args, kwargs, job_id)
elif trigger_type in ('limited_interval', 'limited_cron'):
# 有限次数触发器:执行指定次数后自动结束
runs = int(trigger_config.pop('runs', 0))
if runs <= 0:
logging.warning(f"跳过 limited 任务 '{job_id}': 'runs' 必须是正数")
continue
# 创建基础触发器
base_trigger_type = trigger_type.split('_')[1]
base_trigger = None
if base_trigger_type == 'cron':
base_trigger = CronTrigger(**trigger_config, timezone=scheduler_tz)
elif base_trigger_type == 'interval':
base_trigger = IntervalTrigger(**trigger_config, timezone=scheduler_tz)
# 计算并添加指定次数的任务
current_time = datetime.now(scheduler_tz)
last_fire_time = current_time
added_count = 0
for i in range(runs):
next_fire_time = base_trigger.get_next_fire_time(None, last_fire_time)
if next_fire_time:
limited_job_id = f"{job_id}_{i + 1}"
date_trigger = DateTrigger(run_date=next_fire_time, timezone=scheduler_tz)
self.add_job_internal(func_to_call, date_trigger, args, kwargs, limited_job_id)
last_fire_time = next_fire_time
added_count += 1
logging.info(f"为 '{job_id}' 成功添加了 {added_count} 个一次性任务")
3.3 动态任务调度机制
针对需要按日期范围分批执行的场景,我们实现了一个特殊的 dynamic_fetch_job
函数:
python
# utils/scheduler_util.py
def dynamic_fetch_job(
target_job_name: str,
tab=None,
config=None,
job_id="dynamic_fetch_job",
**kwargs
):
"""通用动态调度包装器"""
# 计数文件路径,用于记录已执行次数
count_file = f"output/{job_id}_count.txt"
if not os.path.exists("output"):
os.makedirs("output")
# 读取已调度次数
if os.path.exists(count_file):
with open(count_file, "r") as f:
count = int(f.read().strip())
else:
count = 0
# 获取执行间隔天数
interval_days = kwargs.get("interval_days", 1)
# 计算本次应该处理的日期范围
days_to_add = count * interval_days
start_date = datetime.strptime(config["start_date"], "%Y-%m-%d") + timedelta(days=days_to_add)
end_date = datetime.strptime(config["end_date"], "%Y-%m-%d") + timedelta(days=days_to_add)
# 更新配置
new_config = {
"start_date": start_date.strftime("%Y-%m-%d"),
"end_date": end_date.strftime("%Y-%m-%d")
}
config.update(new_config)
# 查找并执行目标任务
function_registry = kwargs.get("function_registry", {})
job_func = function_registry.get(target_job_name)
if not job_func:
raise ValueError(f"目标job函数 {target_job_name} 未注册!")
# 调用目标job
job_func(tab=tab, config=config, **kwargs)
# 更新执行计数
with open(count_file, "w") as f:
f.write(str(count + 1))
四、系统集成与应用
4.1 调度器初始化与任务注册
在应用入口文件中,我们初始化调度器并注册所有可调度的爬虫任务:
python
# app.py
from fastapi import FastAPI
from utils.scheduler_util import CronScheduler, dynamic_fetch_job
from service.daily_electricity_data_fetch import fetch_daily_electricity_data_job
from service.disclosure_data_fetch import disclosure_data_fetch_job
# 导入其他任务...
from utils.common_util import merge_with_local_config
app = FastAPI()
# 初始化函数注册表
default_function_registry = {
'fetch_daily_electricity_data_job': merge_with_local_config(fetch_daily_electricity_data_job),
'disclosure_data_fetch_job': merge_with_local_config(disclosure_data_fetch_job),
'holdings_data_fetch_job': merge_with_local_config(holdings_data_fetch_job),
'min_electricity_fetch_job': merge_with_local_config(min_electricity_fetch_job),
# 注册其他任务...
}
# 注册动态任务函数
dynamic_fetch_job_wrapper = merge_with_local_config(lambda **kwargs: dynamic_fetch_job(function_registry=default_function_registry, **kwargs))
default_function_registry['dynamic_fetch_job'] = dynamic_fetch_job_wrapper
# 创建并启动调度器
scheduler = CronScheduler(function_registry=default_function_registry)
scheduler.load_jobs_from_config("jobs_temp.ini")
scheduler.start()
4.2 配置文件示例
以下是一个任务配置文件的示例:
ini
; jobs_temp.ini
[每日电量数据抓取]
func_name = fetch_daily_electricity_data_job
trigger_type = cron
trigger_config = {'hour': 9, 'minute': 0}
kwargs = {"config": {"start_date": "2025-09-01", "end_date": "2025-09-01"}}
id = daily_electricity_9am
[披露数据抓取]
func_name = disclosure_data_fetch_job
trigger_type = cron
trigger_config = {'hour': 9, 'minute': 30}
kwargs = {"config": {"start_date": "2025-09-01", "end_date": "2025-09-01"}}
id = disclosure_data_930am
[持仓量查询]
func_name = holdings_data_fetch_job
trigger_type = cron
trigger_config = {'hour': 10, 'minute': 0}
kwargs = {"config": {"start_date": "2025-09-01", "end_date": "2025-09-01"}}
id = holdings_data_10am
[动态分批抓取]
func_name = dynamic_fetch_job
trigger_type = interval
trigger_config = {'hours': 1}
args = ["fetch_daily_electricity_data_job"]
kwargs = {"config": {"start_date": "2025-01-01", "end_date": "2025-01-01"}, "interval_days": 1}
id = dynamic_batch_fetch
4.3 HTTP API 接口
我们基于 FastAPI 提供了以下任务管理接口:
python
# app.py
from fastapi import HTTPException
from pydantic import BaseModel
from typing import Optional, Dict, Any
# 定义任务请求模型
class JobRequest(BaseModel):
job_type: str
trigger_type: str
trigger_config: Dict[str, Any]
config: dict
job_id: Optional[str] = None
@app.delete("/jobs/{job_id}")
async def remove_job(job_id: str):
"""删除指定的调度任务"""
try:
scheduler.scheduler.remove_job(job_id)
return {"message": f"任务 {job_id} 已删除"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/jobs/add")
async def add_job(request: JobRequest):
"""添加新的调度任务"""
try:
# 使用 add_job 方法创建任务
scheduler.add_job(
func_name=request.job_type,
trigger_type=request.trigger_type,
job_id=request.job_id or f"{request.job_type}_{int(time.time())}",
trigger_config=request.trigger_config,
kwargs=request.config
)
return {"message": "任务添加成功", "job_id": request.job_id or f"{request.job_type}_{int(time.time())}"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/get_jobs")
async def list_jobs():
"""获取当前所有调度任务的信息"""
try:
jobs = scheduler.list_jobs()
return {"jobs": jobs}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
六、性能优化与最佳实践
6.1 性能优化技巧
- 单线程执行 :设置
ThreadPoolExecutor(1)
避免浏览器资源竞争 - 任务容错配置 :设置
misfire_grace_time
提高系统稳定性 - 日志分级:合理设置日志级别,便于问题排查
- 文件管理:对于大量文件的处理,实现批处理和压缩功能
6.2 最佳实践建议
- 任务拆分:将复杂任务拆分为多个简单任务,便于管理和监控
- 配置管理:使用集中化的配置管理,支持不同环境切换
- 异常处理:为每个任务添加完善的异常处理机制
- 监控告警:添加任务执行状态监控和失败告警
- 执行记录:保存任务执行记录,便于追踪和审计
6.3 代码优化建议
- 配置文件验证
python
def validate_config_file(filepath):
"""验证配置文件格式的完整性"""
config = configparser.ConfigParser()
config.read(filepath)
for section in config.sections():
required_fields = ['func_name', 'trigger_type', 'trigger_config']
for field in required_fields:
if field not in config[section]:
raise ValueError(f"配置文件缺少必要字段: {field} 在任务 {section} 中")
- 任务执行监控装饰器
python
def monitor_job_execution(job_id, success_callback=None, failure_callback=None):
"""监控任务执行状态的装饰器"""
def decorator(func):
def wrapper(*args, **kwargs):
try:
logging.info(f"任务 {job_id} 开始执行")
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
logging.info(f"任务 {job_id} 执行成功,耗时: {end_time - start_time:.2f}秒")
if success_callback:
success_callback(job_id)
return result
except Exception as e:
logging.error(f"任务 {job_id} 执行失败: {e}")
if failure_callback:
failure_callback(job_id, e)
raise
return wrapper
return decorator