etcd 存储:如何实现键值对的读操作?(新书上架了)

你好,我是aoho。今天和大家介绍下我的新书 《etcd工作笔记:架构分析、优化与最佳实践》 ,这本书已经在京东上面预售了。为了让小伙伴们更好地了解本书的内容,我们简单聊聊书中关于 etcd 读取键值对操作的过程。

在介绍 etcd 整体架构时,我们梳理了 etcd 的分层架构以及交互概览。本文将会聚焦于 etcd 存储是如何实现键值对的读操作。

读操作

在 etcd 中读请求占了大部分,是高频的操作。我们使用 etcdctl 命令行工具进行读操作:

$ etcdctl --endpoints http://localhost:2379 get foo

foo
bar

将整个读操作划分成如下几个步骤:

  • etcdctl 会创建一个 clientv3 库对象,选取一个合适的 etcd 节点;
  • 调用 KVServer 模块的 Range RPC 方法(上一课时有讲解),发送请求;
  • 拦截器拦截,主要做一些校验和监控;
  • 调用 KVServer 模块的 Range 接口获取数据;

接着就进入了读请求的核心步骤,会经过线性读 ReadIndex 模块、MVCC (包含 treeIndex 和 BlotDB)模块。

这里提一下线性读,线性读是相对串行读来说。集群模式下会有多个 etcd 节点,不同节点之间可能存在一致性问题,串行读直接返回状态数据,不需要与集群中其他节点交互。这种方式速度快,开销小,但是会存在数据不一致的情况。而线性读则需要集群成员之间达成共识,存在开销,响应速度相对慢。但是能够保证数据的一致性,etcd 默认读模式是线性读。我们将在后面的课时重点介绍分布式一致性的实现。

继续往下,看看如何读取 etcd 中的数据。etcd 中查询请求,查询单个键或者一组键,亦或是查询数量,到了底层实际都会调用 rangeKeys 方法,我们来分析下这个方式的实现。range 请求的结构图如下所示:

image.png

从上至下,查询键值对的流程包括:

  • 在 treeIndex 中根据键利用 BTree 快速查询该键对应的索引项 keyIndex,索引项中包含 Revision;
  • 根据查询到的版本号信息 Revision,在 Backend 的缓存 buffer 中利用二分法查找,如果命中则直接返回;
  • 若缓存中不符合条件,在 BlotDB 中查找(基于 BlotDB 的索引),查询之后返回键值对信息。

图中的 ReadTx 和 BatchTx 是两个接口,用于负责读写请求。我们在创建 backend 结构时,都会创建 readTx 和 batchTx 结构体,这两个结构体分别实现了 ReadTx 和 BatchTx 接口,一个负责处理只读请求,另一个负责处理读写请求。

rangeKeys 方法的实现如下所示:

// 位于 mvcc/kvstore_txn.go:117
func (tr *storeTxnRead) rangeKeys(key, end []byte, curRev int64, ro RangeOptions) (*RangeResult, error) {
	rev := ro.Rev
	if rev > curRev {
		return &RangeResult{KVs: nil, Count: -1, Rev: curRev}, ErrFutureRev
	}
	if rev <= 0 {
		rev = curRev
	}
	if rev < tr.s.compactMainRev {
		return &RangeResult{KVs: nil, Count: -1, Rev: 0}, ErrCompacted
	}
  // 获取索引项 keyIndex,索引项中包含 Revision
	revpairs := tr.s.kvindex.Revisions(key, end, rev)
	tr.trace.Step("range keys from in-memory index tree")
  // 结果为空,直接返回
	if len(revpairs) == 0 {
		return &RangeResult{KVs: nil, Count: 0, Rev: curRev}, nil
	}
	if ro.Count {
		return &RangeResult{KVs: nil, Count: len(revpairs), Rev: curRev}, nil
	}

	limit := int(ro.Limit)
	if limit <= 0 || limit > len(revpairs) {
		limit = len(revpairs)
	}

	kvs := make([]mvccpb.KeyValue, limit)
	revBytes := newRevBytes()
	for i, revpair := range revpairs[:len(kvs)] {
		revToBytes(revpair, revBytes)
    // UnsafeRange 实现了 ReadTx,查询对应的键值对
		_, vs := tr.tx.UnsafeRange(keyBucketName, revBytes, nil, 0)
		if len(vs) != 1 {
			tr.s.lg.Fatal(
				"range failed to find revision pair",
				zap.Int64("revision-main", revpair.main),
				zap.Int64("revision-sub", revpair.sub),
			)
		}
		if err := kvs[i].Unmarshal(vs[0]); err != nil {
			tr.s.lg.Fatal(
				"failed to unmarshal mvccpb.KeyValue",
				zap.Error(err),
			)
		}
	}
	tr.trace.Step("range keys from bolt db")
	return &RangeResult{KVs: kvs, Count: len(revpairs), Rev: curRev}, nil
}

在上述代码的实现中,我们需要通过 Revisions 方法从 Btree 中获取范围内所有的 keyIndex,以此才能获取一个范围内的所有键值对。Revisions 方法实现如下:

// 位于 mvcc/index.go:106
func (ti *treeIndex) Revisions(key, end []byte, atRev int64) (revs []revision) {
	if end == nil {
		rev, _, _, err := ti.Get(key, atRev)
		if err != nil {
			return nil
		}
		return []revision{rev}
	}
	ti.visit(key, end, func(ki *keyIndex) {
    // 使用 keyIndex.get 来遍历整颗树
		if rev, _, _, err := ki.get(ti.lg, atRev); err == nil {
			revs = append(revs, rev)
		}
	})
	return revs
}

如果只是获取一个键对应的版本,使用 treeIndex 方法即可,但是一般会从 Btree 索引中获取多个 Revision 值时,需要调用 keyIndex.get 方法来遍历整颗树并选取合适的版本。这是因为 BoltDB 保存一个 key 的多个历史版本。每一个 Key 的 keyIndex 中其实都存储着多个历史版本,我们需要根据传入的参数返回正确的版本。

对于上层的键值存储来说,它会利用这里返回的 Revision 从真正存储数据的 BoltDB 中查询当前 Key 对应 Revision 的结果。BoltDB 内部使用的也是类似 bucket 的方式存储,其实就是对应 MySQL 中的表结构,用户的 key 数据存放的 bucket 名字的是 key,etcd MVCC 元数据存放的 bucket 是 meta。

etcd工作笔记

以上内容摘自《etcd工作笔记:架构分析、优化与最佳实践》,这本书围绕etcd组件,从基础知识点到底层原理全面深入地展开介绍,最后结合了实践的案例。

image.png

通过etcd学习分布式组件的“道”,掌握学习之道会在后续的自我提升中发挥长期价值。无论在将来的面试还是开发中,切中分布式系统开发的要点,并将原理和应用结合起来,才能充分体现个人的核心竞争力。

image.png

感谢大家的支持,想直接购书的同学,可以通过上面的二维码去京东优惠购买。

(完)