随笔 - 63  文章 - 2 评论 - 124 阅读 - 87万
< 2025年3月 >
23 24 25 26 27 28 1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31 1 2 3 4 5

什么是延迟消息

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

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

posted @ 2023-01-17 23:33 nick hao 阅读(2915) 评论(0) 推荐(0) 编辑
摘要: 1.依赖隔离概述 依赖隔离是Hystrix的核心目的。依赖隔离其实就是资源隔离,把对依赖使用的资源隔离起来,统一控制和调度。那为什么需要把资源隔离起来呢?主要有以下几点: 1.合理分配资源,把给资源分配的控制权交给用户,某一个依赖的故障不会影响到其他的依赖调用,访问资源也不受影响。 2.可以方便的指 阅读全文
posted @ 2018-01-10 21:52 nick hao 阅读(7814) 评论(1) 推荐(1) 编辑
摘要: 在以前的文章中,我们介绍过使用Gauva实现限流的功能,现在我们来了解一下如何在服务框架中实现熔断和降级的方法。 简介Hystrix 大型系统架构的演进基本上都是这样一个方向:从单体应用到分布式架构。这个演进过程离不开一个字“拆”,我们会把一个系统拆成很多子系统,子系统之间一定会存在依赖,有的是强依 阅读全文
posted @ 2017-12-24 18:18 nick hao 阅读(885) 评论(0) 推荐(1) 编辑
摘要: Flume Sink的目的是从Flume Channel中获取数据然后输出到存储或者其他Flume Source中。Flume Agent启动的时候,它会为每一个Sink都启动一个SinkRunner的对象,SinkRunner.start()方法会启动一个新的线程去管理每一个Sink的生命周期。每 阅读全文
posted @ 2017-09-13 21:55 nick hao 阅读(3665) 评论(1) 推荐(2) 编辑
摘要: 在elastic-job详解(三):Job的手动触发功能一文中讲到了如何手动触发一个Job,但是我们手动触发的时候常常需要输入一些参数。举个栗子:我们有个日统计报表,每天凌晨统计一次,统计上一天的数据。但我们发现几天前的某一天的数据有问题,需要重跑统计。这就需要统计程序能执行指定某一天的数据。这个功 阅读全文
posted @ 2017-09-08 11:06 nick hao 阅读(8750) 评论(0) 推荐(1) 编辑
摘要: elastic-job中最关键的特性之一就是失效转移。配置了失效转移之后,如果在任务执行过程中有一个执行实例挂了,那么之前被分配到这个实例的任务(或者分片)会在下次任务执行之前被重新分配到其他正常节点实例上执行。 简单的HA 当某一个任务实例节点宕机(离开与zookeeper的连接),会触发elastic-job主节点的重新分片逻辑。elastic-job启动任务节点以后生成的zookeepe... 阅读全文
posted @ 2017-06-23 07:50 nick hao 阅读(13904) 评论(1) 推荐(2) 编辑
摘要: elastic-job的任务都是使用quartz来触发的,quartz表达式一般都是定期执行。但有时候一些周期较长的任务,比如一天一次,几小时一次的任务,我们需要等待很久才能触发一次。如果我们需要测试一些功能,或者说重跑一些任务的话,手动触发任务功能就变得尤为重要,也是任务框架一般都必须要实现的特性 阅读全文
posted @ 2017-06-10 06:37 nick hao 阅读(18326) 评论(1) 推荐(0) 编辑
摘要: JobScheduler是elastic-job作业调度的关键类,也是起始类,在包com.dangdang.ddframe.job.lite.api下。调度任务的执行需要包含两大步骤:任务的配置和任务的注册。JobScheduler的构造函数除了任务配置和注册相关信息之外还有事件和监听。后两者是el 阅读全文
posted @ 2017-06-03 16:53 nick hao 阅读(19492) 评论(0) 推荐(0) 编辑
摘要: 数据分片的目的在于把一个任务分散到不同的机器上运行,既可以解决单机计算能力上限的问题,也能降低部分任务失败对整体系统的影响。elastic-job并不直接提供数据处理的功能,框架只会将分片项分配至各个运行中的作业服务器(其实是Job实例,部署在一台机器上的多个Job实例也能分片),开发者需要自行处理分片项与真实数据的对应关系。框架也预置了一些分片策略:平均分配算法策略,作业名哈希值奇偶数算法策略,... 阅读全文
posted @ 2017-05-29 23:48 nick hao 阅读(43337) 评论(0) 推荐(1) 编辑
摘要: 单机定式任务调度的问题 在很多应用系统中我们常常要定时执行一些任务。比如,订单系统的超时状态判断、缓存数据的定时更新、定式给用户发邮件,甚至是一些定期计算的报表等等。常见的处理方式有线程的while(true) 和sleep组合、使用Timer定时器触发任务又或者是使用quartz框架。貌似这些方法可以完美的解决方案,为什么还需要分布式呢?主要有如下两点原因: 1.高可用:单机版的定式任务调度... 阅读全文
posted @ 2017-05-21 21:39 nick hao 阅读(43662) 评论(2) 推荐(6) 编辑
点击右上角即可分享
微信分享提示