基础架构

基础架构

image-20240606164308547

按照分层模型,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则保存了集群元数据和用户写入的数据。

etcd是典型的读多写少存储

etcd读请求

读请求流程图

image-20240606164336292

KVServer模块

client发送Range RPC请求到了server后,就开始进入我们架构图中的流程二,也就是KVServer模块了。

etcd提供了丰富的metrics、日志、请求行为检查等机制,可记录所有请求的执行耗时及错误码、来源IP等,也可控制请求是否允许通过,比如etcd Learner节点只允许指定接口和参数的访问,帮助大家定位问题、提高服务可观测性等,而这些特性是怎么非侵入式的实现呢?

答案就是拦截器。

拦截器

etcd server定义了如下的Service KV和Range方法,启动的时候它会将实现KV各方法的对象注册到gRPC Server,并在其上注册对应的拦截器。下面的代码中的Range接口就是负责读取etcd key-value的的RPC接口。

1
2
3
4
5
6
7
8
9
10
service KV {  
// Range gets the keys in the range from the key-value store.
rpc Range(RangeRequest) returns (RangeResponse) {
option (google.api.http) = {
post: "/v3/kv/range"
body: "*"
};
}
....
}

拦截器提供了在执行一个请求前后的hook能力,除了我们上面提到的debug日志、metrics统计、对etcd Learner节点请求接口和参数限制等能力,etcd还基于它实现了以下特性:

  • 要求执行一个操作前集群必须有Leader;
  • 请求延时超过指定阈值的,打印包含来源IP的慢查询日志(3.5版本)。

server收到client的Range RPC请求后,根据ServiceName和RPC Method将请求转发到对应的handler实现,handler首先会将上面描述的一系列拦截器串联成一个执行,在拦截器逻辑中,通过调用KVServer模块的Range接口获取数据。

串行读与线性读

进入KVServer模块后,我们就进入核心的读流程了,对应架构图中的流程三和四。我们知道etcd为了保证服务高可用,生产环境一般部署多个节点,那各个节点数据在任意时间点读出来都是一致的吗?什么情况下会读到旧数据呢?

这里为了帮助你更好的理解读流程,我先简单提下写流程。如下图所示,当client发起一个更新hello为world请求后,若Leader收到写请求,它会将此请求持久化到WAL日志,并广播给各个节点,若一半以上节点持久化成功,则该请求对应的日志条目被标识为已提交,etcdserver模块异步从Raft模块获取已提交的日志条目,应用到状态机(boltdb等)。

image-20240606164413824

此时若client发起一个读取hello的请求,假设此请求直接从状态机中读取, 如果连接到的是C节点,若C节点磁盘I/O出现波动,可能导致它应用已提交的日志条目很慢,则会出现更新hello为world的写命令,在client读hello的时候还未被提交到状态机,因此就可能读取到旧数据,如上图查询hello流程所示。

从以上介绍我们可以看出,在多节点etcd集群中,各个节点的状态机数据一致性存在差异。而我们不同业务场景的读请求对数据是否最新的容忍度是不一样的,有的场景它可以容忍数据落后几秒甚至几分钟,有的场景要求必须读到反映集群共识的最新数据。

我们首先来看一个对数据敏感度较低的场景

假如老板让你做一个旁路数据统计服务,希望你每分钟统计下etcd里的服务、配置信息等,这种场景其实对数据时效性要求并不高,读请求可直接从节点的状态机获取数据。即便数据落后一点,也不影响业务,毕竟这是一个定时统计的旁路服务而已。

这种直接读状态机数据返回、无需通过Raft协议与集群进行交互的模式,在etcd里叫做串行(Serializable)读,它具有低延时、高吞吐量的特点,适合对数据一致性要求不高的场景。

我们再看一个对数据敏感性高的场景

当你发布服务,更新服务的镜像的时候,提交的时候显示更新成功,结果你一刷新页面,发现显示的镜像的还是旧的,再刷新又是新的,这就会导致混乱。再比如说一个转账场景,Alice给Bob转账成功,钱被正常扣出,一刷新页面发现钱又回来了,这也是令人不可接受的。

以上的业务场景就对数据准确性要求极高了,在etcd里面,提供了一种线性读模式来解决对数据一致性要求高的场景。

什么是线性读呢?

你可以理解一旦一个值更新成功,随后任何通过线性读的client都能及时访问到。虽然集群中有多个节点,但client通过线性读就如访问一个节点一样。etcd默认读模式是线性读,因为它需要经过Raft协议模块,反应的是集群共识,因此在延时和吞吐量上相比串行读略差一点,适用于对数据一致性要求高的场景。

如果你的etcd读请求显示指定了是串行读,就不会经过架构图流程中的流程三、四。默认是线性读,因此接下来我们看看读请求进入线性读模块,它是如何工作的。

在etcd中,读操作主要有两种模式:串行读(Serialize)和线性读(Linearizable)。

  1. 串行读:这种读模式是在同一个节点上进行的,它不需要经过Raft协议模块的处理。串行读可以快速地读取数据,因为它不需要等待其他节点的确认,但是它可能无法读取到最新的数据。这种模式在对数据一致性要求不高,但对读取速度有较大需求的场景下比较适用。
  2. 线性读:这种读模式需要经过Raft协议模块的处理,它能反应集群的共识状态,也就是说,它可以保证读取到的数据是最新的。但是,因为需要经过Raft协议的处理,所以在延时和吞吐量上,线性读可能会比串行读略差一些。这种模式在对数据一致性要求高的场景下比较适用。

举个例子,假设我们有一个在线购物网站,用户在浏览商品信息时,我们可以使用串行读来快速获取商品信息,因为这些信息一般不会频繁更新,即使偶尔读取到的是稍微旧一点的数据,也不会影响用户的使用体验。而在用户下单购买商品时,我们需要使用线性读来获取商品的库存信息,因为这个信息可能会被其他用户的购买行为实时改变,我们需要确保读取到的库存信息是最新的,以防止出现超卖的情况。

线性读之ReadIndex

前面我们聊到串行读时提到,它之所以能读到旧数据,主要原因是Follower节点收到Leader节点同步的写请求后,应用日志条目到状态机是个异步过程,那么我们能否有一种机制在读取的时候,确保最新的数据已经应用到状态机中?

image-20240606164512356

其实这个机制就是叫ReadIndex,它是在etcd 3.1中引入的,我把简化后的原理图放在了上面。当收到一个线性读请求时,它首先会从Leader获取集群最新的已提交的日志索引(committed index),如上图中的流程二所示。

Leader收到ReadIndex请求时,为防止脑裂等异常场景,会向Follower节点发送心跳确认,一半以上节点确认Leader身份后才能将已提交的索引(committed index)返回给节点C(上图中的流程三)。

C节点则会等待,直到状态机已应用索引(applied index)大于等于Leader的已提交索引时(committed Index)(上图中的流程四),然后去通知读请求,数据已赶上Leader,你可以去状态机中访问数据了(上图中的流程五)。

以上就是线性读通过ReadIndex机制保证数据一致性原理, 当然还有其它机制也能实现线性读,如在早期etcd 3.0中读请求通过走一遍Raft协议保证一致性, 这种Raft log read机制依赖磁盘IO, 性能相比ReadIndex较差。

总体而言,KVServer模块收到线性读请求后,通过架构图中流程三向Raft模块发起ReadIndex请求,Raft模块将Leader最新的已提交日志索引封装在流程四的ReadState结构体,通过channel层层返回给线性读模块,线性读模块等待本节点状态机追赶上Leader进度,追赶完成后,就通知KVServer模块,进行架构图中流程五,与状态机中的MVCC模块进行进行交互了。

etcd写请求

写请求流程图

image-20240606164533960

Quota模块

首先是流程一client端发起gRPC调用到etcd节点,和读请求不一样的是,写请求需要经过流程二db配额(Quota)模块,它有什么功能呢?

我们先从此模块的一个常见错误说起,你在使用etcd过程中是否遇到过”etcdserver: mvcc: database space exceeded”错误呢?

我相信只要你使用过etcd或者Kubernetes,大概率见过这个错误。它是指当前etcd db文件大小超过了配额,当出现此错误后,你的整个集群将不可写入,只读,对业务的影响非常大。

哪些情况会触发这个错误呢?

一方面默认db配额仅为2G,当你的业务数据、写入QPS、Kubernetes集群规模增大后,你的etcd db大小就可能会超过2G。

另一方面我们知道etcd v3是个MVCC数据库,保存了key的历史版本,当你未配置压缩策略的时候,随着数据不断写入,db大小会不断增大,导致超限。

最后你要特别注意的是,如果你使用的是etcd 3.2.10之前的旧版本,请注意备份可能会触发boltdb的一个Bug,它会导致db大小不断上涨,最终达到配额限制。

了解完触发Quota限制的原因后,我们再详细了解下Quota模块它是如何工作的。

当etcd server收到put/txn等写请求的时候,会首先检查下当前etcd db大小加上你请求的key-value大小之和是否超过了配额(quota-backend-bytes)。

如果超过了配额,它会产生一个告警(Alarm)请求,告警类型是NO SPACE,并通过Raft日志同步给其它节点,告知db无空间了,并将告警持久化存储到db中。

最终,无论是API层gRPC模块还是负责将Raft侧已提交的日志条目应用到状态机的Apply模块,都拒绝写入,集群只读。

那遇到这个错误时应该如何解决呢?

首先当然是调大配额。具体多大合适呢?etcd社区建议不超过8G。遇到过这个错误的你是否还记得,为什么当你把配额(quota-backend-bytes)调大后,集群依然拒绝写入呢?

原因就是我们前面提到的NO SPACE告警。Apply模块在执行每个命令的时候,都会去检查当前是否存在NO SPACE告警,如果有则拒绝写入。所以还需要你额外发送一个取消告警(etcdctl alarm disarm)的命令,以消除所有告警。

其次你需要检查etcd的压缩(compact)配置是否开启、配置是否合理。etcd保存了一个key所有变更历史版本,如果没有一个机制去回收旧的版本,那么内存和db大小就会一直膨胀,在etcd里面,压缩模块负责回收旧版本的工作。

压缩模块支持按多种方式回收旧版本,比如保留最近一段时间内的历史版本。不过你要注意,它仅仅是将旧版本占用的空间打个空闲(Free)标记,后续新的数据写入的时候可复用这块空间,而无需申请新的空间。

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

最后你需要注意配额(quota-backend-bytes)的行为,默认’0’就是使用etcd默认的2GB大小,你需要根据你的业务场景适当调优。如果你填的是个小于0的数,就会禁用配额功能,这可能会让你的db大小处于失控,导致性能下降,不建议你禁用配额。

KVServer模块

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

Preflight Check

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

image-20240606164603041

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

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

Propose

最后通过一系列检查之后,会生成一个唯一的ID,将此请求关联到一个对应的消息通知channel,然后向Raft模块发起(Propose)一个提案(Proposal),提案内容为“大家好,请使用put方法执行一个key为hello,value为world的命令”,也就是整体架构图里的流程四。

向Raft模块发起提案后,KVServer模块会等待此put请求,等待写入结果通过消息通知channel返回或者超时。etcd默认超时时间是7秒(5秒磁盘IO延时+2*1秒竞选超时时间),如果一个请求超时未返回结果,则可能会出现你熟悉的etcdserver: request timed out错误。

WAL模块

Raft模块收到提案后,如果当前节点是Follower,它会转发给Leader,只有Leader才能处理写请求。Leader收到提案后,通过Raft模块输出待转发给Follower节点的消息和待持久化的日志条目,日志条目则封装了我们上面所说的put hello提案内容。

etcdserver从Raft模块获取到以上消息和日志条目后,作为Leader,它会将put提案消息广播给集群各个节点,同时需要把集群Leader任期号、投票信息、已提交索引、提案内容持久化到一个WAL(Write Ahead Log)日志文件中,用于保证集群的一致性、可恢复性,也就是我们图中的流程五模块。

WAL日志结构是怎样的呢?

image-20240606164635657

上图是WAL结构,它由多种类型的WAL记录顺序追加写入组成,每个记录由类型、数据、循环冗余校验码组成。不同类型的记录通过Type字段区分,Data为对应记录内容,CRC为循环校验码信息。

WAL记录类型目前支持5种,分别是文件元数据记录、日志条目记录、状态信息记录、CRC记录、快照记录:

  • 文件元数据记录包含节点ID、集群ID信息,它在WAL文件创建的时候写入;
  • 日志条目记录包含Raft日志信息,如put提案内容;
  • 状态信息记录,包含集群的任期号、节点投票信息等,一个日志文件中会有多条,以最后的记录为准;
  • CRC记录包含上一个WAL文件的最后的CRC(循环冗余校验码)信息, 在创建、切割WAL文件时,作为第一条记录写入到新的WAL文件, 用于校验数据文件的完整性、准确性等;
  • 快照记录包含快照的任期号、日志索引信息,用于检查快照文件的准确性。

WAL模块又是如何持久化一个put提案的日志条目类型记录呢?

首先我们来看看put写请求如何封装在Raft日志条目里面。下面是Raft日志条目的数据结构信息,它由以下字段组成:

  • Term是Leader任期号,随着Leader选举增加;
  • Index是日志条目的索引,单调递增增加;
  • Type是日志类型,比如是普通的命令日志(EntryNormal)还是集群配置变更日志(EntryConfChange);
  • Data保存我们上面描述的put提案内容。
1
2
3
4
5
6
type Entry struct {
Term uint64 `protobuf:"varint,2,opt,name=Term" json:"Term"`
Index uint64 `protobuf:"varint,3,opt,name=Index" json:"Index"`
Type EntryType `protobuf:"varint,1,opt,name=Type,enum=Raftpb.EntryType" json:"Type"`
Data []byte `protobuf:"bytes,4,opt,name=Data" json:"Data,omitempty"`
}

了解完Raft日志条目数据结构后,我们再看WAL模块如何持久化Raft日志条目。它首先先将Raft日志条目内容(含任期号、索引、提案内容)序列化后保存到WAL记录的Data字段, 然后计算Data的CRC值,设置Type为Entry Type, 以上信息就组成了一个完整的WAL记录。

最后计算WAL记录的长度,顺序先写入WAL长度(Len Field),然后写入记录内容,调用fsync持久化到磁盘,完成将日志条目保存到持久化存储中。

当一半以上节点持久化此日志条目后, Raft模块就会通过channel告知etcdserver模块,put提案已经被集群多数节点确认,提案状态为已提交,你可以执行此提案内容了。

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

Apply模块

执行put提案内容对应我们架构图中的流程七,其细节图如下。那么Apply模块是如何执行put请求的呢?若put请求提案在执行流程七的时候etcd突然crash了, 重启恢复的时候,etcd是如何找回异常提案,再次执行的呢?

image-20240606164651171

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

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

我们在上一节课里讲到,etcd是个MVCC数据库,每次更新都会生成新的版本号。如果没有幂等性保护,同样的命令,一部分节点执行一次,一部分节点遭遇异常故障后执行多次,则系统的各节点一致性状态无法得到保证,导致数据混乱,这是严重故障。

因此etcd必须要确保幂等性。怎么做呢?Apply模块从Raft模块获得的日志条目信息里,是否有唯一的字段能标识这个提案?

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

是的。但是这还不够安全,如果执行命令的请求更新成功了,更新index的请求却失败了,是不是一样会导致异常?

因此我们在实现上,还需要将两个操作作为原子性事务提交,才能实现幂等。

正如我们上面的讨论的这样,etcd通过引入一个consistent index的字段,来存储系统当前已经执行过的日志条目索引,实现幂等性。

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

总结

首先我们介绍了Quota模块工作原理和我们熟悉的database space exceeded错误触发原因,写请求导致db大小增加、compact策略不合理、boltdb Bug等都会导致db大小超限。

其次介绍了WAL模块的存储结构,它由一条条记录顺序写入组成,每个记录含有Type、CRC、Data,每个提案被提交前都会被持久化到WAL文件中,以保证集群的一致性和可恢复性。

随后我们介绍了Apply模块基于consistent index和事务实现了幂等性,保证了节点在异常情况下不会重复执行重放的提案。