📖 开场:外卖订单超时
想象你在美团点外卖 🍜:
场景:
10:00 下单
10:15 未支付 → 自动取消订单 ⏰
需求:
- 15分钟后执行取消订单任务
- 如果用户支付了,取消这个任务
简单实现:
方法1:定时任务每分钟扫描数据库
SELECT * FROM orders
WHERE status = 'UNPAID'
AND create_time < NOW() - INTERVAL 15 MINUTE
问题:
- 数据库压力大(每分钟全表扫描)❌
- 不够实时(最多1分钟延迟)❌
需要:分布式延迟任务调度系统 ⭐
🤔 核心需求
业务场景
| 场景 | 延迟时间 | 说明 |
|---|---|---|
| 订单超时取消 | 15-30分钟 | 未支付自动取消 |
| 优惠券过期 | 7天-30天 | 到期后失效 |
| 红包过期 | 1天-3天 | 到期后回收 |
| 定时消息推送 | 任意时间 | 定时发送 |
核心要求
| 要求 | 说明 | 重要性 |
|---|---|---|
| 精确触发 | 误差<1秒 | ⭐⭐⭐ |
| 高可用 | 不能丢任务 | ⭐⭐⭐ |
| 高性能 | 支持百万级任务 | ⭐⭐⭐ |
| 可取消 | 支持任务取消 | ⭐⭐ |
| 可观测 | 任务状态查询 | ⭐⭐ |
🎯 技术方案
方案1:数据库轮询(不推荐)❌
原理
定时任务每1秒扫描数据库:
SELECT * FROM delay_task
WHERE execute_time <= NOW()
AND status = 'PENDING'
LIMIT 100
问题:
- 数据库压力大 ❌
- 扫描慢(全表扫描)❌
- 精度低(最多1秒误差)❌
方案2:DelayQueue(单机)📦
原理
JDK的DelayQueue:
// ⭐ 创建DelayQueue
DelayQueue<DelayTask> queue = new DelayQueue<>();
// ⭐ 添加延迟任务
queue.put(new DelayTask("task1", 15, TimeUnit.MINUTES));
// ⭐ 阻塞获取(到时间才返回)
DelayTask task = queue.take();
内部实现:
- 基于优先队列(堆)
- 按到期时间排序
- 到期的任务排在最前面
问题:
- 单机,无法分布式 ❌
- 任务丢失(JVM重启)❌
方案3:Redis + ZSet ⭐⭐⭐
原理
使用Redis的ZSet:
key: delay:task
value: {
member: taskId,
score: 执行时间戳
}
自动按score排序 ✅
例如:
delay:task = {
(task1, 1609459200), ← 10:00:00
(task2, 1609460100), ← 10:15:00
(task3, 1609461000) ← 10:30:00
}
扫描逻辑:
while (true) {
// 1. 查询已到期的任务(score <= 当前时间)
Set<String> tasks = ZRANGEBYSCORE delay:task 0 now
// 2. 执行任务
for (task : tasks) {
execute(task)
ZREM delay:task task // 删除
}
// 3. 休眠1秒
Thread.sleep(1000)
}
代码实现
@Component
@Slf4j
public class RedisDelayQueue {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String DELAY_QUEUE_KEY = "delay:task";
/**
* ⭐ 添加延迟任务
*/
public void addTask(String taskId, long delayMillis) {
// 计算执行时间(当前时间 + 延迟时间)
long executeTime = System.currentTimeMillis() + delayMillis;
// ⭐ 添加到ZSet(score = 执行时间)
redisTemplate.opsForZSet().add(DELAY_QUEUE_KEY, taskId, executeTime);
log.info("添加延迟任务: taskId={}, delayMillis={}, executeTime={}",
taskId, delayMillis, new Date(executeTime));
}
/**
* ⭐ 取消任务
*/
public void cancelTask(String taskId) {
// 从ZSet中删除
redisTemplate.opsForZSet().remove(DELAY_QUEUE_KEY, taskId);
log.info("取消任务: taskId={}", taskId);
}
/**
* ⭐ 扫描并执行到期的任务
*/
@Scheduled(fixedRate = 1000) // 每1秒执行一次
public void scanAndExecute() {
long now = System.currentTimeMillis();
// ⭐ 查询已到期的任务(score <= 当前时间)
Set<String> tasks = redisTemplate.opsForZSet()
.rangeByScore(DELAY_QUEUE_KEY, 0, now);
if (tasks == null || tasks.isEmpty()) {
return;
}
log.info("扫描到到期任务: count={}", tasks.size());
for (String taskId : tasks) {
// ⭐ 执行任务
executeTask(taskId);
// ⭐ 删除任务
redisTemplate.opsForZSet().remove(DELAY_QUEUE_KEY, taskId);
}
}
/**
* 执行任务
*/
private void executeTask(String taskId) {
try {
log.info("执行任务: taskId={}", taskId);
// ⭐ 从数据库查询任务详情
DelayTask task = taskDao.findById(taskId);
if (task == null) {
log.warn("任务不存在: taskId={}", taskId);
return;
}
// ⭐ 根据任务类型执行不同的逻辑
switch (task.getType()) {
case "ORDER_CANCEL":
orderService.cancelOrder(task.getOrderId());
break;
case "COUPON_EXPIRE":
couponService.expireCoupon(task.getCouponId());
break;
default:
log.warn("未知任务类型: type={}", task.getType());
}
// ⭐ 更新任务状态
task.setStatus("COMPLETED");
taskDao.update(task);
} catch (Exception e) {
log.error("任务执行失败: taskId={}", taskId, e);
}
}
}
优化:分布式锁
问题:多个服务器同时扫描,可能重复执行
解决:分布式锁
@Scheduled(fixedRate = 1000)
public void scanAndExecute() {
String lockKey = "lock:delay:scan";
String lockValue = UUID.randomUUID().toString();
try {
// ⭐ 获取分布式锁(5秒过期)
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, 5, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(acquired)) {
// 其他服务器正在扫描
return;
}
// 扫描并执行任务
long now = System.currentTimeMillis();
Set<String> tasks = redisTemplate.opsForZSet()
.rangeByScore(DELAY_QUEUE_KEY, 0, now);
for (String taskId : tasks) {
executeTask(taskId);
redisTemplate.opsForZSet().remove(DELAY_QUEUE_KEY, taskId);
}
} finally {
// ⭐ 释放锁(Lua脚本)
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
lockValue
);
}
}
方案4:时间轮(Timing Wheel)⏱️
原理
时间轮 = 环形数组 + 指针
时间轮(60个槽位,1秒一格):
0 1 2 3 4 5
┌─┬─┬─┬─┬─┬─┬─┬─┐
│ │A│ │ │B│ │ │ │...
└─┴─┴─┴─┴─┴─┴─┴─┘
↑
指针(每秒移动一格)
任务A:2秒后执行 → 放在槽位1
任务B:5秒后执行 → 放在槽位4
指针移动到槽位1 → 执行任务A
指针移动到槽位4 → 执行任务B
多层时间轮
问题:60个槽位,只能表示60秒
解决:多层时间轮
第1层:秒级(60个槽位)
第2层:分钟级(60个槽位)
第3层:小时级(24个槽位)
第4层:天级(30个槽位)
例如:
任务:3小时5分10秒后执行
↓
第4层:3 → 第3层:5 → 第2层:10 → 第1层:执行
Netty的HashedWheelTimer
使用示例:
@Component
public class TimingWheelScheduler {
private HashedWheelTimer timer;
@PostConstruct
public void init() {
// ⭐ 创建时间轮
// 参数:每格时间100ms,512个槽位
timer = new HashedWheelTimer(
new DefaultThreadFactory("delay-task"),
100, TimeUnit.MILLISECONDS,
512
);
timer.start();
}
/**
* ⭐ 添加延迟任务
*/
public void addTask(String taskId, long delayMillis, Runnable task) {
// ⭐ 添加到时间轮
Timeout timeout = timer.newTimeout(t -> {
log.info("执行任务: taskId={}", taskId);
task.run();
}, delayMillis, TimeUnit.MILLISECONDS);
// 保存timeout(用于取消)
taskTimeouts.put(taskId, timeout);
}
/**
* ⭐ 取消任务
*/
public void cancelTask(String taskId) {
Timeout timeout = taskTimeouts.remove(taskId);
if (timeout != null) {
timeout.cancel();
log.info("取消任务: taskId={}", taskId);
}
}
private Map<String, Timeout> taskTimeouts = new ConcurrentHashMap<>();
@PreDestroy
public void destroy() {
if (timer != null) {
timer.stop();
}
}
}
优点:
- 高性能(O(1)添加和删除)
- 精确(tick粒度可配置)
缺点:
- 单机,无法分布式
- 内存占用(任务量大时)
方案5:RocketMQ延迟消息 📮
原理
RocketMQ支持固定级别的延迟:
延迟级别:
1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
例如:
level=3 → 延迟10秒
level=16 → 延迟30分钟
代码实现
@Service
@Slf4j
public class RocketMQDelayService {
@Autowired
private RocketMQTemplate rocketMQTemplate;
/**
* ⭐ 发送延迟消息
*/
public void sendDelayMessage(String taskId, int delayLevel) {
// 构造消息
Message<String> message = MessageBuilder
.withPayload(taskId)
.build();
// ⭐ 发送延迟消息
rocketMQTemplate.syncSend(
"DelayTopic",
message,
3000, // 超时时间
delayLevel // ⭐ 延迟级别
);
log.info("发送延迟消息: taskId={}, delayLevel={}", taskId, delayLevel);
}
/**
* ⭐ 消费延迟消息
*/
@RocketMQMessageListener(
topic = "DelayTopic",
consumerGroup = "delay-consumer-group"
)
public class DelayMessageListener implements RocketMQListener<String> {
@Override
public void onMessage(String taskId) {
log.info("收到延迟消息: taskId={}", taskId);
// 执行任务
executeTask(taskId);
}
}
}
优点:
- 可靠性高(RocketMQ保证)
- 分布式(天然支持)
缺点:
- 延迟级别固定(只有18个级别)
- 无法取消任务
📊 完整架构设计
整体架构
┌─────────────────────────────────────────┐
│ 客户端 │
│ - 创建延迟任务 │
│ - 取消任务 │
│ - 查询任务状态 │
└─────────────┬───────────────────────────┘
│
↓
┌─────────────────────────────────────────┐
│ 应用服务器 │
│ │
│ - 任务API │
│ - 任务调度器 │
│ - 任务执行器 │
└───────┬─────────────┬───────────────────┘
│ │
↓ ↓
┌──────────────┐ ┌──────────────────────┐
│ Redis │ │ MySQL │
│ │ │ │
│ - ZSet存储 │ │ - 任务详情 │
│ - 分布式锁 │ │ - 任务历史 │
└──────────────┘ └──────────────────────┘
数据库设计
-- ⭐ 延迟任务表
CREATE TABLE delay_task (
id VARCHAR(64) PRIMARY KEY,
type VARCHAR(32) NOT NULL COMMENT '任务类型',
biz_id VARCHAR(64) COMMENT '业务ID(如订单ID)',
params TEXT COMMENT '任务参数(JSON)',
execute_time DATETIME NOT NULL COMMENT '执行时间',
status VARCHAR(16) NOT NULL COMMENT '状态:PENDING/COMPLETED/FAILED/CANCELLED',
retry_count INT DEFAULT 0 COMMENT '重试次数',
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
INDEX idx_execute_time (execute_time),
INDEX idx_type_status (type, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
核心Service
@Service
@Slf4j
public class DelayTaskService {
@Autowired
private DelayTaskDao taskDao;
@Autowired
private RedisDelayQueue delayQueue;
/**
* ⭐ 创建延迟任务
*/
@Transactional
public String createTask(DelayTaskRequest request) {
// 1. 生成任务ID
String taskId = UUID.randomUUID().toString();
// 2. 保存到数据库
DelayTask task = new DelayTask();
task.setId(taskId);
task.setType(request.getType());
task.setBizId(request.getBizId());
task.setParams(JSON.toJSONString(request.getParams()));
task.setExecuteTime(request.getExecuteTime());
task.setStatus("PENDING");
task.setCreateTime(new Date());
task.setUpdateTime(new Date());
taskDao.insert(task);
// 3. ⭐ 添加到Redis延迟队列
long delayMillis = request.getExecuteTime().getTime() - System.currentTimeMillis();
delayQueue.addTask(taskId, delayMillis);
log.info("创建延迟任务: taskId={}, type={}, delayMillis={}",
taskId, request.getType(), delayMillis);
return taskId;
}
/**
* ⭐ 取消任务
*/
@Transactional
public void cancelTask(String taskId) {
// 1. 更新数据库状态
DelayTask task = taskDao.findById(taskId);
if (task == null) {
throw new NotFoundException("任务不存在");
}
if (!"PENDING".equals(task.getStatus())) {
throw new IllegalStateException("任务已执行或已取消");
}
task.setStatus("CANCELLED");
task.setUpdateTime(new Date());
taskDao.update(task);
// 2. ⭐ 从Redis延迟队列删除
delayQueue.cancelTask(taskId);
log.info("取消任务: taskId={}", taskId);
}
/**
* 查询任务
*/
public DelayTask getTask(String taskId) {
return taskDao.findById(taskId);
}
}
API接口
@RestController
@RequestMapping("/api/delay-task")
public class DelayTaskController {
@Autowired
private DelayTaskService taskService;
/**
* ⭐ 创建延迟任务
*/
@PostMapping
public Result<String> createTask(@RequestBody DelayTaskRequest request) {
String taskId = taskService.createTask(request);
return Result.success(taskId);
}
/**
* ⭐ 取消任务
*/
@DeleteMapping("/{taskId}")
public Result<?> cancelTask(@PathVariable String taskId) {
taskService.cancelTask(taskId);
return Result.success("取消成功");
}
/**
* 查询任务
*/
@GetMapping("/{taskId}")
public Result<DelayTask> getTask(@PathVariable String taskId) {
DelayTask task = taskService.getTask(taskId);
return Result.success(task);
}
}
@Data
class DelayTaskRequest {
private String type; // 任务类型
private String bizId; // 业务ID
private Map<String, Object> params; // 任务参数
private Date executeTime; // 执行时间
}
🎓 面试题速答
Q1: 如何实现延迟任务调度?
A: 三种方案:
-
数据库轮询:
- 定时扫描数据库
- 缺点:性能差 ❌
-
Redis ZSet(推荐):
- score = 执行时间戳
- 定时扫描到期任务
- 优点:性能高 ✅
-
时间轮:
- 环形数组 + 指针
- 优点:O(1)性能
- 缺点:单机 ❌
推荐:Redis ZSet ⭐⭐⭐
Q2: Redis ZSet如何实现延迟任务?
A: 原理:
1. 添加任务:
ZADD delay:task {执行时间戳} {taskId}
2. 扫描到期任务:
ZRANGEBYSCORE delay:task 0 {当前时间}
3. 执行并删除:
ZREM delay:task {taskId}
优点:
- 自动排序(按时间)
- 性能高(O(logN))
- 支持分布式(多服务器扫描)
Q3: 时间轮是什么?
A: 时间轮 = 环形数组 + 指针
结构:
- 数组:N个槽位(如60个)
- 指针:每tick移动一格(如1秒)
- 任务:放在对应的槽位
例如:
任务A:5秒后执行 → 放在槽位5
指针移动到槽位5 → 执行任务A
多层时间轮:
- 第1层:秒级(60槽)
- 第2层:分钟级(60槽)
- 第3层:小时级(24槽)
优点:O(1)添加和删除
Q4: 如何防止任务重复执行?
A: 两种方案:
-
分布式锁:
// 扫描时获取锁 if (redisTemplate.opsForValue().setIfAbsent("lock:scan", "1", 5, TimeUnit.SECONDS)) { // 只有一个服务器扫描 scanAndExecute(); } -
CAS删除:
// 执行前先删除(原子操作) Long removed = redisTemplate.opsForZSet().remove(DELAY_QUEUE_KEY, taskId); if (removed > 0) { // 删除成功,执行任务 executeTask(taskId); }
Q5: 如何保证任务不丢失?
A: 三种保障:
-
持久化:
- Redis AOF/RDB
- MySQL存储任务详情
-
重试机制:
try { executeTask(task); } catch (Exception e) { // 重试3次 if (task.getRetryCount() < 3) { reAddTask(task); } } -
定时扫描:
// 每小时扫描数据库中PENDING状态的任务 // 如果执行时间已过,重新加入队列 @Scheduled(cron = "0 0 * * * ?") public void scanPendingTasks() { List<DelayTask> tasks = taskDao.findPendingTasks(); for (DelayTask task : tasks) { reAddTask(task); } }
Q6: RocketMQ延迟消息的限制是什么?
A: 限制:
-
固定延迟级别:
- 只有18个级别
- 不支持任意延迟时间
-
无法取消:
- 发送后无法取消
- 只能在消费时判断
-
最大延迟2小时:
- level=18 → 2小时
- 超过2小时需要多次发送
适用场景:
- 固定延迟时间(如15分钟)
- 不需要取消任务
🎬 总结
延迟任务调度方案对比
┌────────────────────────────────────┐
│ 数据库轮询 │
│ - 定时扫描数据库 │
│ - 性能差 ❌ │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ Redis ZSet(推荐)⭐⭐⭐ │
│ - score = 执行时间 │
│ - 性能高,支持分布式 │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 时间轮 │
│ - 环形数组 + 指针 │
│ - O(1)性能,但单机 │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ RocketMQ延迟消息 │
│ - 可靠性高 │
│ - 但延迟级别固定 │
└────────────────────────────────────┘
Redis ZSet是最佳选择!✅
🎉 恭喜你!
你已经完全掌握了分布式延迟任务调度系统的设计!🎊
核心要点:
- Redis ZSet:score = 执行时间戳
- 分布式锁:防止重复执行
- 持久化:MySQL + Redis AOF
- 重试机制:保证任务不丢失
下次面试,这样回答:
"延迟任务调度系统采用Redis ZSet实现。任务添加时,将任务ID作为member,执行时间戳作为score存入ZSet。定时任务每秒扫描一次,使用ZRANGEBYSCORE查询已到期的任务(score <= 当前时间),执行后删除。
为防止多服务器重复执行,扫描时先获取分布式锁。任务详情存储在MySQL,Redis只存任务ID和执行时间,减少内存占用。
可靠性方面,Redis使用AOF持久化,MySQL存储任务详情作为备份。任务执行失败后自动重试3次,每小时还有定时任务扫描数据库中PENDING状态的任务,重新加入队列。
我们项目的订单超时取消系统采用这套方案,支持百万级延迟任务,平均执行误差50ms以内,运行稳定。"
面试官:👍 "很好!你对分布式延迟任务调度系统的设计理解很深刻!"
本文完 🎬
上一篇: 204-设计一个新闻Feed流系统.md
下一篇: 206-设计一个限流系统.md
作者注:写完这篇,我都想去美团做外卖订单系统了!⏰
如果这篇文章对你有帮助,请给我一个Star⭐!