深入浅出etcd之raft实现
etcd是coreOS使用golang开发的分布式,一致性的kv存储系统,因其易用性和高可靠性被广泛运用于服务发现、消息发布和订阅、分布式锁和共享配置等方面,也被认为是zookeeper的强有力的竞争者。作为分布式kv,其底层使用raft算法实现多副本数据的强一致性。etcd作为raft开源实现的标杆,在设计上,将 raft 算法逻辑和持久化、网络、线程等完全抽离出来单独实现,充分解耦,在工程上,实现了诸多性能优化,是 raft 开源实践中较早的工业级的实现,很多后来的 raft 实践者都直接或者间接的参考了 ectd-raft 的设计和实现,例如kubernetes,tiDb等。其广泛的影响力和优雅的golang代码实践也使得ectd成为golang的明星项目。在我们实际的分布式存储系统的项目开发中,raft也被应用于元信息管理和数据存储等多个模块,因此熟悉和理解etcd-raft的实现具有重大意义,本文从raft的基本原理出发,深入浅出地分析了raft在ectd中的具体实现。
raft原理
架构
image
每个节点都包含状态机,日志模块和一致性模块。功能分别是:
- 状态机:数据一致性指的即是状态机的一致性,从内部服务看表现为状态机中的数据都保持一致
- log模块:保存了所有的操作记录
- 一致性模块:一致性模块算法保证写入log命令的一致性,是raft的核心内容。
实现一致性的过程可分为Leader选举(Leader election),日志同步(Log replication),安全性(safty),日志压缩(Log compaction),成员变更(membership change)
leader 选举
竞选过程
- 节点由Follower变为Candidate,同时设置当前Term。
- Candidate给自己投票,带上termid 和日志序号,同时向其他节点发送拉票请求
- 等待结果,成为Leader,follower 或者在选举未成为产生结果的情况下节点状态保持为Candidatae。
选举结果
- 成功当选收到超过半数的选票时,成为Leader,定时给其他节点发送心跳,并带上任期id,其他节点发现当前的任期id小于接收到leader发送过来的id,则将将状态切换至follower.
- 选举失败在Candidate状态接收到其他节点发送的心跳信息,且心跳中的任期id大于自己,则变为follower。
- 未产生结果没有一个Candidate所获得的选票超过半数,未产生leader,则Candidate再进入下一轮投票。为了避免长期没有leader产生,raft采用如下策略避免:
- 选举超时时间为随机值,第一个超时的节点带着最大的任期id立刻进入新一任的选举
- 如果存在多个Candidate同时竞选的情况,发送拉票请求也是一段随机延时。
日志同步(Log Replication)
image
Leader选出后接受客户端请求,Leader把请求日志作为日志条目加入到日志中,然后向其他Follower节点复制日志,但超过半数的日志复制成功,则Leader将日志应用到状态机并向客户端返回执行结果,同时Follower也将结果提交。如果存在Follower没有成功复制日志,Leader会无限重试。
日志同步的关键点:
- 日志由有序编号的日志条目组成,每条日志包含创建的任期和用于执行的命令,日志是保证所有节点数据一致的关键。
- Leader 负责一致性检查,同时让所有的Follower都和自己保持一致。
- 在Leader发生切换时,如何保证各节点日志一致。leader为每一个follower维护一个nextIndex,将index和termid信息发送至follower,从缺失的termid和index 为follow 补齐数据,直至和leader完全一致。
- 只允许主节点提交包含当前term的日志。否则会出现已经commit的日志出现更改的情况
安全性
安全性的原则是一个term只有一个leader,被提交至状态机的数据不能发生更改。保证安全性主要通过限制leader的选举来保证:
- Candidate在拉票时需要携带本地已持久化的最新的日志信息,如果投票节点发现本地的日志信息比Candidate更新,则拒绝投票。
- 只允许Leader提交当前Term的日志。
- 拥有最新的已提交的log entry的Follower才有资格成为Leader。
raft协议实现
raft的golang的开源实现主要包含两个:coreOS的raft实现 , 使用的项目如tidb和cockroachdb这两个经典的newsql。另外一个是hashicrop的raft实现,使用的项目如服务发现解决方案consul和时序数据库influxdb。对比二者的实现主要有如下特点:
- hashicrop的实现完整度高,包含了snapshot,wal,storage等,在集成时只需要关注业务逻辑
- etcd中的raft模块则是raft协议的轻量级实现,对于上述功能只定义了相关interface,需要业务方去具体实现,优点是增加灵活性,etcdserver就是集成raft算法并实现snapshot,wal,storage这样一个应用程序。
etcd/raft 代码结构
- 日志持久化storage.go:持久化日志保存模块,以interface的方式定义了实现的方式,并基于内存实现了memoryStorage用于存储日志数据。log.go:raft算法日志模块的逻辑log_unstable.go:raft 算法的日志缓存,日志优先写缓存,待状态稳定后进行持久化
- 节点node.go: raft集群节点行为的实现,定义了各节点通信方式process.go:从leader的角度,为每个follower维护一个子状态机,根据状态的切换决定leader该发什么消息给Follower.
- Raft算法raft.go:raft算法的具体逻辑实现,每个节点都有一个raft实例read_only.go: 实现了线性一致读(linearizable read),线性一致读要求读请求读到最新提交的数据。针对raft存在的stale read(多leader场景),此模块通过ReadIndex的方式保证了一致性。
etcd/raft的实现分析
分析raft的实现流程,我们可以从raft的几个核心问题入手:
- 如何选举leader?
- 如何实现log的复制?
- 如何进行leadership的transfer?
- 如何实现线性一致读?
其中leader的选举、log复制和线性一致读是raft协议的最基本要求,而leadership的转移在工程实践中有重大意义。
核心数据结构
- struct node node 中主要定义一系列channel,raft的实现就是通过channel 传递消息,当节点启动通过select机制监听上述channel确定相应的状态切换。
// node is the canonical implementation of the Node interface
type node struct {
propc chan msgWithResult
recvc chan pb.Message
confc chan pb.ConfChange
confstatec chan pb.ConfState
readyc chan Ready
advancec chan struct{}
tickc chan struct{}
done chan struct{}
stop chan struct{}
status chan chan Status
logger Logger
}
- interface node定义了node要实现raft算法必须实现的方法
type Node interface {
Tick() //时钟的实现,选举超时和心跳超时基于此实现
Campaign(ctx context.Context) error //参与leader竞争
Propose(ctx context.Context, data []byte) error //在日志中追加数据,需要实现方保证数据追加的成功
ProposeConfChange(ctx context.Context, cc pb.ConfChange) error // 集群配置变更
Step(ctx context.Context, msg pb.Message) error //根据消息变更状态机的状态
//标志某一状态的完成,收到状态变化的节点必须提交变更
Ready() <-chan Ready
//进行状态的提交,收到完成标志后,必须提交过后节点才会实际进行状态机的更新。在包含快照的场景,为了避免快照落地带来的长时间阻塞,允许继续接受和提交其他状态,即使之前的快照状态变更并没有完成。
Advance()
//进行集群配置变更
ApplyConfChange(cc pb.ConfChange) *pb.ConfState
//变更leader
TransferLeadership(ctx context.Context, lead, transferee uint64)
//保证线性一致性读,
ReadIndex(ctx context.Context, rctx []byte) error
//状态机当前的配置
Status() Status
// ReportUnreachable reports the given node is not reachable for the last send.
//上报节点的不可达
ReportUnreachable(id uint64)
//上报快照状态
ReportSnapshot(id uint64, status SnapshotStatus)
//停止节点
Stop()
}
节点的启动和运行
节点初始化raft,读取配置启动各个各个节点,初始化logindex.启动后 以for-loop方式循环运行,用select 机制监听不同的channel 实现对状态变化的监听,并执行相应动作。
//启动
func StartNode(c *Config, peers []Peer) Node {
r := newRaft(c) //初始化raft算法实例
r.becomeFollower(1, None)
//将配置中的节点加入集群
for _, peer := range peers {
...
}
//初始化logindex
r.raftLog.committed = r.raftLog.lastIndex()
for _, peer := range peers {
//初始化节点状态机(progress)
r.addNode(peer.ID)
}
n := newNode()
n.logger = c.Logger
go n.run(r)
return &n
}
//运行
func (n *node) run(r *raft) {
...
select {
//接收到写消息
case pm := <-propc:
...
//接收到readindex 请求
case m := <-n.recvc:
...
//配置变更
case cc := <-n.confc:
...
//超时时间到,包括心跳超时和选举超时等
case <-n.tickc:
...
//数据ready
case readyc <- rd:
...
//可以进行状态变更和日志提交
case <-advancec:
...
//节点状态信号
case c := <-n.status:
...
//收到停止信号
case <-n.stop:
...
}
}
}
leader 选举
初始化node为follower,设置任期为1,并初始化tickElection函数,这是实际参与选举的函数,同时也初始化step为stepFollower,这是作为follower的核心信息处理函数,后续选举,日志复制和快照等功能都基于此函数进行:
r := newRaft(c)
r.becomeFollower(1, None)
当节点接收leader的heartbeat超时时(每个节点都有随机的超时时间),会触发run函数中的tickc这个channel。发送MsgHup消息,并调用campaign参选, 将自身设置为candidate,并递增currentTerm,向其他节点发送竞选消息。其他节点通过监听propc channel获取其他节点发送的投票消息,并调用Step对消息进行判断,选择是否投票。
其中投票的判断逻辑主要分两步:1.如果投票信息中的任期id 是否 小于自身的id,则直接返回nil。2.通过isUpToDate判断能否投票,通过和本地已存在的最新log比较,首先要有最大任期id,如果任期id相同则要求有最大的logindex。
candidate节点收到其他节点的回复后,判断获取的票数是否超过半数,如果是则设置自身为leader,否则为follower。
func (n *node) run(r *raft) {
...
for {
select {
...
//触发heartbeat 超时
case <-n.tickc:
r.tick()
...
}
}
}
//超时触发选举
func (r *raft) tickElection() {
r.electionElapsed++
if r.promotable() && r.pastElectionTimeout() {
r.electionElapsed = 0
r.Step(pb.Message{From: r.id, Type: pb.MsgHup})
}
}
//随机超时时间
func (r *raft) pastElectionTimeout() bool {
return r.electionElapsed >= r.randomizedElectionTimeout
}
func (r *raft) resetRandomizedElectionTimeout() {
r.randomizedElectionTimeout = r.electionTimeout + globalRand.Intn(r.electionTimeout)
}
//参与选举
func (r *raft) campaign(t CampaignType) {
var term uint64
var voteMsg pb.MessageType
//成为candicate,将任期id加1
if t == campaignPreElection {
r.becomePreCandidate()
voteMsg = pb.MsgPreVote
term = r.Term + 1
} else {
r.becomeCandidate()
voteMsg = pb.MsgVote
term = r.Term
}
//判断获取的票数是否超过半数,如果是当选为leader
if r.quorum() == r.poll(r.id, voteRespMsgType(voteMsg), true) {
if t == campaignPreElection {
r.campaign(campaignElection)
} else {
r.becomeLeader()
}
return
}
//向其他节点发送竞选消息
for id := range r.prs {
if id == r.id {
continue
}
var ctx []byte
if t == campaignTransfer {
ctx = []byte(t)
}
r.send(pb.Message{Term: term, To: id, Type: voteMsg, Index: r.raftLog.lastIndex(), LogTerm: r.raftLog.lastTerm(), Context: ctx})
}
}
//节点投票过程
func (r *raft) Step(m pb.Message) error {
...
//比较任期id
case m.Term > r.Term:
if m.Type == pb.MsgVote || m.Type == pb.MsgPreVote {
force := bytes.Equal(m.Context, []byte(campaignTransfer))
inLease := r.checkQuorum && r.lead != None && r.electionElapsed < r.electionTimeout
if !force && inLease {
return nil
}
}
switch m.Type {
case pb.MsgVote, pb.MsgPreVote:
...
//与本地最新的持久化日志比较
if canVote && r.raftLog.isUpToDate(m.Index, m.LogTerm) {
//发送投票信息
r.send(pb.Message{To: m.From, Term: m.Term, Type: voteRespMsgType(m.Type)})
if m.Type == pb.MsgVote {
// Only record real votes.