RocketMQ 任意时间维度的延迟消息(秒级)
基于开源版本固定等级的延迟消息实现原理的基础上进行扩展,将所有维度的延迟消息封装成任务添加到时间轮上,通过时间轮固定周期的扫描,检测任务是否到执行时间, 进而达到任意等级维度的延迟消息。
1、修改RocketMQ client消息体结构, 新增一个属性来标识该消息的定时投递的时间。
// org.apache.rocketmq.common.message.Message#setDelayTimeAtTime
public void setDelayTimeAtTime(long time){
// 延迟消息落盘问题 对延迟时间进行限制
// 精确到s级
this.putProperty(MessageConst.PROPERTY_DELAY_ARBITRARILY_TIME_LEVEL, String.valueOf(time));
}
2、在消息投递到Broker端进行存储时,判断消息是否为定时消息。如果是定时消息,则将消息进行转换。
if (msg.getDelayTimeAtTime() > 0){
// 该消息是定时消息
String timeTopic = TopicValidator.RMQ_TIME_TOPIC; //定时消息内置Topic
// 消息真实Topic 存储到消息扩展字段中
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_TOPIC, msg.getTopic());
// 消息真实queueId 存储到消息扩展字段中
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_QUEUE_ID,String.valueOf(msg.getQueueId()));
msg.setPropertiesString(MessageDecoder.messageProperties2String(msg.getProperties()));
// 将消息的目标topic 修改为内置topic
msg.setTopic(timeTopic);
// 目前暂时设置一个队列, 所有定时消息均放置到一个队列上。
msg.setQueueId(0);
}
3、消息保存到CommitLog上之后, 会有org.apache.rocketmq.store.DefaultMessageStore.ReputMessageService#doReput任务对刚刚保存到CommitLog上的消息构建ConsumeQueue、index文件。此处也会对TopicValidator.RMQ_TIME_TOPIC构建ConsumeQueue(原有逻辑,此处不用改变)
4、轮询扫描(3)步骤生成的ConsumeQueue。如果有数据,则根据偏移量从CommitLog上获取消息真实内容,基于消息的定时时间将消息任务放入时间轮。
public void run() {
long currentTime = System.currentTimeMillis();
// 根据偏移量 读取consumerQueue, 将consumerQueue中未消费的数据 加载到时间轮中
// 默认一个队列
ConsumeQueue consumeQueue = FixedTimeMessageService.this.defaultMessageStore.findConsumeQueue(TopicValidator.RMQ_TIME_TOPIC, 0);
this.offset = Optional.ofNullable(FixedTimeMessageService.this.queueOffsetTable.get(0)).orElse(0L);
if (consumeQueue == null){
FixedTimeMessageService.this.scheduleNextTimerTask(this.offset);
return;
}
// 读取 consumerQueue 队列, 加载到时间轮中
SelectMappedBufferResult mappedBufferResult = consumeQueue.getIndexBuffer(this.offset);
if (mappedBufferResult == null){
FixedTimeMessageService.this.scheduleNextTimerTask(this.offset);
return;
}
int i = 0;
for (; i < mappedBufferResult.getSize() && isStarted(); i += ConsumeQueue.CQ_STORE_UNIT_SIZE){
// 从commmitLog加载消息 加载消息具体内容到时间轮
long offsetPy = mappedBufferResult.getByteBuffer().getLong();
int sizePy = mappedBufferResult.getByteBuffer().getInt();
long tagCode = mappedBufferResult.getByteBuffer().getLong();
MessageExt messageExt = FixedTimeMessageService.this.defaultMessageStore.lookMessageByOffset(offsetPy, sizePy);
if (messageExt == null){
System.err.println("load message from commitlog error, offsetPy: " + offsetPy + ", sizePy" + sizePy);
continue;
}
long delayTimeAtTime = messageExt.getDelayTimeAtTime();
long delayTime = delayTimeAtTime - currentTime;
System.out.println("delayTime : " + delayTime + ", delayTimeAtTime: "+ delayTimeAtTime + ", currentTime:" + currentTime);
if (delayTime <= -1000){
continue;
}
if (delayTime <= 0){
delayTime = 1;
}
FixedTimeMessageService.this.addTask(messageExt.getQueueOffset(), messageExt.getQueueId(), delayTime);
}
long queueOffset = this.offset + (i / 20);
// 此时只是提交任务到时间轮, 此时刷新消费进度会有问题, 如果此时持久化进度文件,并宕机 则重启后会导致消息丢失。
FixedTimeMessageService.this.queueOffsetTable.put(0, queueOffset);
FixedTimeMessageService.this.doImmediate(queueOffset);
}
5、时间轮任务。在时间轮检测到时间到期之后,会触发执行TimerTask,此时根据参数将原消息从CommitLog中取出,然后转换消息topic为真实的topic,queueId为真实的queueId,并将原消息的定时参数清除,重新投递到CommitLog,此时消息便对消费者可见。
public class FixedTimeTask implements TimerTask {
private long queueOffset;
private int queueId;
public FixedTimeTask(long queueOffset, int queueId){
this.queueOffset = queueOffset;
this.queueId = queueId;
}
@Override
public void run(Timeout timeout) throws Exception {
System.out.println("[FixedTimeTask] execute task >>>> ");
// 获取 ConsumeQueue
ConsumeQueue consumeQueue = FixedTimeMessageService.this.defaultMessageStore.findConsumeQueue(TopicValidator.RMQ_TIME_TOPIC, queueId);
if (consumeQueue == null){
// redo
System.err.println("[FixedTimeTask] consumer queue is null, queueId: " + queueId);
return;
}
// 获取该offset的 consumerQueue 数据,之后的不用管
SelectMappedBufferResult bufferResult = consumeQueue.getIndexBuffer(this.queueOffset, ConsumeQueue.CQ_STORE_UNIT_SIZE);
if (bufferResult == null){
// redo
System.err.println("[FixedTimeTask] bufferResult is null, queueOffset: " + queueOffset);
return;
}
// 此时ByteBuffer 会获取很多
long offsetPy = bufferResult.getByteBuffer().getLong();
int sizePy = bufferResult.getByteBuffer().getInt();
// 获取message
MessageExt msgExt = FixedTimeMessageService.this.defaultMessageStore.lookMessageByOffset(offsetPy, sizePy);
if (msgExt == null){
System.out.println("[FixedTimeTask] load message offsetPy: " + offsetPy + " sizePy:" + sizePy);
return;
}
// 转换消息 topic 换成真实topic
MessageExtBrokerInner messageExtBrokerInner = FixedTimeMessageService.this.messageTimeup(msgExt);
// 投递到commitLog
System.out.println("[MessageExtBrokerInner] topic: " + messageExtBrokerInner.getTopic() + ", queueId: " + messageExtBrokerInner.getQueueId());
CompletableFuture<PutMessageResult> asyncPutMessage =
FixedTimeMessageService.this.defaultMessageStore.asyncPutMessage(messageExtBrokerInner);
PutMessageResult putMessageResult = asyncPutMessage.get();
System.out.println("[FixedTimeTask] put message result " + putMessageResult);
// 此时应记录消费的进度。但需要考虑一个问题 比如offset 100 的消息定时时间是2022-05-10 23:00:00
// 但是 offset 200的消息定时时间是2022-05-10 19:00:00,那么会是offset 200的消息先消费 但此时如果直接将消费进度修改为200,会有问题
// 可参考 集群并发消费模式的 消费进度上报机制。 treeMap
}
}
5、定时持久化消费进度,重启时加载消费进度。Broker启动时启动定时消息线程。