【ETCD】ETCD Defrag底层原理分析

Posted by Hao Liang's Blog on Saturday, December 18, 2021

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 数据时:

    1. 先通过 etcd 内存中维护的 treeIndex (B+ Tree实现)找到对应 key 的版本号。(treeIndex 维护 key 和 keyIndex 版本号的对应关系)
    1. 优先在 buffer 中查找对应版本号的 key-value 信息。
    1. 如果没有命中 buffer,则根据 key 版本号在 boltdb 的 B+ Tree 中范围查找,找到对应的 branch page id,再通过索引找到下一层的 leaf page id,最终定位到用户的 key-value。

当用户通过 boltdb Open API 删除某个 key 时:

    1. key-value 所在的 leaf page 就会删除对应的数据。
    1. treeIndex 中的 key 和版本号对应关系的数据并不会被删除,而是做删除标记,做压缩时才真正删除。(lazy delete,让 watcher 能够获取 key 的所有状态,防止 B+ Tree 频繁重平衡)
    1. 随着 key-value 的不断删除, 就会产生空闲的 leaf page,被记录在 freeList page 中。
    1. 后续如果有新的 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 的历史版本数据,对数据是有损的