源码分析 RocketMQ DLedger(多副本) 之日志追加流程

本文详细解释了RocketMQDLedger中,Leader节点如何判断Push队列是否已满,如何存储数据,以及主节点如何等待从节点复制ACK的过程,涉及数据结构、文件存储和异步通信机制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

long currTerm = memberState.currTerm();

if (dLedgerEntryPusher.isPendingFull(currTerm)) { // @1

AppendEntryResponse appendEntryResponse = new AppendEntryResponse();

appendEntryResponse.setGroup(memberState.getGroup());

appendEntryResponse.setCode(DLedgerResponseCode.LEADER_PENDING_FULL.getCode());

appendEntryResponse.setTerm(currTerm);

appendEntryResponse.setLeaderId(memberState.getSelfId());

return AppendFuture.newCompletedFuture(-1, appendEntryResponse);

} else { // @2

DLedgerEntry dLedgerEntry = new DLedgerEntry();

dLedgerEntry.setBody(request.getBody());

DLedgerEntry resEntry = dLedgerStore.appendAsLeader(dLedgerEntry);

return dLedgerEntryPusher.waitAck(resEntry);

}

Step2:如果预处理队列已经满了,则拒绝客户端请求,返回 LEADER_PENDING_FULL 错误码;如果未满,将请求封装成 DledgerEntry,则调用 dLedgerStore 方法追加日志,并且通过使用 dLedgerEntryPusher 的 waitAck 方法同步等待副本节点的复制响应,并最终将结果返回给调用方法。

  • 代码@1:如果 dLedgerEntryPusher 的 push 队列已满,则返回追加一次,其错误码为 LEADER_PENDING_FULL。

  • 代码@2:追加消息到 Leader 服务器,并向从节点广播,在指定时间内如果未收到从节点的确认,则认为追加失败。

接下来就按照上述三个要点进行展开:

  • 判断 Push 队列是否已满

  • Leader 节点存储消息

  • 主节点等待从节点复制 ACK

1.1 如何判断 Push 队列是否已满

DLedgerEntryPusher#isPendingFull

public boolean isPendingFull(long currTerm) {

checkTermForPendingMap(currTerm, “isPendingFull”); // @1

return pendingAppendResponsesByTerm.get(currTerm).size() > dLedgerConfig.getMaxPendingRequestsNum(); // @2

}

主要分两个步骤:

代码@1:检查当前投票轮次是否在 PendingMap 中,如果不在,则初始化,其结构为:Map< Long/* 投票轮次*/, ConcurrentMap<Long, TimeoutFuture< AppendEntryResponse>>>。

代码@2:检测当前等待从节点返回结果的个数是否超过其最大请求数量,可通过maxPendingRequests

Num 配置,该值默认为:10000。

上述逻辑比较简单,但疑问随着而来,ConcurrentMap<Long, TimeoutFuture< AppendEntryResponse>> 中的数据是从何而来的呢?我们不妨接着往下看。

1.2 Leader 节点存储数据

Leader 节点的数据存储主要由 DLedgerStore 的 appendAsLeader 方法实现。DLedger 分别实现了基于内存、基于文件的存储实现,本文重点关注基于文件的存储实现,其实现类为:DLedgerMmapFileStore。

下面重点来分析一下数据存储流程,其入口为DLedgerMmapFileStore 的 appendAsLeader 方法。

DLedgerMmapFileStore#appendAsLeader

PreConditions.check(memberState.isLeader(), DLedgerResponseCode.NOT_LEADER);

PreConditions.check(!isDiskFull, DLedgerResponseCode.DISK_FULL);

Step1:首先判断是否可以追加数据,其判断依据主要是如下两点:

  • 当前节点的状态是否是 Leader,如果不是,则抛出异常。

  • 当前磁盘是否已满,其判断依据是 DLedger 的根目录或数据文件目录的使用率超过了允许使用的最大值,默认值为85%。

ByteBuffer dataBuffer = localEntryBuffer.get();

ByteBuffer indexBuffer = localIndexBuffer.get();

Step2:从本地线程变量获取一个数据与索引 buffer。其中用于存储数据的 ByteBuffer,其容量固定为 4M ,索引的 ByteBuffer 为两个索引条目的长度,固定为64个字节。

DLedgerEntryCoder.encode(entry, dataBuffer);

public static void encode(DLedgerEntry entry, ByteBuffer byteBuffer) {

byteBuffer.clear();

int size = entry.computSizeInBytes();

//always put magic on the first position

byteBuffer.putInt(entry.getMagic());

byteBuffer.putInt(size);

byteBuffer.putLong(entry.getIndex());

byteBuffer.putLong(entry.getTerm());

byteBuffer.putLong(entry.getPos());

byteBuffer.putInt(entry.getChannel());

byteBuffer.putInt(entry.getChainCrc());

byteBuffer.putInt(entry.getBodyCrc());

byteBuffer.putInt(entry.getBody().length);

byteBuffer.put(entry.getBody());

byteBuffer.flip();

}

Step3:将 DLedgerEntry,即将数据写入到 ByteBuffer中,从这里看出,每一次写入会调用 ByteBuffer 的 clear 方法,将数据清空,从这里可以看出,每一次数据追加,只能存储4M的数据。

DLedgerMmapFileStore#appendAsLeader

synchronized (memberState) {

PreConditions.check(memberState.isLeader(), DLedgerResponseCode.NOT_LEADER, null);

// … 省略代码

}

Step4:锁定状态机,并再一次检测节点的状态是否是 Leader 节点。

DLedgerMmapFileStore#appendAsLeader

long nextIndex = ledgerEndIndex + 1;

entry.setIndex(nextIndex);

entry.setTerm(memberState.currTerm());

entry.setMagic(CURRENT_MAGIC);

DLedgerEntryCoder.setIndexTerm(dataBuffer, nextIndex, memberState.currTerm(), CURRENT_MAGIC);

Step5:为当前日志条目设置序号,即 entryIndex 与 entryTerm (投票轮次)。并将魔数、entryIndex、entryTerm 等写入到 bytebuffer 中。

DLedgerMmapFileStore#appendAsLeader

long prePos = dataFileList.preAppend(dataBuffer.remaining());

entry.setPos(prePos);

PreConditions.check(prePos != -1, DLedgerResponseCode.DISK_ERROR, null);

DLedgerEntryCoder.setPos(dataBuffer, prePos);

Step6:计算新的消息的起始偏移量,关于 dataFileList 的 preAppend 后续详细介绍其实现,然后将该偏移量写入日志的 bytebuffer 中。

DLedgerMmapFileStore#appendAsLeader

for (AppendHook writeHook : appendHooks) {

writeHook.doHook(entry, dataBuffer.slice(), DLedgerEntry.BODY_OFFSET);

}

Step7:执行钩子函数。

DLedgerMmapFileStore#appendAsLeader

long dataPos = dataFileList.append(dataBuffer.array(), 0, dataBuffer.remaining());

PreConditions.check(dataPos != -1, DLedgerResponseCode.DISK_ERROR, null);

PreConditions.check(dataPos == prePos, DLedgerResponseCode.DISK_ERROR, null);

Step8:将数据追加到 pagecache 中。该方法稍后详细介绍。

DLedgerMmapFileStore#appendAsLeader

DLedgerEntryCoder.encodeIndex(dataPos, entrySize, CURRENT_MAGIC, nextIndex, memberState.currTerm(), indexBuffer);

long indexPos = indexFileList.append(indexBuffer.array(), 0, indexBuffer.remaining(), false);

PreConditions.check(indexPos == entry.getIndex() * INDEX_UNIT_SIZE, DLedgerResponseCode.DISK_ERROR, null);

Step9:构建条目索引并将索引数据追加到 pagecache。

DLedgerMmapFileStore#appendAsLeader

ledgerEndIndex++;

ledgerEndTerm = memberState.currTerm();

if (ledgerBeginIndex == -1) {

ledgerBeginIndex = ledgerEndIndex;

}

updateLedgerEndIndexAndTerm();

Step10:ledgerEndeIndex 加一(下一个条目)的序号。并设置 leader 节点的状态机的 ledgerEndIndex 与 ledgerEndTerm。

Leader 节点数据追加就介绍到这里,稍后会重点介绍与存储相关方法的实现细节。

1.3 主节点等待从节点复制 ACK

其实现入口为 dLedgerEntryPusher 的 waitAck 方法。

DLedgerEntryPusher#waitAck

public CompletableFuture waitAck(DLedgerEntry entry) {

updatePeerWaterMark(entry.getTerm(), memberState.getSelfId(), entry.getIndex()); // @1

if (memberState.getPeerMap().size() == 1) { // @2

AppendEntryResponse response = new AppendEntryResponse();

response.setGroup(memberState.getGroup());

response.setLeaderId(memberState.getSelfId());

response.setIndex(entry.getIndex());

response.setTerm(entry.getTerm());

response.setPos(entry.getPos());

return AppendFuture.newCompletedFuture(entry.getPos(), response);

} else {

checkTermForPendingMap(entry.getTerm(), “waitAck”);

AppendFuture future = new AppendFuture<>(dLedgerConfig.getMaxWaitAckTimeMs()); // @3

future.setPos(entry.getPos());

CompletableFuture old = pendingAppendResponsesByTerm.get(entry.getTerm()).put(entry.getIndex(), future); // @4

if (old != null) {

logger.warn(“[MONITOR] get old wait at index={}”, entry.getIndex());

}

wakeUpDispatchers(); // @5

return future;

}

}

代码@1:更新当前节点的 push 水位线。

代码@2:如果集群的节点个数为1,无需转发,直接返回成功结果。

代码@3:构建 append 响应 Future 并设置超时时间,默认值为:2500 ms,可以通过 maxWaitAckTimeMs 配置改变其默认值。

代码@4:将构建的 Future 放入等待结果集合中。

代码@5:唤醒 Entry 转发线程,即将主节点中的数据 push 到各个从节点。

接下来分别对上述几个关键点进行解读。

1.3.1 updatePeerWaterMark 方法

DLedgerEntryPusher#updatePeerWaterMark

private void updatePeerWaterMark(long term, String peerId, long index) { // 代码@1

synchronized (peerWaterMarksByTerm) {

checkTermForWaterMark(term, “updatePeerWaterMark”); // 代码@2

if (peerWaterMarksByTerm.get(term).get(peerId) < index) { // 代码@3

peerWaterMarksByTerm.get(term).put(peerId, index);

}

}

}

代码@1:先来简单介绍该方法的两个参数:

  • long term

当前的投票轮次。

  • String peerId

当前节点的ID。

  • long index

当前追加数据的序号。

代码@2:初始化 peerWaterMarksByTerm 数据结构,其结果为 < Long /** term */, Map< String /** peerId */, Long /** entry index*/>。

代码@3:如果 peerWaterMarksByTerm 存储的 index 小于当前数据的 index,则更新。

1.3.2 wakeUpDispatchers 详解

DLedgerEntryPusher#updatePeerWaterMark

public void wakeUpDispatchers() {

for (EntryDispatcher dispatcher : dispatcherMap.values()) {

dispatcher.wakeup();

}

}

该方法主要就是遍历转发器并唤醒。本方法的核心关键就是 EntryDispatcher,在详细介绍它之前我们先来看一下该集合的初始化。

DLedgerEntryPusher 构造方法

for (String peer : memberState.getPeerMap().keySet()) {

if (!peer.equals(memberState.getSelfId())) {

dispatcherMap.put(peer, new EntryDispatcher(peer, logger));

}

}

原来在构建 DLedgerEntryPusher 时会为每一个从节点创建一个 EntryDispatcher 对象。

显然,日志的复制由 DLedgerEntryPusher 来实现。由于篇幅的原因,该部分内容将在下篇文章中继续。

上面在讲解 Leader 追加日志时并没有详细分析存储相关的实现,为了知识体系的完备,接下来我们来分析一下其核心实现。

2、日志存储实现详情


本节主要对 MmapFileList 的 preAppend 与 append 方法进行详细讲解。

存储部分的设计请查阅笔者的博客:源码分析 RocketMQ DLedger 多副本存储实现,MmapFileList 对标 RocketMQ 的MappedFileQueue。

2.1 MmapFileList 的 preAppend 详解

该方法最终会调用两个参数的 preAppend方法,故我们直接来看两个参数的 preAppend 方法。

MmapFileList#preAppend

public long preAppend(int len, boolean useBlank) { // @1

MmapFile mappedFile = getLastMappedFile(); // @2 start

if (null == mappedFile || mappedFile.isFull()) {

mappedFile = getLastMappedFile(0);

}

if (null == mappedFile) {

logger.error(“Create mapped file for {}”, storePath);

return -1;

} // @2 end

int blank = useBlank ? MIN_BLANK_LEN : 0;

if (len + blank > mappedFile.getFileSize() - mappedFile.getWrotePosition()) { // @3

if (blank < MIN_BLANK_LEN) {

logger.error(“Blank {} should ge {}”, blank, MIN_BLANK_LEN);

return -1;

} else {

ByteBuffer byteBuffer = ByteBuffer.allocate(mappedFile.getFileSize() - mappedFile.getWrotePosition()); // @4

byteBuffer.putInt(BLANK_MAGIC_CODE); // @5

byteBuffer.putInt(mappedFile.getFileSize() - mappedFile.getWrotePosition()); // @6

if (mappedFile.appendMessage(byteBuffer.array())) { // @7

//need to set the wrote position

mappedFile.setWrotePosition(mappedFile.getFileSize());

} else {

logger.error(“Append blank error for {}”, storePath);

return -1;

}

mappedFile = getLastMappedFile(0);

if (null == mappedFile) {

logger.error(“Create mapped file for {}”, storePath);

return -1;

}

}

}

return mappedFile.getFileFromOffset() + mappedFile.getWrotePosition();// @8

}

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

img

写在最后

学习技术是一条慢长而艰苦的道路,不能靠一时激情,也不是熬几天几夜就能学好的,必须养成平时努力学习的习惯。所以:贵在坚持!

最后再分享的一些BATJ等大厂20、21年的面试题,把这些技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节,由于篇幅有限,上面只是以图片的形式给大家展示一部分。

蚂蚁金服三面直击面试官的Redis三连,Redis面试复习大纲在手,不慌

Mybatis面试专题

蚂蚁金服三面直击面试官的Redis三连,Redis面试复习大纲在手,不慌

MySQL面试专题

蚂蚁金服三面直击面试官的Redis三连,Redis面试复习大纲在手,不慌

并发编程面试专题

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!
!**

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

img

写在最后

学习技术是一条慢长而艰苦的道路,不能靠一时激情,也不是熬几天几夜就能学好的,必须养成平时努力学习的习惯。所以:贵在坚持!

最后再分享的一些BATJ等大厂20、21年的面试题,把这些技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节,由于篇幅有限,上面只是以图片的形式给大家展示一部分。

[外链图片转存中…(img-IsGqu6T8-1713750672287)]

Mybatis面试专题

[外链图片转存中…(img-Ug28TGBZ-1713750672287)]

MySQL面试专题

[外链图片转存中…(img-oxyxZeCI-1713750672287)]

并发编程面试专题

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值