You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

217 lines
18 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 02 | 基础架构etcd一个读请求是如何执行的
你好,我是唐聪。
在上一讲中我和你分享了etcd的前世今生同时也为你重点介绍了etcd v2的不足之处以及我们现在广泛使用etcd v3的原因。
今天我想跟你介绍一下etcd v3的基础架构让你从整体上对etcd有一个初步的了解心中能构筑起一幅etcd模块全景图。这样在你遇到诸如“Kubernetes在执行kubectl get pod时etcd如何获取到最新的数据返回给APIServer”等流程架构问题时就能知道各个模块由上至下是如何紧密协作的。
即便是遇到请求报错,你也能通过顶层的模块全景图,推测出请求流程究竟在什么模块出现了问题。
## 基础架构
下面是一张etcd的简要基础架构图我们先从宏观上了解一下etcd都有哪些功能模块。
![](https://static001.geekbang.org/resource/image/34/84/34486534722d2748d8cd1172bfe63084.png?wh=1920*1240)
你可以看到按照分层模型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是典型的读多写少存储在我们实际业务场景中读一般占据2/3以上的请求。为了让你对etcd有一个深入的理解接下来我会分析一个读请求是如何执行的带你了解etcd的核心模块进而由点及线、由线到面地帮助你构建etcd的全景知识脉络。
在下面这张架构图中我用序号标识了etcd默认读模式线性读的执行流程接下来我们就按照这个执行流程从头开始说。
![](https://static001.geekbang.org/resource/image/45/bb/457db2c506135d5d29a93ef0bd97e4bb.png?wh=1920*1229)
## 环境准备
首先介绍一个好用的进程管理工具[goreman](https://github.com/mattn/goreman)基于它我们可快速创建、停止本地的多节点etcd集群。
你可以通过如下`go get`命令快速安装goreman然后从[etcd release](https://github.com/etcd-io/etcd/releases/v3.4.9)页下载etcd v3.4.9二进制文件,再从[etcd源码](https://github.com/etcd-io/etcd/blob/v3.4.9/Procfile)中下载goreman Procfile文件它描述了etcd进程名、节点数、参数等信息。最后通过`goreman -f Procfile start`命令就可以快速启动一个3节点的本地集群了。
```
go get github.com/mattn/goreman
```
## client
启动完etcd集群后当你用etcd的客户端工具etcdctl执行一个get hello命令如下对应到图中流程一etcdctl是如何工作的呢
```
etcdctl get hello --endpoints http://127.0.0.1:2379
hello
world
```
首先etcdctl会对命令中的参数进行解析。我们来看下这些参数的含义其中参数“get”是请求的方法它是KVServer模块的API“hello”是我们查询的key名“endpoints”是我们后端的etcd地址通常生产环境下中需要配置多个endpoints这样在etcd节点出现故障后client就可以自动重连到其它正常的节点从而保证请求的正常执行。
在etcd v3.4.9版本中etcdctl是通过clientv3库来访问etcd server的clientv3库基于gRPC client API封装了操作etcd KVServer、Cluster、Auth、Lease、Watch等模块的API同时还包含了负载均衡、健康探测和故障切换等特性。
在解析完请求中的参数后etcdctl会创建一个clientv3库对象使用KVServer模块的API来访问etcd server。
接下来就需要为这个get hello请求选择一个合适的etcd server节点了这里得用到负载均衡算法。在etcd 3.4中clientv3库采用的负载均衡算法为Round-robin。针对每一个请求Round-robin算法通过轮询的方式依次从endpoint列表中选择一个endpoint访问(长连接)使etcd server负载尽量均衡。
关于负载均衡算法,你需要特别注意以下两点。
1. 如果你的client 版本<= 3.3那么当你配置多个endpoint时负载均衡算法仅会从中选择一个IP并创建一个连接Pinned endpoint这样可以节省服务器总连接数。但在这我要给你一个小提醒在heavy usage场景这可能会造成server负载不均衡。
2. 在client 3.4之前的版本中负载均衡算法有一个严重的Bug如果第一个节点异常了可能会导致你的client访问etcd server异常特别是在Kubernetes场景中会导致APIServer不可用。不过该Bug已在 Kubernetes 1.16版本后被修复。
为请求选择好etcd server节点client就可调用etcd server的KVServer模块的Range RPC方法把请求发送给etcd server。
这里我说明一点client和server之间的通信使用的是基于HTTP/2的gRPC协议。相比etcd v2的HTTP/1.xHTTP/2是基于二进制而不是文本、支持多路复用而不再有序且阻塞、支持数据压缩以减少包大小、支持server push等特性。因此基于HTTP/2的gRPC协议具有低延迟、高性能的特点有效解决了我们在上一讲中提到的etcd v2中HTTP/1.x 性能问题。
## KVServer
client发送Range RPC请求到了server后就开始进入我们架构图中的流程二也就是KVServer模块了。
etcd提供了丰富的metrics、日志、请求行为检查等机制可记录所有请求的执行耗时及错误码、来源IP等也可控制请求是否允许通过比如etcd Learner节点只允许指定接口和参数的访问帮助大家定位问题、提高服务可观测性等而这些特性是怎么非侵入式的实现呢
答案就是拦截器。
### 拦截器
etcd server定义了如下的Service KV和Range方法启动的时候它会将实现KV各方法的对象注册到gRPC Server并在其上注册对应的拦截器。下面的代码中的Range接口就是负责读取etcd key-value的的RPC接口。
```
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等)。
![](https://static001.geekbang.org/resource/image/cf/d5/cffba70a79609f29e1f2ae1f3bd07fd5.png?wh=1920*1074)
此时若client发起一个读取hello的请求假设此请求直接从状态机中读取 如果连接到的是C节点若C节点磁盘I/O出现波动可能导致它应用已提交的日志条目很慢则会出现更新hello为world的写命令在client读hello的时候还未被提交到状态机因此就可能读取到旧数据如上图查询hello流程所示。
从以上介绍我们可以看出在多节点etcd集群中各个节点的状态机数据一致性存在差异。而我们不同业务场景的读请求对数据是否最新的容忍度是不一样的有的场景它可以容忍数据落后几秒甚至几分钟有的场景要求必须读到反映集群共识的最新数据。
我们首先来看一个**对数据敏感度较低的场景**。
假如老板让你做一个旁路数据统计服务希望你每分钟统计下etcd里的服务、配置信息等这种场景其实对数据时效性要求并不高读请求可直接从节点的状态机获取数据。即便数据落后一点也不影响业务毕竟这是一个定时统计的旁路服务而已。
这种直接读状态机数据返回、无需通过Raft协议与集群进行交互的模式在etcd里叫做**串行(****Serializable****)读**,它具有低延时、高吞吐量的特点,适合对数据一致性要求不高的场景。
我们再看一个**对数据敏感性高的场景**。
当你发布服务更新服务的镜像的时候提交的时候显示更新成功结果你一刷新页面发现显示的镜像的还是旧的再刷新又是新的这就会导致混乱。再比如说一个转账场景Alice给Bob转账成功钱被正常扣出一刷新页面发现钱又回来了这也是令人不可接受的。
以上的业务场景就对数据准确性要求极高了在etcd里面提供了一种线性读模式来解决对数据一致性要求高的场景。
**什么是线性读呢?**
你可以理解一旦一个值更新成功随后任何通过线性读的client都能及时访问到。虽然集群中有多个节点但client通过线性读就如访问一个节点一样。etcd默认读模式是线性读因为它需要经过Raft协议模块反应的是集群共识因此在延时和吞吐量上相比串行读略差一点适用于对数据一致性要求高的场景。
如果你的etcd读请求显示指定了是串行读就不会经过架构图流程中的流程三、四。默认是线性读因此接下来我们看看读请求进入线性读模块它是如何工作的。
### 线性读之ReadIndex
前面我们聊到串行读时提到它之所以能读到旧数据主要原因是Follower节点收到Leader节点同步的写请求后应用日志条目到状态机是个异步过程那么我们能否有一种机制在读取的时候确保最新的数据已经应用到状态机中
![](https://static001.geekbang.org/resource/image/1c/cc/1c065788051c6eaaee965575a04109cc.png?wh=1920*1095)
其实这个机制就是叫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模块进行进行交互了。
## MVCC
流程五中的多版本并发控制(Multiversion concurrency control)模块是为了解决上一讲我们提到etcd v2不支持保存key的历史版本、不支持多key事务等问题而产生的。
它核心由内存树形索引模块(treeIndex)和嵌入式的KV持久化存储库boltdb组成。
首先我们需要简单了解下boltdb它是个基于B+ tree实现的key-value键值库支持事务提供Get/Put等简易API给etcd操作。
那么etcd如何基于boltdb保存一个key的多个历史版本呢?
比如我们现在有以下方案方案1是一个key保存多个历史版本的值方案2每次修改操作生成一个新的版本号(revision)以版本号为key value为用户key-value等信息组成的结构体。
很显然方案1会导致value较大存在明显读写放大、并发冲突等问题而方案2正是etcd所采用的。boltdb的key是全局递增的版本号(revision)value是用户key、value等字段组合成的结构体然后通过treeIndex模块来保存用户key和版本号的映射关系。
treeIndex与boltdb关系如下面的读事务流程图所示从treeIndex中获取key hello的版本号再以版本号作为boltdb的key从boltdb中获取其value信息。
![](https://static001.geekbang.org/resource/image/4e/a3/4e2779c265c1da1f7209b5293e3789a3.png?wh=1920*1124)
### treeIndex
treeIndex模块是基于Google开源的内存版btree库实现的为什么etcd选择上图中的B-tree数据结构保存用户key与版本号之间的映射关系而不是哈希表、二叉树呢在后面的课程中我会再和你介绍。
treeIndex模块只会保存用户的key和相关版本号信息用户key的value数据存储在boltdb里面相比ZooKeeper和etcd v2全内存存储etcd v3对内存要求更低。
简单介绍了etcd如何保存key的历史版本后架构图中流程六也就非常容易理解了 它需要从treeIndex模块中获取hello这个key对应的版本号信息。treeIndex模块基于B-tree快速查找此key返回此key对应的索引项keyIndex即可。索引项中包含版本号等信息。
### buffer
在获取到版本号信息后就可从boltdb模块中获取用户的key-value数据了。不过有一点你要注意并不是所有请求都一定要从boltdb获取数据。
etcd出于数据一致性、性能等考虑在访问boltdb前首先会从一个内存读事务buffer中二分查找你要访问key是否在buffer里面若命中则直接返回。
### boltdb
若buffer未命中此时就真正需要向boltdb模块查询数据了进入了流程七。
我们知道MySQL通过table实现不同数据逻辑隔离那么在boltdb是如何隔离集群元数据与用户数据的呢答案是bucket。boltdb里每个bucket类似对应MySQL一个表用户的key数据存放的bucket名字的是keyetcd MVCC元数据存放的bucket是meta。
因boltdb使用B+ tree来组织用户的key-value数据获取bucket key对象后通过boltdb的游标Cursor可快速在B+ tree找到key hello对应的value数据返回给client。
到这里,一个读请求之路执行完成。
## 小结
最后我们来小结一下一个读请求从client通过Round-robin负载均衡算法选择一个etcd server节点发出gRPC请求经过etcd server的KVServer模块、线性读模块、MVCC的treeIndex和boltdb模块紧密协作完成了一个读请求。
通过一个读请求我带你初步了解了etcd的基础架构以及各个模块之间是如何协作的。
在这过程中我想和你特别总结下client的节点故障自动转移和线性读。
一方面, client的通过负载均衡、错误处理等机制实现了etcd节点之间的故障的自动转移它可助你的业务实现服务高可用建议使用etcd 3.4分支的client版本。
另一方面我详细解释了etcd提供的两种读机制(串行读和线性读)原理和应用场景。通过线性读对业务而言访问多个节点的etcd集群就如访问一个节点一样简单能简洁、快速的获取到集群最新共识数据。
早期etcd线性读使用的Raft log read也就是说把读请求像写请求一样走一遍Raft的协议基于Raft的日志的有序性实现线性读。但此方案读涉及磁盘IO开销性能较差后来实现了ReadIndex读机制来提升读性能满足了Kubernetes等业务的诉求。
## 思考题
etcd在执行读请求过程中涉及磁盘IO吗如果涉及是什么模块在什么场景下会触发呢如果不涉及又是什么原因呢
你可以把你的思考和观点写在留言区里,我会在下一节课里给出我的答案。
感谢你阅读,也欢迎你把这篇文章分享给更多的朋友一起阅读,我们下节课见。