RocketMQ之顺序消息和延时消息(四)

998 阅读4分钟

顺序消息和延时消息

顺序消息

顺序消息 指的是生产者生产消息的顺序 消费者同样按照改顺序进行消费,顺序消息对于一般的MQ中间接来说都是比较麻烦的,因为保证顺序的同时必然会损失效率,但是在我们日常开发中 遇到的顺序消息内的需求太多了。

在RocketMQ中顺序的消息主要实现方式是通过生产者将顺序消息发送到同一个队列中,那么基于一个队列只能被一个消费者消费的约束,自然就可以实现顺序消费了。

主要是在我们生产消息的时候选择对应的投递策略,将顺序消息投递到一个队列中即可,一般的做法都是通过自定义MessageQueueSelector的方式去重写对应逻辑。

SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
              @Override
              public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
                  Long id = (Long) arg;  //根据订单id选择发送queue
                  long index = id % mqs.size();
                  return mqs.get((int) index);
              }
          }, orderList.get(i).getOrderId());//订单id

image.png 用上图描述 假设生产者 有2个不同的订单,但是每个订单的消息要按照顺序消费,就需要将A订单的数据发往Queue1中,将B订单的数据顺序投放到Queue2中,这样就可以保证消费者1获取的订单A消息是按照顺序消费的,消费者2获取的订单B是按照订单B的顺序消费的。

延时消息

在Rocketmq中延迟消息 是通过设置Message#setDelayTimeLevel的方式设置延迟等级的,这里笔者主要研究下RocketMQ内部是如何处理这个延迟消息的。

首先通过Message#setDelayTimeLevel设置消息延迟的,添加了消息的一个属性为PROPERTY_DELAY_TIME_LEVEL的延迟等级标识。

private String messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h";
public void setDelayTimeLevel(int level) {
    this.putProperty(MessageConst.PROPERTY_DELAY_TIME_LEVEL, String.valueOf(level));
}

接受到消息之后的commitlog#asyncPutMessage中对于延迟消息的处理

final int tranType = MessageSysFlag.getTransactionValue(msg.getSysFlag());
if (tranType == MessageSysFlag.TRANSACTION_NOT_TYPE
        || tranType == MessageSysFlag.TRANSACTION_COMMIT_TYPE) {
    // ....
        //延迟消息固定Topic SCHEDULE_TOPIC_XXXX
        topic = TopicValidator.RMQ_SYS_SCHEDULE_TOPIC;
        //对应延迟时间的队列
        queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel());

        // 将原有的Topic 添加到消息的属性中
        MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_TOPIC, msg.getTopic());
        // 将原有的queueId 添加到消息的属性中
        MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_QUEUE_ID, String.valueOf(msg.getQueueId()));
        msg.setPropertiesString(MessageDecoder.messageProperties2String(msg.getProperties()));

        // 设置消息的Topic为 SCHEDULE_TOPIC_XXXX
        msg.setTopic(topic);
        // 设置消息的queueId为对应leve的queue
        msg.setQueueId(queueId);
    }
}

上面代码主要实现了如下功能

替换原有消息的Topic 为 SCHEDULE_TOPIC_XXXX
替换原有消息的Queue为对应Level延迟时间的Queue
将原有消息的Topic 和 Queue 存放到消息Property

SCHEDULE_TOPIC_XXXX是个啥?我们可以通过配置文件查看对应Topic的配置
可以看到在本地文件的Conifg目录下Topics.json文件的TopicConfigTable中 有18个队列 正好对应18个Level

"SCHEDULE_TOPIC_XXXX":{
   "order":false,
   "perm":6,
   "readQueueNums":18,
   "topicFilterType":"SINGLE_TAG",
   "topicName":"SCHEDULE_TOPIC_XXXX",
   "topicSysFlag":0,
   "writeQueueNums":18
}

以及还有在ConsumeQueue中的特殊操作

// Timing message processing
{
    String t = propertiesMap.get(MessageConst.PROPERTY_DELAY_TIME_LEVEL);
    if (TopicValidator.RMQ_SYS_SCHEDULE_TOPIC.equals(topic) && t != null) {
        int delayLevel = Integer.parseInt(t);

        if (delayLevel > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
            delayLevel = this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel();
        }

        if (delayLevel > 0) {
            tagsCode = this.defaultMessageStore.getScheduleMessageService().computeDeliverTimestamp(delayLevel,
                storeTimestamp);
        }
    }
}

这样我们大致就有个思路了,RocketMQ会通过SCHEDULE_TOPIC_XXXX下队列 来处理不同延迟时间的数据以及存储在ConsumeQueue中的TagCode变成了需要发送的时间 不了解ConsumeQueue的同学可以点这里笔者之前有些过关于ConsumeQueue的一些说明.

ScheduleMessageService

ScheduleMessageService是主要实现延迟消息定时调度的服务,在DefaultMessageStore#handleScheduleMessageService中启用的.

首先我们ScheduleMessageService是通过Timer实现的延迟调度

//首次启动延迟
private static final long FIRST_DELAY_TIME = 1000L;
//每一个延时级别 在执行完后再次延迟多少时间放入调度池中
private static final long DELAY_FOR_A_WHILE = 100L;
//每一个延时级别调度失败后 再次放入调度池中的时间间隔
private static final long DELAY_FOR_A_PERIOD = 10000L;

接下来我们看下ScheduleMessageService#start干了什么

public void start() {
    if (started.compareAndSet(false, true)) {
        super.load();
        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) {
                //创建对应级别的TimerTask 加入到调度池中 延迟FIRST_DELAY_TIME启动
                this.timer.schedule(new DeliverDelayedMessageTimerTask(level, offset), FIRST_DELAY_TIME);
            }
        }

        this.timer.scheduleAtFixedRate(new TimerTask() {

            @Override
            public void run() {
                try {
                    //隔10s 将数据存储到delayOffset.json中
                    if (started.get()) ScheduleMessageService.this.persist();
                } catch (Throwable e) {
                    log.error("scheduleAtFixedRate flush exception", e);
                }
            }
        }, 10000, this.defaultMessageStore.getMessageStoreConfig().getFlushDelayOffsetInterval());
    }
}

通过上面代码我们知道 ScheduleMessageService给每一个延迟级别都创建了一个TimerTask调度任务。 接下来我们看下DeliverDelayedMessageTimerTask#run执行逻辑.

....
try {
    if (isStarted()) {
        this.executeOnTimeup();
    }
} catch (Exception e) {
     调度失败后延迟DELAY_FOR_A_PERIOD重新调度
    ScheduleMessageService.this.timer.schedule(new DeliverDelayedMessageTimerTask(
        this.delayLevel, this.offset), DELAY_FOR_A_PERIOD);
}
...

继续查看executeOnTimeup 代码实在太长 方便理解主分支 以下面代码删减部分内容

public void executeOnTimeup() {
   //通过 消费队列ConsumeQueue 找到Topic为SCHEDULE_TOPIC_XXXX下对应延迟队列消费的ID
    ConsumeQueue cq =ScheduleMessageService.this.defaultMessageStore.findConsumeQueue(TopicValidator.RMQ_SYS_SCHEDULE_TOPIC,
            delayLevel2QueueId(delayLevel));

    long failScheduleOffset = offset;

    if (cq != null) {
       //通过offet找到对应MappedBuffer对应的consumeQueue的索引
        SelectMappedBufferResult bufferCQ = cq.getIndexBuffer(this.offset);
        if (bufferCQ != null) {
            try {
                long nextOffset = offset;
                int i = 0;
                ConsumeQueueExt.CqExtUnit cqExtUnit = new ConsumeQueueExt.CqExtUnit();
                for (; i < bufferCQ.getSize(); i += ConsumeQueue.CQ_STORE_UNIT_SIZE) {
                    ... ConsumeQueue省略索引对应数据内容解析

                // 延时消息的ConsumeQueue存储的是对应需要发送的时间
                if (cq.isExtAddr(tagsCode)) {
                    if (cq.getExt(tagsCode, cqExtUnit)) {
                        tagsCode = cqExtUnit.getTagsCode();
                    } else {
                        //can't find ext content.So re compute tags code.
                        log.error("[BUG] can't find consume queue extend file content!addr={}, offsetPy={}, sizePy={}",
                tagsCode, offsetPy, sizePy);
                        long msgStoreTime = defaultMessageStore.getCommitLog().pickupStoreTimestamp(offsetPy, sizePy);
                        tagsCode = computeDeliverTimestamp(delayLevel, msgStoreTime);
                        }
                  }

                    long now = System.currentTimeMillis();
                    //计算对应消息的需要发送的时间戳
                    long deliverTimestamp = this.correctDeliverTimestamp(now, tagsCode);
                    //下一次获取数据时的偏移量
                    nextOffset = offset + (i / ConsumeQueue.CQ_STORE_UNIT_SIZE);


                    long countdown = deliverTimestamp - now;

                    //消息延时时间已到 或者超过
                    if (countdown <= 0) {
                        
                        //通过偏移量加消息大小获取消息
                        MessageExt msgExt =
                            ScheduleMessageService.this.defaultMessageStore.lookMessageByOffset(
                                offsetPy, sizePy);

                        if (msgExt != null) {
                            try {
                                //获取消息 并且将延时消息 解析为之前发送过来的消息
                                MessageExtBrokerInner msgInner = this.messageTimeup(msgExt);
                                ... 省略各种判断
                                //将消息存入到commitlog中
                                PutMessageResult putMessageResult =
                                    ScheduleMessageService.this.writeMessageStore
                                        .putMessage(msgInner);

                                //省略逻辑计算部分
                                .....
                                
                                }
        ....
 }                               

以上代码大致实现逻辑如下4步:

1.通过ConsumeQueue找到当前延迟队列的中的数据索引
2.ConsumeQueue中TagCode 在延时消息的时候存储的是需要发送的时间戳
3.通过ConsumeQueue 找到对应CommitLog中的消息 还原为真实消息
4.将还原的消息存储到Commitlog中

image.png

上图差不多是一个延时消息的主分支流程的一个描述