图 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,也不抢一个可能只是抖了一下的节点。最后两轮执行全部命中幂等键,说明重复触发被消化在了业务键上。
图 2:四个构件的分工与接力。
选型时的四个问题。
评估任何调度框架(或决定自建),就按四构件问:分片怎么切、扩缩容迁移量多大?租约多长、心跳怎么续、依赖什么时钟?故障转移从哪续跑、有没有检查点?重复执行靠什么消化、幂等键谁生成?四个问题都有清晰答案的系统,才扛得住生产。
这套构件在 AI 平台里一样无处不在:批量推理的样本切分、数据标注任务派发、训练任务的抢占与恢复,全是「分片加租约加幂等」的变体。你设计或维护过任务调度系统吗?租约时长和幂等键是怎么定的,踩过什么坑?欢迎聊聊。