什么是延迟消息

延迟消息顾名思义不是用户能立即消费到的,而是等待一段特定的时间才能收到。举例如下场景比较适合使用延时消息:

  • 场景一:物联网系统经常会遇到向终端下发命令,如果终端一段时间没有应答,就需要设置命令的状态为超时。
  • 场景二:订单下单之后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的队列呢?
如果延迟级别很多,队列就会很多,会不会有其他的性能问题?

posted on 2023-01-17 23:33  nick hao  阅读(2262)  评论(0编辑  收藏  举报