开源地址与系列文章
- 开源地址 :
https://gitee.com/sh_wangwanbao/job-flow - 系列文章:
前言
延时调度上线后,我们发现了几个问题:
用户期望:订单30分钟后自动取消
实际执行:30分01秒 到 30分05秒之间
误差:最多5秒
数据库压力:每5秒扫描一次
日志写入:每个任务立即写入
对于秒杀、限时优惠这种场景,5秒的延迟是不可接受的。
这篇文章就来讲讲我们是怎么优化的:用时间轮把延迟降到1秒以内,用滑动窗口把数据库压力降低95%。
一、问题在哪
先看看原来的方案有什么问题。
定时扫描的延迟
原来的逻辑:每5秒扫描一次数据库
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秒一次扫描:
每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
高峰期:
- 数据库连接池耗尽
- 慢查询增多
- 主从延迟变大
三个核心问题
总结一下:
延迟大] --> 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
二、时间轮:从扫描到内存调度
核心思路
把"定时扫描数据库"改为"内存时间轮调度"。
定时扫描数据库] --> 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一次,指针前进
任务放在对应的槽位里
指针到了就执行
时间轮结构
任务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一次:
每秒触发] --> 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;
}
优势对比
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个槽位(很小) |
三、滑动窗口:从同步到批量
核心思路
把"每个任务立即写日志"改为"批量写入"。
同步单条写入] --> 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 → 队列满了,批量写入
批量写入原理
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
(?, ?, ?, ?, ?, ...),
(?, ?, ?, ?, ?, ...),
...
(?, ?, ?, ?, ?, ...)
降级保护
批量写入失败怎么办?
代码实现:
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已停止,剩余日志已写入");
}
四、怎么整合到一起
完整流程
从任务创建到执行完成:
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秒
延迟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%)
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); // 把剩余日志写完
}
应用关闭时,确保队列里的日志都写入了。
两者结合的效果
降低延迟] --> 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天范围
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:队列长度
九、总结
这次优化用了两个经典的数据结构:
经典调度算法] --> 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 的时间轮与滑动窗口优化。