RocketMQ的消息都是按照先来后到,顺序的存储在CommitLog中的,而消费者通常只关心某个Topic下的消息。顺序的查找CommitLog肯定是不现实的,我们可以构建一个索引文件,里面存放着某个Topic下面所有消息在CommitLog中的位置,这样消费者获取消息的时候,只需要先查找这个索引文件,然后再去CommitLog中获取消息就 OK了。这个索引文件,就是我们的ComsumerQueue。
在Broker中,构建ComsummerQueue不是存储完CommitLog就马上同步构建的,而是通过一个线程任务异步的去做这个事情。在DefaultMessageStore中有一个ReputMessageService成员,它就是负责构建ComsumerQueue的任务线程。
public class DefaultMessageStore implements MessageStore {
// 。。。。省略无关代码
private final ReputMessageService reputMessageService;
}
ReputMessageService继承自ServiceThread,表明其是一个服务线程,它的run方法很简单,如下所示:
public void run() {
while (!this.isStopped()) {
try {
Thread.sleep(1);
this.doReput(); // 构建ComsumerQueue
} catch (Exception e) {
DefaultMessageStore.log.warn(this.getServiceName() + " service has exception. ", e);
}
}
}
在run方法里,每休息1毫秒就进行一次构建ComsumerQueue的动作。因为必须先写入CommitLog,然后才能进行ComsumerQueue的构建。那么不排除构建ComsumerQueue的速度太快了,而CommitLog还没写入新的消息。这时就需要sleep下,让出cpu时间片,避免浪费CPU资源。
doReput的代码如下所示:
private void doReput() {
for (boolean doNext = true; this.isCommitLogAvailable() && doNext; ) {
SelectMappedBufferResult result = DefaultMessageStore.this.commitLog.getData(reputFromOffset);// 拿到所有的最新写入CommitLog的数据
if (result != null) {
try {
this.reputFromOffset = result.getStartOffset();
for (int readSize = 0; readSize < result.getSize() && doNext; ) {
DispatchRequest dispatchRequest =
DefaultMessageStore.this.commitLog.checkMessageAndReturnSize(result.getByteBuffer(), false, false); // 一条一条的读消息
int size = dispatchRequest.getMsgSize();
if (dispatchRequest.isSuccess()) {
if (size > 0) {
DefaultMessageStore.this.doDispatch(dispatchRequest); // 派发消息,进行处理,其中就包括构建ComsumerQueue
this.reputFromOffset += size;
readSize += size;
} else if (size == 0) { //
this.reputFromOffset = DefaultMessageStore.this.commitLog.rollNextFile(this.reputFromOffset);
readSize = result.getSize();
}
} else if (!dispatchRequest.isSuccess()) { // 获取消息异常
if (size > 0) {
log.error("[BUG]read total count not equals msg total size. reputFromOffset={}", reputFromOffset);
this.reputFromOffset += size;
} else {
doNext = false;
if (DefaultMessageStore.this.brokerConfig.getBrokerId() == MixAll.MASTER_ID) {
this.reputFromOffset += result.getSize() - readSize;
}
}
}
}
} finally {
result.release();
}
} else {
doNext = false;
}
}
}
为了突出重点,省略了一些和构建ComsumerQueue不相干的代码。在doReput里面,其实做了3件事情:
1-获取最新写入到CommitLog中的数据byteBuffer。
2-从byteBuffer中一条条的读取消息,并派发出去处理。
3-更新reputFromOffset位移。
reputFromOffset是一个非常重要参数,它指出了我们应该从哪里开始构建ComsumerQueue。在DefaultMessageStore的start()方法中,对reputFromOffset进行了初始化:
public void start() throws Exception
if (this.getMessageStoreConfig().isDuplicationEnable()) {
this.reputMessageService.setReputFromOffset(this.commitLog.getConfirmOffset());
} else {
this.reputMessageService.setReputFromOffset(this.commitLog.getMaxOffset());
}
this.reputMessageService.start();
}
如果允许消息重复,那么reputFromOffset会从CommitLog的ConfirmOffset中获取,否则获取CommitLog的最大偏移量。duplicationEnable默认是关闭的,也就是默认是获取CommitLog的最大写入的偏移量。
我对这个confirmOffset其实并不理解,在代码里也没有搜到设置该值的源头,应该是需要自己实现MessageStore类的时候才用得到。既然默认是不允许重复的,那么这个就不再去深究了,也不影响我们对ComsumerQueue的理解。
看到这里,我其实是有一个疑问的:为什么会从CommitLog的最大偏移量开始构建呢?。按照正常的思路,应该最开始从零开始构建,然后构建一个,reputFromOffset就累加一个。构建完一批后,如果又可以构建了,则接着从上次结束的地方开始构建。我们来一探究竟。
我们先看看getMaxOffset方法:
public long getMaxOffset() {
MappedFile mappedFile = getLastMappedFile();
if (mappedFile != null) {
return mappedFile.getFileFromOffset() + mappedFile.getReadPosition();
}
return 0;
}
在RocketMQ第一次启动时,没有发送消息,Commit文件还没有创建。此时getLastMappedFile()返回的是null,因此getMaxOffset拿到的就是0。也就是说,当RocketMQ第一次启动,构建ConsumerQueue是从头开始的,这个符合我们的期望。通过断点查看reputFromOffset,确实如此,如下所示:
那么如果CommitLog还有数据没有处理完,Broker通过shutdown正常停止了呢?那下次重新启动,reputFromOffset设置成了getMaxOffset,那不是丢了一部分数据了嘛?
不必担心,在DefaultMessageStore关闭时,会尽力等待数据追上。具体来说就是通过50次sleep,每次100毫秒,如果在这时间内ConsumerQueue可以把数据追平(reputFromOffset == maxOffset),那么就没有问题。如果仍然不能追平,那没办法了,打个警告日志吧。后续可以通过手工重发消息处理。
public void shutdown() {
for (int i = 0; i < 50 && this.isCommitLogAvailable(); i++) {
try {
Thread.sleep(100);
} catch (InterruptedException ignored) {
}
}
if (this.isCommitLogAvailable()) {
log.warn("shutdown ReputMessageService, but commitlog have not finish to be dispatched, CL: {} reputFromOffset: {}",
DefaultMessageStore.this.commitLog.getMaxOffset(), this.reputFromOffset);
}
super.shutdown();
}
好,继续回到我们doReput所做的3件事情。
当设置好reputFromOffset之后,就可以从CommitLog中获取从reputFromOffset到目前已经写入的所有消息内容。
public SelectMappedBufferResult getData(final long offset, final boolean returnFirstOnNotFound) {
int mappedFileSize = this.defaultMessageStore.getMessageStoreConfig().getMapedFileSizeCommitLog();
MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset, returnFirstOnNotFound);
if (mappedFile != null) {
int pos = (int) (offset % mappedFileSize);
SelectMappedBufferResult result = mappedFile.selectMappedBuffer(pos);
return result;
}
return null;
}
首先从mappedFileQueue中,根据偏移量找到消息的mappedFile文件,具体算法是根据reputFromOffset-第一个文件的offset,然后除以单个文件大小,得到该文件的索引值。如果该文件存在,则返回,否则循环查找一遍。
找到文件后,取出对应的byteBuffer内容,封装后返回。
拿到所有消息的byteBuffer后,循环读取消息,封装成DispatchRequest进行派发处理。
DefaultMessageStore.this.doDispatch(dispatchRequest);
DispatchRequest类如下所示:
public class DispatchRequest {
private final String topic;
private final int queueId;
private final long commitLogOffset;
private final int msgSize;
private final long tagsCode;
private final long storeTimestamp;
private final long consumeQueueOffset; // 逻辑偏移量,非物理偏移量
private final String keys;
private final boolean success;
private final String uniqKey;
// .....省略非重点
}
构建ComsumerQueue的派发处理在CommitLogDispatcherBuildConsumeQueue类中进行处理,如下所示:
public void dispatch(DispatchRequest request) {
final int tranType = MessageSysFlag.getTransactionValue(request.getSysFlag());
switch (tranType) {
case MessageSysFlag.TRANSACTION_NOT_TYPE:
case MessageSysFlag.TRANSACTION_COMMIT_TYPE:
DefaultMessageStore.this.putMessagePositionInfo(request);
break;
case MessageSysFlag.TRANSACTION_PREPARED_TYPE:
case MessageSysFlag.TRANSACTION_ROLLBACK_TYPE:
break;
}
}
由于正常情况下是非事务消息,因此走到的是MessageSysFlag.TRANSACTION_NOT_TYPE。
public void putMessagePositionInfo(DispatchRequest dispatchRequest) {
ConsumeQueue cq = this.findConsumeQueue(dispatchRequest.getTopic(), dispatchRequest.getQueueId());
cq.putMessagePositionInfoWrapper(dispatchRequest);
}
然后根据topic和queueId找到对应的ConsumerQueue。这里queueId是Producer发送消息时就根据算法选好了的,具体怎么选的可以参考之前的文章:消息队列如何选择的?。找到队列后,就可以保存相关信息了,如下所示:
boolean result = this.putMessagePositionInfo(request.getCommitLogOffset(),
request.getMsgSize(), tagsCode, request.getConsumeQueueOffset());
其中这个cosumerQueueOffset是逻辑偏移量,并非物理偏移量。因为每一个条目都是固定的20个字节大小,
存放的内容是8字节的消息偏移+4字节的消息长度+8字节的tagsCode。这样存储一个索引条目时,通过这个逻辑偏移量*20字节,就可以得到它的物理偏移量。如下所示:
final long expectLogicOffset = cqOffset * CQ_STORE_UNIT_SIZE;
然后就是熟悉的流程了,找到偏移所在的文件,然后保存内容。
在循环处理消息时,每处理一条,则将reputFromOffset更新。这里更新是根据获取消息的结果来的,因此有必要看看是如何获取单条消息的。
/**
* check the message and returns the message size
*
* @return 0 Come the end of the file // >0 Normal messages // -1 Message checksum failure
*/
public DispatchRequest checkMessageAndReturnSize(java.nio.ByteBuffer byteBuffer, final boolean checkCRC,
final boolean readBody) {}
注释写的比较清楚,size=0,说明到了文件末尾。 > 0说明是正常的消息。< 0说明消息有误。下面我们跟着checkMessageAndReturnSize代码一个个来看:
int magicCode = byteBuffer.getInt();
switch (magicCode) {
case MESSAGE_MAGIC_CODE:
break;
case BLANK_MAGIC_CODE:
return new DispatchRequest(0, true /* success */);
default:
log.warn("found a illegal magic code 0x" + Integer.toHexString(magicCode));
return new DispatchRequest(-1, false /* success */);
}
在之前的讲解RocketMQ消息存储结构-CommitLog的时候,讲过这个magicCode:
因此当该mappedFile没有空间存储该条消息时,其magicCode是BLANK_MAGIC_CODE,此时我们返回的DispatchRequest(0, true /* success */)。后续的处理就是滚动到下一个mappedFile:
此时reputFromOffset更新为下个文件的偏移开始位置。
注:rollNextFile里面reputFromOffset计算显得略微复杂了,不知为何。其实直接取该mappedFile的fileFromOffset就可以了。
当magicCode不正确的时候,表明消息出问题了。此时返回DispatchRequest(-1, false /* success */)。后续处理就是结束本次doReput,并将reputFromOffset设置到本次数据末尾。
消息出问题后,Broker MASTER节点的reputFromOffset跳过了剩下的数据,这意味着后续的部分数据都被忽略不再处理了。为什么不仅仅跳过该条消息呢?这里我还不大清楚。
接下来是检查消息CRC,如果检查失败,返回的也是DispatchRequest(-1, false /* success */),处理方法同magicCode一样,都是消息本身有问题的处理方式。
然后是手动计算一遍消息大小和读取的消息大小是否一致,如果不一致,则返回DispatchRequest(totalSize, false/* success */)。此时reputFromOffset就跳过该条消息,如下所示:
this.reputFromOffset += size;
至此,reputFromOffset的更新,我们就讲完了。
通过此篇文章,我们大致的了解了RocketMQ是如何构建消费队列(ConsumerQueue)的。通过一个服务线程,异步的从CommitLog中获取已经写入的消息,然后将消息位置,大小,tagsCode保存至我们的选好的ConsumerQueue中。但是,这里的保存仅仅是写入byteBuffer,还没有真正的落到物理文件上。真正的落盘操作,也是通过服务线程进行异步处理的,限于篇幅,我们后续再谈。