设计一个分布式任务调度系统

本文系统设计一个高可用、可扩展的分布式任务调度平台:任务模型与定时/延迟/循环触发、调度器与执行器分离、任务分片与路由、容错(失败重试/幂等/死信)、任务编排(DAG 依赖)、控制台与可观测性,并给出架构图、数据表、调度伪代码与量级估算。

几乎所有在线系统都有「到点要干的事」:每天凌晨跑一次报表、订单支付成功后 30 分钟发送提醒、业务高峰后的数据对账。单机 Cron 能解决小规模场景,但一旦任务量上到千万级、执行节点上百个,就需要一个高可用、可水平扩展、支持失败重试与依赖编排的分布式任务调度系统。本文按照系统设计面试的标准答题结构,设计一个生产级的分布式任务调度平台。

一句话:分布式任务调度的核心是「调度与执行解耦」——调度器只负责「何时、触发哪个任务」,执行器负责「把任务真正跑完」,两者之间用消息与状态机保证「不丢、不重、不错」。

一、需求澄清与量级估算

1.1 需求澄清

面试官给出题目「设计一个分布式任务调度系统」后,先通过提问明确边界:

  • 任务类型:定时任务(cron)、延迟任务(延时触发一次)、周期循环任务、DAG 编排任务,覆盖哪些?
  • 执行方式:任务在独立执行器上跑(脚本/Java/容器),还是进程内执行?
  • 触发时机:固定时间、cron 表达式、事件后延迟(下单 30 分钟后提醒)、依赖前置任务完成后?
  • 可靠性:执行失败如何处理?重复执行是否允许(幂等)?错过窗口(宕机恢复)如何补偿?
  • 管理能力:控制台创建/启停/重跑任务、查看执行日志、限流与超时?
  • 规模:多少任务、多少执行节点、每天多少触发次数?

明确假设(面向面试的合理假设):

需求项假设
任务类型定时(cron)、延迟、循环、DAG 依赖四类
执行方式独立执行器节点,支持水平扩容
触发cron 表达式 + 事件延迟 + DAG 依赖驱动
可靠性至少一次执行 + 业务幂等;失败自动重试
管理控制台全生命周期管理 + 可观测
规模10 万任务、200 执行节点、日触发 5000 万次

1.2 量级估算

指标估算值推导
任务数10 万个活跃任务—
日触发5000 万次高频任务 + 延迟任务
触发峰值2000/秒准点批量触发(如整点跑批)
执行节点200 个按 CPU/内存分规格
执行耗时秒级~分钟级任务多样性
调度到执行延迟< 1 秒定时任务可接受范围
延迟任务量日均 3000 万订单/消息类场景

一句话:10 万任务、日触发 5000 万次,调度器本身不能成为瓶颈——「准点触发」走定时扫描/时间轮,「延迟触发」走 Redis 延迟队列,两类触发源分而治之。

二、高层架构设计

   ┌───────────────────────────┐   ┌──────────────────────────────┐
   │        控制台(Web)        │   │ 业务系统(调用方)             │
   │  任务 CRUD/启停/重跑/日志   │   │ 提交延迟任务/手动触发/查询状态  │
   └─────────────┬─────────────┘   └─────────────┬────────────────┘
                 │ 任务定义/命令                    │ 触发请求
   ┌─────────────▼───────────────────────────────▼────────────────┐
   │                     调度中心(Scheduler)                       │
   │  ┌─────────────┐  ┌──────────────┐  ┌───────────────────────┐ │
   │  │ 任务注册表   │  │ 触发器       │  │ 调度队列/时间轮          │ │
   │  │ (任务定义/   │  │ cron解析      │  │ (准点触发 + 延迟队列)   │ │
   │  │ 版本/状态)   │  │ 时间计算      │  │                       │ │
   │  └─────────────┘  └──────────────┘  └───────────┬───────────┘ │
   │  ┌─────────────┐  ┌──────────────┐  ┌───────────▼───────────┐ │
   │  │ 路由与分片   │  │ 重试与补偿   │  │ DAG 依赖引擎           │ │
   │  │ (节点选择)   │  │ (死信/窗口)  │  │ (前置完成→触发后继)    │ │
   │  └─────────────┘  └──────────────┘  └───────────────────────┘ │
   └─────────────┬──────────────────────────────────▲─────────────┘
                 │ 派发执行请求(带任务ID/参数/分片)    │ 心跳/上报/结果
   ┌─────────────▼──────────────────────────────────┴─────────────┐
   │                    执行器集群(Executor 200 节点)              │
   │  ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────────┐ │
   │  │ 任务执行器 │ │ 执行上下文 │ │ 结果上报   │ │ 分片执行器      │ │
   │  │ (脚本/JVM) │ │ (超时/限流)│ │ (成功/失败)│ │ (按分片并行)   │ │
   │  └───────────┘ └───────────┘ └───────────┘ └───────────────┘ │
   └─────────────┬──────────────────────────────────▲─────────────┘
                 │                               ┌──┴────────────┐
   ┌─────────────▼──────────┐                    │  存储层         │
   │ 元数据 MySQL            │                    │  Redis:       │
   │ 任务定义/实例/日志/分片   │                    │  延迟队列/锁/   │
   └─────────────────────────┘                    │  心跳/幂等      │
                                                  └───────────────┘

整体拆为四层:

  1. 控制台:任务生命周期管理、手动触发、日志查看。
  2. 调度中心:任务注册、触发器、调度队列/时间轮、路由分片、重试补偿、DAG 依赖引擎。
  3. 执行器集群:实际执行任务,上报结果、心跳保活。
  4. 存储:MySQL(任务元数据与实例) + Redis(延迟队列、分布式锁、心跳、幂等)。

2.1 调度与执行解耦

核心设计原则:调度中心只负责「决策派发」,执行器只负责「执行回报」。

调度中心 → 执行请求消息 → 执行器 → 执行 → 结果上报 → 调度中心更新实例状态
两者之间是异步消息,中间用 MySQL 实例表 + Redis 幂等保证一致性
调度中心与执行器都可独立水平扩展

一句话:把「什么时候跑」(调度)与「跑什么」(执行)拆开,调度中心即使宕机,执行中的任务不受影响;执行器扩容只是加节点,调度压力完全隔离。

三、核心组件设计

3.1 任务模型

任务是核心实体,支持四种触发类型:

CREATE TABLE task_def (
  task_id       BIGINT PRIMARY KEY,
  name          VARCHAR(128),
  trigger_type  TINYINT,       -- 1cron 2delay 3loop 4dag
  cron_expr     VARCHAR(64),   -- cron 触发表达式(trigger_type=1)
  delay_sec     INT,           -- 延迟秒数(trigger_type=2)
  retry_policy  JSON,          -- {"max_retry":3,"backoff_ms":1000}
  timeout_ms    INT,           -- 执行超时
  handler       VARCHAR(256),  -- 执行器内处理器标识
  shard_count   INT,           -- 分片数(0 表示不分片)
  status        TINYINT,       -- 0停用 1启用
  version       INT            -- 乐观锁:任务更新用版本号
);

CREATE TABLE task_instance (
  instance_id   BIGINT PRIMARY KEY,   -- 一次触发产生一个实例
  task_id       BIGINT,
  trigger_time  DATETIME,
  schedule_time DATETIME,
  status        TINYINT,              -- 0待执行 1执行中 2成功 3失败 4超时 5重试中 6死信
  exec_node     VARCHAR(64),
  retry_count   INT,
  result_msg    VARCHAR(512)
);

3.2 触发器:准点触发与延迟触发分而治之

准点 cron 任务:不适合每秒扫全表(10 万任务全量扫描代价高),用时间轮 + 秒级索引:

方案A(粗粒度轮询,任务量大时必选):
  按「下一触发秒」建二级索引(MySQL 或内存时间轮)
  调度器每秒取出「这一秒该触发」的任务 → 派发
  cron 解析后把每次触发时间点插入时间轮,而非轮询全表

方案B(延迟任务):
  用 Redis ZSET(延迟队列):score = 触发时间戳
  每毫秒取 score <= now 的队头 → 派发 → 幂等去重
  优点:毫秒级精度、天然支持「事件后延迟」
;; 伪代码:Redis 延迟队列轮询
(defn poll-delay-queue []
  (loop []
    (let [now (current-ms)
          job (redis/zpopmin "delay:queue" now)]   ; 取到期最早的
      (when job
        (dispatch! job)                            ; 派发执行
        (recur)))))

;; 时间轮:内存环状结构,秒级槽位挂任务链表
(def time-wheel (make-wheel 3600))                 ; 1 小时环
(defn schedule-cron [task]
  (doseq [t (next-trigger-times (:cron_expr task))]
    (add-wheel time-wheel t task)))

要点:准点任务与延迟任务是两种触发源——准点靠「时间轮 + 秒级索引」避免全表扫描,延迟靠「Redis ZSET」拿毫秒精度与天然顺序;两者合并到统一派发出口即可。

3.3 路由与分片

任务派发要选一个执行节点,分片任务要按分片并行执行:

路由策略:
  ① 一致性哈希(按 task_id):同任务稳定落在同节点(利于本地缓存)
  ② 最少负载(按当前执行数):动态均衡
  ③ 指定节点:特殊任务固定节点

分片执行:
  大数据量任务(如全量用户跑批)拆 N 片,每片一个子任务并行
  分片键:按主键取模 / 按日期 / 按业务域
  分片状态:每片独立实例,聚合完成才置任务成功
;; 伪代码:分片派发
(defn dispatch-sharded [task]
  (let [shards (range (:shard_count task))]
    (doseq [s shards]
      (dispatch! {:task_id  (:task_id task)
                  :shard_no s
                  :shard_of (:shard_count task)
                  :handler  (:handler task)
                  :node     (route-node task s)}))))

3.4 容错:失败重试、幂等、死信与补偿

任务执行可能失败(网络、下游抖动、业务异常),容错设计:

① 失败重试:指数退避重试(max_retry 次),重试走 Redis 延迟队列实现「退避」
② 幂等执行:执行器在任务内按「业务幂等键」去重
   (同一实例被重复派发时,结果一致、不重复副作用)
③ 死信队列:超过重试上限 → 进入死信,人工介入/告警
④ 错过窗口补偿:调度中心宕机恢复后,扫描「错过触发时间且需补偿」的任务
;; 伪代码:重试退避入队
(defn on-failure [instance]
  (if (< (:retry_count instance) (:max_retry task))
    (let [backoff (exponential-backoff (:retry_count instance))]
      (redis/zadd "delay:queue" (+ now backoff) retry-msg)
      (update-instance instance :status 5 :retry_count inc))
    (mark-dead-letter instance)))

3.5 DAG 依赖编排

任务之间存在依赖(如「数据同步完成 → 清洗 → 建模」)。DAG 依赖引擎在调度中心内:

DAG 定义:任务间依赖图(DAG),无环校验
触发机制:下游任务「被前置完成事件」触发,而非固定 cron
执行语义:
  前置全部成功 → 触发下游
  任一前置失败 → 下游跳过/告警(按配置)
  支持重跑某个上游 → 级联重跑受影响下游
CREATE TABLE task_dag_edge (
  upstream_id   BIGINT,
  downstream_id BIGINT,
  PRIMARY KEY (upstream_id, downstream_id)
);
DAG 引擎实现:
  每个任务实例完成 → 查下游 → 满足前置集合则触发
  用一个「实例依赖表」记录前置完成数量,完成计数归零即派发

四、深入权衡

4.1 定时扫描 vs 时间轮

方案精度复杂度适用
每 5 秒扫全表秒级粗精度低,实现简单任务量少
MySQL 索引扫描秒级中,需维护索引万级任务
时间轮(内存)秒级、内存快高,需多副本十万级任务
Redis ZSET 延迟队列毫秒级中延迟任务为主

结论:任务量大且准点要求高时,调度器内存时间轮 + Redis 延迟队列是最优组合;全表扫描只适合任务量小、精度要求低的场景。

4.2 调度中心高可用

调度中心是「决策大脑」,不能单点。高可用方案:

① 主备模式:主调度器 + 从调度器(ZooKeeper 选主),主宕机秒级切换
② 多副本 + 分布式锁:多个调度器共同工作,用 Redis/DB 锁保证同一任务同一时刻只被一个调度器派发
③ 无状态化:调度器无状态(状态全在 MySQL/Redis),任意副本可接管

一句话:调度中心可以「多副本 + 锁去重」,因为真正的一致性落在 MySQL 实例表和 Redis 幂等上——调度器本身是「可替换的大脑」,丢了换一个即可。

4.3 至少一次 vs 精确一次

分布式环境下「精确一次调度 + 精确一次执行」成本极高,工程上通常接受至少一次 + 幂等:

调度:至少一次(失败重试、错过补偿)→ 可能重复派发
执行:幂等(业务幂等键去重)→ 重复执行副作用为 0

结论:把「精确一次」从分布式层下放到「业务幂等层」解决,是任务调度系统最务实的权衡——调度层保证不漏,业务层保证不重。

五、总结

分布式任务调度系统的骨架是调度与执行解耦:调度中心管「何时触发、触发谁」(时间轮 + Redis 延迟队列双触发源 + DAG 依赖引擎 + 路由分片),执行器集群管「真正跑完」(心跳保活、结果上报、幂等执行)。可靠性上接受「至少一次 + 业务幂等」,用指数退避重试、死信告警、错过窗口补偿兜住所有失败路径;调度中心多副本 + 锁去重实现高可用,元数据全部落 MySQL/Redis 保证状态一致。最终,调度系统对业务透明地提供「到点必触发、失败必重试、重复必幂等、依赖必有序」的可靠保证。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「design」更多文章

  1. 设计一个弹幕系统
  2. 设计一个权限系统(RBAC + ABAC)
  3. 设计一个内容审核系统