Raft 代码分析

核心RPC服务分类

在 Raft 协议中,RPC(Remote Procedure Call) 是一种远程过程调用机制,用于不同节点之间进行通信。

业务应用RPC服务

ExampleService 是一个 RPC 服务接口,它定义了两个操作:set 和 get,分别用于数据的写入和读取。这类接口通常用于定义服务端提供的业务功能,并通过 RPC 技术实现客户端与服务端的通信。

接口定义:

java 复制代码
public interface ExampleService {
    ExampleProto.SetResponse set(ExampleProto.SetRequest request);
    ExampleProto.GetResponse get(ExampleProto.GetRequest request);
}

set(SetRequest):数据写入操作,用于将某些数据存储到服务器中。

get(GetRequest):数据读取操作,用于从服务器获取数据。

Raft节点间通信RPC服务

在 Raft 协议中,节点之间的通信通常是通过 RPC(远程过程调用)来实现的。这些 RPC 服务用于处理选举、日志复制、快照同步等关键操作。

java 复制代码
public interface RaftConsensusService {
    RaftProto.VoteResponse preVote(RaftProto.VoteRequest request);
    RaftProto.VoteResponse requestVote(RaftProto.VoteRequest request);
    RaftProto.AppendEntriesResponse appendEntries(RaftProto.AppendEntriesRequest request);
    RaftProto.InstallSnapshotResponse installSnapshot(RaftProto.InstallSnapshotRequest request);
}

preVote(VoteRequest request)

作用:preVote 方法是 Raft 协议中的 预投票 RPC 。它用于候选者在开始正式选举之前向其他节点请求投票。预投票的目的是减少因网络延迟或部分节点故障而导致的选举冲突。候选者会在收到预投票时验证是否可以获得投票。

响应:VoteResponse 表示预投票的结果。

requestVote(VoteRequest request)

作用:requestVote 方法是 Raft 协议中的 正式投票 RPC 。候选者在选举过程中会向其他节点请求投票。如果节点尚未投票且候选者的日志比较新(符合 Raft 协议的条件),该节点会返回投票给候选者。

响应:VoteResponse 表示正式投票的结果。

appendEntries(AppendEntriesRequest request)

作用:appendEntries 方法是 Raft 协议中的 日志复制和心跳 RPC 。当选举完成后,领导者需要向其他节点复制日志条目以保证日志一致性。即使没有日志复制的任务,领导者也会定期向所有跟随者发送心跳信号(空的 AppendEntriesRequest)来维持领导者的地位。

响应:AppendEntriesResponse 表示日志复制或心跳的结果。

installSnapshot(InstallSnapshotRequest request)

作用:installSnapshot 方法是 Raft 协议中的 快照安装 RPC 。由于 Raft 协议要求保持节点日志的一致性,节点的日志会不断增长,导致存储开销增大。为了减少日志的大小,Raft 支持通过快照来压缩日志。快照是节点状态的完整副本,通过此 RPC 安装快照。

响应:InstallSnapshotResponse 表示快照安装的结果。

客户端管理RPC服务

RaftClientService 接口定义了与 Raft 集群通信的客户端管理 RPC 服务。这个接口的主要功能是获取集群的领导节点信息、获取集群的配置、以及管理集群节点(添加或删除节点)

java 复制代码
public interface RaftClientService {
    RaftProto.GetLeaderResponse getLeader(RaftProto.GetLeaderRequest request);
    RaftProto.GetConfigurationResponse getConfiguration(RaftProto.GetConfigurationRequest request);
    RaftProto.AddPeersResponse addPeers(RaftProto.AddPeersRequest request);
    RaftProto.RemovePeersResponse removePeers(RaftProto.RemovePeersRequest request);
}

getLeader(GetLeaderRequest request)

作用:该方法用于获取当前 Raft 集群的领导节点(Leader)信息。通常在集群中,只有 Leader 节点可以接受客户端的请求,并进行数据的写入。

请求:GetLeaderRequest 可以是一个简单的请求类型,不需要太多的参数。

响应:GetLeaderResponse 返回 Leader 节点的相关信息(如 Leader 的地址、ID )

getConfiguration(GetConfigurationRequest request)

作用:该方法用于获取当前 Raft 集群的配置信息,包括集群中所有节点的状态(哪些是 Leader、哪些是 Follower、哪些是 Candidate 等)。它还可以返回每个节点的地址以及它们在集群中的角色。

请求:GetConfigurationRequest 是一个获取配置信息的请求。

响应:GetConfigurationResponse 返回集群中各个节点的信息。

addPeers(AddPeersRequest request)

作用:该方法用于向 Raft 集群中添加一个新的节点。这个请求通常由 Leader 节点发起,因为 Leader 负责管理集群成员的变动。

请求:AddPeersRequest 包含要添加的节点的信息(比如节点的地址、角色等)

响应:AddPeersResponse 表示添加操作是否成功。

removePeers(RemovePeersRequest request)

作用:该方法用于从 Raft 集群中删除一个节点。如果节点失效或者需要进行集群扩容/缩容,可以使用此方法删除节点。

请求:RemovePeersRequest 包含要删除的节点的信息。

响应:RemovePeersResponse 表示删除操作是否成功。

客户端写操作RPC调用链

客户端发起写请求

入口: ClientMain.java → exampleService.set(setRequest)

ClientMain 是客户端的主入口类,负责从命令行接收参数、初始化 RPC 客户端、发起请求并打印响应结果。

ClientMain代码如下:

java 复制代码
public class ClientMain {
    public static void main(String[] args) {
        if (args.length < 2) {
            System.out.printf("Usage: ./run_client.sh CLUSTER KEY [VALUE]\n");
            System.exit(-1);
        }

        // parse args
        String ipPorts = args[0];
        String key = args[1];
        String value = null;
        if (args.length > 2) {
            value = args[2];
        }

        // init rpc client
        RpcClient rpcClient = new RpcClient(ipPorts);
        ExampleService exampleService = BrpcProxy.getProxy(rpcClient, ExampleService.class);
        final JsonFormat jsonFormat = new JsonFormat();

        // set
        if (value != null) {
            ExampleProto.SetRequest setRequest = ExampleProto.SetRequest.newBuilder()
                    .setKey(key).setValue(value).build();
            ExampleProto.SetResponse setResponse = exampleService.set(setRequest);
            System.out.printf("set request, key=%s value=%s response=%s\n",
                    key, value, jsonFormat.printToString(setResponse));
        } else {
            // get
            ExampleProto.GetRequest getRequest = ExampleProto.GetRequest.newBuilder()
                    .setKey(key).build();
            ExampleProto.GetResponse getResponse = exampleService.get(getRequest);
            System.out.printf("get request, key=%s, response=%s\n",
                    key, jsonFormat.printToString(getResponse));
        }

        rpcClient.stop();
    }
}

下面分析ClientMain代码

客户端启动过程
命令行参数解析:

客户端从命令行接收集群信息(ipPorts)、键(key)和值(value)作为参数。

如果没有提供值(value),客户端会执行一个 get 操作,否则执行 set 操作。

java 复制代码
String ipPorts = args[0];   // 集群的 IP 和端口
String key = args[1];       // 请求的键
String value = null;
if (args.length > 2) {
    value = args[2];        // 请求的值
}

初始化 RPC 客户端:

创建一个 RpcClient 实例并指定服务器集群的 IP 和端口(ipPorts)

使用 BrpcProxy.getProxy 来生成代理对象 exampleService,它会向 ExampleService 接口发送 RPC 请求。

java 复制代码
RpcClient rpcClient = new RpcClient(ipPorts);
ExampleService exampleService = BrpcProxy.getProxy(rpcClient, ExampleService.class);

构建请求:

如果有 value 参数,构造一个 SetRequest 请求,发送 set 请求给服务器。

如果没有 value,构造一个 GetRequest 请求,发送 get 请求。

java 复制代码
ExampleProto.SetRequest setRequest = ExampleProto.SetRequest.newBuilder()
        .setKey(key).setValue(value).build();

发送请求并打印响应:

调用 exampleService.set(setRequest),发起 RPC 请求。

打印响应信息。

java 复制代码
ExampleProto.SetResponse setResponse = exampleService.set(setRequest);
System.out.printf("set request, key=%s value=%s response=%s\n",
        key, value, jsonFormat.printToString(setResponse));

停止客户端:

在操作完成后,停止 RPC 客户端。

java 复制代码
rpcClient.stop();
业务服务层处理

ExampleServiceImpl.java → set()方法

ExampleServiceImpl 类中的 set 方法负责处理客户端发起的写请求。该方法根据当前节点的角色(是否为领导者)来决定如何处理请求,并与 Raft 集群中的其他节点进行交互。

set方法代码如下:

java 复制代码
@Override
public ExampleProto.SetResponse set(ExampleProto.SetRequest request) {
    ExampleProto.SetResponse.Builder responseBuilder = ExampleProto.SetResponse.newBuilder();

    // 如果自己不是leader,将写请求转发给leader
    if (raftNode.getLeaderId() <= 0) {
        responseBuilder.setSuccess(false);  // 没有领导者
    } else if (raftNode.getLeaderId() != raftNode.getLocalServer().getServerId()) {
        // 当前节点不是领导者,转发请求给领导者
        onLeaderChangeEvent();
        ExampleService exampleService = BrpcProxy.getProxy(leaderRpcClient, ExampleService.class);
        ExampleProto.SetResponse responseFromLeader = exampleService.set(request);
        responseBuilder.mergeFrom(responseFromLeader);  // 合并领导者的响应
    } else {
        // 当前节点是领导者,执行数据同步
        byte[] data = request.toByteArray();  // 将请求数据序列化
        boolean success = raftNode.replicate(data, RaftProto.EntryType.ENTRY_TYPE_DATA);  // 将数据同步到集群
        responseBuilder.setSuccess(success);  // 设置成功标志
    }

    // 构建响应并返回
    ExampleProto.SetResponse response = responseBuilder.build();
    LOG.info("set request, request={}, response={}", jsonFormat.printToString(request),
            jsonFormat.printToString(response));
    return response;
}

下面分析 set 方法

  1. 判断是否是领导者节点

首先,方法判断当前节点是否为 Raft 集群的领导者:

java 复制代码
if (raftNode.getLeaderId() <= 0) {
    responseBuilder.setSuccess(false);  // 没有领导者
}

如果 raftNode.getLeaderId() 小于等于 0,表示当前没有有效的领导者节点,方法返回失败。

  1. 转发请求给领导者

如果当前节点不是领导者,方法将请求转发给 Raft 集群中的领导者进行处理:

java 复制代码
else if (raftNode.getLeaderId() != raftNode.getLocalServer().getServerId()) {
    onLeaderChangeEvent();  // 通知领导者发生变化
    ExampleService exampleService = BrpcProxy.getProxy(leaderRpcClient, ExampleService.class);
    ExampleProto.SetResponse responseFromLeader = exampleService.set(request);
    responseBuilder.mergeFrom(responseFromLeader);  // 合并领导者的响应
}

raftNode.getLeaderId() 获取当前领导者的 ID,raftNode.getLocalServer().getServerId() 获取当前节点的 ID。

如果当前节点不是领导者,调用 onLeaderChangeEvent() 方法来处理领导者变更事件(更新领导者信息)

使用 BRPC 框架代理 ExampleService,向领导者节点发送 set 请求,并将领导者的响应合并到当前响应中。

  1. 领导者节点处理数据同步

如果当前节点是领导者,方法将数据同步到 Raft 集群中的所有节点:

java 复制代码
else {
    byte[] data = request.toByteArray();  // 将请求数据序列化
    boolean success = raftNode.replicate(data, RaftProto.EntryType.ENTRY_TYPE_DATA);  // 将数据同步到集群
    responseBuilder.setSuccess(success);  // 设置成功标志
}

将请求数据序列化为字节数组,调用 raftNode.replicate() 方法将数据同步到 Raft 集群。

RaftProto.EntryType.ENTRY_TYPE_DATA 表示这是一条普通的数据写入操作。

如果同步成功,返回成功状态。

  1. 返回响应

无论是哪种情况,方法最后都构建并返回一个 SetResponse 响应:

java 复制代码
ExampleProto.SetResponse response = responseBuilder.build();
LOG.info("set request, request={}, response={}", jsonFormat.printToString(request),
        jsonFormat.printToString(response));
return response;

responseBuilder.build() 用于生成最终的 SetResponse 对象。

通过 LOG.info 打印请求和响应的日志信息,以便调试和记录。

ExampleProto.SetResponse.Builder 与 ExampleProto.SetResponse 的区别

在 Protobuf 中,SetResponse 是一个不可变的消息对象,它的实例不能直接修改。为了能够构建和修改 SetResponse 对象,Protobuf 提供了一个构建器(Builder)类。
ExampleProto.SetResponse

SetResponse 是最终的、不可变的响应对象,表示 Protobuf 消息的完整实例。

一旦创建,SetResponse 的字段值就不能再改变。

它是通过 Builder 类构建出来的,通常用来在处理完请求后,返回给调用者的响应结果。

ExampleProto.SetResponse.Builder

Builder 是 SetResponse 类的内部构建器类,用于动态地构建 SetResponse 实例。

使用 Builder 类可以设置 SetResponse 中的字段(例如 setSuccess())

Builder 允许修改和设置字段值,直到完成所有的设置后,通过 build() 方法将其转换为一个不可变的 SetResponse 实例。

Raft核心复制逻辑

RaftNode.java → replicate()方法

replicate() 方法是 Raft 协议中关键的日志复制逻辑部分,它在领导者节点上执行,负责将新的日志条目复制到集群中的其他节点。

方法接收客户端的数据,在领导者节点上生成日志条目,触发向集群内其他节点的日志复制,并等待多数节点确认后返回成功。

replicate 方法代码如下:

java 复制代码
public boolean replicate(byte[] data, RaftProto.EntryType entryType) {
    lock.lock();
    long newLastLogIndex = 0;
    try {
        if (state != NodeState.STATE_LEADER) {
            LOG.debug("I'm not the leader");
            return false;
        }

        RaftProto.LogEntry logEntry = RaftProto.LogEntry.newBuilder()
                .setTerm(currentTerm)
                .setType(entryType)
                .setData(ByteString.copyFrom(data))
                .build();

        List<RaftProto.LogEntry> entries = new ArrayList<>();
        entries.add(logEntry);
        newLastLogIndex = raftLog.append(entries);
        // raftLog.updateMetaData(currentTerm, null, raftLog.getFirstLogIndex());

        for (RaftProto.Server server : configuration.getServersList()) {
            final Peer peer = peerMap.get(server.getServerId());
            executorService.submit(() -> appendEntries(peer));
        }

        if (raftOptions.isAsyncWrite()) {
            // 主节点写成功后,就返回。
            return true;
        }

        // sync wait commitIndex >= newLastLogIndex
        long startTime = System.currentTimeMillis();
        while (lastAppliedIndex < newLastLogIndex) {
            if (System.currentTimeMillis() - startTime >= raftOptions.getMaxAwaitTimeout()) {
                break;
            }
            commitIndexCondition.await(raftOptions.getMaxAwaitTimeout(), TimeUnit.MILLISECONDS);
        }
    } catch (Exception ex) {
        ex.printStackTrace();
    } finally {
        lock.unlock();
    }

    LOG.debug("lastAppliedIndex={} newLastLogIndex={}", lastAppliedIndex, newLastLogIndex);
    if (lastAppliedIndex < newLastLogIndex) {
        return false;
    }
    return true;
}

下面分析 replicate 方法

replicate() 方法的主要功能是:

将客户端的写请求作为新的日志条目添加到 Raft 日志中。

将日志条目异步地复制到集群中的其他节点。

如果 isAsyncWrite() 配置为 false,则同步等待日志复制到大多数节点并且日志提交。

java 复制代码
public boolean replicate(byte[] data, RaftProto.EntryType entryType) {
    lock.lock();  // 获取锁,确保线程安全
    long newLastLogIndex = 0;
    try {
        // 确保当前节点是领导者
        if (state != NodeState.STATE_LEADER) {
            LOG.debug("I'm not the leader");
            return false;
        }

lock.lock():方法首先获取一个锁,确保在执行日志复制时不会有其他线程干扰。

state != NodeState.STATE_LEADER:如果当前节点不是领导者,直接返回 false,表示日志复制失败。

java 复制代码
RaftProto.LogEntry logEntry = RaftProto.LogEntry.newBuilder()
                    .setTerm(currentTerm)
                    .setType(entryType)
                    .setData(ByteString.copyFrom(data)).build();

创建日志条目 :根据传入的 data 和 entryType 构建一个新的日志条目(LogEntry),并将其设置为当前任期(currentTerm)和指定的条目类型(entryType)。

java 复制代码
List<RaftProto.LogEntry> entries = new ArrayList<>();
            entries.add(logEntry);
            newLastLogIndex = raftLog.append(entries);

日志条目追加到 Raft 日志中 :将创建的 logEntry 加入到 Raft 日志,并返回新的日志索引(newLastLogIndex)

raftLog.append(entries):负责将日志条目写入日志存储中,并返回新添加条目的索引。

java 复制代码
for (RaftProto.Server server : configuration.getServersList()) {
    final Peer peer = peerMap.get(server.getServerId());
    executorService.submit(new Runnable() {
        @Override
        public void run() {
            appendEntries(peer);
        }
    });
}

日志复制到其他节点 :遍历 Raft 集群中的所有服务器,将日志条目异步地发送到每个节点。每个节点会通过 appendEntries(peer) 方法来处理日志复制。

executorService.submit():用于提交异步任务,确保日志条目的复制不阻塞当前线程。

java 复制代码
if (raftOptions.isAsyncWrite()) {
    // 主节点写成功后,就返回。
    return true;
}

异步写入 :如果配置为异步写入(isAsyncWrite()),replicate() 方法会立即返回 true,表示日志条目已经提交,客户端可以认为写入操作已经完成。

return true:即使日志没有完全复制到所有节点,领导者节点也会返回 true,表示写请求已经成功提交。

java 复制代码
// 同步等待日志复制到大多数节点
long startTime = System.currentTimeMillis();
while (lastAppliedIndex < newLastLogIndex) {
    if (System.currentTimeMillis() - startTime >= raftOptions.getMaxAwaitTimeout()) {
        break;  // 超时退出
    }
    commitIndexCondition.await(raftOptions.getMaxAwaitTimeout(), TimeUnit.MILLISECONDS);
}

同步等待 :如果不是异步写入,则进入同步等待阶段。通过 commitIndexCondition.await() 方法阻塞当前线程,直到 commitIndex 达到或超过 newLastLogIndex(即日志复制完成)

超时机制:如果在等待期间超时,循环会退出。

领导者将日志复制到多数节点后,更新 commitIndex 并广播。

跟随者收到广播后,将 commitIndex 之前的日志应用到状态机(更新 lastAppliedIndex)

客户端需要等待操作被提交后才能确认成功

java 复制代码
} catch (Exception ex) {
    ex.printStackTrace();  // 异常处理
} finally {
    lock.unlock();  // 释放锁
}

LOG.debug("lastAppliedIndex={} newLastLogIndex={}", lastAppliedIndex, newLastLogIndex);
if (lastAppliedIndex < newLastLogIndex) {
    return false;  // 如果日志没有成功复制到大多数节点,返回 false
}
return true;  // 日志复制成功,返回 true

日志复制状态检查 :如果 lastAppliedIndex 小于 newLastLogIndex,说明日志还没有成功复制到大多数节点,方法会返回 false。

最终返回值:如果日志复制成功,返回 true。

思考:为什么异步直接返回 true,同步不行

在异步写入模式下,领导者节点接收到客户端的写请求后,不会等待日志复制的完成或提交。它会立即返回 true,告诉客户端请求已经成功提交。异步写入的设计目的是提高性能,减少等待时间,允许客户端快速继续执行下一步操作,而不需要等到日志复制到所有节点。缺点是日志复制失败或超时的情况下,客户端并不知情。

在同步写入模式下,领导者节点在返回客户端之前会等待日志条目被复制到大多数节点,并且等待 commitIndex 达到或超过新日志条目的索引(newLastLogIndex)。这样可以保证客户端的写请求在日志复制完成后才会被认为是成功的。

异步写入:

目的是提高性能,降低响应延迟。

客户端在请求发送后不会等待日志复制完成,领导者会立即返回 true,表示请求已提交。

这种方式适合不需要强一致性的场景,比如需要高吞吐量且可以容忍数据丢失的系统。

同步写入:

目的是保证日志的持久性和一致性。

客户端在请求发送后会等待日志条目被复制到大多数节点并提交,确保写入操作被可靠地记录。

这种方式适合需要强一致性的场景,比如数据库或关键系统,确保数据在大多数节点之间一致。

日志复制RPC调用

RaftNode.java → appendEntries()方法

appendEntries() 方法是 Raft 协议中的日志复制核心部分,它用于将领导者节点的日志条目复制到跟随者节点,并处理日志同步和快照安装。

作为领导者,向指定跟随者发送日志复制请求(AppendEntriesRequest),并处理响应结果。

appendEntries 方法代码如下:

java 复制代码
public void appendEntries(Peer peer) {
    RaftProto.AppendEntriesRequest.Builder requestBuilder = RaftProto.AppendEntriesRequest.newBuilder();
    long prevLogIndex;
    long numEntries;

    boolean isNeedInstallSnapshot = false;
    lock.lock();
    try {
        long firstLogIndex = raftLog.getFirstLogIndex();
        if (peer.getNextIndex() < firstLogIndex) {
            isNeedInstallSnapshot = true;
        }
    } finally {
        lock.unlock();
    }

    LOG.debug("is need snapshot={}, peer={}", isNeedInstallSnapshot, peer.getServer().getServerId());
    if (isNeedInstallSnapshot) {
        if (!installSnapshot(peer)) {
            return;
        }
    }

    long lastSnapshotIndex;
    long lastSnapshotTerm;
    snapshot.getLock().lock();
    try {
        lastSnapshotIndex = snapshot.getMetaData().getLastIncludedIndex();
        lastSnapshotTerm = snapshot.getMetaData().getLastIncludedTerm();
    } finally {
        snapshot.getLock().unlock();
    }

    lock.lock();
    try {
        long firstLogIndex = raftLog.getFirstLogIndex();
        Validate.isTrue(peer.getNextIndex() >= firstLogIndex);
        prevLogIndex = peer.getNextIndex() - 1;
        long prevLogTerm;
        if (prevLogIndex == 0) {
            prevLogTerm = 0;
        } else if (prevLogIndex == lastSnapshotIndex) {
            prevLogTerm = lastSnapshotTerm;
        } else {
            prevLogTerm = raftLog.getEntryTerm(prevLogIndex);
        }
        requestBuilder.setServerId(localServer.getServerId());
        requestBuilder.setTerm(currentTerm);
        requestBuilder.setPrevLogTerm(prevLogTerm);
        requestBuilder.setPrevLogIndex(prevLogIndex);
        numEntries = packEntries(peer.getNextIndex(), requestBuilder);
        requestBuilder.setCommitIndex(Math.min(commitIndex, prevLogIndex + numEntries));
    } finally {
        lock.unlock();
    }

    RaftProto.AppendEntriesRequest request = requestBuilder.build();
    RaftProto.AppendEntriesResponse response = peer.getRaftConsensusServiceAsync().appendEntries(request);

    lock.lock();
    try {
        if (response == null) {
            LOG.warn("appendEntries with peer[{}:{}] failed",
                    peer.getServer().getEndpoint().getHost(),
                    peer.getServer().getEndpoint().getPort());
            if (!ConfigurationUtils.containsServer(configuration, peer.getServer().getServerId())) {
                peerMap.remove(peer.getServer().getServerId());
                peer.getRpcClient().stop();
            }
            return;
        }
        LOG.info("AppendEntries response[{}] from server {} " +
                        "in term {} (my term is {})",
                response.getResCode(), peer.getServer().getServerId(),
                response.getTerm(), currentTerm);

        if (response.getTerm() > currentTerm) {
            stepDown(response.getTerm());
        } else {
            if (response.getResCode() == RaftProto.ResCode.RES_CODE_SUCCESS) {
                peer.setMatchIndex(prevLogIndex + numEntries);
                peer.setNextIndex(peer.getMatchIndex() + 1);
                if (ConfigurationUtils.containsServer(configuration, peer.getServer().getServerId())) {
                    advanceCommitIndex();
                } else {
                    if (raftLog.getLastLogIndex() - peer.getMatchIndex() <= raftOptions.getCatchupMargin()) {
                        LOG.debug("peer catch up the leader");
                        peer.setCatchUp(true);
                        // signal the caller thread
                        catchUpCondition.signalAll();
                    }
                }
            } else {
                peer.setNextIndex(response.getLastLogIndex() + 1);
            }
        }
    } finally {
        lock.unlock();
    }
}

下面分析 appendEntries 方法

appendEntries 方法的主要功能是:

向某个 peer(即跟随者节点)发送日志条目,确保日志一致性。

如果跟随者节点的日志落后过多,可能需要安装快照。

如果日志复制成功,更新跟随者的 matchIndex 和 nextIndex。

处理领导者和跟随者之间的通信,以及日志复制的成功或失败。

检查是否需要安装快照

java 复制代码
boolean isNeedInstallSnapshot = false;
lock.lock();
try {
    long firstLogIndex = raftLog.getFirstLogIndex();
    if (peer.getNextIndex() < firstLogIndex) {
        isNeedInstallSnapshot = true;  // 如果跟随者的 nextIndex 小于领导者日志的起始索引,说明需要安装快照
    }
} finally {
    lock.unlock();
}

if (isNeedInstallSnapshot) {
    if (!installSnapshot(peer)) {
        return;
    }
}

检查条件:如果跟随者的 nextIndex 小于领导者的日志起始索引,说明跟随者的日志落后过多,领导者需要发送快照而不是日志条目。

installSnapshot(peer):如果需要安装快照,则调用 installSnapshot(peer) 来处理快照安装过程。

获取快照的元数据

java 复制代码
long lastSnapshotIndex;
long lastSnapshotTerm;
snapshot.getLock().lock();
try {
    lastSnapshotIndex = snapshot.getMetaData().getLastIncludedIndex();
    lastSnapshotTerm = snapshot.getMetaData().getLastIncludedTerm();
} finally {
    snapshot.getLock().unlock();
}

获取快照元数据:如果需要同步快照,首先从快照中获取元数据,lastSnapshotIndex 和 lastSnapshotTerm 记录了快照的索引和任期。

准备日志条目的前一个条目

java 复制代码
lock.lock();
try {
    long firstLogIndex = raftLog.getFirstLogIndex();
    Validate.isTrue(peer.getNextIndex() >= firstLogIndex);
    prevLogIndex = peer.getNextIndex() - 1;  // 获取前一个日志条目的索引
    long prevLogTerm;
    if (prevLogIndex == 0) {
        prevLogTerm = 0;
    } else if (prevLogIndex == lastSnapshotIndex) {
        prevLogTerm = lastSnapshotTerm;  // 如果前一个索引是快照的索引,使用快照的任期
    } else {
        prevLogTerm = raftLog.getEntryTerm(prevLogIndex);  // 否则获取日志条目的任期
    }
    requestBuilder.setServerId(localServer.getServerId());
    requestBuilder.setTerm(currentTerm);
    requestBuilder.setPrevLogTerm(prevLogTerm);
    requestBuilder.setPrevLogIndex(prevLogIndex);
    numEntries = packEntries(peer.getNextIndex(), requestBuilder);  // 打包条目
    requestBuilder.setCommitIndex(Math.min(commitIndex, prevLogIndex + numEntries));
} finally {
    lock.unlock();
}

前一个日志条目的索引和任期:为了确保日志一致性,需要将前一个日志条目的索引和任期发送给跟随者,以便跟随者能够正确地验证日志条目。

packEntries(peer.getNextIndex(), requestBuilder):打包从 nextIndex 开始的日志条目,准备发送给跟随者。或者说这个方法用于根据 peer.getNextIndex()(即跟随者的下一条日志索引)从领导者的日志中选择一组条目并将它们打包到 requestBuilder 中。

发送 AppendEntries 请求

java 复制代码
RaftProto.AppendEntriesRequest request = requestBuilder.build();
RaftProto.AppendEntriesResponse response = peer.getRaftConsensusServiceAsync().appendEntries(request);

使用构建的 requestBuilder 创建 AppendEntriesRequest 请求,并通过异步调用 peer.getRaftConsensusServiceAsync().appendEntries(request) 发送请求。

处理 AppendEntries 响应

java 复制代码
lock.lock();
try {
    if (response == null) {
        LOG.warn("appendEntries with peer[{}:{}] failed", peer.getServer().getEndpoint().getHost(), peer.getServer().getEndpoint().getPort());
        if (!ConfigurationUtils.containsServer(configuration, peer.getServer().getServerId())) {
            peerMap.remove(peer.getServer().getServerId());
            peer.getRpcClient().stop();
        }
        return;
    }
    LOG.info("AppendEntries response[{}] from server {} in term {} (my term is {})",
             response.getResCode(), peer.getServer().getServerId(),
             response.getTerm(), currentTerm);

    if (response.getTerm() > currentTerm) {
        stepDown(response.getTerm());  // 如果响应的 term 比当前 term 大,则降级
    } else {
        if (response.getResCode() == RaftProto.ResCode.RES_CODE_SUCCESS) {
            peer.setMatchIndex(prevLogIndex + numEntries);
            peer.setNextIndex(peer.getMatchIndex() + 1);
            if (ConfigurationUtils.containsServer(configuration, peer.getServer().getServerId())) {
                advanceCommitIndex();  // 如果是集群中的服务器,更新 commitIndex
            } else {
                if (raftLog.getLastLogIndex() - peer.getMatchIndex() <= raftOptions.getCatchupMargin()) {
                    LOG.debug("peer catch up the leader");
                    peer.setCatchUp(true);
                    catchUpCondition.signalAll();  // 如果跟随者赶上了领导者,唤醒其他线程
                }
            }
        } else {
            peer.setNextIndex(response.getLastLogIndex() + 1);  // 如果日志不匹配,调整 nextIndex
        }
    }
} finally {
    lock.unlock();
}

响应检查:

如果响应为空,说明与该跟随者的连接失败,日志复制失败。

如果响应的 term 大于当前 term,说明集群的领导者已经改变,领导者需要降级。

如果响应成功,更新 peer 的 matchIndex 和 nextIndex,并决定是否推进 commitIndex。

如果日志条目不匹配,更新 nextIndex,并请求跟随者重新同步。

matchIndex:每个跟随者已成功复制的最后一条日志的索引,领导者为每个跟随者维护该值。

nextIndex:领导者下次向跟随者发送日志时,待发送的第一条日志的索引(初始为领导者最后一条日志索引 + 1)

commitIndex:集群中已被多数节点确认的最后一条日志的索引(所有节点需应用此索引前的日志)

Follower节点处理日志复制

RaftConsensusServiceImpl.java → appendEntries()方法

appendEntries() 方法是 Raft 协议中 跟随者(Follower) 节点处理领导者发送的 日志复制请求(AppendEntriesRequest)的实现部分。该方法负责验证日志的一致性、接收日志条目,并请求更新自己的根据日志。如果请求是一个心跳请求,它还会更新 commitIndex 并响应成功。

作为跟随者,接收领导者的日志复制请求,验证请求合法性并处理,最终返回响应

appendEntries 方法代码如下:

java 复制代码
@Override
public RaftProto.AppendEntriesResponse appendEntries(RaftProto.AppendEntriesRequest request) {
    raftNode.getLock().lock();
    try {
        RaftProto.AppendEntriesResponse.Builder responseBuilder
                = RaftProto.AppendEntriesResponse.newBuilder();
        responseBuilder.setTerm(raftNode.getCurrentTerm());
        responseBuilder.setResCode(RaftProto.ResCode.RES_CODE_FAIL);
        responseBuilder.setLastLogIndex(raftNode.getRaftLog().getLastLogIndex());
        if (request.getTerm() < raftNode.getCurrentTerm()) {
            return responseBuilder.build();
        }
        raftNode.stepDown(request.getTerm());
        if (raftNode.getLeaderId() == 0) {
            raftNode.setLeaderId(request.getServerId());
            LOG.info("new leaderId={}, conf={}",
                    raftNode.getLeaderId(),
                    PRINTER.printToString(raftNode.getConfiguration()));
        }
        if (raftNode.getLeaderId() != request.getServerId()) {
            LOG.warn("Another peer={} declares that it is the leader " +
                            "at term={} which was occupied by leader={}",
                    request.getServerId(), request.getTerm(), raftNode.getLeaderId());
            raftNode.stepDown(request.getTerm() + 1);
            responseBuilder.setResCode(RaftProto.ResCode.RES_CODE_FAIL);
            responseBuilder.setTerm(request.getTerm() + 1);
            return responseBuilder.build();
        }

        if (request.getPrevLogIndex() > raftNode.getRaftLog().getLastLogIndex()) {
            LOG.info("Rejecting AppendEntries RPC would leave gap, " +
                    "request prevLogIndex={}, my lastLogIndex={}",
                    request.getPrevLogIndex(), raftNode.getRaftLog().getLastLogIndex());
            return responseBuilder.build();
        }
        if (request.getPrevLogIndex() >= raftNode.getRaftLog().getFirstLogIndex()
                && raftNode.getRaftLog().getEntryTerm(request.getPrevLogIndex())
                != request.getPrevLogTerm()) {
            LOG.info("Rejecting AppendEntries RPC: terms don't agree, " +
                    "request prevLogTerm={} in prevLogIndex={}, my is {}",
                    request.getPrevLogTerm(), request.getPrevLogIndex(),
                    raftNode.getRaftLog().getEntryTerm(request.getPrevLogIndex()));
            Validate.isTrue(request.getPrevLogIndex() > 0);
            responseBuilder.setLastLogIndex(request.getPrevLogIndex() - 1);
            return responseBuilder.build();
        }

        if (request.getEntriesCount() == 0) {
            LOG.debug("heartbeat request from peer={} at term={}, my term={}",
                    request.getServerId(), request.getTerm(), raftNode.getCurrentTerm());
            responseBuilder.setResCode(RaftProto.ResCode.RES_CODE_SUCCESS);
            responseBuilder.setTerm(raftNode.getCurrentTerm());
            responseBuilder.setLastLogIndex(raftNode.getRaftLog().getLastLogIndex());
            advanceCommitIndex(request);
            return responseBuilder.build();
        }

        responseBuilder.setResCode(RaftProto.ResCode.RES_CODE_SUCCESS);
        List<RaftProto.LogEntry> entries = new ArrayList<>();
        long index = request.getPrevLogIndex();
        for (RaftProto.LogEntry entry : request.getEntriesList()) {
            index++;
            if (index < raftNode.getRaftLog().getFirstLogIndex()) {
                continue;
            }
            if (raftNode.getRaftLog().getLastLogIndex() >= index) {
                if (raftNode.getRaftLog().getEntryTerm(index) == entry.getTerm()) {
                    continue;
                }
                // truncate segment log from index
                long lastIndexKept = index - 1;
                raftNode.getRaftLog().truncateSuffix(lastIndexKept);
            }
            entries.add(entry);
        }
        raftNode.getRaftLog().append(entries);
//            raftNode.getRaftLog().updateMetaData(raftNode.getCurrentTerm(),
//                    null, raftNode.getRaftLog().getFirstLogIndex());
        responseBuilder.setLastLogIndex(raftNode.getRaftLog().getLastLogIndex());

        advanceCommitIndex(request);
        LOG.info("AppendEntries request from server {} " +
                        "in term {} (my term is {}), entryCount={} resCode={}",
                request.getServerId(), request.getTerm(), raftNode.getCurrentTerm(),
                request.getEntriesCount(), responseBuilder.getResCode());
        return responseBuilder.build();
    } finally {
        raftNode.getLock().unlock();
    }
}

下面分析 appendEntries 方法

appendEntries 方法的主要功能是:

向跟随者节点(peer)发送日志条目,并确保日志的一致性。

如果请求中的前一个日志条目的索引(prevLogIndex)不匹配当前节点的日志,则拒绝请求,或者截断不一致的日志部分。

处理心跳请求(即没有日志条目的请求),保持领导者地位。

如果跟随者节点的日志落后较多,可能需要更新 commitIndex 或安装快照。

如果日志条目复制成功,更新跟随者的 matchIndex 和 nextIndex,以便下次继续同步。

处理领导者和跟随者之间的通信,确保日志一致性,并根据响应调整日志复制策略。

获取锁

java 复制代码
raftNode.getLock().lock();
try {
    // 代码逻辑
} finally {
    raftNode.getLock().unlock();
}

lock.lock():在多线程环境中,lock 用于确保对共享资源(如日志和状态)的访问是线程安全的。所有日志和状态的修改都被锁定,以避免并发访问导致不一致。

finally 块中的 lock.unlock():确保在方法执行完毕后释放锁,避免死锁。

初始化响应

java 复制代码
RaftProto.AppendEntriesResponse.Builder responseBuilder
        = RaftProto.AppendEntriesResponse.newBuilder();
responseBuilder.setTerm(raftNode.getCurrentTerm());
responseBuilder.setResCode(RaftProto.ResCode.RES_CODE_FAIL);
responseBuilder.setLastLogIndex(raftNode.getRaftLog().getLastLogIndex());

创建 AppendEntriesResponse 响应对象的构建器。

setTerm(raftNode.getCurrentTerm()):设置响应的任期号,当前节点的任期号。

setResCode(RaftProto.ResCode.RES_CODE_FAIL):默认设置响应码为 FAIL,除非后续条件满足并改变为 SUCCESS。

setLastLogIndex(raftNode.getRaftLog().getLastLogIndex()):设置响应中的最后日志索引,表示当前节点日志的最后一条记录。

任期检查

java 复制代码
if (request.getTerm() < raftNode.getCurrentTerm()) {
    return responseBuilder.build();
}

任期检查:如果请求的 term 小于当前节点的 currentTerm,则说明请求的领导者已经过期(已经被新领导者取代),因此拒绝该请求,返回失败响应。

降级处理

java 复制代码
raftNode.stepDown(request.getTerm());

stepDown(request.getTerm()):如果接收到的请求的 term 比当前节点的 term 小,那么当前节点会降级为跟随者,更新节点状态。

更新领导者信息

java 复制代码
if (raftNode.getLeaderId() == 0) {
    raftNode.setLeaderId(request.getServerId());
    LOG.info("new leaderId={}, conf={}",
            raftNode.getLeaderId(),
            PRINTER.printToString(raftNode.getConfiguration()));
}

更新领导者信息:如果当前节点的 leaderId 为 0(没有领导者),则将请求中的 serverId 设置为新的领导者,并记录日志。

领导者不匹配的处理

java 复制代码
if (raftNode.getLeaderId() != request.getServerId()) {
    LOG.warn("Another peer={} declares that it is the leader " +
                    "at term={} which was occupied by leader={}",
            request.getServerId(), request.getTerm(), raftNode.getLeaderId());
    raftNode.stepDown(request.getTerm() + 1);
    responseBuilder.setResCode(RaftProto.ResCode.RES_CODE_FAIL);
    responseBuilder.setTerm(request.getTerm() + 1);
    return responseBuilder.build();
}

领导者不匹配:如果请求的领导者 ID 与当前节点的领导者不一致,说明出现了领导者冲突。此时当前节点会降级,stepDown() 会将当前节点的状态更新为跟随者,并更新响应的 term。

日志条目验证(日志索引和任期)

java 复制代码
if (request.getPrevLogIndex() > raftNode.getRaftLog().getLastLogIndex()) {
    LOG.info("Rejecting AppendEntries RPC would leave gap, " +
            "request prevLogIndex={}, my lastLogIndex={}",
            request.getPrevLogIndex(), raftNode.getRaftLog().getLastLogIndex());
    return responseBuilder.build();
}

日志索引检查:如果请求的 prevLogIndex(前一个日志条目的索引)大于当前节点的 lastLogIndex,说明请求的日志条目不一致,无法应用。这意味着领导者的日志和跟随者的日志有差异,直接拒绝请求。

java 复制代码
if (request.getPrevLogIndex() >= raftNode.getRaftLog().getFirstLogIndex()
        && raftNode.getRaftLog().getEntryTerm(request.getPrevLogIndex())
        != request.getPrevLogTerm()) {
    LOG.info("Rejecting AppendEntries RPC: terms don't agree, " +
            "request prevLogTerm={} in prevLogIndex={}, my is {}",
            request.getPrevLogTerm(), request.getPrevLogIndex(),
            raftNode.getRaftLog().getEntryTerm(request.getPrevLogIndex()));
    Validate.isTrue(request.getPrevLogIndex() > 0);
    responseBuilder.setLastLogIndex(request.getPrevLogIndex() - 1);
    return responseBuilder.build();
}

日志条目任期检查:如果请求中的 prevLogTerm 与当前节点日志中相应条目的任期不同,说明日志条目在两个节点间不一致,拒绝该请求并返回失败。

心跳请求的处理

java 复制代码
if (request.getEntriesCount() == 0) {
    LOG.debug("heartbeat request from peer={} at term={}, my term={}",
            request.getServerId(), request.getTerm(), raftNode.getCurrentTerm());
    responseBuilder.setResCode(RaftProto.ResCode.RES_CODE_SUCCESS);
    responseBuilder.setTerm(raftNode.getCurrentTerm());
    responseBuilder.setLastLogIndex(raftNode.getRaftLog().getLastLogIndex());
    advanceCommitIndex(request);
    return responseBuilder.build();
}

心跳请求:如果请求中没有日志条目(即 entriesCount == 0),说明这是一个 心跳请求。领导者发送心跳请求用于维持领导者地位,确保跟随者没有超时。此时,领导者会返回一个成功响应并更新 commitIndex。

处理日志条目

java 复制代码
responseBuilder.setResCode(RaftProto.ResCode.RES_CODE_SUCCESS);
List<RaftProto.LogEntry> entries = new ArrayList<>();
long index = request.getPrevLogIndex();
for (RaftProto.LogEntry entry : request.getEntriesList()) {
    index++;
    if (index < raftNode.getRaftLog().getFirstLogIndex()) {
        continue;
    }
    if (raftNode.getRaftLog().getLastLogIndex() >= index) {
        if (raftNode.getRaftLog().getEntryTerm(index) == entry.getTerm()) {
            continue;
        }
        // truncate segment log from index
        long lastIndexKept = index - 1;
        raftNode.getRaftLog().truncateSuffix(lastIndexKept);
    }
    entries.add(entry);
}
raftNode.getRaftLog().append(entries);

处理日志条目:如果请求包含日志条目,首先检查这些日志条目是否与当前节点的日志匹配。如果日志条目不匹配(即日志的任期或内容不同),则需要 截断不匹配的日志 并将新条目追加到日志中。

raftNode.getRaftLog().truncateSuffix(lastIndexKept):这行代码用于截断日志中不匹配的部分,只保留一致的日志。

更新 commitIndex

java 复制代码
responseBuilder.setLastLogIndex(raftNode.getRaftLog().getLastLogIndex());
advanceCommitIndex(request);

更新 commitIndex:在处理完日志条目后,更新响应中的 lastLogIndex,并调用 advanceCommitIndex(request) 来更新 commitIndex,确保日志的提交索引正确。

返回响应

java 复制代码
LOG.info("AppendEntries request from server {} " +
                "in term {} (my term is {}), entryCount={} resCode={}",
        request.getServerId(), request.getTerm(), raftNode.getCurrentTerm(),
        request.getEntriesCount(), responseBuilder.getResCode());
return responseBuilder.build();

日志记录和响应返回:记录处理日志条目的日志信息,最后返回构建的 AppendEntriesResponse 响应。


尚未完结

相关推荐
西岭千秋雪_2 小时前
RabbitMQ队列的选择
笔记·分布式·学习·rabbitmq·ruby
武子康3 小时前
Java-70 深入浅出 RPC Dubbo 详细介绍 上手指南
java·分布式·网络协议·spring·rpc·dubbo·nio
我重来不说话11 小时前
xFile:高性能虚拟分布式加密存储系统——Go
分布式·压缩存储·权限系统·动态加密·虚拟存储
2401_8315017312 小时前
Linux之Zabbix分布式监控篇(一)
分布式·zabbix
gorgor在码农14 小时前
分布式ID方案
分布式
Aikes90215 小时前
基于redis的分布式session共享管理之销毁事件不生效问题
redis·分布式·缓存
简婷1870199877517 小时前
高速路上的 “阳光哨兵”:分布式光伏监控系统守护能源高效运转
分布式·能源
钺商科技18 小时前
【6.1.2 漫画分布式事务技术选型】
分布式
帅次18 小时前
系统分析师-计算机系统-输入输出系统
人工智能·分布式·深度学习·神经网络·架构·系统架构·硬件架构