etcd在存储数据的时候主要分为了两个部分,第一部分叫做kvstore,这个kvstore是存储在内存当中的,这个是in memory的kvstore,任何数据库都需要做索引,所以etcd就是通过这种方式在内存当中构建了索引,这个索引的目标是来做快速的查找。
另外后端会有真正落盘的数据库,etcd默认使用blotdb去实现的,blotdb是Google开源的key value数据库,当你任何数据要去存储到etcd store的时候,它同时会去存索引和落盘,通过blotdb去落盘。(最终的数据持久化是基于blotdb)
watchablestore是干嘛的呢?因为etcd本身是支持监听的,你可以去监听某个对象的事件,这些对象就会组织在watchablestore里面。
存储机制 kvstore blotdb
etcd v3 store分为两部分∶一部分是内存中的索引,kvindex,是基于Google开源的一个Golang的Btree实现的,另外一部分是后端存储。按照它的设计,backend可以对接多种存储,当前使用的是boltdb。
boltdb是一个单机的支持事务的KV存储,etcd的事务是基于boltdb的事务实现的。etcd在boltdb中存储的key是reversion, value是etcd自己的key-value组合, 也就是说 etcd 会在 boltdb 中把每个版本都保存下,从而实现了多版本机制。
存储机制 reversion
reversion主要由两部分组成,第一部分main rev,每次事务进行加一,第二部分 sub rev,同一个事务中的每次操作加一。
对于任何的数据都有reversion的概念,它相当于是一个版本的信息,reversion主要由两部分组成,一部分是main rev,一部分是sub rev。
下面去get一个key,-wjson是将其细节打印出来,这里面可以看到有create mode reversion,这里就有一个版本的概念,对于任何etcd里面存的对象,它都有版本的概念,有个当前的reversion 3。
reversion是当前集群类似于自增长的这么一个值,当我们去对整个集群做任何数据修改的时候,它的reversion就会增加,reversion分为了sub和main两个部分,如果你开了一个事务,那么这个事务里面所有写操作可以公用mian reversion,然后里面子命令对应sub reversion。
对于k8s来说,我们大部分时候使用main reversion,sub reversion没有用。
回过头看k8s对象的时候,有个resource version,可以将它理解为乐观锁,其实这个resource reversion就和etcd里面的对象mod reversion是一 一对应的,当对etcd做数据变更的时候,这个etcd mod revesion也会跟着变化。k8s在读取对象的时候就会以mod reversion作为它的resource version。
客户端要发起一个写的请求,假设这个请求发给了follower,像预检查,kv这块处理是一致的,最终这个请求会转给一致性模块,一致性模块会去判断自己是不是leader,如果是leader就直接处理,如果不是就转给leader。
预检查
请求到了leader要做一些预检查,第一个配额,就是etcd有数据大小的配置,你这次写请求还能不能放进去。
第二要限速,作为一个服务器要频繁的调取我的写操作会把我压垮的。
第三就是去认证鉴权(你是谁,有没有读写权限)。
第四会去做数据包检查,它会做一些限制,如果数据包超过1.5M,它就不让写了。如果数据包太大了,它反复确认,包括后面做索引,做查询,它的开销就非常的大,会导致etcd的性能直线下降。因为它需要多次确认,需要数据同步,它就会导致数据同步的效率非常低,所以它做了一些限制,让你不要无休止的去增加,把数据变的非常大,在k8s里面,很难将yaml写成1.5M以上的,yaml不是无限增长的,而是有一定限制的。
kvserver
这些预检查做完之后,请求会被发送到主模块叫做kvserver,kvserver接收到请求发现是一次写请求,比如是y=9,它会把请求通过propose方法发到一致性模块。
一致性模块 选主/日志复制
一致性模块实现两件事,它其实就是raft协议的实现者,第一,它会实现选主,第二就是日志复制,它会在etcd的内存里面构建raft log,这个raft log其实是一个数据结构,这个raft log其实是一个数据结构,它会先将自己的信息y=9写到unstable里面,记录了我有一条数据要写入,接下来这个请求会被同时写入到本地的wal log,就是写y=9,将y=9的这条日志写到本地,这个写入最后是要落盘的,不能每次写入都落盘这样效率太低了,wal log落盘其实是由fsyn,就相当于前端写在buffer里面,最后通过fsync周期性的将这些事件正真的落在磁盘上面。
写wal log的同时它有另外的goroutine去把同样的message,就是通过一个append message发到其他follower那里,follower那边收到请求之后,它要做同样的事情,就相当于要将写操作写入wal log。
并且写完以后回复response,回到leader这边,leader发现有半数确认了这条日志的写入,那么它就认为这条数据已经commit了,所以它会更新自己的数据结构,更新match index,每条写入都有自增长的index,它会记录match index往前走了。
同时它这个请求会以apply的方式请求状态机来记录这次数据。状态机基于mvcc模块,就是多版本并发控制的这么一个模块,这里面包含了kv的index和blotdb,经过半数确认之后要往mvcc里面去写。
MVCC
来看看在memory和boltdb里面怎么存储的,首先wal log,它有type,有index,有term,还有最终的数据,这些数据经过确认之后需要往mvcc里面去写,在treeindex也就是内存里面,它会记录当你写入数据的key,就是y,这次写入9,但是可能有很多次的变更,每次变更都有历史变更信息的,所以在tree index里面,它的key是写入数据的key,但是value并没有保存任何真实的值,而是保存了这个key所有关联的版本信息,这里就会有modfiy version,就是当前最后一次的变更信息,还有一个generation,就是你这个key有可能创建删除很多次的,它每次创建删除都是generation的一代。
所以对于历史信息他就会存generation,每个generation里面都有哪些reversion,reversion又是全局唯一的,所以通过这样的一个索引就知道一个键值以前有过多少版本,当前的版本是什么,以前有多少代,每代有多少版本。
这样其实一个key的完整的生命周期,所有的变更都记录在这里面了。
落盘的地方,它也是键值对,但是它的key是reversion,它以reversion为key,value就是key-value的完整的信息。包括创建的reversion,nodify的reversion。key是什么,version是什么,value是什么。
也就是整个数据变更的信息作为value存放在boltdb里面。
这些整个的信息会和版本信息对应。
通过这种方式实现了对任意的一个key完整生命周期的监听,不管更新了多少次,这边都存着一份所有版本的信息。
这也就是为什么,频繁变更的时候blot或者etcd里面的存储数据会变大,因为它所有的历史信息都保存下来了,它占用的空间就大了。
文字详细叙述上图过程
(1)当客户端对etcd发起请求的时候,如果etcd不是leader的状态而是follower,follower则会将请求转发leader; 如果是leader后, 会对其进行预检查,检查(配额、限速、鉴权【判断请求是否合法】、包大小【需要小于1.5M,过大则会拒绝】)。(2)如果请求本身是合法的,会将请求转发给KVServer处理。
(3)KVserver一致性模块进行数据处理,一致性模块是基于raft协议实现的,这时候的数据本身是处于unstable状态。
(4)当leader该数据处理unstable状态后,会通过rpc通知其他follower也来同步该数据,并且leader本身会在数据同步到日志模块【wal日志, wal日志通过fsync落盘到磁盘中】。而其他follow在同步该数据的时候,本身完成的是步骤3和数据同步到日志模块,follower一致性模块数据变成commited状态,当完成了这些后通过上次rpc返回响应体给leader。
(5)leader在收到了超过半数集群本身确认后,更新MatchIndex, 一致性模块中数据本身由unstable变化成commited状态。这时候通过MVCC模块【treeIndex和BoltDB开源组件组成】进行状态机的写入,将数据同步到treeIndex【会更新modified版本[当前版本号], generations信息[创建的版本,当前版本数,过往的所有版本号]】。再通过BoltDB落盘到磁盘中。这时候一致性模块数据由commited变化为applied状态。【在这里如果没有要求数据强一致性,弱一致性的话,那么数据在commited状态就认为数据已经同步完成了】。
(6)再通过heatbeat将数据同步到follower中MVCC模块中。最终完成数据的一致性。如下图所示。 【如果follower比leader落后好几个版本,leader会通过headbeat带到follower进行同步】。
有个matchindex,在leader这一端,它会保存一个信息,第一个term大家都写了a和b,然后后面就变为第二个term,在第二个term里面又发生了很多的变更,wal log里面存了a b c d e f g,这里有8次变更,每次变更都会在etcd里面去维护一个自身的index,leader这边都会维护一个matchindex,代表leader和哪个index保持一致了。
这里有leader a,follower b c,以提交的日志是代表超过半数确认的日志,这三个人组成的集群超过半数确认的是到第7个index,也就是当前的match index是7,第8还没有被多数确认,它并不是一个正真确认过的index,如果重新选举的话,c的log本身比leader的commit index是要小的,如果c去作为新的leader就有可能丢数据了。
之所以记录index,就是用来确保说拥有最新数据的这些人,才可以去做新的leader,c是做不了新的leader的,所以通过index从leader这边记录一下,最新的commit log的index已经到哪里了,来确保新的leader永远包含所有已经确认的数据,通过这样的方式保证数据的一致性。
etcd除了上面的提供基本的读写功能,还提供了watch的机制。
watch类型
watch又分为两类,一类是watch某一类的key,这种叫做key watcher,一类是通过--prefix,通过模糊匹配来查询以斜杠开头的所有key的变更,这种叫做range watcher。
etcd V3的 Watch机制支持Watch某个固定的 key,也支持Watch一个范围(可以用于模拟目录的结构的Watch),所以 watchGroup 包含两种 watcher,一种是key watchers,数据结构是每个key 对应一组 watcher,另外一种是 range watchers,数据结构是一个IntervalTree,方便通过区间查找到对应的watcher。
如何满足watch请求
etcd如何满足watch请求,就是之前说的watchablestore,它会开辟了一片内存空间,来满足watch的需求,watchablestore里面分了两个组,一个叫sync group,一个叫unsync group。
当你要去获取最新数据的时候,没有增加reversion,那么这个时候就可以直接将内存当中的数据给你,这种watch叫做sync group。
如果是带着历史的reversion,那么在整个内存当中是没有这个信息的,它要去db里面加载,所以你去拿unsync group当中数据的时候这个请求会被发到unsync group,发到unsync group之后,etcd会有backend的也就是背后会去启动goroutine去同步数据,当将数据同步到内存之后,然后会把watch发到sync group,然后将数据同步给你。
同时,每个WatchableStore 包含两种 watcherGroup,一种是 synced,一种是 unsynced,前者表示该 group 的watcher数据都已经同步完毕,在等待新的变更,后者表示该group 的watcher 数据同步落后于当前最新变更,还在追赶。
当 etcd收到客户端的 watch请求,如果请求携带了revision参数,则比较请求的revision和
store 当前的revision,如果大于当前revision,则放入 synced组中,否则放入 unsynced 组。
同时,etcd 会启动一个后台的 goroutine 持续同步 unsynced 的watcher,然后将其迁移到Synced 组。
在这种机制下,etcd V3支持从任意版本开始 watch,没有V2的1000条历史 event表限制的问题(当然这是指没有compact的情况下)。
这里有两个窗口,一个更新一个watch,左边的窗口可以看到有put的操作,不断的变更,左边的窗口就不断收到通知,一个个的put事件,
-wjson将当前数据的详细信息展示出来
可以看到key values本身是加密过的了,记录了创建的reversion是多少,可以看到创建对象的时候的它的一个状态。这个信息还是存放在etcd里面,虽然这个值已经改掉了,但是还是存储在db里面。
可以看到依次记录了这几次reversion的变化,如果其他线程改变了值,那么reversion还是会跳的。
左边的watch窗口关闭重开,不加任何的reversion,也就是从当前的版本给我通知新的事件,如果有新的变更,这个时候这些对象没有发生变化,所以它就没有任何的通知。
也可以指定从4的版本开始watch,可以看到将所有的变更都发给我了
上面就是etcd多版本的变更。