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
    评论
RocketMQ的事务消息实现主要分为两个阶段:正常事务的发送及提交和事务信息的补偿流程。在正常事务的发送及提交阶段,事务消息的发送是在本地事务提交之前进行的。如果在发送事务消息之后发生异常,导致本地事务未能成功提交,那么事务消息也会被回滚。在事务信息的补偿流程中,RocketMQ会定期扫描未收到确认消息的Prepared消息,并执行事务回查的逻辑,主动去消息生产方确认事务状态。\[1\] 为了实现RocketMQ的事务消息,需要使用RocketMQ事务专属的TransactionMQProducer,并设置一个事务监听器(TransactionListener)。在事务监听器中,需要实现接口方法,以等待本地事务的执行情况。此外,由于监听器需要等待本地事务的执行情况,所以在生产者发送完消息后不能立即关闭。\[3\] 综上所述,RocketMQ的事务消息实现包括正常事务的发送及提交阶段和事务信息的补偿流程,需要使用TransactionMQProducer和设置事务监听器,以保证本地事务和事务消息的一致性。\[1\]\[3\] #### 引用[.reference_title] - *1* *2* *3* [RocketMQ事务消息](https://blog.csdn.net/qq_42877546/article/details/125404307)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control_2,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

程序员小潘

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

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

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

打赏作者

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

抵扣说明:

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

余额充值