⏰ 设计一个分布式延迟任务调度系统:定时器的艺术!

15 阅读10分钟

📖 开场:外卖订单超时

想象你在美团点外卖 🍜:

场景

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│ │ │ │...
   └─┴─┴─┴─┴─┴─┴─┴─┘
     ↑
   指针(每秒移动一格)

任务A2秒后执行 → 放在槽位1
任务B5秒后执行 → 放在槽位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 → 延迟10level=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: 三种方案

  1. 数据库轮询

    • 定时扫描数据库
    • 缺点:性能差 ❌
  2. Redis ZSet(推荐):

    • score = 执行时间戳
    • 定时扫描到期任务
    • 优点:性能高 ✅
  3. 时间轮

    • 环形数组 + 指针
    • 优点: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: 两种方案

  1. 分布式锁

    // 扫描时获取锁
    if (redisTemplate.opsForValue().setIfAbsent("lock:scan", "1", 5, TimeUnit.SECONDS)) {
        // 只有一个服务器扫描
        scanAndExecute();
    }
    
  2. CAS删除

    // 执行前先删除(原子操作)
    Long removed = redisTemplate.opsForZSet().remove(DELAY_QUEUE_KEY, taskId);
    if (removed > 0) {
        // 删除成功,执行任务
        executeTask(taskId);
    }
    

Q5: 如何保证任务不丢失?

A: 三种保障

  1. 持久化

    • Redis AOF/RDB
    • MySQL存储任务详情
  2. 重试机制

    try {
        executeTask(task);
    } catch (Exception e) {
        // 重试3次
        if (task.getRetryCount() < 3) {
            reAddTask(task);
        }
    }
    
  3. 定时扫描

    // 每小时扫描数据库中PENDING状态的任务
    // 如果执行时间已过,重新加入队列
    @Scheduled(cron = "0 0 * * * ?")
    public void scanPendingTasks() {
        List<DelayTask> tasks = taskDao.findPendingTasks();
        for (DelayTask task : tasks) {
            reAddTask(task);
        }
    }
    

Q6: RocketMQ延迟消息的限制是什么?

A: 限制

  1. 固定延迟级别

    • 只有18个级别
    • 不支持任意延迟时间
  2. 无法取消

    • 发送后无法取消
    • 只能在消费时判断
  3. 最大延迟2小时

    • level=18 → 2小时
    • 超过2小时需要多次发送

适用场景

  • 固定延迟时间(如15分钟)
  • 不需要取消任务

🎬 总结

     延迟任务调度方案对比

┌────────────────────────────────────┐
│ 数据库轮询                         │
│ - 定时扫描数据库                   │
│ - 性能差 ❌                        │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ Redis ZSet(推荐)⭐⭐⭐            │
│ - score = 执行时间                 │
│ - 性能高,支持分布式               │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 时间轮                             │
│ - 环形数组 + 指针                  │
│ - O(1)性能,但单机                 │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ RocketMQ延迟消息                   │
│ - 可靠性高                         │
│ - 但延迟级别固定                   │
└────────────────────────────────────┘

    Redis ZSet是最佳选择!✅

🎉 恭喜你!

你已经完全掌握了分布式延迟任务调度系统的设计!🎊

核心要点

  1. Redis ZSet:score = 执行时间戳
  2. 分布式锁:防止重复执行
  3. 持久化:MySQL + Redis AOF
  4. 重试机制:保证任务不丢失

下次面试,这样回答

"延迟任务调度系统采用Redis ZSet实现。任务添加时,将任务ID作为member,执行时间戳作为score存入ZSet。定时任务每秒扫描一次,使用ZRANGEBYSCORE查询已到期的任务(score <= 当前时间),执行后删除。

为防止多服务器重复执行,扫描时先获取分布式锁。任务详情存储在MySQL,Redis只存任务ID和执行时间,减少内存占用。

可靠性方面,Redis使用AOF持久化,MySQL存储任务详情作为备份。任务执行失败后自动重试3次,每小时还有定时任务扫描数据库中PENDING状态的任务,重新加入队列。

我们项目的订单超时取消系统采用这套方案,支持百万级延迟任务,平均执行误差50ms以内,运行稳定。"

面试官:👍 "很好!你对分布式延迟任务调度系统的设计理解很深刻!"


本文完 🎬

上一篇: 204-设计一个新闻Feed流系统.md
下一篇: 206-设计一个限流系统.md

作者注:写完这篇,我都想去美团做外卖订单系统了!⏰
如果这篇文章对你有帮助,请给我一个Star⭐!