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

170 阅读12分钟

开源地址与系列文章

前言

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

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

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

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

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

一、问题在哪

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

定时扫描的延迟

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

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

问题:

任务期望 10:00:30 执行
扫描间隔 5秒

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

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

用户感知:

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

数据库压力大

每5秒一次扫描:

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

问题:

扫描频率:
- 每5秒1次 = 12次/分钟 = 720次/小时

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

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

三个核心问题

总结一下:

graph LR
    A[问题一<br/>延迟大] --> B[最多5秒<br/>平均2.5秒]
    C[问题二<br/>DB压力] --> D[频繁扫描<br/>同步写入]
    E[问题三<br/>响应慢] --> F[新任务等扫描<br/>不是实时]
    
    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[优化前<br/>定时扫描数据库] --> B[延迟0-5秒<br/>频繁查询]
    C[优化后<br/>内存时间轮] --> D[延迟1秒内<br/>零查询]
    
    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<br/>任务A] --> B[槽位1<br/>空]
    B --> C[槽位2<br/>任务B]
    C --> D[...]
    D --> E[槽位59<br/>任务C]
    E --> F[回到槽位0<br/>循环]
    
    style A fill:#90EE90
    style C fill:#90EE90
    style E fill:#90EE90
    style B fill:#D3D3D3

关键参数:

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

任务怎么放进去?

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

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

示例:

当前时间:10:00:00,指针在槽位0

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

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

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

执行流程

每秒tick一次:

graph LR
    A[定时器<br/>每秒触发] --> B[检查当前槽位]
    B --> C{rounds=0?}
    C -->|是| D[立即执行]
    C -->|否| E[rounds减1<br/>继续等待]
    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

代码逻辑:

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[数据库扫描<br/>5秒间隔] --> B[延迟大<br/>压力大]
    C[时间轮<br/>1秒tick] --> D[延迟小<br/>零查询]
    
    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[优化前<br/>同步单条写入] --> B[1000任务<br/>1000次IO]
    C[优化后<br/>异步批量写入] --> D[1000任务<br/>50次IO]
    
    style A fill:#FFB6C1
    style B fill:#FF6B6B
    style C fill:#87CEEB
    style D fill:#90EE90

什么是滑动窗口?

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

任务1 → 放入队列
任务2 → 放入队列
...
任务20 → 队列满了,批量写入

批量写入原理

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

触发机制:

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

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

为什么这样设计?

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

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

批量写入实现

后台线程监听队列:

// 后台线程
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:

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

代码实现:

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);
            }
        }
    }
}

优雅关闭

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

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

四、怎么整合到一起

完整流程

从任务创建到执行完成:

graph LR
    A[创建任务<br/>API调用] --> B[写入数据库]
    B --> C[加载到时间轮<br/>立即]
    C --> D[时间轮tick<br/>1秒后]
    D --> E[执行任务<br/>HTTP调用]
    E --> F[提交日志<br/>到队列]
    F --> G[批量写入<br/>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

关键时间点:

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秒后)

初始化时间轮

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

@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创建任务后,立即加载到时间轮:

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);
}

时间轮触发执行

时间到了自动执行:

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);  // 异步批量写入
    }
}

补偿扫描

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

@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);
        }
    }
}

为什么还要扫描?

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

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

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

五、效果怎么样

延迟对比

实测数据:

优化前:
- 创建时间: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[优化前<br/>延迟0-5秒] --> B[平均2.5秒]
    C[优化后<br/>延迟0-1秒] --> D[平均0.5秒]
    
    style A fill:#FFB6C1
    style B fill:#FF6B6B
    style C fill:#87CEEB
    style D fill:#90EE90

数据库压力

扫描频率:

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

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

日志写入对比:

场景:1000个任务

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

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

响应速度

新任务提交后的响应:

优化前:
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%
日志ION次N/20次95%
新任务响应等待扫描立即加载实时

六、设计要点

时间轮的精妙之处

O(1)复杂度

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

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

多圈支持

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

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

线程安全

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

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

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

异步执行

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

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

滑动窗口的巧妙之处

双重触发机制

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

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

优雅降级

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

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

优雅关闭

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

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

两者结合的效果

graph LR
    A[时间轮<br/>降低延迟] --> C[用户体验好<br/>DB压力小]
    B[滑动窗口<br/>批量写入] --> 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[秒级轮<br/>60格1秒] --> B[分级轮<br/>60格1分钟]
    B --> C[时级轮<br/>24格1小时]
    
    style A fill:#90EE90
    style B fill:#87CEEB
    style C fill:#FFE4B5

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

动态调整批量大小

根据并发量动态调整:

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

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

Prometheus监控

增加关键指标:

时间轮指标:
- timewheel_tasks_total:时间轮中的任务总数
- timewheel_tick_duration_ms:每次tick的耗时

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

九、总结

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

graph LR
    A[时间轮<br/>经典调度算法] --> C[高性能<br/>低延迟]
    B[滑动窗口<br/>批量优化模式] --> 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%
日志ION次N/20次95%
新任务响应等待扫描立即加载实时

设计理念:

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

关键是:

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

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