etcd之读写请求的执行过程

etcd读请求如何执行
首先,会对命令中的参数进行解析,在解析完请求中的参数后,会创建一个库对象通过gRPC API来访问 etcd,对应流程一 。
然后通过负载均衡算法选择一个etcd 节点,然后调用 etcd的模块的 Range RPC 方法,把请求发送给 etcd,在 etcd 3.4 中 ,  库采用的负载均衡算法为 Round-robin , 对应流程二 。
收到的 Range RPC 请求后,首先会根据和 RPC将请求转发到对应的实现,首先会将、日志、请求行为检查等一系列拦截器串联后执行,之后就进入核心的读流程 , 对应架构图中的流程三和四,这里首先介绍下串行读和线性读 。
串行读
etcd 的串行读是直接由对应节点的状态机返回数据、无需通过 Raft 协议与集群进行交互的模式 , 具有低延时、高吞吐量的特点,适合对数据一致性要求不高的场景 。
线性读
etcd 默认读模式是线性读,因为它需要经过 Raft 协议模块的,因此在延时和吞吐量上相比串行读略差一点,适用于对数据一致性要求高的场景,即一个值更新成功,随后任何通过线性读的都能及时访问到 。
线性读之
串行读时之所以能读到旧数据,主要原因是当发起一个写请求,收到写请求后会将此请求持久化到 WAL 日志,并广播给各个节点,若一半以上节点持久化成功则该请求对应的日志条目被标识为已提交,的模块再异步的从 Raft 模块获取已提交的日志条目,应用到状态机 (、 等)。
在 etcd 3.1 中引入的,当收到一个线性读请求时,节点首先会向Raft 模块发送请求,此时Raft模块会先向各节点发送心跳确认,一半以上节点确认身份后由节点将已提交日志索引 ( index) 封装成结构体通过层层返回给线性读模块 。线性读模块会等待本节点状态机的已应用日志索引 ( index) 大于等于的已提交日志索引,就通知模块 , 可以与状态机中的 MVCC 模块进行交互读取数据了 。
MVCC
MVCC多版本并发控制模块是为了解决提到 etcd v2 不支持保存 key 的历史版本、不支持多 key 事务等问题而产生的 , 核心由内存树形索引模块 () 、嵌入式的 KV 持久化存储库以及组成 。
其中是基于 B+ tree 实现的 key-value 键值库 , 支持事务,提供 Get/Put 等简易 API 给 etcd 操作 。的 key 是全局递增的版本号 (),value 是用户 key、value 等字段组合成的结构体 , 然后通过模块来保存用户 key 和版本号的映射关系 。
模块是基于开源的内存版 btree 库实现的 。
则是在获取到版本号信息后,并不是所有请求都一定要从获取数据 。etcd 出于数据一致性、性能等考虑 , 在访问前,首先会从一个内存读事务中,二分查找你要访问 key 是否在里面,若命中则直接返回 。
etcd写请求如何执行
首先端通过负载均衡算法选择一个 etcd 节点 , 发起 gRPC 调用 。然后 etcd 节点收到请求后经过 gRPC 拦截器、Quota 模块后,进入模块,模块向 Raft 模块提交一个提案,提案内容为“ put 一个 key 为 hello,value 为 world 的数据” 。随后此提案通过网络模块转发、经过集群多数节点持久化后,状态会变成已提交 ,  从 Raft 模块获取已提交的日志条目,传递给 Apply 模块,Apply 模块通过 MVCC 模块执行提案内容,更新状态机 。
Quota 模块
端发起 gRPC 调用到 etcd 节点,和读请求不一样的是,写请求需要经过Quota模块 。当 etcd收到 put/txn 等写请求的时候,会首先检查下当前 etcd db 大小加上你请求的 key-value 大小之和是否超过了配额(quota--bytes) 。如果超过了配额,它会产生一个 NO SPACE的告警,并通过 Raft 日志同步给其它节点,告知 db 无空间了,并将告警持久化存储到 db 中 。
因为etcd v3 是个 MVCC 数据库,保存了 key 的历史版本,当你未配置压缩策略()的时候,随着数据不断写入,db 大小会不断增大,导致超限 。压缩模块支持按多种方式回收旧版本,比如保留最近一段时间内的历史版本 。不过它仅仅是将旧版本占用的空间打个空闲(Free)标记,后续新的数据写入的时候可复用这块空间而无需申请新的空间 。
如果你需要回收空间减少 db 大?。?得使用碎片整理() ,  它会遍历旧的 db 文件数据并写入到一个新的 db 文件 。但是它对服务性能有较大影响,不建议你在生产集群频繁使用 。
模块
通过配额检查后,请求就从 API 层转发到了模块的 put 方法,我们知道 etcd 是基于 Raft 算法实现节点间数据复制的,因此它需要将 put 写请求内容打包成一个提案消息,提交给 Raft 模块 。不过模块在提交提案前,还有如下的一系列检查和限速 。
Check:为了保证集群稳定性,避免雪崩 , 任何提交到 Raft 模块的请求,都会做一些简单的限速判断 。如果 Raft 模块已提交的日志索引( index)比已应用到状态机的日志索引( index)超过了 5000,那么它就返回一个": too many "错误给。
鉴权:尝试去获取请求中的鉴权信息,若使用了密码鉴权、请求中携带了 token,如果 token 无效,则返回"auth:auth token"错误给。
包大?。夯峒觳槟阈慈氲陌笮∈欠癯系?1.5MB,如果超过了会返回":is too large"错误给给。
通过一系列检查之后 , 会生成一个唯一的 ID并将请求关联到一个对应的消息通知,然后向 Raft 模块发起()一个提案(),即流程四 。向 Raft 模块发起提案后,模块会等待此 put 请求,等待写入结果通过消息通知返回或者超时 。etcd 提交提案的默认超时时间是 7 秒(5 秒磁盘 IO 延时 +2*1 秒竞选超时时间),如果一个提案请求超时未返回结果 , 则可能会出现你熟悉的 :timed out 错误 。
WAL 模块
Raft 模块收到提案后,如果当前节点是 ,它会转发给 ,只有才能处理写请求 。收到提案后,通过 Raft 模块 将 Raft日志条目 广播给节点 , 同时将待持久化的Raft日志条目持久化到 WAL 文件中,最后将 Raft日志条目追加到稳定的 Raft 日志存储中(内存) , 各个收到 Raft日志条目 后会持久化到 WAL 日志中,并将消息追加到 Raft 日志存储,随后会向回复确认信息 。即每个提案在被提交前都会各个节点被持久化到 WAL 日志文件中,以保证集群的一致性、可恢复性(crash后重启恢复) , 即流程五 。
当一半以上节点持久化此Raft日志条目(WAL预写日志)后 ,  Raft 模块就会通过告知模块,put 提案已经被集群多数节点确认,提案状态为已提交 , 可以继续向Apply模块提交提案内容 。
于是进入流程六 ,  模块从取出提案内容,添加到先进先出(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 模块来执行提案内容 。MVCC 主要由三部分组成 , 一个是内存索引模块,是一个内存版 BTree  , 保存用户 key 和版本号 ()的映射关系;另一个是模块,是基于 B+tree 实现的 key-value 嵌入式 db,通过提供桶()机制实现类似 MySQL 表的逻辑隔离 ,  的 key 是全局递增的版本号,value 是用户 key、value 等字段组合成的结构体;另一个是用来保存 合并后异步定时批量提交 导致的暂未提交的事务数据 。
版本号()在 etcd 里面发挥着重大作用,etcd 启动的时候默认版本号是 1 , 随着你对 key 的增、删、改操作而全局单调递增,则使用版本号作为key,的 value具体包括了用户key 值,value 值、key 创建时的版本号()、最后一次修改时的版本号()、key 自身修改的次数(),租约信息 。
虽然 put 调用成功 , 但etcd 并未提交事务 , 数据只更新在所管理的内存数据结构中 。事务提交的过程 , 包含 B+tree 的平衡、分裂 , 将的脏数据(dirty page)、元数据信息刷新到磁盘,因此事务提交的开销是昂贵的,如果我们每次更新都提交事务,etcd 写性能就会较差 。
etcd 采用是合并后异步定时批量提交,etcd 通过合并多个写事务请求,异步定时的(默认每隔 100ms)将批量事务一次性提交从而刷新到磁盘 ,  从而大大提高吞吐量,仅默认堆积的写事务数大于 1 万才在写事务结束时同步持久化 。
同手,etcd 引入了一个来保存暂未提交的事务数据,在更新的时候 , etcd 也会同步数据到。因此 etcd 处理读请求的时候会优先从里面读取,其次再从读,通过实现读写性能提升 , 同时保证数据一致性
etcd增删改查的过程
修改key时会根据key从中获取版本号信息,并以版本号为key,用户key-value作为value写入,同时更新的版本号信息,之后etcd 采用是合并后异步定时批量提交事务写入磁盘;
查询key时未指定版本号则返回 key的最新版本号,根据版本号优先从中查询,未命中则从中查询key-value;
删除一个key时,采用的是异步删除 , 只不过给和 的 key 会打上删除标记,真正删除中的索引对象、 中的 key 是通过压缩 () 组件异步完成 。
要点记录
1、线性读 需要等待本节点状态机的已应用日志索引 ( index) 大于等于的已提交日志索引,才能继续读取数据 。
2、etcd 提交提案的默认超时时间是 7 秒 , 如果一个提案请求超时未返回结果 , 则会出现的 :timed out 错误 。
3、Raft 模块收到提案后 , 如果当前节点是 ,它会转发给  , 只有才能处理写请求 。收到提案后,通过 Raft 模块 将 Raft日志条目 广播给节点,同时将待持久化的Raft日志条目持久化到 WAL 文件中,并也将 Raft日志条目追加到稳定的 Raft 日志存储中(内存),各个收到 Raft日志条目 后会持久化到 WAL 日志中 , 并也将消息追加到 Raft 日志存储 , 随后会向回复确认信息 。即每个提案在被提交前都会各个节点被持久化到 WAL 日志文件中,以保证集群的一致性、可恢复性(crash后重启恢复)
4、当一半以上节点持久化了WAL日志后,Raft 模块才会通过告知模块 , put 提案已经被集群多数节点确认,提案状态为已提交,可以继续提交给Apply模块执行 。
etcd 重启时会从 WAL预写式日志中解析出 Raft 日志条目内容,并重放已提交的日志提案到 Apply 模块执行 。
etcd 在启动的时候会将etcd db文件内容拷贝到etcd进程内存中,启动拷贝是需要磁盘IO,节点内存足够的请求下,后续处理读请求过程中就不会产生磁盘 I/IO 了 。
5、MVCC 主要由三部分组成,一个是内存索引模块 ,实现是BTree,用于保存用户 key 和版本号 ()的映射关系;另一个是模块,是基于 B+tree 实现的 key-value 嵌入式 db,通过提供桶()机制实现类似 MySQL 表的逻辑隔离,的 key 是全局递增的版本号,value 是用户 key、value 等字段组合成的结构体;第三个是用来保存 合并后异步定时批量提交 导致的暂未提交的事务数据 。
【etcd之读写请求的执行过程】6、etcd采用合并后异步定时批量提交 , etcd 通过合并多个写事务请求,异步定时的(默认每隔 100ms)将事务批量一次性提交,进而刷新到磁盘,从而大大提高吞吐量 。