Raft协议实现之etcd(一):基本架构

前言

之前解析过Raft协议基本原理(传送门),一直想找个具体实现来看一下。Etcd是一款开源的分布式KV数据库,由于K8S中使用它作为注册和配置中心而被广泛认知。跟它最类似的产品还有相同应用场景Zookeeper,经常被用作注册中心和分布式锁。Etcd也是比较早的使用Raft协议来实现分布式存储的产品,而且它的架构设计中将raft协议部分单独剥离出来,这样其它的程序如果想用raft协议,可以直接依赖它的raft模块。后续几篇文章将通过etcd源码来查看如何完整实现一个Raft协议。

知识准备

Etcd是使用Go语言实现的,如果之前没了解过Go但是用过其它面向对象的语言(如Java),相信对文章中90%以上内容的理解都不会有问题。如果想要自己对照着源码一起看文章,了解下Go的基本概念还是必要的。可以着重了解下Go的如下特性:

  • interface和struct
  • 方法和函数的区别
  • channel 通道,Go语言设计思想的核心,etcd设计中大量用到

etcd架构

Etcd本质上是一个分布式KV数据库,Raft只是它为了实现分布式数据一致性所采用的协议。在整个架构上,我们只需要关心围绕着Raft部分模块的设计。相关模块的架构如下:

EtcdServer

以上的架构并不是整个etcd,而只是提取了和Raft相关的模块。从上图中可以看出,etcd中raft协议模块和其它模块是分开的,Raft模块只负责协议状态机的正常转换,而数据的持久化及和其它节点的通信通过EtcdServer调用其它模块来实现。这样就很好的保证了协议部分的低耦合,其它的程序如果要用Raft协议,可以直接将Raft模块集成进自己程序。
EtcdServer
etcd的主线程,代表一个etcd节点。
Storage
负责日志条目的持久化以及数据Snapshot备份
KV
etcd的KV持久化存储,这个模块其实跟raft没有多大关系。因为raft协议只负责告诉使用方什么时候可以把日志应用生效,至于日志中的数据怎么用不是Raft关心的范畴。etcd是KV数据库,它的数据存储在boltDB中。当etcd客户端写数据时,etcd通过raft leader节点将数据复制到超过半数节点并应用到boltDB后返回成功。
Raft
Raft协议算法实现,接收EtcdServer调用来进行状态转换

模块分解

EtcdServer

EtcdServer可以看作是etcd节点的核心控制器,它即实现了etcd的一个服务节点,每个EtcdServer都包含了一个Raft的节点实现。它主要包含如下的功能:
节点初始化
etcd节点启动时会初始化并启动一个EtcdServer,启动的过程主要包含如下的部分:

  • 重启时会加载之前已经持久化到磁盘的数据,恢复整个Server。数据包括WAL中的日志数据和KV的snapshot数据。etcd节点在运行过程中,如果数据已经写到boltDB中,则会永久存储。但是日志数据有可能还没有commit或者apply,这部分数据会在WAL文件中,etcd启动时会重新将这部分数据重新加载到raft内存中。
  • 启动Raft状态机,启动后将通过raftNode和raft状态机进行交互
  • 初始化和集群中其它节点的通信

请求通信
EtcdServer会发送/接收两种来源的请求,客户端和集群其它节点

  • 客户端请求,EtcdServer收到客户端的写请求和线性读请求后,会交给Raft来处理,然后等待处理完成后回复客户端。
  • 集群节点间的请求,包括心跳、投票、日志复制,当raft模块发现需要有请求发送给其它节点时就会通过EtcdServer来转发,这样就降低了Raft模块的复杂性。

发起心跳
EtcdServer通过内置一个定时器来定时触发Raft的心跳接口。如果是Leader,则发送心跳给集群中其他节点;如果是Follower则检查Leader的心跳是否超时,以决定是否发起一次新的选举 。

日志持久化
上面的架构图中,Raft模块也保存有日志条目,但是这个日志条目其实只是在内存中,真正的数据持久化是由EtcdServer完成的。回忆下Raft协议中,只规定了哪些数据和属性应该持久化,而并没有规定怎么存,存在什么地方。所以etcd将持久化部分和Raft协议部分分开实现是合理的。

KV数据操作
Raft协议只保证日志复制的一致性,对于最终日志应用不关心。etcd是KV数据库,所以收到raft可以将日志应用的时候,就会将日志中包含的操作应用到后端的KV存储中。KV存储部分的逻辑不属于Raft的范围,所以后续不会关注这一块的实现。

Raft

Raft模块是协议算法的实现,后续的源码解读也会集中在这一模块上。
Node
Node接口代表raft集群中的一个节点,对外提供操作raft协议节点的方法。如果有别的程序想只使用etcd的raft实现的话,可以启动一个Node,然后直接调用这个接口来和raft状态机交互就可以了。

type Node interface {
    // 触发一次心跳,raft会在触发后检查leader选举超时或发送心跳
    Tick()
    // 触发节点将自己变成候选人,开始选举
    Campaign(ctx context.Context) error
    // 提交日志条目
    Propose(ctx context.Context, data []byte) error
    // 集群配置变更
    ProposeConfChange(ctx context.Context, cc pb.ConfChangeI) error
    // 发送一条消息给状态机,触发状态变化
    Step(ctx context.Context, msg pb.Message) error
    // 如果raft状态机有变化,会通过channel返回一个Ready的数据结构,里面包含变化信息,比如日志变化、心跳发送等。调用方在处理完后需要调用Advance()方法告诉状态机上一个Ready处理完了
    Ready() <-chan Ready
    Advance()
    // 应用集群变化到状态机
    ApplyConfChange(cc pb.ConfChangeI) *pb.ConfState
    // 将Leader转给transferee.
    TransferLeadership(ctx context.Context, lead, transferee uint64)
    // 请求一次线性读
    ReadIndex(ctx context.Context, rctx []byte) error
    // raft state machine当前状态.
    Status() Status
    // 告诉状态机指定id节点不可达.
    ReportUnreachable(id uint64)
    // 告诉状态机给id节点发送snapshot的最终处理状态.
    ReportSnapshot(id uint64, status SnapshotStatus)
    // 关闭节点.
    Stop()
}

struct node 是Node接口的默认实现,它包含了多了go channel来接收请求,也就是说对它的调用大部分都是异步处理的。在启动的时候会读取channel中的消息,然后调用RawNode相应的方法来处理请求。

type node struct {
    propc      chan msgWithResult
    recvc      chan pb.Message
    confc      chan pb.ConfChangeV2
    confstatec chan pb.ConfState
    readyc     chan Ready
    advancec   chan struct{}
    tickc      chan struct{}
    done       chan struct{}
    stop       chan struct{}
    status     chan chan Status

    rn *RawNode
}

node中定义的channel基本可以和接口中的方法对应上,propc用来接收日志,recvc用来接收除日志之外的消息。
RawNode
RawNode只是对raft做了一层封装,可以看成是raft的Facade接口。Node通过调用RawNode的接口来操作raft,而RawNode的大部分操作只是对raft的Step()方法做了一个封装。RawNode同时缓存了两个State属性,是为了在获取raft状态机数据时比对状态是否有变化。

type RawNode struct {
    raft       *raft
    prevSoftSt *SoftState
    prevHardSt pb.HardState
}

RawNode对于状态机操作的方法都直接发给raft,比如接收新日志的方法实现如下:

func (rn *RawNode) Propose(data []byte) error {
    return rn.raft.Step(pb.Message{
        Type: pb.MsgProp,
        From: rn.raft.id,
        Entries: []pb.Entry{
            {Data: data},
        }})
}

还有一对重要的方法是Ready()和Advance(),当raft状态机有变化时,包括状态和数据变化,通过Ready()方法就可以取到变化的数据,调用方拿到数据后该持久化的持久化,该发送的发送。在调用方处理完后调用Advance()方法通知raft状态机已处理完成。
raft
协议算法的核心实现,实际上就是一个状态机的实现,当有外部请求来的时候,比如新的日志,发送心跳等,就调用它的Step()方法来触发状态机。它维护了Raft协议中规定的所有属性,如Term、CommitIndex、Vote等。同时通过RaftLog来持有日志。

type raft struct {
    //在raft集群中的唯一id
    id uint64
    //选举周期
    Term uint64
    //上一次投票的节点,Leader等于自己的id
    Vote uint64
    //线性读状态
    readStates []ReadState
    // 日志
    raftLog *raftLog
    ....
    // 跟踪Follower节点的状态,比如日志复制的matchIndex
    prs tracker.ProgressTracker
    // 当前节点的类型
    state StateType
    // 节点是不是 learner.
    isLearner bool
     // 待发送给其它节点的消息
    msgs []pb.Message
    // leader id,leader人工转移时的目标节点
    leadTransferee uint64
    ...
    // 还未提交的日志条数,非准确值
    uncommittedSize uint64
    readOnly *readOnly
    // 选举时记录ticks
    electionElapsed int
    // 记录心跳的ticks
    heartbeatElapsed int
    ...
    //心跳超时
    heartbeatTimeout int
    // 选举超时
    electionTimeout  int
    //随机选举超时
    randomizedElectionTimeout int
    disableProposalForwarding bool

    tick func()
    step stepFunc
    ...
}

raft的属性基本跟raft协议中规定的节点的属性对应,其中raftLog在内存中存储了日志条目,prs跟踪节点日志复制的进度。
关于心跳和选举超时的计时,etcd是用tick的方式来计算的,每两次tick之间的耗时其实就是心跳时间。所有的超时都是以tick的倍数来计算的,比如electionTimeout等于2,就是选举超时是两倍的心跳时间间隔。randomizedElectionTimeout就是raft协议中建议的,选举超时应该是在一个范围内的随机值,防止所有Follower在Leader超时后同时发起选举。
msg属性用来存储需要处理的消息,比如心跳。前面讲过raft模块只负责状态机的算法处理,而持久化及通信部分则交给调用方处理,这个msg就是存放需要处理的消息。
raft中最后两个属性tick和step是函数类型,在节点处于不同角色时,这个属性对应的方法实现是不一样的。比如tick()方法,在Follower中被调用时是检查距离上次收到Leader心跳是否超时,而在Leader中是向Follower发送心跳。step()方法也一样,不同的节点类型收到同一种消息时的处理逻辑是不一样的。

函数类型对Java程序员可能比较陌生,在有的语言中也叫方法指针。是指一个成员变量指向的是一个方法,在赋值的时候是将一个方法实现赋给这个变量

其它概念

  • HardState, 封装了raft协议中规定的需要实时持久化的状态属性:当前选举周期、投票和已提交的Index
type HardState struct {
    Term             uint64 `protobuf:"varint,1,opt,name=term" json:"term"`
    Vote             uint64 `protobuf:"varint,2,opt,name=vote" json:"vote"`
    Commit           uint64 `protobuf:"varint,3,opt,name=commit" json:"commit"`
    XXX_unrecognized []byte `json:"-"`
}
  • SoftState, 封装了raft协议中规定的无需持久化的状态信息:当前的Leader,节点角色
type SoftState struct {
    Lead      uint64 // must use atomic operations to access; keep 64-bit aligned.
    RaftState StateType
}
  • state,一个raft节点只可能是4中角色中的一种:
    Follower:接收Leader的日志
    Candidate: 发起投票的候选人
    Leader:发送心跳和日志给Follower
    PreCandidate:如果PreVote打开的话,在正式变成候选人之前需要获得超半数节点的同意,在征询意见时节点处于的角色
  • step,所有发给对状态机的请求,其实都是调用的它的step方法,对于不同的角色,处理Step方法的参数逻辑也是不一样的

RaftLog

从名字可以看出,RaftLog用于存储日志条目,它的所有数据都是在内存中。

type raftLog struct {
    // 上一次snapshot之后,已经被持久化的日志条目
    storage Storage
    // 所有还没有被持久化的日志条目
    unstable unstable
    // 已经commit的日志index
    committed uint64
    // 已经应用到状态机的日志的index
    applied uint64
    ...
    ...
}

RaftLog中的日志条目不是一直存着的,这样内存会爆掉,对于EtcdServer来说,已经应用到KV的日志条目不会再用到了(除非有Follower的日志落后很多),所以EtcdServer会定期对KV做Snapshot,然后告诉raft状态机可以删除已包含在快照中日志。

Storage

日志的持久化存储和最终应用不是raft协议关心的部分,它只是要求节点在收到日志并持久化后才能回复客户端成功。所以etcd并没有把持久化的部分放在raft状态机中,而是通过EtcdServer来做的。
WAL
WAL全称是Write Ahead Log,是数据库中常用的持久化数据的方法。比如我们更新数据库的一条数据,如果直接找到这条数据并更新,可能会耗费比较长的时间。更快更安全的方式是先写一条Log数据到文件中,然后由后台线程来完成最终数据的更新,这条log中通常包含的是一条指令。
etcd通过wal将日志持久化,wal中一条日志的结构如下:

type Record struct {
    Type             int64  `protobuf:"varint,1,opt,name=type" json:"type"`
    Crc              uint32 `protobuf:"varint,2,opt,name=crc" json:"crc"`
    Data             []byte `protobuf:"bytes,3,opt,name=data" json:"data,omitempty"`
    XXX_unrecognized []byte `json:"-"`
}
  • Type:日志的类型
    metadataType:元数据,代表Data中存的是节点信息,id、clusterid
    entryType:日志条目,代表Data中是收到的客户端的日志
    stateType:状态,每次HardState有变化时会写一条数据到wal中
    crcType:校验位,读WAL时会根据Crc的值校验防止文件损坏
    snapshotType:Snapshot记录,每做一次KV的snapshot都会记录一条wal日志,这是为了节点重启时恢复数据。

SnapShotter
etcd需要定期对KV做快照,快照的目的一是为了在新的Follower加进来时可以快速复制数据,二是做完快照后可以清除日志释放空间,三是重启时从快照中恢复比从日志中恢复要快的多。

KV
etcd是一个KV数据库,客户端写一条数据给etcd时,通过复制日志将数据发送到超过半数节点,然后写道KV中才会返给客户端成功。etcd的KV使用自研的boltDB实现,提供了事务和监听Key变化的功能。

总结

简单总结了etcd中raft相关的模块,为后面理解raft实现做准备。etcd中对于raft算法的实现都在raft这个struct中,而数据的存储以及集群节点间的通信通过EtcdServer来完成,很好的做了模块的解耦,也使其它的产品可以很容易的集成它的raft实现。
整个Server启动后运行的逻辑如下:

  • EtcdServer定时触发raft中的心跳接口,raft根据自身角色决定是发送心跳还是检查是否要发起选举
  • EtcdServer调用raft的Ready接口查看是否需要有数据处理,包括需持久化的日志,需要发给Follower的投票、心跳或者日志,需应用到KV的日志等
  • EtcdServer在收到其它节点的消息后调用raft的Step触发raft状态机处理
  • EtcdServer在收到客户端更新请求时,发送给raft处理,一直到请求在KV中生效,回复客户端成功

后续文章通过阅读源码来理解etcd中的选举、日志复制、持久化实现逻辑。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,919评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,567评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,316评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,294评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,318评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,245评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,120评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,964评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,376评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,592评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,764评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,460评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,070评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,697评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,846评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,819评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,665评论 2 354

推荐阅读更多精彩内容