前几篇攒下的工具——日志分析、磁盘清理、健康检查——都有一个共同宿命:不能总靠人手敲。这篇讲定时调度,把「每天/每小时/每五分钟跑一次」交给机器。Python 里有两级方案:轻量的 schedule 和生产级的 APScheduler,各有各的位置。
一、轻量级:schedule 的甜与短
01 篇已经用过 schedule,先说清楚它的甜:
import schedule
schedule.every(10).seconds.do(job) # 每 10 秒
schedule.every().hour.do(job) # 每小时
schedule.every().day.at("09:00").do(job) # 每天 09:00
schedule.every().monday.at("08:30").do(job) # 每周一 08:30
schedule.every().day.at("09:30:59").do(job) # 精确到秒
while True:
schedule.run_pending()
time.sleep(0.5)
再看清楚它的短——它只是一个「到点提醒器」,别的什么都不管:
- 没有持久化:进程一重启,所有任务全忘;
- 没有重试:任务抛异常,这一轮就没了,不吭一声(异常甚至会让
run_pending()往外冒); - 没有错过处理:到点时进程正忙,这一轮直接丢;
- 单线程串行:上一个任务没跑完,下一个到点也只能等。
结论:个人小工具、轻量常驻场景用它没问题;一旦任务是「不许漏、要留痕」的运维动作,就该升级了。
二、生产级:APScheduler 上手
APScheduler 是 Python 生态里事实上的调度标准,比 schedule 多出来的正是运维要的东西:持久化 jobstore、错过触发处理、并发控制、crontab 语法。看一个最能说明问题的演示(demos/scheduler_demo.py):
import logging
import time
from apscheduler.schedulers.background import BackgroundScheduler
# 把 APScheduler 的警告打出来,才能看到「任务重叠被跳过」的日志
logging.basicConfig(level=logging.WARNING, format="%(asctime)s %(levelname)s %(message)s")
def tick() -> None:
print(time.strftime("%H:%M:%S"), "tick:常规巡检")
def slow_job() -> None:
print(time.strftime("%H:%M:%S"), "慢任务开始(单次要跑 4 秒)")
time.sleep(4)
print(time.strftime("%H:%M:%S"), "慢任务结束")
sched = BackgroundScheduler(timezone="Asia/Shanghai")
sched.add_job(tick, "interval", seconds=3, id="tick")
# 每 2 秒触发一次、但单次要跑 4 秒的任务:
# 不设 max_instances 时会并发起多个实例;设为 1 则重叠触发直接跳过
sched.add_job(slow_job, "interval", seconds=2, id="slow", max_instances=1)
print("== 调度开始 ==")
sched.start()
time.sleep(9) # 演示只跑 9 秒;真实部署 start() 后主线程做自己的事即可
sched.shutdown()
print("== 调度已优雅关停 ==")
真实运行输出(stdout):
== 调度开始 ==
20:58:48 慢任务开始(单次要跑 4 秒)
20:58:49 tick:常规巡检
20:58:52 tick:常规巡检
20:58:52 慢任务结束
20:58:54 慢任务开始(单次要跑 4 秒)
20:58:58 慢任务结束
== 调度已优雅关停 ==
同一时刻的日志输出(stderr),注意那两条 WARNING——它们是本篇的主角:
2026-09-28 20:58:50,894 WARNING Execution of job "slow_job (trigger: interval[0:00:02], next run at: 2026-09-28 20:58:50 CST)" skipped: maximum number of running instances reached (1)
2026-09-28 20:58:52,893 WARNING Execution of job "slow_job (trigger: interval[0:00:02], next run at: 2026-09-28 20:58:52 CST)" skipped: maximum number of running instances reached (1)
逐秒拆解:20:58:48 慢任务启动(要跑 4 秒);20:58:50、20:58:52 两个触发点到了,但上一次还没跑完——APScheduler 检测到 max_instances=1 已占满,直接跳过这两次触发并留下 WARNING。20:58:52 任务结束,20:58:54 的下一次触发恢复正常。
三、任务重叠:定时任务的头号事故源
上一步展示的不是报错,是保护。如果没有 max_instances=1,慢任务会被并发出第二个、第三个实例——想想这些场景:
- 清理脚本每 5 分钟跑一次,某次卡了 20 分钟 → 同时 4 个清理脚本在删同一批文件;
- 数据同步每 10 分钟跑一次,某次跑了 35 分钟 → 三个实例互相覆盖写同一张表;
- 巡检每分钟跑一次,网络抖动拖慢到 3 分钟 → 三个巡检同时发告警,告警群被刷爆。
所以给每个 job 配 max_instances=1 是默认动作,而日志里的 skipped WARNING 要接入监控——「任务老是重叠被跳过」本身说明:要么任务太慢,要么周期太密,该修的是任务而不是调大 max_instances。
另外两个配套参数:
misfire_grace_time=60:到点没跑成(比如进程正忙),60 秒内补跑还算数,超时放弃;coalesce=True:积压了多次错过触发时,只补跑一次而不是连环补。
四、踩坑实录:不要让任务「定时自杀」
本篇第一版演示代码是这样写自动关停的——把 shutdown 注册成一个定时任务:
# 第一版(错误示范):
sched = BlockingScheduler(timezone="Asia/Shanghai")
sched.add_job(sched.shutdown, "date", run_date=datetime.now() + timedelta(seconds=9))
sched.start()
真实运行结果,调到 9 秒后直接炸出两层堆栈:
apscheduler.jobstores.base.JobLookupError: 'No job by the id of 38f19be74a50483ab1e81263fd759cd6 was found'
...
RuntimeError: cannot join current thread
根因:shutdown() 要等线程池里所有任务收尾并 join 各自线程,而它自己是被线程池的线程在执行的——一个线程没法 join 自己,这就是 cannot join current thread。前一条 JobLookupError 则是关停过程把还在入队的 job 表清掉了。
教训一句话:生命周期管理(启动/关停/重载)属于主线程,任务只管干活。修复版用 BackgroundScheduler + 主线程 time.sleep(9) 后 shutdown(),就是第二节跑通的版本。
五、crontab 语法:迁移零成本
APScheduler 的 CronTrigger 和 crontab 一一对应,从系统 cron 迁移过来的经验全部有效:
sched.add_job(daily_report, "cron", hour=9, minute=0) # 每天 09:00 = "0 9 * * *"
sched.add_job(weekly_clean, "cron", day_of_week="mon", hour=2) # 每周一 02:00 = "0 2 * * 1"
sched.add_job(peek_check, "cron", minute="*/5") # 每 5 分钟 = "*/5 * * * *"
那系统 cron 和 APScheduler 怎么选?一张表说清:
| 方案 | 适合 | 关键差异 |
|---|---|---|
| cron / 任务计划程序 | 一次性巡检、系统级任务 | 不依赖 Python 进程常驻,机器重启后自动恢复 |
| systemd(Restart=always) | 常驻型监控进程 | init 进程托底,崩了自动拉起 |
| schedule | 个人小工具、轻量常驻 | 零依赖,但无持久化无重试 |
| APScheduler | 应用内调度、要持久化/重叠控制 | 功能全,随应用生命周期 |
| Celery beat | 分布式多机任务 | 需要消息队列基础设施,本文不展开 |
我的实践分工:监控脚本本身交给 systemd 托管;脚本内部的细粒度节奏用 APScheduler;个人工具用 schedule。
六、上生产前的可靠性清单
- 任务必须幂等:任何定时任务都可能被补跑、重跑,第二次执行不能产生错误结果(清理脚本重复删同一文件没事,付款脚本重复执行就是事故);
- 任务里要有日志和 try/except:让每一轮执行都留下「跑了、跑了多久、结果如何」,异常吃掉并记录而不是炸掉调度器;
- 接入告警:skipped WARNING、任务连续失败 N 次、任务超时——都该触发 01 篇的
notify(); - 时区显式声明:
BlockingScheduler(timezone="Asia/Shanghai"),别让服务器时区决定业务时间; - 持久化按需上:默认任务存内存,进程重启全丢;要「重启后接着排程」就配
SQLAlchemyJobstore,这是 APScheduler 相对 cron 的核心增值之一。
下一篇处理规模问题:任务要跑到几十台机器上,怎么一条命令管到底。
本篇代码:demos/scheduler_demo.py,已在 Python 3.13.15 / Windows(APScheduler 3.11.3)真实运行,stdout 与 stderr 均原样收录;第四节为真实踩坑的第一版代码及其真实报错。