第 21 章:定时任务与后台作业 — 集群下只跑一次
示例工程:
examples-middleware/scheduler_demo(需要 Postgres + Redis,见第 14.2 节)本章把后半本书的概念串起来:分布式锁(第 15 章)、优雅停机(第 16 章)、 幂等(第 18 章)、重任务外置消息队列(第 14 章),都在定时任务里同时用上。
学习目标
- 会用
tokio::time::interval和 cron 表达式两种方式触发周期任务 - 理解集群下「同一个 tick 触发 N 次」的问题,用分布式锁 + 唯一约束双重去重
- 会给调度器接优雅停机,让在途任务跑完再退出
- 知道 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,是「无状态多实例」的必然结果。解法是给「本该只发生一次」的任务加一道协调:
每个实例的调度器都触发了(3 个 tick 同时发生)
→ 各自去抢同一把分布式锁 lock:job:{任务名}:{触发时刻}
→ 只有一个抢到 → 它执行任务
→ 其余两个抢不到 → 跳过(这是正常,不是错误)锁的 key 里带上触发时刻(run_key,如 对账-2026072603),保证「这一次触发」全局唯一——同一次触发大家抢同一把锁,下一次触发换一把新锁。这正是第 15 章分布式锁的应用场景。
21.3 双重防线:锁 + 数据库唯一约束
只靠锁够吗?不够。锁有 TTL,极端情况下(持锁实例卡顿超过 TTL、锁提前释放)仍可能两个实例都执行。生产做法是加第二道防线——执行留痕表 + 唯一约束:
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_demo 的 guarded_tick 把两道防线串起来:
1. 抢分布式锁(快速筛掉绝大多数并发)
2. 抢到后 INSERT ... ON CONFLICT DO NOTHING 记录本次触发
→ 真插入了(返回 true):执行业务
→ 撞唯一约束(返回 false):别人已记录,跳过run_key 在这里同时是「锁的 key」和「幂等键」——和第 18 章的幂等键是同一个思想:用一个代表业务意图的唯一标识,让重复触发变成无害操作。锁负责性能(避免大量无效 INSERT),唯一约束负责正确性(锁失效也兜得住)。
21.4 优雅停机:别把任务腰斩
调度器是长驻后台循环,部署更新时(第 16 章的 SIGTERM)不能直接 kill——可能正跑到一半。调度循环要监听停机信号:
loop {
select! {
_ = interval.tick() => guarded_tick(...).await, // 正常触发
_ = shutdown.cancelled() => break, // 收到停机信号,退出循环
}
}
// 循环外:等待在途任务结束再返回收到信号后停止触发新任务、等当前任务跑完——和第 16 章 Web 服务的优雅停机是同一套心智。
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 又当幂等键
- [ ] 我能描述调度器优雅停机的处理流程
- [ ] 我能说出为什么重任务要抢锁后外置到消息队列
练习
- 把
scheduler_demo的业务从「记录一行」换成「向mq_rabbit投一条任务消息」,让 worker 异步执行,体会「调度器只触发、worker 才执行」的分工。 - 给任务加 misfire 补跑:记录上次成功的 run_key,启动时检查中间是否有遗漏的触发点并补跑。
- 用 cron 表达式实现「每周一 9:00 发周报」,并思考跨时区时 cron 该按哪个时区解释(提示:tokio-cron-scheduler 的时区配置)。
- 把
scheduler_demo/src/lock.rs和cluster_demo/src/dlock.rs抽成一个公共 crate,消除重复——体会 workspace 内共享代码的组织方式(第 7 章)。
动手验证:
cd examples-middleware
docker compose up -d postgres redis
cargo test -p scheduler_demo -- --ignored # 并发两实例同一 tick 只落一条记录的断言