SOFAJRaft 源码分析三(状态机、线性一致性读)

428 阅读10分钟

1.概述

今天来看一下jraft如何将日志写入到状态机,其实就是业务真正的存储工作。如果我们需要使用jraft,我们对这里的实现就需要足够的了解。然后还会介绍jraft的读取逻辑。

2.思路整理

对于状态机,我们关注问题如下:

  • 何时会将日志同步到状态机?
  • 对于节点变化,状态机会做什么?
  • 状态机为了业务解藕做了怎么样封装? 对于读取操作:主要就是如何做读取优化操作? 那我们带着这几个问题一起深入源码,寻找答案吧!

3.状态机源码分析

主要就是leader和follower将日志应用到状态机的过程。当然leader和follower应用的时机不一样,但是过程都是一样的。

我们先来看leader。leader再增加日志的时候,会有一个回调,如果成功会执行这个回调方法(具体时机为将日志添加到本地磁盘后,也就是AppendBatcher 的flush方法)。

这个回调回执行ballotBox的commitAt方法。

@Override
public void run(final Status status) {
    if (status.isOk()) {
        NodeImpl.this.ballotBox.commitAt(this.firstLogIndex, this.firstLogIndex + this.nEntries - 1,
            NodeImpl.this.serverId);
    } else {
        LOG.error("Node {} append [{}, {}] failed, status={}.", getNodeId(), this.firstLogIndex,
            this.firstLogIndex + this.nEntries - 1, status);
    }
}

commitAt方法

这里说一下,ballotBox主要记录了日志提交的状态。每个节点提交日志成功后都会调用这个方法。

上面只说了leader成功,如果follower提交成功,则会以响应的形式告诉leader。在onAppendEntriesReturned 中也会调用该方法。如下图。

这就很清晰了。其实上篇博客都介绍了这个方法的作用。因为和状态机实现衔接,所以我们在来回顾一下这个方法。

final long startAt = Math.max(this.pendingIndex, firstLogIndex);
Ballot.PosHint hint = new Ballot.PosHint();
for (long logIndex = startAt; logIndex <= lastLogIndex; logIndex++) {
    final Ballot bl = this.pendingMetaQueue.get((int) (logIndex - this.pendingIndex));
    hint = bl.grant(peer, hint);
    if (bl.isGranted()) {
        lastCommittedIndex = logIndex;
    }
}
if (lastCommittedIndex == 0) {
    return true;
}
this.pendingMetaQueue.removeFromFirst((int) (lastCommittedIndex - this.pendingIndex) + 1);
LOG.debug("Committed log fromIndex={}, toIndex={}.", this.pendingIndex, lastCommittedIndex);
this.pendingIndex = lastCommittedIndex + 1;
this.lastCommittedIndex = lastCommittedIndex;
...
this.waiter.onCommitted(lastCommittedIndex);
  • pendingIndex:当前已经ok的日志索引+1(何为ok,就是大多数节点都持久化的)
  • firstLogIndex:本次提交成功日志的起始值。
  • lastLogIndex:本次提交成功日志的终止值。

为什么这里startAt为max,因为这个有很大的可能pendingIndex比firstLogIndex大,原因是这个节点响应比较慢。在他响应之前Ballot的isGranted已经返回true了。

这样的话,我们能理解这个方法其实就是用来维护this.lastCommittedIndex这个成员变量。最后他会调用this.waiter.onCommitted方法。

onCommitted方法

其实这个方法就是commit到状态机的入口。 当然这个方法也会在两处被调用。一处是被leader,一处是被follower调用。

@Override
public boolean onCommitted(final long committedIndex) {
    return enqueueTask((task, sequence) -> {
        task.type = TaskType.COMMITTED;
        task.committedIndex = committedIndex;
    });
}

这个方法逻辑比较简单,就是创建一个commit时间丢到FSMCallerImpl 的队列。 我们顺藤摸瓜看看follower何时调用这个onCommitted方法。

FollowerStableClosure#run

1.在follower增加日志成功之后,有执行FollowerStableClosure 回调,上篇文章说过,他就是用来响应leader。当然在响应之前回执行node.ballotBox.setLastCommittedIndex方法。其实这个方法最后会调用onCommitted方法。

2.在follower处理心跳或者探针消息的时候。也会调用setLastCommittedIndex方法。ok,到这里我们已经了解了。follower和leader何时会给FSMCallerImpl 的队列提交commit事件。接下来我们只需要关注如何处理事件了。

if (entriesCount == 0) {
    // heartbeat
    final AppendEntriesResponse.Builder respBuilder = AppendEntriesResponse.newBuilder() //
        .setSuccess(true) //
        .setTerm(this.currTerm) //
        .setLastLogIndex(this.logManager.getLastLogIndex());
    doUnlock = false;
    this.writeLock.unlock();
    // see the comments at FollowerStableClosure#run()
    this.ballotBox.setLastCommittedIndex(Math.min(request.getCommittedIndex(), prevLogIndex));
    return respBuilder.build();
}

enqueueTask

在这之前我们先看一下enqueueTask方法。我们发现,他有很多事件,不仅只有我们上面看到的commit事件,还会有节点变化,或者快照等一系列事件。我们后面一起分析。

ApplyTaskHandler

这个handler就是对应事件处理器。具体是runApplyTask方法。

private class ApplyTaskHandler implements EventHandler<ApplyTask> {
    // max committed index in current batch, reset to -1 every batch
    private long maxCommittedIndex = -1;

    @Override
    public void onEvent(final ApplyTask event, final long sequence, final boolean endOfBatch) throws Exception {
        this.maxCommittedIndex = runApplyTask(event, this.maxCommittedIndex, endOfBatch);
    }
}

runApplyTask方法

这个方法逻辑其实很简单。其实就是根据实现类型执行不同的处理操作。

比如下面的代码,这里为了尽量少贴代码,只放了三个case。很明显根据不同类型会调用对应的方法。

其实方法的实现也很简单,就是调用业务方状态机实现类的对象方法。其实之前我们也说过,要实现的话,只需要继承对应的适配器类,实现想要实现的方法即可。

case LEADER_STOP:
    this.currTask = TaskType.LEADER_STOP;
    doLeaderStop(task.status);
    break;
case LEADER_START:
    this.currTask = TaskType.LEADER_START;
    doLeaderStart(task.term);
    break;
case START_FOLLOWING:
    this.currTask = TaskType.START_FOLLOWING;
    doStartFollowing(task.leaderChangeCtx);
    break;

我们重点关注提交数据的逻辑,这里更新最大commitIndex,然后调用doCommitted方法。

if (endOfBatch && maxCommittedIndex >= 0) {
    this.currTask = TaskType.COMMITTED;
    doCommitted(maxCommittedIndex);
    maxCommittedIndex = -1L; // reset maxCommittedIndex
}

doCommitted方法

1.获取上一次提交的Index,如果当前commitIndex小于上一个提交的index,直接return。

final long lastAppliedIndex = this.lastAppliedIndex.get();
// We can tolerate the disorder of committed_index
if (lastAppliedIndex >= committedIndex) {
    return;
}

2.创建迭代器,这里依赖commitIndex以及LogManager,LogManager主要就是根据偏移获取日志。

final long firstClosureIndex = this.closureQueue.popClosureUntil(committedIndex, closures, taskClosures);

// Calls TaskClosure#onCommitted if necessary
onTaskCommitted(taskClosures);

Requires.requireTrue(firstClosureIndex >= 0, "Invalid firstClosureIndex");
final IteratorImpl iterImpl = new IteratorImpl(this.fsm, this.logManager, closures, firstClosureIndex,
    lastAppliedIndex, committedIndex, this.applyingIndex);
while (iterImpl.isGood()) {
    final LogEntry logEntry = iterImpl.entry();
    if (logEntry.getType() != EnumOutter.EntryType.ENTRY_TYPE_DATA) {
        if (logEntry.getType() == EnumOutter.EntryType.ENTRY_TYPE_CONFIGURATION) {
            if (logEntry.getOldPeers() != null && !logEntry.getOldPeers().isEmpty()) {
                // Joint stage is not supposed to be noticeable by end users.
                this.fsm.onConfigurationCommitted(new Configuration(iterImpl.entry().getPeers()));
            }
        }
        if (iterImpl.done() != null) {
            iterImpl.done().run(Status.OK());
        }
        iterImpl.next();
        continue;
    }
    // Apply data task to user state machine
    doApplyTasks(iterImpl);
}

最终会调用doApplyTasks,其实就是调用了fsm的apply方法。

如果是leader,还会有个closureQueue,这个队列存储的是业务方执行apply请求的回调方法。一般就是成功应用状态机后响应给调用方。下面代码就是Counter例子的回调

public void handleRequest(final RpcContext rpcCtx, final IncrementAndGetRequest request) {
    final CounterClosure closure = new CounterClosure() {
        @Override
        public void run(Status status) {
            rpcCtx.sendResponse(getValueResponse());
        }
    };
    this.counterService.incrementAndGet(request.getDelta(), closure);
}

3.后续的状态更新

final long lastIndex = iterImpl.getIndex() - 1;
final long lastTerm = this.logManager.getTerm(lastIndex);
final LogId lastAppliedId = new LogId(lastIndex, lastTerm);
this.lastAppliedIndex.set(lastIndex);
this.lastAppliedTerm = lastTerm;
this.logManager.setAppliedId(lastAppliedId);
notifyLastAppliedIndexUpdated(lastIndex);

其实上面的逻辑很简单,就是根据commitIndex然后取迭代操作,最后调用apply方法。我们有必要关注这个apply方法如何实现。其实根据不同的业务,有不同的实现,比如如果是数据库,那么直接通过数据库引擎执行对应语句。如果只是简单的counter,那么非常容易。

下面是counter的例子实现:

这里其实有个优化,如果iter.done不为空,说明当前为leader,我们就不需要从日志序列化数据,直接从done返回。

CounterClosure closure = null;
if (iter.done() != null) {
    // This task is applied by this node, get value from closure to avoid additional parsing.
    closure = (CounterClosure) iter.done();
    counterOperation = closure.getCounterOperation();
} else {
    // Have to parse FetchAddRequest from this user log.
    final ByteBuffer data = iter.getData();
    try {
        counterOperation = SerializerManager.getSerializer(SerializerManager.Hessian2).deserialize(
            data.array(), CounterOperation.class.getName());
    } catch (final CodecException e) {
        LOG.error("Fail to decode IncrementAndGetRequest", e);
    }
}

最后根据执行执行对应的操作,如果只是get,直接返回结果。 如果是increment,那么调用原子自增。最后执行回调。

if (counterOperation != null) {
    switch (counterOperation.getOp()) {
        case GET:
            current = this.value.get();
            LOG.info("Get value={} at logIndex={}", current, iter.getIndex());
            break;
        case INCREMENT:
            final long delta = counterOperation.getDelta();
            final long prev = this.value.get();
            current = this.value.addAndGet(delta);
            LOG.info("Added value={} by delta={} at logIndex={}", prev, delta, iter.getIndex());
            break;
    }

    if (closure != null) {
        closure.success(current);
        closure.run(Status.OK());
    }
}

其实到这里,状态机的实现我们已经足够了解了。

jraft通过适配器模式。留了一个适配器的类StateMachineAdapter 。 业务方只需要继承该类即可。然后在初始化server的时候。将我们的实现类设置到配置中即可。

this.fsm = new CounterStateMachine();
// 设置状态机到启动参数
nodeOptions.setFsm(this.fsm);

这里jraft就可以在对应操作的时候执行我们实现的方法,实现业务解藕。

4.线性一致性读

首先我们应该理解线性一致性读的概念:在T1时刻写入的值,在T1时刻之后读肯定可以读到。也即读的数据必须是读开始之后的某个值,不能是读开始之前的某个值。不要求返回最新的值,返回时间大于读开始的值就可以。

LogRead

这是一种很简单并且容易理解的解决方案,也就是说对于读操作也要写入Log,因为每个Log都是有其顺序的,如果按照顺序去执行,必然会保证线性一致性。 但是这个缺点明显,因为对于读操作还要记录Log,这就会导致没必要的磁盘IO。所以有了一些优化的实现。

ReadIndex

他和LogRead区别就是,他不需要记录Log。每次读请求到达后,会将当前commitIndex记录为ReadIndex。然后判断当前节点是否为leader,如果是,等待状态机至少应用到ReadIndex,然后执行读请求,返回给客户端。 当然这种优化还是要确定自己是否为leader,需要走一次RPC请求。

LeaseRead

其实这种就是尽量避免了Rpc。因为raft选举有个election timeout的阈值,所以 LeaseRead取了一个比election timeout小的租期,但是其正确性和时间挂钩。所以时间飘走严重,就会出现不一致现象。

源码跟踪

ReadOnlyServiceImpl#addRequest方法

while (true) {
    if (this.readIndexQueue.tryPublishEvent(translator)) {
        break;
    } else {
        retryTimes++;
        if (retryTimes > MAX_ADD_REQUEST_RETRY_TIMES) {
            Utils.runClosureInThread(closure,
                new Status(RaftError.EBUSY, "Node is busy, has too many read-only requests."));
            this.nodeMetrics.recordTimes("read-index-overload-times", 1);
            LOG.warn("Node {} ReadOnlyServiceImpl readIndexQueue is overload.", this.node.getNodeId());
            return;
        }
        ThreadHelper.onSpinWait();
    }
}

在读请求到达后,都会走这个方法,主要就是构建请求Event,丢进队列。 按照老的套路,我们此时需要看一下处理队列的Handler如何实现。

其实这个handler主要是调用executeReadIndexEvents方法,这个方法会构建一个ReadIndexRequest 请求,然后调用handleReadIndexRequest 方法。

handleReadIndexRequest方法

public void handleReadIndexRequest(final ReadIndexRequest request, final RpcResponseClosure<ReadIndexResponse> done) {
    final long startMs = Utils.monotonicMs();
    this.readLock.lock();
    try {
        switch (this.state) {
            case STATE_LEADER:
                readLeader(request, ReadIndexResponse.newBuilder(), done);
                break;
            case STATE_FOLLOWER:
                readFollower(request, done);
                break;
            case STATE_TRANSFERRING:
                done.run(new Status(RaftError.EBUSY, "Is transferring leadership."));
                break;
            default:
                done.run(new Status(RaftError.EPERM, "Invalid state for readIndex: %s.", this.state));
                break;
        }
    } finally {
        this.readLock.unlock();
        this.metrics.recordLatency("handle-read-index", Utils.monotonicMs() - startMs);
        this.metrics.recordSize("handle-read-index-entries", request.getEntriesCount());
    }
}

这个方法会先加读锁,然后会根据当前节点状态执行对应的操作。

readLeader方法

1.如果当前leader在任期期间没有提交过日志则直接失败

if (this.logManager.getTerm(lastCommittedIndex) != this.currTerm) {
    // Reject read only request when this leader has not committed any log entry at its term
    closure
        .run(new Status(
            RaftError.EAGAIN,
            "ReadIndex request rejected because leader has not committed any log entry at its term, logIndex=%d, currTerm=%d.",
            lastCommittedIndex, this.currTerm));
    return;
}

2.根据配置判断采用什么方式进行读取。

ReadOnlyOption readOnlyOpt = this.raftOptions.getReadOnlyOptions();
if (readOnlyOpt == ReadOnlyOption.ReadOnlyLeaseBased && !isLeaderLeaseValid()) {
    // If leader lease timeout, we must change option to ReadOnlySafe
    readOnlyOpt = ReadOnlyOption.ReadOnlySafe;
}

switch (readOnlyOpt) {
    case ReadOnlySafe:
        final List<PeerId> peers = this.conf.getConf().getPeers();
        Requires.requireTrue(peers != null && !peers.isEmpty(), "Empty peers");
        final ReadIndexHeartbeatResponseClosure heartbeatDone = new ReadIndexHeartbeatResponseClosure(closure,
            respBuilder, quorum, peers.size());
        // Send heartbeat requests to followers
        for (final PeerId peer : peers) {
            if (peer.equals(this.serverId)) {
                continue;
            }
            this.replicatorGroup.sendHeartbeat(peer, heartbeatDone);
        }
        break;
    case ReadOnlyLeaseBased:
        // Responses to followers and local node.
        respBuilder.setSuccess(true);
        closure.setResponse(respBuilder.build());
        closure.run(Status.OK());
        break;
}
  • 如果是ReadOnlySafe,这里采用readIndex的方式,会向每个节点发送心跳,避免leader飘走问题。
  • 如果是ReadOnlyLeaseBased,因为其需求保证当前节点为leader,所以直接返回即可。

其实这个心跳方法我们之前文章有说过。就不再赘述。 我们重点看一下心跳之后的回调

public synchronized void run(final Status status) {
    if (this.isDone) {
        return;
    }
    if (status.isOk() && getResponse().getSuccess()) {
        this.ackSuccess++;
    } else {
        this.ackFailures++;
    }
    // Include leader self vote yes.
    if (this.ackSuccess + 1 >= this.quorum) {
        this.respBuilder.setSuccess(true);
        this.closure.setResponse(this.respBuilder.build());
        this.closure.run(Status.OK());
        this.isDone = true;
    } else if (this.ackFailures >= this.failPeersThreshold) {
        this.respBuilder.setSuccess(false);
        this.closure.setResponse(this.respBuilder.build());
        this.closure.run(Status.OK());
        this.isDone = true;
    }
}

这里并没有采用boltCtx去存储心跳的结果了。而是通过synchronized进行控制。毕竟这个回调对象都是同一个,只要通过锁进行并发控制即可。如果多半节节点认可该节点为leader,则返回成功。否则失败。对去leader的readIndex实现其实就这么简单。

上面无论是哪种方式,成功后都会执行回调。 这个主要在ReadIndexResponseClosure 的run方法实现。

ReadIndexResponseClosure#run

// Success
final ReadIndexStatus readIndexStatus = new ReadIndexStatus(this.states, this.request,
    readIndexResponse.getIndex());
for (final ReadIndexState state : this.states) {
    // Records current commit log index.
    state.setIndex(readIndexResponse.getIndex());
}

boolean doUnlock = true;
ReadOnlyServiceImpl.this.lock.lock();
try {
    if (readIndexStatus.isApplied(ReadOnlyServiceImpl.this.fsmCaller.getLastAppliedIndex())) {
        // Already applied, notify readIndex request.
        ReadOnlyServiceImpl.this.lock.unlock();
        doUnlock = false;
        notifySuccess(readIndexStatus);
    } else {
        // Not applied, add it to pending-notify cache.
        ReadOnlyServiceImpl.this.pendingNotifyStatus
            .computeIfAbsent(readIndexStatus.getIndex(), k -> new ArrayList<>(10)) //
            .add(readIndexStatus);
   }
  }

这个方法逻辑其实很简单了。首先就会构建一个ReadIndexStatus(jraft做读取逻辑时候采用批量读,减少网络io)。

每个读取请求都是一个ReadIndexStatu,里面会有成功回调。jraft只需要再可读的时候执行该回调(在notifySuccess中执行)。当然这个回调是由业务方实现的。

如果不符合读取条件,也就是apply小于readIndex,那么会将其加入到pendingNotifyStatus。在apply变化的时候会通过监听通知pendingNotifyStatus,去再次判断。还有一个定时任务也在保证。

主要可以参考ReadOnlyServiceImpl 的onApplied 方法。

具体到这里我们就完全明白了jraft如何实现读取的。该篇文章也就讲这么多。

5.总结

本文主要分析了状态机的实现,详细讲解了何时会同步日志到状态机,并且业务方如何实现。jraft通过适配器模式,让我们可以更加方便的去实现业务逻辑。设计架构其实很简单,也很容易理解。就是我们将实现传入配置。jraft会在对应的时候执行对应的逻辑。

还有就是jraft的线性一致性读,其实原理上面也说清楚了,当然jraft作了足够的优化,通过异步回调,并且采用batch读的方式,减少必要的网络IO。是值得我们去学习的!

文章参考 pingcap.com/blog-cn/lin…