This document is an overview of the dragonboat library, it is in Chinese. The English translation is being worked on and it will be provided soon.
本文档提供Dragonboat的使用综述信息,阅读完后您将可以自如的使用Dragonboat库在您的项目中实现您所需要的各类有强一致需求的功能与组件。本文档假设您已掌握Go编程,但您无需对Raft或其它任何分布式共识算法有详细理解。作为入门类工程性文档,本文刻意避免理论、原理型的描述,对这方面有兴趣的用户请参考各类分布式算法相关论文。
如果您对Raft等分布式共识算法已经有足够了解,可以跳过本节。
为了避免单点问题,我们希望数据有多个副本且被保存在多个地域分布的服务器上,这样当某一台或几台服务器出现当机时,根据副本数和位置可以有机会使得数据依旧在线进而服务继续可用,这也就是常说的高可用。存储在多个服务器上的多个副本同时提供了更高的整体读带宽,这对互联网应用普遍具有的高读写比特性也具备较高实际应用的意义。
以三个副本的数据为例,如果不考虑三个副本是否一致,只要任何一个副本在线,我们就可以认为数据可用。但这样的做法引入了副本间数据不一致的问题,把巨大的应用设计和实现上的麻烦强行推给了应用开发者,对于很多后台应用,这种做法已经被大面积淘汰接近消亡。在多数副本在线这一前提下,即三个副本中的至少两个可用时,共识算法可以使得这三个副本在外界看起来始终数据一致并提供高可用。几乎所有主要互联网公司均已开始不同程度使用共识算法来达到上述高可用与副本数据一致。
为了提供这样的数据一致与高可用,同时保证应用开发的便捷,共识算法使用一种称为复制状态机的系统模型。用户的应用被抽象为状态机,对状态机状态的更新由用户提交的称为Proposal的提议来实现。共识算法确保当有多个用户同时提交提议时,那些被多数副本成功收到且持久保存的提议会被采纳(committed),在不同的副本上,各提议将被赋予相同的index序号并根据该序号顺序记录在称为Raft Log的一个有序数据结构中。这些被采纳的提议,将以上述既定的顺序被逐一应用到用户应用的状态机中以更新状态机的状态。当状态机的多个副本的初始状态相同,且在各个副本上均严格按照Log内的内容逐一更新,那么Log中同一被采纳的提议被执行后,各个状态副本的状态也将是相同的。
如下图所示,该示例Raft组有三个分布的副本。状态机的状态为一个整形数变量X,各副本通过Raft协议维护一个Raft Log的副本,所有已采纳的提议在各Raft Log副本中的各值与顺序均严格相同,它们将按照index值升序次序逐一应用至状态机以更新状态机状态。对于各状态机副本,在应用了同一index值所对应的Raft Log那的提议记录后,各副本的状态将是相同的。
读操作的一致需要满足以下两点:
- 当一个写请求以提议的形式被采纳以后,此时通过读操作,必须可以读到这个写请求的结果,或者比它更新的结果。
- 当一个读请求返回了结果后开始下一个新的读请求,该新的读请求必须返回上一次读的结果或比它更新的结果。
共识库最主要功能即实现诸如Raft这样的共识算法并提供编程接口供使用者方便地实现他们的应用状态机,进而方便用户使用共识库的状态机读写功能来操作应用状态机及其数据,以实现有高可用和数据一致保障的各私有应用逻辑。
下面各节将展开讨论如何使用dragonboat提供的各项便利,方便地构建基于Raft分布式共识算法的应用。
Raft组:Raft协议控制下的一个独立的具有多个副本的实体,组内各个副本提供上一节中描述的一致性保证。一个应用可以使用管理一个或者多个Raft组。每个Raft组由一个系统内全局唯一的用户设定的64位整形数ShardID来指代。 集群Shard:Raft组的别称。 节点Node:Raft组中的一个成员副本。每个节点由一个Raft组内唯一的用户设定的64位整形数ReplicaID来指代。 初始成员节点Initial Member:一个Raft组在最初出现的时候所设定的原始成员。 Leader:Raft协议中定义的扮演Leader角色的节点。每个Raft组应有一个Leader节点,只有当Leader节点确定时才能对该Raft组进行读写。 快照Snapshot:把状态机在某具体时间点上的状态完全保存所得到的数据,可用于快速恢复状态机状态。
状态机是用户应用的核心,它实现用户的业务逻辑,比如当您想构建类似Redis的基于内存的Key-Value数据库,那您的状态机就是这样一个KV数据库。状态机同时也是用户应用与Dragonboat一大交互接口,Dragonboat库通过状态机所实现的IStateMachine或IOnDiskStateMachine接口与之交互,完成状态机状态更新与查询等操作。
普通状态机通常将数据存放于内存内,这决定了其总的数据量相对较小。普通状态机的状态会在每次节点重启后被完全重置,其管理下的存储于磁盘上的数据也需要清理并忽略,然后通过已持久化保存的Log和快照Snapshot给予恢复。它的特点是数据量受限于内存大小、实现简单、吞吐可达千万次每秒,但定期的保存快照Snapshot以及每次重启后重建状态所带来额外的CPU、IO以及磁盘空间损耗较大。较常见的此类状态机的例子是类似Redis的基于内存的Key-Value数据库。此类型状态机是Raft论文中提及的通常类型的状态机。
用户需要实现statemachine包中的IStateMachine接口以实现这类普通状态机。此类状态机状态在每次重启后应确保其初始状态为空,Dragonboat负责通过状态机的Update与RecoverFromSnapshot两个方法来恢复状态机的状态。请注意,普通状态机的状态在重启后被重置,但没有任何数据会被丢失,已持久保存的快照Snapshot和Raft Log包含所有已被采纳的用户数据。
基于磁盘的状态机的主要数据保存于磁盘上,因此其总数据量相对较大,状态机的状态始终在磁盘上持久保存,每次节点重启后状态机状态不受影响。Dragonboat依旧需要定期保存快照Snapshot,但此类快照仅包含少量元数据,创建的开销极小。它的特点是较前述普通状态机而言,它保存快照的额外IO开销极小,且重启后无需状态重建的过程。较常见的此类状态机的例子是基于RocksDB或LevelDB的带多副本与强一致保障的分布式KV数据库。此类型状态机可认为是Raft论文5.2节提及的特殊类型状态机。
用户需要实现statemachine包中的IOnDiskStateMachine接口以实现基于磁盘的状态机。此类基于磁盘的状态机由用户负责其状态机状态的持久化保存以及并发读写的支持,同时需要保存所最后应用的Log的index值,Dragonboat仅负责在每次重启后打开并启用已保存的状态机。
上述两类状态机的选择的最重要指标是状态机所管理的总数据大小。在所有状态机数据可以被存放于内存内的时候,比如几十G字节以内,建议使用基于内存的状态机,基于磁盘的状态机可以视为是状态机管理数据量较大情况下的一种针对避免额外开销的优化。
使用一个节点前首先需要启动该节点,使得其被NodeHost装载并管理。NodeHost的StartReplica, StartConcurrentReplica与StartOnDiskReplica方法用于启动相应节点。
当一个Raft shard的各初始成员首次启动时,用户需要提供该Raft shard的所有初始成员信息(initial members),且各副本必须以完全相同的初始成员信息启动。该初始成员信息用于确保各副本从一个一致的成员列表开始演进后续用户要求的成员变更。当一个副本并非该Raft shard的初始成员,而是后续通过成员变更(如SyncRequestAddReplica)所新增的节点,其第一次启动时无需提供初始成员信息,只需要将join参数设置为true。
当一个节点重启时,不论该节点是一个初始节点还是后续通过成员变更添加的节点,均无需再次提供初始成员信息,也不再需要设置join参数为true。
用户可以通过NodeHost的StopShard方法来停止所指定的Raft shard在该NodeHost管理下的副本。停止后的节点不再响应读写请求,但可以通过上述节点启动方式再次重新启动。
在一个副本被StopShard要求停止后,如果它正在执行快照的创建或恢复,该节点可能不会立刻停止而需等待至快照的创建或恢复完成。为避免这种长期等待,由用户实现的快照创建与恢复方法提供了一个<-chan struct{}的参数,当节点被要求停止后,该<-chan struct{}会被关闭,用户的快照创建与恢复方法可据此选择是否放弃当前的快照创建与恢复,从而快速响应节点停止的请求。
对状态机的写操作称为提议Propose。用户可以通过使用NodeHost的Propose与SyncPropose方法发起异步或者同步的提议。
前述的共识算法常识可知,一个Raft组的过半数成员在线且可以互相正常交换网络消息,此时用户的提议才可以成为被采纳状态(committed)最终被送至各状态机的副本执行。Dragonboat通过向状态机接口的Update方法提供已采纳的提议,供状态机依次执行。
NodeHost的Propose方法开始一个异步的提议,它会立刻返回。如果发起成功它会提供一个RequestState对象,用户可以通过它等待提议的操作结果并获取结果状态。一个被成功开始的提议的可能结果有成功、超时和失败。成功表示提议被采纳且已经被应用当当前的节点Node中,此后所有开始的读操作均将可以读到该提议的执行结果或者更新的结果。超时表示在用户指定的时间内无法完成提议的整个流程,提议状态未明,这是典型的分布式系统中的三态情况。失败是指向Propose提供的参数不合理或节点在提议流程完成前已经被关闭。
NodeHost的SyncPropose方法开始一个同步的提议,调用者的Goroutine会被挂起直到SyncPropose返回了明确成功、超时或者错误结果才会返回。它目前是对Propose方法的一个封装,可查看源代码比较两者实现的区别。
因为写操作超时以后导致上述三态情况,当用户重试一个超时的写请求的时候需要充分考虑这一情况,确保之前超时的操作如果已经成功执行了写操作,那么再次重试的写操作的再次执行不会对系统带来负面干扰,这样的特性称为幂等。显然,用户可以选择在状态机的设计上实现自己的幂等处理的方法。用户同时也可以选择使用Dragonboat自带的针对普通状态机的幂等方案,该方案基于Raft论文。
简单来说,Propose与SyncPropose方法均需要一个称为Session的输入参数,它是一个客户端访问状态进行写的Session。当用户选择不使用Dragonboat内建的幂等功能的时候,可以使用通过调用NodeHost的GetNoOPSession方法获得一个NOOP Session,它仅用来指明写操作针对的Raft组的ShardID标示。如果选择使用内建的幂等支持,那可以使用GetNewSession以获取一个有效的具体Session对象,并在当前的client每次调用Propose或者SyncPropose方法时使用这个Session对象。当Propose或SyncPropose成功后,需要调用Session的ProposalCompleted方法,而Propose或SyncPropose超时后则不调用ProposalCompleted方法而直接再次调用Propose或SyncPropose来重试已超时的提议。
基于磁盘的状态机不支持该内置的幂等功能,必须使用NOOP Session,如有需求,用户需自行在状态机内实现其幂等的支持。
写操作的用户输入数据应该是状态机执行更新操作的唯一输入数据来源,状态机在Update方法被调用以更新状态机状态时,不应该使用诸如系统时间、随机数、当前进程号等等在各个副本上不确定的数据源。
默认设置下,一个Proposal只有在被确认已采纳且已成功应用于状态机时才会通知客户端,对部分有特殊需求的应用,可以通过设置NodeHostConfig的NotifyCommit参数使Dragonboat在Proposal被采纳后另行通知客户端。
对状态机的读操作通常用以查询状态机内容与状态,且读操作不改变状态机的状态。读操作必须采用确保一致性的协议,绝不可直接读取状态机内容。Dragonboat使用Raft论文中描述的称为ReadIndex的协议来实现高效的确保一致性的读。用户可以使用NodeHost的SyncRead以或者ReadIndex与ReadLocalNode的组合方式,完成对状态机的读,前者为同步操作,后者为异步。
与写操作类似,读操作所依赖的ReadIndex协议的顺利执行需要一个Raft组的过半数成员在线且可以互相正常交换网络消息。Dragonboat通过向状态机接口的Lookup方法提供用户查询内容,执行后返回查询结果。
NodeHost的ReadIndex方法开始一次异步的ReadIndex协议的执行,它立刻返回一个RequestState对象,用户可以通过它等待ReadIndex协议执行的成功。一旦成功,用户可以立即使用ReadLocalNode方法对同一节点开始查询操作。与前述的Propose方法类似,一个被成功开始的ReadIndex协议可能有三种结果,分别为成功、超时和失败。超时和失败的意义与Propose的超时或失败类似。ReadLocalNode方法只有在每次ReadIndex成功后才可以被调用,直接调用ReadLocalNode将破坏所读取得到的结果的一致性保证。
NodeHost的SyncRead方法进行一次同步的读操作,该方法只有当SyncRead返回了明确的查询结果或者确认了超时或者失败以后才会返回。它是对ReadIndex与ReadLocalNode方法的一个封装,可查看源代码参考具体实现的方法。
因为读操作始终不改变状态机状态,因此读操作超时以后不存在写操作超时以后的重复写入更新问题,也因此不涉及幂等操作的考虑。
上述读操作使用interface{}为输入输出数据,它会引起额外的堆内存分配heap allocation,对于密集读应用,用户可在状态机那实现可选的IExtended接口以后,使用NAReadLocalNode来进行读操作,它以[]byte为输入输出数据,可避免额外的堆内存分配。
NodeHost同时提供名为StaleRead的函数,如它的方法名称所表述的,它不保证任何一致性,仅用来读取状态机当前状态。
对于一个Raft组,成员变更可以改变其组成员节点Node的组成,比如可以增加一个新节点或者删除一个已经失效的节点。成员变更对于用户状态机透明,无需用户在状态机内做任何处理,用户只需要通过调用NodeHost的RequestAddReplica与RequestDeleteReplica方法来增删节点完成成员变更。
成员变更的注意事项如下:
- 成员变更的操作本身也是通过Raft协议的一种提议,与普通的用户提议一样,被采纳的成员变更操作会被持久保存并复制到各副本节点。
- 已经被删除的节点不允许被再次添加回Raft组内。
- 成员变更改变系统对于Raft组成员信息的记录,所增删的节点需由用户负责实际启停。
- 对于每个Raft组,成员变更需要逐个依次进行,一次只能有一个等待完成的成员变更请求。
- 仅由同一个线程那逐一执行的成员变更操作是幂等的,比如增加一个节点的请求超时以后如果重试该操作,并不会引起意外结果。
通过成员变更将某节点删除以后,可使用NodeHost的RemoveData方法删除该节点的所有数据以释放磁盘空间。该操作需谨慎,对尚未通过成员变更删除的节点使用RemoveData清理数据将不可修复的损坏该Raft组。
绝大多数情况下,用户应用只在一个线程内逐一的执行成员变更操作,此时成员变更操作是幂等的。如无法满足这一条件,所有的成员变更请求前(含重试)可通过GetShardMembership方法首先获得Raft组当前的Membership成员记录,用户软件根据当前成员情况做出成员变更决定后,在调用成员变更的API时提供上一步所返回的Membership记录的ConfigChangeID值,从而确保多线程并发执行成员变更的幂等。
快照是对某一特定时间点时候的节点状态的一次保存,快照含有如下信息:
- 当前已执行的提议的序号
- 当前Raft组成员信息
- 当前活动的用户Session信息
- 用户状态机当前状态
基于磁盘的状态机不支持用户Session、它的状态机当前状态始终在磁盘上,因此上述两项均不会在定期产生的或者用户通过RequestSnapshot接口请求的快照中包括。基于 磁盘的状态机仅在向进度落后的节点实时传输一个快照时或者根据用户请求导出一个快照时才会包含完整的对状态机状态。
快照可由下列方式产生:
- 通过Config对象设置定期产生快照的频率,此处定期是指状态机每执行N个更新以后
- 通过调用NodeHost的RequestSnapshot方法请求产生一个快照
快照Snapshot有如下作用:
- 普通状态机重启后内存那数据丢失状态被重置,使用快照可以快速恢复状态机状态,无需逐一执行大量的历史Raft Log记录以恢复状态机状态,通常可节省执行时间与磁盘读带宽的消耗。
- Raft组中某节点显著落后于Leader节点时,Leader可以通过向该落后节点网络发送快照而非逐一复制各提议,通常可节省传输时间与带宽。
- 普通状态机状态被保存至快照后,该节点之前的历史Log均可清理以释放磁盘存储空间。
- 导出后的快照可被用于修复已永久丢失多数节点的Raft组。
状态机接口的SaveSnapshot方法用于将状态机状态保存至一个io.Writer对象中,RecoverFromSnapshot方法负责将以io.Reader形式提供的快照数据恢复至状态机内完成快照的应用。快照内其余信息,如当前成员与Session信息,均由Dragonboat负责维护与应用。
由前述关于状态机一致性的描述可知,当状态机执行至某特定提议,那么所有副本的状态应该是相同。快照的产生也必须考虑这点,状态机执行至某特定提议后开始产生快照,状态机多个副本在执行了同一特定提议后所产生的快照应该是严格相同的。
用户可以通过启动各个节点时提供的Config实例中的SnapshotEntries字段设置每执行多少个提议定期进行一次快照的保存,通常:
- 对于普通状态机,建议一天保存1-2次快照
- 对于基于磁盘的状态机,建议每小时保存一次快照
用户也可以使用NodeHost的RequestSnapshot方法对指定的Raft组请求创建快照。使用DefaultSnapshotOption所创建的快照就是一个普通快照,它由系统管理。如果讲SnapshotOption的Exported值设为true,那么所创建的快照会被导出到ExportPath所指向的目录,导出后的快照可备份后未来被用于修复已永久丢失多数节点的Raft组,导出至上述指定目录的快照由用户完全负责保存、转移和释放清理,系统不再干预。对于所请求的非导出的快照,用户同时可以通过SnapshotOption的OverrideCompactionOverhead和CompactionOverhead值来控制每次请求的快照产生以后多少已在快照中包含的Log需要被清理以释放磁盘空间。
上述导出的快照,可以在多数节点均永久失效以后通过tools包提供的ImportSnapshot方法被用来修复已无法使用的Raft组。此时因为Raft组的多数节点已经永久失效,数据已有丢失,该操作为数据有损操作。用户程序应该通过设置合理的副本数以及加强服务器监控维护,通过避免发生多数节点永久失效来规避数据丢失问题。请注意,多数节点发生可恢复的失效,比如多数节点发生重启或短暂网络故障,并不会引起已保存数据的丢失。多数节点永久失效是指多数服务器上的磁盘损坏或服务器永久不再可用等故障。ImportSnapshot具体使用请参考其godoc文档。
默认下,每个Raft组的每个副本在被加入系统时都由用户明确指定它所在的节点的RaftAddress位置,系统以此确保各类Raft消息可以被正确发送给该副本。该方案简单直接,但缺点是RaftAddress必须是固定不变的,这要求使用固定的IP或者由用户维护一个DNS Name。在这一要求无法满足时,可以使用gossip功能来规避这一问题。
从v3.3版本开始,每个NodeHost节点都会被随机分配一个永久固定不变的NodeHostID值,它的值如nhid-1234567890形式,该值可由NodeHost的ID方法返回。在NodeHostConfig的DefaultNodeRegistryEnabled项被设置为真后,所有新创建的副本都需要被指定其对应的NodeHostID值。此后,每次启动NodeHost实例时用于NodeHost间通讯的RaftAddress值可随意变化,每个NodeHost实例的RaftAddress与NodeHostID的对应关系将自动由后台的一个gossip服务来动态的维护,当Raft消息需要在两个副本间传递时,首先发生ReplicaID到NodeHostID的转换,接着由NodeHostID通过gossip服务查询得到对应的RaftAddress地址并完成消息副本间的传输。
Gossip服务本身是一个全分布的网络服务,用户仅需要通过NodeHostConfig.Gossip项简单设置其相关地址参数即可。
Dragonboat通过NodeHost提供下列其它常用功能:
- Non-Voting节点。观察者节点不参与Leader的选举,不参与一个提议是否可以被采纳,它仅仅用来接受并执行Raft组各个已采纳的提议。观察者节点的状态机与普通节点一样,正常情况下将具备完整且相同的状态机状态,它可以被用来做为一个额外的只读节点,供用户读取有一致性保证的状态机状态。观察者节点的另一大作用是允许一个新加入的节点以观察者身份加入Raft组,在其逐渐获取所有状态机状态后再提升其为正常节点。在观察者节点所在的NodeHost上发起一次SyncRead或者一次GetShardMembership,如果成功返回则表示ReadIndex协议被完整执行了一轮,这表示观察者节点已经拥有基本所有Log Entry,具备了将其升级为正常节点的条件。
- Leader迁移。正常情况下,Leader以选举方式由用户程序透明的方式选举产生。用户可以使用NodeHost提供的RequestLeaderTransfer方法尝试将Leader迁移至指定节点。
- NodeHost同时提供GetNodeHostInfo与GetShardMembership方法供查询当前各NodeHost管理下的各Raft组信息。