Elastic-Job-Lite作业分片策略源码解析与选型实战
1. 从一个真实困惑说起为什么我的定时任务总在单机上打转刚接触分布式定时任务那会儿我遇到过一个很典型的问题明明部署了三台机器任务配置也写的是分片模式可每次跑起来日志里只有一台机器在干活另外两台像没事人一样干看着。当时第一反应是集群没组起来查了注册中心三个实例都在又怀疑是任务配置写错了翻来覆去看了好几遍shardingTotalCount也设成了 3。折腾了大半天最后才定位到问题出在作业分片策略上——默认的分片策略在某些场景下会把所有分片都塞给同一个实例。这件事让我意识到Elastic-Job-Lite 这套框架里分片策略虽然只是配置项里的一个字符串但它直接决定了任务能不能真正并行起来。很多人用 Elastic-Job-Lite 的时候注意力都放在怎么把任务跑起来上对分片策略往往是一笔带过等到任务量上来了、机器扩了才发现性能根本没提升。这篇内容就围绕 Elastic-Job-Lite 的作业分片策略展开把源码层面的实现逻辑、几种内置策略的差异、以及实际选型时的判断依据讲清楚。适合已经用过 Elastic-Job-Lite、但对分片机制还停留在配置一下就行阶段的开发者也适合正在做分布式任务调度选型、想搞清楚分片底层逻辑的技术负责人。需要先说明的是Elastic-Job-Lite 的分片策略本质上是一个接口契约框架只负责在合适的时机调用它具体怎么分、按什么规则分完全由实现类决定。理解这一点后面看源码就不会迷路。2. 分片策略在整体调度链路中的位置2.1 分片到底解决的是什么问题在单机时代一个定时任务就是一个线程在跑任务处理的数据量大了要么加机器做集群要么把任务拆成多个子任务手动分配。Elastic-Job-Lite 的分片机制本质上是把一个任务在逻辑上切成 N 份每份叫一个分片项sharding item然后把这些分片项分配给集群里的各个作业实例去执行。举个具体的例子假设有一个任务需要处理 100 万条订单数据集群里有 3 台机器shardingTotalCount设为 3。理想情况下每台机器分到 1 个分片项各自处理大约 33 万条数据总耗时降到单机的三分之一左右。但实际能不能达到这个效果取决于分片策略怎么把 3 个分片项分给 3 个实例。这里有个容易被忽略的点分片项的数量和实例数量不一定相等。如果shardingTotalCount设为 10集群只有 3 个实例那么有的实例会分到 4 个分片项有的分到 3 个。分片策略要处理的就是这种分片项如何映射到实例的问题。2.2 分片发生的时机不是启动时一次性算完很多人以为分片是在任务启动时算一次就固定了实际上 Elastic-Job-Lite 的分片是动态的。触发重新分片的时机主要有这么几个集群中有新的作业实例上线有作业实例下线正常关闭或宕机分片总数shardingTotalCount配置发生变化主节点发生选举切换每次触发重新分片框架都会重新走一遍分片流程先由主节点leader收集当前所有存活的作业实例列表然后调用配置的分片策略计算出哪个分片项归哪个实例最后把结果写入注册中心。各个实例再从注册中心读取属于自己的分片项开始执行。这个设计带来的一个直接后果是分片策略的实现必须是幂等且可重复执行的。因为同一个集群状态可能被多次分片策略不能依赖上一次的分片结果每次都要能独立算出一套完整的分片方案。2.3 分片策略接口的核心方法Elastic-Job-Lite 中分片策略的顶层接口是JobShardingStrategy核心方法签名大致是这样的ListShardingItem sharding( ListJobInstance jobInstances, String jobName, int shardingTotalCount );三个入参的含义分别是jobInstances当前存活的作业实例列表已经按某种规则排好序jobName作业名称某些策略可能会用到shardingTotalCount分片总数返回值是一个ShardingItem列表每个元素包含分片项索引和分配到的实例。框架拿到这个列表后会按实例分组把属于同一个实例的分片项打包下发。理解了这个接口再看具体策略的实现就非常清晰了——无非就是怎么把shardingTotalCount个分片项合理地分配给jobInstances里的实例。3. 三种内置分片策略的源码级拆解Elastic-Job-Lite 内置了三种分片策略分别对应不同的分配逻辑。下面逐个拆解它们的实现思路和适用场景。3.1 AverageAllocationJobShardingStrategy平均分配这是默认的分片策略也是用得最多的一种。它的核心思想是尽量平均分余数依次摊。源码逻辑大致是这样的先算出每个实例至少能分到几个分片项用shardingTotalCount / jobInstances.size()取整然后算余数shardingTotalCount % jobInstances.size()接着按实例顺序前余数个实例各多分一个分片项。举个例子shardingTotalCount 10实例数 3基础值 10 / 3 3余数 10 % 3 1结果第 1 个实例分 4 个第 2 个分 3 个第 3 个分 3 个分片项的分配是连续的也就是说第 1 个实例拿到的是分片项 0、1、2、3第 2 个拿到 4、5、6第 3 个拿到 7、8、9。这种连续分配有个好处如果分片项和数据范围有对应关系比如按 ID 取模连续分片能让每个实例处理的数据在逻辑上更集中减少跨实例的数据竞争。但这个策略有个明显的短板它假设所有实例的处理能力是一样的。如果集群里机器配置参差不齐或者某个实例上还跑着其他吃资源的任务平均分配就会导致弱的实例拖后腿。另外当实例数远大于分片数时会有大量实例分不到任何分片项白白占着资源。3.2 OdevitySortByNameJobShardingStrategy按名称奇偶排序这个策略的名字有点长但逻辑不复杂。它先根据作业名称的哈希值判断奇偶然后决定实例列表是按正序还是倒序排列最后再调用平均分配的逻辑。为什么要搞这么一出核心目的是让同一个作业在不同集群环境下分片结果尽量稳定。假设有两个集群跑同一个作业如果实例列表的排序规则不一致可能导致分片结果差异很大。通过作业名称的哈希来决定排序方向可以让分片结果在一定程度上可预测。不过说实话这个策略在实际项目里用得不多。它解决的是一个比较边缘的问题而且按名称哈希决定排序这个规则本身也谈不上多优雅。除非你有明确的跨集群一致性需求否则没必要特意选它。3.3 RotateServerByNameJobShardingStrategy按名称轮转这个策略的思路是根据作业名称的哈希值对实例列表做一次轮转rotate然后再走平均分配。轮转的意思是把实例列表看成一个环从某个位置开始重新排列。比如实例列表是[A, B, C]轮转 1 位后变成[B, C, A]。这样做的效果是不同的作业即使实例列表相同分片结果也会错开。这个策略的价值在于避免热点集中。假设集群里有多个作业如果都用平均分配那么排在前面的实例总是分到更多的分片项因为余数优先给前面的。时间一长前面的实例负载明显偏高。用轮转策略不同作业的优先实例不一样负载就能摊开。三种策略的对比可以整理成下面这张表策略名称核心逻辑适用场景主要缺点AverageAllocation平均分余数依次摊实例配置一致、作业数量少实例能力不均时负载失衡OdevitySortByName按名称奇偶决定排序方向后平均分需要跨集群分片一致性规则较隐晦收益有限RotateServerByName按名称轮转实例列表后平均分多作业共存、避免热点集中单作业场景下与平均分配无异4. 自定义分片策略什么时候需要怎么写4.1 内置策略搞不定的几种情况内置的三种策略覆盖了大部分常规场景但有几类需求它们确实处理不了第一类是实例能力差异化。比如集群里有的机器是 8 核 16G有的是 4 核 8G平均分配显然不合理应该让强机器多分几个分片项。内置策略完全不考虑实例的硬件信息因为它根本拿不到这些数据。第二类是分片项与数据有强绑定关系。比如分片项 0 专门处理北京的数据分片项 1 处理上海的数据这种场景下你希望特定分片项落到特定实例上而不是让框架随便分。第三类是需要考虑实例的实时负载。比如某个实例当前 CPU 已经跑满了就不应该再给它分新的分片项。这需要策略实现能读取外部监控数据。遇到这些情况就得自己实现JobShardingStrategy接口。4.2 一个可落地的自定义策略示例假设我们要实现一个按实例权重分配的策略权重信息存在配置里。核心思路是先算出总权重然后按权重比例分配分片项余数给权重最高的实例。public class WeightedJobShardingStrategy implements JobShardingStrategy { Override public ListShardingItem sharding(ListJobInstance jobInstances, String jobName, int shardingTotalCount) { ListShardingItem result new ArrayList(); // 获取每个实例的权重这里简化为从实例ID哈希映射 MapString, Integer weights new HashMap(); int totalWeight 0; for (JobInstance instance : jobInstances) { int weight getWeight(instance); weights.put(instance.getInstanceId(), weight); totalWeight weight; } int assigned 0; int itemIndex 0; for (int i 0; i jobInstances.size(); i) { JobInstance instance jobInstances.get(i); int weight weights.get(instance.getInstanceId()); // 按权重比例计算该实例应得的分片数 int count; if (i jobInstances.size() - 1) { count shardingTotalCount - assigned; // 最后一个实例兜底 } else { count (int) Math.floor( (double) shardingTotalCount * weight / totalWeight); } for (int j 0; j count; j) { result.add(new ShardingItem(itemIndex, instance.getInstanceId())); } assigned count; } return result; } private int getWeight(JobInstance instance) { // 实际项目中可以从配置中心或实例元数据读取 return 1; } }这段代码有几个关键点值得说明最后一个实例兜底浮点运算会有精度问题按比例算出来的分片数加起来可能不等于总数所以最后一个实例直接拿剩余的全部保证分片项不丢。分片项索引连续分配和内置策略保持一致便于后续按分片项做数据处理。权重来源示例里简化成了固定值实际使用时可以从实例启动时注册的元数据里读或者从配置中心动态拉取。4.3 注册自定义策略的两种方式写好了策略类还得让框架知道用它。Elastic-Job-Lite 提供了两种注册方式方式一SPI 扩展。在META-INF/services目录下创建JobShardingStrategy文件内容写实现类的全限定名。这种方式的好处是配置里直接写策略名就行不需要改代码。方式二API 显式设置。在构建JobCoreConfiguration或LiteJobConfiguration的时候通过jobShardingStrategyClass指定实现类的全限定名。这种方式更直接适合策略类不多、不想折腾 SPI 的场景。LiteJobConfiguration.newBuilder( JobCoreConfiguration.newBuilder(myJob, 0/10 * * * * ?, 10) .build()) .jobShardingStrategyClass(com.example.WeightedJobShardingStrategy) .build();注意自定义策略类必须有一个无参构造函数框架是通过反射实例化的。如果策略初始化需要读取配置建议在sharding方法内部懒加载而不是放在构造函数里。5. 分片策略选型与调优的实战经验5.1 分片总数怎么定不是拍脑袋shardingTotalCount这个值设多少直接决定了分片的粒度。设得太小并行度不够设得太大分片管理本身的开销就上来了。我的经验是分片总数最好是实例数的整数倍且留有一定的扩展余量。比如当前有 3 台机器预计半年内可能扩到 6 台那分片总数可以设成 6 或 12。这样扩容的时候分片能均匀重新分配不会出现加了机器但分片分不匀的尴尬。另外分片总数和任务的执行频率也有关系。高频任务比如每秒执行一次如果分片数太多每次分片切换的开销可能比任务本身还大。低频任务比如每天一次则可以设得细一些充分利用并行度。5.2 分片不均匀的排查思路如果发现任务跑起来负载明显不均可以按这个顺序排查第一步确认分片结果。Elastic-Job-Lite 会把分片结果写到注册中心的节点上直接去看/jobName/sharding路径下的内容能看到每个分片项对应的实例。如果分片结果本身就是均匀的那问题不在策略上而在任务执行逻辑里。第二步检查实例列表的顺序。分片策略拿到的jobInstances是排过序的排序规则会影响分配结果。如果实例 ID 的生成规则导致排序后总是同一批实例排在前面余数就会一直落在它们头上。第三步看是不是任务本身的问题。有时候分片是均匀的但某个分片项对应的数据量特别大导致处理它的实例看起来很忙。这种情况要调整的是数据分片逻辑而不是分片策略。5.3 一个容易踩的坑分片项与线程池的关系Elastic-Job-Lite 默认会给每个分片项分配一个独立的线程去执行。如果分片总数设得很大而实例的线程池配置又比较小就会出现分片项排队等待线程的情况。表面上看分片是均匀的实际上很多分片项在排队并行度根本没上去。这个坑的隐蔽之处在于日志里能看到所有分片项都开始执行了但实际的处理时间被拉得很长。排查的时候要关注线程池的活跃线程数和队列长度而不是只看分片结果。提示shardingTotalCount的值建议不要超过单实例线程池最大线程数的 2 到 3 倍否则排队现象会很明显。6. 分片策略与失效转移、错过执行的配合6.1 失效转移时分片策略还起作用吗作业实例宕机后它负责的分片项需要转移给其他存活实例。这个转移过程不走分片策略而是由框架的失效转移机制直接处理——把宕机实例的分片项标记为待转移然后由主节点分配给当前存活的实例。这意味着如果你依赖自定义分片策略来做负载均衡失效转移场景下这个均衡会被打破。转移过来的分片项是额外加给存活实例的可能导致某些实例负载骤增。要缓解这个问题可以在任务执行逻辑里做动态的限流或降级而不是完全依赖分片策略。6.2 错过执行的分片补偿当实例下线再上线或者分片重新分配后某些分片项可能会错过本该执行的时间点。Elastic-Job-Lite 提供了monitorExecution和failover等配置来控制补偿行为。这里要注意的是分片策略决定了谁来做但不决定做不做。补偿逻辑是独立于分片策略的。如果发现任务有漏执行先检查补偿相关的配置而不是去改分片策略。7. 写在最后的一点个人体会分片策略这个东西刚上手的时候觉得就是个配置项用久了才发现它是分布式任务调度里最需要因地制宜的部分。内置的三种策略能覆盖七八成的场景但剩下那两三成往往才是真正考验设计能力的地方。我自己在项目里用得最多的还是默认的平均分配配合合理的shardingTotalCount设置大部分场景都能跑得不错。真正需要自定义策略的时候通常是遇到了实例能力差异或者数据亲和性的硬需求。这时候与其硬套内置策略不如老老实实写一个代码量其实不大但效果立竿见影。还有一点想提醒分片策略的调整一定要配合监控。光看分片结果均不均匀是不够的还要看每个实例的实际处理耗时、线程池状态、以及任务的整体完成时间。分片是手段不是目的最终要的是任务跑得快、跑得稳。