「etcd」- 数据读取流程

问题描述

该笔记将记录:我们将学习 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 负载尽量均衡;

关于负载均衡算法,需要特别注意以下两点;

如果 client version <= 3.3,那么当配置多个 endpoint 时,负载均衡算法仅会从中选择一个 IP 并创建一个连接(Pinned endpoint),这样可以节省服务器总连接数。但在在 heavy usage 场景中,这可能会造成 server 负载不均衡(etcd version < 3.4,负载均衡算法的 bug);

在 client 3.4 之前的版本中,负载均衡算法有一个严重的 Bug:如果第一个节点异常,可能会导致 client 访问 etcd server 异常,特别是在 Kubernetes 场景中会导致 API Server 不可用。但该 Bug 已在 Kubernetes 1.16 版本后被修复;

为请求选择好 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 模块;

拦截器

拦截器提供在执行一个请求前后的 hook 能力。通过拦截器,etcd 非侵入式地实现如下特性:提供丰富的 metrics 统计、调试日志、请求行为检查等机制,可记录所有请求的执行耗时及错误码、来源 IP 等,也可控制请求是否允许通过(比如只允许指定接口、受限参数来访问 etcd Learner 节点),帮助大家定位问题、提高服务可观测性等;

etcd 还基于拦截器实现以下特性:

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):

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

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

如果 etcd 读请求显示指定了是串行读,就不会经过架构图流程中的 ③ / ④ 流程;

线性读(Linearizable Read,LR)

对数据敏感性高的场景。当发布服务时,提交的时候显示更新成功,但是读取数据时发现数据是旧的,再读取又是新的,这就会导致混乱;

在 etcd 中,提供 LR 模式来解决对数据一致性要求高的场景。LR 是 etcd 默认读模式;

通过 LR,当某个值更新成功,随后任何使用 LR 的 Client 都能访问到最新数据。虽然集群中有多个节点,但 Client 通过 LR 就如访问单个节点一样;

鉴于 LR 需要经过 Raft 协议模块,反应的是集群共识,因此在延时和吞吐量上相比串行读略差一点,适用于对数据一致性要求高的场景;

KVServer 模块收到线性读请求后:

1)KVServer 通过 ③ 向 Raft 模块发起 ReadIndex 请求;

2)Raft 模块将 Leader 最新的已提交日志索引封装在 ④ 的 ReadState 结构体,通过 channel 层层返回给线性读模块;

3)当前节点的线性读模块将等待当前节点的状态机追赶上 Leader 进度(即等待数据写入完成)。当追赶完成后,就通知 KVServer 模块,进行 ⑤ 流程,与状态机中的 MVCC 模块进行进行交互;

第四步、Data Reading

问题:在 etcd v2 中,其存在不支持保存 key 的历史版本、不支持多 key 事务等问题;

方案:多版本并发控制 (Multiversion concurrency control) 模块,用于解决该问题;

etcd 是如何保存一个 key 的多个历史版本呢?

方案 1:是一个 key 保存多个历史版本的值;方案 2 每次修改操作,生成一个新的版本号 (revision),以版本号为 key, value 为用户 key-value 等信息组成的结构体。该方案会导致 value 较大,存在明显读写放大(扫描所有 Key)、并发冲突(多次对同一个 key 进行写时,是在同一个 key 下操作,以 B+树为例,会导致 key 下面子树的 rebalance)等问题;

方案 2:每次修改操作,生成一个新的版本号 (revision),以版本号为 key,value 为用户 key-value 等信息组成的结构体。该方案无并发冲突(每次新增修改时,都是在新增的版本的 key 下面去修改)。该方案是 etcd 所采用的:通过 treeIndex 模块来保存用户 key 和版本号的映射关系。而 boltdb 的 key 是全局递增的版本号 (revision),value 是用户 key、value 等字段组合成的结构体;

即 MVCC 其组成包括:

treeIndex(内存树形索引模块):

1)treeIndex 模块是基于 Google 开源的内存版 btree 库实现的;

2)treeIndex 模块只会保存用户的 key 和相关版本号信息;

3)treeIndex 模块基于 B-tree 快速查找 key,返回此 key 对应的索引项 keyIndex 即可,索引项中包含 revision 等信息。每个 key 在 treeIndex 中有一个对应的数据结构 keyIndex,它保存所有 revision 信息。在获取到 revision 信息后,就可从 boltdb 模块中获取用户的 KV 数据;

buffer:

1)但是并非所有请求都一定要从 boltdb 获取数据。etcd 出于数据一致性、性能等考虑,在访问 boltdb 前,首先会从一个内存读事务 buffer 中,二分查找要访问 key 是否在 buffer 里面,若命中则直接返回;

2)若 buffer 未命中,此时就真正需要向 boltdb 模块查询数据;

boltdb(嵌入式的 KV 持久化存储库):

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一致性算法原理 – 简书