整体架构
etcd 中每一次更新、删除 key 的操作,treeIndex 的 keyIndex 索引中都会追加一个版本号,boltdb 中也都会生成一个新版本的 boltdb key 和 value。随着不停更新、删除,etcd 进程内存占用和 db 文件会越来越大,最终导致 etcd OOM 和 db 大小增长到最大 db 配额,最终不可写。
etcd 主要通过压缩(compact)机制来回收历史版本数据,控制内存占用和大小。目前支持两类方式进行压缩,一种是 client 发起人工压缩操作,另一种配置自动压缩策略(时间周期和保留版本号数量)。
下图是整个压缩模块的架构图,
-
API 发起一个 Compact 请求的时候,KV Server 会收到 Compact 请求,并提交给 Raft 模块处理。如果配置了相应的自动压缩策略,那么自动压缩模块(Periodic Compactor/Revision Compactor)会根据配置的策略发起 Compact 请求。
-
在 Raft 模块中提交后,Apply 模块会继续处理该请求。Apply 模块会调用 MVCC 模块的 Compact 接口执行压缩任务。
-
MVCC Compact 接口会首先更新当前已压缩的版本号,然后将耗时昂贵的压缩任务保存到 FIFO 队列中异步执行。
详细的过程为:
-
首先会检查 Compact 请求的版本号 rev 是否已被压缩过,若是则返回 ErrCompacted 错误给 client。收到的错误是:("mvcc: required revision has been compacted") 。
-
其次会检查 rev 是否大 于当前 etcd server 的最大版本号,若是则返回 ErrFutureRev 给 client。收到的错误是:("mvcc: required revision is a future revision")。
-
上述检查都通过后。会通过 boltdb 的 API 在 meta bucket 中更新当前已调度的压缩版本号 (scheduledCompactedRev) 号,然后将压缩任务追加到 FIFO Scheduled 中,异步调度执行。
保存 scheduledCompactedRev 的主要原因是 Compact 任务是异步执行的,如果在异步执行的过程中了发生了 crash,又没有持久化这个版本号,则重启之后各个节点的数据可能会不一致。如果持久化了 scheduledCompactedRev,节点 crash 重启后,可以重新向 FIFO Scheduled 中添加压缩任务,从而保证各个节点间的数据一致性。
-
-
压缩任务执行时,它会首先压缩 MVCC 中 treeIndex 模块的 keyIndex 索引,其次遍历 boltdb 中的 key,删除已废弃的 key。
详细的过程是:
-
首先是压缩 treeIndex 模块中的各 key 的历史版本、已删除的版本。
为了避免压缩工作影响读写性能,会克隆一个 B-tree,然后在克隆后的 B-tree 上遍历每一个 keyIndex 对象。它会保留 keyIndex 中最大的版本号,移除小于等于 CompactedRev(压缩版本号)的版本号。保留 keyIndex 中最大的版本号是因为最大版本号是这个 key 的最新版本,移除了会导致 key 丢失,而 Compact 的目的是回收旧版本。但是如果 keyIndex 中的最大版本号被打了删除标记 (tombstone),那么小于等于该版本号的都是无效的,整个 keyIndex 都会被删除。
-
压缩任务执行完索引压缩后,它通过遍历 B-tree、keyIndex 中的所有 generation 获得 treeIndex 中有效的版本号,并记录在 map 中返回给 boltdb 模块使用。
-
之后就是删除 boltdb 中废弃的历史版本数据。
它会通过一个名为 scheduleCompaction 任务来完成。scheduleCompaction 任务获取到 boltdb 中从 0 到 CompactedRev 的所有 key(boltdb 中的 key 是 revision),然后再通过 treeIndex 模块返回的有效索引信息,判断这个 key 是否有效,无效则调用 boltdb 的 delete 接口将 key-value 数据删除。
由于 scheduleCompaction 任务遍历、删除 key 的过程可能会对 boltdb 造成压力,为了不影响正常读写请求,它在执行过程中会通过参数控制每次遍历、删除的 key 数(默认为 100,每批间隔 10ms),分批完成 boltdb key 的删除操作。
这个过程中,scheduleCompaction 任务还会更新当前 etcd 已经完成的压缩版本号 (finishedCompactRev),将其保存到 boltdb 的 meta bucket 中。
-
整个过程其实需要注意以下几点:
- 压缩的本质是回收历史版本,目标对象是历史版本,不包括一个 key-value 数据的最新版本,因此可以放心执行压缩命令,不会删除你的最新版本数据。
- Watch 机制中的历史版本数据同步依赖于 MVCC 中是否还保存了相关数据,因此在调用压缩 API 或者配置自动压缩策略的时候需要考虑这点。
自动压缩策略
自动压缩策略支持两种模式,分别是按时间周期性压缩和保留版本号数量压缩。配置相应策略后,etcd 节点会自动周期性地发起 Compact 操作。etcd 中通过以下两个参数进行配置,
- auto-compaction-mode 为 periodic 时,表示启用时间周期性压缩,auto- compaction-retention 为保留的时间的周期,比如 1h。
- auto-compaction-mode 为 revision 时,表示启用版本号压缩模式;auto- compaction-retention 为要保留的历史版本号数量,比如 10000。
- auto-compaction-retention 为'0'时,将关闭自动压缩策略。
// Interpret 'auto-Compaction-retention' one of: periodic|revision.
--auto-compaction-mode
// Auto compaction retention length. 0 means disable auto Compaction.
--auto-compaction-retention
时间周期性
etcd server 启动后,如果配置的模式是 periodic,则会创建 periodic Compactor。periodic Compactor 会异步定时获取并记录过去一段时间的版本号。如果当前时间减去上一次成功执行 Compact 操作的时间大于一个小时,则会从记录中取出版本号,然后发起压缩操作。
如果压缩周期为 1h,periodic Compactor 会将其划分成 10 个区间,每个区间 6 分钟。每隔 6 分钟,它会通过 etcd MVCC 模块的接口获取当前的版本号,追加到 rev 数组中。如果当前时间减去上一次成功执行 Compact 操作的时间大于一个小时,它会取出 rev 数组首元素,调用 etcd 的 Compact 接口,发起压缩操作。
保留版本号
etcd server 启动后,如果配置的模式是 revision,则会创建 revision Compactor。revision Compactor 会根据设置的保留版本号数量,每隔 5 分钟定时获取当前的最大版本号,减去配置的要保留的历史版本数量,然后通过 etcd server 的 Compact 接口发起压缩操作即可。
保留版本号适合于这样的场景:当写请求比较多,可能产生比较多的历史版本导致 db 增长时,或者不确定配置 periodic 周期为多少才是最佳的时候。此时,可以通过设置压缩模式为 revision,指定保留的历史版本号数。
手动执行压缩
如果 etcd server 的自带压缩机制无法满足诉求,想要更精细化的控制 etcd 保留的历史版本记录。此时可以基于 etcd 的 Compact API,在自己的业务逻辑代码中主动触发压缩操作。
但是,此时需要确保发起 Compact 操作的程序高可用。压缩的频率、保留的历史版本都合理范围内,并最终能使 etcd 的 db 大小保持平稳,否则会导致 db 大小不断增长,直至 db 配额满,无法写入。
下面是通过 etcdctl 命令手动执行压缩的示例,
$ etcdctl compact 9
附录
为什么压缩后 db 文件大小不减少?
boltdb 删除大量的 key,在事务提交后 B+ tree 经过分裂、平衡,会释放出若干 branch/leaf page 页面,但是 boltdb 并不会将其释放给磁盘,因为释放和后续重新申请页的操作是昂贵的,对性能有较大的影响。因此为了实现高性能读写,boltdb 会通过 freelist page 记录这些释放的空闲页的位置,在收到新的写请求后,直接从空闲页数组中申请若干连续页使用,避免释放和申请导致的性能损耗,实现了高性能的读写。只有当连续空闲页申请无法得到满足的时候,boltdb 才会通过增大 db 大小来补充空闲页。
一般情况下,压缩操作释放的空闲页就能满足后续新增写请求的空闲页需求,db 大小会趋于整体稳定。