问题描述
该笔记将记录:我们将学习 etcd 数据读流程,以理解 etcd 数据读取的工作原理;
解决方案
极客时间 /etcd 实战课(唐聪,腾讯云资深工程师,etcd 活跃贡献者)
虽说 etcd 是典型的「读多写少」存储,但是具体的读写取决于业务场景,部分我们将学习读请求是如何执行的;
执行命令:
// 写入数据 // 如果为提前 put 数据,Server 将产生 connect reset by peer 错误 # ./bin/etcdctl put hello "world" --endpoints http://127.0.0.1:2379 OK // 读取数据 // “get”是请求的方法,它是 KVServer 模块的 API; // “hello”是我们查询的 key 名; // “endpoints”是我们后端的 etcd 地址,通常,生产环境下需要配置多个 endpoints,以在节点出现故障后,client 能够自动重连到其它正常的节点; # ./bin/etcdctl get hello --endpoints http://127.0.0.1:2379 hello world
第一步、etcdctl => etcd/KVServer
在 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 中,Client v3 库采用的 Round-robin 负载均衡算法为。针对每一个请求,Round-robin 算法通过轮询的方式依次从 endpoint 列表中选择一个 endpoint 访问 (长连接),使 etcd server 负载尽量均衡;
关于负载均衡算法,需要特别注意以下两点;
为请求选择好 etcd server 节点,client 就可调用 etcd server 的 KVServer 模块的 Range RPC 方法,把请求发送给 etcd server;
补充说明,client 和 server 间使用的是基于 HTTP/2 的 gRPC 协议通信。相比 etcd v2 的 HTTP/1.x,HTTP/2 是基于二进制而不是文本、支持多路复用而不再有序且阻塞、支持数据压缩以减少包大小、支持 server push 等特性。因此,基于 HTTP/2 的 gRPC 协议具有低延迟、高性能的特点,有效解决我们在上一讲中提到的 etcd v2 中 HTTP/1.x 性能问题;
第二步、inside KVServer
client 发送 Range RPC 请求到 server 后,就开始 ② 步骤,即是 KVServer 模块;
拦截器
1)要求执行一个操作前集群必须有 Leader;
2)请求延时超过指定阈值的,打印包含来源 IP 的慢查询日志 (3.5 版本);
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: "*" }; } .... }
server 收到 client 的 Range RPC 请求后,根据 ServiceName 和 RPC Method 将请求转发到对应的 handler 实现,handler 首先会将上面描述的一系列拦截器串联成一个执行,在拦截器逻辑中,通过调用 KVServer 模块的 Range 接口获取数据;
第三步、Read Consistency
进入 KVServer 模块后,我们就进入核心的读流程,对应 ③ / ④ 流程。我们知道 etcd 为保证服务高可用,生产环境一般部署多个节点;
但是根据 etcd 写入原理(异步提交),在多节点 etcd 集群中,各个节点的状态机数据一致性存在差异。而我们不同业务场景的读请求对数据是否最新的容忍度是不一样的,有的场景它可以容忍数据落后几秒甚至几分钟,有的场景要求必须读到反映集群共识的最新数据;
串行读(Serializable Read,SR):
线性读(Linearizable Read,LR)
1)KVServer 通过 ③ 向 Raft 模块发起 ReadIndex 请求;
2)Raft 模块将 Leader 最新的已提交日志索引封装在 ④ 的 ReadState 结构体,通过 channel 层层返回给线性读模块;
3)当前节点的线性读模块将等待当前节点的状态机追赶上 Leader 进度(即等待数据写入完成)。当追赶完成后,就通知 KVServer 模块,进行 ⑤ 流程,与状态机中的 MVCC 模块进行进行交互;
第四步、Data Reading
问题:在 etcd v2 中,其存在不支持保存 key 的历史版本、不支持多 key 事务等问题;
方案:多版本并发控制 (Multiversion concurrency control) 模块,用于解决该问题;
etcd 是如何保存一个 key 的多个历史版本呢?
即 MVCC 其组成包括:
1)treeIndex 模块是基于 Google 开源的内存版 btree 库实现的;
2)treeIndex 模块只会保存用户的 key 和相关版本号信息;
3)treeIndex 模块基于 B-tree 快速查找 key,返回此 key 对应的索引项 keyIndex 即可,索引项中包含 revision 等信息。每个 key 在 treeIndex 中有一个对应的数据结构 keyIndex,它保存所有 revision 信息。在获取到 revision 信息后,就可从 boltdb 模块中获取用户的 KV 数据;
1)但是并非所有请求都一定要从 boltdb 获取数据。etcd 出于数据一致性、性能等考虑,在访问 boltdb 前,首先会从一个内存读事务 buffer 中,二分查找要访问 key 是否在 buffer 里面,若命中则直接返回;
2)若 buffer 未命中,此时就真正需要向 boltdb 模块查询数据;
1)基于 B+ tree 实现的 KV 键值库,支持事务,向 etcd 提供 Get/Put 等简易 API;
2)用户 key 的 value 数据存储在 boltdb 里面,相比 etcd v2 全内存存储,etcd v3 对内存要求更低;
3)数据隔离:boltdb 里每个 bucket 类似对应 MySQL 一个表,用户的 key 数据存放的 bucket Name 的是 key,etcd MVCC 元数据存放的 bucket 是 meta;
4)因 boltdb 使用 B+ tree 来组织用户的 key-value 数据,获取 bucket key 对象后,通过 boltdb 的游标 Cursor 可快速在 B+ tree 找到 key hello 对应的 value 数据,返回给 client;
treeIndex 与 boltdb 关系,如下面的读事务流程图所示:
1)从 treeIndex 中获取 key hello 的 revision(架构图步骤 ⑥ 所示)
2)再以 revision 作为 boltdb 的 key,从 boltdb 中获取其 value 信息(架构图步骤 ⑦ 所示);
常见问题
如果 buffer 没读到,从 boltdb 读时会产生磁盘 I/O?
实际上,etcd 在启动的时候会通过 mmap 机制将 etcd db 文件映射到 etcd 进程地址空间,并设置了 mmap 的 MAP_POPULATE flag,它会告诉 Linux 内核预读文件,Linux 内核会将文件内容拷贝到物理内存中,此时会产生磁盘 I/O。节点内存足够的请求下,后续处理读请求过程中就不会产生磁盘 I/IO 了;
若 etcd 节点内存不足,可能会导致 db 文件对应的内存页被换出,当读请求命中的页未在内存中时,就会产生缺页异常,导致读过程中产生磁盘 IO,你可以通过观察 etcd 进程的 majflt 字段来判断 etcd 是否产生了主缺页中断;
expensive request 是否影响写请求性能?
在 etcd 3.0 中,线性读请求需要走一遍 Raft 协议持久化到 WAL 日志中,因此读性能非常差,写请求肯定也会被影响;
在 etcd 3.1 中,引入了 ReadIndex 机制提升读性能,读请求无需再持久化到 WAL 中;
在 etcd 3.2 中,优化思路转移到了 MVCC/boltdb 模块,boltdb 的事务锁由粗粒度的互斥锁,优化成读写锁,实现“N reads or 1 write”的并行,同时引入了 buffer 来提升吞吐量。问题就出在这个 buffer,读事务会加读锁,写事务结束时要升级锁更新 buffer,但是 expensive request 导致读事务长时间持有锁,最终导致写请求超时;
在 etcd 3.4 中,实现了全并发读,创建读事务的时候会全量拷贝 buffer, 读写事务不再因为 buffer 阻塞,大大缓解了 expensive request 对 etcd 性能的影响。尤其是 Kubernetes List Pod 等资源场景来说,etcd 稳定性显著提升。
参考文献
etcd 实战课(唐聪,腾讯云资深工程师,etcd 活跃贡献者)_极客时间
ETCD背后的Raft一致性算法原理 – 简书