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就可以正常消费了。