字节跳动面试:延迟队列,怎么实现的,还有没有其他实现方案?redis还有没有其他机制可以实现延迟队列?

188 阅读9分钟

面试官:延迟队列怎么实现?我答了三种他还不满意……

最近有个小伙伴在面试中被问到:

「“延迟队列怎么实现?除了Redis还有别的方案吗?”」

这老哥一口气说了三种实现方式,结果面试官还是穷追不舍……最后他跑来问我:“到底还有多少种姿势可以实现这玩意儿?” 😅

今天我们就来好好盘一盘延迟队列这个看似简单却暗藏玄机的话题。

先来说说,为什么需要延迟队列?

想象一下这个场景: 🛒

你在电商平台下了个订单,如果30分钟内没支付,订单就要自动取消。这个“30分钟后检查支付状态”的功能,用延迟队列再合适不过了!

// 伪代码 - 订单超时取消
public class OrderTimeoutHandler {
    // 订单创建时,投递一个30分钟后执行的延迟任务
    public void onOrderCreated(Order order) {
        delayQueue.offer(new DelayTask(order.getId(), 30, TimeUnit.MINUTES));
    }
    
    // 30分钟后执行的任务
    public void checkOrderPayment(String orderId) {
        Order order = orderService.getOrder(orderId);
        if (order.getStatus() == OrderStatus.UNPAID) {
            orderService.cancelOrder(orderId);
            // 同时释放库存
            inventoryService.releaseStock(order.getItems());
        }
    }
}

这不比定时轮询数据库优雅多了? ✅

方案一:Redis ZSET 实现延迟队列(最经典!)

核心思想:用分数表示执行时间

ZSET的魔力就在于它的**「排序特性」** - 每个成员都有一个分数(score),我们可以把**「执行时间戳作为分数」**,这样就能自动按时间排序了!

// Java - Spring Data Redis 完整实现
@Component
@Slf4j
public class ZSetDelayQueue {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private static final String DELAY_QUEUE_KEY = "delay_queue:orders";
    
    // 添加延迟任务
    public boolean addDelayTask(String taskId, Object taskData, long delay, TimeUnit timeUnit) {
        try {
            // 计算精确的执行时间戳
            long executeTime = System.currentTimeMillis() + timeUnit.toMillis(delay);
            
            // 构造任务消息
            DelayTaskMessage message = new DelayTaskMessage(taskId, taskData, executeTime);
            String messageJson = JSON.toJSONString(message);
            
            // 添加到ZSET,分数=执行时间
            Boolean result = redisTemplate.opsForZSet()
                .add(DELAY_QUEUE_KEY, messageJson, executeTime);
            
            log.info("添加延迟任务成功: taskId={}, 执行时间={}", taskId, 
                     LocalDateTime.ofInstant(Instant.ofEpochMilli(executeTime), ZoneId.systemDefault()));
            
            return result != null && result;
        } catch (Exception e) {
            log.error("添加延迟任务失败: taskId={}", taskId, e);
            return false;
        }
    }
    
    // 处理到期任务 - 支持批量处理
    public void processExpiredTasks(int batchSize) {
        long now = System.currentTimeMillis();
        
        try {
            // 使用Lua脚本保证原子性:获取并移除到期任务
            String luaScript = 
                "local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, ARGV[2]) " +
                "if #tasks > 0 then " +
                "   redis.call('ZREM', KEYS[1], unpack(tasks)) " +
                "end " +
                "return tasks";
            
            List<String> expiredTasks = redisTemplate.execute(
                new DefaultRedisScript<>(luaScript, List.class),
                Collections.singletonList(DELAY_QUEUE_KEY),
                String.valueOf(now), String.valueOf(batchSize)
            );
            
            // 处理任务
            for (String taskJson : expiredTasks) {
                handleTask(taskJson);
            }
            
        } catch (Exception e) {
            log.error("处理延迟任务异常", e);
        }
    }
    
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class DelayTaskMessage {
        private String taskId;
        private Object data;
        private long executeTime;
    }
}
// Golang 完整实现
package main

import (
 "context"
 "encoding/json"
 "fmt"
 "log"
 "sync"
 "time"

 "github.com/go-redis/redis/v8"
)

type ZSetDelayQueue struct {
 client *redis.Client
 key    string
 ctx    context.Context
}

type DelayTask struct {
 TaskID      string      `json:"task_id"`
 Data        interface{} `json:"data"`
 ExecuteTime int64       `json:"execute_time"`
}

func NewZSetDelayQueue(redisAddr, password string) *ZSetDelayQueue {
 client := redis.NewClient(&redis.Options{
  Addr:     redisAddr,
  Password: password,
  DB:       0,
 })

 return &ZSetDelayQueue{
  client: client,
  key:    "delay_queue:orders",
  ctx:    context.Background(),
 }
}

// 添加延迟任务
func (q *ZSetDelayQueue) AddTask(taskID string, data interface{}, delay time.Duration) error {
 executeTime := time.Now().Add(delay).UnixMilli()
 
 task := DelayTask{
  TaskID:      taskID,
  Data:        data,
  ExecuteTime: executeTime,
 }

 taskJson, err := json.Marshal(task)
 if err != nil {
  return fmt.Errorf("序列化任务失败: %v", err)
 }

 // 使用ZADD添加,分数为执行时间戳
 err = q.client.ZAdd(q.ctx, q.key, &redis.Z{
  Score:  float64(executeTime),
  Member: string(taskJson),
 }).Err()

 if err != nil {
  return fmt.Errorf("Redis操作失败: %v", err)
 }

 log.Printf("添加延迟任务成功: taskID=%s, 执行时间=%s", 
  taskID, time.UnixMilli(executeTime).Format("2006-01-02 15:04:05"))
 return nil
}

// 处理到期任务 - 使用Lua脚本保证原子性
func (q *ZSetDelayQueue) ProcessExpiredTasks(batchSize int64) ([]*DelayTask, error) {
 // Lua脚本:原子性地获取并删除到期任务
 luaScript := `
 local key = KEYS[1]
 local now = tonumber(ARGV[1])
 local batchSize = tonumber(ARGV[2])
 
 -- 获取到期的任务
 local tasks = redis.call('ZRANGEBYSCORE', key, 0, now, 'LIMIT', 0, batchSize)
 
 if #tasks > 0 then
  -- 移除这些任务
  redis.call('ZREM', key, unpack(tasks))
 end
 
 return tasks
 `

 result, err := q.client.Eval(q.ctx, luaScript, []string{q.key}, 
  time.Now().UnixMilli(), batchSize).Result()
 if err != nil && err != redis.Nil {
  return nil, err
 }

 var tasks []*DelayTask
 if result != nil {
  taskStrings := result.([]interface{})
  for _, taskStr := range taskStrings {
   var task DelayTask
   if err := json.Unmarshal([]byte(taskStr.(string)), &task); err == nil {
    tasks = append(tasks, &task)
   }
  }
 }

 return tasks, nil
}

// 启动消费者
func (q *ZSetDelayQueue) StartConsumer() {
 var wg sync.WaitGroup
 wg.Add(1)

 go func() {
  defer wg.Done()
  ticker := time.NewTicker(1 * time.Second)
  defer ticker.Stop()

  for {
   select {
   case <-ticker.C:
    tasks, err := q.ProcessExpiredTasks(100)
    if err != nil {
     log.Printf("处理延迟任务失败: %v", err)
     continue
    }

    for _, task := range tasks {
     q.handleTask(task)
    }

   case <-q.ctx.Done():
    return
   }
  }
 }()

 log.Println("延迟队列消费者已启动")
}

func (q *ZSetDelayQueue) handleTask(task *DelayTask) {
 log.Printf("处理任务: taskID=%s, data=%v", task.TaskID, task.Data)
 // 这里实现具体的业务逻辑
}

ZSET方案的核心优势 🚀

  1. 「天然排序」 - 不需要额外排序逻辑

  2. 「时间复杂度优秀」

    • 添加任务:O(log N)
    • 获取到期任务:O(log N + M) M为返回数量
  3. 「持久化支持」 - Redis数据可持久化,任务不会丢失

  4. 「分布式友好」 - 多个消费者可以同时处理

实战中的关键细节

1. 原子性操作问题

「问题」:先获取再删除不是原子操作,可能重复执行

「解决方案」:使用Lua脚本保证原子性

-- 原子获取并删除到期任务
local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, ARGV[2])
if #tasks > 0 then
    redis.call('ZREM', KEYS[1], unpack(tasks))
end
return tasks
2. 性能优化技巧
// 批量处理 + 管道优化
public void processWithPipeline(int batchSize) {
    long now = System.currentTimeMillis();
    
    // 使用管道批量操作
    List<Object> results = redisTemplate.executePipelined(new SessionCallback<Object>() {
        @Override
        public Object execute(RedisOperations operations) {
            Set<Object> tasks = operations.opsForZSet()
                .rangeByScore(DELAY_QUEUE_KEY, 0, now, 0, batchSize);
            
            if (tasks != null) {
                tasks.forEach(task -> operations.opsForZSet().remove(DELAY_QUEUE_KEY, task));
            }
            return null;
        }
    });
}

Redis 的其他实现方式

面试官可能会追问:"除了Sorted Set,Redis还有其他方式吗?"

当然有! 🕵️‍♂️

1. Keyspace Notifications + 过期键

利用Redis的过期键通知机制:

@Component
public class RedisExpirationDelayQueue {
    
    // 设置带过期时间的key
    public void addDelayTask(String taskId, long delaySeconds) {
        String key = "delay_task:" + taskId;
        redisTemplate.opsForValue().set(key, taskId, delaySeconds, TimeUnit.SECONDS);
    }
    
    // 监听过期事件
    @EventListener
    public void handleKeyExpiration(KeyExpiredEvent event) {
        String key = event.getKey();
        if (key.startsWith("delay_task:")) {
            String taskId = key.substring(11);
            // 处理过期任务
            handleExpiredTask(taskId);
        }
    }
}

「但是!」 这种方式有个大坑:Redis的键过期通知是**「不可靠」**的,消息可能会丢失,不适合重要业务场景。 ❌

2. List + Lua 脚本

用BLPOP实现简单的延迟:

-- Lua 脚本:延迟入队
local function push_delayed_task(task_key, delay_queue_key, task_data, delay_seconds)
    local target_time = redis.call('TIME')[1] + delay_seconds
    redis.call('ZADD', task_key, target_time, task_data)
    
    -- 同时记录到延迟队列索引中
    redis.call('LPUSH', delay_queue_key, task_key)
    return true
end

方案二:消息队列的延迟消息功能

现在很多消息队列都内置了延迟消息功能,比如:

RabbitMQ - 死信队列实现

@Configuration
public class RabbitDelayConfig {
    
    // 正常业务交换机
    @Bean
    public DirectExchange businessExchange() {
        return new DirectExchange("business.exchange");
    }
    
    // 延迟队列 - 设置TTL
    @Bean
    public Queue delayQueue() {
        Map<String, Object> args = new HashMap<>();
        args.put("x-dead-letter-exchange", "business.exchange"); // 死信交换机
        args.put("x-dead-letter-routing-key", "process.key");   // 死信路由键
        args.put("x-message-ttl", 30000); // 30秒TTL
        return new Queue("delay.queue", true, false, false, args);
    }
}

RocketMQ - 原生支持

public class RocketMQDelayProducer {
    
    public void sendDelayMessage(String message, int delayLevel) {
        Message msg = new Message("DelayTopic", "TagA", message.getBytes());
        // 设置延迟级别:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
        msg.setDelayTimeLevel(delayLevel);
        producer.send(msg);
    }
}

方案三:时间轮算法(Timing Wheel)

这才是真正的大杀器!Netty、Kafka都在用这种算法。

什么是时间轮?

想象一个钟表 🕐,钟表上有60个刻度(格子),每个格子代表1秒。每个格子里挂着一个链表,存储在该时刻需要执行的任务。

// 简化版时间轮实现
public class TimingWheel {
    
    private final Object[] slots;  // 时间槽
    private final int tickDuration; // 每槽代表的时间
    private final int wheelSize;    // 轮子大小
    
    private volatile int currentTick = 0; // 当前指针
    private final Timer timer;            // 推进指针的定时器
    
    public TimingWheel(int tickDuration, int wheelSize, TimeUnit timeUnit) {
        this.slots = new Object[wheelSize];
        this.tickDuration = tickDuration;
        this.wheelSize = wheelSize;
        
        // 初始化每个槽的链表
        for (int i = 0; i < wheelSize; i++) {
            slots[i] = new LinkedList<Runnable>();
        }
        
        // 启动指针推进器
        this.timer = new Timer();
        timer.scheduleAtFixedRate(new TimerTask() {
            @Override
            public void run() {
                currentTick = (currentTick + 1) % wheelSize;
                executeTasks((LinkedList<Runnable>) slots[currentTick]);
            }
        }, 0, timeUnit.toMillis(tickDuration));
    }
    
    // 添加任务
    public void addTask(Runnable task, long delay, TimeUnit unit) {
        long totalTicks = unit.toMillis(delay) / (tickDuration * 1000);
        int targetSlot = (currentTick + (int)(totalTicks % wheelSize)) % wheelSize;
        
        LinkedList<Runnable> slotTasks = (LinkedList<Runnable>) slots[targetSlot];
        synchronized (slotTasks) {
            slotTasks.add(task);
        }
    }
    
    private void executeTasks(LinkedList<Runnable> tasks) {
        synchronized (tasks) {
            while (!tasks.isEmpty()) {
                Runnable task = tasks.poll();
                // 实际生产中应该用线程池执行
                task.run();
            }
        }
    }
}

「时间轮的优点」

  • 「O(1)」 的时间复杂度添加/删除任务
  • 单个任务调度是**O(1)**性能
  • 避免Redis的网络开销

完整的生产级示例

让我们看一个电商订单超时的完整例子:

@Service
@Slf4j
public class OrderTimeoutService {
    
    @Autowired
    private ZSetDelayQueue delayQueue;
    
    @Autowired
    private OrderService orderService;
    
    // 创建订单时添加延迟检查任务
    public void onOrderCreated(Order order) {
        String taskId = "order_timeout:" + order.getOrderNo();
        OrderTimeoutTask taskData = new OrderTimeoutTask(order.getOrderNo(), order.getUserId());
        
        // 30分钟后检查支付状态
        delayQueue.addDelayTask(taskId, taskData, 30, TimeUnit.MINUTES);
        
        log.info("订单超时检查任务已创建: orderNo={}", order.getOrderNo());
    }
    
    // 处理订单超时任务
    public void handleOrderTimeout(OrderTimeoutTask task) {
        Order order = orderService.getOrderByNo(task.getOrderNo());
        
        if (order == null) {
            log.warn("订单不存在: orderNo={}", task.getOrderNo());
            return;
        }
        
        if (order.getStatus() == OrderStatus.UNPAID) {
            log.info("订单超时未支付,开始取消: orderNo={}", task.getOrderNo());
            
            // 取消订单
            orderService.cancelOrder(task.getOrderNo(), "超时未支付");
            
            // 释放库存
            inventoryService.releaseStock(order.getItems());
            
            // 发送通知
            notificationService.sendOrderCanceledMsg(task.getUserId(), task.getOrderNo());
        } else {
            log.info("订单已支付,无需处理: orderNo={}, status={}", 
                     task.getOrderNo(), order.getStatus());
        }
    }
    
    @Data
    public static class OrderTimeoutTask {
        private String orderNo;
        private Long userId;
        
        public OrderTimeoutTask(String orderNo, Long userId) {
            this.orderNo = orderNo;
            this.userId = userId;
        }
    }
}

各方案对比

方案优点缺点适用场景
Redis ZSET实现简单、支持持久化、天然排序需要轮询、网络开销大中小规模、已有Redis环境
Redis 过期键实现极其简单消息可能丢失、不可靠非关键业务、可接受数据丢失
RabbitMQ DLX利用现有组件、可靠配置复杂、TTL固定已有RabbitMQ、要求可靠性的场景
RocketMQ延迟原生支持、使用简单延迟级别固定、不够灵活使用RocketMQ的场景
时间轮性能极高、低延迟内存易失、实现复杂高性能要求、单机场景

实战建议

  1. 「数据量小」 → 直接用Redis ZSET,简单够用
  2. 「要求可靠性」 → 用RabbitMQ死信队列或RocketMQ延迟消息
  3. 「高性能要求」 → 时间轮算法,比如限流控制、心跳检测等
  4. 「分布式场景」 → Redis ZSET + 集群分片

监控和运维建议 📊

@Component
public class DelayQueueMonitor {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // 监控队列积压情况
    public DelayQueueMetrics getQueueMetrics() {
        String key = "delay_queue:orders";
        
        Long totalTasks = redisTemplate.opsForZSet().size(key);
        Long expiredTasks = redisTemplate.opsForZSet()
            .count(key, 0, System.currentTimeMillis());
        
        return new DelayQueueMetrics(totalTasks, expiredTasks);
    }
    
    // 清理过期任务(防止积压)
    public long cleanupExpiredTasks() {
        long now = System.currentTimeMillis();
        Long removed = redisTemplate.opsForZSet()
            .removeRangeByScore("delay_queue:orders", 0, now - 24 * 60 * 60 * 1000); // 清理24小时前的
        
        log.info("清理过期任务完成: 数量={}", removed);
        return removed != null ? removed : 0;
    }
}

总结:ZSET方案的适用场景

「适合」

  • 任务量中等(日百万级别以内)
  • 延迟精度要求秒级
  • 已有Redis环境,希望快速上线

「不适合」

  • 超大规模(日千万级以上)
  • 延迟精度要求毫秒级
  • 对Redis网络延迟敏感的场景

ZSET方案最大的优势就是**「简单可靠」**,在大多数业务场景下完全够用。

思考题 🤔

如果我要实现一个**「分布式延迟队列」**,支持千万级任务量,该怎么设计?

提示:可以考虑**「分片 + 多级时间轮 + 持久化」**的方案,这个话题我们下次再聊!


觉得有用的话,**「点赞、在看、转发」**三连支持一下呗! 😊

有什么想了解的技术话题,也欢迎在评论区告诉我~