1、相关源码
server/storage/backend/backend.go#defrag()
server/storage/backend/backend.go#defragdb()
2、为什么需要 defrag
我们日常使用 K8s 集群时,如果频繁增删集群数据,会发现一个奇怪的现象:
- 即使集群中的对象数据量没有明显增加,但是etcd数据文件所占用的磁盘空间却越来越大。
这时,查阅相关资料,etcd 官方建议使用提供的 etcdctl 工具的 defrag 命令对每个 etcd 节点的数据做碎片整理:
etcdctl --endpoints=${ENDPOINTS} defrag --command-timeout=60s
碎片整理后往往能释放大量的磁盘空间,防止 etcd 的数据量达到初始设置的 –quota-backend-bytes 而导致数据只读。
3、defrag 底层做了啥?我的数据会被删除吗?
etcd 如何存储数据
etcd 的数据以 db 文件的形式存在 boltdb 中:
ls -l /path-to-etcd-dir/etcd/member/snap/db
-rw------- 1 root root 2270765056 Dec 18 17:34 db
而 db 文件以 page 页的方式存储数据,page 分为以下几类:
- meta page:记录 db 元数据,包含文件标识、版本号、页大小、root bucket(指向 branch page 或 leaf page 中的 bucket)、freelist 页 ID 等信息。
- freeList page:记录 db 中哪些页是空闲可使用的。
- branch page:记录 leaf page 的索引(leaf page id),记录了 key 和 leaf page id 的对应关系。
- leaf page:记录 bucket 或者用户的 key-value 信息
- free page:空闲页
Bucket 和 bucket
etcd 的源码中有两种 Bucket / bucket
数据结构:
其中 bucket 是一种面向 page 的数据结构,root 字段指向该 bucket 根节点的 page id,比如记录用户 key-value 数据的 bucket,它的 root 字段就是 meta page 的 id, 也就是说 meta page 中的 root bucket 是所有 bucket 的根 bucket。
// bucket represents the on-file representation of a bucket.
// This is stored as the "value" of a bucket key. If the bucket is small enough,
// then its root page can be stored inline in the "value", after the bucket
// header. In the case of inline buckets, the "root" will be 0.
type bucket struct {
root pgid // page id of the bucket's root-level page
sequence uint64 // monotonically incrementing, used by NextSequence()
}
而 Bucket 则是面向 B+ Tree 的数据结构,包含了 bucket 和它对应的 key-value 的信息,当我们访问用户 key-value 时,访问的就是 Bucket node 的 key-value 信息
// Bucket represents a collection of key/value pairs inside the database.
type Bucket struct {
*bucket
tx *Tx // the associated transaction
buckets map[string]*Bucket // subbucket cache
page *page // inline page reference
rootNode *node // materialized node for the root page.
nodes map[pgid]*node // node cache
// Sets the threshold for filling nodes when they split. By default,
// the bucket will fill to 50% but it can be useful to increase this
// amount if you know that your write workloads are mostly append-only.
//
// This is non-persisted across transactions so it must be set in every Tx.
FillPercent float64
}
boltdb 逻辑上通过 B+ Tree 来管理所有的子 bucket 和 key-value 数据。
当用户需要查找某个 key-value 数据时:
-
- 先通过 etcd 内存中维护的 treeIndex (B+ Tree实现)找到对应 key 的版本号。(treeIndex 维护 key 和 keyIndex 版本号的对应关系)
-
- 优先在 buffer 中查找对应版本号的 key-value 信息。
-
- 如果没有命中 buffer,则根据 key 版本号在 boltdb 的 B+ Tree 中范围查找,找到对应的 branch page id,再通过索引找到下一层的 leaf page id,最终定位到用户的 key-value。
当用户通过 boltdb Open API 删除某个 key 时:
-
- key-value 所在的 leaf page 就会删除对应的数据。
-
- treeIndex 中的 key 和版本号对应关系的数据并不会被删除,而是做删除标记,做压缩时才真正删除。(lazy delete,让 watcher 能够获取 key 的所有状态,防止 B+ Tree 频繁重平衡)
-
- 随着 key-value 的不断删除, 就会产生空闲的 leaf page,被记录在 freeList page 中。
-
- 后续如果有新的 key-value 添加,则会直接使用这部分空闲页。
由于空闲页和用户删除 key-value 后留下一些没有装满 key-value 的 leaf page的存在,boltdb 占用的磁盘空间会越来越大,这时就需要 defrag 操作 将这些有空闲碎片空间的 leaf page 释放出来,让 key-value 数据在磁盘中排列得更紧凑。
defrag 分析
我们再来看看 defrag 的源码:
- 开始进行 defrag:
// server/storage/backend/backend.go
func (b *backend) defrag() error {
...
// 创建临时db文件
dir := filepath.Dir(b.db.Path())
temp, err := os.CreateTemp(dir, "db.tmp.*")
...
tdbp := temp.Name()
tmpdb, err := bolt.Open(tdbp, 0600, &options)
...
dbp := b.db.Path()
...
// 开始执行 defrag 逻辑
err = defragdb(b.db, tmpdb, defragLimit)
...
// defrag完成后将 tmp 文件重命名为 db 文件
err = os.Rename(tdbp, dbp)
if err != nil {
b.lg.Fatal("failed to rename tmp database", zap.Error(err))
}
...
return nil
}
- defrag 具体实现逻辑:
// server/storage/backend/backend.go
func defragdb(odb, tmpdb *bolt.DB, limit int) error {
// 新建的临时 boltdb 开启写事务
tmptx, err := tmpdb.Begin(true)
if err != nil {
return err
}
// defrag 出现异常则事务回滚
defer func() {
if err != nil {
tmptx.Rollback()
}
}()
// 原来的 boltdb 开启读事务
tx, err := odb.Begin(false)
if err != nil {
return err
}
defer tx.Rollback()
c := tx.Cursor()
count := 0
//通过 cursor 遍历原来的 boltdb 的所有 key
for next, _ := c.First(); next != nil; next, _ = c.Next() {
b := tx.Bucket(next)
if b == nil {
return fmt.Errorf("backend: cannot defrag bucket %s", string(next))
}
tmpb, berr := tmptx.CreateBucketIfNotExists(next)
if berr != nil {
return berr
}
tmpb.FillPercent = 0.9 // for bucket2seq write in for each
if err = b.ForEach(func(k, v []byte) error {
count++
// 每个写事务更新的 key-value 超过 limit (默认10000)时,会重新开启新的写事务
// 避免每次写事务更新的 key-value 过多,占用内存过大
if count > limit {
err = tmptx.Commit()
if err != nil {
return err
}
tmptx, err = tmpdb.Begin(true)
if err != nil {
return err
}
tmpb = tmptx.Bucket(next)
tmpb.FillPercent = 0.9 // for bucket2seq write in for each
count = 0
}
// 往新建的临时 boltdb 放入遍历得到的 key-value
return tmpb.Put(k, v)
}); err != nil {
return err
}
}
return tmptx.Commit()
}
由以上代码可知,defrag 做了以下几件事:
- 创建一个临时的 db 文件
- 开启事务,遍历旧 db 中的 bucket(保存用户的 key-value),写入新 db 的 bucket 中
- 每写入 10000 次db,写事务提交一次(落盘持久化)
- 全部 bucket 的 key-value 写入完成后,将临时的 db 文件改名,替换掉原来的 db
以上过程其实就是将 key-value 数据遍历一遍重新写入 leaf page 的过程,这样能够释放掉原来 boltdb 中有碎片空间的 leaf page,让 leaf page 把 key-value 装满。 这样 boltdb 占用的磁盘空间就会释放出来。
4、defrag 和 compact 的区别
- defrag 不会删除 boltdb 中的数据,而是通过整理 leaf page 的碎片来释放磁盘空间,对数据是无损的
- compact 会删除 key 的历史版本数据,对数据是有损的