RocketMQ定时消息实现原理分析

1. 前言

定时消息(延迟消息)是RocketMQ比较有用的特性之一,定时消息被发送到Broker后,不会马上投递给Consumer,而是等待特定的时间,然后再投递消费。应用场景举例:用户下单后,系统锁定库存,如果用户在15分钟内未付款,系统自动取消订单,释放库存让其他用户有购买的机会。这种场景通过延迟消息就可以很轻松的实现。

延迟消息并不支持用户指定任意时间,而是通过设置延迟级别来指定的。RocketMQ最多支持18个延迟级别,每个延迟级别对应的延迟时间可以通过配置messageDelayLevel自定义,默认值为“1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h”。该配置属于Broker,对所有Topic有效!默认的level值为0,代表非延迟消息,超过18按最大值18计算。

如果Broker接收到的是延迟消息,会改写消息Topic为SCHEDULE_TOPIC_XXXX,改写queueId为延迟级别level-1,这样在构建ConsumeQueue的时候,消息就不会被构建到目标消费队列,消费者暂时也就无法消费这条消息了。然后由ScheduleMessageService服务,定时去扫描延迟队列里的消息,完成延迟消息的交付。如果消息的交付时间到了,会构建新的Message对象,恢复原来的Topic、queueId等属性,重新写回CommitLog,之后Consumer就可以正常消费这条数据了。

简单点说,就是Broker先替Producer将这条消息暂存到统一的「延迟队列」中,然后定时去扫描这个队列,将需要交付的消息重新写回到CommitLog。

2. 相关组件

在看源码之前,先了解一下消息投递涉及的相关组件很有必要。

2.1 DefaultMessageStore

默认的消息仓库,RocketMQ将消息存储到磁盘,涉及的文件有:CommitLog、ConsumeQueue、Index,这些文件均通过MessageStore进行维护管理,例如:消息写入CommitLog、索引写入ConsumeQueue等。

2.2 CommitLog

CommitLog用来存储消息主体和其元数据,虽然RocketMQ是基于Topic主题订阅模式的,但是对于Broker而言,所有消息全部写入CommitLog,不关心Topic,因此CommitLog是完全顺序写的。

2.3 ScheduleMessageService

定时消息服务,用来处理延迟消息,将需要交付的消息重新写回CommitLog。它内部有一个Timer定时器,通过提交延时任务的方式来工作。

2.4 DeliverDelayedMessageTimerTask

交付延时消息的定时任务,它继承自TimerTask,可以提交到Timer调度执行。当ScheduleMessageService需要处理延时消息时,就会创建该对象提交到Timer,当消息还未到交付时间时,会计算倒计时,然后再重新提交一个延时任务。

3. 源码分析

Broker处理延时消息的时序图如下:
在这里插入图片描述

1.Producer在发送消息时,如果发送的是延时消息,需要设置延迟级别。

Message message = new Message("Topic", "tags", "Content".getBytes());
message.setDelayTimeLevel(3);// 10s
producer.send(message);

延迟级别会被保存到消息的Properties属性中,Key为DELAY

public void setDelayTimeLevel(int level) {
    this.putProperty(MessageConst.PROPERTY_DELAY_TIME_LEVEL, String.valueOf(level));
}

然后消息正常发送,之后全部由Broker处理。

2.Broker接收到消息后,会调用CommitLog的asyncPutMessage方法写入消息,写之前会判断是否是延迟消息,判断依据就是Properties中的DELAY属性。如果延迟级别大于0,代表是延迟消息,此时Broker会通过延迟级别去改写消息的Topic和queueId,Topic名称为SCHEDULE_TOPIC_XXXX,这样消息就会被发送到「延迟队列」中,消费者目前是无法消费的。改写数据的同时,会将原来的Topic和queueId写入Properties,后续重新投递时要用到的。

if (msg.getDelayTimeLevel() > 0) {// 定时消息
    // 延迟级别
    if (msg.getDelayTimeLevel() > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
        msg.setDelayTimeLevel(this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel());
    }
    // 如果是定时消息,改写Topic,统一扔到 SCHEDULE_TOPIC_XXXX
    topic = TopicValidator.RMQ_SYS_SCHEDULE_TOPIC;
    // 每个延迟级别对应一个MessageQueue,延迟级别转QueueId
    queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel());
    // 真实的Topic和QueueID写到Properties,后续投递是要用到
    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);
}

3.之后消息会被正常写入CommitLog,然后由ReputMessageService线程对消息进行重放,重放时会读取消息构建DispatchRequest对象,然后将它分发出去,CommitLogDispatcher子类会完成ConsumeQueue、Index、布隆过滤器数据的构建工作。

在构建DispatchRequest对象时,有一个点需要注意,如果是延迟消息,TagsHash存储的不再是Tag字符串的哈希值,而是存储消息的交付时间戳,ScheduleMessageService线程会根据这个时间戳判断消息是否需要交付。

// 消息延迟级别
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();
    }
    /**
                         * 如果是延迟消息,TagsHash存储的是消息交付时间戳。
                         * 对于延迟队列来说,不会根据Tag过滤消息,TagsHash没有意义。
                         */
    if (delayLevel > 0) {
        tagsCode = this.defaultMessageStore.getScheduleMessageService().computeDeliverTimestamp(delayLevel,
                                                                                                storeTimestamp);
    }
}

构建好的DispatchRequest对象会被分发到CommitLogDispatcherBuildConsumeQueue类处理,开始构建ConsumeQueue索引,然后等待ScheduleMessageService定时扫描这些消息。

4.ScheduleMessageService服务会跟随Broker一起启动,先看一下它的属性:

// 延迟级别对应的时间
private final ConcurrentMap<Integer /* level */, Long/* delay timeMillis */> delayLevelTable =
    new ConcurrentHashMap<Integer, Long>(32);
// Level队列处理偏移量
private final ConcurrentMap<Integer /* level */, Long/* offset */> offsetTable =
    new ConcurrentHashMap<Integer, Long>(32);
// 消息仓库,依赖它读写CommitLog、ConsumeQueue
private final DefaultMessageStore defaultMessageStore;
// 服务启动标记
private final AtomicBoolean started = new AtomicBoolean(false);
// 定时器,执行延时任务
private Timer timer;
// 写消息仓库,默认是同一个
private MessageStore writeMessageStore;
// 最大延迟级别
private int maxDelayLevel;

delayLevelTable存储了所有的延迟级别和对应的延迟时间,服务启动时,会对所有的延迟队列进行扫描。

// 遍历所有的延迟级别
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);
    }
}

然后每隔10秒会对延迟队列的处理进度做一次持久化。

// 10秒进行一次持久化
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());

5.DeliverDelayedMessageTimerTask是一个定时任务类,它的工作是对到期的消息进行交付,属性如下:

// 延迟级别
private final int delayLevel;
// 消息在ConsumeQueue里的起始逻辑位点
private final long offset;

在它的run方法里会调用处理消息的核心方法executeOnTimeup,先根据Topic和延迟级别获取ConsumeQueue。

ConsumeQueue cq =ScheduleMessageService.this.defaultMessageStore
    .findConsumeQueue(TopicValidator.RMQ_SYS_SCHEDULE_TOPIC,delayLevel2QueueId(delayLevel));

然后根据处理的Offset定位到ByteBuffer,每次读取20个字节的索引项,后8个字节是TagsHash,前面说过了,如果是延迟消息,存储的就是消息交付时间戳,然后就可以判断消息是否到期。

long offsetPy = bufferCQ.getByteBuffer().getLong();
int sizePy = bufferCQ.getByteBuffer().getInt();
long tagsCode = bufferCQ.getByteBuffer().getLong();

long now = System.currentTimeMillis();
// 交付时间戳
long deliverTimestamp = this.correctDeliverTimestamp(now, tagsCode);
// 消息交付倒计时
long countdown = deliverTimestamp - now;

如果到期,则根据消息偏移量从CommitLog读取出完整的消息。

MessageExt msgExt =
    ScheduleMessageService.this.defaultMessageStore.lookMessageByOffset(
    offsetPy, sizePy);

创建新的MessageExtBrokerInner,并恢复消息原来的Topic和queueId等属性。

// 消息到期,恢复属性
private MessageExtBrokerInner messageTimeup(MessageExt msgExt) {
    // 构建新的MessageExtBrokerInner,用来写入CommitLog
    MessageExtBrokerInner msgInner = new MessageExtBrokerInner();
    // 从延迟消息中恢复数据
    msgInner.setBody(msgExt.getBody());
    msgInner.setFlag(msgExt.getFlag());
    MessageAccessor.setProperties(msgInner, msgExt.getProperties());
    TopicFilterType topicFilterType = MessageExt.parseTopicFilterType(msgInner.getSysFlag());
    // 恢复TagsHash
    long tagsCodeValue =
        MessageExtBrokerInner.tagsString2tagsCode(topicFilterType, msgInner.getTags());
    msgInner.setTagsCode(tagsCodeValue);
    msgInner.setPropertiesString(MessageDecoder.messageProperties2String(msgExt.getProperties()));

    msgInner.setSysFlag(msgExt.getSysFlag());
    msgInner.setBornTimestamp(msgExt.getBornTimestamp());
    msgInner.setBornHost(msgExt.getBornHost());
    msgInner.setStoreHost(msgExt.getStoreHost());
    msgInner.setReconsumeTimes(msgExt.getReconsumeTimes());
    msgInner.setWaitStoreMsgOK(false);
    MessageAccessor.clearProperty(msgInner, MessageConst.PROPERTY_DELAY_TIME_LEVEL);

    // 恢复原来的Topic和queueId
    msgInner.setTopic(msgInner.getProperty(MessageConst.PROPERTY_REAL_TOPIC));
    String queueIdStr = msgInner.getProperty(MessageConst.PROPERTY_REAL_QUEUE_ID);
    int queueId = Integer.parseInt(queueIdStr);
    msgInner.setQueueId(queueId);
    return msgInner;
}

将新创建的MessageExtBrokerInner重新写入CommitLog,然后消费者就可以正常消费这条消息了。

// 重新写入到CommitLog
PutMessageResult putMessageResult =
    ScheduleMessageService.this.writeMessageStore.putMessage(msgInner);

如果写入CommitLog失败,会提交延时任务,稍后在此重试。

ScheduleMessageService.this.timer.schedule(
    new DeliverDelayedMessageTimerTask(this.delayLevel,nextOffset), DELAY_FOR_A_PERIOD);

如果消息没有到期,会根据倒计时countdown提交延时任务,时间到了再执行。

ScheduleMessageService.this.timer.schedule(
    new DeliverDelayedMessageTimerTask(this.delayLevel, nextOffset),countdown);

至此,延迟消息的处理流程就基本结束了。

4. 总结

RocketMQ对延迟消息的实现原理是,Broker默认会有一个延迟消息专属的Topic,下面有18个队列,每个延迟级别对应一个队列。如果Broker接收到的是延迟消息,会改写消息的Topic和queueId,将消息暂时统一写入延迟队列中,然后由ScheduleMessageService线程对延迟队进行扫描,将到期需要交付的消息从CommitLog中读出来,然后恢复消息原本的Topic和queueId等属性,重新写回CommitLog,然后Consumer就可以正常消费了。

  • 2
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员小潘

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值