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

推荐订阅源

V
V2EX
J
Java Code Geeks
MongoDB | Blog
MongoDB | Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Blog — PlanetScale
Blog — PlanetScale
A
About on SuperTechFans
Engineering at Meta
Engineering at Meta
美团技术团队
N
Netflix TechBlog - Medium
月光博客
月光博客
博客园 - 三生石上(FineUI控件)
F
Fortinet All Blogs
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
MyScale Blog
MyScale Blog
aimingoo的专栏
aimingoo的专栏
Last Week in AI
Last Week in AI
The Cloudflare Blog
量子位
Apple Machine Learning Research
Apple Machine Learning Research
小众软件
小众软件
腾讯CDC
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
D
DataBreaches.Net
H
Hackread – Cybersecurity News, Data Breaches, AI and 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的队列呢?
如果延迟级别很多,队列就会很多,会不会有其他的性能问题?