HA 服务
rocketmq 的高可用机制分为两部分,(1)主从复制,(2)读写分离
主从复制避免了 broker 的单点故障,提供了消息消费的高可用。消息在主节点落盘后,还需要将消息复制到从节点,同步发送消息才算完成;如果主节点宕机,消费者还可以从从节点上拉取消息。
读写分离提高了主节点的可用性,让从节点也参与了消息拉取负载。
主从复制
rocketmq的 HA 服务在org.apache.rocketmq.store.ha.HAService
中实现。
HAService的核心属性如下:
List<HAConnection> connectionList = new LinkedList<>(); //master 和从节点的连接,其中包含了对从节点的读写操作
AcceptSocketService acceptSocketService; //监听客户端的实现类,master 节点需要
DefaultMessageStore defaultMessageStore; //当前broker 的消息存储服务实现类,commitLog 就在其中
WaitNotifyObject waitNotifyObject = new WaitNotifyObject(); //等待通知的对象
AtomicLong push2SlaveMaxOffset = new AtomicLong(0); //从节点最大复制偏移量
GroupTransferService groupTransferService; //负责主从同步复制的通知实现
HAClient haClient; //从节点接收复制数据的实现
HAConnection封装了对从节点的读写操作,主要有以下两部分:
- HAConnection.ReadSocketService,负责维护从节点反馈的复制完成的偏移量,并且通知给发送者线程。
- HAConnection.WriteSocketService,复制将 commitLog 中的消息写入到从节点的socket 中。
这两个操作都是独立的线程,不停的轮询。所以即使复制完成了,发送者线程也不知道。
通知发送者
这时候就需要 GroupTransferService 出场了,它也是个独立的线程,它的核心属性如下:
private final WaitNotifyObject notifyTransferObject = new WaitNotifyObject(); //需要通知的对象
private volatile List<CommitLog.GroupCommitRequest> requestsWrite = new ArrayList<>(); //刚刚写入的请求
private volatile List<CommitLog.GroupCommitRequest> requestsRead = new ArrayList<>(); //等待通知的请求
这里可能有点迷糊,先看看其核心方法 GroupTransferService#doWaitTransfer
:
private void doWaitTransfer() {
synchronized (this.requestsRead) {
if (!this.requestsRead.isEmpty()) {
for (CommitLog.GroupCommitRequest req : this.requestsRead) {
//主从复制的偏移量计算
boolean transferOK = HAService.this.push2SlaveMaxOffset.get() >= req.getNextOffset();
long waitUntilWhen = HAService.this.defaultMessageStore.getSystemClock().now()
+ HAService.this.defaultMessageStore.getMessageStoreConfig().getSyncFlushTimeout();
while (!transferOK && HAService.this.defaultMessageStore.getSystemClock().now() < waitUntilWhen) {
//如果没复制完成, 就让通知对象所在线程继续等待100ms
this.notifyTransferObject.waitForRunning(1000);
transferOK = HAService.this.push2SlaveMaxOffset.get() >= req.getNextOffset();
}
//在指定的时间内还是没复制完成
if (!transferOK) {
log.warn("transfer messsage to slave timeout, " + req.getNextOffset());
}
// 设置复制结果
req.wakeupCustomer(transferOK ? PutMessageStatus.PUT_OK : PutMessageStatus.FLUSH_SLAVE_TIMEOUT);
}
//清空等待通知的请求
this.requestsRead.clear();
}
}
}
上面这段代码不难理解,获取从节点的最大复制偏移量,nextOffSet = wroteOffset + wroteBytes ,当push2SlaveMaxOffset >=nextOffSet 说明当前请求的主从复制已完成,那么设置结果就可以了。
为什么要清空 requestsRead 呢。因为要和requestsWrite 进行交换,为什么要交换,因为使用requestsRead 只是在当前线程,而写入请求是其他一组线程,那么只维护一个请求队列的情况下,势必会影响通知线程的执行效率,读写会冲突嘛。那么改为两个呢,写入是requestsWrite队列,读取在requestsRead,每循环一圈,交换requestsWrite 和 requestsRead,读写就可以同时运行了。想想是不是觉得思路开阔很多呢。
复制数据
上面讲到 HAConnection 是对读写服务的
复制消息到从节点也是独立的线程,那么最重要的就是线程的 run 方法了。下面看看 WriteSocketService 的 run 方法。
this.selector.select(1000);
if (-1 == HAConnection.this.slaveRequestOffset) {
Thread.sleep(10);
continue;
}
- 阻塞在 selector 上,每一秒循环执行一次,如果还未收到 从节点的拉取请求,则放弃本次处理
if (-1 == this.nextTransferFromWhere) {
if (0 == HAConnection.this.slaveRequestOffset) {
long masterOffset = HAConnection.this.haService.getDefaultMessageStore().getCommitLog().getMaxOffset();
masterOffset =
masterOffset
- (masterOffset % HAConnection.this.haService.getDefaultMessageStore().getMessageStoreConfig().getMappedFileSizeCommitLog());
if (masterOffset < 0) {
masterOffset = 0;
}
this.nextTransferFromWhere = masterOffset;
} else {
this.nextTransferFromWhere = HAConnection.this.slaveRequestOffset;
}
}
- nextTransferFromWhere 等于-1 表示初次复制数据;slaveRequestOffset 等于 0 表示 要从 master 节点的最大 offset 开始复制,接下来就是计算最大的offset,然后按照指定的offset开始传输数据。
if (this.lastWriteOver) {
long interval =
HAConnection.this.haService.getDefaultMessageStore().getSystemClock().now() - this.lastWriteTimestamp;
if (interval > HAConnection.this.haService.getDefaultMessageStore().getMessageStoreConfig()
.getHaSendHeartbeatInterval()) {
// Build Header
this.byteBufferHeader.position(0);
this.byteBufferHeader.limit(headerSize);
this.byteBufferHeader.putLong(this.nextTransferFromWhere);
this.byteBufferHeader.putInt(0);
this.byteBufferHeader.flip();
this.lastWriteOver = this.transferData();
if (!this.lastWriteOver)
continue;
}
} else {
this.lastWriteOver = this.transferData();
if (!this.lastWriteOver)
continue;
}
- lastWriteOver 表示上个事件是否已经写完,默认值为 true。如果当前心跳间隔已经超时了,发送一个心跳包。如果上个事件的数据没写完则继续先写上个事件的数据
SelectMappedBufferResult selectResult =
HAConnection.this.haService.getDefaultMessageStore().getCommitLogData(this.nextTransferFromWhere);
if (selectResult != null) {
int size = selectResult.getSize();
if (size > HAConnection.this.haService.getDefaultMessageStore().getMessageStoreConfig().getHaTransferBatchSize()) {
size = HAConnection.this.haService.getDefaultMessageStore().getMessageStoreConfig().getHaTransferBatchSize();
}
long thisOffset = this.nextTransferFromWhere;
this.nextTransferFromWhere += size;
selectResult.getByteBuffer().limit(size);
this.selectMappedBufferResult = selectResult;
// Build Header
this.byteBufferHeader.position(0);
this.byteBufferHeader.limit(headerSize);
this.byteBufferHeader.putLong(thisOffset);
this.byteBufferHeader.putInt(size);
this.byteBufferHeader.flip();
this.lastWriteOver = this.transferData();
} else {
HAConnection.this.haService.getWaitNotifyObject().allWaitForRunning(100);
}
- 根据下次传输的偏移量,去 commitLog 中查找是否还有未写的数据。(1)如果没有则通知所有等待线程继续等待 100ms;(2)如果有数据,先确认是否超过一次传输数据的限制,如果超过则设置为最大传输限制大小 32K。所以从节点可能会收到不完整的消息。
数据发送出去后,从节点接收是怎么处理的呢?
从节点接收数据
HAClient
也是一个 NIO 线程,不断的轮询处理读取到的数据,所以继续从 run 方法开始吧
while (!this.isStopped()) {
try {
if (this.connectMaster()) {
if (this.isTimeToReportOffset()) {
//向 master 报告 从节点的 写入偏移量
boolean result = this.reportSlaveMaxOffset(this.currentReportedOffset);
if (!result) {
this.closeMaster();
}
}
this.selector.select(1000);
//处理写入事件
boolean ok = this.processReadEvent();
if (!ok) {
this.closeMaster();
}
if (!reportSlaveMaxOffsetPlus()) {
continue;
}
long interval =
HAService.this.getDefaultMessageStore().getSystemClock().now()
- this.lastWriteTimestamp;
if (interval > HAService.this.getDefaultMessageStore().getMessageStoreConfig()
.getHaHousekeepingInterval()) {
this.closeMaster();
}
} else {
this.waitForRunning(1000 * 5);
}
} catch (Exception e) {
log.warn(this.getServiceName() + " service has exception. ", e);
this.waitForRunning(1000 * 5);
}
}
- 如果当前和 master 节点是连接是正常的,初次启动时会连接到 master 节点。否则等待 5s 后再运行。
- 如果到了报告当前节点复制偏移量的时候,就发送复制偏移量给 master 节点。
- 每秒处理一次事件,开始处理读取数据事件。
- 如果处理读入数据失败,就关闭和 master 节点的socket连接。同时会保存当前节点已经写入的偏移量,因为读入了数据不一定代表处理完了。
- 再次尝试报告当前节点的复制偏移量,如果报告失败则忽略下面的步骤
- 如果报告成功了,那么检查是否已经超过心跳间隔了,是的话则关闭和 master 的 socket 连接。
注意这里面的异常处理,任何异常都不能放过。
实际的读数据处理是在dispatchReadRequest私有方法里面,
private boolean dispatchReadRequest() {
final int msgHeaderSize = 8 + 4; // phyoffset + size
int readSocketPos = this.byteBufferRead.position();
while (true) {
int diff = this.byteBufferRead.position() - this.dispatchPosition;
if (diff >= msgHeaderSize) {
long masterPhyOffset = this.byteBufferRead.getLong(this.dispatchPosition);
int bodySize = this.byteBufferRead.getInt(this.dispatchPosition + 8);
long slavePhyOffset = HAService.this.defaultMessageStore.getMaxPhyOffset();
//确保 slave 的内容和 master 没有偏差,错误可能出现在主从切换的时候
if (slavePhyOffset != 0) {
if (slavePhyOffset != masterPhyOffset) {
log.error("master pushed offset not equal the max phy offset in slave, SLAVE: "
+ slavePhyOffset + " MASTER: " + masterPhyOffset);
return false;
}
}
if (diff >= (msgHeaderSize + bodySize)) {
byte[] bodyData = new byte[bodySize];
this.byteBufferRead.position(this.dispatchPosition + msgHeaderSize);
this.byteBufferRead.get(bodyData);
//将消息内容追加到自己的 commitLog 中,并且需要维护当前复制的位置
HAService.this.defaultMessageStore.appendToCommitLog(masterPhyOffset, bodyData);
this.byteBufferRead.position(readSocketPos);
this.dispatchPosition += msgHeaderSize + bodySize;
// 反馈拉取进度给master
if (!reportSlaveMaxOffsetPlus()) {
return false;
}
continue;
}
}
if (!this.byteBufferRead.hasRemaining()) {
this.reallocateByteBuffer();
}
break;
}
return true;
}
总结
HA 的机制相对来说比较简单,Master 刷盘,复制数据到 slave 节点,等待复制完成通知发送者,读取 slave 的刷盘进度等,都是独立的线程。这是个并行的过程。通过读取当前同步到从节点的最大偏移量可以知道哪些消息是真正发送成功,可以通知发送者了。从节点只需要不停的读取数据,然后记录同步的偏移量,向 master反馈偏移量。读写分离我们留到分析消费者时再处理。
但是还有一些问题没有解决,例如主节点在写入消息成功后宕机,此时消息还没有发送给从节点,执行了主从切换,那这条消息要怎么处理?主从切换是怎么通知的其他节点的?这些细节有机会在补充