Rocketmq源码解读——定时消息

910 阅读5分钟

前言

rocketmq只支持固定精度的定时消息,如果要支持任意的时间精度,需要在broker层面做消息排序,如果要持久化,那么将产生巨大的开销。

延迟级别我就不列举了。

这篇文章除了讲解定时消息,还会连带讲解一下ReputMessageServiceConsumeQueue和消息重试(消费失败),因为延时消息和以上两个模块都息息相关。

代码

存储消息时,延迟消息进入TopicSCHEDULE_TOPIC_XXXX

// Delay Delivery
if (msg.getDelayTimeLevel() > 0) {
if (msg.getDelayTimeLevel() > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
msg.setDelayTimeLevel(this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel());
}
// 存储消息时,延迟消息进入 `Topic` 为 `SCHEDULE_TOPIC_XXXX` 。
topic = ScheduleMessageService.SCHEDULE_TOPIC;
// 延迟级别与消息队列编号做固定映射
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);
}

这句queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel());用来计算queueId,实际上就是delayLevel - 1

这样就把一条消息放到了SCHEDULE_TOPIC_XXXXdelayLevel - 1这个队列里。

这是存储过程,下面讲如何实现定时消费。

定式消费

定时消费实际上依赖的是tagsCode过滤实现的。先了解一下什么是tagsCode,这个tagsCode在普通消息时,存储的是taghashcode,有什么用呢?答案是没什么用。之前笔者曾纠结过这个tagsCode对于普通消息的作用,后来看了一下代码发现实际上是没有作用的,那么tag是怎么使用的呢?

答案是在consumer端进行过滤用的,说白了,拉消息是根据topic拉取的,但是用consumer怎么用消息,是根据tag过滤的。

知道了这个,先来看一下ReputMessageServicedoReput过程,这个主要是生成ConsumeQueue

// 获取从reputFromOffset开始的commitLog对应的MappeFile对应的MappedByteBuffer
SelectMappedBufferResult result = DefaultMessageStore.this.commitLog.getData(reputFromOffset);
if (result != null) {
try {
this.reputFromOffset = result.getStartOffset();
// 遍历MappedByteBuffer
for (int readSize = 0; readSize < result.getSize() && doNext; ) {
// 生成重放消息重放调度请求。我们一会会细看这个checkMessageAndReturnSize方法
DispatchRequest dispatchRequest =
DefaultMessageStore.this.commitLog.checkMessageAndReturnSize(result.getByteBuffer(), false, false);

//消息长度
int size = dispatchRequest.getMsgSize();
if (dispatchRequest.isSuccess()) {
if (size > 0) {
DefaultMessageStore.this.doDispatch(dispatchRequest);
//这里没细看,是HA相关的部分么?
if (BrokerRole.SLAVE != DefaultMessageStore.this.getMessageStoreConfig().getBrokerRole()
&& DefaultMessageStore.this.brokerConfig.isLongPollingEnable()) {
DefaultMessageStore.this.messageArrivingListener.arriving(dispatchRequest.getTopic(),
dispatchRequest.getQueueId(), dispatchRequest.getConsumeQueueOffset() + 1,
dispatchRequest.getTagsCode(), dispatchRequest.getStoreTimestamp(),
dispatchRequest.getBitMap(), dispatchRequest.getPropertiesMap());
}

this.reputFromOffset += size;
//省去一些统计的代码
} else if (size == 0) {
// 读取到MappedFile文件尾
this.reputFromOffset = DefaultMessageStore.this.commitLog.rollNextFile(this.reputFromOffset);
readSize = result.getSize();
}
} else if (!dispatchRequest.isSuccess()) {
// 读取失败
if (size > 0) {
this.reputFromOffset += size;
} else {
doNext = false;
if (DefaultMessageStore.this.brokerConfig.getBrokerId() == MixAll.MASTER_ID) {
this.reputFromOffset += result.getSize() - readSize;
}
}
}
}
} finally {
result.release();
}
}

说是重放消息,实际上这个服务有两个功能:

  1. 不断生成消息位置信息到消费队(ConsumeQueue),给消费者拉取消息用
  2. 不断生成消息索引到索引文件(消息落在broker上的时间信息)

看个图:
MappedFile存储 | center |

我们一会会去细看checkMessageAndReturnSiz方法,先理解一下ConsumeQueue的结构和功能。

之前其实我们已经讲过ConsumeQueue的结构和功能,这里重新拿出来简单复习一下:

consumeQueue的路径是/Users/xxx/store/consumequeue/TopicTest,内容是两种:

  1. MESSAGE_POSITION_INFO
  2. BLANK

MESSAGE_POSITION_INFO中有个字段叫tagsCode,之前已经说了,这个tagsCode对于普通消息来说是没有用的,而对于延时消息来说就有用了。

先看看这个tagsCode怎么生成的,看下刚刚说的checkMessageAndReturnSize方法中有这么一段:

// Timing message processing
{
String t = propertiesMap.get(MessageConst.PROPERTY_DELAY_TIME_LEVEL);
if (ScheduleMessageService.SCHEDULE_TOPIC.equals(topic) && t != null) {
int delayLevel = Integer.parseInt(t);
if (delayLevel > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
delayLevel = this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel();
}
if (delayLevel > 0) {
tagsCode = this.defaultMessageStore.getScheduleMessageService().computeDeliverTimestamp(delayLevel,
storeTimestamp);
}
}
}

这里如果是定时消息,并且delayLevel > 0,继续看tagsCode的生成,computeDeliverTimestamp方法:

Long time = this.delayLevelTable.get(delayLevel);
if (time != null) {
return time + storeTimestamp;
}
return storeTimestamp + 1000;

可以看到,返回的是实际应该消费时间

注意的是,我们的消费者拉取消息是从ConsumeQueue里拉去的,这里的topic信息是没有SCHEDULE_TOPIC,而之前的一大堆,实际上是把消息放到了SCHEDULE_TOPIC里。

所以这里就需要一步操作,把在SCHEDULE_TOPIC的各个Queue里,到达消费时间的消息,放到ConsumeQueue里。

这里做这个的实际上是DeliverDelayedMessageTimerTask。先总结一下流程再看代码吧:

  1. 延时消息到达broker后,broker把这条消息放到topic为SCHEDULE_TOPIC,queueId为delayLevel - 1的queue里。
  2. DeliverDelayedMessageTimerTask定时任务,轮训这些queue,然后根据tagsCode,把到达可消费时间的消息取出来,放到CommitLog里。
  3. ReputMessageService定时把commitLog中的消息生成索引放到ConsumeQueue中给消费者拉取用。

总结一下,定时的功能,实际上是通过:不到可消费时间,就不让消费者拉取的方式实现的。

理解了上面,看看DeliverDelayedMessageTimerTask的代码就很明白了:


ConsumeQueue cq = ScheduleMessageService.this.defaultMessageStore.findConsumeQueue(SCHEDULE_TOPIC, delayLevel2QueueId(delayLevel));
long failScheduleOffset = offset;
if (cq != null) {
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) {
long offsetPy = bufferCQ.getByteBuffer().getLong();
int sizePy = bufferCQ.getByteBuffer().getInt();
long tagsCode = bufferCQ.getByteBuffer().getLong();

if (cq.isExtAddr(tagsCode)) {
if (cq.getExt(tagsCode, cqExtUnit)) {
tagsCode = cqExtUnit.getTagsCode();
} else {
//can't find ext content.So re compute tags code.
long msgStoreTime = defaultMessageStore.getCommitLog().pickupStoreTimestamp(offsetPy, sizePy);
tagsCode = computeDeliverTimestamp(delayLevel, msgStoreTime);
}
}
long now = System.currentTimeMillis();
long deliverTimestamp = this.correctDeliverTimestamp(now, tagsCode);
nextOffset = offset + (i / ConsumeQueue.CQ_STORE_UNIT_SIZE);
long countdown = deliverTimestamp - now;
if (countdown <= 0) {
// 消息到达可发送时间
MessageExt msgExt = ScheduleMessageService.this.defaultMessageStore.lookMessageByOffset(offsetPy, sizePy);
if (msgExt != null) {
try {
MessageExtBrokerInner msgInner = this.messageTimeup(msgExt);
//把消息放到commitLog里
PutMessageResult putMessageResult = ScheduleMessageService.this.defaultMessageStore.putMessage(msgInner);
if (putMessageResult != null
&& putMessageResult.getPutMessageStatus() == PutMessageStatus.PUT_OK) {
continue;
} else {
// 放commitLog失败,安排下一次任务
ScheduleMessageService.this.timer.schedule(
new DeliverDelayedMessageTimerTask(this.delayLevel,
nextOffset), DELAY_FOR_A_PERIOD);
// 更新进度
ScheduleMessageService.this.updateOffset(this.delayLevel,
nextOffset);
return;
}
} catch (Exception e) {
}
}
} else {
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;
}
}
} // end of if (cq != null)
ScheduleMessageService.this.timer.schedule(new DeliverDelayedMessageTimerTask(this.delayLevel,
failScheduleOffset), DELAY_FOR_A_WHILE);

除了以上的部分,broker还会持久化延时消费的进度,定时消息发送进度存储在文件(../config/delayOffset.json)里。

消息重试

Consumer将消费失败的消息发回Broker,进入延迟消息队列。即,消费失败的消息,不会立即消费。

具体方法再consumerSendMsgBack,没有什么可看的,有兴趣的同学自己去扒一下吧。