跳到正文

调度系统的五个故障域 ​

一句话:分布式调度不是「多一个 Cron 触发器」,而是要在重复执行、漏执行、数据倾斜、故障接管、错过补偿五个地方同时做取舍;框架只能提供机制,正确性仍要业务兜底。

适用版本:ElasticJob 3.0.5、XXL-JOB 2.4.x、Kubernetes CronJob(batch/v1,1.21+ 稳定)。参数默认值以对应版本文档为准。

为什么需要 ​

单机 cron 只有一个失败面:进程挂了。搬到多机后,失败面变成五个:两个实例同时处理同一批数据;该跑的时间点没有任何实例在跑;分片数、实例数、数据分布不匹配,忙闲不均;实例挂掉后分片没人接,或接了却从头重跑;上一次没跑完,下一次触发时间已经到了。这五个问题互相牵制:为了不漏执行而补跑,可能造成重复;为了不重复而串行,可能加剧漏执行。

核心机制 ​

故障域典型触发ElasticJob 机制XXL-JOB 机制业务必须做
重复执行重分片、故障转移、人工重跑分片独占 + monitorExecution阻塞策略 + 分片广播幂等键/状态机
漏执行无在线实例、上次超时、时钟漂移misfire、maxTimeDiffSecondsmisfireStrategy、Admin 调度补数任务
倾斜分片数少、数据分布不均分片策略 + 分片参数shardTotal + 取数算法按数据量切分
故障接管实例宕机、网络分区ZK 会话过期 + 重新分片 + failover30s 心跳、90s 剔除、FAILOVER 路由幂等 + 可重入
错过补偿执行超过调度间隔misfire(true)FIRE_ONCE_NOW按业务日期回补

一、重复执行 ​

重复执行来自重新分片、故障转移、人工点击「立即执行」。ElasticJob 的分片独占只保证「同一时刻一个分片项只有一个宿主」,不保证「同一批数据只被处理一次」。monitorExecution(true) 会写 running 节点,正常运行时能挡住同一分片项的并发执行;但崩溃遗留标记、故障转移和人工重跑仍会制造重复,它也不记录 offset。failover(false) 是默认值,打开后失败分片会被重新分配,重复概率更高。

XXL-JOB 用 executorBlockStrategy 控制同一执行器上的并发:SERIAL_EXECUTION 排队、DISCARD_LATER 丢弃后续调度、COVER_EARLY 终止前一次。它同样不解决跨实例重复;FAILOVER 路由在挑选阶段先 beat 探测,跳过探测失败的执行器,但执行阶段失败后是否重试由 failRetryCount 决定,仍可能被执行两次。

真正有效的是业务侧幂等:

sql
-- 状态机抢占:只有把 PENDING 改成 RUNNING 成功的实例才处理
UPDATE t_order SET status = 'RUNNING', owner = #{instance}
WHERE id = #{id} AND status = 'PENDING';

-- 或唯一键去重:同一业务日期 + 分片 + 记录只允许处理一次
INSERT INTO t_job_done(biz_date, shard_item, order_id)
VALUES (#{bizDate}, #{item}, #{orderId});

二、漏执行 ​

触发时没有在线实例、上一次执行还没结束、调度线程被阻塞、机器时钟不一致,都会漏。ElasticJob 在每个实例本地按 cron 触发,没有在线实例时这次触发直接消失。misfire(true) 是默认值:如果本次因上一次未结束而错过,会在条件允许时补一次「最近一次」,不会把错过的每个时间窗都重放。maxTimeDiffSeconds 默认 -1,不检查时钟偏差。

XXL-JOB 由 Admin 统一触发,misfireStrategy 二选一:DO_NOTHING 错过就跳过,FIRE_ONCE_NOW 立即补触发一次。注意 90s 是执行器心跳剔除阈值(30s×3),不是调度补偿窗口。

Kubernetes CronJob 也不是「没有错过补偿」:startingDeadlineSeconds 允许在计划时间之后的一定窗口内补启动,concurrencyPolicy: Forbid 防止同一 CronJob 并发,backoffLimit 控制失败重试。超过 deadline 的调度才会被跳过;它缺的是原生分片、跨 Pod 协调和业务级执行记录。

三、倾斜 ​

倾斜分两层:分片项在实例间分得不均,以及分片项之间的数据量不均。框架只能解决第一层。

ElasticJob 默认用 AverageAllocationJobShardingStrategy 平均分配。内置策略只有平均、奇偶(OdevitySortByNameJobShardingStrategy)、轮询(RotateServerByNameJobShardingStrategy)三种;按标签分配需要自己实现 JobShardingStrategy。shardingItemParameters 只是给分片项附一个业务字符串,不参与分配。

XXL-JOB 分片广播的 shardTotal 等于当前在线执行器数,shardIndex 是本次触发中该执行器的分片序号(0 到 shardTotal-1)。实例数变化后,下一次触发自动使用新的 shardTotal,不需要手工改分片配置。取数仍由业务决定,例如 MOD(order_id, total) = index;如果 order_id 集中在少数区间,取模也会倾斜,应改成按主键范围切分或先哈希再取模。

分片总数经验上取峰值实例数的 2–4 倍,并保证单个分片的耗时可接受。这是经验值,不是框架公式;分片太多会增加 ZK 节点和重新分片的开销,必须按实际数据量压测。

四、故障接管 ​

ElasticJob 依赖 ZK 会话感知实例存活。会话过期后临时节点删除,Leader 重新分片,存活实例接管空缺项。客户端默认请求 60s 会话超时,服务端按 2×tickTime ~ 20×tickTime 协商;默认 tickTime=2000ms 时上限是 40s。所以接管是几十秒级。failover(true) 默认关闭,打开后失败分片会换实例重试,但不会从断点继续。

XXL-JOB 的心跳是 30s 一次,Admin 每 30s 扫描注册表,超过 90s 未更新才剔除。FAILOVER 路由在挑选阶段依次 beat 探测执行器,跳过探测失败的节点;执行阶段失败后是否重试由 failRetryCount 决定。分片广播本身没有故障转移,失败分片由业务或下一次触发重新处理。它们都不保存业务进度,接管后能否不重复,取决于业务幂等。

五、Misfire 与补数 ​

Misfire 是「调度时间到了,但上一次还没结束或实例不可用」时的补偿语义,只补一次,不保证每个时间窗都被执行。ElasticJob 的 misfire(true) 默认打开,misfire(false) 表示错过就等下一个周期;XXL-JOB 用 misfireStrategy 二选一。两者都不适合「必须逐日处理」的场景。

逐日、逐小时不能丢的任务,正确做法是把调度和补数分开:调度只负责触发「处理某个业务日期」;业务日期写入任务参数或进度表;启动时先扫描未完成的日期,逐个补跑;每个日期用唯一键保证只成功一次。这样即使调度漏了三天,重启后也能按日期表补齐,而不是依赖框架的 misfire 窗口。

最小上手 ​

用 01 的 OrderJob 和 ScheduleJobBootstrap 打成 fat jar,启动两个实例即可验证「重复执行」和「接管」:

bash
# 终端 1、2 分别启动同一个 fat jar(同一 namespace + jobName)
java -jar order-job.jar

停掉一个实例,等会话过期(默认请求 60s,实际受 2×tickTime ~ 20×tickTime 约束),再观察分片归属:

bash
zkCli.sh -server 127.0.0.1:2181
ls /elasticjob/orderJob/sharding
get /elasticjob/orderJob/sharding/0/instance

把处理逻辑写成「先 UPDATE ... WHERE status='PENDING' 再处理」,在接管前杀进程,就能看到幂等键是否真的挡住了重复。

XXL-JOB 对应配置:executorBlockStrategy: SERIAL_EXECUTION、misfireStrategy: FIRE_ONCE_NOW。停掉一个执行器后,注册表约 90s 才把它剔除;在此之前,FAILOVER 的 beat 探测也可能超时,业务要按失败处理,不能假设请求一定落到存活节点。

生产动作 ​

先定幂等键,再选框架。 没有幂等键,任何故障转移和补跑都会制造重复;有幂等键,ElasticJob 和 XXL-JOB 的差异就只剩调度模型和运维成本。

分开配置「调度失败」和「业务失败」。 调度失败看注册表、会话、Admin 日志;业务失败看任务日志和业务状态表。把两者混在一个重试策略里,通常会导致重复执行。

给补数留入口。 任务参数里带 bizDate,并提供按日期重跑的接口或控制台按钮。不要依赖 misfire 追三天前的数据。

监控三个量: 本次触发是否有实例、分片是否全部有宿主、每个分片的处理条数是否接近。第三个量能同时发现倾斜和漏数。

边界 ​

  • 框架不提供 exactly-once。 分片独占、阻塞策略、misfire 都只是减少重复/遗漏,不是事务保证。
  • misfire 不是补数系统。 它最多补最近一次,不能替代按业务日期回补。
  • K8s CronJob 不是分布式调度框架。 它支持迟到启动和并发控制,但没有原生分片、跨 Pod 协调和业务级执行记录。
  • 分片数不是越多越好。 分片越多,单分片越短,但 ZK 节点、重新分片和调度开销也越高。
  • 不要用实例数做取模基数。 实例数会变,分片总数才是稳定的路由基数;用实例数取模会在扩缩容时改变数据归属。

讲清楚 ​

分布式调度要同时处理五个故障域。重复执行靠幂等键和状态机,不靠框架的分片独占;漏执行要靠业务日期补数,不能只靠 misfire;倾斜要先让分片项之间的数据量均匀,再谈实例间均匀;故障接管只是把分片换一台机器跑,不会从断点继续;misfire 只是补最近一次,不是补数系统。

ElasticJob 和 XXL-JOB 的差别主要在调度模型:前者是各实例本地 cron + ZK 选主分片,后者是 Admin 统一触发 + 分片广播。两者都把「取哪些数据」和「处理是否重复」留给业务。选型时先看团队能不能维护 ZK、需不需要统一控制台,再看这些故障域的配置是否够用。

把一门技术讲透到既懂也会用