03| ETCD 的一个写请求是如何执行的

先简单看一个 etcd put hello 为 world 的写请求的简要执行流程

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

首先 client 端通过负载均衡算法选择一个 etcd 节点,发起 GRPC 调用。

然后 etcd 节点收到请求后经过 GRPC 拦截器、Quota 模块后,进入 KVServer 模块。KVServer 模块向 Raft 模块提交一个提案,提案内容可以理解为「大家好,请使用一个 put 方法执行一个 key 为 hello,value 为 world 的命令」。

随后此提案经过 RaftHTTP 网络模块转发、经过集群多数节点持久化后,状态会变成已提交,etcd server 从 Raft 模块获取已提交的日志条目,传递给 Apply 模块,Apply 模块通过 MVCC 模块执行提案内容,更新状态机。

与读流程不同的是,写流程还经过 Quota、WAL、Apply 三个模块。crash-safe 及幂等性也是基于 WAL 和 Apply流程的 consistent index 等实现的。

下面我们沿着流程图,分析一个 key-value 是如何安全的、幂等的持久化到磁盘的。

Quota 模块


首先是流程一 client 端发起 GRPC 调用到 etcd 节点,和读请求不一样的是,写请求需要经过流程二 db 配额(Quota)模块。

我们先从这个模块的一个常见错误说起。

Quota 常见问题

在使用 etcd 或者 Kubernetes 大概率会碰到过下面这个错误

etcdserver: mvcc: database space exeeded

它指当前的 etcd db 文件大小超过了配额,当出现此错误后,你的整个集群将不可写入,只读,对业务影响非常大。

触发这个错误常见场景如下

  • 默认 db 配额仅为 2G,当你的业务数据、写入 QPS、Kubernetes 集群规模增大后,你的 etcd db 大小就可能超过 2G。
  • etcd v3 是一个 MVCC 数据库,保存了 key 的历史版本,当你未配置压缩策略的时候,随着数据不断写入,db 大小会断增大,导致超限。
  • etcd 3.2.10 之前的 boltdb 备份的 bug,会导致 db 大小不断上涨超出配额限制

Quota 模块是如何工作的

了解完 Quota 限制的原因后,我们再来看看 Quota 模块是如何工作的。

  • 当 etcd server 收到 put/txn 等写请求时,首先会检查下当前的 etcd db 大小加上你请求的 key-value 大小之和是否超过了配额(quota-bakend-bytes)。
  • 如果超过了配额,它会产生一个告警(Alarm)请求,高清类型是 NO SPACE,并通过 Raft 日志同步给其他节点,告知 db 无空间 ,并将告警持久化到存储 db 中。
  • 最终无论是 API 层 GRPC 模块还是负责将 Raft 侧已提交的日志条目应用到状态机的 Apply 模块,都拒绝写入,集群只读。

如何解决 Quota 模块的错误

首先是调大配额。但 etcd 社区简易不超过 8G。

但当你调大配额(quota-backend-bytes)后,会发现集群还是出于拒绝写入的状态。

这是因为前面的 NO SPACE 告警。

Apply 模块在执行每个命令的时候,都会去检查当前是否存在 NO SPACE 告警,如果又则拒绝写入。所以你还需要额外发送下面这个取消告警的命令来消除所有告警。

etcdctl alarm disarm

其次你需要检查 etcd 的压缩(compact)配置是开启以及配置是否合理。etcd 保存了一个 key 所有变更历史版本,如果没有一个机制去回收旧的版本,那么内存和 db 大小就会一直膨胀,在 etcd 里面,压缩模块负责回收旧版本的工作。

压缩模块支持按多种方式回收旧版本,比如保留最近一段时间内的历史版本。不过需要注意的是,它仅仅是将旧版本占用的空间个 Free 的 TAG,后续新的数据写入的时候可以复用这块空间,而无须申请新的空间。

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

最后需要注意配额(quota-backend-bytes)的行为。默认 ‘0’ 就是使用 etcd 默认的 2GB 的大小,你可以根据你的业务场景适当调优。如果你填的是小于 0 的个数,就会禁用配额功能,这可能会让的 db 大小处于失控,导致性能下降;不建议禁用配额功能

KVServer 模块


通过流程而的配额检查后,请求从 API 层转发到了流程三的 KVServer 模块的 put 方法,我们知道 etcd 是基于 Raft 算法实现节点间数据复制的,因此它需要将 put 写请求打包成一个提案消息,提交给 Raft 模块。不过 KVServer 模块在提交提案前,还有如下的一系列检查和限速。

Preflight Check


为了保证集群稳定性,避免雪崩,任何提交到 Raft 模块的请求,都会做一些简单的限速判断。如下面的流程图所示。

  • 首先,如果 Raft 模块已提交的日志索引比已应用到状态机的日志索引(applied index)超过了 5000,那么它会你会一个 etcdserver: too many requests 错误给 client。

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

  • 其次它会检查你写入的包大小是否超过默认的 1.5MB,如果超过了会返回 etcdserver: request is too large 错误给 client。

Propose


最后通过一系列检查之后,会生成一个唯一的 ID,将此请求关联到一个对应的消息通知 channel,然后向 Raft 模块发起一个(Propose)一个提案(Proposal),提案内容为”大家好,请使用 put 方法执行一个 key 为 hello,value 为 world 的命令”,也就是整体架构图里的流程四。

向 Raft 模块发起提案后,KVServer 模块会等待此 put 请求,等待写入结果通过消息通知 channel 返回或者超时。etcd 默认超时时间是 7s(5s 磁盘 IO 延时 + 2 * 1s 竞选超时),如果一个请求超时未返回结果,则可能会出现你熟悉的 etcdserver: request timed out 错误。

WAL 模块


Raft 模块收到提案后,如果当前节点是 Follower,它会转发给 Leader,只有 Leader 才能处理写请求。Leader 收到提案后,通过 Raft 模块输出带转发给 Follower 节点的消息和待持久化的日志条目,日志条目则封装了我们上面所说的 put hello 提案内容。

etcdserver 从 Raft 模块获取到上述消息和日志条目后,作为 Leader,它会 put 提案消息广播给集群各个节点,同视需要把集群 Leader 任期号、投票信息、已提交索引、提案内容持久化到一个 WAL(Write Ahead Log)日志文件中,用于保证集群的一致性、可恢复性,也就是我们途中的流程五。

WAL 日志结构

WAL 日志结构大致如下:

WAL 日志结构

一个 WAL 结构由多种类型的 WAL 记录顺序追加写入组成,每个记录由类型、数据、循环冗余校验码组成。不同类型的记录通过 Type 字段区分,Data 为对应记录内容,CRC 为循环校验码信息。

WAL 记录类型目前支持五种:

  • 文件元数据记录:包含节点 ID、集群 ID 信息,它在 WAL 文件创建的时候写入;
  • 日志条目记录:包含 Raft 日志信息,如 put 提案内容;

  • 状态信息记录:包含集群的任期号、节点投票信息等,一个日志文件中可能会有多条,以最后的记录为准;

  • CRC 记录:包含上一个 WAL 文件的最后的 CRC(循环校验码)信息,在创建、切割 WAL 文件时,作为第一条记录写入到新的 WAL 文件,用于校验数据文件的完整性、准确性等;
  • 快照记录:包含快照的任期号、日志索引信息,用于检查快照文件的准确性。

WAL 持久化提案的过程

首先我们先看 put 写请求是如何被封装在 Raft 日志条目里的。

Raft 日志数据结构

下面是 Raft 日志条目的数据结构信息,它由以下字段组成:

  • Term:Leader 任期号,随着 Leader 选举增加;
  • Index:日志条目的索引,单调递增增加;
  • Type:日志类型,比如是普通的命令日志(EntryNormal)还是集群配置变更日志(EntryConfChange);
  • Data:保存我们上面描述的 put 提案内容。
type Entry struct {
   Term             uint64    `protobuf:"varint,2,opt,name=Term" json:"Term"`
   Index            uint64    `protobuf:"varint,3,opt,name=Index" json:"Index"`
   Type             EntryType `protobuf:"varint,1,opt,name=Type,enum=Raftpb.EntryType" json:"Type"`
   Data             []byte    `protobuf:"bytes,4,opt,name=Data" json:"Data,omitempty"`
}

WAL 模块持久化 Raft 日志条目过程

  • 首先先将 Raft 日志条目内容(含任期号、索引、提案内容)序列化后保存到 WAL 记录的 Data 字段;
  • 然后计算 Data 的 CRC 值,设置 Type 为 Entry Type,以上信息就组成了一个完整的 WAL 记录;
  • 接着计算 WAL 记录的长度,顺序先写入 WAL 长度(Len Field),再写入记录内容;
  • 最后调用 fsync 持久化到磁盘,完成将日志条目哦存到持久化存储中。

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

于是进入流程六,etcdserver 模块从 Channel 取出提案内容,添加到先进先出(FIFO)调度队列,随后通过 Apply 模块按入队顺序,异步、依次执行提案内容。

Apply 模块


执行 put 提案内容对应我们架构图中的流程七,其细节图如下:

问题来了

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

核心就在于我们上面提到的 WAL 日志,因为提交给 Apply 模块执行的提案已获得多数节点确认、持久化,etcd 重启时,会从 WAL 中解析出 Raft 日志条目内容,追加到 Raft 日志的存储中,并重放已提交的日志提案给 Apply 模块执行

然而这又引发了另一个问题:

如何确保幂等性,防止提案重复执行导致数据混乱

etcd 是一个 MVCC 数据库,每次更新都会生成新的版本号。如果没有幂等性保护,同样的命令,一部分节点执行一次,一部分节点遭遇异常故障后执行多次,则系统的各节点一致性状态无法得到保证,导致数据混乱,这是严重故障。

在 etcd 中,标识这个提案的唯一字段就是 Raft 日志条目中的索引(Index)字段。日志条目索引是全局单调递增的,每个日志条目索引对应一个提案,如果一个命令执行后,我们在 db 里面也记录下当前已经执行过的日志条目索引。同时 etcd 也额外引入了 consistent index 字段来存储当前已经执行过的日志条目索引,通过这个字段与 index 字段作为原子性事务提交,实现了幂等性

Apply 模块在执行提案内容前,首先会判断当前提案是否已经执行过了,如果执行过了则直接返回,若为执行同时无 db 配额满告警,则进入 MVCC 模块,开始与持久化存储块打交道。

MVCC


Apply 模块判断此提案未执行后,就会调用 MVCC 模块来执行提案内容。MVCC 主要由两部分组成:

  • 内存索引模块(treeIndex):用于保存历史版本号信息;
  • boltdb 模块:用于持久化存储 key-value 数据。

treeIndex

版本号(revision)是 etcd 的逻辑时钟,etcd 启动的时候 默认版本号为 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 写数据了,进入了整体架构图中的流程九。

boltdb 是一个基于 B+tree 实现的 key-value 嵌入式 db,它通过提供 bucket 机制实现类似 MySQL 表的逻辑隔离。

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

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

写入 boltdb 的 value 包含哪些信息

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

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

  • key 名称;
  • key 创建时的版本号(create_revision)
  • key 最后一次修改时的版本号(mod_revision)
  • key 自身修改的次数(version);
  • value 值;
  • 租约信息

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

put 调用成功是否代表数据已经持久化到 db 文件了

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

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

etcd 如何处理写性能差的问题

etcd 采用了合并再合并来解决每次更新的事务提交导致的写性能差。

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

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

事务未提交可能会导致读请求无法从 boltdb 获取最新数据

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

小结


在这一节,我们学到了 etcd 的写请求流程,重点学习了 Quota、WAL、Apply 模块。

首先我们学习了 Quote 模块工作原理和我们熟悉的 database space exceeded 错误触发原因,写请求导致 db 大小增加、compact 策略不合理、boltdb Bug 等都会导致 db 大小超限。

其次学习了 WAL 模块的存储结构,它由一条条记录顺序写入组成,每个记录含有 Type、CRC、Data,每个天北提交前都会被持久化到 WAL 文件中,以保证集群的一致性和可恢复性。

随后学习了 Apply 模块基于 consistent index 和事务实现了幂等性,保证了节点再异常情况下不会重复执行重放的提案。

最后我们学习了 MVCC 模块是如何维护索引版本号、重启后如何从 boltdb 模块中获取内存索引结构的。以及 etcd 通过异步、批量提交事务机制,以提升 QPS 和吞吐量。


本文内容摘抄自极客时间专栏 etcd 实战课

02| ETCD 的一个读请求是如何执行的

前言


ETCD 是一个典型的读多写少的存储,在我们实际业务场景中,读一般占用 2/3 以上的请求。

我们先从 ETCD 默认的线性读来入手。

环境准备


首先介绍一个好用的进程管理工具 goreman​,基于它我们可以快速创建、停止本地的多节点 etcd 集群。

你可以通过 go install​命令快速安装 goreman,然后从 etcd release 页面下载 etcd v3.4.9 的二进制文件,再从 etcd 源码 中下载 goreman Procfile 文件,它描述了 etcd 进程名、节点数、参数等信息。最后通过 goreman -f Procfile start​ 命令就可以快速启动一个 3 节点的本地集群了。

go install github.com/mattn/goreman

Client


启动完 etcd 集群后,当你使用 etcdctl 执行一个 get hello 命令时,etcdctl 是如何工作的呢?

etcdctl get hello --endpoints http://127.0.0.1:2379

首先,etcd 会对命令中的参数进行解析。

我们先看下这组参数的含意:

  • get:请求的方法,它是 KVServer 模块的 API;
  • hello:查询的 key 名;
  • endpoints:后端 etcd 的地址,通常一个生产过环境会配置多个 endpoint,这样在 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。

接下来,etcd 会通过负载均衡算法来为 get hello 请求选择一个合适的 etcd server 节点。

在 etcd 3.4 中,clientv3 库使用的负载均衡算法为 Round-robin。针对每个请求,Round-robin 算法通过轮询的方式依次从 endpoint 列表中选择一个 endpoint 访问(长连接),使 etcd server 负载尽量均衡

关于负载均衡算法需要注意下面两点:

  • 如果你的 client 版本 <= 3.3,那么当时配置多个 endpoint 时,负载均衡算法仅会从中选择一个 IP 并创建一个连接(Pinned endpoint),这样可以节省服务器总连接数。但在 heavy useage 场景,这可能会造成 server 负载不均衡。
  • 在 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.x,HTTP/2 是基于二进制而不是文本、支持多路复用而不在有序且阻塞、支持数据压缩以减少包大小、支持 server push 等特性。因此,基于 HTTP/2 的 GRPC 协议具有低延迟、高性能的特点,有效解决了 etcd v2 中的 HTTP/1.x 性能问题。

KVServer


client 发送 Range RPC 发送请求到 server 后,就到了 KVServer 模块。

拦截器


etcd 提供了丰富的 metrics、日志、请求行为检查等机制,可记录所有请求的执行耗时及错误码、来源 IP 等,也可控制请求是否允许通过。比如 etcd Learner 节点只允许指定接口和参数访问,帮助大家定位问题、提高服务可观测性等。而这些特性是通过拦截器来非侵入式实现的。

etcd server 定义了如下的 Server 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 等)。

此时若 client 发起一个读取 hello 的请求,假设此请求直接从状态机中读取,如果连接到的是 C 节点,若 C 节点的磁盘 I/O 出现波动,可能导致它应用已提交的日志条目很慢,则会出现更新 hello 为 world 的写命令,在 client 读 hello 的时候还未被提交到状态机,因此就可能读取到旧数据,如上图查询 hello 流程所示。

我们先来看下面两个场景:

  • 对数据敏感度较低的场景: 假如老板让你做一个旁路数据统计服务,希望你每分钟统计下 etcd 里的服务、配置信息等,这种场景其实对数据时效性要求不高,读请求可以直接从节点的状态机获取数据。即使数据落后一点,也不影响业务,毕竟这只是一个定时统计的旁路服务而已。 这种直接读状态机数据返回,无须通过 Raft 协议与集群进行交互的模式,在 etcd 里叫做串行读(Serializable),它具有低延时、高吞吐量的特点,适合对数据一致性要求不高的场景。
  • 数据敏感性高的场景: 当你发布服务,更新服务的镜像的时候,提交的时候显示更新成功,结果你一刷新页面,发现显示的镜像还是旧的,再刷新优势新的,这就会导致混乱。再比如说一个转账场景,A 给 B 转账,前被正常扣除,一刷新页面发现钱又回来了,这也是令人不可接受的。 以上业务场景就对数据准确性要求极高了,在 etcd 里面,提供了一种线性读模式来解决对数据一致性要求高的场景。

线性读模块

什么是线性读呢

你可以理解一旦一个值更新成功,随后任何通过线性读的 client 都能及时访问到。虽然级群众又多个节点,但 client 通过线性读就像访问一个节点一样。etcd 默认读模式是线性读,它需要经过 Raft 协议模块,反应的是集群共识,因此在延时和吞吐量上相比串行读略差一些,适用于对数据一致性较高的场景。

如果你的 etcd 读请求显示指定了是串行读,就不会经过架构图中的流程三、四。

线性读之 ReadIndex

前面我们提及串行读能够读到旧数据,主要原因是 Follower 节点受到 Leader 节点同步的写请求后,应用日志条目到状态机是一个异步过程,那么我们能否有一种机制在读取的时候,确保最新的数据已经应用到状态机中呢?​​

其实这个机制就是叫 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 最新的已提交日志索引封装在流程四的 RedState 结构体,通过 channel 层层返回给线性读模块,线性读模块等待本节点状态机赶上 Leader 进度,追赶完成后就通知 KVServer 模块,进行架构图中的流程五与状态机中的 MVCC 模块进行交互了。

MVCC


流程五中的多版本并发控制(Multiversion concurrency control)模块是为了解决之前提到 etcd v2 不支持保存 key 的历史版本、不支持多 key 事物等问题产生的。

它核心由内存树形索引模块(treeIndex)和嵌入式的 KV 持久化存储库 boltdb 组成。

首先我们需要简单了解下 boltdb,他是一个基于 B+ tree 实现的 keyvalue 键值库,支持事务,提供 Get/Put 等简易 API 给 etcd 操作。boltdb 的 key 时全局递增的版本号(revision),value 时用户 key-value 等字段组成的结构体,然后通过 treeIndex 模块来保存用户 key 和版本号的映射关系。

treeIndex 与 boltdb 关系如下面的读事务流程图所示,从 treeIndex 中获取 key hello 的版本号,再以版本号作为 boltdb 的 key,从 boltdb 中获取其 value 信息。

treeIndex

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

treeIndex 模块只会保存用户的 key 和相关版本号信息,用户 key 的 value 数据存储在 boltdb 里面,相比 zookeeper 和 etcd v2 的全内存存储,etcd v3 对内存要求更低。

回看架构图中的流程六,我们不难发现 etcd 需要从 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 名字是 key,etcd 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 在执行读请求过程中涉及磁盘 IO 吗?如果涉及,是什么模块在什么场景下会触发呢?如果不涉及,又是什么原因呢?

答案:

从 boltdb 读时会产生磁盘 I/O,这是一个常见误区。实际上,etcd 在启动的时候会通过 mmap 机制将 etcd db 文件映射到 etcd 进程地址空间,并设置了 mmap 的 MAP_POPULATE flag,它会告诉 Linux 内核预读文件,Linux 内核会将文件内容拷贝到物理内存中,此时会产生磁盘 I/O。节点内存足够的请求下,后续处理读请求过程中就不会产生磁盘 I/IO 了。若 etcd 节点内存不足,可能会导致 db 文件对应的内存页被换出,当读请求命中的页未在内存中时,就会产生缺页异常,导致读过程中产生磁盘 IO,你可以通过观察 etcd 进程的 majflt 字段来判断 etcd 是否产生了主缺页中断。


本文内容摘抄自极客时间专栏 etcd 实战课

01| ETCD 的基础架构

基础架构

如图,按照分层模型,etcd 可以分为一下基层:

  • Client 层:Client 层提供了简洁易用的 API,同时支持负载均衡、节点间故障自动转移,可极大降低业务使用 ETCD 复杂度,提高开发效率和服务可用性。
  • API 网络层:API 网络层主要包括 Client 访问 Server 和 Server 节点之家你的通信协议,包括 HTTP/1.x 协议、GRPC 协议以及通过 Raft 算法实现数据复制和 Leader 选举功能时使用的 HTTP 协议。
  • Raft 算法层:实现了 Leader 选举、日志复制、ReadIndex 等核心算法特性,用于保障 ETCD 多个节点间的数据一致性、提升服务可用性等,是 ETCD 的基石和亮点
  • 功能逻辑层:ETCD 核心特性实现层,如典型的 KVServer 模块、MVCC 模块、Auth 鉴权模块、Lease 租约模块、Compactor 压缩模块。其中 MVCC 模块主要由 treeIndex 模块和 boltdb 模块组成。
  • 存储层:存储层包含预写日志(WAL)模块、快照(Snapshot)模块、boltdb 模块。其中 WAL 可保障 etcd crash 后间不丢失,boltdb 则保存了集群元数据和用户写入的数据。

本文内容摘抄自极客时间专栏 etcd 实战课

ETCD

随着 Kubernetes 成为容器编排领域霸主,etcd 也越来越火热,越来越多的软件工程师使用 etcd 去解决各类业务场景中遇到的痛点。你知道吗?etcd 的 GitHub star 数已超过 34.2K,它的应用场景相当广泛,从服务发现到分布式锁,从配置存储到分布式协调等等。可以说,etcd 已经成为了云原生和分布式系统的存储基石。

另外,etcd 作为最热门的云原生存储之一,在腾讯、阿里、Google、AWS、美团、字节跳动、拼多多、Shopee、明源云等公司都有大量的应用,覆盖的业务可不仅仅是 Kubernetes 相关的各类容器产品,更有视频、推荐、安全、游戏、存储、集群调度等核心业务。

ETCD 使用过程的典型问题

我们在使用 Kubernetes、etcd 的过程中,很可能也会遇到下面这些典型问题:

  • etcd Watch 机制能保证事件不丢吗?(原理类)
  • 哪些因素会导致你的集群 Leader 发生切换? (稳定性类)
  • 为什么基于 Raft 实现的 etcd 还可能会出现数据不一致?(一致性类)
  • 为什么你删除了大量数据,db 大小不减少?为何 etcd 社区建议 db 大小不要超过 8G?(db 大小类)
  • 为什么集群各节点磁盘 I/O 延时很低,写请求也会超时?(延时类)
  • 为什么你只存储了 1 个几百 KB 的 key/value, etcd 进程却可能耗费数 G 内存? (内存类)
  • 当你在一个 namespace 下创建了数万个 Pod/CRD 资源时,同时频繁通过标签去查询指定 Pod/CRD 资源时,APIServer 和 etcd 为什么扛不住?(最佳实践类)

应该怎么学 etcd?

首先,你能知道什么是 etcd,了解它的基本读写原理、核心特性和能解决什么问题。

然后,在使用 etcd 解决各类业务场景需求时,能独立判断 etcd 是否适合你的业务场景,并能设计出良好的存储结构,避免 expensive request。

其次,在使用 Kubernetes 的过程中,你能清晰地知道你的每个操作背后的 etcd 是如何工作的,并遵循 Kubernetes/etcd 最佳实践,让你的 Kubernetes 集群跑得更快更稳。

接着,在运维 etcd 集群的时候,你能知道 etcd 集群核心监控指标,了解常见的坑,制定良好的巡检、监控策略,及时发现、规避问题,避免事故的产生。

最后,当你遇到 etcd 问题时,能自己分析为什么会出现这样的错误,并知道如何解决,甚至给社区提 PR 优化,做到知其然知其所以然。

基础篇

基础篇分为了以下的学习小目标:

  • etcd 基础架构。通过为你梳理 etcd 前世今生、分析 etcd 读写流程,帮助你建立起对 etcd 的整体认知,了解一个分布式存储系统的基本模型、设计思想。
  • Raft 算法。通过为你介绍 Raft 算法在 etcd 中是如何工作的,帮助你了解 etcd 高可用、高可靠背后的核心原理。
  • 鉴权模块。通过介绍 etcd 的鉴权、授权体系,带你了解 etcd 是如何保护你的数据安全,以及各个鉴权机制的优缺点。
  • 租约模块。介绍 etcd 租约特性的实现,帮助你搞懂如何检测一个进程的存活性,为什么它可以用于 Leader 选举中。
  • MVCC/Watch 模块。通过这两个模块帮助你搞懂 Kubernetes 控制器编程模型背后的原理。

实践篇

实践篇分为以下的学习小目标:

  • 问题篇。为你分析 etcd 使用过程中的各类典型问题,和你细聊各种异常现象背后的原理、最佳实践。
  • 性能优化篇。通过读写链路的分析,为你梳理可能影响 etcd 性能的每一个瓶颈。
  • 实战篇。带你从 0 到 1 亲手参与构建一个简易的分布式 KV 数据库,进一步提升你对分布式存储系统的认知。
  • Kubernetes 实践篇。为你分析 etcd 在 Kubernetes 中的应用,让你对 Kubernetes 原理有更深层次的理解。
  • etcd 应用篇。介绍 etcd 在分布式锁、配置系统、服务发现场景中的应用。

本文内容摘抄自极客时间专栏 etcd 实战课