面试官:延迟队列怎么实现?我答了三种他还不满意……
最近有个小伙伴在面试中被问到:
「“延迟队列怎么实现?除了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方案的核心优势 🚀
-
「天然排序」 - 不需要额外排序逻辑
-
「时间复杂度优秀」:
- 添加任务:O(log N)
- 获取到期任务:O(log N + M) M为返回数量
-
「持久化支持」 - Redis数据可持久化,任务不会丢失
-
「分布式友好」 - 多个消费者可以同时处理
实战中的关键细节
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的场景 |
| 时间轮 | 性能极高、低延迟 | 内存易失、实现复杂 | 高性能要求、单机场景 |
实战建议
- 「数据量小」 → 直接用Redis ZSET,简单够用
- 「要求可靠性」 → 用RabbitMQ死信队列或RocketMQ延迟消息
- 「高性能要求」 → 时间轮算法,比如限流控制、心跳检测等
- 「分布式场景」 → 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方案最大的优势就是**「简单可靠」**,在大多数业务场景下完全够用。
思考题 🤔
如果我要实现一个**「分布式延迟队列」**,支持千万级任务量,该怎么设计?
提示:可以考虑**「分片 + 多级时间轮 + 持久化」**的方案,这个话题我们下次再聊!
觉得有用的话,**「点赞、在看、转发」**三连支持一下呗! 😊
有什么想了解的技术话题,也欢迎在评论区告诉我~