初识Etcd

376 阅读18分钟

在一言不合就谈论到分布式,高可用的互联网世界里,etcd是一个被广泛应用的组件——服务发现,分布式锁,kv存储等各种实践中都有它的身影。我平时也只是项目中使用到了etcd,并没有过多的了解,借着这次机会希望可以一点一点地深入剖析一下etcd。

what——什么是etcd?

知其然,才能知其所以然,首先需要了解一下什么是etcd:

etcd是由Go语言编写的一个高度一致的分布式键值存储集群

  • 基于raft共识机制来管理集群,保证数据一致性
  • 可用于服务发现,共享配置,应用调度,分布式锁等应用

在单机系统的时期,所有数据和操作都在一台机器上执行,不会涉及到数据不一致,顺序协调等问题。

但在广泛使用分布式系统的今天,所有分布式系统都会面临一个多节点之间的数据共享和一致的难题,而etcd提供就这么一套机制,解决这个问题:

  • 使用HTTP协议对外提供服务
  • 使用raft机制实现高可用的选主,日志复制和数据同步,保证了集群的稳定和数据一致性
  • kv存储系统实现数据的存储

structure——etcd架构

先简单看一下etcd的结构

image-20220722233236786.png 我们将多台服务器组成的etcd集群看成一个服务,用户的请求发送过来,由HTTP层转发给store存储层进行处理;如果是涉及修改的操作,需要经由raft模块进行状态变更,日志记录然后同步到各个集群中的其他节点判断操作是否成功,于此同时,需要将操作写入WAL——Write Ahead Log(预写式日志)进行持久化记录,最后才是同步成功,数据写入store层。

HTTP Server

用于处理用户发送的API请求以及其它etcd节点的同步与心跳信息请求。

RAFT

Raft强一致性算法的具体实现,etcd的核心。

WAL

Write Ahead Log(预写式日志),是etcd的数据存储方式,用于系统提供原子性和持久性的一系列技术。除了在内存中存有所有数据的状态以及节点的索引以外,etcd就通过WAL进行持久化存储。WAL中,所有的数据提交前都会事先记录日志。

  1. Entry[日志内容]:

    负责存储具体日志的内容。

  2. 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,向其他服务器拉票

状态变化如下图所示,后面会进行讲解。

image-20220723000608460.png

2、Raft的任务

Raft 协议主要完成三项任务,分别是Leader选举日志复制安全性保证

2.1 Leader选举

首先是Leader选举,Raft 使用心跳(heartbeat)机制触发Leader选举。在集群正常工作时,Leader会向所有Followers周期性发送heartbeat。如果Follower在选举超时时间内没有收到Leader的heartbeat,他就会变为Candidate,发起一次Leader选举。

先介绍一个概念:

  • Term--任期:

image-20220723001345079.png

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后没有人获得一半以上投票,选举失败,等待超时发起下一次选举

image-20220723002931531.png Ps.图可能不太准确

为了保证上述情况,每个节点需要有以下约束

  • 在每个任期中每个人只能投出一票
  • Candidate肯定投给自己,Follower是先到先得(他的election timeout还没到)
  • 当选leader的条件是得到多数(N/2+1)选票:此处的多数选票是为了避免脑裂而出现多leader的情况而进行的约束,保证了整个集群中leader的唯一性
  • Leader的Term必须是最大的
  • 节点尽量是单数个,保证leader能产生;election timeout随机,防止多次同时开始选举

2.2 日志复制

Leader选举保证了整个集群的稳定性和统一性。当leader选出后,集群就可以接受客户端请求。

客户端的请求由leader处理,写入日志队列,然后向其他服务器发起 AppendEntries 复制日志条目。如果这条日志被大部分的服务器接受,那么就会应用到状态机上并向客户端返回成功。

image.png

2.2.1 日志复制流程

整个日志复制(Log Replication)分为两步:复制(Replication)提交(Commit)。Leader收到消息并将消息添加到自己的日志队列中,然后将消息发送给其它所有的节点的过程为复制。被大部分服务器接受后Leader将该消息提交到状态机中,并返回状态机执行的结果给所有服务器(follower也要写入日志队列和状态机)的过程为commit,commit了的消息为实际写入,不能回滚。如果follower 挂了或因为网络原因消息丢失了,主节点会不断重试直到所有从节点最终成功复制该消息。

image.png Ps.看起来这种属于最终一致性,但是最终一致性是对内的,对外部的client的透明的,外部的client只会看到leader上状态的强一致性。(读请求可以打到follower上,但follower会向leader 请求一个index确保信息一致)

2.2.2 日志格式

日志由多条log entry组成,每条entry由日志编号(log index)任期号(term)日志内容组成

image.png 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保持一致。但也会出现一些特殊情况。(据说下图包含了所有特殊情况?)

image.png 当一个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 请求完成。

image.png

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的日志被间接提交)。

image.png 在阶段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之前的日志都可以丢弃(以前的数据已经落盘了)。

image.png Snapshot中包含以下内容:

  • 日志元数据。最后一条已提交的 log entry的 log index和term。这两个值在snapshot之后的第一条log entry的AppendEntries RPC的完整性检查的时候会被用上。
  • 系统当前状态。

当Leader要发给某个日志落后太多Follower的log entry被丢弃时,Leader会将snapshot发给Follower。或者当新加进一台机器时,也会发送snapshot给它。

到这里我们已经简单的把raft算法了解一下,知道它可以保障整个集群只有一个leader,保障集群的数据一致性和稳定性。

etcd架构分析

etcd 更详细的架构图如下所示(本部分参考自极客时间--etcd实战课) image.png

  • 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读流程

image.png

当我们发起一个读请求(线性),客户端通过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:用户要的数据信息)

image.png

实际上,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写流程

image.png

当客户端发起一个写请求,首先通过负载均衡选择一个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

image.png

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-…

www.modb.pro/db/81902

zhuanlan.zhihu.com/p/32052223 time.geekbang.org/column/intr…