RocketMQ 之 延迟消息原理

2,720 阅读4分钟

概述

在 JDK 中, ScheduledThreadPoolExecutor 有提供延时消息的功能,但是因为内部的队列使用的是 DelayedWorkQueue 队列,而 DelayedWorkQueue 使用的是 ,堆的插入时间复杂度均为 O(logn), 这个效率是非常低的,绝大多数的 MQ 都无法忍受这样的效率。

因此,MQ 都会重新实现延迟功能。

RocketMQ 在延迟消息的实现上更是取巧,没有使用大多数 MQ 会使用的 时间轮 算法,而是简单的通过 Timer 实现。

延迟消息转存

producer 发送延迟消息的时候,broker 肯定必须要等到消息到期了,才能让 consumer 消费消息。

也就是说,消息在 broker 端必须做额外的处理,才能避免。

topic、queueId 转存

broker 在消息准备写入 commitlog 之前,会将消息 topic 重设为 SCHEDULE_TOPIC_XXXX, queueId 重设为 delayLevel - 1。 再将原始的 topic, queueId 写入到消息的 properties 中,以便消息到期时,能够恢复原消息的 topic, queueId

代码位置:DefaultMessageStore#putMessage(final MessageExtBrokerInner msg)


public class CommitLog {

    public PutMessageResult putMessage(final MessageExtBrokerInner msg) {

    if (msg.getDelayTimeLevel() > 0) {

        if (msg.getDelayTimeLevel() > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {

        msg.setDelayTimeLevel(this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel());

        }

        //TODO 将发送的延迟消息转存到另外的 SCHEDULE_TOPIC_XXXX topic 中
        topic = ScheduleMessageService.SCHEDULE_TOPIC;

        // TODO 将延迟等级 -1 作为我们的 queueId
        queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel());

        // Backup real topic, queueId
        // TODO 备份我们的原始 topic, 以及原始 queueId
        // TODO REAL_TOPIC, REAL_QID
        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);

        }
   }
}

经过以上处理后,Consumer 就无法立即消费到刚发送的延迟消息。

ConsumeQueue 子条目 tagsCode 重置

Broker 在将 CommitLog 转存到 ConsumeQueue 时,如果发现是延迟消息,会将 tagsCode 设置为 消息的到期时间。

代码位置:CommitLog#checkMessageAndReturnSize


public class CommitLog {

    public DispatchRequest checkMessageAndReturnSize(java.nio.ByteBuffer byteBuffer, final boolean checkCRC,

    final boolean readBody) {

    //TODO 如果是延迟消息,那么会将消息的到期时间,存储为 tagsCode

        if (delayLevel > 0) {

            tagsCode = this.defaultMessageStore.getScheduleMessageService().computeDeliverTimestamp(delayLevel,

            storeTimestamp);

        }

    }

}

消息到期处理

Broker 如何感知到消息已经到期,可以让 Consumer 消费,这是重点。下面,来看看 RocketMQ 的取巧处理。

我们知道,Broker 端可以通过配置 messageDelayLevel 来改变 RocketMQ 默认延迟等级配置。

下面就是 RocketMQ 默认的配置


messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h

也就是说,RocketMQ 其实无法像其他 MQ 一样提供灵活的延迟消息,必须通过配置文件配置。

不提供这样的功能,就是为了更加简单搞笑的实现延迟消息功能。

延迟消息的实现,均在该类下 ScheduleMessageService

解析 messageDelayLevel 配置

Broker 启动时,会解析 messageDelayLevel 配置

代码位置: ScheduleMessageService#parseDelayLevel()

主要的逻辑如下:

解析 messageDelayLevel 并将值放入 delayLevelTable map 中。


public class ScheduleMessageService extends ConfigManager {

    public boolean parseDelayLevel() {

        HashMap<String, Long> timeUnitTable = new HashMap<String, Long>();

        timeUnitTable.put("s", 1000L);

        timeUnitTable.put("m", 1000L * 60);

        timeUnitTable.put("h", 1000L * 60 * 60);

        timeUnitTable.put("d", 1000L * 60 * 60 * 24);

        String levelString = this.defaultMessageStore.getMessageStoreConfig().getMessageDelayLevel();

        try {

            String[] levelArray = levlString.split(" ");

            for (int i = 0; i < levelArray.length; i++) {

                String value = levelArray[i];

                String ch = value.substring(value.length() - 1);

                Long tu = timeUnitTable.get(ch);

                int level = i + 1;

                if (level > this.maxDelayLevel) {

                    this.maxDelayLevel = level;

                }

                long num = Long.parseLong(value.substring(0, value.length() - 1));

                long delayTimeMillis = tu * num;

                this.delayLevelTable.put(level, delayTimeMillis);

            }

        } catch (Exception e) {

            log.error("parseDelayLevel exception", e);

            log.info("levelString String = {}", levelString);

            return false;
        }
        return true;
    }
}

Timer 启动

代码位置: ScheduleMessageService#start()

public void start() {
    if (started.compareAndSet(false, true)) {
        this.timer = new Timer("ScheduleMessageTimerThread", true);
        // 获取配置的延迟等级
        for (Map.Entry<Integer, Long> entry : this.delayLevelTable.entrySet()) {
            Integer level = entry.getKey();
            Long timeDelay = entry.getValue();
            // 每个延迟等级的消费偏移
            Long offset = this.offsetTable.get(level);
            if (null == offset) {
                offset = 0L;
            }

            // 初始化延迟调度任务

            if (timeDelay != null) {
                this.timer.schedule(new DeliverDelayedMessageTimerTask(level, offset), FIRST_DELAY_TIME);
            }
        }
    }
}

offsetTable 是在 Broker 启动的时候,从 ${storePath}/store/config/delayOffset.json 解析出来的。该文件存储的内容是一个 json, 存放着每个延迟等级对应的 的消费偏移。大致如下


{
"offsetTable":{1:10}
}

DeliverDelayedMessageTimerTask

Timer 中的任务是 DeliverDelayedMessageTimerTask, 接下来看下该类的 run() 方法实现逻辑。

run() 执行的逻辑如下

  1. 根据 延迟队列的 消费偏移,从对应队列中获取消息

  2. 根据 ConsumeQueue 子条目中的 tagsCode 拿到消息存储时的时间戳

  3. tagsCode 与当前时间对比,如果小于等于当前时间,则将延迟消息恢复为原消息,供 Consumer 消费

  4. 继续调度下一个延迟消息

消费偏移持久化

Broker 每隔 10s 就会将 延迟消息消费偏移 持久化。

代码位置:ScheduleMessageService#start()


public class ScheduleMessageService extends ConfigManager {

    public void start() {
        if (started.compareAndSet(false, true)) {
            // 默认每隔 10s,执行一次持久化
            this.timer.scheduleAtFixedRate(new TimerTask() {
                @Override
                public void run() {
                    try {
                        if (started.get()) ScheduleMessageService.this.persist();
                    } catch (Throwable e) {
                        log.error("scheduleAtFixedRate flush exception", e);
                    }
                }
            }, 10000, this.defaultMessageStore.getMessageStoreConfig().getFlushDelayOffsetInterval());
        }
    }
}