JobFlow调度的难题:超时、补偿与漏调

开源地址与系列文章

前言

前面几篇文章讲了 JobFlow 的无锁调度是怎么做的:通过 Hash 分区 + Owner 判定,让每个调度器实例自己算出该不该执行任务,避免了数据库锁竞争。

但无锁设计带来了新问题:

复制代码
场景一:任务触发了,但执行器一直没响应
→ 是真的在跑,还是已经挂了?

场景二:任务失败了,需要重试
→ 重试几次?间隔多久?

场景三:某一轮任务根本没被触发
→ 怎么发现?怎么补偿?

这就是分布式调度的三个终极难题:超时、补偿、漏调

这篇文章就来讲讲 JobFlow 是怎么解决这三个问题的。

一、整体方案:三层保障机制

在深入细节之前,先看看 JobFlow 的整体设计思路。

核心理念

JobFlow 的异常处理遵循一个简单的原则:

diff 复制代码
正常路径:追求性能
- 无锁调度,快速触发
- 异步回调,立即响应
- 99% 的任务走这条路

异常路径:追求可靠性
- 超时巡检,确认状态
- 分片补偿,限次重试
- 漏调检测,主动补偿
- 1% 的任务需要兜底

三层保障机制

JobFlow 通过三个层次来保障任务执行:

graph LR A[第一层
正常调度] --> B[第二层
超时与补偿] B --> C[第三层
漏调检测] A --> D[99%任务
快速完成] B --> E[0.9%任务
重试成功] C --> F[0.1%任务
补偿执行] style A fill:#90EE90 style B fill:#FFE4B5 style C fill:#FFB6C1 style D fill:#90EE90 style E fill:#87CEEB style F fill:#87CEEB

第一层:正常调度

  • Owner 判定避免重复
  • 创建父子记录
  • 初始分配 + 回调驱动
  • 大部分任务在这一层完成

第二层:超时与补偿

  • 超时巡检(3 分钟一次)
  • 询问执行器确认真实状态
  • 分片补偿,限次重试
  • 处理执行慢、失败的情况

第三层:漏调检测

  • 低频任务巡检(10 分钟一次)
  • 时间窗口内查找执行记录
  • 发现漏调主动补偿
  • 处理整轮未触发的情况

关键角色分工

不是所有调度器实例都做所有事情:

graph LR A[普通实例
负责正常调度] --> B[按 Hash 分区
各管各的任务] C[巡检Leader
最小节点] --> D[超时巡检
分片补偿
漏调检测] style A fill:#90EE90 style B fill:#87CEEB style C fill:#FFE4B5 style D fill:#FFB6C1

这样设计的好处:

  • 正常调度负载均衡(Hash 分区)
  • 巡检任务单点执行(避免重复)
  • 最小节点挂了,下一个自动接管

有了整体认识,我们再深入每个环节的细节。

二、问题拆解:三种异常场景

先看看 JobFlow 需要处理哪些异常场景:

graph LR A[正常执行
99%场景] --> B[任务完成] C[执行超时
卡住不动] --> D[超时判定] E[执行失败
需要重试] --> F[补偿重试] G[任务漏调
整轮错过] --> H[漏调检测] style A fill:#90EE90 style C fill:#FFB6C1 style E fill:#FFB6C1 style G fill:#FFB6C1

核心设计思路:

正常路径追求性能:

  • 无锁 owner 判定
  • 快速触发执行
  • 异步回调更新

异常路径追求可靠性:

  • 超时巡检确认状态
  • 分片补偿限次重试
  • 低频任务漏调检测

接下来分别展开讲。

三、正常流程快速回顾

在讲异常处理之前,快速回顾一下正常流程(详细内容见第二、三篇文章):

调度触发:定时扫描 → Owner 判定 → Cron 到期 → 触发执行

分片执行:创建父记录 → 创建子记录(100 个 PENDING 分片)→ 初始分配 → 回调驱动

这就是正常路径。问题是,异常情况怎么处理?

四、超时判定:从感觉超时到确认超时

什么叫超时?

任务触发了,分片也调度了,但一直没回调。这时候调度器不知道:

复制代码
可能性一:执行器还在跑,只是比较慢
可能性二:执行器挂了,回调发不出来
可能性三:网络抖动,回调丢了

JobFlow 的策略:先巡检,再询问,最后判死刑。

巡检入口

超时巡检由最小节点负责:

graph LR A[定时巡检
每3分钟] --> B{isInspectionLeader?} B -->|否| C[跳过] B -->|是| D[查询超时分片] D --> E[分类处理] style A fill:#87CEEB style B fill:#FFE4B5 style D fill:#FFB6C1 style E fill:#90EE90

为什么由最小节点负责?

复制代码
所有实例从 Nacos 拉取实例列表
排序后,最小的那个就是 leader
不需要复杂的选举算法
最小节点挂了,下一个自动接管

代码逻辑:

java 复制代码
public void inspectTimeoutSubExecutions() {
    // 1. 判断我是不是巡检 leader
    if (!isInspectionLeader()) {
        return;
    }

    // 2. 查询超时分片(基准时间 + 超时阈值)
    List<JobSubExecution> timeoutSubs = jobRepository.findTimeoutSubExecutions(timeoutThresholdMinutes);
    
    // 3. 分类处理
    for (JobSubExecution sub : timeoutSubs) {
        if (sub.getStatus() == RUNNING) {
            handleRunningTimeoutSubExecution(sub, now);
        } else if (sub.getStatus() == PENDING) {
            handleSubExecutionForCompensation(sub, now);
        }
    }
}

三步询问策略

对于 RUNNING 状态的分片,JobFlow 不会直接判死刑,而是先问一问:

graph LR A[发现RUNNING超时] --> B{超过2倍阈值?} B -->|是| C[直接标记TIMEOUT] B -->|否| D[HTTP询问执行器] D --> E{执行器返回?} E -->|SUCCESS/FAILED| F[更新为真实状态] E -->|RUNNING| G[暂不处理] E -->|超时/异常| H[标记TIMEOUT] style A fill:#FFB6C1 style B fill:#FFE4B5 style C fill:#FF6B6B style D fill:#87CEEB style E fill:#FFE4B5 style F fill:#90EE90 style H fill:#FF6B6B

这个策略分三步:

第一步:询问执行器

向执行器发送 HTTP 请求查询状态

java 复制代码
String statusUrl = executorUrl + "/internal/job/status?traceId=" + shardTraceId;
String statusText = restTemplate.getForObject(statusUrl, String.class);

第二步:执行器应答

执行器返回真实状态

java 复制代码
// 执行器端代码
@GetMapping("/internal/job/status")
public String getStatus(@RequestParam String traceId) {
    // 从内存查询任务状态
    ExecutionStatus status = taskContext.getStatus(traceId);
    return status.name();
}

第三步:根据应答决定行动

java 复制代码
if (remoteStatus == SUCCESS || remoteStatus == FAILED) {
    // 以执行器反馈为准,更新状态
    jobRepository.updateSubExecutionResult(...);
    updateParentExecutionStatus(parentExecutionId);
} else if (remoteStatus == RUNNING) {
    // 还在跑,暂不处理
    log.info("任务仍在执行中,暂不处理");
} else {
    // 超时或异常,标记 TIMEOUT
    markSubExecutionTimeout(sub, now, "No valid response from executor");
}

为什么要这么麻烦?

因为分布式系统的不确定性:

复制代码
场景一:执行器在跑批处理,数据量特别大,确实很慢
→ 如果直接判超时,任务白跑了

场景二:执行器挂了,或者网络断了
→ 这时候确实该判超时

场景三:执行器已经执行完了,但回调失败了
→ 询问后拿到真实状态,避免重复执行

超时阈值的双重保护

JobFlow 有两层超时判断:

第一层:任务配置的超时时间

sql 复制代码
CREATE TABLE job_definition (
    ...
    timeout_seconds INT NOT NULL DEFAULT 300,  -- 默认5分钟
    ...
);

第二层:巡检阈值(2 倍超时时间)

java 复制代码
long elapsedSeconds = Duration.between(baseTime, now).getSeconds();
if (elapsedSeconds >= timeoutSeconds * 2) {
    // 超过2倍阈值,直接标记 TIMEOUT,不再询问
    markSubExecutionTimeout(sub, now, "Exceeded 2x timeout threshold");
    return;
}

这样设计的好处:

复制代码
第一次超时(5分钟):巡检发现,询问执行器
→ 如果还在跑,继续等

第二次超时(10分钟):确认已经卡死,直接判死刑
→ 避免一直询问,浪费资源

超时处理小结

超时处理的核心思路:

复制代码
不确定的时候,先问问
问了还不确定,再等等
等太久了,该死就死

这种策略在性能和可靠性之间找到了平衡:

  • 不会因为临时慢就误判
  • 也不会因为真挂了还一直等

五、分片补偿:失败了怎么重试

什么时候需要补偿?

分片补偿处理两种情况:

graph LR A[分片状态异常] --> B{什么状态?} B -->|PENDING超时| C[从未执行
重新分配] B -->|FAILED| D[执行失败
重试执行] style A fill:#FFB6C1 style B fill:#FFE4B5 style C fill:#87CEEB style D fill:#87CEEB

PENDING 超时:调度器已经创建了分片记录,但一直没分配出去

diff 复制代码
可能原因:
- 所有执行器都挂了
- 初始分配时网络异常
- 前面的分片都卡住了,后面的没机会执行

FAILED 状态:分片执行过,但失败了

diff 复制代码
可能原因:
- 业务逻辑异常
- 数据库连接超时
- 下游服务不可用

补偿逻辑

补偿的核心逻辑在 handleSubExecutionForCompensation

java 复制代码
private void handleSubExecutionForCompensation(JobSubExecution sub, LocalDateTime now) {
    // 1. 查询任务定义,获取最大重试次数
    JobDefinition jobDef = jobRepository.findJobByName(sub.getJobName());
    int maxRetry = jobDef != null ? jobDef.getMaxRetry() : 3;

    // 2. 检查是否超过最大重试次数
    if (sub.getRetryCount() >= maxRetry) {
        log.warn("分片已达最大重试次数,标记为最终失败");
        jobRepository.updateSubExecutionStatus(sub.getId(), FAILED, now, 
            "Max retry exceeded in compensation");
        updateParentExecutionStatus(sub.getParentExecutionId());
        return;
    }

    // 3. 重新分配执行
    JobExecution parent = jobRepository.findExecutionById(sub.getParentExecutionId());
    List<ServiceInstance> instances = discoveryClient.getInstances(jobDef.getServiceName());
    
    compensateSubExecution(sub, parent, jobDef, instances);
}

补偿流程:

graph LR A[发现异常分片] --> B{重试次数?} B -->|未达上限| C[CAS更新状态
PENDING to RUNNING] B -->|达到上限| D[标记最终失败] C --> E{CAS成功?} E -->|是| F[调用执行器] E -->|否| G[其他实例已处理] style A fill:#FFB6C1 style B fill:#FFE4B5 style C fill:#87CEEB style D fill:#FF6B6B style F fill:#90EE90

关键点:CAS 更新保护

java 复制代码
// 尝试 CAS 更新状态
boolean updated = jobRepository.casUpdateSubExecutionStatus(
    sub.getId(), 
    sub.getStatus(),      // 旧状态
    RUNNING,              // 新状态
    sub.getRetryCount()   // 版本号
);

if (!updated) {
    // CAS 失败,说明其他实例已经在处理了
    log.info("CAS更新失败,分片可能已被其他实例补偿");
    return;
}

为什么要用 CAS?

css 复制代码
场景:多个调度器实例同时发现同一个分片超时

实例A:CAS 更新 PENDING → RUNNING,成功
实例B:CAS 更新 PENDING → RUNNING,失败(状态已经是 RUNNING 了)

结果:只有实例A 会真正调用执行器,避免重复执行

重试策略:限次不限时

JobFlow 的重试策略很简单:

ini 复制代码
max_retry = 3  (可配置)

第1次失败:立即重试
第2次失败:立即重试
第3次失败:立即重试
第4次失败:标记最终失败,不再重试

为什么不用指数退避?

周期任务的重试和延时任务不同:

周期任务:

  • 执行窗口有限(下一轮很快就来了)
  • 希望尽快完成
  • 失败原因通常是临时性的(网络抖动、GC暂停)

延时任务:

  • 执行时间灵活
  • 可以等一等
  • 需要避免雪崩

所以周期任务用"限次不限时",延时任务用"指数退避"。

补偿的触发时机

补偿有两个触发入口:

入口一:超时巡检(前面讲过)

java 复制代码
public void inspectTimeoutSubExecutions() {
    // ...
    for (JobSubExecution sub : timeoutSubs) {
        if (sub.getStatus() == PENDING) {
            handleSubExecutionForCompensation(sub, now);
        }
    }
}

入口二:立即失败

java 复制代码
// 调用执行器失败时,立即标记 FAILED
if (!success) {
    jobRepository.updateSubExecutionStatus(sub.getId(), FAILED, now, errorMsg);
}

这样两条路径配合:

复制代码
立即失败:快速响应,不等巡检
超时巡检:兜底保护,防止遗漏

六、低频任务的漏调检测

什么是漏调?

漏调是指:任务本该执行,但整轮都没触发。

复制代码
示例:每天凌晨2点的对账任务

正常情况:
12月20日 02:00:00 → 执行
12月21日 02:00:00 → 执行
12月22日 02:00:00 → 执行

漏调情况:
12月20日 02:00:00 → 执行
12月21日 02:00:00 → 没执行(所有调度器都挂了)
12月22日 02:00:00 → 执行

问题:21号的对账任务漏了

对于高频任务(分钟级),漏调影响较小,很快就有下一轮。

但对于低频任务(小时级、天级),漏调可能导致严重后果:

复制代码
对账任务漏调:财务数据不准
报表生成漏调:业务决策延误
数据清理漏调:磁盘空间耗尽

怎么检测漏调?

JobFlow 的思路:时间窗口 + 执行记录

graph LR A[计算Cron周期] --> B{周期大于等于1小时?} B -->|否| C[高频任务
跳过] B -->|是| D[计算上一轮时间窗口] D --> E{窗口内有执行记录?} E -->|是| F[未漏调] E -->|否| G[触发补偿] style A fill:#87CEEB style B fill:#FFE4B5 style C fill:#D3D3D3 style D fill:#87CEEB style E fill:#FFE4B5 style G fill:#90EE90 style G fill:#90EE90

核心逻辑:

java 复制代码
// 1. 解析 Cron,计算周期
CronExpression cronExpression = CronExpression.parse(cron);
LocalDateTime next1 = cronExpression.next(now.minusSeconds(1));
LocalDateTime next2 = cronExpression.next(next1);
Duration period = Duration.between(next1, next2);

// 2. 过滤:只检测周期 ≥ 1 小时的任务
if (period == null || period.toMinutes() < 60) {
    return;  // 高频任务不检测
}

// 3. 计算上一轮的时间窗口
LocalDateTime lastSlotTime = next1.minus(period);
LocalDateTime windowStart = lastSlotTime;
LocalDateTime windowEnd = now;

// 4. 查询窗口内是否有执行记录
boolean exists = jobRepository.existsExecutionInWindow(
    job.getName(), windowStart, windowEnd);

// 5. 如果没有记录,触发补偿
if (!exists) {
    log.info("检测到低频任务漏调,触发补偿,jobName={}, window=[{}, {}]",
        job.getName(), windowStart, windowEnd);
    triggerExecution(job, now);
}

SQL 查询:

sql 复制代码
SELECT COUNT(1) FROM job_execution 
WHERE job_name = ? 
  AND trigger_time >= ? 
  AND trigger_time < ?

时间窗口示例

以"每天凌晨2点"的任务为例:

ini 复制代码
当前时间:2024-12-22 10:00:00

计算过程:
1. next1 = 2024-12-23 02:00:00(下一个触发时间)
2. next2 = 2024-12-24 02:00:00(再下一个触发时间)
3. period = 24小时
4. lastSlotTime = 2024-12-23 02:00:00 - 24小时 = 2024-12-22 02:00:00

时间窗口:
windowStart = 2024-12-22 02:00:00
windowEnd = 2024-12-22 10:00:00(当前时间)

判断:
如果窗口内有执行记录 → 未漏调
如果窗口内没有执行记录 → 漏调了,补偿一次

为什么只检测低频任务?

因为高频任务的漏调影响小:

diff 复制代码
分钟级任务:
- 漏调一次,1分钟后就有下一轮
- 业务影响小
- 容易被监控发现

小时级任务:
- 漏调一次,要等1小时
- 可能影响业务
- 不容易被发现

天级任务:
- 漏调一次,要等1天
- 严重影响业务
- 很难被发现

所以 JobFlow 的策略是:

复制代码
高频任务(< 1小时):不检测,靠监控
低频任务(≥ 1小时):主动检测,自动补偿

漏调检测的触发频率

漏调检测不需要太频繁:

java 复制代码
@Scheduled(fixedDelay = 600000)  // 10分钟一次
public void compensateLowFrequencyMisfires() {
    if (!clusterInstanceService.shouldRunInspection()) {
        return;  // 非巡检leader,跳过
    }
    
    List<JobDefinition> jobs = jobRepository.findAllEnabledJobs();
    for (JobDefinition job : jobs) {
        compensateIfLowFrequencyAndMissed(job, LocalDateTime.now());
    }
}

为什么10分钟一次就够了?

复制代码
对于小时级任务:10分钟检测一次,足够及时
对于天级任务:10分钟检测一次,更够了
对于分钟级任务:不检测,等下一轮就好

七、延时任务的超时与重试

延时任务和周期任务不同,它是一次性的:

复制代码
周期任务:每天凌晨2点执行
延时任务:30分钟后执行一次

延时任务的状态机

延时任务的状态流转:

graph LR A[PENDING
等待中] --> B[SENDING
调用中] B --> C[SENT
已发送] B --> D[FAILED
失败] D --> E{重试次数?} E -->|未达上限| A E -->|达到上限| F[FAILED_FINAL
最终失败] style A fill:#87CEEB style B fill:#FFE4B5 style C fill:#90EE90 style D fill:#FFB6C1 style F fill:#FF6B6B

核心字段:

sql 复制代码
CREATE TABLE job_delay_task (
    id BIGINT PRIMARY KEY,
    execute_time TIMESTAMP,      -- 预期执行时间
    next_attempt_time TIMESTAMP, -- 下次重试时间
    status VARCHAR(20),          -- PENDING/SENDING/SENT/FAILED/FAILED_FINAL
    retry_count INT DEFAULT 0,   -- 重试次数
    max_retry INT DEFAULT 3,     -- 最大重试次数
    ...
);

调度流程

延时任务的调度逻辑:

graph LR A[扫描到期任务] --> B{isOwner?} B -->|否| C[跳过] B -->|是| D[CAS更新
PENDING to SENDING] D --> E{CAS成功?} E -->|否| F[其他实例处理] E -->|是| G[HTTP调用执行器] G --> H{成功?} H -->|是| I[标记SENT] H -->|否| J[重试逻辑] style A fill:#87CEEB style B fill:#FFE4B5 style D fill:#87CEEB style G fill:#FFE4B5 style I fill:#90EE90 style J fill:#FFB6C1

关键代码:

java 复制代码
// 1. Owner 判定(按 serviceName 分区)
if (!clusterInstanceService.isOwner(task.getServiceName())) {
    return;
}

// 2. CAS 抢占
boolean locked = delayTaskRepository.tryMarkSending(
    task.getId(), task.getStatus(), task.getRetryCount());
if (!locked) {
    return;  // 被其他实例抢了
}

// 3. HTTP 调用
boolean success = callExecutor(task);

// 4. 更新状态
if (success) {
    delayTaskRepository.markSent(task.getId(), LocalDateTime.now());
} else {
    handleCallFailure(task, errorMsg);
}

重试策略:指数退避

延时任务的重试用指数退避:

java 复制代码
private void handleCallFailure(JobDelayTask task, String errorMsg) {
    int newRetryCount = task.getRetryCount() + 1;
    
    if (newRetryCount >= task.getMaxRetry()) {
        // 标记最终失败
        delayTaskRepository.markFailedFinal(task.getId(), newRetryCount, errorMsg);
        return;
    }
    
    // 计算下次重试时间
    LocalDateTime nextAttempt;
    if (newRetryCount == 1) {
        nextAttempt = now.plusMinutes(3);   // 第1次:3分钟后
    } else if (newRetryCount == 2) {
        nextAttempt = now.plusMinutes(5);   // 第2次:5分钟后
    } else {
        nextAttempt = now.plusMinutes(5);   // 第3次:5分钟后
    }
    
    delayTaskRepository.markFailedAndScheduleNext(
        task.getId(), newRetryCount, nextAttempt, errorMsg);
}

重试时间轴:

ini 复制代码
初始执行:execute_time = 10:00:00
第1次失败:next_attempt_time = 10:03:00(+3分钟)
第2次失败:next_attempt_time = 10:08:00(+5分钟)
第3次失败:next_attempt_time = 10:13:00(+5分钟)
第4次失败:标记 FAILED_FINAL

SENDING 超时检测

延时任务还有个特殊场景:调用执行器后,一直没响应。

这时候数据库里的状态是 SENDING,需要有个超时检测:

java 复制代码
// 扫描 SENDING 超时任务
List<JobDelayTask> stuckTasks = delayTaskRepository.findStuckSendingTasks(
    sendingTimeoutSeconds, scanBatchSize);

for (JobDelayTask task : stuckTasks) {
    handleSendingTimeout(task);
}
java 复制代码
private void handleSendingTimeout(JobDelayTask task) {
    String timeoutMsg = "SENDING timeout (over " + sendingTimeoutSeconds + " seconds)";
    log.warn("检测到SENDING超时,视为失败,traceId={}", task.getTraceId());
    handleCallFailure(task, timeoutMsg);
}

查询 SQL:

sql 复制代码
SELECT * FROM job_delay_task 
WHERE status = 'SENDING' 
  AND sent_at < NOW() - INTERVAL ? SECOND
LIMIT ?

为什么要这样设计?

diff 复制代码
场景:调用执行器后,执行器挂了

结果:
- 回调不会来
- 状态一直是 SENDING
- 需要超时检测来兜底

处理:
- 视为一次失败
- 进入重试流程
- 最多重试3次

八、巡检任务的协调:谁来做?

前面讲了三种巡检:

  • 超时巡检(分片超时)
  • 分片补偿(分片失败)
  • 漏调检测(低频任务)

这些巡检都需要有人做,但不能所有实例都做(会重复)。

选主策略:最小节点

JobFlow 的选主很简单:

graph LR A[从Nacos获取
所有调度器实例] --> B[按IP:PORT排序] B --> C{我是最小的?} C -->|是| D[我是Leader
负责巡检] C -->|否| E[我是Follower
跳过巡检] style A fill:#87CEEB style B fill:#87CEEB style C fill:#FFE4B5 style D fill:#90EE90 style E fill:#D3D3D3

代码实现:

java 复制代码
public boolean shouldRunInspection() {
    List<ServiceInstance> instances = discoveryClient.getInstances("jobflow-scheduler");
    if (instances == null || instances.isEmpty()) {
        return false;
    }
    
    // 排序
    instances.sort(Comparator.comparing(i -> i.getHost() + ":" + i.getPort()));
    
    // 判断我是不是最小的
    String smallest = instances.get(0).getHost() + ":" + instances.get(0).getPort();
    String myself = localHost + ":" + localPort;
    
    return smallest.equals(myself);
}

为什么选最小节点?

优点:

  • 简单:不需要复杂的选举算法
  • 确定:所有实例看到的排序结果一致
  • 快速:最小节点挂了,下一个立刻接管

缺点:

  • 负载不均:所有巡检都在一个节点上

但对于巡检任务来说,负载很小,这个缺点可以接受。

巡检频率

不同巡检的频率不同:

diff 复制代码
超时巡检:3分钟一次
- 需要及时发现卡住的任务
- 但也不能太频繁,避免打爆执行器

分片补偿:随超时巡检触发
- 和超时巡检是一起的

漏调检测:10分钟一次
- 低频任务本来就慢,不需要太频繁
- 降低数据库压力

九、设计权衡与取舍

回顾一下 JobFlow 在超时、补偿、漏调这三个问题上的设计权衡:

权衡一:询问 vs 直接判死刑

方案A:直接判死刑

  • 优点:简单快速
  • 缺点:误杀正常任务

方案B:先询问执行器(JobFlow 的选择)

  • 优点:准确性高,避免误判
  • 缺点:增加一次 HTTP 调用

JobFlow 选择了方案B,因为:

diff 复制代码
对于批处理任务:
- 数据量大,确实很慢
- 误判成本高(任务白跑了)
- 多一次 HTTP 询问,成本可接受

权衡二:立即重试 vs 指数退避

周期任务:立即重试

  • 原因:执行窗口有限
  • 目标:尽快完成

延时任务:指数退避

  • 原因:时间灵活
  • 目标:避免雪崩

这是根据场景特点做的差异化设计。

权衡三:全量检测 vs 分层检测

方案A:所有任务都检测漏调

  • 优点:覆盖全面
  • 缺点:数据库压力大

方案B:只检测低频任务(JobFlow 的选择)

  • 优点:性价比高
  • 缺点:高频任务漏调不管

JobFlow 选择了方案B,因为:

diff 复制代码
高频任务漏调影响小:
- 1分钟后就有下一轮
- 容易被监控发现
- 不需要主动检测

低频任务漏调影响大:
- 可能要等很久
- 不容易被发现
- 需要主动检测

权衡四:所有实例巡检 vs 单实例巡检

方案A:所有实例都巡检

  • 优点:分布式,负载均衡
  • 缺点:容易重复,需要分布式锁

方案B:最小节点巡检(JobFlow 的选择)

  • 优点:简单,无需锁
  • 缺点:单点,负载集中

JobFlow 选择了方案B,因为:

diff 复制代码
巡检负载很小:
- 3分钟一次,10分钟一次
- 每次扫描几百个任务
- 单实例完全扛得住

简单优先:
- 不需要分布式锁
- 不需要复杂的协调
- 最小节点挂了,自动切换

十、总结

JobFlow 处理超时、补偿、漏调的核心思路:

正常路径优先

  • 99% 的任务走正常路径
  • 无锁调度,性能好
  • 回调驱动,响应快

异常路径兜底

  • 超时巡检:询问执行器确认真实状态
  • 分片补偿:CAS 保护,限次重试
  • 漏调检测:时间窗口,主动补偿

设计原则

markdown 复制代码
1. 简单优于复杂
   - 最小节点选主,而不是 Raft 选举
   - CAS 保护,而不是分布式锁

2. 分层优于全量
   - 只检测低频任务漏调
   - 超时判定分两层

3. 询问优于猜测
   - 先问执行器,再判超时
   - 准确性优于性能

4. 兜底优于完美
   - 接受1%的漏调,用巡检兜底
   - 接受短暂的状态不一致

核心价值

JobFlow 没有追求完美的一致性,而是用异步补偿换取了:

  • 更好的性能(无锁调度)
  • 更低的复杂度(无需分布式锁)
  • 更好的可维护性(逻辑清晰)

这就是 JobFlow 在分布式调度这个领域的核心竞争力:在性能和可靠性之间找到最优平衡点。

相关推荐
Postkarte不想说话2 小时前
ElasticSearch操作系统环境设置
后端
i听风逝夜2 小时前
Gradle秒级打包部署SpringBoot项目,行云流水
后端
白帽黑客-晨哥2 小时前
Web安全方向的面试通常会重点考察哪些漏洞和防御方案?
安全·web安全·面试·职场和发展·渗透测试
why技术2 小时前
如果让我站在科技从业者的角度去回看 2025 年,让我选一个词出来形容它,我会选择“vibe coding”这个词。
前端·后端·程序员
喵个咪3 小时前
Go单协程事件调度器:游戏后端的无锁有序与响应时间掌控
后端·游戏开发
Kiyra3 小时前
八股篇(1):LocalThread、CAS和AQS
java·开发语言·spring boot·后端·中间件·性能优化·rocketmq
开心比对错重要3 小时前
进程、线程、虚拟线程详解及线程个数设置
java·jvm·算法·面试
木风小助理3 小时前
在 Spring Boot 中实现 JSON 字段的蛇形命
spring boot·后端·json
William_cl3 小时前
【保姆级】ASP.NET Razor 视图引擎:@if/@foreach 核心语法拆解(附避坑指南 + 生活类比)
后端·asp.net·生活