开源地址与系列文章
- 开源地址 :
https://gitee.com/sh_wangwanbao/job-flow - 系列文章:
前言
做批处理任务,很多人第一反应都是分页拉数:
ini
page=1 → 拉100条
page=2 → 拉100条
...
page=N → 拉50条,结束
看起来很自然,但在分布式场景下,这个设计有个致命问题:
你在翻页期间,数据一直在变化
→ 第1000页和第1001页的边界,是个动态的、不稳定的东西
→ 有些数据可能永远落不到任何一页
→ 你既不能保证不重复,也不能保证不遗漏
JobFlow 的选择是:放弃分页,改用固定分片。
这篇文章就来讲讲这个设计。
一、分页的问题:边界在抖动
问题场景
假设你要做订单对账,每天凌晨扫描全量订单:
拉100条] B --> C[page=2
拉100条] C --> D[...] D --> E[page=1000
拉100条] E --> F{还有下一页?} style A fill:#87CEEB style B fill:#90EE90 style C fill:#90EE90 style E fill:#90EE90 style F fill:#FFE4B5
问题在哪?
核心问题:边界在抖动
ini
你在扫第1页的时候:
- 用户在下新订单
- 订单在更新状态
- 历史订单在被清理
等你扫到第1000页:
- 前面的页已经变了
- 边界也跟着变了
具体表现:
第一轮:page=1000 拉到 ID=100000-100100
第二轮:期间有订单被删除
page=1000 拉到 ID=100000-100105(边界变了)
page=1001 拉到 ID=100095-100195(边界又变了)
结果:ID=100095-100100 这5条订单
→ 可能被重复处理
→ 也可能被遗漏
极端情况:
某个订单刚好在你翻页的时候被插入
→ 它永远落不到任何一页
→ 也永远不会被扫描到(幽灵数据)
本质矛盾
分页把一个"空间划分"的问题,错误地建模成了"时间快照"的问题:
边界动态] --> B[快照一直在变] C[分片模型
边界稳定] --> D[空间固定不变] style A fill:#FFB6C1 style B fill:#FF6B6B style C fill:#87CEEB style D fill:#90EE90
分页模型的问题:
- 边界定义:当前快照下的第N页
- 任何数据变更都会改变快照
- 你永远在追一个移动的靶子
分片模型的优势:
- 边界定义:固定的空间划分
- 数据变更不影响分片归属
- 一次性定义,后续稳定
二、固定分片的核心思路
JobFlow 的思路很简单:
arduino
不要再问"还有没有下一页"
调度器自己把责任空间切成固定数量的分片
数据库设计
核心配置在 job_definition 表:
sql
CREATE TABLE job_definition (
id BIGINT PRIMARY KEY,
name VARCHAR(100),
shard_count INT DEFAULT 1, -- 固定分片数
shard_strategy VARCHAR(20) DEFAULT 'MOD_HASH', -- 分片策略
...
);
关键点:
关键点一:分片数是固定的
ini
订单对账任务:配置 shard_count = 100
→ 这个任务永远是100个分片
→ 无论订单有100万还是1000万
→ 无论执行器有3个实例还是10个实例
关键点二:分片策略由业务定义
ini
MOD_HASH 策略:
- shard_0 负责 orderId % 100 == 0 的订单
- shard_1 负责 orderId % 100 == 1 的订单
- ...
- shard_99 负责 orderId % 100 == 99 的订单
只要 orderId 不变,它永远归属同一个分片
调度器只关心两件事
要跑多少片?] A --> C[每一片
编号是几?] B --> D[读配置
shard_count=100] C --> E[分配编号
0-99] style A fill:#87CEEB style B fill:#FFE4B5 style C fill:#FFE4B5 style D fill:#90EE90 style E fill:#90EE90
调度器不关心:
- 具体怎么分片(业务决定)
- 每片有多少数据(业务决定)
- 数据怎么查询(业务决定)
调度器只关心:
- 这100个分片,我都调度到了吗?
- 每个分片的状态是什么?(PENDING/RUNNING/SUCCESS/FAILED)
- 超时了要不要补偿?
三、固定分片怎么落地
创建父子记录
任务触发时,创建一个父记录和100个子记录:
job_execution] B --> C[创建100个子记录
job_sub_execution] C --> D[shard_index
0-99] style A fill:#87CEEB style B fill:#90EE90 style C fill:#90EE90 style D fill:#FFE4B5
代码逻辑:
java
// 1. 创建父记录
String rootTraceId = buildRootTraceId(triggerTime);
Long parentExecutionId = createParentExecution(job, triggerTime, shardCount, rootTraceId);
// 2. 批量创建子记录
for (int i = 0; i < shardCount; i++) {
JobSubExecution subExec = new JobSubExecution();
subExec.setParentExecutionId(parentExecutionId);
subExec.setShardIndex(i); // 固定分片编号:0-99
subExec.setStatus(PENDING);
subExec.setRetryCount(0);
// ...
}
jobRepository.batchInsertSubExecutions(subExecutions);
关键点:分片在调度时就固化了
这不是在执行时临时决定"我要扫第几页",而是在调度时就把这一轮的责任空间切成100片,固化到数据库里。
分配调度
初始分配逻辑:
PENDING状态] --> B[查询执行器实例
假设3个] B --> C[初始分配
min分片数,实例数] C --> D[分片0 to 实例1] C --> E[分片1 to 实例2] C --> F[分片2 to 实例3] style A fill:#87CEEB style B fill:#FFE4B5 style C fill:#90EE90 style D fill:#90EE90 style E fill:#90EE90 style F fill:#90EE90
代码逻辑:
java
// 初始分配:min(分片数, 实例数) 个分片
int initialAllocation = Math.min(shardCount, instances.size());
for (int i = 0; i < initialAllocation; i++) {
allocateAndCallNextShard(parentExecutionId, rootTraceId, job, instances);
}
为什么只分配3个?
因为后续是回调驱动的:
erlang
执行器1 执行完 分片0 → 回调调度器
→ 调度器分配 分片3 给执行器1
→ 执行器1 执行完 分片3 → 回调调度器
→ 调度器分配 分片6 给执行器1
→ ...
这样就实现了动态负载均衡:快的执行器多干活,慢的执行器少干活。
具体分配逻辑
java
public void allocateAndCallNextShard(...) {
lock.lock();
try {
// 1. 查询下一条 PENDING 分片
JobSubExecution nextShard = jobRepository.findNextPendingSubExecution(parentExecutionId);
if (nextShard == null) {
return; // 所有分片都分配完了
}
// 2. 选择执行器实例(负载均衡)
ServiceInstance instance = instances.get(nextShard.getShardIndex() % instances.size());
String executorUrl = instance.getUri().toString();
// 3. CAS 更新状态 PENDING → RUNNING
boolean updated = jobRepository.updateSubExecutionWithVersion(
nextShard.getId(),
nextShard.getVersion(),
RUNNING,
executorUrl,
LocalDateTime.now()
);
if (!updated) {
return; // CAS 失败,其他实例已经处理了
}
} finally {
lock.unlock();
}
// 4. 锁外异步调用执行器
executorService.submit(() -> callExecutor(nextShard, ...));
}
关键点:
关键点一:shardIndex 是固定的
无论你今天有3个实例还是明天扩到10个实例,分片编号永远是 0-99。
关键点二:实例选择只是负载均衡
diff
shardIndex % instances.size()
3个实例时:
- 分片0,3,6,9... → 实例0
- 分片1,4,7,10... → 实例1
- 分片2,5,8,11... → 实例2
10个实例时:
- 分片0,10,20... → 实例0
- 分片1,11,21... → 实例1
- ...
分片的空间语义和实例数量解耦了。
四、为什么固定分片更靠谱
边界稳定
对比一下两种方案:
分页方案:
ini
今天扫描:
- page=1000 拉到 ID=100000-100100
- 明天有订单被删除
- page=1000 拉到 ID=100000-100105(边界变了)
分片方案:
ini
shard_0 负责 orderId % 100 == 0 的订单
今天扫描:
- shard_0 拉到 ID=100, 200, 300, ...
- 明天有订单被删除
- shard_0 还是拉 orderId % 100 == 0 的订单(归属不变)
补偿更简单
有了固定分片,补偿逻辑很清晰:
状态?} B -->|PENDING| C[从未执行
重新分配] B -->|RUNNING| D[询问执行器
确认状态] B -->|FAILED| E{重试次数?} E -->|未达上限| F[CAS更新
重新执行] E -->|达到上限| G[标记最终失败] style A fill:#FFB6C1 style B fill:#FFE4B5 style C fill:#87CEEB style D fill:#87CEEB style F fill:#90EE90 style G fill:#FF6B6B
设计目标从:
我到底有没有漏扫某一页?(无法回答)
变成了:
shard_3 最终是 SUCCESS 还是 FAILED?(明确的)
如果 FAILED,补偿几次?(可控的)
和超时巡检天然协同
JobFlow 的超时巡检是按分片粒度的:
diff
超时巡检发现:
- shard_0: SUCCESS
- shard_1: SUCCESS
- shard_2: RUNNING 超时
- shard_3: PENDING 超时
- ...
处理:
- shard_2: 询问执行器,确认状态
- shard_3: 重新分配执行
因为有了固定分片,可以做到:
部分分片超时 → 局部补偿
而不是:整体任务失败 → 全部重来
整体流程对比
边界动态] --> B[不知道
下一页在哪] C[分片方案
边界固定] --> D[100个分片
状态明确] style A fill:#FFB6C1 style B fill:#FF6B6B style C fill:#87CEEB style D fill:#90EE90
五、业务侧怎么用
执行器实现
业务侧只需要实现分片内部的处理逻辑:
java
@JobHandler("orderSync")
public JobResult syncOrders(JobContext context) {
// 1. 从上下文拿到分片信息
int shardIndex = context.getShardIndex();
int shardCount = context.getShardCount();
// 2. 根据分片策略查询数据
List<Order> orders = orderDao.findByShardIndex(shardIndex, shardCount);
// 3. 处理业务逻辑
for (Order order : orders) {
// 对账、同步、清理...
}
return JobResult.success();
}
分片策略示例:
java
// MOD_HASH 策略
public List<Order> findByShardIndex(int shardIndex, int shardCount) {
return jdbcTemplate.query(
"SELECT * FROM orders WHERE id % ? = ?",
new Object[]{shardCount, shardIndex},
rowMapper
);
}
// RANGE 策略
public List<Order> findByShardIndex(int shardIndex, int shardCount) {
long totalCount = getTotalCount();
long rangeSize = totalCount / shardCount;
long startId = shardIndex * rangeSize;
long endId = (shardIndex + 1) * rangeSize;
return jdbcTemplate.query(
"SELECT * FROM orders WHERE id >= ? AND id < ?",
new Object[]{startId, endId},
rowMapper
);
}
自定义策略
如果内置策略不满足需求,可以自定义:
java
// 按地区分片
public List<Order> findByShardIndex(int shardIndex, int shardCount) {
// 100个分片,前50个是华东,后50个是华南
if (shardIndex < 50) {
return findByRegion("华东");
} else {
return findByRegion("华南");
}
}
// 按时间分片
public List<Order> findByShardIndex(int shardIndex, int shardCount) {
// 100个分片,每片负责一个小时
LocalDateTime startTime = baseTime.plusHours(shardIndex);
LocalDateTime endTime = startTime.plusHours(1);
return findByTimeRange(startTime, endTime);
}
业务有完全的灵活性,调度器不关心具体怎么分。
六、一个完整的例子
假设订单对账任务,配置 100 个分片:
第一步:配置任务
sql
INSERT INTO job_definition (name, service_name, handler, cron, shard_count, shard_strategy)
VALUES ('orderSync', 'order-service', 'orderSync', '0 2 * * *', 100, 'MOD_HASH');
第二步:调度器触发
凌晨2点,调度器扫描到任务到期
→ 创建父记录(traceId: 20241222020000-abc123)
→ 创建100个子记录(shard_0 - shard_99,状态 PENDING)
→ 初始分配3个分片给3个执行器实例
第三步:执行器处理
ini
执行器1 收到请求:
- shardIndex = 0
- shardCount = 100
→ 查询:SELECT * FROM orders WHERE id % 100 = 0
→ 处理这些订单
→ 回调调度器:shard_0 SUCCESS
调度器收到回调:
→ 更新 shard_0 状态为 SUCCESS
→ 分配下一个 PENDING 分片(shard_3)给执行器1
→ 执行器1 继续处理 shard_3
第四步:循环直到完成
diff
执行器1, 2, 3 不断:
- 执行分片
- 回调调度器
- 获取新分片
- 继续执行
直到所有100个分片都是 SUCCESS 状态
第五步:异常处理
sql
如果 shard_5 执行失败:
→ 状态标记为 FAILED
→ 巡检任务发现
→ CAS 更新状态 FAILED → RUNNING
→ 重新分配给某个执行器
→ 最多重试3次
如果 shard_8 一直 RUNNING 不回调:
→ 超时巡检发现
→ HTTP 询问执行器状态
→ 根据返回结果决定:继续等 / 标记超时 / 更新状态
七、固定分片的本质
用一句话概括:
分页模型:我在追一个移动的靶子
分片模型:我在负责一块固定的空间
技术上的差异:
设计上的本质:
分页模型:
- 把数据当成一个不断变化的列表
- 用"页号 + 当前快照"描述进度
- 快照在变,边界也在变
分片模型:
- 把数据空间当成一个稳定的坐标系
- 分片是坐标系上的分区
- 每个分区有一个调度责任人
核心区别:
分页问的是:"现在还有没有第1001页?"(问题本身是错的)
分片说的是:"这一轮的100片,我都负责到底了。"(责任明确)
八、总结
固定分片不是什么高深的技术,代码也不复杂。
但它解决的是一个认知问题:
arduino
你要先意识到"问第几页"这个问题本身是错的
然后才能把思维模型切换到"空间分片 + 固定责任域"
JobFlow 的固定分片设计带来的好处:
好处一:边界稳定
- 分片归属由固定规则决定
- 数据变更不影响分片编号
- 补偿有明确的空间载体
好处二:责任清晰
- 每个分片都有明确的状态
- 调度器知道每片是否执行成功
- 超时、失败、补偿都有据可查
好处三:协同自然
- 和超时巡检天然协同
- 和回调驱动天然协同
- 和动态负载均衡天然协同
好处四:业务灵活
- 业务决定分片策略
- 业务决定查询方式
- 调度器只管调度和状态
从工程角度看,这不是"写不出来的高难度代码",而是在正确的抽象层上重新定义了边界。
对一个分布式调度系统来说,这种边界级别的设计,比任何一行技巧性的代码都更值钱。