惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园 - Franky
Microsoft Azure Blog
Microsoft Azure Blog
阮一峰的网络日志
阮一峰的网络日志
宝玉的分享
宝玉的分享
量子位
N
Netflix TechBlog - Medium
M
MIT News - Artificial intelligence
GbyAI
GbyAI
Apple Machine Learning Research
Apple Machine Learning Research
博客园_首页
博客园 - 叶小钗
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
酷 壳 – CoolShell
酷 壳 – CoolShell
T
Tailwind CSS Blog
Y
Y Combinator Blog
L
LangChain Blog
The Cloudflare Blog
T
The Blog of Author Tim Ferriss
U
Unit 42
Martin Fowler
Martin Fowler
aimingoo的专栏
aimingoo的专栏
G
Google Developers Blog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
月光博客
月光博客

博客园 - nick hao

Hystrix入门与分析(二):依赖隔离之线程池隔离 Hystrix入门与分析(一):初识Hystrix 自定义Flume Sink:ElasticSearch Sink elastic-job详解(五):自定义任务参数 elastic-job详解(四):失效转移 elastic-job详解(三):Job的手动触发功能 elastic-job详解(二):作业的调度 elastic-job详解(一):数据分片 定时任务的分布式调度 HBase多条件及分页查询的一些方法 谈谈高并发系统的限流 Phoenix系列:原子的Upsert Phoenix系列:二级索引(2) Phoenix系列:二级索引(1) 延迟任务的实现总结 使用RabbitMQ实现延迟任务 使用Curator操作ZooKeeper ElasticSearch5.X的冷热数据架构 剖析Elasticsearch集群系列之三:近实时搜索、深层分页问题和搜索相关性权衡之道
RocketMQ的延迟消息
nick hao · 2023-01-17 · via 博客园 - nick hao

什么是延迟消息

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

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