「etcd」- 数据写入流程

问题描述

该笔记将记录:我们将学习 etcd 数据读流程,以理解 etcd 数据读取的工作原理;

解决方案

极客时间 /etcd 实战课(唐聪,腾讯云资深工程师,etcd 活跃贡献者)

写数据命令:

etcdctl put hello world --endpoints http://127.0.0.1:2379
OK

Client =(gPRC)=> etcd Node

通过负载均衡算法,Client 选择一个 etcd Node,发起 gRPC 调用,即 ① 步骤;

Quota Module

然后 etcd Node 收到请求后,经过 gRPC 拦截器,Write Request 需要经过 Quota(配额模块)处理,即 ② 步骤;

模块功能:配额限制;

概述原理

当 etcd Server 收到 put/txn 等 Write Request 的时候,会首先检查当前 etcd db 大小加上请求的 K/V 大小之和是否超过配额(quota-backend-bytes);

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

最终,gRPC Module(API 层 )和 Apply Module(负责将 Raft 侧已提交的日志条目应用到状态机),都拒绝写入,集群只读;

触发配额限制的场景

在使用 etcd 过程中,出现 etcdserver: mvcc: database space exceeded 错误,则表示当前 etcd db 文件大小超过配额。当出现此错误后,整个集群将不可写入(只读状态);

默认 db 配额仅为 2G,当业务数据、Write QPS、Kubernetes 集群规模增大后,etcd db 大小就可能会超过 2G;

etcd v3 是个 MVCC 数据库,保存 key 的历史版本。当未配置压缩策略的时候,随着数据不断写入,db 大小会不断增大,导致超限;

如果 etcd Version < 3.2.10 版本,备份操作可能会触发 boltdb 的某个 Bug,它会导致 db 大小不断上涨,最终达到配额限制;

调整配额的方法(流程)

调整配额
1)直接调整配额,但 etcd 社区建议不超过 8G;
2)需要注意配额(quota-backend-bytes)的行为:默认’0’就是使用 etcd 默认的 2GB 大小;如果参数 < 0 ,就会禁用配额功能,可能会导致 db 大小处于失控,导致性能下降,所以不建议禁用配额;

取消警告:还需要额外发送一个取消告警(etcdctl alarm disarm)的命令,以消除所有告警。原因就是,前面提到的 NO SPACE 告警未消除,而 Apply Module 在执行每个命令的时候,都会去检查当前是否存在 NO SPACE 告警。如果存在 NO SPACE 告警,那么即使把配额(quota-backend-bytes)调大后,集群依然拒绝写入;

压缩检查
1)其次需要检查 etcd 的压缩(compact)配置是否开启,及其配置是否合理。etcd 保存一个 key 所有变更历史版本,如果没有 Compactor Module(压缩模块)去回收旧的版本,那么内存和 db 大小就会不断膨胀;
2)压缩模块支持按多种方式回收旧版本,比如保留最近一段时间内的历史版本等等。注意,它仅将旧版本占用的空间通过 Free 标记,后续新的数据写入时,将复用这块空间,而无需申请新的空间。换句话说,开启 Compactor 功能并不会减小 etcd 占用的空间,排查 etcd 的可用空间时,不能仅关注 etcd 已占用的空间;

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

KVServer Module

当通过 Quota Module 检查后,请求就从 API 层转发到的 KVServer Module 的 put 方法,即 ③ 步骤;

KVServer Module 会向 Raft Module 提交 propose(put hello) 写请求;

Preflight Check

为保证集群稳定性(避免雪崩),任何提交到 Raft Module 的请求,在执行前,都会做些简单的限速判断及其他检查;

流程如下图所示:

当 WAL 在多数节点应用成功后,就会返回写入成功,后面在异步提交到状态机,那么会出现已提交日志索引比应用到状态机的日志索引超出的情况。所以,如果 Raft Module 已提交的日志索引(committed index)比已应用到状态机的日志索引(applied index)超过 5000,那么它就返回 etcdserver: too many requests 错误给 Client;

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

其次,它会检查写入的包大小是否超过默认的 1.5MB(所以 Kubernetes 的 ConfigMap 不能超过 1.5mb), 如果超过,则会向 Client 返回 etcdserver: request is too large 错误;

Propose

当通过 Preflight Check 检查后,会生成一个唯一的 ID,将此请求关联到一个对应的消息通知 channel,

etcd 是基于 Raft 实现节点间数据复制的,所以它需要将 Write Request 内容打包成 Propose 消息,提交给 Raft Module 来处理。所以 KVServer 向 Raft Module 发起一个提案(Proposal),其内容为“大家好,请使用 put 方法执行一个 key 为 hello,value 为 world 的命令”,也就是 ④ 流程;

KVServer 向 Raft Module 发送 Propose 后,KVServer 会等待此 put 请求的写入结果通过消息通知 channel 返回。或超时,默认超时时间是 7s(5 秒磁盘 IO 延时 + 2*1 秒竞选超时时间)。如果请求超时未返回结果,则可能会出现 etcdserver: request timed out 错误;

Raft Module

当前节点 Raft Module 收到 propose 后,如果当前节点是 Follower,它会转发给 Leader(只有 Leader 才能处理 Write Request,如果此时集群中无 Leader,整个请求就会超时;);

当 Leader 收到 propose 后:

首先,通过 Raft Module 产生:(1)待转发给 Follower 的消息;(2)待持久化的日志条目,上述 propose(put hello) 的内容将封装在日志条目中;

然后,作为 Leader(etcd Server),其将从 Raft Module 获取到以上消息和日志条目:

1)需要把 Leader 任期号、投票信息、已提交索引、propose 内容持久化到 WAL 文件中,以保证集群一致性及可恢复性,即 ⑤ 流程;

2)同时(并行)Leader 会将 propose(put hello) 消息广播给各个 Follower;

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

于是进入 ⑥ 流程,etcdserver 模块从 channel 取出 propose 内容(通过 RaftHTTP 网络模块转发),添加到 FIFO 调度队列,随后传递给 Apply Module,通过 Apply Module 按入队顺序,异步、依次执行 propose 内容;

Apply Module

流程概述

执行 put propose 内容,如图 ⑦ 流程,其细节如下;

Apply Module 是如何执行 put 请求的呢?

Apply Module 在执行 propose 内容前,首先会判断当前 propose 是否已经执行过。如果执行,则直接返回。若未执行,同时无 db 配额满告警,则通过 MVCC Module 执行 propose 内容,来更新状态机(持久化存储);

常见问题

若 put 请求 propose 在执行流程七的时候 etcd 突然 crash,重启恢复的时候,etcd 是如何找回异常 propose,再次执行的呢?

核心就是 WAL 日志,因为提交给 Apply Module 执行的 propose 已获得多数节点确认、持久化。所以当 etcd 重启后,会从 WAL 中解析出 Raft 日志条目内容,追加到 Raft 日志的存储中,并重放已提交的日志 propose 给 Apply Module 执行;

如何确保幂等性,防止 propose 重复执行导致数据混乱呢?etcd 是个 MVCC 数据库,每次更新都会生成新的版本号。如果没有幂等性保护,同样的命令,一部分节点执行一次,一部分节点遭遇异常故障后执行多次,则系统的各节点一致性状态无法得到保证。因此 etcd 必须要确保幂等性。怎么做呢?Apply Module 从 Raft Module 获得的日志条目信息里,是否有唯一的字段能标识这个 propose?

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

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

所以,在实现上还需要将两个操作作为原子性事务提交,才能实现幂等。etcd 通过引入一个 consistent index 的字段,来存储系统当前已经执行过的日志条目索引,实现幂等性;

MVCC Module

当 Apply Module 判断此 propose 未执行后,就会调用 MVCC Module 来执行 propose 内容;

MVCC 主要由两部分组成:是内存索引模块 treeIndex,保存 key 的历史版本号信息; boltdb 模块,用来持久化存储 key-value 数据;

那么 MVCC Module 执行 put hello 为 world 命令时,它是如何构建内存索引和保存哪些数据到 db 呢?

treeIndex

当 treeIndex 收到更新 key hello 为 world 的时候,此 key 的索引版本号信息是怎么生成的呢?需要维护、持久化存储一个全局版本号吗?

版本号(revision)在 etcd 里面发挥着重大作用,它是 etcd 的逻辑时钟。但 etcd 启动时,默认 revision=1,随着 key 的增、删、改操作而全局单调递增;

因为 boltdb 中的 key 就包含此信息,所以 etcd 并不需要再去持久化一个全局版本号。我们只需要在启动的时候,从最小值 1 开始枚举到最大值,未读到数据的时候则结束,最后读出来的版本号即是当前 etcd 的最大版本号 currentRevision;

MVCC 写事务在执行 put hello 为 world 的请求时,会基于 currentRevision 自增生成新的 revision 如{2,0},然后从 treeIndex 模块中查询 key 的创建版本号、修改次数信息。这些信息将填充到 boltdb 的 value 中,同时将用户的 hello key 和 revision 等信息存储到 B-tree,

也就是下面简易写事务图的流程一,整体架构图中的流程八;

boltdb

MVCC 写事务自增全局版本号后,生成的 revision{2,0},它就是 boltdb 的 key,通过它就可以往 boltdb 写数据,进入整体架构图中的流程九;

在 etcd 里面通过 put/txn 等 KV API 操作的数据,全部保存在一个名为 key 的桶里面,这个 key 桶在启动 etcd 的时候会自动创建;

除保存用户 KV 数据的 key 桶,etcd 本身及其它功能需要持久化存储的话,都会创建对应的桶。比如上面我们提到的 etcd 为保证日志的幂等性,保存一个名为 consistent index 的变量在 db 里面,它实际上就存储在 meta 桶里面;

那么写入 boltdb 的 value 含有哪些信息呢?

写入 boltdb 的 value, 并不是简单的”world”,如果只存一个用户 value,索引又是保存在易失的内存上,那重启 etcd 后,我们就丢失用户的 key 名,无法构建 treeIndex 模块;

所以,为构建索引和支持 Lease 等特性,etcd 会持久化以下信息:

1)key 名称;

2)key 创建时的版本号(create_revision)、最后一次修改时的版本号(mod_revision)、key 自身修改的次数(version);

3)value 值;

4)租约信息(后面介绍);

boltdb value 的值就是将含以上信息的结构体序列化成的二进制数据,然后通过 boltdb 提供的 put 接口,etcd 就快速完成将的数据写入 boltdb,对应上面简易写事务图的流程二;

但是 put 调用成功,就能够代表数据已经持久化到 db 文件吗?

这里需要注意的是,在以上流程中,etcd 并未提交事务(commit),因此数据只更新在 boltdb 所管理的内存数据结构中;

事务提交的过程,包含 B+tree 的平衡、分裂,将 boltdb 的脏数据(dirty page)、元数据信息刷新到磁盘,因此事务提交的开销是昂贵的。如果我们每次更新都提交事务,etcd 写性能就会较差;

那么解决的办法是什么呢?etcd 的解决方案是合并再合并;

首先 boltdb key 是版本号,put/delete 操作时,都会基于当前版本号递增生成新的版本号,因此属于顺序写入,可以调整 boltdb 的 bucket.FillPercent 参数,使每个 page 填充更多数据,减少 page 的分裂次数并降低 db 空间;

其次 etcd 通过合并多个写事务请求,通常情况下,是异步机制定时(默认每隔 100ms)将批量事务一次性提交(pending 事务过多才会触发同步提交), 从而大大提高吞吐量,对应上面简易写事务图的流程三;

但是这优化又引发另外的一个问题:因为事务未提交,Read Request 可能无法从 boltdb 获取到最新数据;

为解决这个问题,etcd 引入一个 bucket buffer 来保存暂未提交的事务数据。在更新 boltdb 的时候,etcd 也会同步数据到 bucket buffer。因此 etcd 处理 Read Request 的时候会优先从 bucket buffer 里面读取,其次再从 boltdb 读,通过 bucket buffer 实现读写性能提升,同时保证数据一致性;