1. FastAPI定时任务需求背景与选型思考
第一次在FastAPI项目里实现定时任务时,我天真地以为用BackgroundTasks就能搞定。直到线上环境出现了邮件重复发送、数据重复统计的问题,才意识到自己犯了个典型的新手错误。FastAPI的BackgroundTasks设计初衷是处理请求后的异步操作,而非持久化的定时任务管理。
这里有个关键区别:BackgroundTasks的生命周期与HTTP请求绑定,而定时任务需要独立于请求之外持续运行。举个例子,你需要每天凌晨3点清理临时文件,这个任务不应该依赖任何API调用触发。此时就需要引入专业的任务调度库,而APScheduler正是Python生态中最轻量级的选择。
为什么不是Celery?在需要分布式任务队列的复杂场景下Celery确实更合适,但大多数中小型项目的定时任务需求相对简单。APScheduler的优势在于:
- 零外部依赖(仅需安装即可使用)
- 与FastAPI进程生命周期完美契合
- 支持内存和多种数据库持久化
- 提供精确的cron风格调度
2. APScheduler核心架构解析
理解APScheduler的架构,可以类比为一个智能化的工厂生产管理系统:
2.1 调度器(Scheduler) - 生产指挥中心
作为核心控制器,负责协调各个组件的工作。FastAPI项目通常选择AsyncIOScheduler,因为它与异步框架兼容性最好。创建实例时需要特别注意时区设置:
python复制from apscheduler.schedulers.asyncio import AsyncIOScheduler
scheduler = AsyncIOScheduler(timezone="Asia/Shanghai") # 必须明确指定时区
2.2 触发器(Trigger) - 生产计划表
决定任务何时执行,主要有三种类型:
- date:一次性任务(如2023-12-01 10:00执行)
- interval:固定间隔(如每30分钟)
- cron:复杂周期(如每周一9:00)
实际项目中,80%的场景会使用cron触发器。它的配置语法非常灵活:
python复制# 每天9:15执行
scheduler.add_job(daily_report, 'cron', hour=9, minute=15)
# 每周一、三、五的上班前执行
scheduler.add_job(weekly_cleanup, 'cron', day_of_week='mon,wed,fri', hour=8)
2.3 作业存储器(Job Store) - 生产任务数据库
默认使用内存存储,但生产环境强烈建议配置持久化存储。SQLAlchemyJobStore是最常用的选择:
python复制from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
jobstores = {
'default': SQLAlchemyJobStore(url='sqlite:///jobs.sqlite')
}
2.4 执行器(Executor) - 生产车间
决定任务如何执行。对于IO密集型任务,ThreadPoolExecutor是稳妥选择:
python复制from apscheduler.executors.pool import ThreadPoolExecutor
executors = {
'default': ThreadPoolExecutor(20) # 线程池大小根据任务数量调整
}
3. FastAPI集成完整方案
3.1 项目结构设计
推荐采用模块化组织代码:
code复制project/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI应用入口
│ ├── scheduler.py # 调度器配置
│ └── tasks/ # 任务函数目录
│ ├── __init__.py
│ ├── email.py # 邮件相关任务
│ └── cleanup.py # 清理任务
3.2 调度器初始化
在scheduler.py中完成核心配置:
python复制from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.executors.pool import ThreadPoolExecutor
def create_scheduler():
jobstores = {
'default': SQLAlchemyJobStore(
url='sqlite:///jobs.sqlite',
engine_options={'connect_args': {'check_same_thread': False}}
)
}
executors = {
'default': ThreadPoolExecutor(20)
}
job_defaults = {
'coalesce': False, # 错过的任务不合并执行
'max_instances': 3 # 并发控制
}
return AsyncIOScheduler(
jobstores=jobstores,
executors=executors,
job_defaults=job_defaults,
timezone="Asia/Shanghai"
)
scheduler = create_scheduler()
3.3 生命周期管理
使用FastAPI的lifespan事件实现优雅启停:
python复制# main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from .scheduler import scheduler
from .tasks import register_jobs
@asynccontextmanager
async def lifespan(app: FastAPI):
# 启动阶段
register_jobs(scheduler)
scheduler.start()
print("Scheduler started")
yield
# 关闭阶段
scheduler.shutdown(wait=False)
print("Scheduler stopped")
app = FastAPI(lifespan=lifespan)
4. 多进程部署的陷阱与解决方案
4.1 问题重现
使用Uvicorn多worker启动时:
bash复制uvicorn app.main:app --workers 4
每个worker进程都会独立初始化调度器,导致:
- 定时任务重复注册
- 相同任务被多次执行
- 数据库写入冲突
4.2 解决方案一:文件锁机制
适用于单机多进程场景:
python复制import fcntl
import os
class FileLock:
def __init__(self, filename):
self.filename = filename
self.handle = None
def acquire(self):
self.handle = open(self.filename, 'w')
try:
fcntl.flock(self.handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
return True
except (BlockingIOError, OSError):
return False
def release(self):
if self.handle:
fcntl.flock(self.handle, fcntl.LOCK_UN)
self.handle.close()
os.unlink(self.filename)
# 使用示例
lock = FileLock('/tmp/scheduler.lock')
if lock.acquire():
scheduler.start()
4.3 解决方案二:数据库选举
适合分布式环境:
python复制from sqlalchemy import select
from datetime import datetime, timedelta
async def acquire_leader_lock(db):
# 尝试获取或更新锁记录
lock = await db.get(LeaderLock, 1)
if not lock:
# 首次获取
lock = LeaderLock(id=1, owner=os.getpid(), last_heartbeat=datetime.now())
db.add(lock)
await db.commit()
return True
# 检查锁是否过期(假设10分钟无心跳视为过期)
if datetime.now() - lock.last_heartbeat > timedelta(minutes=10):
lock.owner = os.getpid()
lock.last_heartbeat = datetime.now()
await db.commit()
return True
return lock.owner == os.getpid() # 是否已经是持有者
4.4 方案对比
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 文件锁 | 单机部署 | 实现简单,性能高 | 不支持跨机器 |
| 数据库选举 | 分布式环境 | 支持多节点 | 需要额外的心保活机制 |
| Redis锁 | 混合部署 | 性能与分布式兼顾 | 需要Redis依赖 |
5. 实战经验与避坑指南
5.1 时区问题
遇到过最隐蔽的bug是时区配置不一致:
- APScheduler配置了时区
- 服务器系统时区却是UTC
- 数据库存储时间又用了本地时区
解决方案是强制统一时区:
python复制import os
os.environ['TZ'] = 'Asia/Shanghai' # 影响time模块
5.2 任务幂等性设计
即使解决了多进程问题,网络抖动或异常重启仍可能导致任务重复执行。所有任务函数都应实现幂等:
python复制async def process_order(order_id):
# 先检查状态
order = await get_order(order_id)
if order.status != 'pending':
return
# 标记处理中
await mark_order_processing(order_id)
try:
# 实际处理逻辑
await real_processing(order_id)
await mark_order_completed(order_id)
except Exception:
await reset_order_status(order_id)
raise
5.3 日志与监控
完善的日志能快速定位问题:
python复制from apscheduler.events import EVENT_JOB_EXECUTED, EVENT_JOB_ERROR
def job_listener(event):
if event.exception:
logger.error(f"Job {event.job_id} failed: {event.exception}")
else:
logger.info(f"Job {event.job_id} executed successfully")
scheduler.add_listener(job_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)
5.4 性能优化技巧
- 批量操作:数据库任务合并写入
- 适当错峰:避免所有任务整点执行
- 资源隔离:IO密集型与CPU密集型任务使用不同执行器
python复制executors = {
'default': ThreadPoolExecutor(10), # 常规任务
'cpu_bound': ProcessPoolExecutor(2) # CPU密集型
}
6. 扩展应用场景
6.1 动态任务管理
通过API实现任务动态增删:
python复制@app.post("/jobs")
async def create_job(schedule: JobSchedule):
job = scheduler.add_job(
func=schedule.task_name,
trigger=schedule.trigger,
**schedule.trigger_args
)
return {"job_id": job.id}
@app.delete("/jobs/{job_id}")
async def remove_job(job_id: str):
scheduler.remove_job(job_id)
return {"status": "removed"}
6.2 长周期任务处理
对于执行时间超过调度间隔的任务:
python复制job_defaults = {
'misfire_grace_time': 300, # 允许的触发延迟秒数
'coalesce': True # 合并多次未执行的任务
}
6.3 测试策略
- 单元测试:mock调度器接口
- 集成测试:使用MemoryJobStore快速验证
- 时间模拟:测试特殊日期逻辑
python复制from freezegun import freeze_time
@freeze_time("2023-12-25")
def test_christmas_special():
# 测试圣诞节特殊任务
assert should_run_christmas_task() is True
