引言
延迟队列,简单来说就是让消息在指定时间后才被消费的队列。比如订单超时取消、消息延迟推送、定时任务调度等场景,都需要用到这种定时触发的机制。
起因是这样的。去年刚进公司时小组组织了一次技术竞赛,主题就是基于Redis的延迟队列实现方案。组里四个人各自设计方案、写代码,最终我的方案幸运地被选中并整合到了公司内部Core包中。
现在这套延迟队列方案已经稳定运行在我们的直播、竞拍等多个项目中,支撑着各种延迟业务场景。
至于为什么不用现成的RabbitMQ?原因很简单------当时领导定的方案 Orz。
简单说说它有哪些特性:
- 多 Topic 支持,可按业务隔离逻辑队列;
- 高并发消费能力,适应突发任务量;
- 失败重试机制,避免任务丢失;
- 灵活的队列消费注册与监听接口,便于业务对接;
- 延迟任务追加与幂等控制,可自定义策略合并任务;
了解了这些之后,我们来盘点整体方案。

架构设计
核心设计原则
- 单一职责原则:生产者只负责消息发送,消费者只负责消息处理,观察者负责监控
- 开闭原则:通过接口抽象,支持不同业务场景的消费逻辑扩展
- 依赖倒置原则:业务层依赖抽象接口,而非具体实现
- 高内聚低耦合:各组件职责清晰,相互依赖最小化
整体架构图
系统组件设计
消息实体设计
消息实体是整个系统的数据载体,需要包含完整的生命周期信息:
java
public class DelayMessage {
private String topic; // 消息主题
private String messageId; // 唯一标识
private Object payload; // 业务数据
private long delayTime; // 延迟时间(毫秒)
private int retryCount; // 当前重试次数
// ... 省略其他字段和方法
}
设计要点:
- 使用UUID确保消息ID全局唯一
- 支持泛型payload,适应不同业务场景
- 内置重试机制相关字段,支持失败重试
生产者设计
生产者采用静态工厂模式,提供简洁的API接口:
java
public class DelayQueueProducer {
private static final RedissonClient redissonClient = RedissonConfig.getClient();
private static final ObjectMapper objectMapper = new ObjectMapper();
public static <T> boolean sendDelayMessage(String topic, T payload, long delaySeconds) {
DelayMessage message = new DelayMessage();
message.setTopic(topic);
message.setPayload(payload);
message.setDelayTime(System.currentTimeMillis() + delaySeconds * 1000);
// 获取延迟队列并发送消息
RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(
redissonClient.getBlockingQueue(QUEUE_PREFIX + topic));
// ... 序列化和发送逻辑
return true;
}
}
亮点:
- 静态方法设计,无需实例化,降低使用复杂度
- 支持泛型,类型安全
- 内置JSON序列化,简化使用
消费者接口设计
消费者接口采用策略模式,支持不同的业务处理逻辑:
java
public interface DelayQueueConsumer<T> {
/**
* 处理延迟消息
* @param message 消息内容
* @return 处理结果
*/
ConsumeResult consume(T message);
/**
* 获取消费的数据类型
*/
Class<T> getMessageType();
/**
* 获取订阅的topic
*/
String getTopic();
}
消费者工厂设计
核心
工厂模式在延迟队列系统中发挥着至关重要的作用,它不仅封装了队列获取和消息处理的复杂逻辑,更是整个系统的控制中枢:
java
public class DelayQueueConsumerFactory {
private final Map<String, DelayQueueConsumer<?>> consumers = new ConcurrentHashMap<>();
private final ExecutorService executorService;
private final RedissonClient redissonClient;
// 工厂不仅创建,更要管理整个生命周期
public synchronized <T> DelayQueueConsumer<T> createConsumer(String topic,
Class<T> messageType,
MessageHandler<T> handler) {
if (consumers.containsKey(topic)) {
return (DelayQueueConsumer<T>) consumers.get(topic);
}
DelayQueueConsumer<T> consumer = new DelayQueueConsumer<>(topic, messageType, handler, this);
consumers.put(topic, consumer);
// 启动消费线程
executorService.submit(consumer);
return consumer;
}
}
解决程序重启后的队列连接问题
这里有一个非常重要的技术细节,也是生产环境中经常遇到的问题:
java
public class DelayQueueConsumer<T> implements Runnable {
private final RBlockingQueue<T> blockingQueue;
private final RDelayedQueue<T> delayedQueue;
public DelayQueueConsumer(String topic, Class<T> messageType,
MessageHandler<T> handler,
DelayQueueConsumerFactory factory) {
this.blockingQueue = factory.getRedissonClient().getBlockingQueue(topic);
// 这里是关键:解决程序重启后blockingQueue无法获取队列的问题
this.delayedQueue = factory.getRedissonClient().getDelayedQueue(blockingQueue);
this.messageType = messageType;
this.handler = handler;
}
@Override
public void run() {
Thread.currentThread().setName("DelayQueue-Consumer-" + topic);
while (!Thread.currentThread().isInterrupted()) {
try {
T message = blockingQueue.take(); // 阻塞等待消息
processMessage(message);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
log.error("消费消息异常", e);
}
}
}
}
为什么需要显式调用getDelayedQueue?
这是一个容易被忽略但极其重要的技术点:
- Redisson的DelayedQueue和BlockingQueue是关联关系
- 程序重启后,如果只获取BlockingQueue,延迟队列的定时转移机制可能失效
- 通过
getDelayedQueue(blockingQueue)
确保延迟队列与阻塞队列的关联关系正确建立 - 这保证了即使程序重启,之前放入延迟队列的消息仍能在指定时间后正确转移到阻塞队列
生命周期管理
消费者工厂不仅负责创建,更承担着完整的生命周期管理职责:
java
public class DelayQueueConsumerFactory {
private final Map<String, DelayQueueConsumer<?>> consumers = new ConcurrentHashMap<>();
private final ExecutorService executorService;
private volatile boolean shutdown = false;
// 优雅关闭机制
public void shutdown() {
shutdown = true;
// 停止接受新任务
executorService.shutdown();
try {
// 等待现有任务完成
if (!executorService.awaitTermination(30, TimeUnit.SECONDS)) {
executorService.shutdownNow();
}
} catch (InterruptedException e) {
executorService.shutdownNow();
Thread.currentThread().interrupt();
}
// 清理资源
consumers.clear();
}
// 健康检查
public Map<String, Boolean> getConsumerStatus() {
Map<String, Boolean> status = new HashMap<>();
consumers.forEach((topic, consumer) -> {
status.put(topic, consumer.isRunning());
});
return status;
}
}
消息处理逻辑
在消费者内部,消息处理逻辑经过了精心设计:
java
private void processMessage(T message) {
String traceId = generateTraceId();
MDC.put("traceId", traceId);
try {
// 消息反序列化校验
if (message == null) {
log.warn("接收到空消息,跳过处理");
return;
}
// 执行业务处理
handler.handle(message);
log.info("消息处理成功: {}", message);
} catch (Exception e) {
log.error("消息处理失败: {}", message, e);
// 这里可以实现重试逻辑或死信队列
handleProcessFailure(message, e);
} finally {
MDC.remove("traceId");
}
}
强扩展性
通过工厂模式,可以轻松支持不同类型的消费策略:
java
public class DelayQueueConsumerFactory {
// 支持批量消费
public <T> BatchDelayQueueConsumer<T> createBatchConsumer(String topic,
Class<T> messageType,
BatchMessageHandler<T> handler,
int batchSize) {
// 实现批量消费逻辑
}
// 支持优先级消费
public <T> PriorityDelayQueueConsumer<T> createPriorityConsumer(String topic,
Class<T> messageType,
PriorityMessageHandler<T> handler) {
// 实现优先级消费逻辑
}
// 支持有序消费
public <T> OrderedDelayQueueConsumer<T> createOrderedConsumer(String topic,
Class<T> messageType,
OrderedMessageHandler<T> handler) {
// 实现有序消费逻辑
}
}
核心流程设计
消息发送流程
消息消费流程
高可用性设计
在实现主要的流程后,我们需要考虑一些实际生产环境中会遇到的问题。理想很丰满,现实很骨感,系统总会出现各种意想不到的情况。
下面几个机制值得花点时间好好进行打磨。
防重复消费机制
重复消费是个很常见的问题。
比如网络抖动导致消息重发,或者消费者处理完消息后由于网络原因没有及时确认,都可能造成同一条消息被处理多次。
我们的解决方案是在消费前先获取分布式锁:
java
private void processMessageWithLock(DelayMessage message) {
String lockKey = CONSUME_LOCK_PREFIX + message.getMessageId();
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁,30秒等待时间,300秒持有时间
if (lock.tryLock(30, 300, TimeUnit.SECONDS)) {
try {
// 处理消息的业务逻辑
handleMessage(message);
log.info("消息处理成功: {}", message.getMessageId());
} catch (Exception e) {
log.error("消息处理失败: {}", message.getMessageId(), e);
// 处理失败时的重试逻辑
handleProcessFailure(message, e);
} finally {
// 无论成功还是失败,都要释放锁
lock.unlock();
}
} else {
// 获取锁失败,说明其他消费者正在处理或已处理过这条消息
log.warn("获取消息锁失败,可能存在重复消费: {}", message.getMessageId());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("获取锁被中断: {}", message.getMessageId());
}
}
这里用到了Redisson锁。整个流程是这样的:
- 基于消息ID生成锁的key,保证同一条消息使用同一把锁
- 尝试获取锁,如果获取成功就处理消息
- 处理完成后在finally块中释放锁,确保锁一定会被释放
- 如果获取锁失败,说明其他消费者正在处理这条消息,直接跳过
参数说明:
- 第一个30秒是等待锁的时间,如果30秒内获取不到锁就放弃
- 第二个300秒是锁的持有时间,防止消费者宕机后锁一直不释放
这样设计的好处是既防止了重复消费,又确保了锁资源能够正确释放。
失败重试策略
消息处理失败是不可避免的,可能是网络问题、数据库连接问题、业务逻辑异常等等。简单粗暴的立即重试并不可取,所以我们采用指数退避的策略:
java
private long calculateDelayTime(int retryCount) {
// 指数退避:1s, 2s, 4s, 8s...
return (long) Math.pow(2, retryCount) * 1000;
}
这个算法的好处是重试间隔会越来越长:第一次失败后等1秒重试,第二次失败后等2秒,第三次等4秒,以此类推。这样既给了系统恢复的时间,又避免了频繁重试对系统造成额外压力。
在实际使用中,我们应该根据不同的异常类型制定不同的重试策略。
比如网络超时可以快速重试,而业务逻辑错误可能就不需要重试了。
死信队列处理
有些消息可能因为各种原因始终无法处理成功,比如数据格式错误、依赖的外部服务长期不可用等。如果一直重试下去,不仅浪费系统资源,还可能影响其他正常消息的处理。
这时候死信队列就派上用场了:
java
private void sendToDeadLetterQueue(DelayMessage message) {
RQueue<String> deadLetterQueue = redissonClient
.getQueue(DEAD_LETTER_PREFIX + message.getTopic());
// ... 省略序列化和发送逻辑
}
当消息重试次数超过设定的阈值(比如3次或5次),系统会自动将消息转移到死信队列中。
进入死信队列后的消息,可以根据实际的业务需求来做处理,比如下面这些常见的处理措施:
- 人工介入处理
- 定期分析找出共同问题
- 在问题修复后重新投递到正常队列
这样既保证了系统的稳定运行,又不会丢失业务数据。
监控与观察者模式
消息生命周期监控
监听器接口设计
java
public interface DelayQueueListener {
void onMessageSent(DelayMessage message);
void onMessageConsumed(DelayMessage message, ConsumeResult result);
void onMessageRetry(DelayMessage message, int retryCount);
void onMessageFailed(DelayMessage message, Throwable cause);
}
性能优化策略
列一些可能用到的优化策略。
连接池优化
- 使用Redisson的连接池配置,合理设置最大连接数
序列化优化
- 使用Jackson的优化配置,减少序列化开销
- 如有必要,可以考虑使用Protobuf等二进制序列化方案
并发处理优化
- 消费者使用线程池并行处理消息
- 根据业务场景去调整线程池大小
内存优化
- 及时清理过期的消费标记
- 结合Redis的TTL机制自动清理数据
使用示例
定义消费者
java
@Component
public class OrderTimeoutConsumer implements DelayQueueConsumer<OrderInfo> {
@Override
public ConsumeResult consume(OrderInfo orderInfo) {
try {
// 业务处理逻辑:取消超时订单
orderService.cancelOrder(orderInfo.getOrderId());
return ConsumeResult.SUCCESS;
} catch (Exception e) {
log.error("处理订单超时失败", e);
return ConsumeResult.RETRY;
}
}
@Override
public Class<OrderInfo> getMessageType() {
return OrderInfo.class;
}
@Override
public String getTopic() {
return "order.timeout";
}
}
发送延迟消息
java
// 发送30分钟延迟的订单超时消息
OrderInfo orderInfo = new OrderInfo(orderId, userId);
DelayQueueProducer.sendDelayMessage("order.timeout", orderInfo, 30 * 60);
写在最后
从去年到现在,这套延迟队列方案已经在生产环境稳定运行了一年多。期间经历了各种流量冲击和异常场景的考验,总体来说还是比较抗打的。
当然,这个方案也不是完美的。比如在极高并发场景下,Redis的性能可能会成为瓶颈;再比如分布式锁的开销在某些业务场景下可能显得有点重。但对于大部分中小型项目来说,这套方案的复杂度和性能平衡点还是不错的。
有朋友可能会问,为什么不直接用RabbitMQ的延迟插件或者Kafka的时间轮?说实话,如果重新选型的话,我可能真的会考虑这些成熟的方案。但既然已经造了这个轮子,而且用得还不错,那就继续打磨下去吧。
后续如果有时间,我打算把这套代码整理一下开源到GitHub上,让更多的同学能够参考和使用。毕竟技术这东西,分享出来才有价值。如果你们在使用过程中遇到什么问题,或者有更好的优化建议,欢迎在评论区讨论。
最后,如果这篇文章对你有帮助,麻烦点个赞支持一下,您的每一个赞都是对原创作者最大的鼓励~
技术路上,我们一起前行。
