1. 为什么作业分片策略值得单独拎出来讲
做分布式定时任务的人,绕不开 Elastic-Job-Lite 这个框架。它最核心的价值就两个词:弹性调度和分片执行。弹性调度解决的是任务该在哪个节点跑的问题,而分片执行解决的是——一个任务怎么拆成多份、并行跑在多个节点上、还能在节点上下线时自动重新平衡。
很多人用 Elastic-Job-Lite 用了两三年,配置写得滚瓜烂熟,但一旦遇到“为什么我的任务只在一个节点上跑”“为什么分片数改了之后数据对不上”“为什么某个分片一直空转”这类问题,就抓瞎了。根因几乎都指向同一个地方:作业分片策略没吃透。
这篇文章不打算把源码从头到尾贴一遍,而是从实际使用和排查问题的角度,把分片策略的设计思路、核心实现、参数计算、常见坑点全部拆开讲清楚。适合已经用过 Elastic-Job-Lite、但想真正搞明白分片机制的人,也适合正在做分布式任务调度选型、想评估分片能力边界的人。读完你至少能搞清楚三件事:分片策略到底在哪个环节起作用、不同策略的适用场景和代价是什么、遇到分片相关的问题该从哪里下手排查。
2. 分片策略的整体设计与核心思路拆解
2.1 分片到底解决什么问题
先把这个概念说透。假设你有一个任务,需要处理 100 万条数据。单机跑,可能要 10 分钟。如果你有 5 台机器,最朴素的想法是每台机器处理 20 万条。但问题来了:怎么分配?谁来分配?某台机器挂了怎么办?新加一台机器怎么重新分配?
Elastic-Job-Lite 的答案是:把任务拆成若干个分片项(sharding item),每个分片项就是一个最小的并行执行单元。分片项的总数由你配置,然后通过分片策略决定每个分片项落到哪个作业节点上执行。
这里有一个非常关键的设计理念:分片项是逻辑概念,作业节点是物理概念。分片项的数量在任务启动时就固定了(除非你改配置重启),但作业节点是动态的,可以随时上下线。分片策略要做的,就是在节点发生变化时,重新建立“分片项 → 节点”的映射关系。
注意:分片项的数量不等于节点数量。你可以 3 个节点跑 9 个分片,也可以 5 个节点跑 3 个分片。前者是一对多,后者是多对一。这个比例关系直接影响负载均衡效果。
2.2 分片策略在框架中的位置
Elastic-Job-Lite 的作业执行流程大致是这样的:作业调度器触发任务 → 作业服务器判断自己是否持有分片 → 持有分片则执行对应的业务逻辑。分片策略就卡在“判断自己是否持有分片”这一步之前。
具体来说,当作业节点发生变化(上线、下线、分片数调整)时,会触发一次分片重新分配。这个过程由主节点(leader)发起,调用配置的分片策略实现类,计算出每个分片项应该分配给哪个作业节点。计算结果会写入注册中心(通常是 ZooKeeper),每个作业节点从注册中心读取自己负责的分片项,然后只对这些分片项执行任务。
所以分片策略的本质是一个分配算法:输入是“分片项列表 + 可用作业节点列表”,输出是“分片项到节点的映射表”。
2.3 为什么框架要提供多种策略
因为不同业务场景对“怎么分”这件事的诉求完全不同。
有的业务希望分片尽量均匀,每个节点干的活差不多,这是平均分配的诉求。有的业务希望某个分片固定在某台机器上,比如按机房就近处理,这是自定义分配的诉求。还有的业务希望分片能轮转起来,避免某台机器长期处理“重”分片,这是轮询分配的诉求。
Elastic-Job-Lite 内置了三种策略,加上一个自定义扩展点,基本覆盖了大多数场景。下面逐个拆。
3. 三种内置分片策略的核心细节与实操要点
3.1 AverageAllocationJobShardingStrategy:平均分配策略
这是默认策略,也是用得最多的。它的核心逻辑用一句话概括:尽量把分片项平均分给每个作业节点,余数依次多分给前面的节点。
举个例子。假设有 3 个作业节点,9 个分片项。9 除以 3 等于 3,没有余数,所以每个节点分到 3 个分片项。映射结果就是:
| 作业节点 | 分片项 |
|---|---|
| 节点 A | 0, 1, 2 |
| 节点 B | 3, 4, 5 |
| 节点 C | 6, 7, 8 |
再假设有 3 个节点,10 个分片项。10 除以 3 等于 3 余 1。余数 1 分给第一个节点,所以节点 A 分到 4 个,节点 B 和 C 各分到 3 个:
| 作业节点 | 分片项 |
|---|---|
| 节点 A | 0, 1, 2, 3 |
| 节点 B | 4, 5, 6 |
| 节点 C | 7, 8, 9 |
这个策略的源码实现里有一个细节值得注意:它先计算每个节点至少能分到几个(itemCount / nodeCount),然后计算余数(itemCount % nodeCount),最后遍历节点,前remainder个节点多分一个。这个逻辑保证了分片项是连续分配的,而不是交叉分配。
为什么连续分配很重要?因为很多业务在实现分片逻辑时,会假设分片项是连续的。比如你按 ID 范围分片,分片 0 处理 ID 0-999,分片 1 处理 ID 1000-1999。如果分片项交叉分配,每个节点拿到的 ID 范围就是断开的,处理起来会麻烦很多。
实操心得:如果你的业务对分片连续性有强依赖,平均分配策略是首选。但要注意,当节点数量变化时,分片项的分配会整体重排,原来节点 A 处理分片 0-2,节点 B 上线后可能变成节点 A 处理分片 0-1,节点 B 处理分片 2-4。这意味着分片对应的数据范围会变,如果你的业务逻辑里硬编码了“分片 0 处理 ID 0-999”,节点变化后就会出错。正确的做法是在任务执行时动态计算当前分片对应的数据范围。
3.2 OdevitySortByNameJobShardingStrategy:按作业名哈希奇偶分配策略
这个策略的名字很长,但逻辑很简单:根据作业名称的哈希值奇偶性,决定分片项的分配顺序。
具体来说,它先计算作业名称的哈希值,如果是奇数,分片项从前往后分配;如果是偶数,分片项从后往前分配。分配方式仍然是平均分配,只是顺序不同。
为什么要搞这么一出?核心目的是解决不同作业之间的负载倾斜问题。假设你有两个作业,都用了平均分配策略,分片数都是 10,节点数都是 3。那么两个作业的分片分配结果完全一样:节点 A 拿 0-3,节点 B 拿 4-6,节点 C 拿 7-9。如果这两个作业的处理逻辑对分片项的“重量”敏感(比如分片 0 的数据量特别大),那么节点 A 就会同时承担两个作业的“重”分片,压力翻倍。
通过哈希奇偶性打乱分配顺序,可以让不同作业的“重”分片落到不同节点上,起到错峰的效果。
这个策略在实际中用得不多,因为它的效果依赖于作业名称的哈希分布,而且只能解决两个作业之间的倾斜,作业多了之后效果会衰减。但在一些特定场景下,比如两个作业处理同一份数据的不同维度,确实能起到一定的均衡作用。
3.3 RotateServerByNameJobShardingStrategy:按作业名轮询分配策略
这个策略的思路更直接:根据作业名称的哈希值,决定从哪个节点开始分配分片项。
假设有 3 个节点 A、B、C,9 个分片项。如果作业名称哈希后决定从节点 B 开始,那么分配顺序就是 B → C → A,分片项依次分配:
| 作业节点 | 分片项 |
|---|---|
| 节点 B | 0, 1, 2 |
| 节点 C | 3, 4, 5 |
| 节点 A | 6, 7, 8 |
和上一个策略类似,它的目的也是让不同作业的分片分配错开,避免所有作业的“重”分片都压在同一个节点上。区别在于,轮询策略的错开效果更均匀,因为它直接改变了节点的起始顺序,而不是只改变分片项的顺序。
这三个策略怎么选?我的经验是:单作业场景直接用默认的平均分配;多作业且分片负载不均匀的场景,可以试试轮询策略;哈希奇偶策略比较鸡肋,除非你有非常明确的奇偶性需求,否则不建议用。
4. 自定义分片策略的实现与实操过程
4.1 什么时候需要自定义
内置策略解决不了的问题,才需要自定义。常见的情况有这几种:
第一种是按机房就近分配。比如你有三个机房,希望分片尽量分配给同机房的节点,减少跨机房调用。内置策略做不到这一点,因为它不感知节点的物理位置。
第二种是按节点权重分配。比如你有 5 台机器,其中 2 台配置高、3 台配置低,希望高配机器多承担一些分片。内置策略是平均分配,不考虑权重。
第三种是固定分片映射。比如某个分片必须由特定节点处理,可能是为了数据本地性,也可能是为了故障隔离。
4.2 自定义策略的实现步骤
实现自定义分片策略,需要实现JobShardingStrategy接口。这个接口只有一个方法:
JobNodePath sharding(List<JobInstance> jobInstances, String jobName, int shardingTotalCount)参数含义很明确:jobInstances是当前可用的作业节点列表,jobName是作业名称,shardingTotalCount是分片总数。返回值是一个Map<JobInstance, List<Integer>>,表示每个节点分配到的分片项列表。
实现步骤大致如下:
- 创建一个类,实现
JobShardingStrategy接口。 - 在
sharding方法里编写分配逻辑。 - 在作业配置中通过
jobShardingStrategyClass指定自定义策略类的全限定名。 - 确保自定义策略类在作业节点的 classpath 下。
下面是一个按节点权重分配的示例思路(伪代码):
public Map<JobInstance, List<Integer>> sharding( List<JobInstance> jobInstances, String jobName, int shardingTotalCount) { // 假设每个节点的权重从配置中读取,这里简化为按节点顺序递增 Map<JobInstance, List<Integer>> result = new LinkedHashMap<>(); int totalWeight = 0; Map<JobInstance, Integer> weights = new HashMap<>(); for (int i = 0; i < jobInstances.size(); i++) { int weight = i + 1; // 示例权重 weights.put(jobInstances.get(i), weight); totalWeight += weight; } int assigned = 0; for (JobInstance instance : jobInstances) { int weight = weights.get(instance); int count = (int) Math.floor(shardingTotalCount * weight / (double) totalWeight); List<Integer> items = new ArrayList<>(); for (int i = 0; i < count && assigned < shardingTotalCount; i++) { items.add(assigned++); } result.put(instance, items); } // 处理余数,分配给第一个节点 while (assigned < shardingTotalCount) { result.get(jobInstances.get(0)).add(assigned++); } return result; }注意:自定义策略的实现必须保证幂等性。因为分片分配可能被多次触发(比如网络抖动导致节点列表变化),同样的输入必须产生同样的输出。否则会导致分片分配结果不一致,出现分片重复执行或漏执行的问题。
4.3 自定义策略的配置与生效
配置方式有两种。一种是在代码里通过JobCoreConfiguration的jobShardingStrategyClass属性指定:
JobCoreConfiguration coreConfig = JobCoreConfiguration.newBuilder("myJob", "0/10 * * * * ?", 10) .jobShardingStrategyClass("com.example.MyShardingStrategy") .build();另一种是在 Spring 配置文件中指定:
<job:simple id="myJob" ... job-sharding-strategy-class="com.example.MyShardingStrategy" />配置完成后,作业启动时会加载指定的策略类。如果类加载失败,框架会回退到默认的平均分配策略,并在日志中输出警告。所以配置完成后一定要检查日志,确认策略类加载成功。
5. 分片策略相关的常见问题与排查技巧
5.1 分片分配不均匀
这是最常见的抱怨。表现是某个节点分到的分片数明显多于其他节点。原因通常有三种:
第一种是分片数小于节点数。比如 3 个分片、5 个节点,那么必然有 2 个节点分不到分片。这不是策略的问题,是配置的问题。解决办法是增加分片数,或者减少节点数。
第二种是节点列表不稳定。如果某个节点频繁上下线,分片分配会反复触发,可能导致分配结果抖动。需要排查节点的网络稳定性和健康检查配置。
第三种是自定义策略实现有误。比如权重计算错误、余数处理不当等。需要检查策略类的逻辑,确保在各种输入下都能正确分配。
5.2 分片重新分配导致任务重复执行
这是一个比较隐蔽的问题。当节点变化触发分片重新分配时,原来由节点 A 处理的分片可能被分配给节点 B。如果节点 A 正在执行该分片的任务,而节点 B 也开始执行,就会出现重复执行。
Elastic-Job-Lite 通过分片执行标记来避免这个问题。每个分片在执行前会检查自己是否持有该分片的执行权,执行完成后会清除标记。但在极端情况下,比如节点 A 执行时间过长、超过了分片重新分配的等待时间,仍然可能出现重复执行。
实操心得:对于幂等性要求高的任务,建议在业务逻辑层面做去重。比如通过数据库唯一索引、Redis 分布式锁等方式,确保同一个分片的数据不会被重复处理。不要完全依赖框架的分片机制来保证 exactly-once。
5.3 分片数变更后的数据一致性问题
分片数变更是一个容易被忽视的操作。比如你原来配置了 10 个分片,后来改成 20 个。分片数变了,每个分片对应的数据范围也会变。如果业务逻辑里硬编码了分片对应的数据范围,就会出现数据遗漏或重复。
正确的做法是:分片对应的数据范围必须动态计算。比如用分片项 % 分片总数来决定哪些数据由当前分片处理。这样无论分片数怎么变,数据都能被正确覆盖。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决建议 |
|---|---|---|---|
| 分片分配不均匀 | 分片数小于节点数 | 检查分片数和节点数配置 | 增加分片数或减少节点数 |
| 分片分配不均匀 | 节点列表不稳定 | 检查节点上下线日志 | 排查网络和健康检查配置 |
| 分片分配不均匀 | 自定义策略逻辑错误 | 检查策略类实现 | 补充单元测试覆盖边界情况 |
| 任务重复执行 | 分片重新分配 | 检查分片执行标记 | 业务层做幂等处理 |
| 任务重复执行 | 节点执行超时 | 检查任务执行时间 | 调整分片等待时间或优化任务 |
| 数据遗漏或重复 | 分片数变更 | 检查数据范围计算逻辑 | 改为动态计算数据范围 |
| 自定义策略不生效 | 类加载失败 | 检查日志中的类加载信息 | 确认类路径和全限定名 |
6. 分片策略的选型建议与性能考量
6.1 不同场景下的策略选型
选型这件事没有标准答案,但可以给一个决策路径:
如果你的作业是单节点部署,分片策略其实无所谓,因为所有分片都在一个节点上。但建议还是配置合理的分片数,为将来扩容留余地。
如果是多节点均匀负载的场景,直接用默认的平均分配策略。这是最稳妥的选择,不需要额外配置,行为可预测。
如果是多作业共享节点的场景,且不同作业的分片负载差异较大,可以考虑轮询策略。但要注意,轮询策略的效果依赖于作业名称的哈希分布,如果哈希值恰好相同,轮询策略和平均分配策略的效果是一样的。
如果是节点异构的场景,比如机器配置不同,那就需要自定义策略,按权重分配。
如果是数据本地性敏感的场景,比如希望分片尽量在数据所在节点执行,也需要自定义策略,结合节点的元数据信息做分配。
6.2 分片数设置的经验值
分片数设置多少合适?这个问题我被问过很多次。我的经验是:分片数应该是节点数的 2 到 3 倍。
为什么?因为分片数等于节点数时,一旦有节点下线,它的分片需要重新分配给其他节点,其他节点的负载会瞬间增加。如果分片数是节点数的 2 到 3 倍,每个节点承担多个分片,节点下线时只需要把它的分片分散给其他节点,负载增加相对平缓。
但分片数也不是越多越好。分片数太多会导致分片分配的计算量增加,而且每个分片的执行开销(比如数据库连接、线程切换)也会累积。一般来说,分片数控制在 100 以内比较合适。如果确实需要更多分片,可以考虑分层的方案,比如先按业务维度分大组,再在组内分片。
6.3 分片策略对性能的影响
分片策略本身的计算开销很小,因为它只在节点变化时触发,不是每次任务执行都计算。真正影响性能的是分片分配的结果:如果分配不均匀,某些节点负载过高,任务执行时间就会拉长。
另外,分片重新分配的过程会短暂中断任务执行。虽然框架做了优化,尽量缩短中断时间,但在分片数很多、节点列表很大的情况下,重新分配仍然可能耗时几百毫秒到几秒。对于高频任务,这个中断需要纳入考量。
实操心得:如果你的任务对执行间隔非常敏感,比如每秒执行一次,建议把分片数控制在合理范围内,避免频繁的重新分配。同时,可以通过调整 ZooKeeper 的会话超时时间,减少节点误判导致的重新分配。
7. 从源码角度看分片策略的触发时机
7.1 分片分配的触发条件
分片分配不是定时触发的,而是由事件驱动的。具体来说,以下三种情况会触发分片重新分配:
第一种是作业节点上线。新节点加入集群后,需要重新分配分片,让新节点承担一部分负载。
第二种是作业节点下线。节点下线后,它持有的分片需要转移给其他节点。
第三种是分片总数变更。这通常发生在作业配置修改并重启后。
这三种情况都会导致注册中心上的节点列表发生变化,主节点监听到变化后,触发分片重新分配。
7.2 主节点在分片分配中的角色
Elastic-Job-Lite 的节点分为主节点和从节点。主节点负责发起分片分配,从节点只负责接收分配结果。
主节点的选举是通过 ZooKeeper 的临时顺序节点实现的。第一个创建节点的作业实例成为主节点。如果主节点下线,第二个节点会自动成为新的主节点,并触发一次分片重新分配。
这个设计有一个潜在问题:如果主节点频繁切换,分片分配会频繁触发。所以保证主节点的稳定性很重要。可以通过调整 ZooKeeper 的会话超时时间、优化网络环境来减少主节点切换。
7.3 分片分配结果的存储与读取
分片分配的结果存储在 ZooKeeper 的特定路径下。每个作业实例在启动时,会从该路径读取自己负责的分片项列表。如果读取失败或路径不存在,作业实例会认为没有分片可执行,任务就不会执行。
这个机制意味着:如果 ZooKeeper 不可用,分片分配结果无法读取,任务会停止执行。所以 ZooKeeper 的可用性直接决定了作业的可用性。在生产环境中,ZooKeeper 必须部署为高可用集群。
8. 一些踩过的坑和实操建议
8.1 分片项从 0 开始编号
这是一个很容易踩的坑。Elastic-Job-Lite 的分片项是从 0 开始编号的,不是从 1 开始。如果你在业务逻辑里写了shardingItem >= 1 && shardingItem <= shardingTotalCount,那么分片 0 就会被漏掉。
正确的写法是shardingItem >= 0 && shardingItem < shardingTotalCount。这个细节在文档里没有特别强调,但实际编码时很容易搞错。
8.2 分片逻辑要处理“空分片”
当分片数大于节点数时,每个节点会分到多个分片。但当分片数小于节点数时,有些节点分不到分片。这些节点在执行任务时,分片项列表为空,业务逻辑需要处理这种情况。
如果业务逻辑没有做空判断,可能会抛出异常或者执行无效操作。建议在任务执行入口处加一个判断:如果分片项列表为空,直接返回,不执行后续逻辑。
8.3 分片重新分配时的任务中断
分片重新分配时,正在执行的任务会被中断。框架会等待当前任务执行完成后才进行重新分配,但如果任务执行时间过长,超过了等待阈值,框架会强制中断。
这个等待阈值可以通过配置调整。如果任务执行时间较长,建议适当增大阈值,避免任务被强制中断导致数据不一致。
8.4 自定义策略的测试
自定义策略写完后,一定要做充分的测试。测试用例至少覆盖以下几种情况:节点数等于分片数、节点数大于分片数、节点数小于分片数、节点列表为空、分片数为零。这些边界情况最容易出问题。
测试时可以用单元测试直接调用sharding方法,验证返回的映射关系是否符合预期。不需要启动整个作业框架,这样测试效率更高。
8.5 监控分片分配结果
生产环境中,建议对分片分配结果做监控。比如定期输出每个节点持有的分片项列表,观察分配是否均匀。如果发现某个节点长期持有过多分片,可能需要调整策略或节点配置。
监控数据可以接入现有的监控系统,也可以简单地输出到日志中,通过日志分析工具做统计。关键是要有数据,才能发现问题。
9. 分片策略的扩展思路
9.1 基于节点负载的动态分片
内置策略都是静态的,只考虑节点数量和分片数量,不考虑节点的实际负载。一个自然的扩展思路是:根据节点的 CPU、内存、任务队列长度等指标,动态调整分片分配。
实现方式可以是自定义策略 + 外部监控数据。策略类在分配分片时,从监控系统读取各节点的负载指标,按负载比例分配分片。负载低的节点多分,负载高的节点少分。
这个思路的难点在于:负载指标是动态变化的,而分片分配是事件驱动的。如果负载变化后不触发重新分配,分配结果就不会更新。所以需要额外的机制来定期触发重新分配,或者接受一定程度的滞后。
9.2 基于数据亲和性的分片
如果任务处理的数据有明确的归属关系,比如按用户 ID 分片,那么可以把分片分配给数据所在的节点,减少数据传输开销。
实现方式是在节点启动时注册自己的数据归属信息,策略类根据这些信息做分配。这个思路在数据密集型任务中比较有用,但实现复杂度较高,需要额外的元数据管理。
9.3 分片策略的组合使用
有时候单一策略满足不了需求,可以考虑组合。比如先用平均分配策略做基础分配,再对个别分片做手动调整。或者先用轮询策略打散,再用权重策略做微调。
组合策略的实现需要仔细设计,避免逻辑冲突。建议先用简单的组合,验证效果后再逐步复杂化。
10. 写在最后的一些个人体会
分片策略这个东西,刚接触的时候觉得很简单,不就是分个片吗。但用得越久,越发现里面的细节很多。每一个配置项背后都有它的设计意图,每一个默认值都是经过权衡的。
我自己的经验是:不要轻易自定义策略。内置的平均分配策略能解决 80% 的问题。剩下 20% 的问题里,有一半可以通过调整分片数和节点数来解决。真正需要自定义策略的场景,其实很少。
另外,分片策略只是分布式任务调度的一个环节。它解决的是“怎么分”的问题,但“分完之后怎么保证不重复、不遗漏、不中断”这些问题,需要业务逻辑和框架机制配合解决。不要指望一个分片策略能搞定所有事情。
最后分享一个小技巧:如果你不确定该用哪种策略,先用默认的平均分配策略跑一段时间,观察分片分配结果和任务执行情况。如果发现不均匀或者有热点,再考虑调整。不要一开始就上复杂的自定义策略,那样调试成本很高,而且容易引入新问题。