这篇笔记主要记录延迟队列的实现原理
总结来说:就是producer发送了一个请求之后,broker在收到请求时,会先判断当前请求中(消息中)的延迟级别是否不为null,不为null,就表示当前消息是延迟消息,就会根据程序员设置的延迟级别,将消息投递到指定的topic中,并且设置到期时间,在到期了之后,将消息从指定的topic中取出来,投递到真正程序员指定的topic和queue中
putMessage
putMessage方法是broker作为netty服务端,接收到producer的send请求之后,去处理消息的方法,在调用这个方法之前,还有多层的逻辑判断,暂时先不深究
org.apache.rocketmq.store.CommitLog#putMessage
if (msg.getDelayTimeLevel() > 0) {
// 如果延迟级别超过最大级别,就设置延迟级别为18
if (msg.getDelayTimeLevel() > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
msg.setDelayTimeLevel(this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel());
}
// 设置延迟消息的topic和queue
topic = ScheduleMessageService.SCHEDULE_TOPIC;
queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel());
// Backup real topic, queueId
// 将真正的topic和queueId存起来,存到property属性中
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()));
// 将消息的topic和queue替换为延迟队列的
msg.setTopic(topic);
msg.setQueueId(queueId);
}
ScheduleMessageService#start
这个类是处理延迟队里的关键类,是在broker启动的时候,会取启动这个异步线程,我们先看下异步线程中做的事情
/**
* 在启动broker的时候,会初始化这里的timer
* 并且会根据延迟级别,创建对应的timer任务
*/
public void start() {
if (started.compareAndSet(false, true)) {
this.timer = new Timer("ScheduleMessageTimerThread", true);
// 这里for循环,会为每一个延迟级别创建一个延迟任务
for (Map.Entry<Integer, Long> entry : this.delayLevelTable.entrySet()) {
/**
* level是设置的延迟级别
*/
Integer level = entry.getKey();
/**
* value是延迟级别所对应的延迟时间
* 以及对应的offset 偏移量?
*/
Long timeDelay = entry.getValue();
Long offset = this.offsetTable.get(level);
if (null == offset) {
offset = 0L;
}
// 初始化时,第一次延迟时间是1S,在后面任务执行之后(DeliverDelayedMessageTimerTask),会修改任务延迟时间
if (timeDelay != null) {
this.timer.schedule(new DeliverDelayedMessageTimerTask(level, offset), FIRST_DELAY_TIME);
}
}
// 这里是每隔10s,就把延迟队列的最大消息偏移量写入到磁盘中
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());
}
}
所以这个方法,是启动了18个timerTask,所以核心的逻辑,在timerTask中
DeliverDelayedMessageTimerTask是ScheduleMessageService的一个内部类,在其run()方法中,调用的是executeOnTimeup()方法,所以核心来看下这里的逻辑
public void executeOnTimeup() {
/**
* 1、根据主题和队列ID,获取对应的consumerQueue
*/
ConsumeQueue cq =
ScheduleMessageService.this.defaultMessageStore.findConsumeQueue(SCHEDULE_TOPIC,
delayLevel2QueueId(delayLevel));
// 2、这里的offset,默认第一次是0,后面每次消费了延迟消息之后,会不停的累加
long failScheduleOffset = offset;
// 3.如果取到了consumerQueue,且不为null,就尝试判断消息是否到期
if (cq != null) {
/**
* 3.1 根据消费偏移量从消息队列中获取所有有效的消息
*/
SelectMappedBufferResult bufferCQ = cq.getIndexBuffer(this.offset);
if (bufferCQ != null) {
try {
long nextOffset = offset;
int i = 0;
ConsumeQueueExt.CqExtUnit cqExtUnit = new ConsumeQueueExt.CqExtUnit();
for (; i < bufferCQ.getSize(); i += ConsumeQueue.CQ_STORE_UNIT_SIZE) {
/**
* 3.2 下面这三个属性,就是consumerQueue中存储的最关键的信息,分别是
* 消息偏移量
* 消息大小
* 延迟消息到期时间
*/
long offsetPy = bufferCQ.getByteBuffer().getLong();
int sizePy = bufferCQ.getByteBuffer().getInt();
long tagsCode = bufferCQ.getByteBuffer().getLong();
// 3.3 这段代码暂时没看到,先跳过
if (cq.isExtAddr(tagsCode)) {
if (cq.getExt(tagsCode, cqExtUnit)) {
tagsCode = cqExtUnit.getTagsCode();
} else {
//can't find ext content.So re compute tags code.
log.error("[BUG] can't find consume queue extend file content!addr={}, offsetPy={}, sizePy={}",
tagsCode, offsetPy, sizePy);
long msgStoreTime = defaultMessageStore.getCommitLog().pickupStoreTimestamp(offsetPy, sizePy);
tagsCode = computeDeliverTimestamp(delayLevel, msgStoreTime);
}
}
/**
* 3.4 计算消息应该被消费的时间
*/
long now = System.currentTimeMillis();
long deliverTimestamp = this.correctDeliverTimestamp(now, tagsCode);
nextOffset = offset + (i / ConsumeQueue.CQ_STORE_UNIT_SIZE);
long countdown = deliverTimestamp - now;
/**
* 3.5 上面是用到期时间 -now 计算是否已经到期
* 如果countdown > 0 表示还没有到期,在else中,会继续等countdown秒之后再执行
* 如果countdown <= 0,那就是延迟消息到期了,需要被消费
*/
if (countdown <= 0) {
// 3.7 这里是从commitLog中,根据偏移量和大小获取真正的消息信息
MessageExt msgExt =
ScheduleMessageService.this.defaultMessageStore.lookMessageByOffset(
offsetPy, sizePy);
if (msgExt != null) {
try {
/**
* 3.8 在下面的这个方法中,会对message进行处理,恢复为真正的topic和queue
*/
MessageExtBrokerInner msgInner = this.messageTimeup(msgExt);
if (MixAll.RMQ_SYS_TRANS_HALF_TOPIC.equals(msgInner.getTopic())) {
log.error("[BUG] the real topic of schedule msg is {}, discard the msg. msg={}",
msgInner.getTopic(), msgInner);
continue;
}
// 3.9 把组装好的消息再次投递到broker中
PutMessageResult putMessageResult =
ScheduleMessageService.this.writeMessageStore
.putMessage(msgInner);
// 3.10 如果消息成功,就继续处理下一个延迟消息,如果投递失败,就延迟10S,再次启动timerTask,这里10S,应该和前面10S持久化一次消息有关系
if (putMessageResult != null
&& putMessageResult.getPutMessageStatus() == PutMessageStatus.PUT_OK) {
continue;
} else {
// XXX: warn and notify me
log.error(
"ScheduleMessageService, a message time up, but reput it failed, topic: {} msgId {}",
msgExt.getTopic(), msgExt.getMsgId());
ScheduleMessageService.this.timer.schedule(
new DeliverDelayedMessageTimerTask(this.delayLevel,
nextOffset), DELAY_FOR_A_PERIOD);
ScheduleMessageService.this.updateOffset(this.delayLevel,
nextOffset);
return;
}
} catch (Exception e) {
/*
* XXX: warn and notify me
*/
log.error(
"ScheduleMessageService, messageTimeup execute error, drop it. msgExt="
+ msgExt + ", nextOffset=" + nextOffset + ",offsetPy="
+ offsetPy + ",sizePy=" + sizePy, e);
}
}
} else {
// 这里会修改任务的延迟时间为第一个任务到期剩余时间,比如:到期时间 - now = 50ms,就表示还有50ms到期,那就延迟50ms再次执行
ScheduleMessageService.this.timer.schedule(
new DeliverDelayedMessageTimerTask(this.delayLevel, nextOffset),
countdown);
ScheduleMessageService.this.updateOffset(this.delayLevel, nextOffset);
return;
}
} // end of for
nextOffset = offset + (i / ConsumeQueue.CQ_STORE_UNIT_SIZE);
ScheduleMessageService.this.timer.schedule(new DeliverDelayedMessageTimerTask(
this.delayLevel, nextOffset), DELAY_FOR_A_WHILE);
ScheduleMessageService.this.updateOffset(this.delayLevel, nextOffset);
return;
} finally {
bufferCQ.release();
}
} // end of if (bufferCQ != null)
else {
long cqMinOffset = cq.getMinOffsetInQueue();
if (offset < cqMinOffset) {
failScheduleOffset = cqMinOffset;
log.error("schedule CQ offset invalid. offset=" + offset + ", cqMinOffset="
+ cqMinOffset + ", queueId=" + cq.getQueueId());
}
}
} // end of if (cq != null)
// 4.如果取到的consumerQueue为null,继续等待100ms,再次执行这个任务
ScheduleMessageService.this.timer.schedule(new DeliverDelayedMessageTimerTask(this.delayLevel,
failScheduleOffset), DELAY_FOR_A_WHILE);
}
这个方法就是处理延迟消息,判断是否到期的核心逻辑,总结来看,就是异步线程去取当前延迟队列中的第一个消息,判断是否到期,到期,就转换为真正的消息,投递到broker,如果还有50ms到期,那就延迟50ms再执行,之所以,可以只判断第一个消息是否到期,是因为rocketmq针对每个延迟级别都初始化了一个queue,这样就可以保证,18个queue中,前面的肯定是先到期的,后面的是晚到期的
commitLog、consumerQueue
插一个知识点,在开始学习源码的时候,我一直没太注意这两个文件中数据的区别,所以,在看上面这个代码的时候,我很奇怪,为什么注释1这里是从consumerQueue中取消息的offset、size、tagCode这些信息;而在3.7这里,又是从commitLog中取消息数据
是因为,commitLog中存放的是真正的消息元数据信息;consumerQueue中存放的是每个消息体在commitLog中的offset和size大小,这个tagCode是用来进行消息类型区分的,只是对于延迟消息,会把到期时间放在tagCode上;所以,了解了这两个文件的区别,就知道为什么这段逻辑中,会先取consumerQueue,再去commitLog取消息
设置延迟消息到期时间
这里暂时先TODO吧,设置延迟消息的逻辑不复杂,但是这个逻辑是在commitLog的消息,同步到indexFile和consumerQueue的流程中的,所以暂时先TODO,等我梳理完了,再补上来
if (delayLevel > 0) {
tagsCode = this.defaultMessageStore.getScheduleMessageService().computeDeliverTimestamp(delayLevel,
storeTimestamp);
}
核心逻辑就是这一行,这里的storeTimestamp是在putMessage方法一进来就赋的值,是当前时间戳,所以,tagCode,就是在当前时间戳上 + 延迟级别对应的延迟时间,就是当前消息的到期时间
总结
总结来看:
broker会给每个延迟级别设置一个topic,一个queue,在producer发送延迟消息到broker之后,会判断消息中对应的延迟级别,然后写入到对应的延迟消息topic中
在broker启动的时候,会使用异步线程去处理,为每个延迟级别设置一个timerTask(延迟任务),每100ms去执行一次,拉取最新的消息,判断是否到期,如果未到期,继续等待100ms,再次执行;如果到期,就转换为真正的topic,并清除延迟级别, 重新putMessage()
延迟队列这样设计,有好处,也有坏处
优点:每个延迟级别对应一个queue,这样可以保证消息的有序性,且同一个延迟级别对应的一个queue,可以保证每个queue中,最前面的就是最近到期的
缺点:18个timerTask共用一个timer,因为timer是单线程执行的,所以在延迟消息较多的时候,应该会存在延迟