基本概念

三个角色

  • Leader:接受客户端请求,并向Follower同步请求日志,当日志同步到大多数节点上后告诉Follower提交日志。
  • Follower:接受并持久化Leader同步的日志,在Leader告之日志可以提交之后,提交日志。
  • Candidate:Leader选举过程中的临时角色。

优化角色

  • Learner:同Follower,但不能投票
  • Witness:能投票,不能当Leader,不用同步snapshot,为了保证log写扩散开

一图流

Source file: mmd/raft-v1.mmd:

flowchart LR Follower -->|超时参与选举| Candidate Witness -->|超时参与投票| Candidate Follower --- |心跳|Leader Leader --> |转移让出权限|Witness Candidate --> |超时重新选举| Candidate Candidate --> |收到大多数投票成为|Leader Leader --> |发现更高的term|Follower Candidate --> |发现新的term
发现新的leader|Follower Leader --- |读写扩散|Learner Learner --- |读写扩散|Learner Learner <--> |角色转换/读写扩散|Follower

learner和follower互相转换较为复杂,不如下线重新上线这种添加更干净一些

可以看etcd 的那个learner在角色变换中的处理,太恶心了

Leader选举(Leader Election)

Source file: mmd/raft-RequestVote.mmd:

sequenceDiagram leader->>+follower(s): HeartBeat leader->>+follower1: HeartBeat destroy leader follower(s)-->>+leader: ok follower1-->>+leader: ok Note over leader: 挂了 Note over follower1,follower(s): 心跳超时 create participant candidate1 follower1->>+candidate1: 角色转换 destroy follower1 candidate1-->>+follower1: ok create participant candidate(s) follower(s)->>+candidate(s): 角色转换 destroy follower(s) candidate(s)-->>+follower(s): ok alt 正常流程,先来后到 candidate1->>+candidate(s): PreVote candidate(s)-->>+candidate1: ok candidate1->>+candidate(s): RequestVote:
{term,id,
lastlogterm,lastlogindex} Note over candidate(s): 检查term>currentTerm Note over candidate(s): 检查log的term是不是最新的 candidate(s)-->>+candidate1: Response:
{term,voteGranted} else 如果已经有节点成为了leader candidate1->>+candidate1: 收到AppendEntity,判断term alt term不大于当前term Note over candidate1: 拒绝,继续选举 else term大于当前term Note over candidate1: 成为follower end else 选举超时 candidate1->>+candidate1: 随机选择超时时间(2RTT),term++,重新发起选举 end create participant newLeader as leader candidate1->>+newLeader: 角色转换 create participant newFollower as follower(s) candidate(s)->>+newFollower: 角色转换 destroy candidate1 newLeader-->>+candidate1: ok destroy candidate(s) newFollower-->>+candidate(s): ok newLeader->>+newFollower: HeartBeat newFollower-->>+newLeader: ok

Raft 使用心跳(heartbeat)触发Leader选举。当服务器启动时,初始化为Follower。Leader向所有Followers周期性发送heartbeat。如果Follower在选举超时时间内没有收到Leader的heartbeat,就会等待一段随机的时间后发起一次Leader选举。

Follower将其当前term加一然后转换为Candidate。它首先给自己投票并且给集群中的其他服务器发送 RequestVote RPC

  • 赢得了多数的选票,成功选举为Leader;
  • 收到了Leader的消息,表示有其它服务器已经抢先当选了Leader;
  • 没有服务器赢得多数的选票,Leader选举失败,等待选举时间超时后发起下一次选举。

Q: 时间过长?

两个超时设定

  • 心跳超时:Leader周期性的向Follower发送心跳(0.5ms – 20ms),如果Follower在选举超时时间内没有收到心跳,则触发选举。
  • 选举超时:如果存在两个或者多个节点选主,都没有拿到大多数节点的应答,需要重新选举,Raft引入随机的选举超时时间(150ms – 300ms),避免选主活锁。

心跳超时要小于选举超时一个量级,Leader才能够发送稳定的心跳消息来阻止Follower开始进入选举状态。可以设置:心跳超时=peers max RTT(round-trip time),选举超时=10 * 心跳超时

主动选举

  • leader由于故障/负载高/均衡等原因主动退出leader,通知其他Follower开始ElectNow ,走正常选举流程
  • leader发生孤岛,心跳全断,主动退出leader,拒绝写入

日志同步(Log Replication)

multi-paxos允许日志乱序提交,也就是说允许日志中存在空洞。

raft协议增加了日志顺序性的保证,每个节点只能顺序的commit日志。顺序性日志简化了一致性协议复杂程度,当然在性能上也有了更多的限制,为此,工程上又有了很多对应的优化,如:batch、pipline、leader stickness等等。

约定

  • Raft要求所有的日志不允许出现空洞。
  • Raft的日志都是顺序提交的,不允许乱序提交?
  • Leader不会覆盖和删除自己的日志,只会Append。
  • Follower可能会截断自己的日志。存在脏数据的情况。
  • Committed的日志最终肯定会被Apply。
  • Snapshot中的数据一定是Applied,那么肯定是Committed的。
  • commitIndex、lastApplied不会被所有节点持久化。
  • Leader通过提交一条Noop日志来确定commitIndex。
  • 每个节点重启之后,先加载上一个Snapshot,再加入RAFT复制组

Leader选出后,就开始接收客户端的请求。Leader把请求作为日志条目(Log entries)加入到它的日志中,然后并行的向其他服务器发起 AppendEntries RPC 复制日志条目。当这条日志被复制到大多数服务器上,Leader将这条日志应用到它的状态机并向客户端返回

Followers可能没有成功的复制日志,Leader会无限的重试 AppendEntries RPC直到所有的Followers最终存储了所有的日志条目。

Q:掉线永远复制不成功

Raft日志同步保证如下两点:

  • 如果不同日志中的两个条目有着相同的索引和任期号,则它们所存储的命令是相同的。
  • 如果不同日志中的两个条目有着相同的索引和任期号,则它们之前的所有条目都是完全一样的。

第一条特性源于Leader在一个term内在给定的一个log index最多创建一条日志条目,同时该条目在日志中的位置也从来不会改变。

第二条特性源于 AppendEntries 的一个简单的一致性检查。当发送一个 AppendEntries RPC 时,Leader会把新日志条目紧接着之前的条目的log index和term都包含在里面。如果Follower没有在它的日志中找到log index和term都相同的日志,它就会拒绝新的日志条目。

Q:pipline的优化

multi-raft复用心跳/通信socket

Q:leader切换导致多个不同

Leader通过强制Followers复制它的日志来处理日志的不一致,Followers上的不一致的日志会被Leader的日志覆盖。

Leader为了使Followers的日志同自己的一致,Leader需要找到Followers同它的日志一致的地方,然后覆盖Followers在该位置之后的条目。

Leader会从后往前试,每次AppendEntries失败后尝试前一个日志条目,直到成功找到每个Follower的日志一致位点,然后向后逐条覆盖Followers在该位置之后的条目。

Source file: mmd/raft-AppendEntries.mmd:

sequenceDiagram client->>+leader: OP leader->>+follower: AppendEntries:
{term,id,
prelogterm,prelogindex,
entries[],commitindex} Note over follower: 检查term>currentTerm Note over follower: 检查上一个log的term对应的index是否存在
如果存住但term不同,丢弃,不存在直接失败 Note over follower: 比较/更新commitIndex, alt commitindex存在丢失 follower-->>+leader: Response:
{term,failed} Note over leader: 回退commitindex,
重新发起AppendEntries
低效,加个接口对齐日志,把多个回退合并到一起 else follower-->>+leader: Response:
{term,success} end Note over leader: 只要大多数OK就可以返回
更新commitindex Note over leader,follower: 更新applyindex
commitindex>=applyindex
apply可以是异步 leader-->>+client: OP ok

流程

  • Leader 先将该命令追加到自己的日志中;先持久化再传播

  • Leader 并行地向其它节点发送 AppendEntries RPC,等待响应;

  • 收到超过半数节点的响应,则认为新的日志记录是被提交的:
    • Leader 将命令传给自己的状态机,然后向客户端返回响应
    • 此外,一旦 Leader 知道一条记录被提交了,将在后续的 AppendEntries RPC 中通知已经提交记录的 Followers
    • Follower 将已提交的命令传给自己的状态机
  • 如果 Follower 宕机/超时:Leader 将反复尝试发送 RPC;

  • 性能优化:Leader 不必等待每个 Follower 做出响应,只需要超过半数的成功响应(确保日志记录已经存储在超过半数的节点上)——一个很慢的节点不会使系统变慢,因为 Leader 不必等他

AppendEntries 一致性检查

Raft 通过 AppendEntries RPC 来检测这两个属性。

  • 对于每个 AppendEntries RPC 包含新日志记录之前那条记录的索引(prevLogIndex)和任期(prevLogTerm);
  • Follower 检查自己的 index 和 term 是否与 prevLogIndexprevLogTerm 匹配,匹配则接收该记录;否则拒绝;
  • Leader 通过 nextIndex 来修复日志。当 AppendEntries RPC 一致性检查失败,递减 nextIndex 并重试。

几个需要讨论的问题

和底层引擎的同步问题,比如rocksdb,如果raft刷盘和rocksdb没有配合,可能出现上层日志删了rocksdb memtable没刷的场景,丢数据

就相当于每次raft apply flush到磁盘,引擎层也要flush一把,这对于性能是比较冲击的

如何做这里的异步?区分apply/flush? flush的时候调用一把rocksdb的flush,apply就正常调用write

Log Compaction

崩溃恢复(Crash Recovery)

成员变更(Membership Change)

Source file: mmd/raft-Joint-Consensus.mmd:

sequenceDiagram client->>+leader: ChangeConfig Note over leader: 需要保证变更是唯一的 Note over leader: 当前配置C_old
新配置 C_new leader-->>+follower: ChangeConfig C_{old,new} opt leader 挂了 Note over follower: 当前配置 C_old
不会出现分裂情况 end follower-->>+leader: ok opt leader 挂了 Note over follower: 当前配置 C_{old,new},C_old
不会出现分裂情况 end leader-->>+follower: ChangeConfig C_new opt leader 挂了 Note over follower: 当前配置 C_{old,new},C_new
不会出现分裂情况 end alt follower 不在 C_new follower-->>+leader: ok follower-->>+follower: shutdown else follower-->>+leader: ok end

需要考虑learner/witness变更的复杂度

Raft成员变更的工程实践

新成员先加入再同步数据还是先同步数据再加入?

  优点 缺点
新成员先加入再同步数据 简单并且快速,能加入还不存在的成员 可能降低服务的可用性
新成员先同步数据再加入 不会影响服务可用性 复杂并且较慢,不能加入还不存在的成员

新成员先加入再同步数据,成员变更可以立即完成,并且因为只要大多数成员同意即可加入,甚至可以加入还不存在的成员,加入后再慢慢同步数据。

但在数据同步完成之前新成员无法服务,但新成员的加入可能让多数派集合增大,而新成员暂时又无法服务,此时如果有成员发生Failover,

很可能导致无法满足多数成员存活的条件,让服务不可用。因此新成员先加入再同步数据,简化了成员变更,但可能降低服务的可用性。

新成员先同步数据再加入,成员变更需要后台异步进行,先将新成员作为Learner角色加入,只能同步数据,不具有投票权,不会增加多数派集合,

等数据同步完成后再让新成员正式加入,正式加入后可立即开始工作,不影响服务可用性。因此新成员先同步数据再加入,不影响服务的可用性,

但成员变更流程复杂,并且因为要先给新成员同步数据,不能加入还不存在的成员。

优化点

SetPeer

Raft只能在多数节点存活的情况下才可以正常工作,在实际环境中可能会存在多数节点故障只存活一个节点的情况,这个时候需要提供服务并修复数据。因为已经不能达到多数,不能写入数据,也不能做正常的节点变更。Raft库需要提供一个SetPeer的接口,设置每个节点的复制组节点列表,便于故障恢复。

假设只有一个节点S1存活的情况下,SetPeer设置节点列表为{S1},这样形成一个只有S1的节点列表,让S1继续提供读写服务,后续再调度其他节点进行AddPeer。通过强制修改节点列表,可以实现最大可用模式。

Noop

在分布式系统中,对于一个请求都有三种返回结果:成功、失败、超时。

在failover时,新的Leader由于没有持久化commitIndex,所以并不清楚当前日志的commitIndex在哪,也即不清楚log entry是committed还是uncommitted状态。通常在成为新Leader时提交一条空的log entry来提交之前所有的entry。

RAFT中增加了一个约束:对于之前Term的未Committed数据,修复到多数节点,且在新的Term下至少有一条新的Log Entry被复制或修复到多数节点之后,才能认为之前未Committed的Log Entry转为Committed。即最大化commit原则:Leader在当选后立即追加一条Noop并同步到多数节点,实现之前Term uncommitted的entry隐式commit。

  • 保证commit的数据不会丢。
  • 保证不会读到uncommitted的数据。

MultiRaft

元数据相比数据来说整体数据量要小的多,通常单台机器就可以存储。我们也通常借助于Etcd等使用单个Raft Group来进行元数据的复制和管理。但是单个Raft Group,存在以下两点弊端:

  • 集群的存储容量受限于单机存储容量(排除使用分布式存储)。
  • 集群的性能受限于单机性能(读写都由Leader处理)。

对于集群元数据来说使用单个Raft Group是够了,但是如果想让Raft用于数据的复制,那么必须得使用MultiRaft,也即有多个复制组,类似于Ceph的PG,每个PG、Raft Group是一个复制组。

多个复制组共用心跳

还需解决的问题

  • 负载均衡:可以通过Transfer Leadership的功能保持每个物理节点上Leader个数大致相当。
  • 链接复用:一个物理节点上的所有Raft Group复用链接。会有心跳合并、Lease共用等。
  • 中心节点:用来管理集群包括MultiRaft,使用单个Raft Group做高可靠,类似Ceph Mon。
  • 均匀性问题,需要leader打散分布,需要leader的全局视角,甚至需要优先级信息,来避免选举的不均衡

性能优化

Batch

  • Batch写入落盘:对每一条Log Entry都进行fsync刷盘效率会比较低,可以在内存中缓存多个Log Entry Batch写入磁盘,提高吞吐量,类似于Ceph FileStore批量的写Journal。
  • Batch网络发送:Leader也可以一次性收集多个Log Entry,批量的发送给Follower。
  • Batch Apply:批量的Apply已经commit的Log到业务状态机。

Batch并不会对请求做延迟来达到批量处理的目的,对单个请求的延迟没有影响。

PipeLine

Raft依赖Leader来保持集群的数据一致性,数据的复制都是从Leader到Follower。一个简单的写入流程如下,性能是完全不行的:

  1. Leader收到Client请求。
  2. Leader将数据Append到自己的Log。
  3. Leader将数据发送给其他的Follower。
  4. Leader等待Follower ACK,大多数节点提交了Log,则Apply。
  5. Leader返回Client结果。
  6. 重复步骤1。

Leader跟其他节点之间的Log同步是串行Batch的方式,如果单纯使用Batch,每个Batch发送之后Leader依旧需要等待该Batch同步完成之后才能继续发送下一个Batch,这样会导致较长的延迟。可以通过Leader跟其他节点之间的PipeLine复制来改进,会有效降低延迟。

Parallel

顺序提交

将Leader Append持久化日志和向Followers发送日志并行处理。Leader只需要在内存中保存未Committed的Log Entry,在多数节点已经应答的情况下,无需等待Leader本地IO完成,直接将内存中的Log Entry直接Apply给状态机即可。

乱序提交

Out-of-Order参考:PolarFS: ParallelRaftBlueStore源码分析之事物状态机:IO保序

乱序提交要满足以下两点

  1. Log Entry之间不存在覆盖写,则可以乱序Commit、Apply。
  2. Log Entry之间存在覆盖写,不可以乱序,只能顺序Commit、Apply。

上层不同的应用场景限制了提交的方式

  • 对IO保序要求比较严格,那么只能使用顺序提交
  • 对IO保序没有要求,可以IO乱序完成,那么可顺序提交、乱序提交都可以使用

不同的分布式存储需要的提交方式

  • 分布式数据库(乱序提交):其上层可串行化的事物就可以保证数据一致性,可以容忍底层IO乱序完成的情况。
  • 分布式KV存储(乱序提交):多个KV之间(排除上层应用语义)本身并无相关性,也不需要IO保序,可以容忍IO乱序。
  • 分布式对象存储(乱序提交):本来就不保证同一对象的并发写入一致性,那么底层也就没必要顺序接收顺序完成IO,天然容忍IO乱序。
  • 分布式块存储(顺序提交):由于在块存储上可以构建不同的应用,而不同的应用对IO保序要求也不一样,所以为了通用性只能顺序提交。
  • 分布式文件存储(顺序提交):由于可以基于文件存储(Posix等接口)构建不同的应用,而不同的应用对IO保序要求也不一样,所以为了通用性只能顺序提交,当然特定场景下可以乱序提交,比如PolarFS适用于数据库。
  • 分布式存储具体能否乱序提交最终依赖于应用语义能否容忍存储IO乱序完成

简单分析

单个Raft Group只能顺序提交日志,多个Raft Group之间虽然可以做到并行提交日志,但是受限于上层应用(数据库等)的跨Group分布式事物,可能导致其他不相关的分布式事物不能并行提交,只能顺序提交。

上层应用比如数据库的分布式事物是跨Group(A、B、C)的,Group A被阻塞了,分布式事物不能提交, 那么所有的参数者Group(B、C)就不能解锁,进而不能提交其他不相关的分布式事物,从而引发多个Group的链式反应。

Raft不适用于多连接的高并发环境中,Leader和Follower维持多条连接的情况在生产环境也很常见,单条连接是有序的,多条连接并不能保证有序,有可能发送次序靠后的Log Entry先于发送次序靠前的Log Entry达到Follower,但是Raft规定Follower必须按次序接受Log Entry,就意味着即使发送次序靠后的Log Entry已经写入磁盘了(实际上不能落盘得等之前的Log Entry达到)也必须等到前面所有缺失的Log Entry达到后才能返回。如果这些Log Entry是业务逻辑顺序无关的,那么等待之前未到达的Log Entry将会增加整体的延迟。

其实Raft的日志复制和Ceph基于PG Log的复制一样,都是顺序提交的,虽然可以通过Batch、PipeLine优化,但是在并发量大的情况下延迟和吞吐量仍然上不去。

具体Raft乱序提交的实现可参考:PolarFS: ParallelRaft

Asynchronous

我们知道被committed的日志肯定是可以被Apply的,在什么时候Apply都不会影响数据的一致性。所以在Log Entry被committed之后,可以异步的去Apply到业务状态机,这样就可以并行的Append Log和Apply Log了,提升系统的吞吐量。

其实就和Ceph BlueStore的kv_sync_threadkv_finalize_thread一样,每个线程都有其队列。kv_sync_thread去写入元数据到RocksDB(请求到此已经成功),kv_finalize_thread去异步的回调上层应用通知请求成功。

ReadIndex

Raft的写入流程时走一遍Raft,保证了数据的一致性。为了实现线性一致性读,读流程也可以走一遍Raft,但是会产生磁盘IO,性能不好。Leader具有最新的数据,理论上Leader可以读取到最新的数据。但是在网络分区的情况下,无法确定当前的Leader是不是真的Leader,有可能当前Leader与其他节点发生了网络分区,其他节点形成了一个Group选举了新的Leader并更新了一些数据,此时如果Client还从老的Leader读取数据,便会产生Stale Read

读流程走一遍Raft、ReadIndex、Lease Read都是用来实现线性一致性读,避免Stale Read。

  1. 当收到读请求时,Leader先检查自己是否在当前Term commit过entry,没有否则直接返回。
  2. 然后Leader将自己当前的commitIndex记录到变量ReadIndex里面。
  3. 向Follower发起Heartbeat,收到大多数ACK说明自己还是Leader。
  4. Leader等待 applyIndex >= ReadIndex,就可以提供线性一致性读。
  5. 返回给状态机,执行读操作返回结果给Client。

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

注意:在新Leader刚刚选举出来Noop的Entry还没有提交成功之前,是不能够处理读请求的,可以处理写请求。也即需要步骤1来防止Stale Read。

原因在新Leader刚刚选举出来Noop的Entry还没有提交成功之前,这时候的commitIndex并不能够保证是当前整个系统最新的commitIndex。考虑这个情况:w1->w2->w3->noop| commitIndex在w1;w2、w3对w1有更新;应该读的值是w3因为commitIndex之后可能还有Log Entry对该值更新,只要w1Apply到业务状态机就可以满足applyIndex >= ReadIndex,此时就可以返回w1的值,但是此时w2、w3还未Apply到业务状态机,就没法返回w3,就会产生Stale Read。必须等到Noop执行完才可以执行读,才可以避免Stale Read。

Follower Read

如果是热点数据么可以通过提供Follower Read来减轻Leader的读压力,可用非常方便的通过ReadIndex实现。

  1. Follower向Leader请求ReadIndex。
  2. Leader执行完ReadIndex章节的前4步(用来确定Leader是真正的Leader)。
  3. Leader返回commitIndex给Follower作为ReadIndex。
  4. Follower等待 applyIndex >= ReadIndex,就可以提供线性一致性读。
  5. 返回给状态机,执行读操作返回结果给Client。

Quorum read

Raft 的读虽然可以发送给 follower,但还是要从 leader 获取 readIndexleader 的压力会很大。使用 quorum read 可以利用 follower 读,减小 leader 的压力, 提高读的吞吐量和性能: Improving Read Scalability in Raft-like consensus protocols

Source file: mmd/raft-read-index.mmd:

sequenceDiagram client->>+leader: Read Note over leader: 当前term是否有commit
需要检查自己是leader
得等Noop落地 leader-->>+follower: Heartbeat follower-->>+leader: ok alt applyIndex >= ReadIndex leader-->>+client: Read ok else 需要等待apply leader-->>+leader: apply 直到 applyIndex >= ReadIndex(commit index) end opt follower read follower->>+leader: ReadIndex leader-->>+follower: ok Note over follower: 同Leader
满足applyIndex >= ReadIndex(commit index)
就可以提供读 client->>+follower: Read follower-->>+client: ok end opt quorum read client->>+follower: 发起多个Read alt 当前commit index已经更新 follower-->>+client: Read ok else 需要等待commit index更新 leader-->>+follower: AppendEntries follower-->>+leader: ok follower-->>+client: ok end Note over client: 比较各个value,选最大的版本
有点像backup request/ensurecode?
避免readindex,通过多试几次来容错 end

Lease Read

Lease Read相比ReadIndex更近了一步,不仅省去了Log的磁盘开销,还省去了Heartbeat的网络开销,提升读的性能。

基本思路:Leader获取一个比election timeout小的租期(Lease),因为Follower至少在election timeout时间之后才会发送选举,那么在Lease内是不会进行Leader选举,就可以跳过ReadIndex心跳的环节,直接从Leader上读取。但是Lease Read的正确性是和时间挂钩的,如果时钟漂移比较严重,那么Lease Read就会产生问题。

  1. Leader定时发送(心跳超时时间)Heartbeat给Follower, 并记录时间点start。
  2. 如果大多数回应,那么新的Lease到期时间为start + Lease(<election timeout)
  3. Leader确认自己是Leader后,等待applyIndex >= ReadIndex,就可以提供线性一致性读。
  4. 返回给状态机,执行读操作返回结果给Client。

Source file: mmd/raft-lease-read.mmd:

sequenceDiagram client->>+leader: Read Note over leader: 当前term是否有commit
需要检查自己是leader
得等Noop落地 loop heartbeat timeout leader-->>+follower: Heartbeat follower-->>+leader: ok end Note over leader: 根据心跳响应生成lease alt lease没过期 alt applyIndex >= ReadIndex leader-->>+client: Read ok else 需要等待apply leader-->>+leader: apply 直到 applyIndex >= ReadIndex(commit index) end else lease过期 loop heartbeat timeout leader-->>+follower: Heartbeat follower-->>+leader: ok end Note over leader: 根据心跳响应生成lease end leader-->>+client: Read ok

Double Write-Store

我们知道Raft把数据Append到自己的Log的同时发送请求给Follower,多数回复ACK就认为commit,就可以Apply到业务状态机了。如果业务状态机(分布式KV、分布式对象存储等)也把数据持久化存储,那么数据便Double Write-Store,集群中存在两份相同的数据,如果是三副本,那么就会有6份。

接下来主要思考元数据、数据做的一点点优化。

通常的一个优化方式就是先把数据写入Journal(环形队列、大小固定、空间连续、使用3D XPoint、NVME),然后再把数据写入内存即可返回,最后异步的把数据刷入HDD(最好带有NVME缓存)。

元数据

元数据通常使用分布式KV存储,数据量比较小,Double Write-Store影响不是很大,即使存储两份也不会浪费太多空间,而且以下改进也相比数据方面的改进更容易实现。

可以撸一个简单的Append-Only的单机存储引擎WAL来替代RocksDB作为Raft Log的存储引擎,Apply业务状态机层的存储引擎可以使用RocksDB,但是可以关闭RocksDB的WAL,因为数据已经存储在Append-Only的Raft Log了,细节仍需考虑。

数据

这里的数据通常指非结构化数据:图片、文档、音视频等。非结构化数据通常使用分布式对象存储、块存储、文件存储等来存储,由于数据量比较大,Double Store是不可接受的,大致有两种思路去优化:

  1. Raft Log、User Data分开存:Raft Log只存op-cmd,不存data,类似于Ceph的PG Log。
  2. Raft Log、User Data一起存:作为同一份数据来存储。Bitcask模型天然Append更容易实现。
  我们想要的raft能力 braft etcd
类型 Multi-Raft Multi-Raft Single-Raft
请求/日志合并 支持 不支持 用户实现
日志落盘 Batch+groupcommit Batch+groupcommit 用户实现
日志匹配 O(term) O(index) O(index)
Snapshot类型 File list File list 用户实现
手动snapshot 支持 支持 不支持
PreVote 支持 支持 支持
Aggressive Vote 支持 不支持 不支持
Leader lease 支持 支持 不支持
Membership Change single any single
Reset Membership 支持 支持 不支持
Transfer leadership 支持 不支持
主动发起选举 支持 不支持
Learner 支持可级联 不支持 支持,可转化成follower
Witness 支持

参考资料

  1. https://shimingyah.github.io/2020/03/浅谈分布式存储之raft/#
  2. raft论文中文翻译 https://github.com/maemual/raft-zh_cn/blob/master/raft-zh_cn.md
  3. https://github.com/hashicorp/raft
  4. http://www.philipotoole.com/
  5. https://github.com/otoolep/hraftd
  6. https://github.com/RaftLib/RaftLib/wiki/Getting-Started
  7. https://github.com/feixiao/Distributed-Systems
  8. https://github.com/happyfish100/fastdfs
  9. http://www.cs.utexas.edu/~vijay/papers/pebblesdb-sosp17-slides.pdf
  10. http://www.philipotoole.com/building-a-distributed-key-value-store-using-raft/
  11. https://www.nosuchfield.com/2019/01/26/Distributed-systems-for-fun-and-profit-study-notes/
  12. braft https://github.com/baidu/braft/blob/master/docs/cn/raft_protocol.md
  13. etcd 源码解析 https://jiajunhuang.com/articles/2018_11_20-etcd_source_code_analysis_raftexample.md.html
  14. https://zhuanlan.zhihu.com/p/348680213
  15. https://youjiali1995.github.io/raft/raft-todo/
  16. leader lease https://www.jianshu.com/p/072380e12657
  17. https://zhuanlan.zhihu.com/p/359206808
  18. https://github.com/eBay/NuRaft/blob/master/docs