先简单看一个 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 记录顺序追加写入组成,每个记录由类型、数据、循环冗余校验码组成。不同类型的记录通过 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 实战课