《kubernetes 系列》7. etcd 是如何基于 MVCC 实现 key 的历史变更的?

楔子

在 etcd v2 时,存在的若干局限,如仅保留最新版本 key- value 数据、丢弃历史版本。而 etcd 的核心特性 watch 又依赖历史版本,因此 etcd v2 为了缓解这个问题,会在内存中维护一个较短的全局事件滑动窗口,保留最近的 1000 条变更事件。但是在集群写请求较多等场景下,它依然无法提供可靠的 Watch 机制。

那么不可靠的 etcd v2 事件机制,在 etcd v3 中是如何解决的呢?本文要分享的 MVCC(Multiversion concurrency control)机制,正是为解决这个问题而诞生的。

什么是 MVCC

在数据库领域,并发控制是一个很具有挑战性的问题,常见的并发控制方式包括悲观并发控制、乐观并发控制和多版本并发控制,其中 MVCC 就是多版本并发控制的英文缩写。

悲观并发控制

在关系型数据库管理系统中,悲观并发控制(又名 "悲观锁",Pessimistic Concurrency Control,PCC)是一种并发控制的方法,它能以阻止其它事务的方式来修改数据。如果事务执行的操作对某行数据应用了锁,那么只有在这个事务将锁释放之后,其他事务才能够执行与该锁冲突的操作。悲观并发控制主要用于数据争用激烈的环境,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本。

乐观并发控制

乐观并发控制(又名 "乐观锁")也是一种并发控制的方法,它假设多用户并发的事务在处理时彼此之间不会互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务都会先检查在该事务读取数据之后,有没有其它事务又修改了该数据,如果其它事务有更新的话,那么正在提交的事务会进行回滚。

所以乐观并发控制多用于数据争用不大、冲突较少的环境,在这种环境中,偶尔回滚事务的成本会低于读取数据时锁定数据的成本,因此这种情况下乐观并发控制可以获得比其它并发控制方法更高的吞吐量。

多版本并发控制

多版本并发控制( Multiversion Concurrency Control,MVCC)并不是一个与乐观并发控制、悲观并发控制相对立的概念,它能够与两者很好地结合以增加事务的并发量,目前最流行的 SQL 数据库 MySQL 和 PostgreSQL 都对 MVCC 进行了实现。MVCC 的每一个写操作都会创建一个新版本的数据,读操作会从有限多个版本的数据中挑选一个 "最合适"(要么是最新版本,要么是指定版本) 的结果直接返回。通过这种方式,读写操作之间的冲突就不再需要受到关注。因此如何管理和高效地选取数据的版本就成了 MVCC 需要解决的主要问题。

为什么 etcd 会选择 MVCC

对一个系统进行各种优化时,相应的思路其实并不是凭空产生的,而是有方法论的,首先我们应该分析 etcd 的使用场景,然后才能进行针对性的优化。首先我们知道 etcd 的定位是一个分布式的、一致的 key-value 存储,主要用途是共享配置和服务发现,它不是一个类似于 ceph 那样存储海量数据的存储系统,也不是类似于 MySQL 这样的 SQL 数据库。它存储的其实是一些非常重要的元数据,当然,元数据的写操作其实是比较少的,但是会有很多的客户端同时 watch 这些元数据的变更。也就是说 etcd 的使用场景是一种 "读多写少" 的场景,etcd 里的一个 key ,其实并不会发生频繁的变更,但是一旦发生变更,etcd 就需要通知监控这个 key 的所有客户端。

因为同一时间可能会存在很多用户连接,那么这段时间一定会存在许多并发问题,比如数据竞争,这些并发问题必须得到解决。在这样的背景下, etcd 就必须保证并发操作产生的结果是安全的。etcd v2 是个纯内存数据库,整个数据库有一把 Stop-the-World 大锁,可以通过锁的机制来解决并发带来的数据竞争,但是通过锁的方式也有一些缺点,比如:

  • 锁的粒度不好控制, 每次操作 Stop-the-World 时都会锁住整个数据库;
  • 读锁和写锁会相互阻塞(block);
  • 如果使用基于锁的隔离机制, 并且有一段很长的读事务, 那么在这段时间内这个对象就会无法被改写, 后面的事务也会被阻塞, 直到这个事务完成为止, 这种机制对于并发性能来说影响很大;

多版本并发控制(Multi-Version Concurrency Control,MVCC)则以一种优雅的方式解决了锁带来的问题。在 MVCC 中,每当想要更改或者删除某个数据对象时,DBMS 不会在原地删除或修改这个已有的数据对象本身,而是针对该数据对象创建一个新的版本,这样一来,并发的读取操作仍然可以读取老版本的数据,而写操作就可以同时进行。这个模式的好处在于,可以让读取操作不再阻塞,事实上根本就不需要锁。这是种非常诱人的特性,以至于很多主流的数据库中都采用了 MVCC 的实现,比如 MySQL、PostgreSQL、Oracle、Microsoft SQL Server等等。

可能有人会有疑问,既然整个数据库使用一把 Stop-the-World 大锁会导致并发上不去,那么如果换成每个 key 一把锁是不是就可以了呢?MVCC 方案与这种一个 key 一把锁的方案相比又有什么优势呢?其实即使每个 key 一把锁,写锁也是会阻塞读锁的(写的时候不能读),而 MVCC 在写的时候也是可以并发读的,因为写是在最新的版本上进行写的,读却可以读老的版本(客户端读 key 的时候可以指定一个版本号,服务端保证能返回基于此版本号的新数据,而不是保证返回最新的数据)。

总而言之, MVCC 能最大化地实现高效的读写并发,尤其是高效的读,因此其非常适合 etcd 这种 "读多写少" 的场景。

MVCC 代码演示

MVCC 本质上就是基于多版本技术实现的一种乐观锁机制,它乐观地认为数据不会发生冲突,但是当事务提交时,具备检测数据是否冲突的能力。

在 MVCC 数据库中,你更新一个 key-value 数据的时候,它并不会直接覆盖原数据,而是新增一个版本来存储新的数据,每个数据都有一个版本号。版本号是一个逻辑时间,为了方便深入理解版本号意义,我们画一张 etcd MVCC 版本号时间序列图。

下面通过几个简单命令体验一下 MVCC 特性,看看它是如何帮助我们查询历史修改记录,以及找回不小心删除的 key 的。

# 更新 key hello 为 world1
[root@satori-003 ~]# etcdctl put hello world1
OK

# 查看详细信息(版本号为 104)
[root@satori-003 ~]# etcdctl get hello -w json | python3 -m json.tool
{
    "header": {
        "cluster_id": 16315665522645494256,
        "member_id": 459560502446369791,
        "revision": 104,
        "raft_term": 473
    },
    "kvs": [
        {
            "key": "aGVsbG8=",
            "create_revision": 104,
            "mod_revision": 104,
            "version": 1,
            "value": "d29ybGQx"
        }
    ],
    "count": 1
}

# 再次修改 key hello 为 world2(版本号自增 1 变成 105)
[root@satori-003 ~]# etcdctl put hello world2
OK
[root@satori-003 ~]# etcdctl get hello 
hello
world2

# 指定查询版本号, 获得了 hello 上一次修改的值
[root@satori-003 ~]# etcdctl get hello --rev 104
hello
world1

# 删除 key hello
[root@satori-003 ~]# etcdctl del hello
1
[root@satori-003 ~]# etcdctl get hello
# 删除后指定查询版本号, 获得了 hello 删除前的值
[root@satori-003 ~]# etcdctl get hello --rev=105
hello
world2
[root@satori-003 ~]# etcdctl get hello --rev=104
hello
world1

还是很好理解的,然后我们深入分析一下 etcd 中的 MVCC。

MVCC 的架构

以下是 MVCC 模块的一个整体架构图,整个 MVCC 特性由 treeIndex、Backend/boltdb 组成。当你执行 put 命令后,请求经过 gRPC KV Server、Raft 模块流转,对应的日志条目被提交后,Apply 模块开始执行此日志内容。

Apply 模块通过 MVCC 模块来执行 put 请求,持久化 key-value 数据。MVCC 模块将请求请划分成两个类别,分别是读事务(ReadTxn)和写事务(WriteTxn)。读事务负责处理 range 请求,写事务负责 put/delete 操作,读写事务基于 treeIndex、Backend/boltdb 提供的能力,实现对 key-value 的增删改查功能。

treeIndex 模块基于内存版 B tree 实现了 key 索引管理,它保存了用户 key 与版本号(revision)的映射关系等信息。

Backend 模块负责 etcd 的 key-value 持久化存储,主要由 ReadTx、BatchTx、Buffer 组成,ReadTx 定义了抽象的读事务接口,BatchTx 在 ReadTx 之上定义了抽象的写事务接口,Buffer 是数据缓存区。

etcd 设计上支持多种 Backend 实现,目前实现的 Backend 是 boltdb。boltdb 是一个基于 B+ tree 实现的、支持事务的 key-value 嵌入式数据库。

treeIndex 与 boltdb 关系可参考下图。当你发起一个 get hello 命令时,从 treeIndex 中获取 key 的版本号,然后再通过这个版本号,从 boltdb 获取 value 信息。boltdb 的 value 是包含用户 key-value、各种版本号、lease 信息的结构体。

下面重点聊聊 treeIndex 模块的原理与核心数据结构。

treeIndex 原理

为什么需要 treeIndex 模块呢?对于 etcd v2 来说,当你通过 etcdctl 发起一个 put hello 操作时,etcd v2 直接更新内存树,这就导致历史版本直接被覆盖,无法支持保存 key 的历史版本。在 etcd v3 中引入 treeIndex 模块正是为了解决这个问题,支持保存 key 的历史版本,提供稳定的 Watch 机制和事务隔离等能力。

那 etcd v3 又是如何基于 treeIndex 模块,实现保存 key 的历史版本的呢?

之前我们提到过 etcd 在每次修改 key 时会生成一个全局递增的版本号 (revision),然后通过数据结构 B tree 保存用户 key 与版本号之间的关系,再以版本号作为 boltdb key,以用户的 key-value 等信息作为 boltdb value,保存到 boltdb。那 etcd 保存用户 key 与版本号映射关系的数据结构为什么是 B-tree,而不是哈希表、平衡二叉树呢?

从 etcd 的功能特性上分析, 因 etcd 支持范围查询,因此保存索引的数据结构也必须支持范围查询才行。所以哈希表不适合,而 B tree 和平衡二叉树支持范围查询。但从性能上分析,平横二叉树每个节点只能容纳一个数据、导致树的高度较高,而 B tree 每 个节点可以容纳多个数据,树的高度更低,更扁平,涉及的查找次数更少,具有优越的增、删、改、查性能。

Google 的开源项目 btree,使用 Go 语言实现了一个内存版的 B tree,对外提供了简单易用的接口。etcd 正是基于 btree 库实现了一个名为 treeIndex 的索引模块,通过它来查询、保存用户 key 与版本号之间的关系。

从图中可以看到,通过 put/txn 命令写入的一系列 key,treeIndex 模块基于 B tree 将其组织起来,节点之间基于用户 key 比较大小。当你查找一个 key k95 时,通过 B-tree 的特性,你仅需通过两次快速比较,就可快速找到 k95 所在的节点。

在 treeIndex 中,每个节点的 key 是一个 keyIndex 结构,etcd 就是通过它保存了用户的 key 与版本号的映射关系。那么 keyIndex 结构包含哪些信息呢?

总共包含三个字段:

  • key:用户的键名称,比如 "name"
  • modified:最后一次修改 key 时的 etcd 版本号
  • generations:key 的若干代版本号信息,每代中包含对 key 的多次修改的版本号列表

这个 generations 要如何理解呢?它为什么是个数组呢?

首先 generations 表示一个 key 从创建到删除的过程,每代对应 key 的一个生命周期的开始与结束。当你第一次创建一个 key 时,会生成第 0 代,后续的修改操作都是在往第 0 代中追加修改版本号。当你把 key 删除后,它就会生成新的第 1 代,一个 key 不断经历创建、删除的过程,它就会生成多个代。

generation 结构详细信息如下:

  • ver:此 key 的修改次数
  • created:generation 结构体实例创建时的版本号
  • revs:负责保存每次修改 key 时的 revision

generation 结构体中包含 key 的修改次数、实例创建时的版本号、对 key 的修改版本号记录列表。但你需要注意的是版本号(revision)并不是一个简单的整数,而是一个结构体,定义如下:

revision 包含 main 和 sub 两个字段,main 是全局递增的版本号,它是个 etcd 逻辑时钟,随着 put/txn/delete 等事务递增。sub 是一个事务内的子版本号,从 0 开始随事务内的 put/delete 操作递增。

比如启动一个空集群,全局版本号默认为 1,执行下面的 txn 事务,它包含两次 put、一次 get 操作。

[root@satori-003 ~]# etcdctl txn -i
compares:

success requests (get, put, del):
put name satori
get name
put age 17

那么按照我们上面介绍的原理,全局版本号随读写事务自增,并且初始为 1,因此现在 revision.main 为 2;sub 随事务内的 put/delete 操作递增,因此 key name 的 revison 为 {2, 0},key age 的 revision 为 {2,1}。

posted @ 2023-06-01 15:16  古明地盆  阅读(283)  评论(0编辑  收藏  举报