2. 任务拆分策略:大任务拆小任务的粒度控制与分片算法选择
做调度系统,最怕什么?
我个人最怕那种「一个任务跑三天,最后一天挂了,全部重来」的场景。
嗯,这就是典型的「大任务没拆分」的后果。
把一个大任务拆成若干小任务,听起来很简单。
但拆多细?用什么算法分?
这里面的坑,我踩过不少。
2.1 为什么要拆?拆了有什么好处?
说白了,拆任务就三个目的:
- 降低单点风险:一个子任务失败,不影响其他子任务
- 提高并行度:多个子任务可以同时跑,充分利用集群资源
- 支持断点续传:失败后只重跑失败的那一小块,不用从头再来
我曾经接手过一个数据迁移项目,原始任务是把 10TB 数据从 A 库搬到 B 库。
没拆分前,跑了 18 个小时,第 16 小时网络闪断,全部回滚。
拆成 1000 个分片后,即使有分片失败,重跑也就几分钟的事。
核心原则:拆分的粒度,决定了调度的灵活性和系统的复杂度。粒度越细,灵活性越高,但管理成本也越大。
2.2 粒度控制:拆多细才算合适?
这个问题没有标准答案。但我有一个经验公式:
单个子任务的执行时间,控制在 1~5 分钟之间。
为什么是这个范围?
- 如果子任务执行时间 < 1 分钟:调度开销(任务创建、分发、状态同步)占比太高,得不偿失
- 如果子任务执行时间 > 10 分钟:一旦失败,重试成本依然偏高,失去了拆分的意义
我建议你在设计阶段,先按这个粒度估算分片数量:
// 估算分片数
long totalDataSize = 10_000_000_000L; // 10GB
long perShardSize = 10_000_000L; // 10MB
int shardCount = (int) (totalDataSize / perShardSize);
// 再根据集群资源调整
int availableWorkers = 20;
int optimalShardCount = Math.min(shardCount, availableWorkers * 10);
这里有个坑:不要只看数据量,还要看计算复杂度。
我记得有一次做图像处理任务,每张图 2MB,但处理一张要 30 秒。
按数据量拆,每个分片 10MB 只有 5 张图,执行时间才 2.5 分钟,合理。
但如果按数据量拆的是文本处理任务,10MB 文本可能几秒就处理完了,那就太细了。
经验之谈:先做一次小规模压测,拿到「单位数据量处理耗时」这个关键指标,再反推分片大小。不要拍脑袋定。
2.3 分片算法:怎么分?
分片算法决定了「数据如何被切分」以及「每个分片包含哪些数据」。
我常用的分片算法有四种,各有优劣。
| 算法 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 范围分片 | 有序数据(时间、ID) | 实现简单,天然有序 | 容易数据倾斜 |
| 哈希分片 | 无序数据,均匀分布 | 数据分布均匀 | 范围查询困难 |
| 轮询分片 | 无状态任务 | 负载均衡好 | 无法利用局部性 |
| 动态分片 | 数据量未知或波动大 | 自适应,灵活 | 实现复杂,有额外开销 |
2.3.1 范围分片
按某个字段的值范围来切。比如按用户 ID 分:
// 范围分片示例
// 假设用户 ID 范围 1~1000000,分 10 片
int shardCount = 10;
int rangeSize = 1000000 / shardCount; // 每片 100000
for (int i = 0; i < shardCount; i++) {
long startId = i * rangeSize + 1;
long endId = (i + 1) * rangeSize;
System.out.println("分片 " + i + ": ID " + startId + " ~ " + endId);
}
这个算法最直观。但要注意数据倾斜问题。
我曾经遇到过按订单时间分片,结果双十一那天的数据量是平时的 100 倍。
一个分片跑了 2 小时,其他分片 1 分钟就结束了。
嗯,这就是典型的「热点分片」。
避坑指南:我曾经在金融项目中用时间范围分片做日终批量,没考虑到月末最后一天的数据量是平时的 3 倍。结果月末那天,最后一个分片超时,导致整个批处理链路延迟。后来我改成了「先按时间粗分,再按数据量细分」的两级分片策略。
2.3.2 哈希分片
对某个 key 做哈希,然后取模。数据分布均匀,适合无范围查询的场景。
// 哈希分片示例
int shardCount = 10;
String key = "user_12345";
int shardId = Math.abs(key.hashCode()) % shardCount;
System.out.println("数据 " + key + " 进入分片 " + shardId);
哈希分片有个经典问题:扩缩容时数据需要重新分布。
如果你用取模哈希,从 10 片扩到 11 片,几乎所有数据都要迁移。
我建议用一致性哈希,或者干脆用 虚拟桶 的方式。
2.3.3 轮询分片
简单粗暴,按顺序分配。适合无状态、无依赖的任务。
// 轮询分片示例
List<String> dataList = getDataList();
int shardCount = 10;
Map<Integer, List<String>> shards = new HashMap<>();
for (int i = 0; i < dataList.size(); i++) {
int shardId = i % shardCount;
shards.computeIfAbsent(shardId, k -> new ArrayList<>()).add(dataList.get(i));
}
轮询分片最大的问题是:无法保证数据局部性。
比如你要做「按用户分组聚合」的操作,轮询分片会把同一个用户的数据分散到不同分片,导致每个分片都要做全量聚合,效率极低。
2.3.4 动态分片
这是我最喜欢的方式,也是实现起来最复杂的。
核心思想:不预先固定分片数量,而是让 Worker 主动「拉取」任务。
// 动态分片伪代码
// Master 端:将数据源拆成最小粒度单元(比如一个文件、一个分区)
// Worker 端:不断向 Master 请求下一个待处理单元
// Worker 循环
while (true) {
Task task = master.fetchNextTask(workerId);
if (task == null) break; // 没有更多任务了
process(task);
master.reportCompletion(task.getId(), workerId);
}
动态分片的优势很明显:
- 自动负载均衡:快的 Worker 多干,慢的 Worker 少干
- 天然容错:某个 Worker 挂了,它未完成的任务会被其他 Worker 领走
- 无需预先估算分片数
但代价是:Master 需要维护一个「待分配任务队列」,有额外的网络开销和状态管理复杂度。
2.4 分片策略选择决策树
为了帮你快速选型,我画了一张决策流程图:
2.5 分片后的任务编排
拆完分片,不等于万事大吉。你还要考虑分片之间的依赖关系。
我一般把分片任务分为三类:
- 独立分片:分片之间无依赖,可以全并行。比如数据清洗任务。
- 顺序分片:分片之间有先后顺序。比如 ETL 中的「先抽取、再转换、最后加载」。
- 聚合分片:所有分片完成后,需要做一次汇总。比如 MapReduce 中的 Reduce 阶段。
对于顺序分片和聚合分片,我建议引入一个 屏障(Barrier) 机制:
// 屏障机制示例
// 所有分片完成后,触发聚合任务
// 分片任务
public class ShardTask implements Runnable {
private final String shardId;
private final Barrier barrier;
public void run() {
try {
processShard(shardId);
} finally {
barrier.arrive(shardId); // 通知屏障:本分片完成
}
}
}
// 聚合任务
public class AggregateTask implements Runnable {
private final Barrier barrier;
public void run() {
barrier.waitForAll(); // 等待所有分片完成
doAggregate(); // 执行聚合
}
}
小技巧:如果分片数量很大(比如上万),屏障等待可能会成为瓶颈。我习惯用「分阶段屏障」——先等 100 个分片完成就做一次小聚合,最后再做全局聚合。这样能提前释放部分结果,也减轻了屏障的压力。
2.6 避坑清单
最后,我把这些年踩过的坑整理成一份清单:
- 坑 1:分片粒度过细 —— 调度开销超过计算开销。我曾经把 1GB 数据拆成 10000 个分片,每个分片处理 100KB,结果任务分发花了 5 分钟,实际计算只用了 30 秒。
- 坑 2:忽略数据倾斜 —— 范围分片时没考虑热点。建议在分片前先做一次数据分布采样。
- 坑 3:分片 key 选择不当 —— 用时间戳做哈希分片 key,导致同一时间段的数据分散到不同分片,后续聚合时跨分片查询性能极差。
- 坑 4:忘记处理空分片 —— 动态分片时,如果某个分片没有数据,要优雅跳过,不要抛异常。
- 坑 5:分片元数据丢失 —— 分片任务重启后,不知道哪些分片已完成、哪些未完成。一定要持久化分片状态。
我曾经踩过最深的坑:在金融对账系统中,用哈希分片按交易流水号分片。结果某个渠道的流水号生成规则变了,导致哈希严重倾斜,一个分片处理了 80% 的数据。从那以后,我所有分片算法都加了「数据分布监控」和「动态重分片」的兜底逻辑。
好了,关于任务拆分策略,核心就是这些。
记住:没有最好的分片算法,只有最适合你业务场景的算法。
先搞清楚你的数据特征,再选分片策略,最后用监控数据持续调优。