前文讲述了Raft算法的核心,本文提供将Raft算法应用于分布式系统的宏观指导,将不再赘述算法细节而是专注于用户层面和应用层面,描述一个Raft的分布式系统需要进行哪些工作。
组件
一个Raft节点,需要包含的组件有
- Config 配置
- Storage 日志存储器
- Logger 打印程序运行时日志
- Raft 算法核心
- Node 一个节点的代称,是Raft的上层包装
提议和消息
我们把向Raft集群发起用户自定义操作的行为称为提议(Propose),而一个提议称为(Proposal)。Raft内部沟通的信息称为消息(Message)。
对于一个Node,如果收到的是一个Message,则直接进入Raft的状态机中;如果向Leader发起一个Proposal,则需要先包裹成Message,然后再进入状态机。到了这一步再称为提议(Proposal)可能就不恰当了,我们可以称一个提议的数据实体为Entry。
快照
快照即把当前集群的状态保存下来。事实上,快照并不属于Raft算法的一部分,是由用户自行维护的。别以为很复杂,其实总共需要记录的数据就两个,term和commit index (applied index)。主要是为了再启动时能够回到关闭时的状态。
轮询
需要一个轮询时刻监听来自客户端的Proposal或Message。轮询需要做的事情大概如下:
- 接收到一个Proposal或Message,接收后就会进入状态机中处理。
- 更新时钟滴答,以便于适时发送心跳。
- 进入Ready阶段,发送Message,检查并处理提交后的提议。
以上操作需要顺序执行,且在单线程内完成。
而接收Proposal或Message的具体做法可以通过跨线程管道来处理,即CSP并发模型。这种多生产者,单消费者的模式在这种轮询场景下特别适用。
Ready
当我们把Message投入状态机后,经过状态机的一系列判断和转换,某些操作会产生一个新的Message,并存入内部日志中。要注意的是,Raft算法不应该包含网络传输,使用怎样的网络传输方式应该由外部决定,即用户需要自己从内部日志中获取该Message,然后再发送出去。
Raft会把需要发送的Message,和需要处理的Entry都包裹到一个名为Ready的结构中,它可以包含零个,一个或多个Message和Entry。在Ready阶段,主要对该结构进行处理。
在处理一个Ready时,你可能需要做以下几件事情:
持久化Entry。当本节点拿到新Entry时,需要手动持久化该Entry。注意是持久化Entry,而不仅是你所提议的数据,因为Entry还包含了term,index等信息。
保存快照。快照是对当前内存数据的一次集中存储,方便宕机后快速还原。
发送Message。根据状态机创建的Message中的目标节点ID,向其发送该Message。
处理提交后的Config Entry。提交后的Entry会放到缓存区中,在这里你需要尝试获取,如果有就进行处理。Config Entry是用于配置集群的指令,比如新增一个节点或删除一个节点。根据算法,必须在提议提交后才能正式加入新节点或删除节点,对于类似行为必须在此处进行操作。
处理提交后的Normal Entry。Normal Entry就是用户发起的提议。需要在提议提交后进行一些回调,比如,向客户端响应读操作的结果等。提议和Message一定会被顺序执行和提交,所以你可以使用链表等结构存储提议的回调函数或其他信息。另一个办法是在提议数据中包含一个唯一ID,凭此做对应的处理。
检查是否存储快照。一般记录每隔提交个entry就存储一次快照。
前进(Advance)。这里表示该Ready已经处理完成的意思,标记一下,然后就可以获取下一个Ready,不标记则永远返回同一个Ready。
滴答与心跳
Raft结构体只负责算法的实现,即定义了每隔多少个滴答(Tick)就发送心跳的行为,而程序运行时间和一个滴答的时间间隔则需要外部维护。比如一个滴答是100ms,当发现经过了100ms,就需要让Raft内部的滴答数加一,当滴答数到达3(假设),Raft就自动发送心跳。
运行
讨论Raft集群运行过程中需要处理的问题。
配置
主要配置一些不会变动的信息,启动一个Raft节点主要需要配置以下信息:
- 节点ID
- 心跳的滴答数
- 重新选举的滴答数(没有收到心跳)
- 读操作的模式
- 是否开启预投票(Pre vote)
存储器
存储器是Raft节点加载和存储信息和数据的工具。使得Raft节点在启动能跟上集群的最新状态,以及重新启动时迅速回到上次关闭时的状态。
储存器需要存储和提供以下信息:
- Raft状态,包括commit index, leader, term, 成员ID等信息。
- 所有Entry。
- 快照(Snapshot)。
启动
当启动集群时,有一些需要注意的问题。
- 不要同时启动所有节点,而是先选定一个节点启动一段时间,至少该子节点自己成为了Leader,然后再启动其他节点。如果节点启动时间太近会导致难以选举出leader,因为大部分节点的term和index可能相同,大家都争着当候选人,但又无法拉开差距,进而不断发起选举。
- 时间滴答的时间间隔不能太小,如果太小会导致还没来得及响应投票选举出leader就发起了新一轮选举。
对于多节点启动,配置方式大概分为两种,一种是一开始就在成员配置表中写好所有节点,然后所有节点启动时都读取该配置,即静态配置;第二种方法是先启动一个节点,再启动其他节点,其他节点并没有成员配置表,而是走“新增节点”流程,即运行时配置。
新增节点
新增节点的过程大概如下:
- 新增节点向leader发送一个请求新增节点的指令,需要附带上自己的ID和RPC端口。
- leader收到指令后通过网络库提取出该节点的host地址(IP地址),和它发送过来的RPC端口号可以组成该节点程序的具体地址。
- leader创建一个Conf Message,填入新节点的ID和地址,然后投入状态机。
- 当多数节点收到该Message后就会被提交,提交后所有成员将该节点加入成员记录中。
再启动
再次启动时需要读取本地存储中的快照,即term和commit index,以便于直接跳过已经提交的日志。
删除节点
删除节点过程则简单很多。向leader发起删除指定节点的提议,然后leader将该节点从记录中删除即可。
与对外服务沟通
一般集群服务都会运行一个对外服务程序与外部沟通,可以是TCP Server或HTTP Server。该服务要与Raft服务相互沟通,则可以使对外服务作为生产者,往管道中投入一个Proposal,使其进入Raft的轮询中。并构建一个反向的管道,在回调中向反向管道输入结果,而对外服务则接收结果。
读操作可以向任何节点请求,而写操作只能向Leader请求。一般,所有成员都运行同一份代码,收到提议的节点会通过ID标识并缓存提议和回调,虽然Entry提交后所有节点都会做出同样的响应操作,但只有具有该ID缓存的那个节点才会成功向客户端响应。
框架化
上面讲到的点很多都是通用的,不同的仅仅是业务逻辑。我们可以提炼出一个框架来处理。事实上,框架化是一个很个性化的事情,不同人的思路和实现方式都可能不同,下面仅提供一种思路。
首先,业务逻辑和消息队列交给用户自己维护,抽象为RaftService,而Raft的轮询和算法抽象为RaftServer。我们的目标是把RaftService传入RaftServer中即可完成Raft集群的一系列工作,同时我们也可以把RaftService传入对外服务中,起到沟通作用。
RaftService实现定义一系列接口,具体实现交给用户。我们可以提炼出的接口有:
recv() -> Message
从管道(或队列)获取一个Message。send(Message)
向管道(或队列)投入一个Message。注意,该Message应该是框架定义的对象,而不是Raft里面的Message,因为可能还要包含其他操作。update_context(Context)
更新上下文(Context),上下文对象用于储存集群状态信息,该接口被框架定时调用并传入新的Context。context() -> Context
用户获取可以通过该接口获得集群状态信息。propose(bytes, callback) -> Something
向Raft发起一个提议,用户自行将需要提交的数据包装成Message,并放入管道中。callback是指提交之后的回调函数,由用户自行管理。一般有一个返回值,告诉上游是否提议成功,比如有些提议只能由leader发起。propose_conf_change(ConfChange) -> Something
用于发布配置操作。on_committed(bytes)
提交后的处理,Raft集群提交一个Entry后调用。
其中提议传入bytes只是为了通用,如果你使用的语言有更强大的泛型系统,可以使用泛型取代。
实现
根据上面的理论,我用Rust实现了一个Raft服务框架,详见github。
Comments