Raft是一种协议,节点群集可以使用该协议维护复制的状态机。通过使用复制的日志,状态机保持同步。有关Raft的更多详细信息,请参见Diego Ongaro和John Ousterhout撰写的“寻找可理解的共识算法”(https://raft.github.io/raft.pdf)。(这个论文在之前的博客翻译了一遍,基本上讲了raft的原理)
该Raft库稳定且功能齐全。截至2016年,它是**生产中使用最广泛的Raft库,每天服务数万个集群。它为etcd,Kubernetes,Docker Swarm,Cloud Foundry Diego,CockroachDB,TiDB,Project Calico,Flannel,Hyperledger等分布式系统提供支持。
大多数Raft实现都采用整体设计,包括存储处理,消息传递序列化和网络传输。相反,此库仅通过实现核心Raft算法来遵循简约的设计理念。这种极简主义具有灵活性,确定性和性能。为了使代码库更小并提供灵活性,该库仅实现Raft算法。网络和磁盘IO都留给用户。图书馆用户必须实现自己的传输层,以使信息在Raft对等点之间通过网络传递。同样,用户必须实现自己的存储层才能保留Raft日志和状态。(也就是在etcd中用到的raft模块只用到了基本算法选举,关于网络传输和存储都没用用到!)
为了轻松测试Raft库,其行为应具有确定性。为了实现这种确定性,库将Raft建模为状态机。状态机将“消息”作为输入。消息可以是本地计时器更新,也可以是从远程对等方发送的网络消息。状态机的输出是一个三元组的{{] Messages,[] LogEntries,NextState}`,它由一系列Messages,log条目和Raft状态更改组成。对于状态相同的状态机,相同的状态机输入应始终生成相同的状态机输出。
该Raft实现是Raft协议的全功能实现。功能包括:
-领导者选举
-日志复制
-日志压缩
-成员资格更改
-领导者转移扩展
-领导者和关注者都可以进行有效的线性化只读查询
-领导者使用仲裁进行仲裁,并在处理只读查询之前绕过Raft日志
-关注者要求领导者在处理只读查询之前获得安全的读取索引
-领导者和关注者均可以更有效地使用基于租约的线性化只读查询
-领导者绕过Raft日志并在本地处理只读查询
-跟随者要求领导者获取一个安全的读索引,然后处理只读查询
-这种方法依赖于Raft组中所有计算机的时钟
该Raft实现还包括一些可选的增强功能:
-优化流水线以减少日志复制延迟
-日志复制的流控制
-批处理Raft消息以减少同步的网络I / O调用
-批处理日志条目以减少磁盘同步的I / O
-写入领导者磁盘并行
-从跟随者到领导者的内部建议重定向
-领导者失去仲裁时自动下台
-防止仲裁丢失时不受限制的日志增长
Raft中的主要对象是节点。
使用raft.StartNode从头开始启动Node或使用raft.RestartNode从某个初始状态启动Node。启动三节点集群
storage := raft.NewMemoryStorage()
c := &raft.Config{
ID: 0x01,
ElectionTick: 10,
HeartbeatTick: 1,
Storage: storage,
MaxSizePerMsg: 4096,
MaxInflightMsgs: 256,
}
// Set peer list to the other nodes in the cluster.
// Note that they need to be started separately as well.
n := raft.StartNode(c, []raft.Peer{{ID: 0x02}, {ID: 0x03}})
启动一个单节点集群,如下所示:
// Create storage and config as shown above.
// Set peer list to itself, so this node can become the leader of this single-node cluster.
peers := []raft.Peer{{ID: 0x01}}
n := raft.StartNode(c, peers)
要允许新节点加入该群集,请勿传入任何对等节点。首先,通过在集群内部任何现有节点上调用ProposeConfChange将节点添加到现有集群中。然后,以一个空的对等列表启动该节点,如下所示:
// Create storage and config as shown above.
n := raft.StartNode(c, nil)
要从先前的状态重启节点:
storage := raft.NewMemoryStorage()
// Recover the in-memory storage from persistent snapshot, state and entries.
storage.ApplySnapshot(snapshot)
storage.SetHardState(state)
storage.Append(entries)
c := &raft.Config{
ID: 0x01,
ElectionTick: 10,
HeartbeatTick: 1,
Storage: storage,
MaxSizePerMsg: 4096,
MaxInflightMsgs: 256,
}
// Restart raft without peer information.
// Peer information is already included in the storage.
n := raft.RestartNode(c)
创建节点后,用户将承担以下责任:首先,从Node.Ready()通道读取并处理其包含的更新。这些步骤可以并行执行,除非步骤2中另有说明。1.将条目,HardState和快照按顺序写入持久性存储,即先写入条目,如果不为空,则写入HardState和快照。如果持久性存储支持原子写入,则可以将所有原子写入在一起。请注意,在编写索引为i的条目时,必须丢弃索引> = i的所有先前存在的条目。 2.将所有消息发送到“收件人”字段中命名的节点。重要的是,在将最新的HardState持久化到磁盘上之前,以及任何先前的Ready批处理写入的所有条目之前,不要发送任何消息(可以在持久化同一批处理的条目时发送消息)。为了减少I / O延迟,可以应用优化以使引导者与其跟随者并行地写入磁盘(如Raft论文中的10.2.1节所述)。如果任何消息的类型为MsgSnap,请在发送消息后调用Node.ReportSnapshot()(这些消息可能很大)。注意:编组消息不是线程安全的。重要的是要确保在编组时不保留任何新条目。实现此目的最简单的方法是直接在主Raft循环内序列化消息。 3.将快照(如果有)和CommittedEntries应用于状态机。如果任何已提交的Entry的类型为EntryConfChange,则调用Node.ApplyConfChange()将其应用于节点。此时可以通过在调用ApplyConfChange之前将NodeID字段设置为零来取消配置更改(但是ApplyConfChange必须以一种或另一种方式调用,并且取消决定必须仅基于状态机而不是外部信息,例如观察到的节点运行状况)。 4.调用Node.Advance()表示已准备好进行下一批更新。尽管必须按照Ready返回的顺序处理所有更新,但是可以在步骤1之后的任何时间完成此操作。其次,必须通过存储接口的实现使所有持久日志条目可用。可以使用提供的MemoryStorage类型(如果在重新启动时重新填充其状态),或者可以提供自定义的磁盘支持的实现。第三,从另一个节点收到消息后,将其传递给Node。
func recvRaftRPC(ctx context.Context, m raftpb.Message) {
n.Step(ctx, m)
}
最后,以固定的时间间隔(可能通过“ time.Ticker”)调用“ Node.Tick()”。Raft有两个重要的超时:心跳和选举超时。但是,在Raft包装内部,时间用抽象的“滴答声”表示。总的状态机处理循环如下所示:
for {
select {
case <-s.Ticker:
n.Tick()
case rd := <-s.Node.Ready():
saveToStorage(rd.HardState, rd.Entries, rd.Snapshot)
send(rd.Messages)
if !raft.IsEmptySnap(rd.Snapshot) {
processSnapshot(rd.Snapshot)
}
for _, entry := range rd.CommittedEntries {
process(entry)
if entry.Type == raftpb.EntryConfChange {
var cc raftpb.ConfChange
cc.Unmarshal(entry.Data)
s.Node.ApplyConfChange(cc)
}
}
s.Node.Advance()
case <-s.done:
return
}
}
提议从节点更改状态机以获取应用程序数据,将其序列化为字节片并调用:n.Propose(ctx, data)
如果提案已提交,则数据将以raftpb.EntryNormal类型出现在已提交的条目中。无法保证会执行建议的命令;该命令可能必须在超时后重新提出。要添加或删除集群中的节点,请构建ConfChange结构'cc'并调用:n.ProposeConfChange(ctx, cc)
提交配置更改后,将返回一些类型为raftpb.EntryConfChange的已提交条目。这必须通过以下方式应用于节点:
var cc raftpb.ConfChange
cc.Unmarshal(data)
n.ApplyConfChange(cc)
尽管成员资格更改协议的这种实现与第4章中描述的有所不同,但该实现与最新的Raft论文(https://github.com/ongardie/dissertation/blob/master/stanford.pdf)是最新的。保留成员资格更改一次发生在一个节点上的关键不变性,但是在我们的实现中,成员资格更改在应用其条目时才生效,而不是在将其添加到日志时生效(因此该条目是在旧成员资格下而不是在新成员资格下提交的)新的)。就安全性而言,这是等效的,因为保证了新旧配置的重叠。为了确保没有尝试通过匹配日志位置来一次提交两个成员资格更改(这是不安全的,因为它们应该具有不同的法定要求,这是不安全的),任何提议的成员资格更改都将被禁止,而任何未提交的更改都将显示在领导者的日志中。当从两个成员的集群中删除一个成员时,这种方法会带来一个问题:如果一个成员在另一个成员之前收到了confchange条目的提交而死亡,则该成员将无法再删除,因为该集群无法取得进展。因此,强烈建议在每个群集中使用三个或更多节点。