Skip to content

第 21 章:定时任务与后台作业 — 集群下只跑一次

示例工程:examples-middleware/scheduler_demo(需要 Postgres + Redis,见第 14.2 节)

本章把后半本书的概念串起来:分布式锁(第 15 章)、优雅停机(第 16 章)、 幂等(第 18 章)、重任务外置消息队列(第 14 章),都在定时任务里同时用上。

学习目标

  1. 会用 tokio::time::interval 和 cron 表达式两种方式触发周期任务
  2. 理解集群下「同一个 tick 触发 N 次」的问题,用分布式锁 + 唯一约束双重去重
  3. 会给调度器接优雅停机,让在途任务跑完再退出
  4. 知道 misfire、幂等、重任务外置等生产考量

21.1 两种触发方式:间隔 vs 日历

  • 固定间隔tokio::time::interval):每隔 N 秒/分钟跑一次,不关心具体时刻。适合「每 30 秒拉一次配置」「每 5 分钟清一次过期数据」。最简单,一个 loop { interval.tick().await; ... } 就够。
  • cron 表达式tokio-cron-scheduler):按日历规则触发,「每天凌晨 3 点」「每周一 9 点」。适合和业务日历绑定的任务(对账、报表、账单)。注意 Rust 生态常用 6 段 cron(带秒):*/5 * * * * * 是每 5 秒。

单机场景,选一种起个后台任务就完事了。问题出在集群。

21.2 核心问题:集群里定时任务会跑 N 遍

第 15 章的无状态化让你可以起 3 个实例扛流量——但定时任务是反的:3 个实例各有一个调度器,凌晨 3 点一到,对账任务会被触发 3 次。轻则报表算 3 遍浪费资源,重则发 3 次工资、扣 3 次费——生产事故。

这不是 bug,是「无状态多实例」的必然结果。解法是给「本该只发生一次」的任务加一道协调:

text
每个实例的调度器都触发了(3 个 tick 同时发生)
  → 各自去抢同一把分布式锁 lock:job:{任务名}:{触发时刻}
  → 只有一个抢到 → 它执行任务
  → 其余两个抢不到 → 跳过(这是正常,不是错误)

锁的 key 里带上触发时刻run_key,如 对账-2026072603),保证「这一次触发」全局唯一——同一次触发大家抢同一把锁,下一次触发换一把新锁。这正是第 15 章分布式锁的应用场景。

21.3 双重防线:锁 + 数据库唯一约束

只靠锁够吗?不够。锁有 TTL,极端情况下(持锁实例卡顿超过 TTL、锁提前释放)仍可能两个实例都执行。生产做法是加第二道防线——执行留痕表 + 唯一约束:

sql
CREATE TABLE scheduler.job_runs (
    id BIGSERIAL PRIMARY KEY,
    job_name TEXT, run_key TEXT, instance TEXT,
    ran_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE(job_name, run_key)      -- 同一次触发只能落一条
);

scheduler_demoguarded_tick 把两道防线串起来:

text
1. 抢分布式锁(快速筛掉绝大多数并发)
2. 抢到后 INSERT ... ON CONFLICT DO NOTHING 记录本次触发
   → 真插入了(返回 true):执行业务
   → 撞唯一约束(返回 false):别人已记录,跳过

run_key 在这里同时是「锁的 key」和「幂等键」——和第 18 章的幂等键是同一个思想:用一个代表业务意图的唯一标识,让重复触发变成无害操作。锁负责性能(避免大量无效 INSERT),唯一约束负责正确性(锁失效也兜得住)。

21.4 优雅停机:别把任务腰斩

调度器是长驻后台循环,部署更新时(第 16 章的 SIGTERM)不能直接 kill——可能正跑到一半。调度循环要监听停机信号:

text
loop {
    select! {
        _ = interval.tick()      => guarded_tick(...).await,  // 正常触发
        _ = shutdown.cancelled() => break,                    // 收到停机信号,退出循环
    }
}
// 循环外:等待在途任务结束再返回

收到信号后停止触发新任务、等当前任务跑完——和第 16 章 Web 服务的优雅停机是同一套心智。

bash
cd examples-middleware
docker compose up -d postgres redis
cargo run -p scheduler_demo    # 模拟两个实例,看每个 tick 谁抢到锁、谁跳过

演示会启动两个逻辑实例跑同一个每 2 秒的任务,输出证明:6 秒内触发 3 次,job_runs 表里只有 3 条记录(不是 6 条),每条记下是哪个实例执行的。

21.5 生产考量清单

  • misfire(错过触发):实例宕机期间错过的触发,重启后要不要补跑?对账要补、清理可不补——按业务定,cron 库通常有策略配置
  • 幂等:即使双重防线,业务逻辑本身也应幂等(第 18 章),这是分布式的通用保险
  • 重任务外置:任务超过几秒就别在调度线程里同步跑——抢到锁后只往消息队列(第 14 章 mq_rabbit)投一条,由 worker 消费。调度器只管「触发」,不管「执行」
  • 可观测:每次执行留痕(job_runs 表就是最小形态),记录耗时/成功失败,失败告警
  • 专用调度组件:规模大了可以上专门的调度中间件(Kubernetes CronJob、XXL-JOB、Temporal)——但「触发去重 + 幂等 + 留痕」的原理不变,理解了本章就能看懂它们

本章小结

  • 间隔触发用 tokio::time::interval,日历规则用 cron 表达式(Rust 常见 6 段带秒)
  • 集群里 N 个实例的调度器会把同一任务触发 N 次,这是无状态多实例的必然
  • 去重双防线:分布式锁(性能)+ 数据库唯一约束(正确性),run_key 兼任锁 key 与幂等键
  • 调度器要接优雅停机:停止触发、等在途任务结束再退出
  • 重任务抢到锁后只投消息队列,由 worker 执行,调度器不做重活
  • misfire 补跑策略、业务幂等、执行留痕是生产必备

自测清单

  • [ ] 我能说出间隔触发与 cron 触发各自的适用场景
  • [ ] 我能解释为什么集群里定时任务会跑 N 遍
  • [ ] 我能说清分布式锁和唯一约束在去重里各自兜住什么
  • [ ] 我能解释 run_key 为什么既当锁 key 又当幂等键
  • [ ] 我能描述调度器优雅停机的处理流程
  • [ ] 我能说出为什么重任务要抢锁后外置到消息队列

练习

  1. scheduler_demo 的业务从「记录一行」换成「向 mq_rabbit 投一条任务消息」,让 worker 异步执行,体会「调度器只触发、worker 才执行」的分工。
  2. 给任务加 misfire 补跑:记录上次成功的 run_key,启动时检查中间是否有遗漏的触发点并补跑。
  3. 用 cron 表达式实现「每周一 9:00 发周报」,并思考跨时区时 cron 该按哪个时区解释(提示:tokio-cron-scheduler 的时区配置)。
  4. scheduler_demo/src/lock.rscluster_demo/src/dlock.rs 抽成一个公共 crate,消除重复——体会 workspace 内共享代码的组织方式(第 7 章)。

动手验证

bash
cd examples-middleware
docker compose up -d postgres redis
cargo test -p scheduler_demo -- --ignored   # 并发两实例同一 tick 只落一条记录的断言

导航:上一章 | 返回目录 | 下一章