开源地址与系列文章
- 开源地址 :
https://gitee.com/sh_wangwanbao/job-flow - 系列文章:
前言
前面几篇文章讲了周期任务的调度,但在实际业务中,还有另一种常见需求:
用户下单后30分钟未支付 → 自动取消订单
支付成功后15分钟 → 发送确认短信
会员到期前3天 → 发送续费提醒
这些都不是"每天凌晨2点"这种固定周期,而是运行时动态指定的延时任务。
JobFlow 的延时调度就是用来解决这个问题的。
这篇文章就来讲讲延时调度是怎么设计的。
一、延时调度 vs 定时任务
先看看两者的区别:
Cron表达式] --> B[每天凌晨2点
周期执行] C[延时任务
指定时间点] --> D[30分钟后
执行一次] style A fill:#87CEEB style B fill:#90EE90 style C fill:#FFB6C1 style D fill:#FFE4B5
定时任务:
- 配置:在数据库里配好 Cron 表达式
- 触发:调度器按 Cron 周期性触发
- 场景:报表生成、数据同步、对账
延时任务:
- 配置:运行时通过 API 动态创建
- 触发:到指定时间点执行一次
- 场景:订单超时、消息延迟、定时提醒
对比表格:
| 特性 | 定时任务 | 延时任务 |
|---|---|---|
| 触发方式 | 周期性(Cron) | 一次性(时间点) |
| 创建方式 | 数据库配置 | API 动态调用 |
| 重试机制 | 需自己实现 | 内置指数退避 |
| 幂等性 | 需自己保证 | 框架层面保障 |
二、核心设计
数据模型
延时任务存在 job_delay_task 表:
sql
CREATE TABLE job_delay_task (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
trace_id VARCHAR(64) NOT NULL UNIQUE, -- 链路追踪
biz_uuid VARCHAR(64) NOT NULL UNIQUE, -- 业务幂等ID
service_name VARCHAR(100) NOT NULL, -- 目标服务
handler VARCHAR(100) NOT NULL, -- Handler名称
execute_time TIMESTAMP NOT NULL, -- 期望执行时间
next_attempt_time TIMESTAMP, -- 下次尝试时间
payload_json TEXT, -- 业务参数
status VARCHAR(20) NOT NULL, -- 状态
retry_count INT DEFAULT 0, -- 重试次数
max_retry INT DEFAULT 3, -- 最大重试
...
);
关键字段:
业务唯一ID] --> B[幂等保证
防重复提交] C[next_attempt_time
下次尝试时间] --> D[扫描依据
决定何时执行] E[status
任务状态] --> F[状态机
驱动流转] style A fill:#87CEEB style C fill:#FFE4B5 style E fill:#FFB6C1 style B fill:#90EE90 style D fill:#90EE90 style F fill:#90EE90
biz_uuid:业务方传入的唯一ID,用于幂等
- 示例:
order-timeout-12345 - 唯一约束:重复提交返回已有记录
next_attempt_time:核心字段,决定什么时候执行
- 初始值:
execute_time(业务期望时间) - 失败后:按指数退避延后(+3分钟、+5分钟)
status:状态机流转
- PENDING:等待执行
- SENDING:正在调用
- SENT:执行成功
- FAILED:失败(可重试或最终失败)
状态机
等待中] --> B[SENDING
调用中] B --> C[SENT
已完成] B --> D[FAILED
失败] D --> E{重试次数?} E -->|未达上限| A E -->|达到上限| F[FAILED
最终失败] style A fill:#87CEEB style B fill:#FFE4B5 style C fill:#90EE90 style D fill:#FFB6C1 style F fill:#FF6B6B
正常流程:
objectivec
创建任务 → PENDING
↓
时间到了 → SENDING(CAS抢占)
↓
HTTP调用 → SENT
失败重试:
ini
HTTP调用失败 → FAILED
↓
retry_count < max_retry → 回到 PENDING
↓
next_attempt_time = now + 指数退避时间
↓
等待下次扫描
最终失败:
retry_count >= max_retry → 标记为最终失败
→ 不再重试
→ 需要人工介入
三、创建延时任务
API调用
业务方通过HTTP接口创建延时任务:
http
POST /api/delay-tasks
Content-Type: application/json
{
"serviceName": "order-service",
"handler": "orderTimeoutHandler",
"executeTime": "2025-12-28T16:00:00",
"bizUuid": "order-timeout-12345",
"payloadJson": "{\"orderId\":\"12345\"}"
}
参数说明:
order-service] --> B[目标服务
从Nacos发现] C[handler
orderTimeoutHandler] --> D[Handler名称
执行器实现] E[executeTime
2025-12-28 16:00] --> F[期望执行时间
到时触发] G[bizUuid
order-timeout-12345] --> H[幂等ID
防止重复] style A fill:#87CEEB style C fill:#87CEEB style E fill:#FFE4B5 style G fill:#FFB6C1 style B fill:#90EE90 style D fill:#90EE90 style F fill:#90EE90 style H fill:#90EE90
处理流程
已存在?} D -->|是| E[返回已有记录
幂等] D -->|否| F[插入数据库
返回新记录] style A fill:#87CEEB style B fill:#87CEEB style C fill:#87CEEB style D fill:#FFE4B5 style E fill:#90EE90 style F fill:#90EE90
核心代码:
java
// 1. 生成 traceId
String traceId = "delay_time-" + DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
.format(LocalDateTime.now()) + "-" + shortUuid();
// 2. 封装实体
JobDelayTask task = new JobDelayTask();
task.setTraceId(traceId);
task.setBizUuid(request.getBizUuid());
task.setServiceName(request.getServiceName());
task.setHandler(request.getHandler());
task.setExecuteTime(request.getExecuteTime());
task.setNextAttemptTime(request.getExecuteTime()); // 初始值
task.setPayloadJson(request.getPayloadJson());
task.setStatus("PENDING");
task.setRetryCount(0);
task.setMaxRetry(3);
// 3. 插入或获取
try {
return delayTaskRepository.insert(task);
} catch (DuplicateKeyException e) {
// bizUuid 冲突,返回已有记录(幂等)
return delayTaskRepository.findByBizUuid(request.getBizUuid());
}
幂等性保证:
arduino
业务方重复提交:
POST /api/delay-tasks(bizUuid: order-timeout-12345)
→ 第1次:插入成功,返回新记录
→ 第2次:唯一约束冲突,返回已有记录
→ 业务方得到相同的 traceId
四、扫描与调度
定时扫描
调度器每5秒扫描一次:
java
@Scheduled(fixedDelay = 5000)
public void scanAndDispatch() {
// 1. 扫描到期任务
List<JobDelayTask> dueTasks = findDueTasks();
for (JobDelayTask task : dueTasks) {
dispatchDelayTask(task);
}
// 2. 扫描 SENDING 超时任务
List<JobDelayTask> stuckTasks = findStuckSendingTasks();
for (JobDelayTask task : stuckTasks) {
handleSendingTimeout(task);
}
}
扫描逻辑:
5秒一次] --> B[查询到期任务
PENDING/FAILED] A --> C[查询超时任务
SENDING] B --> D[逐个调度] C --> E[标记失败
进入重试] style A fill:#87CEEB style B fill:#FFE4B5 style C fill:#FFB6C1 style D fill:#90EE90 style E fill:#FFE4B5
到期任务查询:
sql
SELECT * FROM job_delay_task
WHERE status IN ('PENDING', 'FAILED')
AND retry_count < max_retry
AND next_attempt_time <= NOW()
ORDER BY next_attempt_time ASC
LIMIT 100
超时任务查询:
sql
SELECT * FROM job_delay_task
WHERE status = 'SENDING'
AND TIMESTAMPDIFF(SECOND, updated_at, NOW()) > 120
LIMIT 100
无锁调度
调度分三步:Owner判定 → CAS抢占 → HTTP调用
Hash分区} B -->|否| C[跳过] B -->|是| D[CAS抢占
PENDING to SENDING] D --> E{CAS成功?} E -->|否| F[其他实例抢到] E -->|是| G[HTTP调用执行器] style A fill:#87CEEB style B fill:#FFE4B5 style D fill:#87CEEB style E fill:#FFE4B5 style G fill:#90EE90 style C fill:#D3D3D3 style F fill:#D3D3D3
第一步:Owner判定
java
// 基于 serviceName 做 Hash 分区
if (!clusterInstanceService.isOwner(task.getServiceName())) {
return; // 不是我负责,跳过
}
原理:
ini
有3个调度器实例:
- scheduler-001
- scheduler-002
- scheduler-003
任务的 serviceName = "order-service"
→ hash("order-service") % 3 = 1
→ scheduler-002 负责
→ 其他实例跳过
第二步:CAS抢占
java
// CAS 更新状态
boolean success = tryMarkSending(
task.getId(),
task.getStatus(), // 期望状态:PENDING
task.getRetryCount() // 期望重试次数:0
);
if (!success) {
return; // CAS 失败,其他实例抢到了
}
SQL实现:
sql
UPDATE job_delay_task
SET status = 'SENDING', updated_at = NOW()
WHERE id = ?
AND status = ? -- 期望状态匹配
AND retry_count = ? -- 期望重试次数匹配
返回 affected_rows > 0 表示抢占成功。
为什么需要CAS?
diff
极端情况:Owner判定重叠
- 实例列表变化,两个实例都认为自己是 owner
- 同时更新同一条任务
CAS保证:
- 只有一个实例的 UPDATE 会成功
- 另一个实例的 UPDATE 返回 affected_rows = 0
- 最终只有一个实例执行
第三步:HTTP调用
java
// 1. 负载均衡选择实例
List<ServiceInstance> instances = discoveryClient.getInstances(task.getServiceName());
int index = Math.abs(task.getTraceId().hashCode()) % instances.size();
ServiceInstance instance = instances.get(index);
// 2. 构造请求
String url = instance.getUri() + "/internal/job/" + task.getHandler();
HttpHeaders headers = new HttpHeaders();
headers.add("X-Trace-Id", task.getTraceId());
headers.add("X-Biz-UUID", task.getBizUuid());
// 3. 发送请求
HttpEntity<String> entity = new HttpEntity<>(task.getPayloadJson(), headers);
ResponseEntity<String> response = restTemplate.exchange(
url, HttpMethod.POST, entity, String.class);
// 4. 更新状态
if (response.getStatusCode().is2xxSuccessful()) {
markSent(task.getId()); // 标记 SENT
} else {
handleCallFailure(task); // 进入重试
}
五、重试机制
指数退避策略
失败后按指数退避重试:
retry=0] --> B[+3分钟
retry=1] B --> C[第2次失败
retry=1] C --> D[+5分钟
retry=2] D --> E[第3次失败
retry=2] E --> F[+5分钟
retry=3] F --> G[最终失败
不再重试] style A fill:#FFB6C1 style C fill:#FFB6C1 style E fill:#FFB6C1 style G fill:#FF6B6B style B fill:#FFE4B5 style D fill:#FFE4B5 style F fill:#FFE4B5
代码逻辑:
java
private void handleCallFailure(JobDelayTask task, String errorMsg) {
int newRetryCount = task.getRetryCount() + 1;
if (newRetryCount >= task.getMaxRetry()) {
// 达到最大重试次数,标记最终失败
markFailedFinal(task.getId(), newRetryCount, errorMsg);
log.warn("延时任务最终失败,traceId={}, bizUuid={}",
task.getTraceId(), task.getBizUuid());
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分钟
}
// 更新为 FAILED 状态,设置 next_attempt_time
markFailedAndScheduleNext(task.getId(), newRetryCount, nextAttempt, errorMsg);
}
时间轴示例:
makefile
10:00:00 创建任务,execute_time = 10:30:00
10:30:00 第1次执行,失败
10:33:00 第2次执行(+3分钟),失败
10:38:00 第3次执行(+5分钟),失败
10:43:00 第4次执行(+5分钟),失败
达到max_retry=3,标记最终失败
SENDING超时检测
有个特殊场景:HTTP调用后,一直没响应。
进入重试] style A fill:#FFE4B5 style B fill:#FFE4B5 style D fill:#FFB6C1 style C fill:#87CEEB
处理逻辑:
java
private void handleSendingTimeout(JobDelayTask task) {
String timeoutMsg = "SENDING timeout (over 120 seconds)";
log.warn("检测到SENDING超时,traceId={}, bizUuid={}",
task.getTraceId(), task.getBizUuid());
// 视为一次失败,进入重试流程
handleCallFailure(task, timeoutMsg);
}
为什么需要这个检测?
场景:HTTP调用后,执行器挂了
→ 回调不会来
→ 状态一直是 SENDING
→ 没有超时检测,任务就丢了
有了检测:
→ 120秒后发现 SENDING 超时
→ 标记失败,进入重试
→ 最多重试3次
六、业务侧怎么用
执行器实现
业务服务只需要实现一个 Handler:
java
@Component
@JobHandler("orderTimeoutHandler")
public class OrderTimeoutHandler implements IJobHandler {
@Override
public JobResult execute(JobContext context) {
// 1. 获取 traceId(日志追踪)
String traceId = context.getTraceId();
MDC.put("traceId", traceId);
try {
// 2. 解析 payload
String payload = context.getPayload();
JSONObject json = JSON.parseObject(payload);
String orderId = json.getString("orderId");
// 3. 幂等判断(重要!)
Order order = orderRepository.findById(orderId);
if (order.getStatus() != OrderStatus.UNPAID) {
log.info("订单状态已变更,跳过取消,orderId={}", orderId);
return JobResult.success("Order already paid");
}
// 4. 执行业务逻辑
order.setStatus(OrderStatus.CANCELED);
order.setCancelReason("Payment timeout");
orderRepository.save(order);
log.info("订单超时自动取消,orderId={}", orderId);
return JobResult.success("Order canceled");
} catch (Exception e) {
log.error("取消订单失败", e);
return JobResult.fail("Cancel failed: " + e.getMessage());
} finally {
MDC.clear();
}
}
}
关键点:
关键点一:幂等判断
kotlin
可能重复执行的场景:
- 框架重试(失败后重试3次)
- 业务方重复提交(相同bizUuid)
必须在业务逻辑里判断:
if (order.getStatus() != UNPAID) {
return success; // 已经处理过了
}
关键点二:traceId传递
css
调度器生成 traceId
→ HTTP Header 传递给执行器
→ 执行器写入 MDC
→ 所有日志自动带上 traceId
→ ELK 里搜 traceId,看到完整链路
关键点三:明确返回结果
kotlin
成功:return JobResult.success("Order canceled");
失败:return JobResult.fail("Cancel failed: " + e.getMessage());
→ 调度器根据返回结果决定:标记 SENT 还是进入重试
完整示例
订单超时取消的完整流程:
10:00:00] --> B[创建延时任务
executeTime=10:30] B --> C[调度器扫描
10:30:00] C --> D[HTTP调用
orderTimeoutHandler] D --> E[检查订单状态] E --> F{已支付?} F -->|是| G[跳过取消
返回SUCCESS] F -->|否| H[取消订单
返回SUCCESS] G --> I[标记SENT] H --> I style A fill:#87CEEB style B fill:#87CEEB style C fill:#FFE4B5 style D fill:#FFE4B5 style E fill:#FFE4B5 style F fill:#FFE4B5 style G fill:#90EE90 style H fill:#90EE90 style I fill:#90EE90
代码实现:
java
// 1. 用户下单时创建延时任务
public Order createOrder(OrderRequest request) {
// 创建订单
Order order = new Order();
order.setId(generateOrderId());
order.setStatus(OrderStatus.UNPAID);
order.setCreatedAt(LocalDateTime.now());
orderRepository.save(order);
// 创建延时任务(30分钟后自动取消)
DelayTaskRequest delayTask = new DelayTaskRequest();
delayTask.setServiceName("order-service");
delayTask.setHandler("orderTimeoutHandler");
delayTask.setExecuteTime(LocalDateTime.now().plusMinutes(30));
delayTask.setBizUuid("order-timeout-" + order.getId());
delayTask.setPayloadJson(JSON.toJSONString(
Map.of("orderId", order.getId())
));
delayTaskClient.createDelayTask(delayTask);
return order;
}
// 2. 执行器处理超时
@JobHandler("orderTimeoutHandler")
public JobResult orderTimeout(JobContext context) {
String orderId = JSON.parseObject(context.getPayload())
.getString("orderId");
Order order = orderRepository.findById(orderId);
// 幂等判断
if (order.getStatus() != OrderStatus.UNPAID) {
return JobResult.success("Order already paid or canceled");
}
// 取消订单
order.setStatus(OrderStatus.CANCELED);
order.setCancelReason("Payment timeout");
orderRepository.save(order);
return JobResult.success("Order canceled");
}
七、设计要点
为什么用 serviceName 做 Hash 分区
延时任务的 owner 判定是按 serviceName 分区的,而不是按 taskId:
java
// 延时任务
if (!clusterInstanceService.isOwner(task.getServiceName())) {
return;
}
// 周期任务
if (!clusterInstanceService.isOwner(job.getName())) {
return;
}
为什么不一样?
diff
周期任务:
- 任务是固定的(配置在数据库)
- jobName 是稳定的
- 按 jobName 分区,每个任务有固定的 owner
延时任务:
- 任务是动态的(运行时创建)
- taskId 每次都不一样
- 按 taskId 分区没意义
所以:
- 按 serviceName 分区
- 同一个服务的延时任务,由同一个调度器处理
- 减少网络开销
为什么需要双重保护
Owner判定 + CAS抢占,两层保护:
objectivec
第一层:Owner判定(Hash分区)
→ 绝大多数情况避免冲突
→ 性能好,无需锁
第二层:CAS抢占(数据库乐观锁)
→ 极端情况的最后防线
→ 即使 owner 判定重叠,也能保证只有一个实例执行
这和周期任务的设计是一样的:无锁优先,数据库兜底。
为什么 SENDING 需要超时检测
状态机里最容易卡住的就是 SENDING 状态:
正常流程:
PENDING → SENDING → SENT(成功)
PENDING → SENDING → FAILED(失败,重试)
卡住场景:
PENDING → SENDING → ?(执行器挂了,没回调)
没有超时检测:
任务一直 SENDING
→ 不会重试
→ 不会标记失败
→ 任务丢了
有了超时检测:
120秒后发现 SENDING 超时
→ 标记失败
→ 进入重试流程
→ 最多重试3次
八、监控与运维
关键指标
需要关注的指标:
正常] A --> C[持续很多
积压] D[SENDING超时数] --> E[偶尔几个
正常] D --> F[大量超时
执行器异常] G[最终失败数] --> H[需要人工
介入] style A fill:#87CEEB style B fill:#90EE90 style C fill:#FF6B6B style D fill:#87CEEB style E fill:#90EE90 style F fill:#FF6B6B style G fill:#FFB6C1 style H fill:#FFE4B5
SQL查询:
sql
-- 查询最终失败的任务(需人工介入)
SELECT * FROM job_delay_task
WHERE status = 'FAILED'
AND retry_count >= max_retry
ORDER BY updated_at DESC;
-- 查询积压任务
SELECT COUNT(*) FROM job_delay_task
WHERE status IN ('PENDING', 'FAILED')
AND next_attempt_time <= NOW();
-- 查询 SENDING 超时任务
SELECT COUNT(*) FROM job_delay_task
WHERE status = 'SENDING'
AND TIMESTAMPDIFF(SECOND, updated_at, NOW()) > 120;
常见问题
问题一:任务不执行
sql
-- 1. 查看任务状态
SELECT * FROM job_delay_task WHERE biz_uuid = 'xxx';
-- 2. 检查是否到期
SELECT next_attempt_time, NOW() FROM job_delay_task WHERE id = xxx;
-- 3. 检查重试次数
SELECT retry_count, max_retry FROM job_delay_task WHERE id = xxx;
可能原因:
next_attempt_time未到期status = 'SENT'已完成retry_count >= max_retry已失败- 调度器未启动
问题二:任务重复执行
检查是否有重复的 bizUuid:
sql
SELECT biz_uuid, COUNT(*) FROM job_delay_task
GROUP BY biz_uuid
HAVING COUNT(*) > 1;
原因:
- 业务方未正确设置唯一的
bizUuid - 执行器 Handler 未实现幂等
解决:
- 使用业务唯一ID作为
bizUuid(如order-timeout-{orderId}) - Handler 内部增加幂等判断
九、最佳实践
bizUuid 设计
推荐做法:
java
// 使用业务唯一ID
String bizUuid = "order-timeout-" + orderId;
String bizUuid = "payment-remind-" + userId + "-" + paymentId;
不推荐:
java
// 使用时间戳(不唯一)
String bizUuid = "task-" + System.currentTimeMillis();
// 使用随机UUID(无法幂等)
String bizUuid = UUID.randomUUID().toString();
executeTime 设置
推荐做法:
java
// 基于当前时间计算
LocalDateTime executeTime = LocalDateTime.now().plusMinutes(30);
// 基于业务时间计算
LocalDateTime executeTime = order.getCreatedAt().plusMinutes(30);
不推荐:
java
// 过去的时间(会立即执行)
LocalDateTime executeTime = LocalDateTime.now().minusMinutes(10);
Payload 设计
推荐做法:
json
{
"orderId": "12345",
"action": "timeout-cancel"
}
不推荐:
json
// 包含敏感信息
{
"orderId": "12345",
"password": "123456",
"bankCard": "6222..."
}
// 数据过大
{
"orderDetail": "超过1MB的JSON..."
}
建议:
- Payload 只存必要的业务标识
- 执行器内部查询完整数据
- 避免存储敏感信息
十、总结
JobFlow 的延时调度通过以下设计实现了高可用、高性能:
核心特性:
Hash+CAS] --> B[性能好
无需分布式锁] C[自动重试
指数退避] --> D[可靠性高
最多3次] E[幂等保障
bizUuid] --> F[防重复
唯一约束] style A fill:#87CEEB style C fill:#FFE4B5 style E fill:#FFB6C1 style B fill:#90EE90 style D fill:#90EE90 style F fill:#90EE90
适用场景:
- 订单超时处理
- 定时提醒通知
- 延迟消息发送
- 异步回调重试
核心优势:
- 无需额外中间件(如 RabbitMQ 延迟队列)
- 复用 Nacos 服务发现,架构简洁
- 基于 MySQL 存储,可靠性高
- 分布式调度器,高可用
和定时任务的关系:
diff
定时任务:周期性执行,配置固定
延时任务:一次性执行,动态创建
两者互补:
- 定时任务处理周期性业务
- 延时任务处理事件驱动的业务
JobFlow 通过统一的架构、统一的 traceId、统一的执行器接口,把这两种调度需求优雅地整合在一起。
这就是 JobFlow 延时调度的设计和实现。