📖 文档:https://raft.github.io
📓 翻译:https://github.com/maemual/raft-zh_cn
📘 参考:https://hardcore.feishu.cn/docs/doccnMRVFcMWn1zsEYBrbsDf8De#

算法介绍

引入问题

如何多快好省的对大规模数据集进行存储和计算?

  1. 更好的机器
  2. 更多的机器

如果让跨网络的机器之间协调一致的工作?

  1. 状态的立即一致
  2. 状态的最终一致

如何应对网络的不可靠以及节点的失效?

  1. 可读写
  2. 可读
  3. 不可用
  • 组织机器使其状态最终一致并允许局部失败的算法称之为一致性算法。
  • Paxos算法由来已久,目前是功能和性能最完善的一致性算法,但是难以理解和实现。
  • Raft算法简化了Paxos算法,以易于理解为目标,尽量提供与Paxos一样的功能与性能。

复制状态机

一致性算法是从复制状态机的背景下提出的。在这种方法中,一组服务器上的状态机产生相同状态的副本,并且在一些机器宕掉的情况下也可以继续运行。复制状态机在分布式系统中被用于解决很多容错的问题。例如,大规模的系统中通常都有一个集群领导者,像GFS、HDFS和RAMCloud,典型应用就是一个独立的复制状态机去管理领导选举和存储配置信息并且在领导人宕机的情况下也要存活下来,比如Chubby和Zookeeper。


复制状态机通常都是基于复制日志实现的,如图1。每一个服务器存储一个包含一系列指令的日志,并且按照日志的顺序执行。每一个日志都按照相同的顺序包含相同的指令,所以每一个服务器都执行相同的指令序列。因为每个状态机都是确定的,每一次执行操作都产生相同的状态和同样的序列。

保证复制日志相同就是一致性算法的工作了。在一台服务器上,一致性模块接受客户端发送来的指令然后增加到自己的日志上去。它和其他服务器上的一致性模块进行通信类保证每一个服务器上的日志最终以相同的顺序包含相同的请求,尽管有些服务器会宕机。一旦指令被正确的复制,每一个服务器的状态机按照日志顺序处理他们,然后输出结果白返回给客户端。因此,服务器集群看起来形成一个高可靠的状态机。

过程:

  1. Client发送一个写操作命令set x = 3到一个Server节点;
  2. 该Server节点把数据同步到本地日志库中,记录set x = 3
  3. 同时并行请求其他Server节点,其他节点收到请求同样把数据同步到本地日志库中;
  4. 其他节点写入成功之后会回复一个消息告诉该Server节点;
  5. 该Server节点收到所有节点的成功回复之后,返回Client写入成功的消息;
  6. 最后异步将消息同步到状态机。

实际系统中使用的一致性算法通常含有以下特性:

  • 安全性保证:在非拜占庭错误情况下,包括网络延迟、分区、丢包、冗余和乱序等错误都可以保证正确。
  • 可用性:集群中只要有大多数的机器可运行并且能够相互通信、和客户端通信,就可以保证可用。因此,一个典型的包含5个节点的集群可以容忍两个节点的失败。服务器被停止就认为是失败。他们当有稳定的存储的时候可以从状态中恢复回来并重新加入集群。
  • 不依赖时序来保证一致性:物理时钟错误或者极端的消息延迟只有在最坏情况下才会导致可用性问题。
  • 通常情况下,一条指令可以尽可能快的在集群中大多数节点响应一轮远程过程调用时完成。小部分比较慢的节点不会影响系统整体的性能。

定义问题

  1. 输入:写入命令
  2. 输出:所有节点最终处于相同的状态
  3. 约束
    1. 安全性:在非拜占庭情况下,出现网络延迟/分区/丢包/乱序等问题下要保证正确。
    2. 可用性:集群中大部分节点能够保持相互通信,那么集群就应该能够正确响应客户端。
    3. 不依赖时序:不依赖物理时序或极端的消息延迟来保证一致性。
    4. 快速响应:对客户端请求的响应不能依赖集群中最慢的节点。

一个可行解

  1. 初始化的时候有一个领导者节点,复制发送日志到其他跟随者,并决定日志的顺序。
  2. 当读请求倒来时,在任意节点都可以读,而写请求只能重定向到领导者进行。
  3. 领导者先写入自己的日志,然后同步给半数以上节点,跟随者表示都OK了,领导者才提交日志。
  4. 日志最终由领导者先按顺序应用于状态机,其他跟随者随机应用到状态机。
  5. 当领导者崩溃后,其他跟随者通过心跳感知并选举出新的领导者继续集群的正常运转。
  6. 当有新的节点加入或退出集群,需要将配置信息同步给整个集群。

算法实现

通过领导人的方式,Raft将一致性问题分解成了三个相对独立的子问题:

  • 领导选举:当现存的领导人宕机的时候,一个新的领导人需要被选举出来。
  • 日志复制:领导人必须从客户端接受日志条目然后复制到集群中的其他节点,并且强制要求其他节点的日志保持和自己相同。
  • 安全性:Raft安全性的关键是状态机安全。如果有任何的服务器节点已经应用了一个确定的日志条目到它的状态机中,那么其他服务器节点不能再同一个日志索引位置应用一个不同的指令。

状态机


对应一个RAFT集群来说,存在三种角色:

  1. Leader:领导者
  2. Candidate:候选者
  3. Follower:跟随者

节点变更流程

  1. 所有的节点在启动的时候,只有一个领导者,其他都是跟随者。
  2. 启动一个心跳超时计时器,等待领导者发送心跳,如果在超时时间内没有接收到领导者的心跳,跟随者的状态就会变为候选者。
  3. 成为候选者之后,会立刻发起投票请求,投票请求也有一个选举超时计时器,自己会给自己投票,值要获得超过半数以上的票数,那么就选举成功,变更自己的状态为领导者,继续发送心跳,同步日志。
  4. 选举失败的情况:
    1. 选举超时计时器超时,或者选举票数不够。
    2. 在等待投票的时候,收到了领导者的心跳回复。

集群脑裂问题
由于网络分区故障,集群中的不同分区出现了各自的领导者,当网络恢复正常后,集群中出现了多个领导者。RAFT为了解决脑裂问题,给整个集群设置一个任期计数器,每当一个候选者成为领导者,让任期计数器递增。

选票瓜分问题
所有都没有收到领导者的心跳,同时成为候选者,发起投票请求,但是全都给自己投票,导致没有节点的票数超过半数以上。
RAFT为了解决选票给瓜分的问题,给各个节点的心跳超时计时器和选举超时计时器的过期时间设置了随机范围,这样尽可能地错开各个节点的选举时间,让集群更大可能一次性选举出新的领导人。

状态

所有服务器上的持久性状态

参数 解释
currentTerm 服务器已知的最新的任期(在服务器首次启动的时候初始化为0,单调递增)
votedFor 当前任期内收到选票的候选者id,如果没有投给任何候选者则为空
log[] 日志条目,每个条目包含了用于状态机的命令,以及领导者接收到该条目的任期

所有服务器上的易失性状态

参数 解释
commitIndex 已知已提交的最高的日志条目的索引(初始值为0,单调递增)【已提交:命令已经同步给集群中半数以上的节点并且得到确认】
lastApplied 已经被应用到状态机的最高的日志条目的索引(初始值为0,单调递增)【lastApplied <= commitIndex】

领导者上的易失性状态

参数 解释
nextIndex[] 对于每一台服务器,发送到该服务器的下一个日志条目的索引(初始值为领导者最后的日志条目的索引+1)
matchIndex[] 对于每一台服务器,已知的已经复制到该服务器的最高日志条目的索引(初始值为0,单调递增)【跟随者同步到领导者】

追加条目RPC

被领导者调用:用于日志条目的复制,同时也被当做心跳使用

参数 解释
term 领导者的任期
leaderId 领导者ID 因此跟随者可以对客户端进行重定向(译者注:跟随者根据领导者id把客户端的请求重定向到领导者,比如有时客户端把请求发给了跟随者而不是领导者)
prevLogIndex 紧邻新日志条目之前的那个日志条目的索引
prevLogTerm 紧邻新日志条目之前的那个日志条目的任期
entries[] 需要被保存的日志条目(被当做心跳使用是 则日志条目内容为空;为了提高效率可能一次性发送多个)
leaderCommit 领导者的已知已提交的最高的日志条目的索引

结果

返回值 解释
term 当前任期,对于领导者而言 它会更新自己的任期
success 结果为真,如果跟随者所含有的条目和prevLogIndex以及prevLogTerm匹配上了

RPC

主要分为三种:

  1. 候选者发起选举投票RPC到跟随者或者候选者
  2. 领导者发起日志追加RPC到跟随者
  3. 领导者发起心跳通知RPC到跟随者

选举投票

请求投票:

  1. 跟随者变更为候选人后
  2. 选举超时后

请求参数:

参数 解释
term 候选人的任期号
candidateId 请求选票的候选人的id
lastLogIndex 候选人的最后日志条目的索引值
lastLogTerm 候选人最后日志条目的任期号

返回值:

返回值 解释
term 当前任期号,以便于候选人去更新自己的任期号
voteGranted 候选人赢得了此张选票时为真

投票逻辑:

  1. 在转变为候选人后立即开始选举过程
    1. 自增当前的任期号(currentTerm)
    2. 给自己投票
    3. 重置选举超时计时器
    4. 发送请求投票的RPC给其他所有服务器
  2. 如果接收到大多数服务器的选票,那么就变成领导者
  3. 如果接收到来自新的领导者的附加日志RPC,转变为跟随者
  4. 如果选举过程超时,再次发起一次选举

选举规则:

  1. 如果term < currentTerm,返回false
  2. 如果votedFor为空(还未投票)或者candidateId(已经投票),并且与候选人的日志最后一题日志与自己最后一条日志的任期比较大小,大于自己则直接投票,小于自己则拒绝,等于自己则比较索引大小

日志追加&心跳

发送日志/心跳:

  1. 客户端发起写命令请求
  2. 发送心跳时
  3. 日志匹配失败时

请求参数:

参数 解析
term 当前领导者的任期
leaderId 领导者ID
prevLogIndex 紧邻新日志条目之前的那个日志条目的索引
prevLogTerm 紧邻新日志条目之前的那个日志条目的任期
entries 需要被保存的日志条目
leaderCommit 领导者的已知已提交的最高的日志条目的索引

返回值:

返回值 解释
term 当前任期
success 如果跟随者所含的条目prevLogIndex以及prevLogTerm匹配上了,结果为真

日志追加:

  1. 一旦成为领导人:发送空的附加日志RPC(心跳)给所有的服务器:在一定的空余时间之后不停的重复发送,以阻止跟随者超时
  2. 如果接收到来自客户端的请求:附加条目到本地日志,在条目被应用到状态机后响应客户端
  3. 如果对于一个跟随者,最后日志条目的索引值大于等于nextIndex,那么:发送从nextIndex开始的所有日志条目:
    1. 如果成功,更新相应跟随者的nextIndex和matchIndex
    2. 如果因为日志不一致而失败,减少nextIndex重试
  4. 如果存在一个满足N > commitIndex的N,并且大多数的matchIndex[i] >= N成立,并且log[N].term == currentTerm成立,那么令commitIndex等于这个N

算法原理与证明

五条公理

  1. 选举安全特性:对于一个给定的任期号,最多只会有一个领导人被选举出来。
  2. 领导人只附加原则:领导人绝对不会删除或者覆盖自己的日志,只会增加。
  3. 日志匹配规则:如果两个日志在相同的索引位置的日志条目的任期号,那么我们就认为这个日志从头到这个索引位置之间全部完全相同。
  4. 领导人完全特性:如果某个日志条目在某个任期号中已经被提交,那么这个条目必然出现在更大任期号的所有领导人中。
  5. 状态机安全特性:如果一个领导人已经将给定的索引值位置的日志条目应用到状态机中,那么其他任何的服务器在这个索引位置不会应用一个不同的日志。

选举安全特性


在一个任期内半数以上的票数才能当选,保证每个任期要么0个领导要么1个领导。

日志复制过程的完全匹配

  1. 因为 集群在任意时刻最多有一个leader存在,leader在一个任期内只会在同一个索引处写入一次日志
  2. 又因为 领导者从来不会删除或者覆盖自己的日志,并且日志一旦写入就不允许修改
  3. 所以 只要任期和索引相同,那么在任何节点上的日志也都相同
  4. 因为 跟随者每次只会从与leader的PreLog匹配处追加日志,如果不匹配则nextIndex - 1重试
  5. 所以 由递归的性质可知 一旦跟随者和leader在PreLog处匹配,那么之前的所有日志就都是匹配的
  6. 所以 只要把PreLog之后的日志全部按此次Leader同步RPC的日志顺序覆盖即可保证二者的一致性

安全性

选举限制


  1. 时刻a:S1是任期2的领导人并且向部分节点(S1和S2)复制了2号位置的日志条目,然后宕机。
  2. 时刻b:S5获得了S3、S4(S5的日志与S3和S4的一样新,最新的日志的任期号都是1)和自己的选票赢得了选举,成了3号的日志条目。在新日志条目复制到其他节点之前,S5宕机。
  3. 时刻c:S1重启,并且通过S2、S3、S4和自己的选票赢得了选举,成了4号任期的领导人,并且继续向S3复制2号位置的日志。此时,任期2的日志条目已经在大多数节点上完成了复制。
  4. 时刻d:S1发生故障,S5通过S2、S3的选票再次成为领导人(因为S5最后一条日志目的任期号是3,比S2、S3、S4中任意一个节点上的日志都更加新),任期号为5。然后S5用自己的本地日志也写了其他节点上的日志。

这个例子说明:即使日志条目被半数以上的节点写盘(复制)了,也并不代表它已经被提交(commited)到Raft集群了——因为一旦某条日志被提交,那么它将永远没法被删除或修改。同时说明,领导人无法单纯地依靠之前任期的日志条目信息判断它的提交状态。

因此,针对以上场景,Raft算法对日志提交条件增加了额外限制:要求Leader在当前任期至少有一条日志被提交,即被超过半数的节点写盘。

所以,新上任的领导者在接受客户端写入命令之前需要提交一个no-op(空命令),携带自己任期号的日志复制到大多数集群节点上才能真正的保证选举限制的成立。

状态机安全性

(1)三段论
定义A为上个任期最后一条已提交日志,B为当前任期的Leader

  1. 因为 A必然同步到了集群中的半数以上节点
  2. 又因为 B只有获得集群中半数以上节点的选票才能成为Leader
  3. 所以 B的选民中必然存在拥有A日志的节点
  4. 又因为 选举限制,B成为Leader的前提是比给它投票的所有选民都要新
  5. 所以 B的日志中必然要包含A
  6. 又因为 日志完全匹配规则,如果A被B包含,那么比A小的所以日志都被B包含
  7. 因为 lastApplied <= commitIndex
  8. 又因为 Raft保证已提交日志在所有集群节点上的顺序一致
  9. 所以 应用日志必然在所有节点上顺序一致
  10. 因为 状态机只能按序执行应用日志部分
  11. 得证 状态机在整个集群所有节点上必然最终一致

(2)反证法

  1. 当日志条目L被同步给半数以上节点时,LeaderA会移动commitIndex指针提交日志,此时的日志被提交。
  2. 当Leader崩溃后,由一个新节点成为LeaderB是第一个未包含LeaderA最后提交日志的领导者。
  3. 选举过程中,只有获得半数以上节点任课才能成为Leader,因此至少有一个投票给当前LeaderB的节点中含有已经提交的那条日志。
  4. 那么根据选举限制,节点只会将选票投给至少与自己一样新的节点:
    1. 节点C作为包含LeaderA最后提交日志条目的投票者,如果LeaderB与节点C的最后一条日志的任期号一样大时,节点C的条目数一定大于LeaderB,因为LeaderB是第一个未包含最后一条LeaderA日志的领导者。这与选举限制相矛盾,节点C不会投票给LeaderB。
    2. 如果LeaderB最后一条日志的任期号大于节点C最后一条日志的任期号,那么LeaderB的前任领导中必然包含了LeaderA已经提交的日志(LeaderB是第一个不包含LeaderA已提交日志的领导者这一假设根绝日志匹配特性,LeaderB也必须包含LeaderA最后的已提交日志,这与假设矛盾。
  5. 所以证明:未来所有的领导者必然包含过去领导者已提交的日志,并且日志匹配原则,所有已提交日志的顺序一定是一致的。
  6. 又因为任意节点仅会将已提交日志按顺序应用于自身的状态机,更新lastApplied指针,因此所有节点的状态机都会最终顺序一致。

工程优化

容错性

  1. 领导者崩溃同选举可以解决,但跟随者与候选人崩溃呢?

    基础的raft算法,通过无限次幂等的附加复制rpc进行重试来解决。

  2. 当平均故障时间大于信息交换时间,系统将没有一个稳定的领导者,集群无法工作。

    广播时间 << 心跳超时时间 << 平均故障时间

  3. 客户端如何连接raft的server节点?

    客户端随机选择一个节点去访问,如果是跟随着,跟随着会把自己知道的领导者告知客户端。

  4. 领导者提交后返回时崩溃,客户端重试不就导致相同的命令反复执行了吗?

    客户端为每次请求标记唯一序列号,服务端在状态中维护客户端最新的序列号标记进行幂等处理。

  5. 客户端给领导者set a = 3并进行了提交,此时客户端如果从一个未被同步的节点读取a读不到写后的值。

    每个客户端应该维持一个lastestldx值,每个节点在接受读请求的时候与自己的lastAppied值比较,如果这个值大于自己的lastApplied,则拒绝此次读取请求都会返回这个节点的lastApplied值,客户端将lastestidx更新为此值,保证读取的线性一致。

  6. 如果Leader被孤立,其他跟随者选举出Leader,但是当前Leader还是向外提供脏数据怎么办?

    写入数据由于无法提交,因此会立即失败,但无法防止读到脏数据。
    解决办法是心跳超出半数失败,Leader感知到自己处于半数分区而被孤立进而拒绝提供读写服务。

  7. 当出现网络分区后,被孤立少数集合的节点无法选举,只会不断的增加自己的任期,分区恢复后由于失联的节点任期更大,会强行更新所有节点的任期,触发一次重新选举,而又因为其日志不够新,被孤立的节点不可能成为新的Leader,所以,其状态机是安全的,只是触发了一次重新选举,使得集群有一定时间的不可用。这是完全可以避免的。

    在跟随者成为候选人时,先发送一轮pre-vote rpc来判断自己是否在大多数分区内(是否有半数节点回应自己),如果是则任期加1进行选举。否则的话就不断尝试pre-vote请求。

扩展性

  1. 集群的成员发生变化时,存在某一时刻新老配置共存,进而有选举出两个领导者的可能

    1. 新集群节点在配置变更期间必须获得老配置的多数派投票才能成为Leader
    2. 发送新配置c-new给集群的领导者
    3. 领导者将自己的c-old配置与c-new合并为一个c-old-new配置【123-45】
    4. 然后下发给其他跟随者
      1. 当c-old-new被同步给半数以上节点后,那么此配置已经提交,遵循raft安全性机制
      2. 当leader在将c-old-new写入半数以上跟随者之前崩溃了,那么选举出来的心leader会退回到老的配置,此时重新更新配置即可
    5. 当c-old-new被提交之后,leader会真正的提交c-new配置
      1. 如果提交了半数结点,则c-new真正的被提交
      2. 如果未提交给半数结点时崩溃,则新选举的leader必定包含已提交的c-old-new,那么接着更新配置即可

集群变更过于复杂,因此可以简化这一流程,私用单节点变更机制,即每一次只添加或删除一个节点


  1. 单节点变更时,如果leader挂了,造成一致性问题(丢失已提交日志)如何处理?

    新leader先发一条no-op日志再开始配置变更
    阿里技术:Raft成员变更的工程实践

  2. 新的服务器没有存储任何日志,领导要复制很长一段时间,此时不能参加选举会使得整体不可用。

    新加入的节点设置一个保护期,在此保护期内不会参加选举与日志提交决策,只用来同步日志。

  3. 如果集群的领导不是新集群中的一员,该如何处理?

    在提交c-new时,不降自己算作半数提交,并且在提交后要主动退位。

  4. 被移除的节点如果不及时关闭,会导致选举超时后强行发起投票请求干扰在线集群。

    每个节点如果未到达最小心跳超时时间,则不会进行投票。

性能

1. 生成快照

  • 日志如果无限增长会将本地磁盘打满,这会造成可用性问题。

    定时的将状态机中的状态生成快照,而将之前的日志全部删除,是一种常见的压缩方式。

    1. 将节点的状态保存为LSM Tree,然后存储最后应用日志的索引与任期,以保证日志匹配特性
    2. 为支持集群的配置更新,快照中也要将最后应用的集群配置也当做状态保存下来
    3. 当跟随者需要的日志已经在领导者上面被删除时,需要将快照通过RPC发送过

    注意:由领导人调用以将快照的分块发送给跟随者。领导者总是按顺序发送分块。

参数 解释
term 领导人的任期号
leaderId 领导人的Id,以便于跟随者重定向请求
lastIncluedIndex 快照中包含的最后日志条目的索引值
lastIncludedTerm 快照中包含的最后日志条目的任期号
offset 分块在快照中的字节偏移量
data[] 从偏移量开始的快照分块的原始字节
done 如果这是最后一个
结果 解释
term 当前任期号(currentTerm),便于领导人更新自己
1
2
3
4
5
6
7
8
如果term < currentTerm就立即拒绝
如果是第一个分块(offset = 0)就创建一个新的快照
在指定偏移量写入数据
如果 done 是 false,则继续等待更多的数据 ack
保存快照文件,丢弃具有较小索引的任何现有或部分快照
如果现存的日志条目与快照中最后包含的日志条目具有相同的索引值和任期号,则保留
否则,丢弃整个日志
使用快照重置状态机,并架子啊快照的集群配置
  • 快照何时创建?过于频繁会浪费性能,过于低频则日志占用磁盘的量更大,重建时间更长。

    限定日志文件大小到达某一个阈值后立刻生成快照。

  • 写入快照花费的时间昂贵如何处理?如何保证不影响节点的正常工作?

    使用写时复制技术,状态机的函数式顺序性天然支持。

2. 调节参数

  1. 心跳的随机时间,过快会增加网络负载,过慢则导致感知领导者崩溃的时间更长
  2. 选举的随机时间,如果大部分跟随者同时变为候选人则会导致选票被瓜分

3. 流批结合

首先可以做的就是batch,在很多情况下面,使用batch能明显提高性能,譬如对于RocksDB的写入来说,我们通过不会每次写入一个值,而是会用一个WriteBatch缓存一批修改,然后再整个写入。对于Raft来说,Leader可以一次手机多个requests,然后一批发送给Follower。当然,我们也需要有一个最大发送size来限制每次最多可以发送多少数据。
如果只是用batch,Leader还是需要等待Follower返回才能继续后面的流程,我们这里还可以使用Pipeline来进行加速。Leader会维护一个NextIndex的变量来表示下一个给Follower发送的log位置,通常情况下,只要Leader跟Follower建立起了连接,我们都会认为网络是稳定互通的。所以当Leader给Follower发送了一批log之后,它可以直接更新NextIndex,并且立刻发送后面的log,不需要等待Follower的返回。如果网络出现了错误,或者Follower返回一些错误,Leader就需要重新调整NextIndex,然后重新发送log。

4. 并行追加

对于上面提到的一次request简易Raft流程来说,Leader可以先并行的将log发送给Followers,然后再将log append。为什么可以这么做,主要是因为在Raft里面,如果一个log被大多数的结点append,我们就可以认为这个log是被committed了,所以即使Leader再给Follower发送log之后,自己append log失败panic了,只要N / 2 + 1个Follower能接收到这个log并成功append,我们仍可以认为这个log是被committed了,被committed了,被committed的log后续就一定能被成功apply。
原因:主要是因为append log会涉及到落盘,有开销,所以我们完全可以在Leader落盘的同时让Follower也尽快的收到log是被committed了,这样系统就有丢失数据的风险了。
这里我们还需要注意,虽然 Leader 能在 append log 之前给 Follower 发 log,但是 Follower 却不能在 append log 之前告诉 Leader 已经成功 append 这个 log。如果 Follower 提前告诉 Leader 说已经成功 append,但实际后面 append log 的时候失败了,Leader 仍然会认为这个 log 是被 committed 了,这样系统就有丢失数据的风险了。

5. 异步应用

当一个log被大部分节点append之后,我们就可以认为这个log被committed了,被committed的log在什么时候apply都不会再影响数据的一致性。所以当一个log被committed之后,我么可以用另一个线程去异步的apply这个log。
所以整个Raft流程就可以变成:

  1. Leader接受一个client发送的request。
  2. Leader将对应的log发送给其他Follower并本地append。
  3. Leader继续接受其他client的requests,持续进行步骤2。
  4. Leader发现log已经被committed,在另一个线程apply。
  5. Leader异步apply log之后,返回结果给对应的client。

使用 asychronous apply的好处在于我们现在可以安全的并行处理append log和apply log,虽然对于一个client来说,它的一次request仍然要走完完整的Raft流程,但对于多个clients来说,整体的并发和吞吐量是上去了。