ETCD原理

本文会对etcd的原理进行介绍,由于raft协议相对复杂,因此本文先不讲raft协议,之后会单独结合raft协议讲etcd的raft协议

什么是etcd

etcd是分布式系统中的配置中心,将etcd拆开来看,ETC +D(distributed),也就是为分布式系统存储配置信息(这可能和小部分开发者认为的etcd的作用有些出入,很多人潜意识认为etcd就是用于服务发现、发布订阅等,其实这和配置信息没有冲突),当然这个配置信息功能又和业务侧使用的apolo等配置中心有些差异,apolo等配置中心主要用于最上层的业务开发的信息配置,而ETCD则是用于服务注册中心等基本架构的配置

etcd基于Raft协议保证数据的强一致性(Raft协议后续会单独讲一讲),正式etcd的强一致性,使得更好地用于服务注册与发现以及分布式锁等功能

总体来说,etcd扮演两大角色:

  • 持久化的key-value存储系统

  • 分布式系统数据一致性提供者

目前etcd为v3版本,已经比较成熟了,因此本文主要基于v3版本的特性来进行原理解析

etcd整体架构

etcd的整体架构图如下所示

ETCD原理_第1张图片

 

各个模块作用如下:

  • Http Layer: 用于节点间通信以及client-server端通信(etcd任意节点之间都保持着长连接,并且是相互连接)

  • Raft:提供Raft算法具体实现(Raft算法即leader选举算法)

  • Store: 数据存储功能,设计KV存储,Wal日志文件,snapshot文件等(Wal日志文件可以看做类似mysql的binlog/redo等日志文件)

  • Replicate State Machine:复制状态机,保证节点数据一致性的逻辑,通常和Wal日志文件等结合

Etcd数据通道

etcd网络数据通道如下

ETCD原理_第2张图片

 

etcd之间的网络如下:

  • leader节点向follower节点发送心跳包

  • leader向folloer发送日志追加信息(即数据变更日志)

  • leader向foller发送snapshot

  • Candidate发起选举,向其他节点发送投票请求

  • foller将受到的写操作转发给leader

etcd的数据也是通过Protobuf进行编码,并且抽象了两种数据通道

  • stream通道,长连接,处理数据量比较小的消息,比如心跳包,日志追加消息等

  • pipeline通道,通过短连接进行传输,一般用于传输数据量大的消息,比如snapshot

网络层与Raft模块的通信

网络层与Raft模块是通过golang的Channel通道来进行通信

当节点收到数据请求后,将数据放到一个抽象的消息盒子中,由goroutine将消息盒子的数据写入到管道中,而管道的另一层,有另一批goroutine在select,当收到数据后,goroutine就继续执行逻辑

同理,当节点需要主动发送数据给其他节点时,也是由Raft模块将数据放到管道中

client端对etcd server端的交互

通过client端的端口进行HTTP通信

etcd server端之间的通信

通过peer端口使用HTTP进行通信

典型使用场景

  • 服务注册于发现

  • 消息发布与订阅

  • 负载均衡

  • 分布式通信与协调

  • 分布式锁

  • 分布式队列

可以看到,对于大多数场景,其实都是基于ETCD的数据强一致性以及client-server的端watch操作(及时同步数据)

etcdctl

一般使用etcdctl命令行作为客户端来查看或者操作etcd数据,用法见

https://github.com/etcd-io/etcd/tree/main/etcdctl

常用命令:

  • 写入操作 etcdctl put $key %value

  • 读取某个key, etcdctl get $key

  • 读取某个前缀的所有key, etcdctl get --prefix $prefix --print-value-only

  • watch命令,etcdctl watch --rev=2 foo foo9,--rev表示watch从revision=2开始的历史更新,foo foo9表示一个key范围,即可以监听一个或多个key

  • 租约,etcdctl lease grant 20,获取一个20s的租约,会返回一个租约ID,etcdctl put --lease=694d7acbd7ad5d0c foo9 bar,给key赋租约

etcd特性

  • Key range取代键值对和目录,v2是以/为开头,如果不指定的话,key就放在/目录下,所有的key都是以/开头,也可以通过参数指定,创建一个目录,同样也可以在目录下创建键,这个结构类似文件系统的树状结构,v3之后已经没有目录的概念,使用半开区间(通常被成为key range),比如 [foo, foo3)表示以foo为前缀,截止到foo3的所有key(不包含foo3), 比如etcd中有foo, foo1, foo2, foo3,那么foo,foo1,foo2都能通过命令etcdctl get foo foo3查找出来,如果右区间为\0,则表示所有前缀为左区间的key, 右区间指定其他的值都有特殊的含义,总之这个变化既保留了对目录形式的key的查找能力(目录也可以看做是前缀),也增加了对单个key, key前缀的查找能力

  • revision, etcd有个全局计数器,单调递增,只有有数据变更,revision就会变化,而每个revision都关联对应修改的数据,从底层来看,这些修改的数据,在底层B+树中,使用了revision作为索引,这个特性还有助于MVCC,通过这个revision,还可以watch命令watch某一个revision之后的变更; revision的另一个作用就是对于断开重连的客户端,由于客户端记录了比较小的revision,那么重新watch的时候还能watch到从断开时的revision到最新的revision中的数据变更,revision还可用于乐观锁

  • TTL 键可以设置过期时间

  • cas操作,可以通过prev等参数,进行cas写操作

  • 使用grpc框架,使用protobuf协议,以及http2,一个客户端与服务端只需要一个tcp连接

  • 租约,可以将某个key或者多个key绑定一个租约,到期之后绑定的key都会被删除,更新租约的过期时间,绑定的key都生效

  • 连续watch,etcd支持对于一个key连续watch,而不需要重新发起watch

  • 数据存储模型,保存了key的所有历史变更,使用线段树来支持范围,前缀查询,实现了MVCC, 由于保存了key的所有历史记录,数据量大了很多,因此摒弃了内存数据库,使用磁盘数据库,存储引擎使用的是BoltDB

  • 支持事务

ETCD存储

etcd跟redis,mysql这些存储系统不同,etcd存储的一般是项目中重要的元数据信息,这些数据信息一般变动比较少,但是可能会被多个客户端监听(watch)数据的变更,因此属于读多写少的场景,但是既然有写操作,那么必然会有并发问题,因此结合使用场景,etcd使用MVCC来进行并发的写操作的控制。(etcd v2是有一把大锁,锁住整个数据库,v3时使用MVCC)

etcd存储结构

etcd v2中将数据全部放在了内存中,v3版本由于存放了历史数据,全部放在内存中肯定不行,因此引入了持久化存储

etcd中的数据实际上有两种存储形式并存,一个是持久化存储,另一个是内存存储

总体架构如下

ETCD原理_第3张图片

 

持久化存储

etcd默认使用BoltDB来进行持久化存储,BoldDB有如下特性:

  • 使用mmap技术,避免IO操作,简单来讲就是:一般情况下进程读取文件内容,需要将文件内容复制到内核空间,再从内核空间复制到用户空间,而mmap技术可以直接通过指针直接读取该段内存,底层的操作系统能自动将数据写会到对应的文件中,提高了文件读写的效率

  • 使用Copy-On-Write技术,提高读操作的并发,简单来讲就是复制一个文件时并不会把原先的文件复制一份到内存,而是在内存中做一个映射指向原始文件,只有当对文件有修改时才会复制更新后的文件到内存并且修改映射到新的地址

  • 内部使用B+树实现

  • 使用golang语言开发

  • 支持事务

Etcd中抽象出了Backend interface,便于切换底层存储

type Backend interface {
  // ReadTx returns a read transaction. It is replaced by ConcurrentReadTx in the main data path, see #10523.
  ReadTx() ReadTx
  BatchTx() BatchTx
  // ConcurrentReadTx returns a non-blocking read transaction.
  ConcurrentReadTx() ReadTx
​
  Snapshot() Snapshot
  Hash(ignores func(bucketName, keyName []byte) bool) (uint32, error)
  // Size returns the current size of the backend physically allocated.
  // The backend can hold DB space that is not utilized at the moment,
  // since it can conduct pre-allocation or spare unused space for recycling.
  // Use SizeInUse() instead for the actual DB size.
  Size() int64
  // SizeInUse returns the current size of the backend logically in use.
  // Since the backend can manage free space in a non-byte unit such as
  // number of pages, the returned value can be not exactly accurate in bytes.
  SizeInUse() int64
  // OpenReadTxN returns the number of currently open read transactions in the backend.
  OpenReadTxN() int64
  Defrag() error
  ForceCommit()
  Close() error
}

BoldDB中的B+树存储如下所示,B+树的非叶子节点存储的是revision, 对于revision可以理解为唯一并且递增的序列,包含两个字段,main和sub,main表示每个事务的一个id,是全局自增的,sub表示每个事务中对于每个key的更新操作,sub在每个事务中都从0开始

ETCD原理_第4张图片

 

在代码中boltDB的启动顺序为:

etcdmain/etcd.go main() -> etcdmain/etcd.go startEtcdOrProxyV2() -> etcdmain/etcd.go startEtcd() -> embed/etcd.go StartEtcd() -> etcdserver/server.go NewServer() -> etcdserver/backend.go openBackend() -> etcdserver/backend.go newBackend() -> backend/backend.go newBackend

etcd中集成boltDB跟业务代码中集成mysql中还不一样,一般情况业务代码只是调用mysql api,向mysql发起网络请求进行读写操作,但是etcd中集成boltDB是直接向底层存储文件进行读写操作

内存存储

BoltDB存储的key是revision,但是大多数情况下客户端需要通过业务的key(即向用户暴露的key)来获取value,因此在内存中维护了一个基于B树的二级索引来通过业务的Key映射到底层存储中的B+树

其中B树中,key为业务的key,value为keyIndex

type keyIndex struct {
   key         []byte
   modified    revision // the main rev of the last modification
   generations []generation
}

结构体中,key为原始的业务key, modified为该key值最后一次修改对应的revision, generations为一个数组,其中每一个元素表示该key的一个生命周期(从创建到删除为一个生命周期),因为同一个key,可能会删除之后又创建了,那么会在数组append一个新的generation信息,generation结构体为

type generation struct {
  ver     int64    //key修改的次数
  created revision // when the generation is created (put in first revision).
  revs    []revision //每次更新key时,append对应的revision
}

如果查询value时带有特定revision字段,etcd会先从keyIndex中的generations数组中找到符合条件的revision,否则就用该key最新的revision

快照

由于etcd v3本身就是持久化存储的,因此不需要特地做快照来用于持久化机制,不过为了避免磁盘压力,会定期对数据库压缩,删除历史修改记录

总结

etcd虽然有持久化存储和内存存储,但是和redis不太一样,redis虽然也有持久化,但是redis的持久化(rdb, aof)只是为了备份,而etcd的BoltDb是用于查询

日志和快照管理

ETCD对数据的持久化,是通过binlog日志和snapshot(快照)的方式实现的,binlog日志方式在etcd中一般用WAL(Write-Ahead-Log)术语表示

所有的更新操作都需要先写到binlog,然后再执行

而在etcd集群中用于保证高可用和一致性的raft协议中,也是通过master向slave复制binlog,slave节点根据binlog对操作进行重复,以维持数据的多个副本的一致性

而快照的作用一是为了避免在节点上复制或者重做全量数据使用binlog全部操作过慢,而是为了减小binlog文件大小,节省binglog空间

工作目录默认是在启动程序的当前文件夹下的default.etcd文件夹下,里面会有snap快照文件和wal文件夹,wal文件夹下是.tmp文件后缀的预写式日志文件,默认大小是64MB

日志管理

etcd日志的底层代码在wal.go和raft.go文件中,主要方法如下:

  • 初始化时会调用Create方法创建wal文件夹和创建第一个tmp文件

  • Open方法,用于找到并打开在当前快照以后的所有日志文件,场景主要用于重放(比如在新节点上部署或者恢复数据)

  • Save方法,用于追加日志到日志文件中,追加完成后,如果当前日志文件大小超过阈值(默认为64Mb), 则关闭当前WAL日志并创建新的日志文件

  • bcastAppend, 用于向每个follower进行日志同步

快照

Etcd V3的快照机制是从BoldDB中读取数据库的当前版本数据,然后序列化到磁盘中。

事务

etcdV3是支持事务的,在一个事务中,可以对一个或多个key进行操作

etcdV3事务是基于版本号的乐观锁来实现的,事务提交时,如果key的版本号已经发生了改变,则进行回滚或者重试,知道没有冲突位置

/etcd/client/v3/concurrency/stm.go则是etcdv3对于事务的封装

ETCD原理_第5张图片

 

实例代码如下:

ETCD原理_第6张图片

 

调用方只需要传入一个需要执行的函数给STM,检测冲突、重试和撤销的操作则交给STM来执行

watch机制

etcdV3 api由于使用grpc,显著优化了资源消耗,一个client的不同watch可以共享一个tcp连接,大大减轻了server端的资源消耗,并且可以从历史版本开始watch,整体流程如下图所示:

ETCD原理_第7张图片

 

由上图可以看到一些关键词, watchStream, unsynced watch group, synced watch group等,接下来简单分析下流程和关键词

有三个入口会对watch流程进行一些操作

client端watch请求

client端会对server端发起grpc请求,进行watch,由于grpc是一个长连接,可以发送多次请求,因此同一个连接的多次watch请求公用一个流,即Watch Stream

server端会创建一个serverWatchStream, 并封装一个通道ch,用于server内部数据的传输, 然后启动两个goroutine

一个goroutine为recvLoop, 接收client端的新的watch请求,创建要给watcher,并判断watch请求的startRevision参数,如果watch请求中startRev参数为0或者startRev大于当前数据库最大的rev,那么说明该watch请求不需要历史记录,而如果刚好相反,则说明无需历史记录,只需要监听后续key的变化就行。 如果是需要watch历史记录,则放到一个叫做unsynced的watcher group中,由其它的协程来处理这个watch group(下面会讲到),如果不需要watch历史记录,则放到一个叫做synced的watcher group中(意思是当前无需处理,只需要等待有key更新时再去处理)

另一个goroutine为sendLoop,刚刚上面提到serverWatchStream有一个成员变量通道ch,serverWatchStream对应的所有watcher都共用一个ch,当有数据变更时,其他的地方就会将数据写入到ch,而sendLoop则读取ch,发送给client端

server端历史数据同步

上面讲到,client端watch时,可能会带版本号,也就是可能要获取到该key对应的从某个版本号开始到现在的变更,那么就会放到一个叫unsynced的watcher group中

etcd server端启动时,会起两个goroutine, syncWatchersLoop, syncVictimsLoop

syncWatchersLoop会每次从watcher group中一次性取出多个watcher,从这些watcher中获取最小的startRevision,即最小的版本号,然后从数据库中一次性获取到>=该start Revision的记录,进行一些处理后,再循环遍历watcher, 发送每个watcher需要的信息。 这里之所以先取出多个watcher,获取最小的startRevision,目的是为了提高性能,如果直接遍历每个watcher,从数据库中取出该watcher需要的数据,那么显然查询底层数据库的次数会变多。

如果上述syncWatchersLoop中发送给client消息时报错,那么会将watcher移动到victims group中,syncVictimsLoop会循环从victims group取出watcher进行重试

数据变更

当有数据变更请求操作时,etcd server端会先进行正常的数据库操作,操作结束之后,会遍历synced group中的watches, 分别将相关的数据放到每个watcher中的ch(通道)中,等待上文提到的sendLoop goroutine从ch中获取数据发送到client端

具体代码可看流程图中所示的方法

附录

1、https://www.cnblogs.com/traditional/p/9445930.html

2、云原生分布式存储基石 etcd深入解析

3、https://www.chaindesk.cn/witbook/36/508

4、etcd技术内幕

5、https://blog.csdn.net/weixin_43916797/article/details/115591554

你可能感兴趣的:(golang,etcd,配置中心,etcd,分布式存储)