RocketMQ支持消息的高可靠,影响消息可靠性的几种情况:
- Broker正常关闭
- Broker异常Crash
- OS Crash
- 机器掉电,但是能立即恢复供电情况
- 机器无法开机(可能是cpu、主板、内存等关键设备损坏)
- 磁盘设备损坏
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
与正常恢复的区别在于:
- 从最后一个文件开始向前找,直到找到第一个正常的消息
- 判断是否是正常的消息需要根据 checkpoint 文件中的数据
- 重新分发消息到消费队列和索引文件
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;
}
异常恢复,会从有效的文件起始位置开始,将所有有效的消息重新分发到消费队列和索引文件,保证消息至少消费一次,但这可能会导致消息重复消费。