JobFlow:固定分片如何解决分布式扫描的边界抖动

开源地址与系列文章

前言

做批处理任务,很多人第一反应都是分页拉数:

ini 复制代码
page=1 → 拉100条
page=2 → 拉100条
...
page=N → 拉50条,结束

看起来很自然,但在分布式场景下,这个设计有个致命问题:

复制代码
你在翻页期间,数据一直在变化
→ 第1000页和第1001页的边界,是个动态的、不稳定的东西
→ 有些数据可能永远落不到任何一页
→ 你既不能保证不重复,也不能保证不遗漏

JobFlow 的选择是:放弃分页,改用固定分片

这篇文章就来讲讲这个设计。

一、分页的问题:边界在抖动

问题场景

假设你要做订单对账,每天凌晨扫描全量订单:

graph LR A[扫描任务启动] --> B[page=1
拉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条订单
     → 可能被重复处理
     → 也可能被遗漏

极端情况:
某个订单刚好在你翻页的时候被插入
→ 它永远落不到任何一页
→ 也永远不会被扫描到(幽灵数据)

本质矛盾

分页把一个"空间划分"的问题,错误地建模成了"时间快照"的问题:

graph LR A[分页模型
边界动态] --> 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 不变,它永远归属同一个分片

调度器只关心两件事

graph LR A[调度器] --> B[这一轮
要跑多少片?] 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个子记录:

graph LR A[任务触发] --> B[创建父记录
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片,固化到数据库里。

分配调度

初始分配逻辑:

graph LR A[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
- ...

分片的空间语义和实例数量解耦了。

四、为什么固定分片更靠谱

边界稳定

对比一下两种方案:

graph LR A[分页方案] --> B[边界抖动] C[分片方案] --> D[边界稳定] style A fill:#FFB6C1 style B fill:#FF6B6B style C fill:#87CEEB style D fill:#90EE90

分页方案:

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 的订单(归属不变)

补偿更简单

有了固定分片,补偿逻辑很清晰:

graph LR A[发现超时分片] --> B{shard_3
状态?} 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: 重新分配执行

因为有了固定分片,可以做到:

复制代码
部分分片超时 → 局部补偿
而不是:整体任务失败 → 全部重来

整体流程对比

graph LR A[分页方案
边界动态] --> 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 询问执行器状态
→ 根据返回结果决定:继续等 / 标记超时 / 更新状态

七、固定分片的本质

用一句话概括:

复制代码
分页模型:我在追一个移动的靶子
分片模型:我在负责一块固定的空间

技术上的差异:

graph LR A[分页模型] --> B[临时视图] B --> C[边界动态] C --> D[补偿困难] E[分片模型] --> F[永久责任域] F --> G[边界稳定] G --> H[补偿简单] style A fill:#FFB6C1 style D fill:#FF6B6B style E fill:#87CEEB style H fill:#90EE90

设计上的本质:

分页模型:

  • 把数据当成一个不断变化的列表
  • 用"页号 + 当前快照"描述进度
  • 快照在变,边界也在变

分片模型:

  • 把数据空间当成一个稳定的坐标系
  • 分片是坐标系上的分区
  • 每个分区有一个调度责任人

核心区别:

分页问的是:"现在还有没有第1001页?"(问题本身是错的)

分片说的是:"这一轮的100片,我都负责到底了。"(责任明确)

八、总结

固定分片不是什么高深的技术,代码也不复杂。

但它解决的是一个认知问题:

arduino 复制代码
你要先意识到"问第几页"这个问题本身是错的
然后才能把思维模型切换到"空间分片 + 固定责任域"

JobFlow 的固定分片设计带来的好处:

好处一:边界稳定

  • 分片归属由固定规则决定
  • 数据变更不影响分片编号
  • 补偿有明确的空间载体

好处二:责任清晰

  • 每个分片都有明确的状态
  • 调度器知道每片是否执行成功
  • 超时、失败、补偿都有据可查

好处三:协同自然

  • 和超时巡检天然协同
  • 和回调驱动天然协同
  • 和动态负载均衡天然协同

好处四:业务灵活

  • 业务决定分片策略
  • 业务决定查询方式
  • 调度器只管调度和状态

从工程角度看,这不是"写不出来的高难度代码",而是在正确的抽象层上重新定义了边界

对一个分布式调度系统来说,这种边界级别的设计,比任何一行技巧性的代码都更值钱。

相关推荐
q_19132846952 小时前
基于SpringBoot+Vue.js的高校竞赛活动信息平台
vue.js·spring boot·后端·mysql·程序员·计算机毕业设计
职业码农NO.13 小时前
系统架构设计中的 15 个关键取舍
设计模式·架构·系统架构·ddd·架构师·设计规范·领域驱动
NAGNIP3 小时前
Transformer 中为什么用LayerNorm而不用BatchNorm?
人工智能·面试
踏浪无痕3 小时前
JobFlow调度的难题:超时、补偿与漏调
后端·面试·架构
Postkarte不想说话3 小时前
ElasticSearch操作系统环境设置
后端
i听风逝夜3 小时前
Gradle秒级打包部署SpringBoot项目,行云流水
后端
白帽黑客-晨哥3 小时前
Web安全方向的面试通常会重点考察哪些漏洞和防御方案?
安全·web安全·面试·职场和发展·渗透测试
why技术3 小时前
如果让我站在科技从业者的角度去回看 2025 年,让我选一个词出来形容它,我会选择“vibe coding”这个词。
前端·后端·程序员
喵个咪3 小时前
Go单协程事件调度器:游戏后端的无锁有序与响应时间掌控
后端·游戏开发