什么是延迟消息
延迟消息顾名思义不是用户能立即消费到的,而是等待一段特定的时间才能收到。举例如下场景比较适合使用延时消息:
- 场景一:物联网系统经常会遇到向终端下发命令,如果终端一段时间没有应答,就需要设置命令的状态为超时。
- 场景二:订单下单之后30分钟后,如果用户没有付钱,则系统自动取消订单。
实现延迟消息的方式有很多,常见的有:数据库、DelayQueue、时间轮、RabbitMQ等,而RocketMQ同样支持延迟消息。下面我们就来看看 RocketMQ 是怎样实现延迟消息的。本文参考的源码版本为:4.9.4
在RocketMQ中使用延迟消息
不像其他延迟消息的实现,客户端可以自定义延迟时间,而RocketMQ则不支持任意时间的延迟,它提供了18个级别(延迟时间选择)。分别是:
1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
你可以简单的使用如下代码实现向test
这个Topic中发送延迟消息:
DefaultMQProducer producer = new DefaultMQProducer("test-producer");
producer.setNamesrvAddr("172.27.224.1:9876");
producer.start();
Message message = new Message("test", "TestTag", "TestKey", "Hello".getBytes(StandardCharsets.UTF_8));
message.setDelayTimeLevel(2);
SendResult sendResult = producer.send(message);
log.info("消息发送状态:" + sendResult.getSendStatus().name());
以上代码里面使用到了Message#setDelayTimeLevel
,结合上面的18个延迟级别,源码中delayLevel=1 表示延迟1s,delayLevel=2 表示延迟5s,以此类推。setDelayTimeLevel(2)代表消费者可以在5s以后收到。
主要实现流程
RocketMQ专门定义了一个Topic:SCHEDULE_TOPIC_XXXX
来实现延迟消息。这里面有18个队列,每个队列对应一个延迟级别。比如队列0就代表延迟1s的队列,队列1就代表延迟5s的队列。生产者把延迟消息发送到Broker之后,Broker会根据生产者定义的延迟级别放到对应的队列中。而消息原本应该去的Topic和队列,会暂时存放在消息的属性(property)中。
另一方面,在RocketMQ启动后,会有专门的线程池去处理延迟消息。比如18个延迟级别,就会生成18个定时任务,每个任务对应一个队列。这个任务会每隔100毫秒去查看对应队列中的消息,判断消息的执行时间。如果到了执行时间,那么就把消息发送到其本该投递的Topic中,这样消费者就能消费到消息了。同时,该任务会不断循环判断队列中的每一个消息,直到消息的执行时间还没有到,停止消息的遍历。这就是RocketMQ实现延迟消息的主要流程。
源码分析
这一节,我们通过延迟消息的源码分析来进一步理解其原理。
Producer
首先来看客户端,在客户端Producer中,发送延迟消息和一般消息的不同就是Message#setDelayTimeLevel
方法。它其实就是把延迟的级别放到Message的Property中发送到Broker。
public void setDelayTimeLevel(int level) {
this.putProperty(MessageConst.PROPERTY_DELAY_TIME_LEVEL, String.valueOf(level));
}
Broker接收消息处理
Broker收到要发送的消息后,判断如果是延迟消息(getDelayTimeLevel() > 0
),则把消息的Topic设置成 SCHEDULE_TOPIC_XXXX,队列Id设置成 delayLevel-1。 而消息原本的目标topic和queueId则被放到了消息的属性(property)中,以备后面使用。
// Delay Delivery
if (msg.getDelayTimeLevel() > 0) {
if (msg.getDelayTimeLevel() > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
msg.setDelayTimeLevel(this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel());
}
topic = TopicValidator.RMQ_SYS_SCHEDULE_TOPIC;
int queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel());
// Backup real topic, queueId
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_TOPIC, msg.getTopic());
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_QUEUE_ID, String.valueOf(msg.getQueueId()));
msg.setPropertiesString(MessageDecoder.messageProperties2String(msg.getProperties()));
msg.setTopic(topic);
msg.setQueueId(queueId);
}
核心类ScheduleMessageService的启动:load
RocketMQ中处理延迟消息的地方主要在类ScheduleMessageService
中。ScheduleMessageService
随着Broker启动,首先执行load()
方法,加载SCHEDULE_TOPIC_XXXX
中每个队列中发送的offset。SCHEDULE_TOPIC_XXXX
也是Topic,也需要维护offset。
@Override
public boolean load() {
boolean result = super.load();
result = result && this.parseDelayLevel();
result = result && this.correctDelayOffset();
return result;
}
offset维护在一个文件中,load()
方法首先会加载文件${ROCKETMQ_HOME}/store/config/delayOffset.json
,文件内容如下,记录了每个delayLevel所对应的已经发送的offset。
{
"offsetTable":{2:1,6:1,8:1}
}
程序把上面的文件信息加载到内存ConcurrentMap
中,key是delayLevel,值是offset。便于重启后能加载上一次的状态,继续发送之前待发送的消息。
在方法parseDelayLevel()
中,同样构建 ConcurrentMap delayLevelTable
,key是delayLevel,值是对应延迟的时间。以此作为在内存中的配置,便于后续使用。
在源码中,类ScheduleMessageService
中queueId和delayLevel的关系如下:
// 根据queueId获取delayLevel
public static int queueId2DelayLevel(final int queueId) {
return queueId + 1;
}
// 根据delayLevel获取queueId
public static int delayLevel2QueueId(final int delayLevel) {
return delayLevel - 1;
}
核心方法:start
下面来看ScheduleMessageService
类的核心方法start()
。start()
方法根据延迟级别创建对应的定时任务检查SCHEDULE_TOPIC_XXXX
的每一个队列,并且启动定时任务持久化延迟消息的队列进度,就是上文load
方法中的offset。生成定时任务的数量和支持的DelayLevel有关,如果支持18个延迟级别,那么就会生成18个定时任务。每个任务监控 SCHEDULE_TOPIC_XXXX 的一个队列。它会循环一个一个把队列中的消息拿出来,判断是否到了发送的时间,如果到了,就根据偏移量和消息的大小去CommitLog中查找真正的消息。
检查消息的源码在方法executeOnTimeup
中。
根据偏移量找具体消息的源码如下:
MessageExt msgExt = ScheduleMessageService.this.defaultMessageStore.lookMessageByOffset(offsetPy, sizePy);
拿到原始的消息后,重新设置原本目标的Topic和QueueId,通过syncDeliver
重新发送。这里就用到了之前保存在消息的属性(property)中的原本的目标Topic和队列Id。如果发送的时间还没有到,则退出循环,不再看下去了。因为在队列中的消息本来就是有序的(按照发送时间排序,又是同一个延迟级别),前面一个没有到时间,那么后面一个也不会到时间。完成本轮的循环查看后,采用链式调用,再生成一个该延迟级别的检查任务。检查是不是里面有消息到了发送时间了。
在执行检查延迟消息队列任务时,start()
方法还会执行persist()
方法。ScheduleMessageService的ScheduleMessageService#persist()
方法和load()
方法对应,是持久化offset到文件。start()
方法启动后,延迟10s后执行。之后,以默认频率10s执行一次持久化。并且在shutdown()
方法中,也会执行。
为什么不支持任意时间
RocketMQ并不支持任意时间的延迟,个人觉得主要的原因还是因为性能。如果提供任意时间,就会涉及到消息的排序,会有一定的性能损耗。而RocketMQ这种利用固定延迟级别到单个队列的实现方式是一种妥协,灵活性和极致性能的妥协。
是否可以动态的添加Topic:SCHEDULE_TOPIC_XXXX
的队列呢?
如果延迟级别很多,队列就会很多,会不会有其他的性能问题?