JobFlow:时间轮与滑动窗口的实战优化

开源地址与系列文章

前言

延时调度上线后,我们发现了几个问题:

复制代码
用户期望:订单30分钟后自动取消
实际执行:30分01秒 到 30分05秒之间
误差:最多5秒

数据库压力:每5秒扫描一次
日志写入:每个任务立即写入

对于秒杀、限时优惠这种场景,5秒的延迟是不可接受的。

这篇文章就来讲讲我们是怎么优化的:用时间轮把延迟降到1秒以内,用滑动窗口把数据库压力降低95%。

一、问题在哪

先看看原来的方案有什么问题。

定时扫描的延迟

原来的逻辑:每5秒扫描一次数据库

graph LR A[任务创建
10:00:00] --> B[写入数据库
executeTime=10:00:30] B --> C[等待扫描
0-5秒] C --> D[下次扫描
10:00:05] D --> E[发现到期
开始执行] style A fill:#87CEEB style B fill:#87CEEB style C fill:#FFB6C1 style D fill:#FFE4B5 style E fill:#90EE90

问题:

diff 复制代码
任务期望 10:00:30 执行
扫描间隔 5秒

可能在以下时间被扫描到:
- 10:00:30(刚好碰上) → 延迟0秒
- 10:00:35(下一次) → 延迟5秒

平均延迟:2.5秒
最大延迟:5秒

用户感知:

复制代码
提交延时任务 → API返回成功(200ms)
→ 但要等2-5秒才真正执行
→ 用户会觉得:怎么还没生效?

数据库压力大

每5秒一次扫描:

graph LR A[调度器
每5秒] --> B[扫描数据库
WHERE到期] B --> C[返回结果
可能为空] C --> D[更新状态
PENDING to SENDING] A --> E[写入日志
每个任务] style A fill:#87CEEB style B fill:#FFB6C1 style D fill:#FFB6C1 style E fill:#FFB6C1

问题:

diff 复制代码
扫描频率:
- 每5秒1次 = 12次/分钟 = 720次/小时

日志写入:
- 每个任务立即写入
- 1000个任务 = 1000次IO

高峰期:
- 数据库连接池耗尽
- 慢查询增多
- 主从延迟变大

三个核心问题

总结一下:

graph LR A[问题一
延迟大] --> B[最多5秒
平均2.5秒] C[问题二
DB压力] --> D[频繁扫描
同步写入] E[问题三
响应慢] --> F[新任务等扫描
不是实时] style A fill:#FFB6C1 style C fill:#FFB6C1 style E fill:#FFB6C1 style B fill:#FF6B6B style D fill:#FF6B6B style F fill:#FF6B6B

二、时间轮:从扫描到内存调度

核心思路

把"定时扫描数据库"改为"内存时间轮调度"。

graph LR A[优化前
定时扫描数据库] --> B[延迟0-5秒
频繁查询] C[优化后
内存时间轮] --> D[延迟1秒内
零查询] style A fill:#FFB6C1 style B fill:#FF6B6B style C fill:#87CEEB style D fill:#90EE90

什么是时间轮?

想象一个钟表,有60个刻度,每秒走一格:

复制代码
60个槽位,编号 0-59
每秒tick一次,指针前进
任务放在对应的槽位里
指针到了就执行

时间轮结构

graph LR A[槽位0
任务A] --> B[槽位1
空] B --> C[槽位2
任务B] C --> D[...] D --> E[槽位59
任务C] E --> F[回到槽位0
循环] style A fill:#90EE90 style C fill:#90EE90 style E fill:#90EE90 style B fill:#D3D3D3

关键参数:

ini 复制代码
tickSeconds = 1     // 每秒tick一次
wheelSize = 60      // 60个槽位
rounds = N          // 任务需要等几圈

任务怎么放进去?

java 复制代码
// 计算延迟秒数
long delaySeconds = 任务触发时间 - 当前时间;
long ticks = delaySeconds / tickSeconds;

// 计算圈数和槽位
int rounds = (int) (ticks / wheelSize);      // 需要几圈
int index = (int) ((currentIndex + ticks) % wheelSize);  // 放哪个槽

示例:

ini 复制代码
当前时间:10:00:00,指针在槽位0

任务A:10:00:01 执行(延迟1秒)
→ ticks = 1
→ rounds = 0, index = 1
→ 放入槽位1,指针走到1时立即执行

任务B:10:00:59 执行(延迟59秒)
→ ticks = 59
→ rounds = 0, index = 59
→ 放入槽位59

任务C:10:01:02 执行(延迟62秒)
→ ticks = 62
→ rounds = 1, index = 2
→ 放入槽位2,但要等1圈后才执行

执行流程

每秒tick一次:

graph LR A[定时器
每秒触发] --> B[检查当前槽位] B --> C{rounds=0?} C -->|是| D[立即执行] C -->|否| E[rounds减1
继续等待] D --> F[指针前进] E --> F style A fill:#87CEEB style B fill:#FFE4B5 style C fill:#FFE4B5 style D fill:#90EE90 style E fill:#87CEEB style F fill:#87CEEB

代码逻辑:

java 复制代码
private void tick() {
    // 1. 获取当前槽位的所有任务
    List<WheelTask> slot = wheel.get(currentIndex);
    
    List<WheelTask> dueTasks = new ArrayList<>();
    List<WheelTask> remaining = new ArrayList<>();
    
    // 2. 遍历任务
    for (WheelTask task : slot) {
        if (task.rounds <= 0) {
            dueTasks.add(task);      // 到期任务
        } else {
            task.rounds -= 1;        // 圈数减1
            remaining.add(task);     // 继续等待
        }
    }
    
    // 3. 更新槽位(只保留需要继续等待的任务)
    slot.clear();
    slot.addAll(remaining);
    
    // 4. 异步执行到期任务
    for (WheelTask task : dueTasks) {
        executor.execute(task.callback);
    }
    
    // 5. 指针前进
    currentIndex = (currentIndex + 1) % wheelSize;
}

优势对比

graph LR A[数据库扫描
5秒间隔] --> B[延迟大
压力大] C[时间轮
1秒tick] --> D[延迟小
零查询] style A fill:#FFB6C1 style B fill:#FF6B6B style C fill:#87CEEB style D fill:#90EE90

对比表格:

特性 数据库扫描 时间轮
时间精度 5秒 1秒
平均延迟 2.5秒 0.5秒
最大延迟 5秒 1秒
数据库查询 频繁 零(只启动加载)
内存占用 60个槽位(很小)

三、滑动窗口:从同步到批量

核心思路

把"每个任务立即写日志"改为"批量写入"。

graph LR A[优化前
同步单条写入] --> B[1000任务
1000次IO] C[优化后
异步批量写入] --> D[1000任务
50次IO] style A fill:#FFB6C1 style B fill:#FF6B6B style C fill:#87CEEB style D fill:#90EE90

什么是滑动窗口?

一个内存队列,攒够了或者等久了,就批量写:

erlang 复制代码
任务1 → 放入队列
任务2 → 放入队列
...
任务20 → 队列满了,批量写入

批量写入原理

graph LR A[日志1] --> Q[内存队列] B[日志2] --> Q C[日志N] --> Q Q --> D{触发条件?} D -->|数量20| E[批量写入
INSERT批量] D -->|时间2秒| E style Q fill:#87CEEB style D fill:#FFE4B5 style E fill:#90EE90

触发机制:

java 复制代码
// 两个条件,满足任一即可
boolean sizeReached = buffer.size() >= 20;        // 数量触发
boolean timeReached = now - lastFlushTime >= 2000; // 时间触发(毫秒)

if (sizeReached || timeReached) {
    flush(buffer);  // 批量写入
}

为什么这样设计?

diff 复制代码
高并发场景:
- 任务很多,很快就攒够20条
- 立即批量写入
- 降低数据库压力

低并发场景:
- 任务很少,可能攒不到20条
- 等待2秒后也要写入
- 保证日志不会丢失

批量写入实现

后台线程监听队列:

java 复制代码
// 后台线程
while (running) {
    try {
        // 从队列里拿日志(最多等2秒)
        JobTaskLog log = queue.poll(2, TimeUnit.SECONDS);
        
        if (log != null) {
            buffer.add(log);
        }
        
        // 检查是否需要flush
        boolean shouldFlush = 
            buffer.size() >= batchSize ||                      // 数量够了
            (now - lastFlushTime >= maxWaitMillis);            // 时间到了
        
        if (shouldFlush && !buffer.isEmpty()) {
            flush(buffer);
            buffer.clear();
            lastFlushTime = now;
        }
    } catch (InterruptedException e) {
        // 中断时也要flush
        if (!buffer.isEmpty()) {
            flush(buffer);
        }
        break;
    }
}

批量写入SQL:

sql 复制代码
INSERT INTO job_task_log 
    (task_id, task_type, executor_name, start_time, end_time, ...)
VALUES 
    (?, ?, ?, ?, ?, ...),
    (?, ?, ?, ?, ?, ...),
    ...
    (?, ?, ?, ?, ?, ...)

降级保护

批量写入失败怎么办?

graph LR A[批量写入] --> B{成功?} B -->|是| C[完成] B -->|否| D[降级:逐条写入] D --> E[保证数据不丢] style A fill:#87CEEB style B fill:#FFE4B5 style C fill:#90EE90 style D fill:#FFB6C1 style E fill:#90EE90

代码实现:

java 复制代码
private void flush(List<JobTaskLog> buffer) {
    try {
        // 尝试批量写入
        taskLogRepository.batchInsert(buffer);
        log.debug("批量写入日志成功,条数={}", buffer.size());
    } catch (Exception e) {
        log.warn("批量写入失败,降级为逐条写入", e);
        
        // 降级:逐条写入
        for (JobTaskLog log : buffer) {
            try {
                taskLogRepository.insert(log);
            } catch (Exception ex) {
                log.error("逐条写入也失败,日志丢失,taskId={}", log.getTaskId(), ex);
            }
        }
    }
}

优雅关闭

应用关闭时,队列里可能还有日志没写:

java 复制代码
@PreDestroy
public void stop() {
    running = false;              // 停止后台线程
    worker.interrupt();           // 中断线程
    
    // 确保剩余数据写入
    if (!buffer.isEmpty()) {
        flush(buffer);
        buffer.clear();
    }
    
    log.info("TaskLogBatcher已停止,剩余日志已写入");
}

四、怎么整合到一起

完整流程

从任务创建到执行完成:

graph LR A[创建任务
API调用] --> B[写入数据库] B --> C[加载到时间轮
立即] C --> D[时间轮tick
1秒后] D --> E[执行任务
HTTP调用] E --> F[提交日志
到队列] F --> G[批量写入
20条或2秒] style A fill:#87CEEB style C fill:#FFE4B5 style D fill:#FFE4B5 style E fill:#90EE90 style F fill:#87CEEB style G fill:#90EE90

关键时间点:

makefile 复制代码
10:00:00.000  创建任务(executeTime = 10:00:30)
10:00:00.011  加载到时间轮(11ms)
10:00:30.121  时间轮触发执行(延迟121ms)
10:00:30.195  HTTP调用执行器(74ms)
10:00:30.196  任务执行完成(1ms)
10:00:30.197  提交日志到队列
10:00:32.000  批量写入数据库(2秒后)

初始化时间轮

调度器启动时加载已有任务:

java 复制代码
@PostConstruct
public void initTimeWheel() {
    // 1. 创建时间轮
    this.timeWheel = new DelayTimeWheel(1, 60, executor);
    
    // 2. 从数据库加载 PENDING 任务
    List<JobDelayTask> pendingTasks = delayTaskRepository.findDueTasks(1000);
    
    // 3. 加载到时间轮
    for (JobDelayTask task : pendingTasks) {
        if ("PENDING".equals(task.getStatus())) {
            LocalDateTime triggerTime = task.getNextAttemptTime();
            timeWheel.addTask(task, triggerTime, () -> executeDelayTask(task));
        }
    }
    
    log.info("时间轮初始化完成,已装载 {} 条任务", pendingTasks.size());
}

新任务立即加载

API创建任务后,立即加载到时间轮:

java 复制代码
public JobDelayTask createTask(DelayTaskRequest request) {
    // 1. 写入数据库
    JobDelayTask task = buildTask(request);
    delayTaskRepository.insert(task);
    
    // 2. 立即加载到时间轮(不等扫描)
    addTaskToWheel(task);
    
    return task;
}

private void addTaskToWheel(JobDelayTask task) {
    LocalDateTime triggerTime = task.getNextAttemptTime();
    timeWheel.addTask(task, triggerTime, () -> executeDelayTask(task));
    
    log.info("新任务已加载到时间轮,traceId={}, triggerTime={}", 
        task.getTraceId(), triggerTime);
}

时间轮触发执行

时间到了自动执行:

java 复制代码
private void executeDelayTask(JobDelayTask task) {
    log.info("时间轮触发执行,traceId={}", task.getTraceId());
    
    LocalDateTime startTime = LocalDateTime.now();
    boolean success = false;
    String errorMsg = null;
    
    try {
        // 执行任务调度(Owner判定 + CAS抢占 + HTTP调用)
        dispatchDelayTask(task);
        success = true;
    } catch (Exception e) {
        errorMsg = e.getMessage();
        log.error("执行失败,traceId={}", task.getTraceId(), e);
    } finally {
        // 记录日志(提交到批量处理器)
        JobTaskLog logEntity = buildLog(task, startTime, success, errorMsg);
        taskLogBatcher.add(logEntity);  // 异步批量写入
    }
}

补偿扫描

虽然有了时间轮,但还是保留了扫描机制(降低频率):

java 复制代码
@Scheduled(fixedDelay = 10000)  // 从5秒改为10秒
public void scanAndDispatch() {
    // 1. 处理 SENDING 超时任务
    List<JobDelayTask> stuckTasks = findStuckSendingTasks();
    for (JobDelayTask task : stuckTasks) {
        handleSendingTimeout(task);
    }
    
    // 2. 补偿:加载新提交的 PENDING 任务到时间轮
    //    (防止某些任务创建后没加载)
    List<JobDelayTask> newPendingTasks = findNewPendingTasks();
    for (JobDelayTask task : newPendingTasks) {
        if (!isInTimeWheel(task)) {
            addTaskToWheel(task);
        }
    }
}

为什么还要扫描?

diff 复制代码
时间轮:正常流程,99%的任务走这里
补偿扫描:兜底机制,防止遗漏

可能遗漏的场景:
- 调度器重启,部分任务没加载
- 数据库直接插入任务
- 时间轮加载失败

补偿扫描保证:即使时间轮出问题,任务最多延迟10秒

五、效果怎么样

延迟对比

实测数据:

diff 复制代码
优化前:
- 创建时间:10:00:00.000
- 期望执行:10:00:30.000
- 实际执行:10:00:32.500(平均延迟2.5秒)
- 最大延迟:5秒

优化后:
- 创建时间:10:00:00.000
- 期望执行:10:00:30.000
- 实际执行:10:00:30.121(延迟121ms)
- 最大延迟:1秒
graph LR A[优化前
延迟0-5秒] --> B[平均2.5秒] C[优化后
延迟0-1秒] --> D[平均0.5秒] style A fill:#FFB6C1 style B fill:#FF6B6B style C fill:#87CEEB style D fill:#90EE90

数据库压力

扫描频率:

diff 复制代码
优化前:
- 扫描间隔:5秒
- 每小时:720次
- 日志写入:同步单条

优化后:
- 扫描间隔:10秒(降低50%)
- 每小时:360次
- 日志写入:异步批量(降低95%)
graph LR A[优化前
720次/小时] --> B[扫描压力大
IO频繁] C[优化后
360次/小时] --> D[压力减半
批量写入] style A fill:#FFB6C1 style B fill:#FF6B6B style C fill:#87CEEB style D fill:#90EE90

日志写入对比:

diff 复制代码
场景:1000个任务

优化前:
- 1000个任务 = 1000次 INSERT
- 每次都等待数据库响应
- 数据库连接池可能耗尽

优化后:
- 1000个任务 = 50次批量 INSERT(20条/批)
- IO次数降低95%
- 数据库压力大幅降低

响应速度

新任务提交后的响应:

arduino 复制代码
优化前:
POST /api/delay-tasks → 200 OK(数据库写入)
→ 等待下次扫描(0-5秒)
→ 用户感知慢

优化后:
POST /api/delay-tasks → 200 OK(数据库写入)
→ 立即加载到时间轮(11ms)
→ 用户感知快

性能指标总结

指标 优化前 优化后 提升
执行延迟 0-5秒 0-1秒 80-100%
平均延迟 2.5秒 0.5秒 80%
扫描频率 每5秒 每10秒 50%
日志IO N次 N/20次 95%
新任务响应 等待扫描 立即加载 实时

六、设计要点

时间轮的精妙之处

O(1)复杂度

scss 复制代码
添加任务:计算槽位 → 放入 → O(1)
执行任务:检查槽位 → 触发 → O(1)

不需要遍历所有任务,也不需要排序,时间复杂度是常数级的。

多圈支持

ini 复制代码
延迟1秒   → rounds=0, 放槽位1
延迟59秒  → rounds=0, 放槽位59
延迟60秒  → rounds=1, 放槽位0(等1圈)
延迟120秒 → rounds=2, 放槽位0(等2圈)

只要内存够,可以支持任意长的延迟。

线程安全

java 复制代码
synchronized (wheel) {
    wheel.get(index).add(wheelTask);  // 槽位操作都加锁
}

简单粗暴,但够用。因为:

  • 槽位操作很快(O(1))
  • 锁粒度小,不会阻塞太久

异步执行

java 复制代码
for (WheelTask task : dueTasks) {
    executor.execute(task.callback);  // 异步执行,不阻塞tick
}

即使某个任务执行很慢,也不会影响时间轮的tick。

滑动窗口的巧妙之处

双重触发机制

复制代码
高并发:很快攒够20条 → 立即批量写入
低并发:攒不到20条 → 等2秒也写入

既保证了性能,又保证了时效性。

优雅降级

复制代码
批量写入失败 → 降级为逐条写入 → 保证数据不丢

可靠性优先,宁可慢一点,也不能丢数据。

优雅关闭

java 复制代码
@PreDestroy
public void stop() {
    running = false;           // 停止接收新日志
    worker.interrupt();        // 中断后台线程
    flush(buffer);             // 把剩余日志写完
}

应用关闭时,确保队列里的日志都写入了。

两者结合的效果

graph LR A[时间轮
降低延迟] --> C[用户体验好
DB压力小] B[滑动窗口
批量写入] --> C style A fill:#87CEEB style B fill:#FFE4B5 style C fill:#90EE90

时间轮解决了"什么时候执行"的问题,滑动窗口解决了"怎么高效记录"的问题。

七、适用场景

适合用时间轮的场景

复制代码
任务量大:每分钟1000个以上
时间精度要求高:秒级
延迟时间不太长:1小时以内
内存充足:时间轮占用内存很小

不适合的场景

复制代码
任务量极小:每小时只有几个任务
→ 用数据库扫描就够了,不需要时间轮

延迟时间超长:几个小时甚至几天
→ 建议用多级时间轮(秒级轮 + 分级轮 + 时级轮)

内存紧张:
→ 时间轮把任务加载到内存,会占用一些空间

适合用滑动窗口的场景

复制代码
写入频繁:每秒几十上百次
数据库压力大:IO是瓶颈
允许小延迟:2秒内的日志延迟可接受

不适合的场景

复制代码
写入不频繁:每小时只有几次
→ 没必要批量,直接写就好

不允许任何延迟:必须立即落盘
→ 不能用异步批量,必须同步写入

八、后续优化方向

多级时间轮

如果延迟时间很长(几个小时),可以用多级时间轮:

复制代码
秒级轮:60格 × 1秒 = 1分钟范围
分级轮:60格 × 1分钟 = 1小时范围
时级轮:24格 × 1小时 = 1天范围
graph LR A[秒级轮
60格1秒] --> B[分级轮
60格1分钟] B --> C[时级轮
24格1小时] style A fill:#90EE90 style B fill:#87CEEB style C fill:#FFE4B5

任务从高级轮逐级降级到低级轮,最后在秒级轮执行。

动态调整批量大小

根据并发量动态调整:

java 复制代码
// 高峰期:快速批量
if (qps > 100) {
    batchSize = 50;
    maxWaitMillis = 500;
}

// 低峰期:保证时效
if (qps < 10) {
    batchSize = 10;
    maxWaitMillis = 1000;
}

Prometheus监控

增加关键指标:

diff 复制代码
时间轮指标:
- timewheel_tasks_total:时间轮中的任务总数
- timewheel_tick_duration_ms:每次tick的耗时

批量日志指标:
- log_batch_size_avg:平均批量大小
- log_batch_flush_total:总flush次数
- log_queue_size:队列长度

九、总结

这次优化用了两个经典的数据结构:

graph LR A[时间轮
经典调度算法] --> C[高性能
低延迟] B[滑动窗口
批量优化模式] --> C style A fill:#87CEEB style B fill:#FFE4B5 style C fill:#90EE90

核心收获:

时间轮:

  • O(1)复杂度的任务调度
  • 内存占用小
  • 支持任意长延迟(多圈机制)

滑动窗口:

  • 批量写入,降低95%的IO
  • 双重触发,兼顾性能和时效
  • 优雅降级,保证可靠性

优化成果:

维度 优化前 优化后 提升
执行延迟 0-5秒 0-1秒 80-100%
扫描频率 每5秒 每10秒 50%
日志IO N次 N/20次 95%
新任务响应 等待扫描 立即加载 实时

设计理念:

不是所有问题都要用最复杂的方案。时间轮和滑动窗口都是很简单的数据结构,但用对了地方,效果就很好。

关键是:

复制代码
理解问题的本质
选择合适的方案
用简单的方式解决复杂的问题

这就是 JobFlow 的时间轮与滑动窗口优化。

相关推荐
molaifeng2 小时前
像搭积木一样理解 Golang AST
开发语言·后端·golang
踏浪无痕2 小时前
JobFlow 的延时调度:如何可靠地处理“30分钟后取消订单”
后端·面试·开源
侧耳倾听1113 小时前
RESTful API介绍
后端·restful
百***07453 小时前
从零上手 Mistral 3:开源模型接入实战 + 全场景落地指南
开源
vipbic3 小时前
基于 Nuxt 4 + Strapi 5 构建高性能 AI 导航站
前端·后端
API开发平台3 小时前
接口开发开源平台 Crabc 3.5.4 发布
低代码·开源
LuckyDog06233 小时前
性能监控专栏需求内容
开源
老华带你飞3 小时前
电商系统|基于java + vue电商系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot·后端
OC溥哥9994 小时前
2D我的世界创造模式网页版正式出炉——《我们的2D创造世界:无限创意,多人同乐》欢迎来到ourcraft.xin网站上玩
后端·python·阿里云·flask·html·游戏程序