ETCD读写请求执行过程

简介

什么是 Etcd ?

Etcd 是 CoreOS 团队于2013年6月发起的开源项目,它的目标是构建一个高可用的分布式键值(key-value)数据库。etcd内部采用raft协议作为一致性算法,Etcd基于 Go 语言实现。

名字由来,它源于两个方面,unix的“/etc”文件夹和分布式系统(“D”istribute system)的D,组合在一起表示etcd是用于存储分布式配置的信息存储服务。

Kubernetes 为什么用 Etcd ?

2014年6月,Google的Kubernetes项目诞生了,我们前面所讨论到Go语言编写、etcd高可用、Watch机制、CAS、TTL等特性正是Kubernetes所需要的,它早期的0.4版本,使用的正是etcd v0.2版本。

Kubernetes是如何使用etcd v2这些特性的呢?举几个简单小例子。

当你使用Kubernetes声明式API部署服务的时候,Kubernetes的控制器通过etcd Watch机制,会实时监听资源变化事件,对比实际状态与期望状态是否一致,并采取协调动作使其一致。Kubernetes更新数据的时候,通过CAS机制保证并发场景下的原子更新,并通过对key设置TTL来存储Event事件,提升Kubernetes集群的可观测性,基于TTL特性,Event事件key到期后可自动删除。

Kubernetes项目使用etcd,除了技术因素也与当时的商业竞争有关。CoreOS是Kubernetes容器生态圈的核心成员之一。

你可以看到,按照分层模型,etcd可分为Client层、API网络层、Raft算法层、逻辑层和存储层。这些层的功能如下:

  • Client层:Client层包括client v2和v3两个大版本API客户端库,提供了简洁易用的API,同时支持负载均衡、节点间故障自动转移,可极大降低业务使用etcd复杂度,提升开发效率、服务可用性。
  • API网络层:API网络层主要包括client访问server和server节点之间的通信协议。一方面,client访问etcd server的API分为v2和v3两个大版本。v2 API使用HTTP/1.x协议,v3 API使用gRPC协议。同时v3通过etcd grpc-gateway组件也支持HTTP/1.x协议,便于各种语言的服务调用。另一方面,server之间通信协议,是指节点间通过Raft算法实现数据复制和Leader选举等功能时使用的HTTP协议。
  • Raft算法层:Raft算法层实现了Leader选举、日志复制、ReadIndex等核心算法特性,用于保障etcd多个节点间的数据一致性、提升服务可用性等,是etcd的基石和亮点。
  • 功能逻辑层:etcd核心特性实现层,如典型的KVServer模块、MVCC模块、Auth鉴权模块、Lease租约模块、Compactor压缩模块等,其中MVCC模块主要由treeIndex模块和boltdb模块组成。
  • 存储层:存储层包含预写日志(WAL)模块、快照(Snapshot)模块、boltdb模块。其中WAL可保障etcd crash后数据不丢失,boltdb则保存了集群元数据和用户写入的数据。

概念术语

  • Raft:etcd所采用的保证分布式系统强一致性的算法。
  • Node:一个Raft状态机实例。
  • Member: 一个etcd实例。它管理着一个Node,并且可以为客户端请求提供服务。
  • Cluster:由多个Member构成可以协同工作的etcd集群。
  • Peer:对同一个etcd集群中另外一个Member的称呼。
  • Client: 向etcd集群发送HTTP请求的客户端。
  • WAL:预写式日志,etcd用于持久化存储的日志格式。
  • snapshot:etcd防止WAL文件过多而设置的快照,存储etcd数据状态。
  • Proxy:etcd的一种模式,为etcd集群提供反向代理服务。
  • Leader:Raft算法中通过竞选而产生的处理所有数据提交的节点。
  • Follower:竞选失败的节点作为Raft中的从属节点,为算法提供强一致性保证。
  • Candidate:当Follower超过一定时间接收不到Leader的心跳时转变为Candidate开始竞选。
  • Term:某个节点成为Leader到下一次竞选时间,称为一个Term。
  • Index:数据项编号。Raft中通过Term和Index来定位数据。

与zookeeper对比
使用简单:使用 Go 语言编写部署简单;使用 gRPC 定义接口,支持跨语言、跨平台特性,而zookeeper需要安装客户端,且官方只提供了java和c两种语言接口。
发展快:相较于zookeeper的缓慢发展维护,etcd 正处于高速迭代开发中。
性能优越:官方提供的基准测试数据中,etcd 集群可以支持每秒 10000+ 次的写入,性能优于 Zookeeper。
安全性:etcd 支持 TLS 访问,而 ZooKeeper 在权限控制方面做得略显粗糙。
稳定可靠:etcd更加稳定可靠,它的唯一目标就是把分布式一致性KV存储做到极致,所以它更注重稳定性和扩展性。

应用场景

  • 服务注册与发现:服务进程在ETCD注册自己的位置,客户端应用进程向ETCD发起查询,来获取服务的位置,ETCD提供一个可用的服务列表。
  • 发布订阅消息:数据提供者在配置中心发布消息,消息消费者订阅他们关心的主题,一旦主题有新消息发布,就会实时通知订阅者,通过这种方式可以做到分布式系统配置的集中式管理与动态更新。
  • 负载均衡:etcd本身分布式架构存储的信息访问支持负载均衡,etcd集群化以后,每个etcd的核心节点都可以处理用户的请求。所以把数据量小但是访问频繁的消息数据直接存储到etcd中也是个不错的选择。etcd可以监控一个集群中多个节点的状态,利用etcd维护一个负载均衡节点表,当有一个请求发过来后,可以轮询式的把请求转发给存活着的节点。
  • 分布式锁:因为etcd使用Raft算法保持了数据的强一致性,某次操作存储到集群中的值必然是全局一致的,所以很容易实现分布式锁。当有多个竞争者node节点,etcd作为总控,在分布式集群中与一个节点成功分配lock
  • 分布式队列:分布式队列的常规用法与分布式锁的控制时序用法类似,创建一个先进先出的队列,保证顺序。另一种比较有意思的实现是在保证队列达到某个条件时再统一按顺序执行。这种方法的实现可以在/queue这个目录中另外建立一个/queue/condition节点
  • 分布式通知和协调:分布式通知与协调,与消息发布和订阅有些相似。都用到了etcd中Watche机制,通过注册与异步通知机制,实现分布式环境下不同系统之间 的通知与协调,从而对数据变更做到实时处理。实现方式:不同系统都在etcd上对同一个目录进行注册,同时设置Watcher观测该目录的变化(如果对子目的变化也有需要,可以设置递归模式),当某个系统更新了etcd的目录,那么设置了Watcher的系统就会收到通知,并作出相应处理。
  • 主备选举:使用分布式锁,可以完成Leader竞选。这种场景通常是一些长时间CPU计算或者使用IO操作的机器,只需要竞选出的Leader计算或处理一次,就可以把结果复制给其他的Follower,从而避免重复劳动,节省计算资源。
  • 集群监控:使用etcd来实现集群的实时性的监控,可以第一时间检测到各节点的健康状态,以完成集群的监控要求。etcd本身就有自带检点健康监控功能,实现起来也比较简单。

ETCD读请求如何执行

首先ETCDctl会对命令中的参数进解析,在解析完成请求中的参数后,etcdctl会创建一个clentv3库通过GRPC API来访问etcd server对应流程一。

然后通过负载均衡算法选择一个etcd server节点,然后调用etcd server的KVServer模块的rangeRPC方法,把请求发送给etcd server,在etcd3.4中,client3库采用的负载均衡算法为round-robin对应流程二

KVServer收到client的Range RPC请求后,首先会根据ServiceName和RPC method将请求转发到对应的handler实现,handler首先会将metrics,日志,请求行为检查等一系列拦截器串联后执行,之后就进入核心的读流程,对应架构汇总的流程三和四,这里首先介绍下串行读和线性读。

串行读

etcd 的串行读是直接由对应节点的状态机返回数据、无需通过 Raft 协议与集群进行交互的模式,具有低延时、高吞吐量的特点,适合对数据一致性要求不高的场景。

线性读

etcd 默认读模式是线性读,因为它需要经过 Raft 协议模块的ReadIndex,因此在延时和吞吐量上相比串行读略差一点,适用于对数据一致性要求高的场景,即一个值更新成功,随后任何通过线性读的 client 都能及时访问到。

线性读之 ReadIndex
串行读时之所以能读到旧数据,主要原因是当 client 发起一个写请求, Leader 收到写请求后会将此请求持久化到 WAL 日志,并广播给各个Follower节点,若一半以上节点持久化成功则该请求对应的日志条目被标识为已提交,Follower的 etcdserver 模块再异步的从 Raft 模块获取已提交的日志条目,应用到状态机 (treeIndex、boltdb 等) 。

ReadIndex在 etcd 3.1 中引入的,当收到一个线性读请求时,Follower节点首先会向Raft 模块发送ReadIndex请求,此时Raft模块会先向各节点发送心跳确认,一半以上节点确认 Leader 身份后由leader节点将已提交日志索引 (committed index) 封装成 ReadState 结构体通过 channel 层层返回给线性读模块。线性读模块会等待本节点状态机的已应用日志索引 (applied index) 大于等于 Leader 的已提交日志索引,就通知 KVServer 模块,可以与状态机中的 MVCC 模块进行交互读取数据了。

MVCC

MVCC多版本并发控制模块是为了解决提到的etcd v2不支持保存key的历史版本,不支持多key事务等问题而产生的,核心由内存树形索引模块(treeIndex)、嵌入式的KV持久化数据库boltdb以及buffer组成。

其中boltdb是基于B+tree实现的key-value键值库,支持事务,提供get/Put等建议API给ETCD操作。boltdb的key是全局递增的版本号(revision),value是用户key,value等字段组合成的结构体,然后通过treeIndex模块来保存用户key和版本号的映射关系。

treeIndex模块是基于google开源的内存版btree库实现的

buffer则是在获取到版本号信息后,并不是所有请求都一定要从boltdb获取数据。etcd处于一致性,性能等考虑,在访问boltdb钱,首先会从一个内存读事务中buffer,二分查找你要访问的key是否在buffer里面,若命中则直接返回

ETCD写请求的执行

首先 client 端通过负载均衡算法选择一个 etcd 节点,发起 gRPC 调用。然后 etcd 节点收到请求后经过 gRPC 拦截器、Quota 模块后,进入 KVServer 模块,KVServer 模块向 Raft 模块提交一个提案,提案内容为“ put 一个 key 为 hello,value 为 world 的数据”。随后此提案通过 RaftHTTP 网络模块转发、经过集群多数节点持久化后,状态会变成已提交,etcdserver 从 Raft 模块获取已提交的日志条目,传递给 Apply 模块,Apply 模块通过 MVCC 模块执行提案内容,更新状态机。

Quota (配额)模块

client 端发起 gRPC 调用到 etcd 节点,和读请求不一样的是,写请求需要经过Quota模块。当 etcd server 收到 put/txn 等写请求的时候,会首先检查下当前 etcd db 大小加上你请求的 key-value 大小之和是否超过了配额(quota-backend-bytes)。如果超过了配额,它会产生一个 NO SPACE的告警,并通过 Raft 日志同步给其它节点,告知 db 无空间了,并将告警持久化存储到 db 中。

因为etcd v3 是个 MVCC 数据库,保存了 key 的历史版本,当你未配置压缩策略(compact)的时候,随着数据不断写入,db 大小会不断增大,导致超限。压缩模块支持按多种方式回收旧版本,比如保留最近一段时间内的历史版本。不过它仅仅是将旧版本占用的空间打个空闲(Free)标记,后续新的数据写入的时候可复用这块空间而无需申请新的空间。

如果你需要回收空间减少 db 大小,得使用碎片整理(defrag), 它会遍历旧的 db 文件数据并写入到一个新的 db 文件。但是它对服务性能有较大影响,不建议你在生产集群频繁使用。

KVServer模块

通过配额检查后,请求就从 API 层转发到了 KVServer 模块的 put 方法,我们知道 etcd 是基于 Raft 算法实现节点间数据复制的,因此它需要将 put 写请求内容打包成一个提案消息,提交给 Raft 模块。不过 KVServer 模块在提交提案前,还有如下的一系列检查和限速。

Preflight Check:为了保证集群稳定性,避免雪崩,任何提交到 Raft 模块的请求,都会做一些简单的限速判断。如果 Raft 模块已提交的日志索引(committed index)比已应用到状态机的日志索引(applied index)超过了 5000,那么它就返回一个"etcdserver: too many requests"错误给 client。

鉴权:尝试去获取请求中的鉴权信息,若使用了密码鉴权、请求中携带了 token,如果 token 无效,则返回"auth: invalid auth token"错误给 client。

包大小:会检查你写入的包大小是否超过默认的 1.5MB, 如果超过了会返回"etcdserver: request is too large"错误给给 client。

propose

通过一些列检查后,会生成一个唯一的ID并将请求关联到一个对应的消息通知channel,然后向raft模块发起Propose一个提案(proposal),即流程四。向raft模块发起提案后,kvServer模块会等待此put请求,等待写入结果通过消息通知channel返回或超时。Etcd提案的默认超时时间是7S(5 秒磁盘 IO 延时 +2*1 秒竞选超时时间),如果一个提案请求超时未返回结果,则可能会出现你熟悉的 etcdserver: request timed out 错误。

WAL模块

Raft 模块收到提案后,如果当前节点是 Follower,它会转发给 Leader,只有 Leader 才能处理写请求。Leader 收到提案后,通过 Raft 模块 将 Raft日志条目 广播给 Follower 节点,同时将待持久化的Raft日志条目持久化到 WAL 文件中,最后将 Raft日志条目追加到稳定的 Raft 日志存储中(内存),各个 Follower 收到 Raft日志条目 后会持久化到 WAL 日志中,并将消息追加到 Raft 日志存储,随后会向 Leader 回复确认信息。即每个提案在被提交前都会各个节点被持久化到 WAL 日志文件中,以保证集群的一致性、可恢复性(crash后重启恢复),即流程五。

当一半以上节点持久化此Raft日志条目(WAL预写日志)后, Raft 模块就会通过 channel 告知 etcdserver 模块,put 提案已经被集群多数节点确认,提案状态为已提交,可以继续向Apply模块提交提案内容。

于是进入流程六,etcdserver 模块从 channel 取出提案内容,添加到先进先出(FIFO)调度队列,随后通过 Apply 模块按入队顺序,异步、依次执行提案内容。

WAL 模块如何持久化 Raft 日志条目

首先将 Raft 日志条目内容(含任期号、已提交索引、提案内容)序列化后保存到 WAL 记录的 Data 字段, 然后计算 Data 的 CRC 值,设置 Type 为 Entry Type, 以上信息就组成了一个完整的 WAL 记录。最后计算 WAL 记录的长度,然后调用 fsync 持久化到磁盘,完成将Raft日志条目保存到持久化存储中。

apply模块

apply模块在执行提案内容钱,会首先判断当前提案是否已经执行过了,如果执行过了直接返回,若未执行同时呜DB配额满警告,则进入到MVCC模块,开始与持久化存储块打交道,Apply模块执行了put提案内容对应流程七。

Apply模块使用FIFO 执行队列

再考虑故障场景,若 put 请求提案在执行流程七的时候 etcd 突然 crash 了, 重启恢复的时候,etcd 是如何找回异常提案,再次执行呢。

核心就是我们上面介绍的 WAL 日志,因为提交给 Apply 模块执行的提案已获得多数节点确认即持久化到WAL日志文件中,etcd 重启时会从 WAL 中解析出 Raft 日志条目内容,追加到 Raft 日志的存储中,并重放已提交的日志提案到 Apply 模块执行。

然而这又引发了另外一个问题,如何确保幂等性,防止提案重复执行导致数据混乱。

Raft 日志条目中的索引(index)字段是全局单调递增的,每个日志条目索引对应一个提案, 如果一个命令执行后,我们在 db 里面也记录下当前已经执行过的日志条目索引,则可以解决幂等性问题。

但是如果执行命令的请求更新成功了,但是更新 index 的请求却失败了,一样会导致异常,因此我们还需要将两个操作作为原子性事务提交,才能实现幂等。

MVCC

Apply模块判断此提案未执行后,就会调用MVCC模块来执行提案内容。MCC主要由三部分组成,一个是内存索引模块TreeIndex,是一个内存版的Btree,保存用户key和版本号(revision)的映射关系;另一个是boltdb模块,基于B+tree实现的key-value嵌入式db,通过桶(bucket)机制实现类似MySQL表的逻辑隔离,boltdb的key是全局递增的版本号,value是用户key,value等字段组合成的结构体;另一个是bucket buffer用来保存合并后要不定时批量提交导致暂未确定的事务数据。

版本号(revision)在 etcd 里面发挥着重大作用,etcd 启动的时候默认版本号是 1,随着你对 key 的增、删、改操作而全局单调递增,boltdb则使用版本号作为key,boltdb 的 value具体包括了用户key 值,value 值、key 创建时的版本号(create_revision)、最后一次修改时的版本号(mod_revision)、key 自身修改的次数(version),租约信息。

虽然boltdb put 调用成功,但etcd 并未提交事务,数据只更新在 boltdb 所管理的内存数据结构中。事务提交的过程,包含 B+tree 的平衡、分裂,将 boltdb 的脏数据(dirty page)、元数据信息刷新到磁盘,因此事务提交的开销是昂贵的,如果我们每次更新都提交事务,etcd 写性能就会较差。

etcd 采用是合并后异步定时批量提交,etcd 通过合并多个写事务请求,异步定时的(默认每隔 100ms)将批量事务一次性提交从而刷新到磁盘, 从而大大提高吞吐量,仅默认堆积的写事务数大于 1 万才在写事务结束时同步持久化。

同手,etcd 引入了一个 bucket buffer 来保存暂未提交的事务数据,在更新 boltdb 的时候,etcd 也会同步数据到 bucket buffer。因此 etcd 处理读请求的时候会优先从 bucket buffer 里面读取,其次再从 boltdb 读,通过 bucket buffer 实现读写性能提升,同时保证数据一致性

etcd增删改查的过程
修改key时会根据key从treeIndex中获取版本号信息,并以版本号为key,用户key-value作为value写入boltdb,同时更新treeIndex的版本号信息,之后etcd 采用是合并后异步定时批量提交事务写入磁盘;

查询key时未指定版本号则treeIndex返回 key的最新版本号,根据版本号优先从buffer中查询,未命中则从boltdb中查询key-value;

删除一个key时,采用的是异步删除,只不过给treeIndex和boltdb 的 key 会打上删除标记,真正删除 treeIndex 中的索引对象、boltdb 中的 key 是通过压缩 (compactor) 组件异步完成。

要点记录

1、线性读 ReadIndex需要等待本节点状态机的已应用日志索引 (applied index) 大于等于 Leader 的已提交日志索引,才能继续读取数据。

2、etcd 提交提案的默认超时时间是 7 秒,如果一个提案请求超时未返回结果,则会出现的 etcdserver: request timed out 错误。

3、Raft 模块收到提案后,如果当前节点是 Follower,它会转发给 Leader,只有 Leader 才能处理写请求。Leader 收到提案后,通过 Raft 模块 将 Raft日志条目 广播给 Follower 节点,同时将待持久化的Raft日志条目持久化到 WAL 文件中,并也将 Raft日志条目追加到稳定的 Raft 日志存储中(内存),各个 Follower 收到 Raft日志条目 后会持久化到 WAL 日志中,并也将消息追加到 Raft 日志存储,随后会向 Leader 回复确认信息。即每个提案在被提交前都会各个节点被持久化到 WAL 日志文件中,以保证集群的一致性、可恢复性(crash后重启恢复)

4、当一半以上节点持久化了WAL日志后, Raft 模块才会通过 channel 告知 etcdserver 模块,put 提案已经被集群多数节点确认,提案状态为已提交,可以继续提交给Apply模块执行。

etcd 重启时会从 WAL预写式日志中解析出 Raft 日志条目内容,并重放已提交的日志提案到 Apply 模块执行。

etcd 在启动的时候会将etcd db文件内容拷贝到etcd进程内存中,启动拷贝是需要磁盘IO,节点内存足够的请求下,后续处理读请求过程中就不会产生磁盘 I/IO 了。

5、MVCC 主要由三部分组成,一个是内存索引模块 treeIndex,实现是BTree,用于保存用户 key 和版本号 (revision)的映射关系;另一个是 boltdb 模块,是基于 B+tree 实现的 key-value 嵌入式 db,通过提供桶(bucket)机制实现类似 MySQL 表的逻辑隔离,boltdb 的 key 是全局递增的版本号,value 是用户 key、value 等字段组合成的结构体;第三个是bucket buffer 用来保存 合并后异步定时批量提交 导致的暂未提交的事务数据。

6、etcd采用合并后异步定时批量提交,etcd 通过合并多个写事务请求,异步定时的(默认每隔 100ms)将事务批量一次性提交,进而刷新到磁盘, 从而大大提高吞吐量。

转载自:etcd之读写请求的执行过程-CSDN博客

Last modification:January 5, 2024
如果觉得我的文章对你有用,请随意赞赏