← 返回 Day 170 正文

Day 170 · 图解

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 本身 → 原地打转。