Day 170 — 定时任务 · 图解
全部为纯文本 / Mermaid,可离线阅读。
图 1:为什么 time.sleep 不算调度器
朴素循环 真正的调度器
┌──────────────┐ ┌──────────────┐
│ work() 5min │ │ Trigger 算出 │
├──────────────┤ │ 绝对触发时刻 │
│ sleep(60min) │ ← 间隔 = 65min ├──────────────┤
├──────────────┤ 每天漂移 +5min │ 等待到点 │
│ work() 5min │ ├──────────────┤
├──────────────┤ │ Executor 执行│
│ sleep(60min) │ ← 又漂了 │ (不阻塞) │
└──────────────┘ └──────────────┘
一个月后错 90 分钟 间隔恒定,永不漂移
代价对照:
· 睡眠期间什么都干不了,多任务只能排队
· 进程重启 → 任务表回到零点
· 3:00 关机 3:05 开机 → 3:00 的任务永远不跑
图 2:APScheduler 四件套与数据流
add_job(func, trigger, id, args)
│
▼
┌────────────────────────────────────────────┐
│ Scheduler │ 主循环
│ 每隔一小段时间醒来:now >= next_run_time ? │ ← 唯一的裁判
└───────┬────────────────────────────────────┘
│ 到期则提交
▼
┌─────────────────┐ ┌──────────────────┐
│ Executor │───────▶│ func(*args) │
│ 线程池/进程池 │ │ 你写的业务函数 │
└─────────────────┘ └────────┬─────────┘
│ 跑完回写状态 │ 抛异常
▼ ▼
┌────────────────────────────────────────────┐
│ JobStore │
│ id │ next_run_time │ job_state(pickle) │ ← 持久化就存这三样
└────────────────────────────────────────────┘
换组件 = 换一格:
线程池→进程池(CPU 密集) 内存→SQL(重启不丢) Memory→Background(主线程解放)
图 3:三个防重复的参数分别防什么
时间轴 ──────────────────────────────────────────────▶
│ │ │ │ │ │
T1 T2 T3 T4 T5 T6
├────────┼────────┼────────┼────────┼────────┤
↑ T2 来了,上一次还没跑完
max_instances=1 → T2 直接丢弃 + warning
「不排队,只保最新」
适合:采集快照、刷新状态
max_instances=2 → T2 排队等 T1 跑完
「允许并发上限 N」
适合:互不相关的独立任务
── 换成机器卡住 5 分钟的情况 ──
T1 跑了 5 分钟,期间 T2..T5 全到期
coalesce=False → T2 T3 T4 T5 全部补跑(5 次!)
coalesce=True → 只补最后那一次(T5)
「积压合并」
misfire_grace_time=0 → 迟到 1 秒就不跑
misfire_grace_time=300 → 迟到 5 分钟内仍然跑
misfire_grace_time=None → 来者不拒(但会把积压全补一遍)
图 4:AndTrigger 为什么会把调度器卡死
❌ AndTrigger([CronTrigger(day_of_week="mon-fri"),
CronTrigger(hour=9)])
官方实现(apscheduler/triggers/combining.py):
while True: ← 死循环在这里
fire_times = [t.get_next_fire_time(prev, now) for t in triggers]
if None in fire_times: return None
elif min(fire_times) == max(fire_times): return fire_times[0] ← 永远不成立
else: now = max(fire_times)
第 1 轮: [周一 00:00, 周一 09:00] → 不相等 → now = 周一 09:00
第 2 轮: [周二 00:00, 周二 09:00] → 不相等 → now = 周二 09:00
第 3 轮: [周三 00:00, 周三 09:00] → 不相等 → ...
└─▶ 永远不收敛,CPU 100%,整个调度器不再处理任何其他任务
✅ 正确写法:条件合并进同一个 cron 表达式
CronTrigger(day_of_week="mon-fri", hour=9)
→ 一个表达式同时表达两个条件,无循环,直接返回下一个周一 09:00
图 5:多实例部署的重复执行事故
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 容器 A │ │ 容器 B │ │ 容器 C │
│ Scheduler │ │ Scheduler │ │ Scheduler │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ add_job("daily-backup") 三边都注册了
└────────────────┬───────┴───────────────────┘
▼
┌──────────────────────┐
│ 03:00 到了 │
│ A、B、C 都认为该跑 │
└──────────┬───────────┘
▼
💥 备份 3 份 / 邮件发 3 封 / 文件互相覆盖
── 三种解法 ──
① 单实例(推荐)
┌──────────┐ ┌──────────┐ ┌──────────┐
│ web-1 │ │ web-2 │ │ web-3 │ ← 只有这些副本
└──────────┘ └──────────┘ └──────────┘
┌──────────┐
│scheduler │ ← 单独一份,不随 web 扩容
└──────────┘
② 分布式锁(04 实现的就是这个)
A 抢到锁 ──▶ 干活
B 抢不到 ──▶ 退出(skipped_reason="锁被 pid-x 持有")
C 抢不到 ──▶ 退出
⚠️ 锁必须带 TTL,否则持锁进程崩溃后任务静默地再也不会运行
③ 外部编排:交给 cron / K8s CronJob / Airflow,只让它触发
图 6:Mermaid —— 一次完整的定时备份时序
sequenceDiagram
autonumber
participant S as Scheduler
participant J as JobStore
participant E as Executor
participant L as JobLock(SQLite)
participant F as backup_job()
Note over S: 主循环醒来,比较 now 与 next_run_time
S->>J: 读取 job("daily-backup")
J-->>S: next_run_time 已到
S->>E: 提交任务
E->>F: 调用 backup_job()
F->>L: acquire("daily-backup")
alt 锁被别人持有
L-->>F: False
F-->>E: skipped_reason(幂等退出,不报错)
else 抢到锁
L-->>F: True
F->>F: 今天已经成功过吗?
alt 已成功
F-->>E: 幂等跳过
else 未成功
F->>F: tar 打包 data/
F->>F: 立即解包校验(sha256 + 文件数)
alt 校验失败
F->>F: 删除损坏归档 → 记 error → 告警
else 校验通过
F->>F: 写入 backup_log(落账)
F->>F: prune() 按保留策略清理
F-->>E: BackupResult(ok=true)
end
end
F->>L: release()
end
E-->>S: EVENT_JOB_EXECUTED / EVENT_JOB_ERROR
Note over S,J: 回写 next_run_time = 下一次
sequenceDiagram
autonumber
participant S as Scheduler
participant J as JobStore
participant E as Executor
participant L as JobLock(SQLite)
participant F as backup_job()
Note over S: 主循环醒来,比较 now 与 next_run_time
S->>J: 读取 job("daily-backup")
J-->>S: next_run_time 已到
S->>E: 提交任务
E->>F: 调用 backup_job()
F->>L: acquire("daily-backup")
alt 锁被别人持有
L-->>F: False
F-->>E: skipped_reason(幂等退出,不报错)
else 抢到锁
L-->>F: True
F->>F: 今天已经成功过吗?
alt 已成功
F-->>E: 幂等跳过
else 未成功
F->>F: tar 打包 data/
F->>F: 立即解包校验(sha256 + 文件数)
alt 校验失败
F->>F: 删除损坏归档 → 记 error → 告警
else 校验通过
F->>F: 写入 backup_log(落账)
F->>F: prune() 按保留策略清理
F-->>E: BackupResult(ok=true)
end
end
F->>L: release()
end
E-->>S: EVENT_JOB_EXECUTED / EVENT_JOB_ERROR
Note over S,J: 回写 next_run_time = 下一次
图 7:JobStore 持久化了什么
表 apscheduler_jobs(SQLAlchemyJobStore)
┌──────────────┬────────────────────┬──────────────────────────┐
│ id │ next_run_time │ job_state │
├──────────────┼────────────────────┼──────────────────────────┤
│ daily-backup │ 2026-09-30 03:00:00│ pickle(整个 Job 对象) │
│ weekly-report│ 2026-10-03 08:30:00│ 函数引用+参数+trigger 配置 │
└──────────────┴────────────────────┴──────────────────────────┘
│
└─▶ 调度器就是按 next_run_time 排序取任务的
由此推出三条硬约束:
① job_state 是 pickle → 函数必须**模块顶层可导入**
写进 __main__ / 局部函数 / lambda → 重启后反序列化直接炸
② next_run_time 是**共享状态** → 多进程连同一库会互相抢任务
(所以它既能造成"重复执行",也能被拿来做互斥锁)
③ 重启不会补跑 → 停机 3 小时,恢复后仍然只等下一个 03:00
想补跑必须显式改 next_run_time 或靠任务自身的 last_success 逻辑
图 8:锁必须带过期时间
❌ 无 TTL 的锁
┌─────────┐ acquire ┌─────────┐
│ 实例 A │ ─────────▶ │ 持锁 │
└─────────┘ └─────────┘
│
✖ 进程被 kill -9(没机会 release)
┌─────────┐ acquire ┌─────────┐
│ 实例 B │ ─────────▶ │ ✖ 拿不到 │
└─────────┘ └─────────┘
│ acquire
▼
✖ 拿不到
... 明天、后天、永远都拿不到
⚠️ 而且**没有任何报错**:任务只是安静地不再运行
✅ 带 TTL 的锁
acquire(name, ttl=600)
├─ 抢锁时顺手删掉 expires_at < now 的行 ← 过期锁自动让位
└─ 超时未释放 → 10 分钟后自动过期
进程崩溃后的实际表现:
A 崩溃 → B 抢不到 → 跳过本轮 → 10 分钟后的下一轮正常执行
(最坏情况丢一轮,而不是永久失效)
图 9:心跳 activeHours 的窗口语义
配置 agents.defaults.heartbeat.activeHours = {start:"18:00", end:"09:00"}
24 小时时间轴
00 03 06 09 12 15 18 21 24
│─────│─────│─────│─────│─────│─────│─────│─────│
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
░ = 窗口内(心跳运行) ▓ = 窗口外 → skipped: quiet-hours
start 含、end 不含 → 18:00:00~08:59:59 活跃
⚠️ 踩坑记录:若写成 start="18:00" end="09:00",本意是"夜里安静",
但白天 09:00~17:59 每半小时产生一次 skipped,
而 skipped 在任务面板里被记成 **failed** → 每天 +18 条噪音记录。
结论:activeHours 的两个值都要按实际期望写,别凭直觉。
图 10:schedule 与 APScheduler 的能力边界
┌────────────────────┬──────────────────┬──────────────────┐
│ 维度 │ schedule │ APScheduler │
├────────────────────┼──────────────────┼──────────────────┤
│ 触发器 │ interval+day/time│ cron/interval/ │
│ │ │ date/Or │
│ 持久化 │ ✖ 无 │ ✔ SQL/Redis/Mongo│
│ 并发 │ ✖ 单线程串行 │ ✔ 线程/进程池 │
│ 错过补偿 │ ✖ 无 │ ✔ coalesce+grace │
│ 多实例互斥 │ ✖ 无 │ ✔ 靠共享 JobStore│
│ 失败可见 │ 异常直接抛出 │ ✔ 事件监听器 │
│ 依赖 │ 0(纯标准库) │ APScheduler+DB │
│ 体积 │ 单文件几 KB │ 数百 KB │
├────────────────────┼──────────────────┼──────────────────┤
│ 适用 │ 树莓派灯控 │ 生产关键任务 │
│ │ 本地小提醒 │ 备份/对账/报表 │
│ │ 单机脚本 │ 多副本服务 │
└────────────────────┴──────────────────┴──────────────────┘
图 11:三种 Trigger 的下一次触发推算
基准:2026-01-05(周一)09:00
CronTrigger(hour=3, minute=0)
周二 03:00 → 周三 03:00 → 周四 03:00
(从日历推,跳过大段空闲)
CronTrigger(day_of_week="mon-fri", hour=9)
周一 09:00 → 周二 09:00 → 周三 09:00 ← 条件写进同一个表达式
(不是 AndTrigger 拼两个 cron,那样会死循环)
IntervalTrigger(minutes=30, start_date=基准)
周一 09:00 → 09:30 → 10:00 → 10:30 (从注册时刻累加)
⚠️ 不给 start_date 时默认「此刻+30min」,与你的基准点无关
DateTrigger(run_date=某时刻)
只会返回那一次;之后再查返回 None(一次性,不会重复触发)
⚠️ 推算时 previous_fire_time 不能一直传 None,
否则 now 命中 cron 时会返回 now 本身 → 原地打转。