深蓝黑背景封面:右侧大标题「任务不重不漏,靠什么保证」,副标题「分片、租约心跳、故障转移、幂等执行」;左侧原创示意图——上方一台调度器向下分发四个任务分片给三个 worker,其中一个 worker 打了叉、它名下的分片被一条虚线箭头重新指给存活 worker,分片卡片上带租约倒计时小标签;左上标注「知识专栏 · 任务调度」,右上标注「Day 04 · 11-01」 图 1:本文封面,一个 worker 倒下,它的分片被重新接管。

「集群会动态扩缩容,一批任务怎么分下去、机器挂了怎么办」是一道经典的集群任务分片面试题。真题解析回答的是那一个场景的问与答;这篇换个角度,把这类系统的通用构件拆出来——任何分布式任务调度系统,不管叫调度中心、批处理框架还是自建定时任务平台,都在回答同样四个问题:任务怎么切、归属怎么定、挂了怎么接、重复怎么消化。看懂这四个构件,就拿到了设计或选型这类系统的坐标系。

构件一:分片策略——不重的前提。

第一步是把任务集合切成 N 个分片,并让每个分片在任一时刻有唯一归属。静态做法是按分片号对 worker 数取模;扩缩容敏感的系统用一致性哈希,节点增减时只有少量分片需要迁移。以开源的 XXL-JOB 为例,其官方文档中的「分片广播」就是这套思路的产品化:一次调度广播触发集群全部执行器,各执行器按分片参数取走自己那份,扩缩容后下一次调度重新分配。分片粒度本身是权衡:切得太粗,单机倾斜;切得太细,归属管理与元数据开销上升。

构件二:租约与心跳——归属的生命周期。

「分片归谁」不能是一次性结论,而是一份带过期时间的租约:worker 领到分片的同时拿到租约,周期心跳续约;心跳断了,租约到期,归属自动失效。租约机制把「节点还活着吗」这个分布式系统最难答的问题,转化成一个只看本地时钟的判定——调度器不需要知道对方为什么没续约,只需要知道租约过期了。租约时长是核心参数:太短,一次网络抖动就引发大规模分片迁移;太长,故障发现变慢、任务停滞时间变长。常见取值在几秒到几十秒,配合「过期前完成续约」的余量设计。

构件三:故障转移——不漏的兜底。

租约到期后,调度器把孤儿分片重新挂给存活节点,新节点从检查点续跑。这里有个必须正视的窗口期:旧节点可能只是网络分区而非真死,它仍在执行分片。于是在租约过期到新节点完成接管的这段时间里,同一个分片可能被执行两次。想完全消除这个窗口就得引入分布式锁或代际 fencing,成本陡增;绝大多数调度系统选择容忍窗口期,把正确性交给下一个构件。

构件四:幂等执行——消化重复的兜底。

既然「至少执行一次」做不到绝对「只执行一次」,就让重复无副作用:任务按业务键去重(已完成就跳过),或结果写成可重放覆盖(同键覆盖写、upsert)。幂等键必须来自业务语义(分片号加批次号),而不是随机生成的执行 ID——后者拦不住「换人重跑」。这条原则不只属于调度系统,消息消费、回调重试都在用它。

四个构件联动一次给你看。

下面这段模拟代码把四构件串起来:8 个分片分给 3 个 worker,心跳续租约,中途 B 失联,租约到期后分片转移,执行阶段靠幂等键拦重复。用 Node.js 22.15.0 直接运行(node scheduler.js):

'use strict';

// 任务调度系统四构件的最小模拟:分片、租约、故障转移、幂等执行
const SHARDS = 8, LEASE_TTL = 3;

function createCluster(workerIds) {
  return {
    tick: 0,
    // 分片归属表:分片 -> { owner, leaseUntil }
    assignment: new Map([...Array(SHARDS).keys()].map((s) => [s, { owner: workerIds[s % workerIds.length], leaseUntil: LEASE_TTL }])),
    done: new Set(), // 幂等键:已完成的分片业务键
    log: [],
  };
}

function heartbeat(c, alive) {
  for (const [shard, a] of c.assignment) if (alive.includes(a.owner)) a.leaseUntil = c.tick + LEASE_TTL;
  c.log.push(`t=${c.tick} 心跳续约:存活节点 ${alive.join(',')} 的租约顺延`);
}

function failover(c, alive) {
  for (const [shard, a] of c.assignment) {
    if (!alive.includes(a.owner) && a.leaseUntil <= c.tick) {
      const next = alive[shard % alive.length];
      c.log.push(`t=${c.tick} 分片 ${shard} 租约到期,从失联节点 ${a.owner} 转移给 ${next}`);
      c.assignment.set(shard, { owner: next, leaseUntil: c.tick + LEASE_TTL });
    }
  }
}

function execute(c, alive) {
  for (const [shard, a] of c.assignment) {
    if (!alive.includes(a.owner)) continue;
    const key = `shard-${shard}`;
    if (c.done.has(key)) { c.log.push(`t=${c.tick} 分片 ${shard} 命中幂等键,跳过重复执行`); continue; }
    c.done.add(key);
    c.log.push(`t=${c.tick} 节点 ${a.owner} 完成分片 ${shard}`);
  }
}

const workers = ['A', 'B', 'C'];
let alive = [...workers];
const c = createCluster(workers);
for (c.tick = 1; c.tick <= 9; c.tick++) {
  if (c.tick === 4) { alive = alive.filter((w) => w !== 'B'); c.log.push(`t=4 节点 B 失联`); }
  heartbeat(c, alive);
  failover(c, alive);
  if (c.tick >= 7) execute(c, alive); // 假设 7 秒后才轮到执行窗口
}
for (const line of c.log) console.log(line);
console.log(`最终完成分片数 ${c.done.size}/${SHARDS},无重复无副作用`);

实测输出(节选关键行):

t=4 节点 B 失联
t=4 心跳续约:存活节点 A,C 的租约顺延
t=6 分片 1 租约到期,从失联节点 B 转移给 C
t=6 分片 4 租约到期,从失联节点 B 转移给 A
t=6 分片 7 租约到期,从失联节点 B 转移给 C
t=7 节点 A 完成分片 0
t=7 节点 C 完成分片 1
……
t=8 分片 0 命中幂等键,跳过重复执行
……
最终完成分片数 8/8,无重复无副作用

时间线值得读一遍:B 在 t=4 失联,但它的分片没有在 t=4 立刻被抢走——租约是 t=3 续的,到 t=6 才到期,转移精确地发生在到期时刻。这就是租约的语义:宁可多等一个 TTL,也不抢一个可能只是抖了一下的节点。最后两轮执行全部命中幂等键,说明重复触发被消化在了业务键上。

四构件图:左上「分片策略」——任务集合被切成 N 个分片,按一致性哈希或取模映射到 worker,扩缩容时只迁移少量分片;右上「租约心跳」——worker 领到分片同时拿到一个带过期时间的租约,时间轴上周期心跳续约,心跳断则租约到期;左下「故障转移」——租约到期后调度器把孤儿分片重新挂给存活节点,新节点从检查点续跑;右下「幂等执行」——同一个分片可能被两个节点先后执行,任务按业务键去重、结果可重放覆盖。底部总结:不重靠分片归属唯一,不漏靠租约兜底,重复副作用靠幂等消化 图 2:四个构件的分工与接力。

选型时的四个问题。

评估任何调度框架(或决定自建),就按四构件问:分片怎么切、扩缩容迁移量多大?租约多长、心跳怎么续、依赖什么时钟?故障转移从哪续跑、有没有检查点?重复执行靠什么消化、幂等键谁生成?四个问题都有清晰答案的系统,才扛得住生产。

这套构件在 AI 平台里一样无处不在:批量推理的样本切分、数据标注任务派发、训练任务的抢占与恢复,全是「分片加租约加幂等」的变体。你设计或维护过任务调度系统吗?租约时长和幂等键是怎么定的,踩过什么坑?欢迎聊聊。