延迟消息的实现原理与使用
在RocketMQ中,发送一个消息我们都是需要指定消息投递到哪个topic,但是如果这个消息设置了消息的延迟级别,那么该消息投递的就不是目标topic的,而是一个叫SCHEDULE_TOPIC_XXXX的topic,由于ConsumeQueue文件是顺序写的,这个topic下每一个ConsumeQueue文件就存储着对应不同延迟级别的消息,比如延迟5s的消息都会在同一个ConsumeQueue文件中,这样既能遵守ConsumeQueue文件顺序写的特性,又能每一个消息的延迟结束时间天然地与写入顺序一致
对于每一个延迟队列来说,都会启动一个定时任务去检查该队列中的消息是否已经到达延迟结束时间,如果到达了就把该消息重新放回目标topic,那么消费者此时就可以消费到了
代码如下:
Message msg = new Message("TopicTest",
"TagA",
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET)
);
msg.setDelayTimeLevel(1);
SendResult sendResult = producer.send(msg);
只需要在创建消息的时候指定消息的延迟级别就可以了,默认有18个延迟级别:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h。
延迟消息源码实现
(1)先写入延迟消息到CommitLog
org.apache.rocketmq.store.CommitLog#asyncPutMessage
调用链就不贴出来了,最终消息都是需要调用CommitLog的写入方法
if (tranType == MessageSysFlag.TRANSACTION_NOT_TYPE
|| tranType == MessageSysFlag.TRANSACTION_COMMIT_TYPE) {
// 条件成立:说明用户发送的是延时消息,设置了消息的延时级别
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;
// 延时消息投递的queueId,delayLevel - 1
queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel());
// 备份下消息原本要投递的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()));
// 设置延时消息投递的topic以及queueId
msg.setTopic(topic);
msg.setQueueId(queueId);
}
}
由于该方法太长,所以这里只贴出关于处理延时消息的部分。可以看到,如果消息设置了延迟级别(默认等于0),首先会把原始投递的topic和queueId保存到自身属性中,然后会把原始topic换成SCHEDULE_TOPIC_XXXX这个topic,并且queueId为延迟级别-1,然后后面就会写入CommitLog文件中
(2)ConsumeQueue写入延迟消息
org.apache.rocketmq.store.DefaultMessageStore.ReputMessageService#doReput
消息写入到CommitLog之后,后台线程会异步地把消息的索引信息写入到ConsumeQueue文件中
DispatchRequest dispatchRequest =
DefaultMessageStore.this.commitLog.checkMessageAndReturnSize(result.getByteBuffer(), false, false);
org.apache.rocketmq.store.CommitLog#checkMessageAndReturnSize(java.nio.ByteBuffer, boolean, boolean)
......
{
// 获取用户设置的消息延迟级别
String t = propertiesMap.get(MessageConst.PROPERTY_DELAY_TIME_LEVEL);
// 这里基本都会成立,当发送延迟消息的时候,会先写入到commitlog中,并且会把消息的topic改成SCHEDULE_TOPIC_XXXX
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();
}
if (delayLevel > 0) {
// 根据延迟级别计算出消息延迟结束时间,也就是说对于延迟消息来说,在延迟时间还没结束之前,ConsumeQueueData中的tagCode记录的是延时结束时间
tagsCode = this.defaultMessageStore.getScheduleMessageService().computeDeliverTimestamp(delayLevel,
storeTimestamp);
}
}
}
......
所以可以看到对于延迟消息来说,在延迟队列对应的consumequeue中存储的条目数据其中tagCode这一块内容存储的并不是该消息的tagCode,而是该消息的延迟结束时间
(3)加载delayOffset.json文件
在broker启动的时候,会去加载各种文件中的数据到内存,其中需要把delayOffset.json文件加载到内存中
org.apache.rocketmq.store.schedule.ScheduleMessageService#load
public boolean load() {
// 读取delayOffset.json文件的数据到offsetTable这个map中
boolean result = super.load();
// 初始化delayLevelTable表
result = result && this.parseDelayLevel();
return result;
}
首先会去读取delayOffset.json文件的数据到offsetTable中
/**
* key=>延迟级别,一个延迟级别对应一个延迟队列
* value=>延迟队列的消费进度
* broker启动的时候这个map的数据会从delayOffset.json文件中获取
*/
private final ConcurrentMap<Integer /* level */, Long/* offset */> offsetTable =
new ConcurrentHashMap<Integer, Long>(32);
offsetTable的key就是延迟级别,value就是这个延迟级别队列中的消费进度,接着还会调用一个parseDelayLevel方法,代码如下:
/**
* 初始化delayLevelTable表
* @return
*/
public boolean parseDelayLevel() {
// 计算出每一个时间单位对应的毫秒数
HashMap<String, Long> timeUnitTable = new HashMap<String, Long>();
timeUnitTable.put("s", 1000L);
timeUnitTable.put("m", 1000L * 60);
timeUnitTable.put("h", 1000L * 60 * 60);
timeUnitTable.put("d", 1000L * 60 * 60 * 24);
// 1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
String levelString = this.defaultMessageStore.getMessageStoreConfig().getMessageDelayLevel();
try {
String[] levelArray = levelString.split(" ");
for (int i = 0; i < levelArray.length; i++) {
String value = levelArray[i];
// 获取到时间单位
String ch = value.substring(value.length() - 1);
// 获取该时间对应的毫秒数
Long tu = timeUnitTable.get(ch);
int level = i + 1;
if (level > this.maxDelayLevel) {
// 得到最大的延迟级别
this.maxDelayLevel = level;
}
long num = Long.parseLong(value.substring(0, value.length() - 1));
long delayTimeMillis = tu * num;
this.delayLevelTable.put(level, delayTimeMillis);
}
} catch (Exception e) {
log.error("parseDelayLevel exception", e);
log.info("levelString String = {}", levelString);
return false;
}
return true;
}
这个方法中做的事情也很简单,就是把配置中的每一个延迟级别都转换成对应的时间毫秒数,然后初始化delayLevelTable
/**
* key=>延迟级别,一个延迟级别对应一个延迟队列
* value=>对应的延迟时间
*/
private final ConcurrentMap<Integer /* level */, Long/* delay timeMillis */> delayLevelTable =
new ConcurrentHashMap<Integer, Long>(32);
delayLevelTable的key是延迟级别,value是这个延迟级别对应的延迟时间
(4)启动延迟消息组件ScheduleMessageService
org.apache.rocketmq.store.schedule.ScheduleMessageService#start
/**
* 在消息存储服务启动的时候会被调用
*/
public void start() {
if (started.compareAndSet(false, true)) {
this.timer = new Timer("ScheduleMessageTimerThread", true);
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;
}
// 给每一个延迟级别都启动对应的TimeTask,延迟1s执行
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());
}
}
首先遍历delayLevelTable表,根据延迟级别从offsetTable中找到对应延迟队列的消费进度,然后给每一个延迟队列都创建一个TimeTask,并且把对应的延迟级别和延迟队列的消费进度传进TimeTask中,然后延迟1s执行。接着再创建一个定时任务每10s把每一个延迟队列的消费进度写入到delayOffset.json文件中
(5)执行检查延迟消息任务
org.apache.rocketmq.store.schedule.ScheduleMessageService.DeliverDelayedMessageTimerTask#executeOnTimeup
public void executeOnTimeup() {
// 获取topic为SCHEDULE_TOPIC_XXXX,queueId为 延迟级别 - 1 的ConsumeQueue,如果没有则创建
ConsumeQueue cq =
ScheduleMessageService.this.defaultMessageStore.findConsumeQueue(TopicValidator.RMQ_SYS_SCHEDULE_TOPIC,
delayLevel2QueueId(delayLevel));
long failScheduleOffset = offset;
if (cq != null) {
// 根据offset得到对应的ConsumeQueue文件,然后再返回该文件从起始偏移量到写入位点的所有ConsumeQueueData
SelectMappedBufferResult bufferCQ = cq.getIndexBuffer(this.offset);
if (bufferCQ != null) {
try {
long nextOffset = offset;
int i = 0;
ConsumeQueueExt.CqExtUnit cqExtUnit = new ConsumeQueueExt.CqExtUnit();
// 遍历每一条ConsumeQueueData
for (; i < bufferCQ.getSize(); i += ConsumeQueue.CQ_STORE_UNIT_SIZE) {
// 消息的CommitLog物理偏移量
long offsetPy = bufferCQ.getByteBuffer().getLong();
// 消息大小
int sizePy = bufferCQ.getByteBuffer().getInt();
// 延迟结束时间,在消息写入到CommitLog之后会进行分发到ConsumeQueue,而对于延迟消息来说,tagCode这个位置存储的是该消息的延迟到期时间
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.
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);
}
}
// 获取当前时间
long now = System.currentTimeMillis();
// 如果这个消息还未到达延迟结束时间,那么deliverTimestamp就是当前时间
// 反之如果这个消息已经到达了延迟结束时间,那么deliverTimestamp就是延迟结束时间
long deliverTimestamp = this.correctDeliverTimestamp(now, tagsCode);
// 这里计算出的偏移位是当前遍历的前一个ConsumeQueueData的位点,作用是当延迟消息投递到原始的topic失败的时候会根据这个偏移位点去重新执行投递
nextOffset = offset + (i / ConsumeQueue.CQ_STORE_UNIT_SIZE);
long countdown = deliverTimestamp - now;
// 条件成立: 说明延迟时间已经结束了
if (countdown <= 0) {
// 根据CommitLog物理偏移量找到msg
MessageExt msgExt =
ScheduleMessageService.this.defaultMessageStore.lookMessageByOffset(
offsetPy, sizePy);
if (msgExt != null) {
try {
// 拷贝一个新的msg对象,该msg对象的topic以及queueId都回到了原始的值,同时还会删除延迟级别
MessageExtBrokerInner msgInner = this.messageTimeup(msgExt);
if (TopicValidator.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;
}
// 写入到CommitLog中
PutMessageResult putMessageResult =
ScheduleMessageService.this.writeMessageStore
.putMessage(msgInner);
// 写入成功,跳过该次循环,继续判断下一条延迟消息是否达到到期时间
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 {
// 重新提交一个TimeTask,并且设置的延迟执行时间为消息剩余的延时时间,
ScheduleMessageService.this.timer.schedule(
new DeliverDelayedMessageTimerTask(this.delayLevel, nextOffset),
countdown);
// 更新这个延迟队列的本地缓存消费进度
ScheduleMessageService.this.updateOffset(this.delayLevel, nextOffset);
return;
}
}
// 代码执行到这里说明要开始判断下一个消息是否已经到达延迟时间了
// 计算出此时最后一个消息的偏移位,然后根据这个偏移位再次发起一个TimeTask
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();
}
}
// 条件成立:说明这个ConsumeQueue文件没有数据
else {
long cqMinOffset = cq.getMinOffsetInQueue();
if (offset < cqMinOffset) {
failScheduleOffset = cqMinOffset;
log.error("schedule CQ offset invalid. offset=" + offset + ", cqMinOffset="
+ cqMinOffset + ", queueId=" + cq.getQueueId());
}
}
}
// 代码执行到这里说明ConsumeQueue == null,延迟100ms继续执行该任务
ScheduleMessageService.this.timer.schedule(new DeliverDelayedMessageTimerTask(this.delayLevel,
failScheduleOffset), DELAY_FOR_A_WHILE);
}
- 根据延迟级别找到topic为SCHEDULE_TOPIC_XXXX的队列,然后根据消费进度得到对应的ConsumeQueue文件,再返回该文件的所有ConsumeQueueData
// 获取topic为SCHEDULE_TOPIC_XXXX,queueId为 延迟级别 - 1 的ConsumeQueue,如果没有则创建 ConsumeQueue cq = ScheduleMessageService.this.defaultMessageStore.findConsumeQueue(TopicValidator.RMQ_SYS_SCHEDULE_TOPIC, delayLevel2QueueId(delayLevel)); // 根据offset得到对应的ConsumeQueue文件,然后再返回该文件从起始偏移量到写入位点的所有ConsumeQueueData SelectMappedBufferResult bufferCQ = cq.getIndexBuffer(this.offset);
- 遍历每一条ConsumeQueueData,获取ConsumeQueueData中的延迟结束时间,判断当前该消息是否到达这个延迟结束时间,判断方法如下:
// 获取当前时间 long now = System.currentTimeMillis(); // 如果这个消息还未到达延迟结束时间,那么deliverTimestamp就是当前时间 // 反之如果这个消息已经到达了延迟结束时间,那么deliverTimestamp就是延迟结束时间 long deliverTimestamp = this.correctDeliverTimestamp(now, tagsCode); // 这里计算出的偏移位是当前遍历的前一个ConsumeQueueData的位点,作用是当延迟消息投递到原始的topic失败的时候会根据这个偏移位点去重新执行投递 nextOffset = offset + (i / ConsumeQueue.CQ_STORE_UNIT_SIZE); long countdown = deliverTimestamp - now;
private long correctDeliverTimestamp(final long now, final long deliverTimestamp) { long result = deliverTimestamp; long maxTimestamp = now + ScheduleMessageService.this.delayLevelTable.get(this.delayLevel); // 条件成立:比如人为地去修改了系统时钟,获取这条消息的延迟结束时间写入有误 if (deliverTimestamp > maxTimestamp) { result = now; } return result; }
通过延迟结束时间与当前时间作对比,如果延迟结束时间大于当前时间,则表示该条消息还不能被消费,否则延迟结束,可以被消费。并且,RocketMQ通过correctDeliverTimestamp方法防止了某条消息写入时延迟结束时间有误,或者系统时钟被人为修改的问题
- 消息到达延迟结束时间的话,那么就把该消息恢复到原始的topic然后再写入到CommitLog,然后更新offsetTable中这个延迟队列的消费进度,最后继续提交TimeTask去检查下一条延迟消息
// 根据CommitLog物理偏移量找到msg MessageExt msgExt =ScheduleMessageService.this.defaultMessageStore.lookMessageByOffset(offsetPy, sizePy); // 拷贝一个新的msg对象,该msg对象的topic以及queueId都回到了原始的值,同时还会删除延迟级别 MessageExtBrokerInner msgInner = this.messageTimeup(msgExt); // 写入到CommitLog中 PutMessageResult putMessageResult = ScheduleMessageService.this.writeMessageStoreputMessage(msgInner); // 计算出此时下一条消息的偏移位,然后根据这个偏移位再次发起一个TimeTask ScheduleMessageService.this.timer.schedule(new DeliverDelayedMessageTimerTask(this.delayLevel, nextOffset), DELAY_FOR_A_WHILE); // 更新延迟队列已消费的消息偏移量 ScheduleMessageService.this.updateOffset(this.delayLevel, nextOffset);
- 消息未到达延迟结束时间的话,重新提交一个TimeTask,延迟执行时间为该当前时间与该消息延时结束时间的差值,那么下一次该TimeTask执行的时候该消息就肯定已经到达了延迟结束时间了
// 重新提交一个TimeTask,并且设置的延迟执行时间为消息剩余的延时时间, ScheduleMessageService.this.timer.schedule( new DeliverDelayedMessageTimerTask(this.delayLevel, nextOffset), countdown);
(6)对延迟队列的消费进度进行持久化
// 每10s把每一个延迟队列的消费进度写入到磁盘中,当在这10s内broker宕机了,在broker启动之后就会导致延迟消息的重复消费
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());
在ScheduleMessageService启动的时候还会启动一个周期任务,这个周期任务会每10s把offsetTable中的数据进行持久化到delayOffset.json文件,这样下一次broker重启之后就可以从delayOffset,json中获取到上一次延迟队列的消费进度了
(7)提出疑问
- 当发送了延迟消息之后broker宕机了,重启之后还能够按照期望收到这个延迟消息吗?
答案是可以的,因为ConsumeQueueData中记录了该条消息的延迟结束时间,并且RocketMQ还通过delayOffset.json文件记录每一个延迟队列的消费进度,重启broker之后会从这个消费进度的消息开始判断,如果这个延迟消息的延迟结束时间早就过了,那么此时可以直接消费这个消息,如果还未到延迟结束时间,则可以继续等待到达这个延迟结束时间
- 延迟消息会被重复消费吗?
RocketMQ对所有消息都不保证被重复消费,不过由于延迟消息有自己的消费进度管理,每10s会持久化一次消费进度,所以如果broker宕机了,就有可能会造成这10s内到达延迟结束时间的消息再次被写入到CommitLog从而再次被消费者所消费