如何用APScheduler和FastAPI打造永不宕机的分布式定时任务系统?

180 阅读4分钟

1. APScheduler分布式定时任务协调核心概念

1.1 为什么需要分布式定时任务?

在分布式系统中部署定时任务时,会遇到两个核心挑战:

  1. 单点故障问题:传统单节点调度器故障会导致所有定时任务中断
  2. 任务重复执行:多节点同时运行时可能触发同一个任务多次执行
    分布式协调就是为解决这些问题而生的技术方案。

1.2 APScheduler架构解析

APScheduler的核心组件和工作流程:

graph TD
    A[调度器 Scheduler] --> B(作业存储 JobStore)
    A --> C(执行器 Executor)
    A --> D(触发器 Trigger)
    B --> E[存储介质如 Redis/SQLite]
    C --> F[执行进程/线程池]
    D --> G[时间触发器逻辑]
  • 作业存储(JobStore):持久化任务信息(Redis/SQLite等)
  • 执行器(Executor):管理任务执行线程/进程
  • 触发器(Trigger):决定任务执行时间规则

1.3 分布式协调核心原理

关键协调机制:

  1. 分布式锁:通过Redis的SETNX实现互斥锁
  2. 心跳检测:节点定期更新Redis中的存活标记
  3. 故障转移:当leader节点故障时,其他节点通过选举接管任务

2. FastAPI集成APScheduler实战

2.1 环境准备与依赖

安装所需库:

pip install fastapi==0.103.1 uvicorn==0.23.2 
pip install apscheduler==3.10.1 redis==4.5.5 pydantic==2.4.2

2.2 核心代码实现

创建分布式任务调度系统:

from fastapi import FastAPI
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.jobstores.redis import RedisJobStore
from pydantic import BaseModel
import redis

# 定义任务参数模型
class TaskConfig(BaseModel):
    task_name: str
    interval: int  # 执行间隔(秒)

app = FastAPI()

# 配置Redis连接
redis_client = redis.Redis(host='redis', port=6379, db=0)
jobstores = {
    'default': RedisJobStore(redis=redis_client)
}

# 创建分布式调度器
scheduler = BackgroundScheduler(jobstores=jobstores)
scheduler.start()

@app.post("/schedule")
def create_scheduled_task(config: TaskConfig):
    def job_function():
        # 获取分布式锁
        with redis_client.lock(f"lock:{config.task_name}", timeout=10):
            print(f"执行任务: {config.task_name}")
            # 实际任务逻辑放这里

    # 添加定时任务
    scheduler.add_job(
        job_function,
        'interval',
        seconds=config.interval,
        id=config.task_name,
        replace_existing=True
    )
    return {"status": "scheduled", "task": config.task_name}

@app.on_event("shutdown")
def shutdown_event():
    scheduler.shutdown()

2.3 关键代码解析

  1. RedisJobStore:使用Redis作为任务存储后端
  2. 分布式锁redis_client.lock()确保任务单节点执行
  3. Pydantic验证:通过TaskConfig模型校验输入参数
  4. 优雅关闭shutdown_event确保服务停止时释放资源

3. 应用场景案例

3.1 电商优惠券过期系统

需求:每天凌晨2点批量作废过期优惠券

def expire_coupons():
    with redis_client.lock("coupon_expire_lock", timeout=30):
        print("执行优惠券过期处理...")
        # 连接数据库执行更新操作

# 在FastAPI启动时添加任务
if not scheduler.get_job("coupon_expiration"):
    scheduler.add_job(
        expire_coupons,
        'cron',
        hour=2,
        minute=0,
        id="coupon_expiration"
    )

3.2 分布式日志清理系统

需求:每30分钟清理过期日志文件

def clean_log_files():
    leader_key = "log_clean_leader"
    
    # 竞选主节点
    if redis_client.setnx(leader_key, "active"):
        redis_client.expire(leader_key, 1800)  # 持有30分钟领导权
        print("当前节点成为leader执行日志清理")
        # 实际清理逻辑
    else:
        print("当前节点为follower")

scheduler.add_job(
    clean_log_files,
    'interval',
    minutes=30,
    id="log_clean"
)

4. 常见问题与解决方案

4.1 任务重复执行(错误示例)

[ERROR] 任务被执行了多次

原因分析

  1. 未正确实现分布式锁
  2. 锁超时时间小于任务执行时间
    解决方案
  3. 使用Redis的原子操作确保锁机制可靠
  4. 根据任务执行时间动态调整锁超时
# 正确加锁方式
with redis_client.lock("my_lock", blocking_timeout=5, timeout=60):
    # 任务代码

4.2 节点状态不同步

[WARNING] 节点心跳丢失

预防措施

  1. 实现双心跳检测机制:
def heartbeat():
    # 更新最近心跳时间
    redis_client.setex(f"heartbeat:{node_id}", 30, "alive")
    
    # 检查其他节点状态
    for node in all_nodes:
        if not redis_client.exists(f"heartbeat:{node}"):
            print(f"{node}节点失效,触发故障转移")

5. Quiz:巩固知识点

问题1
当使用RedisJobStore时,如果Redis服务器重启,定时任务会丢失吗?为什么?

答案解析
不会丢失。因为RedisJobStore默认将任务序列化后持久存储在Redis中,只要Redis配置了持久化(RDB/AOF),重启后任务信息可以恢复。但要注意:

  1. 需确保Redis配置了appendonly yes
  2. 任务添加时设置replace_existing=True防止重复

问题2
如何在不停机的情况下修改已存在定时任务的触发时间?

答案解析
使用modify_job方法:

scheduler.modify_job(
    job_id='my_task',
    trigger='interval',
    minutes=45  # 修改为45分钟间隔
)

原理:调度器会重新计算下次触发时间,并更新JobStore中的元数据

6. 进阶技巧与实践建议

6.1 任务监控与告警

实现任务执行状态监控:

def monitoring_wrapper(job_func):
    def wrapper(*args, **kwargs):
        start = time.time()
        try:
            result = job_func(*args, **kwargs)
            status = "success"
        except Exception as e:
            status = "failed"
            # 发送告警
            send_alert(f"任务失败: {str(e)}")
        finally:
            duration = time.time() - start
            log_execution(job_id, status, duration)
        return result
    return wrapper

# 使用装饰器应用监控
scheduler.add_job(monitoring_wrapper(task_function), ...)

6.2 动态扩缩容策略

根据负载动态调整任务密度:

def dynamic_scaling():
    current_load = get_system_load()
    
    if current_load > 70:
        # 降低任务频率
        for job in scheduler.get_jobs():
            if job.id.startswith("batch_"):
                new_interval = min(300, job.trigger.interval * 1.5)
                scheduler.modify_job(job.id, minutes=new_interval)

6.3 最佳实践建议

  1. 总是为任务设置唯一的job_id
  2. 使用try/except包裹任务函数主体代码
  3. 为分布式锁设置合理的超时时间(建议任务执行时间×2)
  4. 定期备份Redis任务数据
  5. 实现任务执行历史记录:
class TaskLog(BaseModel):
    job_id: str
    status: Literal["success", "failed"]
    timestamp: datetime
    duration: float

@app.post("/log")
def save_log(log: TaskLog):
    redis_client.lpush("task_logs", log.json())