在一言不合就谈论到分布式,高可用的互联网世界里,etcd是一个被广泛应用的组件——服务发现,分布式锁,kv存储等各种实践中都有它的身影。我平时也只是项目中使用到了etcd,并没有过多的了解,借着这次机会希望可以一点一点地深入剖析一下etcd。
what——什么是etcd?
知其然,才能知其所以然,首先需要了解一下什么是etcd:
etcd是由Go语言编写的一个高度一致的分布式键值存储集群
- 基于raft共识机制来管理集群,保证数据一致性
- 可用于服务发现,共享配置,应用调度,分布式锁等应用
在单机系统的时期,所有数据和操作都在一台机器上执行,不会涉及到数据不一致,顺序协调等问题。
但在广泛使用分布式系统的今天,所有分布式系统都会面临一个多节点之间的数据共享和一致的难题,而etcd提供就这么一套机制,解决这个问题:
- 使用HTTP协议对外提供服务
- 使用raft机制实现高可用的选主,日志复制和数据同步,保证了集群的稳定和数据一致性
- kv存储系统实现数据的存储
structure——etcd架构
先简单看一下etcd的结构
我们将多台服务器组成的etcd集群看成一个服务,用户的请求发送过来,由HTTP层转发给store存储层进行处理;如果是涉及修改的操作,需要经由raft模块进行状态变更,日志记录然后同步到各个集群中的其他节点判断操作是否成功,于此同时,需要将操作写入WAL——Write Ahead Log(预写式日志)进行持久化记录,最后才是同步成功,数据写入store层。
HTTP Server
用于处理用户发送的API请求以及其它etcd节点的同步与心跳信息请求。
RAFT
Raft强一致性算法的具体实现,etcd的核心。
WAL
Write Ahead Log(预写式日志),是etcd的数据存储方式,用于系统提供原子性和持久性的一系列技术。除了在内存中存有所有数据的状态以及节点的索引以外,etcd就通过WAL进行持久化存储。WAL中,所有的数据提交前都会事先记录日志。
-
Entry[日志内容]:
负责存储具体日志的内容。
-
Snapshot[快照内容]:
Snapshot是为了防止数据过多而进行的状态快照,日志内容发生变化时保存Raft的状态。
Store
用于处理etcd支持的各类功能的事务,包括数据索引、节点状态变更、监控与反馈、事件处理与执行等等,是etcd对用户提供的大多数API功能的具体实现。
共识机制 --Raft算法
学习etcd之前需要先了解一下它的核心,raft算法。
Raft算法来源于论文:In Search of an Understandable Consensus Algorithm,是工程上使用较为广泛的强一致性,去中心化,高可用的分布式共识算法,相比起Paxos算法,更容易理解和实现。
论文Consensus: Bridging Theory and Practice讲的更为详细,后面有机会可以再看看。
下面简单介绍一下raft算法的内容,不想阅读的朋友可以快速跳过,只要知道raft使etcd集群可以快速选主,保证系统的高可用性,同时raft的日志复制,数据同步使得etcd集群中保持了数据的强一致性。
1、服务器状态
raft集群服务器有三种状态Leader,Follower,Candidate。
- Leader:复制处理所有与客户端的交互和数据处理,协调follower,只有一个
- Follower:听从Leader差遣
- Candidate:当没有Leader的时候,所有服务器可以竞选Leader,向其他服务器拉票
状态变化如下图所示,后面会进行讲解。
2、Raft的任务
Raft 协议主要完成三项任务,分别是Leader选举,日志复制,安全性保证。
2.1 Leader选举
首先是Leader选举,Raft 使用心跳(heartbeat)机制触发Leader选举。在集群正常工作时,Leader会向所有Followers周期性发送heartbeat。如果Follower在选举超时时间内没有收到Leader的heartbeat,他就会变为Candidate,发起一次Leader选举。
先介绍一个概念:
- Term--任期:
raft算法将时间分为一个个的任期term(起到了逻辑时钟的作用),每个term分为选举时间和正常工作时间。当一个Leader挂掉,集群就会开始一轮新的选举,开始新的任期。如果这轮选举失败,这个任期会以没有leader结束。
选举的过程就是围绕着任期展开的。
1、首先,正常工作的时候。Leader会周期向follower发送心跳
2、如果这个时候,leader挂了。follower在选举时间超时(election timeout,一般是150~300ms的随机值)后没有收到心跳,转变为candidate。此时它会先开始一个新的任期,将任期+1,并投票给自己,然后并行发送 RequestVote消息给其它所有节点,这时会出现三种结果:
- candidate 取得超过一半的选票,当选Leader
- 收到leader消息,表面已经有leader产生,candidate转变成follower
- Election timeout后没有人获得一半以上投票,选举失败,等待超时发起下一次选举
Ps.图可能不太准确
为了保证上述情况,每个节点需要有以下约束
- 在每个任期中每个人只能投出一票
- Candidate肯定投给自己,Follower是先到先得(他的election timeout还没到)
- 当选leader的条件是得到多数(N/2+1)选票:此处的多数选票是为了避免脑裂而出现多leader的情况而进行的约束,保证了整个集群中leader的唯一性
- Leader的Term必须是最大的
- 节点尽量是单数个,保证leader能产生;election timeout随机,防止多次同时开始选举
2.2 日志复制
Leader选举保证了整个集群的稳定性和统一性。当leader选出后,集群就可以接受客户端请求。
客户端的请求由leader处理,写入日志队列,然后向其他服务器发起 AppendEntries 复制日志条目。如果这条日志被大部分的服务器接受,那么就会应用到状态机上并向客户端返回成功。
2.2.1 日志复制流程
整个日志复制(Log Replication)分为两步:复制(Replication) 和 提交(Commit)。Leader收到消息并将消息添加到自己的日志队列中,然后将消息发送给其它所有的节点的过程为复制。被大部分服务器接受后Leader将该消息提交到状态机中,并返回状态机执行的结果给所有服务器(follower也要写入日志队列和状态机)的过程为commit,commit了的消息为实际写入,不能回滚。如果follower 挂了或因为网络原因消息丢失了,主节点会不断重试直到所有从节点最终成功复制该消息。
Ps.看起来这种属于最终一致性,但是最终一致性是对内的,对外部的client的透明的,外部的client只会看到leader上状态的强一致性。(读请求可以打到follower上,但follower会向leader 请求一个index确保信息一致)
2.2.2 日志格式
日志由多条log entry组成,每条entry由日志编号(log index)、任期号(term)、日志内容组成
Ps.发了8个请求,commit了7条
Raft的日志保证了下面两点特性:
- 如果不同日志中的两个条目有着相同的索引和任期号,则它们所存储的命令是相同的。
- 如果不同日志中的两个条目有着相同的索引和任期号,则它们之前的所有条目都是完全一样的。
这是怎么做到的呢?
对于第一条特性,Leader在一个term内在给定的一个log index最多创建一条日志条目,同时该条目在日志中的位置也从来不会改变。
对于第二条特性,在 folloswer收到AppendEntries后会进行简单的一致性检查。Leader会把新日志条目紧接着之前的条目的log index和term都包含在AppendEntries请求中。如果Follower没有在它的日志中找到log index和term都相同的日志,它就会拒绝新的日志条目。
2.2.3 特殊情况下的日志复制
一般而言,AppendEntries都能通过一致性检查,follower日志也都能和leader保持一致。但也会出现一些特殊情况。(据说下图包含了所有特殊情况?)
当一个Leader掌权后:
- (a)(b)可能缺失了一些条目
- (c),(d),(e),(f)和leader日志保持同步的同时,还有额外的未提交的entries
- (f)场景比较特殊,就是在任期二、任期三的时候都成为了领导,都接收到了新的entries,但是这些entries还没有commit的时候,就下台了。且后期一只没有上线,没有收到新的log
为了解决冲突,leader的日志会保持不动,然后强制更新follower上的日志和leader保持一致。主要的操作流程为:
- 1、通过AppendEntries 找到日志冲突点,就是follower从哪个位置开始和leader的日志不一致了
- 2、leader把follower日志冲突点以后的日志强行刷新成自己的。
具体细节就是leader会向follower不间断的发送AppendEntries请求,如果follower返回false的话,那就证明follower和leader不一致。那么leader发送的AppendEntries就会把index减1再次发送,找到冲突开始的index后,Leader 将之后的日志条目按顺序发送给 Follower,Follower 开始覆盖本地日志。这里的所有操作,均通过 AppendEntries 请求完成。
Ps.论文中有提到的一个优化手段就是AppendEntries请求返回失败同时,follower也返回冲突entry所在的任期和所在任期的第一个entry对应的logIndex。通过这两个信息,leader调整下次发送的prevLogIndex和prevLogTerm,可以减少rpc请求。
在这种日志复制的机制下,为了将集群日志恢复为一致的,Leader 不需要进行任何特殊的操作,只需要和正常情况下发送 AppendEntries 请求,在日志一致性校验的作用下,不一致的日志就会自动的变为一致。这样 Leader 就只会追加日志,而不需要删除或覆盖自己的日志。
从这里可能有人会问,这种追加不是有可能会造成数据丢失?这个在后面安全性的环境会解释--只有拥有最新最全数据额follower才能被选为leader。
2.3安全性保障
在正常情况下,上面两个任务可以维持集群的正常运作,但是要保证所有的状态机有一样的状态,单凭前几节的算法还不够。例如有 3 个节点 A、B、 C,如果 A 为主节点期间 C 挂了,此时消息被多数节点(A,B)接收,所以 A 会提交这些日志。此时若 A 挂了,而 C 恢复且被选为主节点,则 A 已经提交的日志会被 C 的日志覆盖,从而导致状态机的状态不一致。 因此Raft增加了如下两条限制以保证安全性:
- 1、拥有最新的已提交的log entry的Follower才有资格成为leader。
在Candidate在发送RequestVote RPC时,要带上自己的最后一条日志的term和log index,其他节点收到消息时,如果发现自己的日志比请求中携带的更新,则拒绝投票。日志比较的原则是,如果本地的最后一条log entry的term更大,则term大的更新,如果term一样大,则log index更大的更新。
- 2、 Leader只能推进commit index来提交当前term的已经复制到大多数服务器上的日志,旧term日志的提交要等到提交当前term的日志来间接提交(log index 小于 commit index的日志被间接提交)。
在阶段a,term为2,S1是Leader,且S1写入日志(term, index)为(2, 2),并且日志被同步写入了S2;
在阶段b,S1离线,触发一次新的选主,此时S5被选为新的Leader,此时系统term为3,且写入了日志(term, index)为(3, 2);
S5尚未将日志推送到Followers就离线了,进而触发了一次新的选主,而之前离线的S1经过重新上线后被选中变成Leader,此时系统term为4,此时S1会将自己的日志同步到Followers,按照上图就是将日志(2, 2)同步到了S3,而此时由于该日志已经被同步到了多数节点(S1, S2, S3),因此,此时日志(2,2)可以被提交了。;
在阶段d,S1又下线了,触发一次选主,而S5有可能被选为新的Leader(这是因为S5可以满足作为主的一切条件:1. term = 5 > 4,2. 最新的日志为(3,2),比大多数节点(如S2/S3/S4的日志都新),然后S5会将自己的日志更新到Followers,于是S2、S3中已经被提交的日志(2,2)被截断了。
增加上述限制后,即使日志(2,2)已经被大多数节点(S1、S2、S3)确认了,但是它不能被提交,因为它是来自之前term(2)的日志,直到S1在当前term(4)产生的日志(4, 4)被大多数Followers确认,S1方可提交日志(4,4)这条日志,当然,根据Raft定义,(4,4)之前的所有日志也会被提交。此时即使S1再下线,重新选主时S5不可能成为Leader,因为它没有包含大多数节点已经拥有的日志(4,4)。
2.4 日志压缩
在实际的系统中,不能让日志无限增长,否则系统重启时需要花很长的时间进行回放,从而影响可用性。Raft采用对整个系统进行snapshot来解决,snapshot之前的日志都可以丢弃(以前的数据已经落盘了)。
Snapshot中包含以下内容:
- 日志元数据。最后一条已提交的 log entry的 log index和term。这两个值在snapshot之后的第一条log entry的AppendEntries RPC的完整性检查的时候会被用上。
- 系统当前状态。
当Leader要发给某个日志落后太多Follower的log entry被丢弃时,Leader会将snapshot发给Follower。或者当新加进一台机器时,也会发送snapshot给它。
到这里我们已经简单的把raft算法了解一下,知道它可以保障整个集群只有一个leader,保障集群的数据一致性和稳定性。
etcd架构分析
etcd 更详细的架构图如下所示(本部分参考自极客时间--etcd实战课)
- client层
etcd的client层提供了简单易用的API,支持负载均衡,节点故障自动转移等功能,提高了开发效率以及服务的可用性。(客户端不需要知道谁是Leader谁是follower)
- API层
API层主要包括client和server直接的通信方式和协议,主要包括Grpc,HTTP(raft选主,日志复制服务器之间使用http)等
- Raft 算法层
Raft层实现了Leader选举,日志复制,readIndex等核心特性
- 功能逻辑层
功能逻辑层实现了etcd的核心特性,如KV存储,鉴权,租约管理,压缩,Mvcc等
- 存储层
包含预写日志(WAL)--保证etcd crash后数据不丢失、快照(SnapShot)--日志压缩、boltdb--一个嵌入式key/value的数据库,保存了集群元数据和用户写入的数据(存在磁盘)
Etcd适合读多写少的存储场景,下面先介绍一下etcd读写的流程
1、etcd读流程
当我们发起一个读请求(线性),客户端通过api层调到server的逻辑层(KV server) -- 1,2
KV server收到线性读(ReadIndex)请求后,会先向Leader获取集群最新的commited index(已提交日志序号);而Leader收到这个来自follower的ReadIndex请求,会先向所有follower发送心跳确认(防止脑裂);一半以上的节点确认Leader身份后将commited index返回给这个节点。-- 3,4
节点等待直到状态及已经将应用索引更新到大于等于返回的commited index后,才通知KV server可以去状态机访问数据了。 -- 5
(串行读不需要通过raft,直接读,适用与数据不敏感场景,有可能读到旧/脏数据)
从图中的MVCC模块获取到数据之后,既可返回给用户了 -- 6,7
到此,etcd的读操作就完成了。
1.1、再简单介绍一下etcd 的 MVCC模块
MVCC模块是为了保存key的多个历史版本,支持多key事务。他由内存树形索引(treeIndex)和KV数据库boltdb组成。
在每次key的修改操作时,都会产生一个新的版本号revision,存到内存中的treeIndex中。
因此内存中的treeIndex存放的是(key:用户传过来的key,value:版本号revision),boltdb中存的是(key:版本号revision,value:用户要的数据信息)
实际上,treeIndex的value存放不仅仅是revision,而是一个包含revision的结构体keyIndex
type keyIndex struct{
key []byte // 用户的key名称
modified revision //最后一次修改key的版本号
generations []generation //保存了key的若干代版本号信息,每代都包含了key的修改记录
}
type generation struct{
ver int64 //key到目前为止的修改次数
created revision // generation创建时的版本号
revs []revision//每次修改key时的revision追加到这个数组
}
type revision strct{
main int64 // 一个全局递增的主版本号
sub int64 //一个事务内部的子版本号
}
举个例子,我们第一次发起一个put hello为world操作,etcd会生成一个对应的版本号(启动式默认为1)--revision{2,0},产生的keyIndex即为
keyHello := &keyIndex{
key:"hello",
modified:<2,0>,
generation:[{ver:1,created:<2,0>,revision:[<2,0>]}]
}
而revision<2,0>会作为boltdb的key存入到boltdb中去。
再次发起一个put hello为world2的操作时,keyIndex会被修改成
keyHello := &keyIndex{
key:"hello",
modified:<3,0>,
generation:[{ver:2,created:<2,0>,revision:[<2,0>,<3,0>]}]
}
同样,新的revision也会作为key存到boltdb中去。此时如果发起一个旧版本的读请求,treeindex会遍历generation中的历史版本号,返回对应的revision,然后去boltdb查数据
而对于删除操作,etcd使用的是延期删除操作,方式和key更新类似。
当发起一个删除操作时,生成一个revision<4,0,t>,t为删除标识,generation追加一个空的对象表示该key被删除
keyHello := &keyIndex{
key:"hello",
modified:<4,0>,
generation:[
{ver:3,created:<2,0>,revision:[<2,0>,<3,0>,<4,0>(t)]},
{empty}
]
}
boltdb同样也会插入key为<4,0,t>的,value只有用户key-value的数据;真正的删除会在后续进行,这样也可以防止误删除,及时找回数据。
另外,boltdb存储的value实际上也不仅仅只是用户的key-value信息,它由用户的key,value,created_version,mod_revison,version,lease组成。
created_version -- key创建时的版本号,上面的例子是2
mod_revison -- key最后一次修改的版本号
version --此key的修改次数
2、etcd写流程
当客户端发起一个写请求,首先通过负载均衡选择一个etcd节点,然后通过api层调用到server -- 1,2
写请求到server会首先通过Quota模块 -- 3
Quota模块: Quata模块是一个配额模块,限定了db能写入的文件大小,当发起一个写请求,当前db的大小加上你写入的kv大小之和超过了这个配额,会产生一个告警,并通过Raft模块同步给其他节点,此时的节点会拒接写入,变成只读。 Ps. etcd的compactor压缩模块负责回收压缩旧版本信息,防止db一致膨胀
当请求通过配额检查后,KV server会将请求打包成一个提案信息,然后发送给raft模块进行处理(发送之前会进行一系列限速检查,这里先不展开了),KV server把提案交给raft模块会进行等待,如果超过一定时间没有返回结果则给client返回超时错误 -- 4
Raft收到提案后,会把信息转给Leader,Leader才能处理任务;Leader 收到消息后,会把消息广播给所有follower并且写入WAL日志文件中(消息包括集群leader任期号,投票信息,已提交索引,提案内容等) --5
WAL: WAL的结构如上图所示,支持多种类型的记录
当一半以上的节点回复接受这条消息后,raft模块就会commit这条消息,然后通知 etcd server -- 6
etcd server 通过apply模块,将数据写入boltdb进行持久化-- 7,8,9
最后将执行结果返回给KVserver 返回给client --10
但是,在6的过程中,如果节点crash了,这么重新找回数据,保证数据幂等呢?
1、WAL:已经Commit了的日志都会写到WAL日志中,重启时可以从WAL中提取出日志交给Raft模块,重放给apply模块进行执行写入db
2、保证幂等:raft日志索引唯一,通过一个apply index存储已经执行了的索引,防止重复执行
Reference
www.huweihuang.com/kubernetes-…
zhuanlan.zhihu.com/p/32052223 time.geekbang.org/column/intr…