RocketMQ源码分析——消息可靠性

RocketMQ支持消息的高可靠,影响消息可靠性的几种情况:

  1. Broker正常关闭
  2. Broker异常Crash
  3. OS Crash
  4. 机器掉电,但是能立即恢复供电情况
  5. 机器无法开机(可能是cpu、主板、内存等关键设备损坏)
  6. 磁盘设备损坏

1) 正常的关闭,Broker 可以正常启动并恢复所有数据。
2)、3)、4) 同步刷盘可以保证数据不丢失,异步刷盘可能导致少量数据丢失。
5)、6)属于单点故障,且无法恢复。解决单点故障可以采用增加 Slave 节点,主从异步复制仍然可能有极少量数据丢失,同步复制可以完全避免单点问题。

本篇文章分析 Broker 正常关闭和异常关闭,消息时怎么恢复的。

异步刷盘,Broker 异常关闭:消息只存储到内存,异步线程可能还没有执行刷盘 Broker 就关闭了,导致消息没有存入 CommitLog,部分数据丢失。还有另一种情况,可能消息存入了 CommitLog,但是消费队列还没有刷盘,此时 Broker 异常关闭,异常恢复会导致消息重复消费。

如何判断 Broker 有没有异常关闭

Broker 启动之后会会创建一个 abort 文件
org.apache.rocketmq.store.DefaultMessageStore#createTempFile

private void createTempFile() throws IOException {
    String fileName = StorePathConfigHelper.getAbortFile(this.messageStoreConfig.getStorePathRootDir());
    File file = new File(fileName);
    MappedFile.ensureDirOK(file.getParent());
    boolean result = file.createNewFile();
    log.info(fileName + (result ? " create OK" : " already exists"));
}

正常关闭 Broker 会删除此文件

public void shutdown() {
	...
	// CommitLog 无新消息分发
	if (this.runningFlags.isWriteable() && dispatchBehindBytes() == 0) {
	    this.deleteFile(StorePathConfigHelper.getAbortFile(this.messageStoreConfig.getStorePathRootDir()));
	    shutDownNormal = true;
	} else {
	    log.warn("the store may be wrong, so shutdown abnormally, and keep abort file.");
	}
}

若启动前发现有 abort 文件,说明Broker没有正常退出。启动 Broker 前先加载 CommitLog、ConsumeQueue 文件,然后通过 StoreCheckpoint 恢复索引和以上两个文件。
org.apache.rocketmq.store.DefaultMessageStore#load

public boolean load() {
	boolean result = true;
	try {
	    // 判断是否存在abort文件
	    boolean lastExitOK = !this.isTempFileExist();
	    if (null != scheduleMessageService) {
	        // 延迟队列,用于定时消息
	        result = result && this.scheduleMessageService.load();
	    }
		...
		// load Commit Log
		result = result && this.commitLog.load();
		// load Consume Queue
		result = result && this.loadConsumeQueue();
		if (result) {
		    this.storeCheckpoint =
		        new StoreCheckpoint(StorePathConfigHelper.getStoreCheckpoint(this.messageStoreConfig.getStorePathRootDir()));
		
		    this.indexService.load(lastExitOK);
		
		    this.recover(lastExitOK);
		    ...
加载消息存储文件、消费队列、索引文件

加载消息存储文件 CommitLog
org.apache.rocketmq.store.MappedFileQueue#load

public boolean load() {
    File dir = new File(this.storePath);
    File[] files = dir.listFiles();
    if (files != null) {
        // ascending order
        Arrays.sort(files);
        for (File file : files) {
            if (file.length() != this.mappedFileSize) {
                return false;
            }
            try {
                MappedFile mappedFile = new MappedFile(file.getPath(), mappedFileSize);
                mappedFile.setWrotePosition(this.mappedFileSize);
                mappedFile.setFlushedPosition(this.mappedFileSize);
                mappedFile.setCommittedPosition(this.mappedFileSize);
                this.mappedFiles.add(mappedFile);
            } catch (IOException e) {
                return false;
            }
        }
    }
    return true;
}

读取存储目录下所有的文件,按名称排序后,检验文件长度是否是配置的默认大小,加载符合条件的文件。

加载消费队列文件 ConsumeQueue
org.apache.rocketmq.store.DefaultMessageStore#loadConsumeQueue

private boolean loadConsumeQueue() {
	// 读取消息队列主目录
    File dirLogic = new File(StorePathConfigHelper.getStorePathConsumeQueue(this.messageStoreConfig.getStorePathRootDir()));
    // 获取到所有的主题目录
    File[] fileTopicList = dirLogic.listFiles();
    if (fileTopicList != null) {
		// 遍历主题目录
        for (File fileTopic : fileTopicList) {
            String topic = fileTopic.getName();
			// 获取到主题下的队列目录
            File[] fileQueueIdList = fileTopic.listFiles();
            if (fileQueueIdList != null) {
            	// 遍历队列目录
                for (File fileQueueId : fileQueueIdList) {
                    int queueId;
                    try {
                        queueId = Integer.parseInt(fileQueueId.getName());
                    } catch (NumberFormatException e) {
                        continue;
                    }
                    // 构建消费队列 ConsumeQueue 
                    ConsumeQueue logic = new ConsumeQueue(
                        topic,
                        queueId,
                        StorePathConfigHelper.getStorePathConsumeQueue(this.messageStoreConfig.getStorePathRootDir()),
                        this.getMessageStoreConfig().getMapedFileSizeConsumeQueue(),
                        this);
                    this.putConsumeQueue(topic, queueId, logic);
                    // 加载队列目录下的所有文件
                    if (!logic.load()) {
                        return false;
                    }
                }
            }
        }
    }
    return true;
}

加载存储检查时间点 checkpoint 文件

public StoreCheckpoint(final String scpPath) throws IOException {
	...
    if (fileExists) {
    	// commitLog文件刷盘时间
        this.physicMsgTimestamp = this.mappedByteBuffer.getLong(0);
        // consumeQueue文件刷盘时间
        this.logicsMsgTimestamp = this.mappedByteBuffer.getLong(8);
        // index文件刷盘时间
        this.indexMsgTimestamp = this.mappedByteBuffer.getLong(16);
    }
}
恢复索引文件

org.apache.rocketmq.store.index.IndexService#load

public boolean load(final boolean lastExitOK) {
    File dir = new File(this.storePath);
    // 获取索引目录下所有文件
    File[] files = dir.listFiles();
    if (files != null) {
        // ascending order
        Arrays.sort(files);
       	// 遍历索引文件
        for (File file : files) {
            try {
            	// 加载
                IndexFile f = new IndexFile(file.getPath(), this.hashSlotNum, this.indexNum, 0, 0);
                f.load();
                if (!lastExitOK) {
                    // 上次异常退出,且index文件最大消息时间戳大于最后的刷盘时间,就立即销毁此文件
                    if (f.getEndTimestamp() > this.defaultMessageStore.getStoreCheckpoint()
                        .getIndexMsgTimestamp()) {
                        f.destroy(0);
                        continue;
                    }
                }
                this.indexFileList.add(f);
                ...
恢复消息存储文件、消费队列、索引文件

org.apache.rocketmq.store.DefaultMessageStore#recover

private void recover(final boolean lastExitOK) {
	// 恢复消费队列
    long maxPhyOffsetOfConsumeQueue = this.recoverConsumeQueue();
    if (lastExitOK) {
    	// 正常退出
        this.commitLog.recoverNormally(maxPhyOffsetOfConsumeQueue);
    } else {
    	// 异常退出
        this.commitLog.recoverAbnormally(maxPhyOffsetOfConsumeQueue);
    }
    // 取出 CommitLog 的最小物理偏移量,计算并恢复消费队列最小逻辑偏移量 minLogicOffset
    this.recoverTopicQueueTable();
}
恢复消费队列

org.apache.rocketmq.store.DefaultMessageStore#recoverConsumeQueue

private long recoverConsumeQueue() {
    long maxPhysicOffset = -1;
    for (ConcurrentMap<Integer, ConsumeQueue> maps : this.consumeQueueTable.values()) {
        for (ConsumeQueue logic : maps.values()) {
            logic.recover();
            if (logic.getMaxPhysicOffset() > maxPhysicOffset) {
                maxPhysicOffset = logic.getMaxPhysicOffset();
            }
        }
    }
    return maxPhysicOffset;
}

遍历每个主题下的消费队列,恢复每个 ConsumeQueue 下的所有文件

    public void recover() {
        final List<MappedFile> mappedFiles = this.mappedFileQueue.getMappedFiles();
        if (!mappedFiles.isEmpty()) {
        	// 从倒数第三个文件开始恢复
            int index = mappedFiles.size() - 3;
            if (index < 0)
                index = 0;

            int mappedFileSizeLogics = this.mappedFileSize;
            MappedFile mappedFile = mappedFiles.get(index);
            ByteBuffer byteBuffer = mappedFile.sliceByteBuffer();
            long processOffset = mappedFile.getFileFromOffset();
            // 已校验通过的位置
            long mappedFileOffset = 0;
            long maxExtAddr = 1;
            while (true) {
                for (int i = 0; i < mappedFileSizeLogics; i += CQ_STORE_UNIT_SIZE) {
                	// 在 CommitLog 中的物理偏移量
                    long offset = byteBuffer.getLong();
                    // 消息长度
                    int size = byteBuffer.getInt();
                    long tagsCode = byteBuffer.getLong();
                    if (offset >= 0 && size > 0) {
                        mappedFileOffset = i + CQ_STORE_UNIT_SIZE;
                        this.maxPhysicOffset = offset + size;
                    } else {
                        break;
                    }
                }

                if (mappedFileOffset == mappedFileSizeLogics) {
                    index++;
                    if (index >= mappedFiles.size()) {
                    	// 恢复完成
                        break;
                    } else {
                    	// 切换到下个文件
                        mappedFile = mappedFiles.get(index);
                        byteBuffer = mappedFile.sliceByteBuffer();
                        processOffset = mappedFile.getFileFromOffset();
                        mappedFileOffset = 0;
                    }
                } else {
                    break;
                }
            }
			// 获取到最大有效物理偏移量
            processOffset += mappedFileOffset;
            this.mappedFileQueue.setFlushedWhere(processOffset);
            this.mappedFileQueue.setCommittedWhere(processOffset);
            this.mappedFileQueue.truncateDirtyFiles(processOffset);
            ...
        }
    }

记录 CommitLog 中的最大有效有效物理偏移量 到 ConsumeQueue.mappedFileOffset 中。设置好 ConsumeQueue 的提交点和刷盘点后,还需要删除大于此偏移量的数据。
org.apache.rocketmq.store.MappedFileQueue#truncateDirtyFiles

public void truncateDirtyFiles(long offset) {
    List<MappedFile> willRemoveFiles = new ArrayList<MappedFile>();
    for (MappedFile file : this.mappedFiles) {
        long fileTailOffset = file.getFileFromOffset() + this.mappedFileSize;
        // 判断文件尾部的偏移量是否大于有效位置,小于不用处理
        if (fileTailOffset > offset) {
        	// 判断文件起始偏移量是否小于有效位置
            if (offset >= file.getFileFromOffset()) {
                file.setWrotePosition((int) (offset % this.mappedFileSize));
                file.setCommittedPosition((int) (offset % this.mappedFileSize));
                file.setFlushedPosition((int) (offset % this.mappedFileSize));
            } else {
            	// 销毁文件
                file.destroy(1000);
                // 加入到待删除文件列表
                willRemoveFiles.add(file);
            }
        }
    }
    // 从缓存列表中删除
    this.deleteExpiredFile(willRemoveFiles);
}

设置好消息队列下每个文件的写位置、提交点、刷盘点。

正常恢复 CommitLog

org.apache.rocketmq.store.CommitLog#recoverNormally

/**
 1. When the normal exit, data recovery, all memory data have been flush
 */
public void recoverNormally(long maxPhyOffsetOfConsumeQueue) {
	// 是否需要校验 CRC,默认为 true
    boolean checkCRCOnRecover = this.defaultMessageStore.getMessageStoreConfig().isCheckCRCOnRecover();
    final List<MappedFile> mappedFiles = this.mappedFileQueue.getMappedFiles();
    if (!mappedFiles.isEmpty()) {
        // Began to recover from the last third file
        int index = mappedFiles.size() - 3;
        if (index < 0)
            index = 0;

        MappedFile mappedFile = mappedFiles.get(index);
        ByteBuffer byteBuffer = mappedFile.sliceByteBuffer();
        long processOffset = mappedFile.getFileFromOffset();
        long mappedFileOffset = 0;
        while (true) {
        	// 每次获取一条消息,校验消息并返回消息的长度
            DispatchRequest dispatchRequest = this.checkMessageAndReturnSize(byteBuffer, checkCRCOnRecover);
            int size = dispatchRequest.getMsgSize();
            // Normal data
            if (dispatchRequest.isSuccess() && size > 0) {
                mappedFileOffset += size;
            }
            else if (dispatchRequest.isSuccess() && size == 0) {
                // 获取消息状态为成功,并且消息为空,代表到达文件末尾
                index++;
                if (index >= mappedFiles.size()) {
                    break;
                } else {
                	// 切换到下个文件
                    mappedFile = mappedFiles.get(index);
                    byteBuffer = mappedFile.sliceByteBuffer();
                    processOffset = mappedFile.getFileFromOffset();
                    mappedFileOffset = 0;
                }
            }
            // Intermediate file read error
            else if (!dispatchRequest.isSuccess()) {
                break;
            }
        }
        // 获取到已校验通过的物理偏移量
        processOffset += mappedFileOffset;
        this.mappedFileQueue.setFlushedWhere(processOffset);
        this.mappedFileQueue.setCommittedWhere(processOffset);
        // 删除此偏移量后的脏数据
        this.mappedFileQueue.truncateDirtyFiles(processOffset);

        // Clear ConsumeQueue redundant data 
        // 如果消息队列中的最大有效偏移量大于此偏移量,删除消息队列中的脏数据
        if (maxPhyOffsetOfConsumeQueue >= processOffset) {
            this.defaultMessageStore.truncateDirtyLogicFiles(processOffset);
        }
    } else {
    	// CommitLog 文件不存在,删除所有的消息消费队列数据
        this.mappedFileQueue.setFlushedWhere(0);
        this.mappedFileQueue.setCommittedWhere(0);
        this.defaultMessageStore.destroyLogics();
    }
}

从倒数第三个 CommitLog 文件开始恢复,每次取出一条消息并校验,直到找到第一条不成功的消息。然后恢复提交点、刷盘点,并更新消费队列的数据。

异常恢复 CommitLog

与正常恢复的区别在于:

  1. 从最后一个文件开始向前找,直到找到第一个正常的消息
  2. 判断是否是正常的消息需要根据 checkpoint 文件中的数据
  3. 重新分发消息到消费队列和索引文件

org.apache.rocketmq.store.CommitLog#recoverAbnormally

@Deprecated
public void recoverAbnormally(long maxPhyOffsetOfConsumeQueue) {
    ...
    	// 从后向前
        int index = mappedFiles.size() - 1;
        MappedFile mappedFile = null;
        for (; index >= 0; index--) {
            mappedFile = mappedFiles.get(index);
            // 找到第一个满足恢复条件的 CommitLog 文件
            if (this.isMappedFileMatchedRecover(mappedFile)) {
                break;
            }
        }

        if (index < 0) {
            index = 0;
            mappedFile = mappedFiles.get(index);
        }

        ByteBuffer byteBuffer = mappedFile.sliceByteBuffer();
        long processOffset = mappedFile.getFileFromOffset();
        long mappedFileOffset = 0;
        while (true) {
        	// 获取一条消息
            DispatchRequest dispatchRequest = this.checkMessageAndReturnSize(byteBuffer, checkCRCOnRecover);
            int size = dispatchRequest.getMsgSize();
            if (dispatchRequest.isSuccess()) {
                // Normal data
                if (size > 0) {
                    mappedFileOffset += size;
                    // 默认false
                    if (this.defaultMessageStore.getMessageStoreConfig().isDuplicationEnable()) {
                    	// confirmOffset 看字面意思是已确定的偏移量
                        if (dispatchRequest.getCommitLogOffset() < this.defaultMessageStore.getConfirmOffset()) {
                            this.defaultMessageStore.doDispatch(dispatchRequest);
                        }
                    } else {
                    	// 重新分发此消息到消费队列和索引文件
                        this.defaultMessageStore.doDispatch(dispatchRequest);
                    }
                }
        ...和正常恢复一样

判断是否可从某个 CommitLog 开始恢复消息数据
org.apache.rocketmq.store.CommitLog#isMappedFileMatchedRecover

private boolean isMappedFileMatchedRecover(final MappedFile mappedFile) {
    ByteBuffer byteBuffer = mappedFile.sliceByteBuffer();
	// 判断文件的魔数,是否符合存储规范
    int magicCode = byteBuffer.getInt(MessageDecoder.MESSAGE_MAGIC_CODE_POSTION);
    if (magicCode != MESSAGE_MAGIC_CODE) {
        return false;
    }
	// 第一条消息数据的存储时间是否大于0,为0此文件不存在消息
    long storeTimestamp = byteBuffer.getLong(MessageDecoder.MESSAGE_STORE_TIMESTAMP_POSTION);
    if (0 == storeTimestamp) {
        return false;
    }
	// 校验 
    if (this.defaultMessageStore.getMessageStoreConfig().isMessageIndexEnable()
    	// 是否索引文件的刷盘时间点也参与计算,默认为false
        && this.defaultMessageStore.getMessageStoreConfig().isMessageIndexSafe()) {
        if (storeTimestamp <= this.defaultMessageStore.getStoreCheckpoint().getMinTimestampIndex()) {
            return true;
        }
    } else {
    	// 消息存储的时间早于校验文件刷盘时间,此消息有效
        if (storeTimestamp <= this.defaultMessageStore.getStoreCheckpoint().getMinTimestamp()) {
            return true;
        }
    }
    return false;
}

异常恢复,会从有效的文件起始位置开始,将所有有效的消息重新分发到消费队列和索引文件,保证消息至少消费一次,但这可能会导致消息重复消费。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值