跳到正文

分布式任务调度器系统设计:大规模 cron 且不重复执行

设计分布式任务调度器:调度存储、用 SKIP LOCKED 领取到期任务、租约、幂等执行、重试、错过触发与夏令时。

· 创始人兼开发者

发布于

架构图。Job API 写入 Postgres 调度存储。框内的三个调度器 worker 从 etcd 协调器获取分区租约,并把执行送入队列。执行器 worker 调用目标服务,记录执行历史,通过队列重试或送入死信。

你的产品每天 09:00(按各客户的时区)给每位客户发一封用量报告邮件,每 15 分钟同步一次 CRM 账户,并在午夜让试用到期。只有一台服务器时,cron 就能搞定。再加一台服务器,报告会发两遍。加一把锁,让持锁者在部署期间卡住,某天早上报告一次都发不出去。

本文设计一个分布式任务调度器:它能准时启动数百万个任务,又不让重复执行造成损害,内容涵盖调度存储、租约、夏令时、错过触发、扩展和现有工具。

需求与关键数字

假设一个多租户 SaaS 产品:

  • 1,000 万个活跃调度,多数按小时或按天触发。
  • 如果平均每个调度一天触发 24 次,则每天 2.4 亿次执行:约每秒 2,800 次(240,000,000 / 86,400)。
  • 执行在到期后几秒内开始,最长持续 30 分钟。
  • 30 天的执行历史:每天 2.4 亿行,每行约 200 字节,即每天 48 GB,每月 1.4 TB。

人们习惯写 0 * * * *,所以到期时间会堆积在整分钟上。如果 300 万个小时级调度都用第 0 分钟,300 万次执行会同时到期,要在一分钟内派发完就需要每秒 50,000 次,是平均值的 18 倍。Jenkins 为此加入了 cron 符号 H:它对任务名做哈希,得到一个稳定的分钟数。内部任务也用同样的方法打散。

数据模型:任务与执行

jobs 保存每个定义及其下一次到期时刻;job_runs 为每次触发保存一行。

CREATE TABLE jobs (
  job_id       uuid PRIMARY KEY,
  cron_expr    text NOT NULL,          -- '0 9 * * *'
  time_zone    text NOT NULL,          -- 'America/New_York'
  misfire      text NOT NULL DEFAULT 'run_once',
  partition_id smallint NOT NULL,      -- hash(job_id) % 256
  next_run_at  timestamptz NOT NULL,   -- a UTC instant
  paused       boolean NOT NULL DEFAULT false
);
CREATE INDEX jobs_due ON jobs (partition_id, next_run_at) WHERE NOT paused;

CREATE TABLE job_runs (
  job_id       uuid NOT NULL,
  scheduled_at timestamptz NOT NULL,
  attempt      int NOT NULL DEFAULT 0,
  status       text NOT NULL,          -- queued, running, retrying, succeeded, dead
  lease_until  timestamptz,
  PRIMARY KEY (job_id, scheduled_at)   -- the run key
) PARTITION BY RANGE (scheduled_at);

(job_id, scheduled_at) 是执行键:它标识一次触发,也是之后所有保护措施的锚点。按天分区后,数据保留就变成删除分区;PostgreSQL 允许这个主键,因为它包含分区列。

查找到期任务

每个调度器轮询自己拥有的分区:

SELECT job_id, next_run_at
FROM jobs
WHERE partition_id = ANY($1) AND NOT paused
  AND next_run_at <= now()
ORDER BY next_run_at
LIMIT 500
FOR UPDATE SKIP LOCKED;

在同一个事务里,它为每个任务插入一行 job_runs,并把 next_run_at 向后推进。SKIP LOCKED 会跳过其他事务持有的行而不是等待,PostgreSQL 在文档中说明了它适用于类似队列的表。提交之后,后来的轮询者不会再把这一行当作到期:每次触发只派发一次。

提交后再发布到队列属于双写,所以让新的执行行同时充当 outbox 记录,就像事务性 outbox 模式那样。轮询也有替代方案:

方案 查找到期任务的方式 薄弱点
轮询 jobs 表 在 next_run_at 上建索引并配合 SKIP LOCKED 每次触发都更新带索引的列:无法使用 HOT 更新,vacuum 工作不断
按时间分桶的分区 读取当前分钟对应的桶 向已读过的桶里延迟插入的数据需要一次扫描补漏
延迟队列 在到期时释放执行的 broker 或有序集合 延迟有上限(Amazon SQS:15 分钟);存储仍是事实来源

租约、心跳与幂等执行

从队列开始,投递是 at-least-once:消费者可能在确认前崩溃,中继也可能重复发布。所以执行器先领取这次执行:

UPDATE job_runs
SET status = 'running', attempt = attempt + 1,
    lease_until = now() + interval '30 seconds'
WHERE job_id = $1 AND scheduled_at = $2
  AND (status IN ('queued', 'retrying')
       OR (status = 'running' AND lease_until < now()))
RETURNING attempt;

没有返回行,说明别处有一个有效租约,或者这次执行已经完成:丢弃这条消息。执行器每 10 秒发一次心跳,因此 30 秒的租约可以容忍丢失一次心跳;如果执行器挂了,租约过期,拥有它的调度器会重新发布这次执行。

暂停的执行器可能在租约过期后醒来并继续写入。返回的 attempt 就是一个 fencing token,和 Martin Kleppmann 对分布式锁的分析里讲的一样:结果带 WHERE attempt = $3 写入,过期的尝试就无法覆盖更新的尝试。数据库之外的目标会收到执行键作为幂等键。

这就是诚实的保证:每个执行键只派发一次,至少执行一次,只有幂等的任务效果才只发生一次。Kubernetes CronJob 文档说的也是同样的事,因为 CronJob 可能创建两个 Job,也可能一个都不创建;它的控制器甚至用计划时间给每个 Job 命名,这样重复创建就会失败。

带退避的重试与死信状态

  • 对错误分类。 超时、5xx 和 429 可以重试;校验错误直接进入 dead。
  • 使用全抖动退避: delay = random(0, min(cap, base * 2^attempt))。基数为 10 秒时,四次重试最多分别等待 20、40、80 和 160 秒,第五次失败即为最终失败。如果整个目标都已宕机,重试预算和熔断器可以防止重试不断堆积。
  • 让 dead 状态保持有用: 保留负载、最后一次错误、一条告警,以及复用执行键的重放。
  • 按租户限制并发,就像限流器那样,这样某个租户的突发流量不会饿死其他租户。

如果 09:00 的执行到 10:00 还在重试,由重叠策略决定:跳过、排队、两个都跑,或者取消旧的。Kubernetes 把它叫作 concurrencyPolicy(默认 Allow);Temporal 的调度默认是 Skip。

cron 语义:时区、夏令时与错过触发

每个 cron 表达式都要连同 IANA 时区(如 America/New_York)一起存储,不要用固定偏移,并且先按本地时间计算下一次执行,再转换成 UTC;直接加 24 小时每年会漂移两次。基础设施类任务用 UTC,人们期待按钟表时间执行的场景用本地时间,比如通知系统里的免打扰时段。

夏令时会打乱本地时间:在纽约,2026 年 3 月 8 日 2:00 到 2:59 不存在,2026 年 11 月 1 日 1:00 到 1:59 出现了两次。各调度器对这两种情况的处理并不一致:

调度器 不存在的时间 重复的时间
Linux cron(cronie) 在跳变之后立即运行 运行一次
Amazon EventBridge Scheduler 跳过这次执行 运行一次
带 timeZone 的 Kubernetes CronJob 跳过这次执行 运行两次

Kubernetes 这一行来自它的 cron 库(文档说明春季拨快时钟时的任务不会运行),以及 issue 121594:一个柏林 02:00 的任务运行了两次。选定一条规则并测试它。

错过触发(misfire)是指因调度器宕机或过载而晚启动的执行。调度器领取到迟到的行时,会应用每个任务自己的策略:

策略 停机 3 小时后的小时级任务 适用于 对应项
只运行一次 运行一次,然后恢复正常 同步、清理 Quartz 智能策略,关闭 catchup 的 Airflow(默认)
跳过 等待下一个时间点 提醒 Quartz DO_NOTHING,较短的 Temporal catchup 窗口
补跑 运行全部 3 次,最早的先跑 按小时计费 Quartz IGNORE_MISFIRE_POLICY,Airflow catchup=True

Quartz 默认在 60 秒后把一次执行算作错过触发。Kubernetes 有两个陷阱:startingDeadlineSeconds 小于 10 可能导致跳过执行,因为控制器每 10 秒检查一次;停机恢复后,它只会为最近一次错过的调度启动 Job,更早的都会丢失(错过超过 100 个时,还会记录一条 TooManyMissedTimes 警告)。

扩展调度器

运行多个调度器有三种方式:

  1. 单一 leader。 一个实例持有租约(etcd、ZooKeeper、Kubernetes Lease)并轮询全部任务。方案简单,但上限是一个节点,故障转移要等租约到期:在 CronJob 控制器所在的 kube-controller-manager 里默认是 15 秒。
  2. 竞争式轮询者,在一张表上使用 SKIP LOCKED:不需要协调器,但会有一个热点索引。Quartz 通过数据库锁组成集群,并警告超过大约三个节点后性能会下降。
  3. 分区所有权。 把任务分成 256 个逻辑分区,远多于调度器数量;每个调度器在 etcd 中为自己的分区获取租约,只轮询这些分区。一致性哈希在节点加入或死亡时只迁移一部分,etcd 则通过 Raft 保持租约一致。

所有权是一种优化,而不是保证:如果暂停的旧所有者在交接后又轮询了一次,行锁、已推进的 next_run_at 和执行键会拦住它。分区也会成为数据库分片的单位。

现有工具及各自的长处

工具 擅长 注意
Kubernetes CronJob 在你自己运行的集群上跑批处理容器 每个调度一个对象,计时不精确
Quartz 在 JVM 应用内调度,存储在你的数据库里 集群锁限制横向扩展
Temporal Schedules 带持久重试的多步骤任务 需要自己运行 Temporal 或付费使用其云服务
Apache Airflow 带依赖和回填的数据管道 并非为数百万个小型租户任务而设计
Amazon EventBridge Scheduler AWS 上的托管触发器 撰写时(2026 年 11 月)的默认配额:每个 Region 1,000 万个调度,主要 Region 每秒 1,000 次调用,两者都可调整

只有在需要承载数百万个由租户定义的调度时,才值得自己构建。

分布式任务调度器图

架构图。Job API 写入 Postgres 调度存储。框内的三个调度器 worker 从 etcd 协调器获取分区租约,并把执行送入队列。执行器 worker 调用目标服务,记录执行历史,通过队列重试或送入死信。

18 个组件 · 9 个连接

在 Archboard 中打开
图表内容
  • Job API
  • Schedule store (jobs)
  • Coordinator
  • Scheduler workers · 256 partitions
  • Scheduler 1
  • Scheduler 2
  • Scheduler 3
  • Run queue
  • Executor workers
  • Target services
  • Run history (job_runs)
  • Dead letter
  • POST /jobs
  • due jobs
  • partition leases
  • runs
  • deliver
  • call + Idempotency-Key
  • lease, result
  • after 5 attempts
  • retry: backoff + jitter
分布式任务调度器。持有分区的调度器从 Postgres 领取到期任务并发布执行;执行器为每次执行加租约,带幂等键调用目标,按退避重试,并把用尽重试的执行送入死信队列。

这张图的读法如下:

  • 从左到右,一次执行朝着被执行的方向前进,起点是 Job API 和调度存储。
  • 框代表调度器层: 三个 worker 借助 etcd 的租约分摊 256 个分区,并发布到期的执行。
  • 执行器 worker 为每次执行加租约,租约记录在执行历史里,带幂等键调用目标,并记录结果。
  • 虚线边是失败路径: 带退避的重试,以及第五次失败后的死信队列。
  • Markdown 块列出了错过触发策略和容量估算数字。

把任务调度器图画清楚

  • 把执行键写在画布上。 (job_id, scheduled_at) 比任何箭头都更能说明防重复的机制。
  • 展示所有权: 分区,以及租约从哪里来。只写着调度器的一个方框会把最难的部分藏起来。
  • 用虚线画失败路径,并标注触发条件。
  • 把时间写在画布上: 租约、心跳和重试上限决定恢复时间。

讲解如何把需求变成设计图的文章把同样的习惯用在了一个短链接服务上。

在 Archboard 中构建

  1. 用 Google 或 GitHub 登录并创建一张图表。
  2. 从 C4 模型 包中添加一个 容器 作为 Job API,从 数据 包中添加一个 Postgres 节点作为调度存储。
  3. 从 基础 包中添加一个 框架,里面放三个 容器 节点作为调度器,并在其上方放一个 容器 作为 etcd 协调器。
  4. 从 数据 包中添加一个 队列 用于执行,一个 容器 作为执行器,第二个 Postgres 用于执行历史,一个 队列 用于死信,并从 C4 模型 包中添加一个 外部系统 作为目标服务。
  5. 画出带标签的边,并把重试和死信路径画成虚线。
  6. 从 基础 包中添加一个写有错过触发策略的 Markdown 块,以及领取查询、执行键之类的简短 文本 注释。

用 Mermaid 转可编辑图表工具,可以把管道的 Mermaid 草图变成初稿。

这个示例共用 13 个图表节点,在 Free 套餐的限制之内。

常见问题

如何防止计划任务重复执行? 用任务 ID 加计划时间作为唯一的执行键。在一个事务里领取到期任务并推进它们的下次执行时间,执行前先给每次执行加租约,并把执行键作为幂等键传给目标。

FOR UPDATE SKIP LOCKED 有什么作用? 它会锁定返回的行,并跳过其他事务持有的行,这样多个 worker 可以轮询同一张表而不必互相等待。PostgreSQL 从 9.5 版本开始支持。

任务调度器应该如何处理夏令时? 为每个调度存储一个 IANA 时区,以 UTC 计算下次执行,并决定在不存在和重复的那一小时里怎么处理,因为各工具的行为不同:时钟回拨时,Kubernetes CronJob 甚至可能运行两次。

任务调度中的 misfire 是什么? 指错过了开始时间的执行,通常是因为调度器宕机或过载。错过触发策略决定是现在运行一次、跳到下一个时间点,还是补跑每一次错过的执行。

Kubernetes CronJob 足以充当分布式调度器吗? 对于你已在运行的集群上的几百个批处理任务,足够。对于数百万个租户调度,不够:每个调度都是一个 Kubernetes 对象,每次执行都是带 Pod 的 Job,计时也不精确。

免费开始 — 1 个席位,无需信用卡。

相关文章

← 返回博客

一分钟打开画布

使用 Google 或 GitHub 登录。单席位免费,团队加入后再升级。