在执行写请求过程中,如果进程 crash 了,如何保证数据不丢、命令不重复执行呢?

整体架构

image.png

  1. etcdctl put hello world --endpoints http://127.0.0.1:2379
  2. OK
  1. 首先 client 端通过负载均衡算法选择一个 etcd 节点,发起 gRPC 调用。
  2. 然后 etcd 节点收到请求后经过 gRPC 拦截器、Quota 模块后,
  3. 进入 KVServer 模块,
  4. KVServer 模块向 Raft 模块提交一个提案,提案内容为“大家好,请使用 put 方法执行一个 key 为 hello,value 为 world 的命令”。
  5. 随后此提案通过 RaftHTTP 网络模块转发、经过集群多数节点持久化后,状态会变成已提交,
  6. etcdserver 从 Raft 模块获取已提交的日志条目,传递给 Apply 模块,
  7. Apply 模块通过
  8. MVCC 模块执行提案内容,更新状态机。
  9. MVCC 模块执行提案内容,更新状态机
  10. 返回

Quota 模块

流程一, 流程二
**
从此模块的一个常见错误:

  • etcdserver: mvcc: database space exceeded

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

哪些情况会触发这个错误呢?

  • 一方面默认 db 配额仅为 2G,当你的业务数据、写入 QPS、Kubernetes 集群规模增大后,你的 etcd db 大小就可能会超过 2G。
  • 另一方面我们知道 etcd v3 是个 MVCC 数据库,保存了 key 的历史版本,当你未配置压缩策略的时候,随着数据不断写入,db 大小会不断增大,导致超限。
  • 最后你要特别注意的是,如果你使用的是 etcd 3.2.10 之前的旧版本,请注意备份可能会触发 boltdb 的一个 Bug,它会导致 db 大小不断上涨,最终达到配额限制。

工作流程:

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

遇到这个错误时应该如何解决呢?

  • 调大配额. 建议不超过 8GB
    • 配额 (quota-backend-bytes)
    • 调大后需要向 Apply 模块发送一个取消告警 (etcdctl alarm disarm) 来取消之前的 NO SPACE 告警
  • 检查 etcd 的压缩 (compact) 配置是否开启
    • 压缩模块使用惰删除
    • 碎片整理 (defrag) 对服务性能有较大影响
  • 注意配额的行为
    • 0: 默认 2GB
    • 小于0: 禁用配额功能

KVServer 模块

流程三
**

  • KVServer 模块的 put 方法
  • 将 put 写请求打包成一个提案 (Raft 算法) 消息, 提交给 Raft 模块

KVServer 模块在提交提案前,还有如下的一系列检查和限速。

Preflight Check

限速判断:

  1. 如果 Raft 模块已提交的日志索引(committed index)比已应用到状态机的日志索引(applied index)超过了 5000,那么它就返回一个”etcdserver: too many requests“错误给 client。
  2. 然后它会尝试去获取请求中的鉴权信息,若使用了密码鉴权、请求中携带了 token,如果 token 无效,则返回”auth: invalid auth token“错误给 client。
  3. 其次它会检查你写入的包大小是否超过默认的 1.5MB, 如果超过了会返回”etcdserver: request is too large“错误给给 client。

image.png

Propose

流程四

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

向 Raft 模块发起提案后,KVServer 模块会等待此 put 请求,等待写入结果通过消息通知 channel 返回或者超时。etcd 默认超时时间是 7 秒(5 秒磁盘 IO 延时 +21 秒竞选超时时间),如果一个请求超时未返回结果,则可能会出现你熟悉的 *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 日志结构:

image.png

WAL 日志类型:

  1. 文件元数据记录包含节点 ID、集群 ID 信息,它在 WAL 文件创建的时候写入;
  2. 日志条目记录包含 Raft 日志信息,如 put 提案内容;
  3. 状态信息记录,包含集群的任期号、节点投票信息等,一个日志文件中会有多条,以最后的记录为准;
  4. CRC 记录包含上一个 WAL 文件的最后的 CRC(循环冗余校验码)信息, 在创建、切割 WAL 文件时,作为第一条记录写入到新的 WAL 文件, 用于校验数据文件的完整性、准确性等;
  5. 快照记录包含快照的任期号、日志索引信息,用于检查快照文件的准确性。

WAL 模块又是如何持久化一个 put 提案的日志条目类型记录呢?

Raft 日志条目的数据结构:

  1. Term 是 Leader 任期号,随着 Leader 选举增加;
  2. Index 是日志条目的索引,单调递增增加;
  3. Type 是日志类型,比如是普通的命令日志(EntryNormal)还是集群配置变更日志(EntryConfChange);
  4. 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"`
}

持久化 Raft 日志条目流程:

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

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

流程六

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

Apply 模块

流程七

执行 put 提案内容对应我们架构图中的流程七.

image.png

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

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

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

  • etcd 通过引入一个 consistent index 的字段,来存储系统当前已经执行过的日志条目索引,实现幂等性。
  • Apply 模块在执行提案内容前,首先会判断当前提案是否已经执行过了,如果执行了则直接返回,若未执行同时无 db 配额满告警,则进入到 MVCC 模块,开始与持久化存储模块打交道。

MVCC

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

treeIndex

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

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

流程八
**

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

image.png

boltdb

流程九

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

boltdb 基于 B+tree

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

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

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 中.

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

  • 这里需要注意的是,在以上流程中,etcd 并未提交事务(commit),因此数据只更新在 boltdb 所管理的内存数据结构中(不太清楚这里说的是 etcd 的事务还是 boltdb 的, 根据后文猜测是 boltdb)

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

由此看出 “在以上流程中,etcd 并未提交事务 (commit)” 是指 boltdb 的 commit.

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

  1. 首先 boltdb key 是版本号,put/delete 操作时,都会基于当前版本号递增生成新的版本号,因此属于顺序写入,可以调整 boltdb 的 bucket.FillPercent 参数,使每个 page 填充更多数据,减少 page 的分裂次数并降低 db 空间。(不是很了解该细节)
  2. 其次 etcd 通过合并多个写事务请求,通常情况下,是异步机制定时(默认每隔 100ms)将批量事务一次性提交(pending 事务过多才会触发同步提交), 从而大大提高吞吐量,对应上面简易写事务图的流程三。

这优化又引发了另外的一个问题, 因为事务未提交,读请求可能无法从 boltdb 获取到最新数据

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

02 思考题答案

上节课我给大家留了一个思考题,评论中有同学说 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 是否产生了主缺页中断。