gitbook/etcd实战课/docs/342891.md

191 lines
15 KiB
Markdown
Raw Normal View History

2022-09-03 22:05:03 +08:00
# 11 | 压缩:如何回收旧版本数据?
你好,我是唐聪。
今天是大年初一,你过年都有什么安排?今年过年对我来说,其实是比较特别的。除了家庭团聚走亲访友外,我多了一份陪伴。感谢你和我在这个专栏里一块精进,我衷心祝你在新的一年里平安喜乐,万事胜意。
这节课是我们基础篇里的最后一节,正巧这节课的内容也是最轻松的。新年新气象,我们就带着轻松的心情开始吧!
在[07](https://time.geekbang.org/column/article/340226)里我们知道etcd中的每一次更新、删除key操作treeIndex的keyIndex索引中都会追加一个版本号在boltdb中会生成一个新版本boltdb key和value。也就是随着你不停更新、删除你的etcd进程内存占用和db文件就会越来越大。很显然这会导致etcd OOM和db大小增长到最大db配额最终不可写。
那么etcd是通过什么机制来回收历史版本数据控制索引内存占用和db大小的呢
这就是我今天要和你分享的etcd压缩机制。希望通过今天的这节课能帮助你理解etcd压缩原理在使用etcd过程中能根据自己的业务场景选择适合的压缩策略避免db大小增长失控而不可写入帮助你构建稳定的etcd服务。
## 整体架构
![](https://static001.geekbang.org/resource/image/7c/21/7c5d5212fa14yy6aaf843ae3dfc5f721.png)
在了解etcd压缩模块实现细节前我先给你画了一幅压缩模块的整体架构图。从图中可知你可以通过client API发起人工的压缩(Compact)操作也可以配置自动压缩策略。在自动压缩策略中你可以根据你的业务场景选择合适的压缩模式。目前etcd支持两种压缩模式分别是时间周期性压缩和版本号压缩。
当你通过API发起一个Compact请求后KV Server收到Compact请求提交到Raft模块处理在Raft模块中提交后Apply模块就会通过MVCC模块的Compact接口执行此压缩任务。
Compact接口首先会更新当前server已压缩的版本号并将耗时昂贵的压缩任务保存到FIFO队列中异步执行。压缩任务执行时它首先会压缩treeIndex模块中的keyIndex索引其次会遍历boltdb中的key删除已废弃的key。
以上就是压缩模块的一个工作流程。接下来我会首先和你介绍如何人工发起一个Compact操作然后详细介绍周期性压缩模式、版本号压缩模式的工作原理最后再给你介绍Compact操作核心的原理。
## 压缩特性初体验
在使用etcd过程中当你遇到"etcdserver: mvcc: database space exceeded"错误时若是你未开启压缩策略导致db大小达到配额这时你可以使用etcdctl compact命令主动触发压缩操作回收历史版本。
如下所示你可以先通过endpoint status命令获取etcd当前版本号然后再通过etcdctl compact命令发起压缩操作即可。
```
# 获取etcd当前版本号
$ rev=$(etcdctl endpoint status --write-out="json" | egrep -o '"revision":[0-9]*' | egrep -o '[0-9].*')
$ echo $rev
9
# 执行压缩操作,指定压缩的版本号为当前版本号
$ etcdctl compact $rev
Compacted revision 9
# 压缩一个已经压缩的版本号
$ etcdctl compact $rev
Error: etcdserver: mvcc: required revision has been compacted
# 压缩一个比当前最大版号大的版本号
$ etcdctl compact 12
Error: etcdserver: mvcc: required revision is a future revision
```
请注意如果你压缩命令传递的版本号小于等于当前etcd server记录的压缩版本号etcd server会返回已压缩错误("mvcc: required revision has been compacted")给client。如果版本号大于当前etcd server最新的版本号etcd server则返回一个未来的版本号错误给client("mvcc: required revision is a future revision")。
执行压缩命令的时候,不少初学者有一个常见的误区,就是担心压缩会不会把我最新版本数据给删除?
压缩的本质是**回收历史版本**,目标对象仅是**历史版本**不包括一个key-value数据的最新版本因此你可以放心执行压缩命令不会删除你的最新版本数据。不过我在[08](https://time.geekbang.org/column/article/341060)介绍Watch机制时提到Watch特性中的历史版本数据同步依赖于MVCC中是否还保存了相关数据因此我建议你不要每次简单粗暴地回收所有历史版本。
在生产环境中,我建议你精细化的控制历史版本数,那如何实现精细化控制呢?
主要有两种方案一种是使用etcd server的自带的自动压缩机制根据你的业务场景配置合适的压缩策略即可。
另外一种方案是如果你觉得etcd server的自带压缩机制无法满足你的诉求想更精细化的控制etcd保留的历史版本记录你就可以基于etcd的Compact API在业务逻辑代码中、或定时任务中主动触发压缩操作。你需要确保发起Compact操作的程序高可用压缩的频率、保留的历史版本在合理范围内并最终能使etcd的db 大小保持平稳否则会导致db大小不断增长直至db配额满无法写入。
在一般情况下我建议使用etcd自带的压缩机制。它支持两种模式分别是按时间周期性压缩和保留版本号的压缩配置相应策略后etcd节点会自动化的发起Compact操作。
接下来我就和你详细介绍下etcd的周期性和保留版本号压缩模式。
## 周期性压缩
首先是周期性压缩模式,它适用于什么场景呢?
当你希望etcd只保留最近一段时间写入的历史版本时你就可以选择配置etcd的压缩模式为periodic保留时间为你自定义的1h等。
如何给etcd server配置压缩模式和保留时间呢?
如下所示etcd server提供了配置压缩模式和保留时间的参数
```
--auto-compaction-retention '0'
Auto compaction retention length. 0 means disable auto Compaction.
--auto-compaction-mode 'periodic'
Interpret 'auto-Compaction-retention' one of: periodic|revision.
```
auto-compaction-mode为periodic时它表示启用时间周期性压缩auto-compaction-retention为保留的时间的周期比如1h。
auto-compaction-mode为revision时它表示启用版本号压缩模式auto-compaction-retention为保留的历史版本号数比如10000。
注意etcd server的auto-compaction-retention为'0'时,将关闭自动压缩策略,
那么周期性压缩模式的原理是怎样的呢? etcd是如何知道你配置的1h前的etcd server版本号呢
其实非常简单etcd server启动后根据你的配置的模式periodic会创建periodic Compactor它会异步的获取、记录过去一段时间的版本号。periodic Compactor组件获取你设置的压缩间隔参数1h 并将其划分成10个区间也就是每个区间6分钟。每隔6分钟它会通过etcd MVCC模块的接口获取当前的server版本号追加到rev数组中。
因为你只需要保留过去1个小时的历史版本periodic Compactor组件会通过当前时间减去上一次成功执行Compact操作的时间如果间隔大于一个小时它会取出rev数组的首元素通过etcd server的Compact接口发起压缩操作。
需要注意的一点是在etcd v3.3.3版本之前不同的etcd版本对周期性压缩的行为是有一定差异的具体的区别你可以参考下[官方文档](https://github.com/etcd-io/etcd/blob/v3.4.9/Documentation/op-guide/maintenance.md)。
## 版本号压缩
了解完周期性压缩模式,我们再看看版本号压缩模式,它又适用于什么场景呢?
当你写请求比较多可能产生比较多的历史版本导致db增长时或者不确定配置periodic周期为多少才是最佳的时候你可以通过设置压缩模式为revision指定保留的历史版本号数。比如你希望etcd尽量只保存1万个历史版本那么你可以指定compaction-mode为revisionauto-compaction-retention为10000。
它的实现原理又是怎样的呢?
也很简单etcd启动后会根据你的压缩模式revision创建revision Compactor。revision Compactor会根据你设置的保留版本号数每隔5分钟定时获取当前server的最大版本号减去你想保留的历史版本数然后通过etcd server的Compact接口发起如下的压缩操作即可。
```
# 获取当前版本号,减去保留的版本号数
rev := rc.rg.Rev() - rc.retention
# 调用server的Compact接口压缩
_err := rc.c.Compact(rc.ctx&pb.CompactionRequest{Revision: rev})
```
## 压缩原理
介绍完两种自动化的压缩模式原理后接下来我们就深入分析下压缩的本质。当etcd server收到Compact请求后它是如何执行的呢 核心原理是什么?
如前面的整体架构图所述Compact请求经过Raft日志同步给多数节点后etcd会从Raft日志取出Compact请求应用此请求到状态机执行。
执行流程如下图所示MVCC模块的Compact接口首先会检查Compact请求的版本号rev是否已被压缩过若是则返回ErrCompacted错误给client。其次会检查rev是否大于当前etcd server的最大版本号若是则返回ErrFutureRev给client这就是我们上面执行etcdctl compact命令所看到的那两个错误原理。
通过检查后Compact接口会通过boltdb的API在meta bucket中更新当前已调度的压缩版本号(scheduledCompactedRev)号然后将压缩任务追加到FIFO Scheduled中异步调度执行。
![](https://static001.geekbang.org/resource/image/9a/ff/9ac55d639f564b56324b96dc02f0c0ff.png)
为什么Compact接口需要持久化存储当前已调度的压缩版本号到boltdb中呢
试想下如果不保存这个版本号etcd在异步执行的Compact任务过程中crash了那么异常节点重启后各个节点数据就会不一致。
因此etcd通过持久化存储scheduledCompactedRev节点crash重启后会重新向FIFO Scheduled中添加压缩任务已保证各个节点间的数据一致性。
异步的执行压缩任务会做哪些工作呢?
首先我们回顾下[07](https://time.geekbang.org/column/article/340226)里介绍的treeIndex索引模块它是etcd支持保存历史版本的核心模块每个key在treeIndex模块中都有一个keyIndex数据结构记录其历史版本号信息。
![](https://static001.geekbang.org/resource/image/4f/dc/4f9cb015a842da0d5bd556d6b45970dc.png)
如上图所示,因此异步压缩任务的第一项工作,就是**压缩treeIndex模块中的各key的历史版本**、已删除的版本。为了避免压缩工作影响读写性能首先会克隆一个B-tree然后通过克隆后的B-tree遍历每一个keyIndex对象压缩历史版本号、清理已删除的版本。
假设当前压缩的版本号是CompactedRev 它会保留keyIndex中最大的版本号移除小于等于CompactedRev的版本号并通过一个map记录treeIndex中有效的版本号返回给boltdb模块使用。
为什么要保留最大版本号呢?
因为最大版本号是这个key的最新版本移除了会导致key丢失。而Compact的目的是回收旧版本。当然如果keyIndex中的最大版本号被打了删除标记(tombstone) 就会从treeIndex中删除这个keyIndex否则会出现内存泄露。
Compact任务执行完索引压缩后它通过遍历B-tree、keyIndex中的所有generation获得当前内存索引模块中有效的版本号这些信息将帮助etcd清理boltdb中的废弃历史版本。
![](https://static001.geekbang.org/resource/image/d6/70/d625753e5a7f0f7f37987764b9204270.png)
压缩任务的第二项工作就是**删除boltdb中废弃的历史版本数据**。如上图所示它通过etcd一个名为scheduleCompaction任务来完成。
scheduleCompaction任务会根据key区间从0到CompactedRev遍历boltdb中的所有key通过treeIndex模块返回的有效索引信息判断这个key是否有效无效则调用boltdb的delete接口将key-value数据删除。
在这过程中scheduleCompaction任务还会更新当前etcd已经完成的压缩版本号(finishedCompactRev)将其保存到boltdb的meta bucket中。
scheduleCompaction任务遍历、删除key的过程可能会对boltdb造成压力为了不影响正常读写请求它在执行过程中会通过参数控制每次遍历、删除的key数默认为100每批间隔10ms分批完成boltdb key的删除操作。
## 为什么压缩后db大小不减少呢?
当你执行完压缩任务后db大小减少了吗 事实是并没有减少。那为什么我们都通过boltdb API删除了keydb大小还不减少呢
上节课我们介绍boltdb实现时提到过boltdb将db文件划分成若干个page页page页又有四种类型分别是meta page、branch page、leaf page以及freelist page。branch page保存B+ tree的非叶子节点key数据leaf page保存bucket和key-value数据freelist会记录哪些页是空闲的。
当我们通过boltdb删除大量的key在事务提交后B+ tree经过分裂、平衡会释放出若干branch/leaf page页面然而boltdb并不会将其释放给磁盘调整db大小操作是昂贵的会对性能有较大的损害。
boltdb是通过freelist page记录这些空闲页的分布位置当收到新的写请求时优先从空闲页数组中申请若干连续页使用实现高性能的读写而不是直接扩大db大小。当连续空闲页申请无法得到满足的时候 boltdb才会通过增大db大小来补充空闲页。
一般情况下压缩操作释放的空闲页就能满足后续新增写请求的空闲页需求db大小会趋于整体稳定。
## 小结
最后我们来小结下今天的内容。
etcd压缩操作可通过API人工触发也可以配置压缩模式由etcd server自动触发。压缩模式支持按周期和版本两种。在周期模式中你可以实现保留最近一段时间的历史版本数在版本模式中你可以实现保留期望的历史版本数。
压缩的核心工作原理分为两大任务第一个任务是压缩treeIndex中的各key历史索引清理已删除key并将有效的版本号保存到map数据结构中。
第二个任务是删除boltdb中的无效key。基本原理是根据版本号遍历boltdb已压缩区间范围的key通过treeIndex返回的有效索引map数据结构判断key是否有效无效则通过boltdb API删除它。
最后在执行压缩的操作中虽然我们删除了boltdb db的key-value数据但是db大小并不会减少。db大小不变的原因是存放key-value数据的branch和leaf页它们释放后变成了空闲页并不会将空间释放给磁盘。
boltdb通过freelist page来管理一系列空闲页后续新增的写请求优先从freelist中申请空闲页使用以提高性能。在写请求速率稳定、新增key-value较少的情况下压缩操作释放的空闲页就可以基本满足后续写请求对空闲页的需求db大小就会处于一个基本稳定、健康的状态。
## 思考题
你知道压缩与碎片整理(defrag)有哪些区别吗?为什么碎片整理会影响服务性能呢? 你能想到哪些优化方案来降低碎片整理对服务性能的影响呢?
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。