This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,169 @@
<audio id="audio" title="01 | etcd的前世今生为什么Kubernetes使用etcd" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/yy/36/yy3d617dbf6yycfeb065064193785436.mp3"></audio>
你好,我是唐聪。
今天是专栏课程的第一讲我们就从etcd的前世今生讲起。让我们一起穿越回2013年看看etcd最初是在什么业务场景下被设计出来的
2013年有一个叫CoreOS的创业团队他们构建了一个产品Container Linux它是一个开源、轻量级的操作系统侧重自动化、快速部署应用服务并要求应用程序都在容器中运行同时提供集群化的管理方案用户管理服务就像单机一样方便。
他们希望在重启任意一节点的时候,用户的服务不会因此而宕机,导致无法提供服务,因此需要运行多个副本。但是多个副本之间如何协调,如何避免变更的时候所有副本不可用呢?
为了解决这个问题CoreOS团队需要一个协调服务来存储服务配置信息、提供分布式锁等能力。怎么办呢当然是分析业务场景、痛点、核心目标然后是基于目标进行方案选型评估是选择社区开源方案还是自己造轮子。这其实就是我们遇到棘手问题时的通用解决思路CoreOS团队同样如此。
假设你是CoreOS团队成员你认为在这样的业务场景下理想中的解决方案应满足哪些目标呢
如果你有过一些开发经验,应该能想到一些关键点了,我根据自己的经验来总结一下,一个协调服务,理想状态下大概需要满足以下五个目标:
1. **可用性角度:高可用**。协调服务作为集群的控制面存储,它保存了各个服务的部署、运行信息。若它故障,可能会导致集群无法变更、服务副本数无法协调。业务服务若此时出现故障,无法创建新的副本,可能会影响用户数据面。
1. **数据一致性角度:提供读取“最新”数据的机制**。既然协调服务必须具备高可用的目标就必然不能存在单点故障single point of failure而多节点又引入了新的问题即多个节点之间的数据一致性如何保障比如一个集群3个节点A、B、C从节点A、B获取服务镜像版本是新的但节点C因为磁盘 I/O异常导致数据更新缓慢若控制端通过C节点获取数据那么可能会导致读取到过期数据服务镜像无法及时更新。
1. **容量角度:低容量、仅存储关键元数据配置。**协调服务保存的仅仅是服务、节点的配置信息(属于控制面配置),而不是与用户相关的数据。所以存储上不需要考虑数据分片,无需过度设计。
1. **功能:增删改查,监听数据变化的机制**。协调服务保存了服务的状态信息,若服务有变更或异常,相比控制端定时去轮询检查一个个服务状态,若能快速推送变更事件给控制端,则可提升服务可用性、减少协调服务不必要的性能开销。
1. **运维复杂度:可维护性。**在分布式系统中往往会遇到硬件Bug、软件Bug、人为操作错误导致节点宕机以及新增、替换节点等运维场景都需要对协调服务成员进行变更。若能提供API实现平滑地变更成员节点信息就可以大大降低运维复杂度减少运维成本同时可避免因人工变更不规范可能导致的服务异常。
了解完理想中的解决方案目标我们再来看CoreOS团队当时为什么选择了从0到1开发一个新的协调服务呢
如果使用开源软件当时其实是有ZooKeeper的但是他们为什么不用ZooKeeper呢我们来分析一下。
从高可用性、数据一致性、功能这三个角度来说ZooKeeper是满足CoreOS诉求的。然而当时的ZooKeeper不支持通过API安全地变更成员需要人工修改一个个节点的配置并重启进程。
若变更姿势不正确则有可能出现脑裂等严重故障。适配云环境、可平滑调整集群规模、在线变更运行时配置是CoreOS的期望目标而ZooKeeper在这块的可维护成本相对较高。
其次ZooKeeper是用 Java 编写的部署较繁琐占用较多的内存资源同时ZooKeeper RPC的序列化机制用的是Jute自己实现的RPC API。无法使用curl之类的常用工具与之互动CoreOS期望使用比较简单的HTTP + JSON。
因此CoreOS决定自己造轮子那CoreOS团队是如何根据系统目标进行技术方案选型的呢
## etcd v1和v2诞生
首先我们来看服务高可用及数据一致性。前面我们提到单副本存在单点故障,而多副本又引入数据一致性问题。
因此为了解决数据一致性问题需要引入一个共识算法确保各节点数据一致性并可容忍一定节点故障。常见的共识算法有Paxos、ZAB、Raft等。CoreOS团队选择了易理解实现的Raft算法它将复杂的一致性问题分解成Leader选举、日志同步、安全性三个相对独立的子问题只要集群一半以上节点存活就可提供服务具备良好的可用性。
其次我们再来看数据模型Data Model和API。数据模型参考了ZooKeeper使用的是基于目录的层次模式。API相比ZooKeeper来说使用了简单、易用的REST API提供了常用的Get/Set/Delete/Watch等API实现对key-value数据的查询、更新、删除、监听等操作。
key-value存储引擎上ZooKeeper使用的是Concurrent HashMap而etcd使用的是则是简单内存树它的节点数据结构精简后如下含节点路径、值、孩子节点信息。这是一个典型的低容量设计数据全放在内存无需考虑数据分片只能保存key的最新版本简单易实现。
<img src="https://static001.geekbang.org/resource/image/ff/41/ff4ee032739b9b170af1b2e2ba530e41.png" alt="">
```
type node struct {
Path string //节点路径
Parent *node //关联父亲节点
Value string //key的value值
ExpireTime time.Time //过期时间
Children map[string]*node //此节点的孩子节点
}
```
最后我们再来看可维护性。Raft算法提供了成员变更算法可基于此实现成员在线、安全变更同时此协调服务使用Go语言编写无依赖部署简单。
<img src="https://static001.geekbang.org/resource/image/dd/70/dd253e4fc19885fa6f00c278762ba270.png" alt="">
基于以上技术方案和架构图CoreOS团队在2013年8月对外发布了第一个测试版本v0.1API v1版本命名为etcd。
那么etcd这个名字是怎么来的呢其实它源于两个方面unix的“/etc”文件夹和分布式系统(“D”istribute system)的D组合在一起表示etcd是用于存储分布式配置的信息存储服务。
v0.1版本实现了简单的HTTP Get/Set/Delete/Watch API但读数据一致性无法保证。v0.2版本支持通过指定consistent模式从Leader读取数据并将Test And Set机制修正为CAS(Compare And Swap)解决原子更新的问题同时发布了新的API版本v2这就是大家熟悉的etcd v2版本第一个非stable版本。
下面我用一幅时间轴图给你总结一下etcd v1/v2关键特性。
<img src="https://static001.geekbang.org/resource/image/d0/0e/d0af3537c0eef89b499a82693da23f0e.png" alt="">
## 为什么Kubernetes使用etcd?
这张图里我特别标注出了Kubernetes的发布时间点这个非常关键。我们必须先来说说这个事儿也就是Kubernetes和etcd的故事。
2014年6月Google的Kubernetes项目诞生了我们前面所讨论到Go语言编写、etcd高可用、Watch机制、CAS、TTL等特性正是Kubernetes所需要的它早期的0.4版本使用的正是etcd v0.2版本。
Kubernetes是如何使用etcd v2这些特性的呢举几个简单小例子。
当你使用Kubernetes声明式API部署服务的时候Kubernetes的控制器通过etcd Watch机制会实时监听资源变化事件对比实际状态与期望状态是否一致并采取协调动作使其一致。Kubernetes更新数据的时候通过CAS机制保证并发场景下的原子更新并通过对key设置TTL来存储Event事件提升Kubernetes集群的可观测性基于TTL特性Event事件key到期后可自动删除。
Kubernetes项目使用etcd除了技术因素也与当时的商业竞争有关。CoreOS是Kubernetes容器生态圈的核心成员之一。
当时Docker容器浪潮正席卷整个开源技术社区CoreOS也将容器集成到自家产品中。一开始与Docker公司还是合作伙伴然而Docker公司不断强化Docker的PaaS平台能力强势控制Docker社区这与CoreOS核心商业战略出现了冲突也损害了Google、RedHat等厂商的利益。
最终CoreOS与Docker分道扬镳并推出了rkt项目来对抗Docker然而此时Docker已深入人心CoreOS被Docker全面压制。
以Google、RedHat为首的阵营基于Google多年的大规模容器管理系统Borg经验结合社区的建议和实践构建以Kubernetes为核心的容器生态圈。相比Docker的垄断、独裁Kubernetes社区推行的是民主、开放原则Kubernetes每一层都可以通过插件化扩展在Google、RedHat的带领下不断发展壮大etcd也进入了快速发展期。
在2015年1月CoreOS发布了etcd第一个稳定版本2.0支持了quorum read提供了严格的线性一致性读能力。7月基于etcd 2.0的Kubernetes第一个生产环境可用版本v1.0.1发布了Kubernetes开始了新的里程碑的发展。
etcd v2在社区获得了广泛关注GitHub star数在2015年6月就高达6000+超过500个项目使用被广泛应用于配置存储、服务发现、主备选举等场景。
下图我从构建分布式系统的核心要素角度给你总结了etcd v2核心技术点。无论是NoSQL存储还是SQL存储、文档存储其实大家要解决的问题都是类似的基本就是图中总结的数据模型、复制、共识算法、API、事务、一致性、成员故障检测等方面。
希望通过此图帮助你了解从0到1如何构建、学习一个分布式系统要解决哪些技术点在心中有个初步认识后面的课程中我会再深入介绍。
<img src="https://static001.geekbang.org/resource/image/cd/f0/cde3f155f51bfd3d7fd78fe8e7ac9bf0.png" alt="">
## etcd v3诞生
然而随着Kubernetes项目不断发展v2版本的瓶颈和缺陷逐渐暴露遇到了若干性能和稳定性问题Kubernetes社区呼吁支持新的存储、批评etcd不可靠的声音开始不断出现。
具体有哪些问题呢?我给你总结了如下图:
<img src="https://static001.geekbang.org/resource/image/88/d1/881db1b7d05dc40771e9737f3117f5d1.png" alt="">
下面我分别从功能局限性、Watch事件的可靠性、性能、内存开销来分别给你剖析etcd v2的问题。
首先是**功能局限性问题。**它主要是指etcd v2不支持范围和分页查询、不支持多key事务。
第一etcd v2不支持范围查询和分页。分页对于数据较多的场景是必不可少的。在Kubernetes中在集群规模增大后Pod、Event等资源可能会出现数千个以上但是etcd v2不支持分页不支持范围查询大包等expensive request会导致严重的性能乃至雪崩问题。
第二etcd v2不支持多key事务。在实际转账等业务场景中往往我们需要在一个事务中同时更新多个key。
然后是**Watch机制可靠性问题**。Kubernetes项目严重依赖etcd Watch机制然而etcd v2是内存型、不支持保存key历史版本的数据库只在内存中使用滑动窗口保存了最近的1000条变更事件当etcd server写请求较多、网络波动时等场景很容易出现事件丢失问题进而又触发client数据全量拉取产生大量expensive request甚至导致etcd雪崩。
其次是**性能瓶颈问题**。etcd v2早期使用了简单、易调试的HTTP/1.x API但是随着Kubernetes支撑的集群规模越来越大HTTP/1.x协议的瓶颈逐渐暴露出来。比如集群规模大时由于HTTP/1.x协议没有压缩机制批量拉取较多Pod时容易导致APIServer和etcd出现CPU高负载、OOM、丢包等问题。
另一方面etcd v2 client会通过HTTP长连接轮询Watch事件当watcher较多的时候因HTTP/1.x不支持多路复用会创建大量的连接消耗server端过多的socket和内存资源。
同时etcd v2支持为每个key设置TTL过期时间client为了防止key的TTL过期后被删除需要周期性刷新key的TTL。
实际业务中很有可能若干key拥有相同的TTL可是在etcd v2中即使大量key TTL一样你也需要分别为每个key发起续期操作当key较多的时候这会显著增加集群负载、导致集群性能显著下降。
最后是**内存开销问题。**etcd v2在内存维护了一颗树来保存所有节点key及value。在数据量场景略大的场景如配置项较多、存储了大量Kubernetes Events 它会导致较大的内存开销同时etcd需要定时把全量内存树持久化到磁盘。这会消耗大量的CPU和磁盘 I/O资源对系统的稳定性造成一定影响。
为什么etcd v2有以上若干问题Consul等其他竞品依然没有被Kubernetes支持呢
一方面当时包括Consul在内没有一个开源项目是十全十美完全满足Kubernetes需求。而CoreOS团队一直在聆听社区的声音并积极改进解决社区的痛点。用户吐槽etcd不稳定他们就设计实现自动化的测试方案模拟、注入各类故障场景及时发现修复Bug以提升etcd稳定性。
另一方面用户吐槽性能问题针对etcd v2各种先天性缺陷问题他们从2015年就开始设计、实现新一代etcd v3方案去解决以上痛点并积极参与Kubernetes项目负责etcd v2到v3的存储引擎切换推动Kubernetes项目的前进。同时设计开发通用压测工具、输出Consul、ZooKeeper、etcd性能测试报告证明etcd的优越性。
etcd v3就是为了解决以上稳定性、扩展性、性能问题而诞生的。
在内存开销、Watch事件可靠性、功能局限上它通过引入B-tree、boltdb实现一个MVCC数据库数据模型从层次型目录结构改成扁平的key-value提供稳定可靠的事件通知实现了事务支持多key原子更新同时基于boltdb的持久化存储显著降低了etcd的内存占用、避免了etcd v2定期生成快照时的昂贵的资源开销。
性能上首先etcd v3使用了gRPC API使用protobuf定义消息消息编解码性能相比JSON超过2倍以上并通过HTTP/2.0多路复用机制减少了大量watcher等场景下的连接数。
其次使用Lease优化TTL机制每个Lease具有一个TTL相同的TTL的key关联一个LeaseLease过期的时候自动删除相关联的所有key不再需要为每个key单独续期。
最后是etcd v3支持范围、分页查询可避免大包等expensive request。
2016年6月etcd 3.0诞生随后Kubernetes 1.6发布默认启用etcd v3助力Kubernetes支撑5000节点集群规模。
下面的时间轴图我给你总结了etcd3重要特性及版本发布时间。从图中你可以看出从3.0到未来的3.5更稳、更快是etcd的追求目标。
<img src="https://static001.geekbang.org/resource/image/5f/6d/5f1bf807db06233ed51d142917798b6d.png" alt="">
从2013年发布第一个版本v0.1到今天的3.5.0-pre从v2到v3etcd走过了7年的历程etcd的稳定性、扩展性、性能不断提升。
发展到今天在GitHub上star数超过34K。在Kubernetes的业务场景磨炼下它不断成长走向稳定和成熟成为技术圈众所周知的开源产品而**v3方案的发布也标志着etcd进入了技术成熟期成为云原生时代的首选元数据存储产品。**
## 小结
最后我们来小结下今天的内容我们从如下几个方面介绍了etcd的前世今生并在过程中详细解读了为什么Kubernetes使用etcd
- etcd诞生背景 etcd v2源自CoreOS团队遇到的服务协调问题。
- etcd目标我们通过实际业务场景分析得到理想中的协调服务核心目标高可用、数据一致性、Watch、良好的可维护性等。而在CoreOS团队看来高可用、可维护性、适配云、简单的API、良好的性能对他们而言是非常重要的ZooKeeper无法满足所有诉求因此决定自己构建一个分布式存储服务。
- 介绍了v2基于目录的层级数据模型和API并从分布式系统的角度给你详细总结了etcd v2技术点。etcd的高可用、Watch机制与Kubernetes期望中的元数据存储是匹配的。etcd v2在Kubernetes的带动下获得了广泛的应用但也出现若干性能和稳定性、功能不足问题无法满足Kubernetes项目发展的需求。
- CoreOS团队未雨绸缪从问题萌芽时期就开始构建下一代etcd v3存储模型分别从性能、稳定性、功能上等成功解决了Kubernetes发展过程中遇到的瓶颈也捍卫住了作为Kubernetes存储组件的地位。
希望通过今天的介绍, 让你对etcd为什么有v2和v3两个大版本etcd如何从HTTP/1.x API到gRPC API、单版本数据库到多版本数据库、内存树到boltdb、TTL到Lease、单key原子更新到支持多key事务的演进过程有个清晰了解。希望你能有所收获在后续的课程中我会和你深入讨论各个模块的细节。
## 思考题
最后我给你留了一个思考题。分享一下在你的项目中你主要使用的是哪个etcd版本来解决什么问题呢使用的etcd v2 API还是v3 API呢在这过程中是否遇到过什么问题
感谢你的阅读,欢迎你把思考和观点写在留言区,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,221 @@
<audio id="audio" title="02 | 基础架构etcd一个读请求是如何执行的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/56/b4/56f71f418yy948b576de9d1a1248a6b4.mp3"></audio>
你好,我是唐聪。
在上一讲中我和你分享了etcd的前世今生同时也为你重点介绍了etcd v2的不足之处以及我们现在广泛使用etcd v3的原因。
今天我想跟你介绍一下etcd v3的基础架构让你从整体上对etcd有一个初步的了解心中能构筑起一幅etcd模块全景图。这样在你遇到诸如“Kubernetes在执行kubectl get pod时etcd如何获取到最新的数据返回给APIServer”等流程架构问题时就能知道各个模块由上至下是如何紧密协作的。
即便是遇到请求报错,你也能通过顶层的模块全景图,推测出请求流程究竟在什么模块出现了问题。
## 基础架构
下面是一张etcd的简要基础架构图我们先从宏观上了解一下etcd都有哪些功能模块。
<img src="https://static001.geekbang.org/resource/image/34/84/34486534722d2748d8cd1172bfe63084.png" alt="">
你可以看到按照分层模型etcd可分为Client层、API网络层、Raft算法层、逻辑层和存储层。这些层的功能如下
<li>
**Client层**Client层包括client v2和v3两个大版本API客户端库提供了简洁易用的API同时支持负载均衡、节点间故障自动转移可极大降低业务使用etcd复杂度提升开发效率、服务可用性。
</li>
<li>
**API网络层**API网络层主要包括client访问server和server节点之间的通信协议。一方面client访问etcd server的API分为v2和v3两个大版本。v2 API使用HTTP/1.x协议v3 API使用gRPC协议。同时v3通过etcd grpc-gateway组件也支持HTTP/1.x协议便于各种语言的服务调用。另一方面server之间通信协议是指节点间通过Raft算法实现数据复制和Leader选举等功能时使用的HTTP协议。
</li>
<li>
**Raft算法层**Raft算法层实现了Leader选举、日志复制、ReadIndex等核心算法特性用于保障etcd多个节点间的数据一致性、提升服务可用性等是etcd的基石和亮点。
</li>
<li>
**功能逻辑层**etcd核心特性实现层如典型的KVServer模块、MVCC模块、Auth鉴权模块、Lease租约模块、Compactor压缩模块等其中MVCC模块主要由treeIndex模块和boltdb模块组成。
</li>
<li>
**存储层**:存储层包含预写日志(WAL)模块、快照(Snapshot)模块、boltdb模块。其中WAL可保障etcd crash后数据不丢失boltdb则保存了集群元数据和用户写入的数据。
</li>
etcd是典型的读多写少存储在我们实际业务场景中读一般占据2/3以上的请求。为了让你对etcd有一个深入的理解接下来我会分析一个读请求是如何执行的带你了解etcd的核心模块进而由点及线、由线到面地帮助你构建etcd的全景知识脉络。
在下面这张架构图中我用序号标识了etcd默认读模式线性读的执行流程接下来我们就按照这个执行流程从头开始说。
<img src="https://static001.geekbang.org/resource/image/45/bb/457db2c506135d5d29a93ef0bd97e4bb.png" alt="">
## 环境准备
首先介绍一个好用的进程管理工具[goreman](https://github.com/mattn/goreman)基于它我们可快速创建、停止本地的多节点etcd集群。
你可以通过如下`go get`命令快速安装goreman然后从[etcd release](https://github.com/etcd-io/etcd/releases/v3.4.9)页下载etcd v3.4.9二进制文件,再从[etcd源码](https://github.com/etcd-io/etcd/blob/v3.4.9/Procfile)中下载goreman Procfile文件它描述了etcd进程名、节点数、参数等信息。最后通过`goreman -f Procfile start`命令就可以快速启动一个3节点的本地集群了。
```
go get github.com/mattn/goreman
```
## client
启动完etcd集群后当你用etcd的客户端工具etcdctl执行一个get hello命令如下对应到图中流程一etcdctl是如何工作的呢
```
etcdctl get hello --endpoints http://127.0.0.1:2379
hello
world
```
首先etcdctl会对命令中的参数进行解析。我们来看下这些参数的含义其中参数“get”是请求的方法它是KVServer模块的API“hello”是我们查询的key名“endpoints”是我们后端的etcd地址通常生产环境下中需要配置多个endpoints这样在etcd节点出现故障后client就可以自动重连到其它正常的节点从而保证请求的正常执行。
在etcd v3.4.9版本中etcdctl是通过clientv3库来访问etcd server的clientv3库基于gRPC client API封装了操作etcd KVServer、Cluster、Auth、Lease、Watch等模块的API同时还包含了负载均衡、健康探测和故障切换等特性。
在解析完请求中的参数后etcdctl会创建一个clientv3库对象使用KVServer模块的API来访问etcd server。
接下来就需要为这个get hello请求选择一个合适的etcd server节点了这里得用到负载均衡算法。在etcd 3.4中clientv3库采用的负载均衡算法为Round-robin。针对每一个请求Round-robin算法通过轮询的方式依次从endpoint列表中选择一个endpoint访问(长连接)使etcd server负载尽量均衡。
关于负载均衡算法,你需要特别注意以下两点。
1. 如果你的client 版本&lt;= 3.3那么当你配置多个endpoint时负载均衡算法仅会从中选择一个IP并创建一个连接Pinned endpoint这样可以节省服务器总连接数。但在这我要给你一个小提醒在heavy usage场景这可能会造成server负载不均衡。
1. 在client 3.4之前的版本中负载均衡算法有一个严重的Bug如果第一个节点异常了可能会导致你的client访问etcd server异常特别是在Kubernetes场景中会导致APIServer不可用。不过该Bug已在 Kubernetes 1.16版本后被修复。
为请求选择好etcd server节点client就可调用etcd server的KVServer模块的Range RPC方法把请求发送给etcd server。
这里我说明一点client和server之间的通信使用的是基于HTTP/2的gRPC协议。相比etcd v2的HTTP/1.xHTTP/2是基于二进制而不是文本、支持多路复用而不再有序且阻塞、支持数据压缩以减少包大小、支持server push等特性。因此基于HTTP/2的gRPC协议具有低延迟、高性能的特点有效解决了我们在上一讲中提到的etcd v2中HTTP/1.x 性能问题。
## KVServer
client发送Range RPC请求到了server后就开始进入我们架构图中的流程二也就是KVServer模块了。
etcd提供了丰富的metrics、日志、请求行为检查等机制可记录所有请求的执行耗时及错误码、来源IP等也可控制请求是否允许通过比如etcd Learner节点只允许指定接口和参数的访问帮助大家定位问题、提高服务可观测性等而这些特性是怎么非侵入式的实现呢
答案就是拦截器。
### 拦截器
etcd server定义了如下的Service KV和Range方法启动的时候它会将实现KV各方法的对象注册到gRPC Server并在其上注册对应的拦截器。下面的代码中的Range接口就是负责读取etcd key-value的的RPC接口。
```
service KV {
// Range gets the keys in the range from the key-value store.
rpc Range(RangeRequest) returns (RangeResponse) {
option (google.api.http) = {
post: &quot;/v3/kv/range&quot;
body: &quot;*&quot;
};
}
....
}
```
拦截器提供了在执行一个请求前后的hook能力除了我们上面提到的debug日志、metrics统计、对etcd Learner节点请求接口和参数限制等能力etcd还基于它实现了以下特性:
- 要求执行一个操作前集群必须有Leader
- 请求延时超过指定阈值的打印包含来源IP的慢查询日志(3.5版本)。
server收到client的Range RPC请求后根据ServiceName和RPC Method将请求转发到对应的handler实现handler首先会将上面描述的一系列拦截器串联成一个执行在拦截器逻辑中通过调用KVServer模块的Range接口获取数据。
### 串行读与线性读
进入KVServer模块后我们就进入核心的读流程了对应架构图中的流程三和四。我们知道etcd为了保证服务高可用生产环境一般部署多个节点那各个节点数据在任意时间点读出来都是一致的吗什么情况下会读到旧数据呢
这里为了帮助你更好的理解读流程我先简单提下写流程。如下图所示当client发起一个更新hello为world请求后若Leader收到写请求它会将此请求持久化到WAL日志并广播给各个节点若一半以上节点持久化成功则该请求对应的日志条目被标识为已提交etcdserver模块异步从Raft模块获取已提交的日志条目应用到状态机(boltdb等)。
<img src="https://static001.geekbang.org/resource/image/cf/d5/cffba70a79609f29e1f2ae1f3bd07fd5.png" alt="">
此时若client发起一个读取hello的请求假设此请求直接从状态机中读取 如果连接到的是C节点若C节点磁盘I/O出现波动可能导致它应用已提交的日志条目很慢则会出现更新hello为world的写命令在client读hello的时候还未被提交到状态机因此就可能读取到旧数据如上图查询hello流程所示。
从以上介绍我们可以看出在多节点etcd集群中各个节点的状态机数据一致性存在差异。而我们不同业务场景的读请求对数据是否最新的容忍度是不一样的有的场景它可以容忍数据落后几秒甚至几分钟有的场景要求必须读到反映集群共识的最新数据。
我们首先来看一个**对数据敏感度较低的场景**。
假如老板让你做一个旁路数据统计服务希望你每分钟统计下etcd里的服务、配置信息等这种场景其实对数据时效性要求并不高读请求可直接从节点的状态机获取数据。即便数据落后一点也不影响业务毕竟这是一个定时统计的旁路服务而已。
这种直接读状态机数据返回、无需通过Raft协议与集群进行交互的模式在etcd里叫做**串行(<strong><strong>Serializable**</strong>)读</strong>,它具有低延时、高吞吐量的特点,适合对数据一致性要求不高的场景。
我们再看一个**对数据敏感性高的场景**。
当你发布服务更新服务的镜像的时候提交的时候显示更新成功结果你一刷新页面发现显示的镜像的还是旧的再刷新又是新的这就会导致混乱。再比如说一个转账场景Alice给Bob转账成功钱被正常扣出一刷新页面发现钱又回来了这也是令人不可接受的。
以上的业务场景就对数据准确性要求极高了在etcd里面提供了一种线性读模式来解决对数据一致性要求高的场景。
**什么是线性读呢?**
你可以理解一旦一个值更新成功随后任何通过线性读的client都能及时访问到。虽然集群中有多个节点但client通过线性读就如访问一个节点一样。etcd默认读模式是线性读因为它需要经过Raft协议模块反应的是集群共识因此在延时和吞吐量上相比串行读略差一点适用于对数据一致性要求高的场景。
如果你的etcd读请求显示指定了是串行读就不会经过架构图流程中的流程三、四。默认是线性读因此接下来我们看看读请求进入线性读模块它是如何工作的。
### 线性读之ReadIndex
前面我们聊到串行读时提到它之所以能读到旧数据主要原因是Follower节点收到Leader节点同步的写请求后应用日志条目到状态机是个异步过程那么我们能否有一种机制在读取的时候确保最新的数据已经应用到状态机中
<img src="https://static001.geekbang.org/resource/image/1c/cc/1c065788051c6eaaee965575a04109cc.png" alt="">
其实这个机制就是叫ReadIndex它是在etcd 3.1中引入的我把简化后的原理图放在了上面。当收到一个线性读请求时它首先会从Leader获取集群最新的已提交的日志索引(committed index),如上图中的流程二所示。
Leader收到ReadIndex请求时为防止脑裂等异常场景会向Follower节点发送心跳确认一半以上节点确认Leader身份后才能将已提交的索引(committed index)返回给节点C(上图中的流程三)。
C节点则会等待直到状态机已应用索引(applied index)大于等于Leader的已提交索引时(committed Index)(上图中的流程四)然后去通知读请求数据已赶上Leader你可以去状态机中访问数据了(上图中的流程五)。
以上就是线性读通过ReadIndex机制保证数据一致性原理 当然还有其它机制也能实现线性读如在早期etcd 3.0中读请求通过走一遍Raft协议保证一致性 这种Raft log read机制依赖磁盘IO 性能相比ReadIndex较差。
总体而言KVServer模块收到线性读请求后通过架构图中流程三向Raft模块发起ReadIndex请求Raft模块将Leader最新的已提交日志索引封装在流程四的ReadState结构体通过channel层层返回给线性读模块线性读模块等待本节点状态机追赶上Leader进度追赶完成后就通知KVServer模块进行架构图中流程五与状态机中的MVCC模块进行进行交互了。
## MVCC
流程五中的多版本并发控制(Multiversion concurrency control)模块是为了解决上一讲我们提到etcd v2不支持保存key的历史版本、不支持多key事务等问题而产生的。
它核心由内存树形索引模块(treeIndex)和嵌入式的KV持久化存储库boltdb组成。
首先我们需要简单了解下boltdb它是个基于B+ tree实现的key-value键值库支持事务提供Get/Put等简易API给etcd操作。
那么etcd如何基于boltdb保存一个key的多个历史版本呢?
比如我们现在有以下方案方案1是一个key保存多个历史版本的值方案2每次修改操作生成一个新的版本号(revision)以版本号为key value为用户key-value等信息组成的结构体。
很显然方案1会导致value较大存在明显读写放大、并发冲突等问题而方案2正是etcd所采用的。boltdb的key是全局递增的版本号(revision)value是用户key、value等字段组合成的结构体然后通过treeIndex模块来保存用户key和版本号的映射关系。
treeIndex与boltdb关系如下面的读事务流程图所示从treeIndex中获取key hello的版本号再以版本号作为boltdb的key从boltdb中获取其value信息。
<img src="https://static001.geekbang.org/resource/image/4e/a3/4e2779c265c1da1f7209b5293e3789a3.png" alt="">
### treeIndex
treeIndex模块是基于Google开源的内存版btree库实现的为什么etcd选择上图中的B-tree数据结构保存用户key与版本号之间的映射关系而不是哈希表、二叉树呢在后面的课程中我会再和你介绍。
treeIndex模块只会保存用户的key和相关版本号信息用户key的value数据存储在boltdb里面相比ZooKeeper和etcd v2全内存存储etcd v3对内存要求更低。
简单介绍了etcd如何保存key的历史版本后架构图中流程六也就非常容易理解了 它需要从treeIndex模块中获取hello这个key对应的版本号信息。treeIndex模块基于B-tree快速查找此key返回此key对应的索引项keyIndex即可。索引项中包含版本号等信息。
### buffer
在获取到版本号信息后就可从boltdb模块中获取用户的key-value数据了。不过有一点你要注意并不是所有请求都一定要从boltdb获取数据。
etcd出于数据一致性、性能等考虑在访问boltdb前首先会从一个内存读事务buffer中二分查找你要访问key是否在buffer里面若命中则直接返回。
### boltdb
若buffer未命中此时就真正需要向boltdb模块查询数据了进入了流程七。
我们知道MySQL通过table实现不同数据逻辑隔离那么在boltdb是如何隔离集群元数据与用户数据的呢答案是bucket。boltdb里每个bucket类似对应MySQL一个表用户的key数据存放的bucket名字的是keyetcd MVCC元数据存放的bucket是meta。
因boltdb使用B+ tree来组织用户的key-value数据获取bucket key对象后通过boltdb的游标Cursor可快速在B+ tree找到key hello对应的value数据返回给client。
到这里,一个读请求之路执行完成。
## 小结
最后我们来小结一下一个读请求从client通过Round-robin负载均衡算法选择一个etcd server节点发出gRPC请求经过etcd server的KVServer模块、线性读模块、MVCC的treeIndex和boltdb模块紧密协作完成了一个读请求。
通过一个读请求我带你初步了解了etcd的基础架构以及各个模块之间是如何协作的。
在这过程中我想和你特别总结下client的节点故障自动转移和线性读。
一方面, client的通过负载均衡、错误处理等机制实现了etcd节点之间的故障的自动转移它可助你的业务实现服务高可用建议使用etcd 3.4分支的client版本。
另一方面我详细解释了etcd提供的两种读机制(串行读和线性读)原理和应用场景。通过线性读对业务而言访问多个节点的etcd集群就如访问一个节点一样简单能简洁、快速的获取到集群最新共识数据。
早期etcd线性读使用的Raft log read也就是说把读请求像写请求一样走一遍Raft的协议基于Raft的日志的有序性实现线性读。但此方案读涉及磁盘IO开销性能较差后来实现了ReadIndex读机制来提升读性能满足了Kubernetes等业务的诉求。
## 思考题
etcd在执行读请求过程中涉及磁盘IO吗如果涉及是什么模块在什么场景下会触发呢如果不涉及又是什么原因呢
你可以把你的思考和观点写在留言区里,我会在下一节课里给出我的答案。
感谢你阅读,也欢迎你把这篇文章分享给更多的朋友一起阅读,我们下节课见。

View File

@@ -0,0 +1,244 @@
<audio id="audio" title="03 | 基础架构etcd一个写请求是如何执行的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/69/02/695df87f7b86f380b30f26a89d285f02.mp3"></audio>
你好,我是唐聪。
在上一节课里我通过分析etcd的一个读请求执行流程给你介绍了etcd的基础架构让你初步了解了在etcd的读请求流程中各个模块是如何紧密协作执行查询语句返回数据给client。
那么etcd一个写请求执行流程又是怎样的呢在执行写请求过程中如果进程crash了如何保证数据不丢、命令不重复执行呢
今天我就和你聊聊etcd写过程中是如何解决这些问题的。希望通过这节课让你了解一个key-value写入的原理对etcd的基础架构中涉及写请求相关的模块有一定的理解同时能触类旁通当你在软件项目开发过程中遇到类似数据安全、幂等性等问题时能设计出良好的方案解决它。
## 整体架构
<img src="https://static001.geekbang.org/resource/image/8b/72/8b6dfa84bf8291369ea1803387906c72.png" alt="">
为了让你能够更直观地理解etcd的写请求流程我在如上的架构图中用序号标识了下面的一个put hello为world的写请求的简要执行流程帮助你从整体上快速了解一个写请求的全貌。
```
etcdctl put hello world --endpoints http://127.0.0.1:2379
OK
```
首先client端通过负载均衡算法选择一个etcd节点发起gRPC调用。然后etcd节点收到请求后经过gRPC拦截器、Quota模块后进入KVServer模块KVServer模块向Raft模块提交一个提案提案内容为“大家好请使用put方法执行一个key为hellovalue为world的命令”。
随后此提案通过RaftHTTP网络模块转发、经过集群多数节点持久化后状态会变成已提交etcdserver从Raft模块获取已提交的日志条目传递给Apply模块Apply模块通过MVCC模块执行提案内容更新状态机。
与读流程不一样的是写流程还涉及Quota、WAL、Apply三个模块。crash-safe及幂等性也正是基于WAL和Apply流程的consistent index等实现的因此今天我会重点和你介绍这三个模块。
下面就让我们沿着写请求执行流程图从0到1分析一个key-value是如何安全、幂等地持久化到磁盘的。
## Quota模块
首先是流程一client端发起gRPC调用到etcd节点和读请求不一样的是写请求需要经过流程二db配额Quota模块它有什么功能呢
我们先从此模块的一个常见错误说起你在使用etcd过程中是否遇到过"etcdserver: mvcc: database space exceeded"错误呢?
我相信只要你使用过etcd或者Kubernetes大概率见过这个错误。它是指当前etcd db文件大小超过了配额当出现此错误后你的整个集群将不可写入只读对业务的影响非常大。
哪些情况会触发这个错误呢?
一方面默认db配额仅为2G当你的业务数据、写入QPS、Kubernetes集群规模增大后你的etcd db大小就可能会超过2G。
另一方面我们知道etcd v3是个MVCC数据库保存了key的历史版本当你未配置压缩策略的时候随着数据不断写入db大小会不断增大导致超限。
最后你要特别注意的是如果你使用的是etcd 3.2.10之前的旧版本请注意备份可能会触发boltdb的一个Bug它会导致db大小不断上涨最终达到配额限制。
了解完触发Quota限制的原因后我们再详细了解下Quota模块它是如何工作的。
当etcd server收到put/txn等写请求的时候会首先检查下当前etcd db大小加上你请求的key-value大小之和是否超过了配额quota-backend-bytes
如果超过了配额它会产生一个告警Alarm请求告警类型是NO SPACE并通过Raft日志同步给其它节点告知db无空间了并将告警持久化存储到db中。
最终无论是API层gRPC模块还是负责将Raft侧已提交的日志条目应用到状态机的Apply模块都拒绝写入集群只读。
那遇到这个错误时应该如何解决呢?
首先当然是调大配额。具体多大合适呢etcd社区建议不超过8G。遇到过这个错误的你是否还记得为什么当你把配额quota-backend-bytes调大后集群依然拒绝写入呢?
原因就是我们前面提到的NO SPACE告警。Apply模块在执行每个命令的时候都会去检查当前是否存在NO SPACE告警如果有则拒绝写入。所以还需要你额外发送一个取消告警etcdctl alarm disarm的命令以消除所有告警。
其次你需要检查etcd的压缩compact配置是否开启、配置是否合理。etcd保存了一个key所有变更历史版本如果没有一个机制去回收旧的版本那么内存和db大小就会一直膨胀在etcd里面压缩模块负责回收旧版本的工作。
压缩模块支持按多种方式回收旧版本比如保留最近一段时间内的历史版本。不过你要注意它仅仅是将旧版本占用的空间打个空闲Free标记后续新的数据写入的时候可复用这块空间而无需申请新的空间。
如果你需要回收空间减少db大小得使用碎片整理defrag 它会遍历旧的db文件数据写入到一个新的db文件。但是它对服务性能有较大影响不建议你在生产集群频繁使用。
最后你需要注意配额quota-backend-bytes的行为默认'0'就是使用etcd默认的2GB大小你需要根据你的业务场景适当调优。如果你填的是个小于0的数就会禁用配额功能这可能会让你的db大小处于失控导致性能下降不建议你禁用配额。
## KVServer模块
通过流程二的配额检查后请求就从API层转发到了流程三的KVServer模块的put方法我们知道etcd是基于Raft算法实现节点间数据复制的因此它需要将put写请求内容打包成一个提案消息提交给Raft模块。不过KVServer模块在提交提案前还有如下的一系列检查和限速。
### Preflight Check
为了保证集群稳定性避免雪崩任何提交到Raft模块的请求都会做一些简单的限速判断。如下面的流程图所示首先如果Raft模块已提交的日志索引committed index比已应用到状态机的日志索引applied index超过了5000那么它就返回一个"etcdserver: too many requests"错误给client。
<img src="https://static001.geekbang.org/resource/image/dc/54/dc8e373e06f2ab5f63a7948c4a6c8554.png" alt="">
然后它会尝试去获取请求中的鉴权信息若使用了密码鉴权、请求中携带了token如果token无效则返回"auth: invalid auth token"错误给client。
其次它会检查你写入的包大小是否超过默认的1.5MB 如果超过了会返回"etcdserver: request is too large"错误给给client。
### Propose
最后通过一系列检查之后会生成一个唯一的ID将此请求关联到一个对应的消息通知channel然后向Raft模块发起Propose一个提案Proposal提案内容为“大家好请使用put方法执行一个key为hellovalue为world的命令”也就是整体架构图里的流程四。
向Raft模块发起提案后KVServer模块会等待此put请求等待写入结果通过消息通知channel返回或者超时。etcd默认超时时间是7秒5秒磁盘IO延时+2*1秒竞选超时时间如果一个请求超时未返回结果则可能会出现你熟悉的etcdserver: request timed out错误。
## WAL模块
Raft模块收到提案后如果当前节点是Follower它会转发给Leader只有Leader才能处理写请求。Leader收到提案后通过Raft模块输出待转发给Follower节点的消息和待持久化的日志条目日志条目则封装了我们上面所说的put hello提案内容。
etcdserver从Raft模块获取到以上消息和日志条目后作为Leader它会将put提案消息广播给集群各个节点同时需要把集群Leader任期号、投票信息、已提交索引、提案内容持久化到一个WALWrite Ahead Log日志文件中用于保证集群的一致性、可恢复性也就是我们图中的流程五模块。
WAL日志结构是怎样的呢
<img src="https://static001.geekbang.org/resource/image/47/8d/479dec62ed1c31918a7c6cab8e6aa18d.png" alt="">
上图是WAL结构它由多种类型的WAL记录顺序追加写入组成每个记录由类型、数据、循环冗余校验码组成。不同类型的记录通过Type字段区分Data为对应记录内容CRC为循环校验码信息。
WAL记录类型目前支持5种分别是文件元数据记录、日志条目记录、状态信息记录、CRC记录、快照记录
- 文件元数据记录包含节点ID、集群ID信息它在WAL文件创建的时候写入
- 日志条目记录包含Raft日志信息如put提案内容
- 状态信息记录,包含集群的任期号、节点投票信息等,一个日志文件中会有多条,以最后的记录为准;
- CRC记录包含上一个WAL文件的最后的CRC循环冗余校验码信息 在创建、切割WAL文件时作为第一条记录写入到新的WAL文件 用于校验数据文件的完整性、准确性等;
- 快照记录包含快照的任期号、日志索引信息,用于检查快照文件的准确性。
WAL模块又是如何持久化一个put提案的日志条目类型记录呢?
首先我们来看看put写请求如何封装在Raft日志条目里面。下面是Raft日志条目的数据结构信息它由以下字段组成
- Term是Leader任期号随着Leader选举增加
- Index是日志条目的索引单调递增增加
- Type是日志类型比如是普通的命令日志EntryNormal还是集群配置变更日志EntryConfChange
- Data保存我们上面描述的put提案内容。
```
type Entry struct {
Term uint64 `protobuf:&quot;varint2optname=Term&quot; json:&quot;Term&quot;`
Index uint64 `protobuf:&quot;varint3optname=Index&quot; json:&quot;Index&quot;`
Type EntryType `protobuf:&quot;varint1optname=Typeenum=Raftpb.EntryType&quot; json:&quot;Type&quot;`
Data []byte `protobuf:&quot;bytes4optname=Data&quot; json:&quot;Dataomitempty&quot;`
}
```
了解完Raft日志条目数据结构后我们再看WAL模块如何持久化Raft日志条目。它首先先将Raft日志条目内容含任期号、索引、提案内容序列化后保存到WAL记录的Data字段 然后计算Data的CRC值设置Type为Entry Type 以上信息就组成了一个完整的WAL记录。
最后计算WAL记录的长度顺序先写入WAL长度Len Field然后写入记录内容调用fsync持久化到磁盘完成将日志条目保存到持久化存储中。
当一半以上节点持久化此日志条目后, Raft模块就会通过channel告知etcdserver模块put提案已经被集群多数节点确认提案状态为已提交你可以执行此提案内容了。
于是进入流程六etcdserver模块从channel取出提案内容添加到先进先出FIFO调度队列随后通过Apply模块按入队顺序异步、依次执行提案内容。
## Apply模块
执行put提案内容对应我们架构图中的流程七其细节图如下。那么Apply模块是如何执行put请求的呢若put请求提案在执行流程七的时候etcd突然crash了 重启恢复的时候etcd是如何找回异常提案再次执行的呢
<img src="https://static001.geekbang.org/resource/image/7f/5b/7f13edaf28yy7a6698e647104771235b.png" alt="">
核心就是我们上面介绍的WAL日志因为提交给Apply模块执行的提案已获得多数节点确认、持久化etcd重启时会从WAL中解析出Raft日志条目内容追加到Raft日志的存储中并重放已提交的日志提案给Apply模块执行。
然而这又引发了另外一个问题,如何确保幂等性,防止提案重复执行导致数据混乱呢?
我们在上一节课里讲到etcd是个MVCC数据库每次更新都会生成新的版本号。如果没有幂等性保护同样的命令一部分节点执行一次一部分节点遭遇异常故障后执行多次则系统的各节点一致性状态无法得到保证导致数据混乱这是严重故障。
因此etcd必须要确保幂等性。怎么做呢Apply模块从Raft模块获得的日志条目信息里是否有唯一的字段能标识这个提案
答案就是我们上面介绍Raft日志条目中的索引index字段。日志条目索引是全局单调递增的每个日志条目索引对应一个提案 如果一个命令执行后我们在db里面也记录下当前已经执行过的日志条目索引是不是就可以解决幂等性问题呢
是的。但是这还不够安全如果执行命令的请求更新成功了更新index的请求却失败了是不是一样会导致异常
因此我们在实现上,还需要将两个操作作为原子性事务提交,才能实现幂等。
正如我们上面的讨论的这样etcd通过引入一个consistent index的字段来存储系统当前已经执行过的日志条目索引实现幂等性。
Apply模块在执行提案内容前首先会判断当前提案是否已经执行过了如果执行了则直接返回若未执行同时无db配额满告警则进入到MVCC模块开始与持久化存储模块打交道。
## MVCC
Apply模块判断此提案未执行后就会调用MVCC模块来执行提案内容。MVCC主要由两部分组成一个是内存索引模块treeIndex保存key的历史版本号信息另一个是boltdb模块用来持久化存储key-value数据。那么MVCC模块执行put hello为world命令时它是如何构建内存索引和保存哪些数据到db呢
### treeIndex
首先我们来看MVCC的索引模块treeIndex当收到更新key hello为world的时候此key的索引版本号信息是怎么生成的呢需要维护、持久化存储一个全局版本号吗
版本号revision在etcd里面发挥着重大作用它是etcd的逻辑时钟。etcd启动的时候默认版本号是1随着你对key的增、删、改操作而全局单调递增。
因为boltdb中的key就包含此信息所以etcd并不需要再去持久化一个全局版本号。我们只需要在启动的时候从最小值1开始枚举到最大值未读到数据的时候则结束最后读出来的版本号即是当前etcd的最大版本号currentRevision。
MVCC写事务在执行put hello为world的请求时会基于currentRevision自增生成新的revision如{2,0}然后从treeIndex模块中查询key的创建版本号、修改次数信息。这些信息将填充到boltdb的value中同时将用户的hello key和revision等信息存储到B-tree也就是下面简易写事务图的流程一整体架构图中的流程八。
<img src="https://static001.geekbang.org/resource/image/a1/ff/a19a06d8f4cc5e488a114090d84116ff.png" alt="">
### boltdb
MVCC写事务自增全局版本号后生成的revision{2,0}它就是boltdb的key通过它就可以往boltdb写数据了进入了整体架构图中的流程九。
boltdb上一篇我们提过它是一个基于B+tree实现的key-value嵌入式db它通过提供桶bucket机制实现类似MySQL表的逻辑隔离。
在etcd里面你通过put/txn等KV API操作的数据全部保存在一个名为key的桶里面这个key桶在启动etcd的时候会自动创建。
除了保存用户KV数据的key桶etcd本身及其它功能需要持久化存储的话都会创建对应的桶。比如上面我们提到的etcd为了保证日志的幂等性保存了一个名为consistent index的变量在db里面它实际上就存储在元数据meta桶里面。
那么写入boltdb的value含有哪些信息呢
写入boltdb的value 并不是简单的"world"如果只存一个用户value索引又是保存在易失的内存上那重启etcd后我们就丢失了用户的key名无法构建treeIndex模块了。
因此为了构建索引和支持Lease等特性etcd会持久化以下信息:
- key名称
- key创建时的版本号create_revision、最后一次修改时的版本号mod_revision、key自身修改的次数version
- value值
- 租约信息(后面介绍)。
boltdb value的值就是将含以上信息的结构体序列化成的二进制数据然后通过boltdb提供的put接口etcd就快速完成了将你的数据写入boltdb对应上面简易写事务图的流程二。
但是put调用成功就能够代表数据已经持久化到db文件了吗
这里需要注意的是在以上流程中etcd并未提交事务commit因此数据只更新在boltdb所管理的内存数据结构中。
事务提交的过程包含B+tree的平衡、分裂将boltdb的脏数据dirty page、元数据信息刷新到磁盘因此事务提交的开销是昂贵的。如果我们每次更新都提交事务etcd写性能就会较差。
那么解决的办法是什么呢etcd的解决方案是合并再合并。
首先boltdb key是版本号put/delete操作时都会基于当前版本号递增生成新的版本号因此属于顺序写入可以调整boltdb的bucket.FillPercent参数使每个page填充更多数据减少page的分裂次数并降低db空间。
其次etcd通过合并多个写事务请求通常情况下是异步机制定时默认每隔100ms将批量事务一次性提交pending事务过多才会触发同步提交 从而大大提高吞吐量,对应上面简易写事务图的流程三。
但是这优化又引发了另外的一个问题, 因为事务未提交读请求可能无法从boltdb获取到最新数据。
为了解决这个问题etcd引入了一个bucket buffer来保存暂未提交的事务数据。在更新boltdb的时候etcd也会同步数据到bucket buffer。因此etcd处理读请求的时候会优先从bucket buffer里面读取其次再从boltdb读通过bucket buffer实现读写性能提升同时保证数据一致性。
## 小结
最后我们来小结一下今天我给你介绍了etcd的写请求流程重点介绍了Quota、WAL、Apply模块。
首先我们介绍了Quota模块工作原理和我们熟悉的database space exceeded错误触发原因写请求导致db大小增加、compact策略不合理、boltdb Bug等都会导致db大小超限。
其次介绍了WAL模块的存储结构它由一条条记录顺序写入组成每个记录含有Type、CRC、Data每个提案被提交前都会被持久化到WAL文件中以保证集群的一致性和可恢复性。
随后我们介绍了Apply模块基于consistent index和事务实现了幂等性保证了节点在异常情况下不会重复执行重放的提案。
最后我们介绍了MVCC模块是如何维护索引版本号、重启后如何从boltdb模块中获取内存索引结构的。以及etcd通过异步、批量提交事务机制以提升写QPS和吞吐量。
通过以上介绍希望你对etcd的一个写语句执行流程有个初步的理解明白WAL模块、Apply模块、MVCC模块三者是如何相互协作的从而实现在节点遭遇crash等异常情况下不丢任何已提交的数据、不重复执行任何提案。
## 思考题
expensive read请求如Kubernetes场景中查询大量pod会影响写请求的性能吗
你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾给出我的答案。
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
## 02思考题答案
上节课我给大家留了一个思考题评论中有同学说buffer没读到从boltdb读时会产生磁盘I/O这是一个常见误区。
实际上etcd在启动的时候会通过mmap机制将etcd db文件映射到etcd进程地址空间并设置了mmap的MAP_POPULATE flag它会告诉Linux内核预读文件Linux内核会将文件内容拷贝到物理内存中此时会产生磁盘I/O。节点内存足够的请求下后续处理读请求过程中就不会产生磁盘I/IO了。
若etcd节点内存不足可能会导致db文件对应的内存页被换出当读请求命中的页未在内存中时就会产生缺页异常导致读过程中产生磁盘IO你可以通过观察etcd进程的majflt字段来判断etcd是否产生了主缺页中断。

View File

@@ -0,0 +1,228 @@
<audio id="audio" title="04 | Raft协议etcd如何实现高可用、数据强一致的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b0/a1/b093f933e3244f08d0c5a9f0e30dbda1.mp3"></audio>
你好,我是唐聪。
在前面的etcd读写流程学习中我和你多次提到了etcd是基于Raft协议实现高可用、数据强一致性的。
那么etcd是如何基于Raft来实现高可用、数据强一致性的呢
这节课我们就以上一节中的hello写请求为案例深入分析etcd在遇到Leader节点crash等异常后Follower节点如何快速感知到异常并高效选举出新的Leader对外提供高可用服务的。
同时我将通过一个日志复制整体流程图为你介绍etcd如何保障各节点数据一致性并介绍Raft算法为了确保数据一致性、完整性对Leader选举和日志复制所增加的一系列安全规则。希望通过这节课让你了解etcd在节点故障、网络分区等异常场景下是如何基于Raft算法实现高可用、数据强一致的。
## 如何避免单点故障
在介绍Raft算法之前我们首先了解下它的诞生背景Raft解决了分布式系统什么痛点呢
首先我们回想下,早期我们使用的数据存储服务,它们往往是部署在单节点上的。但是单节点存在单点故障,一宕机就整个服务不可用,对业务影响非常大。
随后,为了解决单点问题,软件系统工程师引入了数据复制技术,实现多副本。通过数据复制方案,一方面我们可以提高服务可用性,避免单点故障。另一方面,多副本可以提升读吞吐量、甚至就近部署在业务所在的地理位置,降低访问延迟。
**多副本复制是如何实现的呢?**
多副本常用的技术方案主要有主从复制和去中心化复制。主从复制又分为全同步复制、异步复制、半同步复制比如MySQL/Redis单机主备版就基于主从复制实现的。
**全同步复制**是指主收到一个写请求后,必须等待全部从节点确认返回后,才能返回给客户端成功。因此如果一个从节点故障,整个系统就会不可用。这种方案为了保证多副本的一致性,而牺牲了可用性,一般使用不多。
**异步复制**是指主收到一个写请求后可及时返回给client异步将请求转发给各个副本若还未将请求转发到副本前就故障了则可能导致数据丢失但是可用性是最高的。
**半同步复制**介于全同步复制、异步复制之间,它是指主收到一个写请求后,至少有一个副本接收数据后,就可以返回给客户端成功,在数据一致性、可用性上实现了平衡和取舍。
跟主从复制相反的就是**去中心化复制**它是指在一个n副本节点集群中任意节点都可接受写请求但一个成功的写入需要w个节点确认读取也必须查询至少r个节点。
你可以根据实际业务场景对数据一致性的敏感度设置合适w/r参数。比如你希望每次写入后任意client都能读取到新值如果n是3个副本你可以将w和r设置为2这样当你读两个节点时候必有一个节点含有最近写入的新值这种读我们称之为法定票数读quorum read
AWS的Dynamo系统就是基于去中心化的复制算法实现的。它的优点是节点角色都是平等的降低运维复杂度可用性更高。但是缺陷是去中心化复制势必会导致各种写入冲突业务需要关注冲突处理。
从以上分析中,为了解决单点故障,从而引入了多副本。但基于复制算法实现的数据库,为了保证服务可用性,大多数提供的是最终一致性,总而言之,不管是主从复制还是异步复制,都存在一定的缺陷。
**如何解决以上复制算法的困境呢?**
答案就是共识算法,它最早是基于复制状态机背景下提出来的。 下图是复制状态机的结构引用自Raft paper 它由共识模块、日志模块、状态机组成。通过共识模块保证各个节点日志的一致性,然后各个节点基于同样的日志、顺序执行指令,最终各个复制状态机的结果实现一致。
<img src="https://static001.geekbang.org/resource/image/3y/eb/3yy3fbc1ab564e3af9ac9223db1435eb.png" alt="">
共识算法的祖师爷是Paxos 但是由于它过于复杂难于理解工程实践上也较难落地导致在工程界落地较慢。standford大学的Diego提出的Raft算法正是为了可理解性、易实现而诞生的它通过问题分解将复杂的共识问题拆分成三个子问题分别是
- Leader选举Leader故障后集群能快速选出新Leader
- 日志复制, 集群只有Leader能写入日志 Leader负责复制日志到Follower节点并强制Follower节点与自己保持相同
- 安全性一个任期内集群只能产生一个Leader、已提交的日志条目在发生Leader选举时一定会存在更高任期的新Leader日志中、各个节点的状态机应用的任意位置的日志条目内容应一样等。
下面我以实际场景为案例分别和你深入讨论这三个子问题看看Raft是如何解决这三个问题以及在etcd中的应用实现。
## Leader选举
当etcd server收到client发起的put hello写请求后KV模块会向Raft模块提交一个put提案我们知道只有集群Leader才能处理写提案如果此时集群中无Leader 整个请求就会超时。
那么Leader是怎么诞生的呢Leader crash之后其他节点如何竞选呢
首先在Raft协议中它定义了集群中的如下节点状态任何时刻每个节点肯定处于其中一个状态
- Follower跟随者 同步从Leader收到的日志etcd启动的时候默认为此状态
- Candidate竞选者可以发起Leader选举
- Leader集群领导者 唯一性拥有同步日志的特权需定时广播心跳给Follower节点以维持领导者身份。
<img src="https://static001.geekbang.org/resource/image/a5/09/a5a210eec289d8e4e363255906391009.png" alt="">
上图是节点状态变化关系图当Follower节点接收Leader节点心跳消息超时后它会转变成Candidate节点并可发起竞选Leader投票若获得集群多数节点的支持后它就可转变成Leader节点。
下面我以Leader crash场景为案例给你详细介绍一下etcd Leader选举原理。
假设集群总共3个节点A节点为LeaderB、C节点为Follower。
<img src="https://static001.geekbang.org/resource/image/a2/59/a20ba5b17de79d6ce8c78a712a364359.png" alt="">
如上Leader选举图左边部分所示 正常情况下Leader节点会按照心跳间隔时间定时广播心跳消息MsgHeartbeat消息给Follower节点以维持Leader身份。 Follower收到后回复心跳应答包消息MsgHeartbeatResp消息给Leader。
细心的你可能注意到上图中的Leader节点下方有一个任期号term 它具有什么样的作用呢?
这是因为Raft将时间划分成一个个任期任期用连续的整数表示每个任期从一次选举开始赢得选举的节点在该任期内充当Leader的职责随着时间的消逝集群可能会发生新的选举任期号也会单调递增。
通过任期号可以比较各个节点的数据新旧、识别过期的Leader等它在Raft算法中充当逻辑时钟发挥着重要作用。
了解完正常情况下Leader维持身份的原理后我们再看异常情况下也就Leader crash后etcd是如何自愈的呢
如上Leader选举图右边部分所示当Leader节点异常后Follower节点会接收Leader的心跳消息超时当超时时间大于竞选超时时间后它们会进入Candidate状态。
这里要提醒下你etcd默认心跳间隔时间heartbeat-interval是100ms 默认竞选超时时间election timeout是1000ms 你需要根据实际部署环境、业务场景适当调优否则就很可能会频繁发生Leader选举切换导致服务稳定性下降后面我们实践篇会再详细介绍。
进入Candidate状态的节点会立即发起选举流程自增任期号投票给自己并向其他节点发送竞选Leader投票消息MsgVote
C节点收到Follower B节点竞选Leader消息后这时候可能会出现如下两种情况
- 第一种情况是C节点判断B节点的数据至少和自己一样新、B节点任期号大于C当前任期号、并且C未投票给其他候选者就可投票给B。这时B节点获得了集群多数节点支持于是成为了新的Leader。
- 第二种情况是恰好C也心跳超时超过竞选时间了它也发起了选举并投票给了自己那么它将拒绝投票给B这时谁也无法获取集群多数派支持只能等待竞选超时开启新一轮选举。Raft为了优化选票被瓜分导致选举失败的问题引入了随机数每个节点等待发起选举的时间点不一致优雅的解决了潜在的竞选活锁同时易于理解。
Leader选出来后它什么时候又会变成Follower状态呢 从上面的状态转换关系图中你可以看到如果现有Leader发现了新的Leader任期号那么它就需要转换到Follower节点。A节点crash后再次启动成为Follower假设因为网络问题无法连通B、C节点这时候根据状态图我们知道它将不停自增任期号发起选举。等A节点网络异常恢复后那么现有Leader收到了新的任期号就会触发新一轮Leader选举影响服务的可用性。
然而A节点的数据是远远落后B、C的是无法获得集群Leader地位的发起的选举无效且对集群稳定性有伤害。
那如何避免以上场景中的无效的选举呢?
在etcd 3.4中etcd引入了一个PreVote参数默认false可以用来启用PreCandidate状态解决此问题如下图所示。Follower在转换成Candidate状态前先进入PreCandidate状态不自增任期号 发起预投票。若获得集群多数节点认可确定有概率成为Leader才能进入Candidate状态发起选举流程。
<img src="https://static001.geekbang.org/resource/image/16/06/169ae84055byya38b616d2e71cfb9706.png" alt="">
因A节点数据落后较多预投票请求无法获得多数节点认可因此它就不会进入Candidate状态导致集群重新选举。
这就是Raft Leader选举核心原理使用心跳机制维持Leader身份、触发Leader选举etcd基于它实现了高可用只要集群一半以上节点存活、可相互通信Leader宕机后就能快速选举出新的Leader继续对外提供服务。
## 日志复制
假设在上面的Leader选举流程中B成为了新的Leader它收到put提案后它是如何将日志同步给Follower节点的呢 什么时候它可以确定一个日志条目为已提交通知etcdserver模块应用日志条目指令到状态机呢
这就涉及到Raft日志复制原理为了帮助你理解日志复制的原理下面我给你画了一幅Leader收到put请求后向Follower节点复制日志的整体流程图简称流程图在图中我用序号给你标识了核心流程。
我将结合流程图、后面的Raft的日志图和你简要分析Leader B收到put hello为world的请求后是如何将此请求同步给其他Follower节点的。
<img src="https://static001.geekbang.org/resource/image/a5/83/a57a990cff7ca0254368d6351ae5b983.png" alt="">
首先Leader收到client的请求后etcdserver的KV模块会向Raft模块提交一个put hello为world提案消息流程图中的序号2流程 它的消息类型是MsgProp。
Leader的Raft模块获取到MsgProp提案消息后为此提案生成一个日志条目追加到未持久化、不稳定的Raft日志中随后会遍历集群Follower列表和进度信息为每个Follower生成追加MsgApp类型的RPC消息此消息中包含待复制给Follower的日志条目。
这里就出现两个疑问了。第一Leader是如何知道从哪个索引位置发送日志条目给Follower以及Follower已复制的日志最大索引是多少呢第二日志条目什么时候才会追加到稳定的Raft日志中呢Raft模块负责持久化吗
首先我来给你介绍下什么是Raft日志。下图是Raft日志复制过程中的日志细节图简称日志图1。
在日志图中,最上方的是日志条目序号/索引日志由有序号标识的一个个条目组成每个日志条目内容保存了Leader任期号和提案内容。最开始的时候A节点是Leader任期号为1A节点crash后B节点通过选举成为新的Leader 任期号为2。
日志图1描述的是hello日志条目未提交前的各节点Raft日志状态。
<img src="https://static001.geekbang.org/resource/image/3d/87/3dd2b6042e6e0cc86f96f24764b7f587.png" alt="">
我们现在就可以来回答第一个疑问了。Leader会维护两个核心字段来追踪各个Follower的进度信息一个字段是NextIndex 它表示Leader发送给Follower节点的下一个日志条目索引。一个字段是MatchIndex 它表示Follower节点已复制的最大日志条目的索引比如上面的日志图1中C节点的已复制最大日志条目索引为5A节点为4。
我们再看第二个疑问。etcd Raft模块设计实现上抽象了网络、存储、日志等模块它本身并不会进行网络、存储相关的操作上层应用需结合自己业务场景选择内置的模块或自定义实现网络、存储、日志等模块。
上层应用通过Raft模块的输出接口如Ready结构获取到待持久化的日志条目和待发送给Peer节点的消息后如上面的MsgApp日志消息需持久化日志条目到自定义的WAL模块通过自定义的网络模块将消息发送给Peer节点。
日志条目持久化到稳定存储中后这时候你就可以将日志条目追加到稳定的Raft日志中。即便这个日志是内存存储节点重启时也不会丢失任何日志条目因为WAL模块已持久化此日志条目可通过它重建Raft日志。
etcd Raft模块提供了一个内置的内存存储MemoryStorage模块实现etcd使用的就是它Raft日志条目保存在内存中。网络模块并未提供内置的实现etcd基于HTTP协议实现了peer节点间的网络通信并根据消息类型支持选择pipeline、stream等模式发送显著提高了网络吞吐量、降低了延时。
解答完以上两个疑问后我们继续分析etcd是如何与Raft模块交互获取待持久化的日志条目和发送给peer节点的消息。
正如刚刚讲到的Raft模块输入是Msg消息输出是一个Ready结构它包含待持久化的日志条目、发送给peer节点的消息、已提交的日志条目内容、线性查询结果等Raft输出核心信息。
etcdserver模块通过channel从Raft模块获取到Ready结构后流程图中的序号3流程因B节点是Leader它首先会通过基于HTTP协议的网络模块将追加日志条目消息MsgApp广播给Follower并同时将待持久化的日志条目持久化到WAL文件中流程图中的序号4流程最后将日志条目追加到稳定的Raft日志存储中流程图中的序号5流程
各个Follower收到追加日志条目MsgApp消息并通过安全检查后它会持久化消息到WAL日志中并将消息追加到Raft日志存储随后会向Leader回复一个应答追加日志条目MsgAppResp的消息告知Leader当前已复制的日志最大索引流程图中的序号6流程
Leader收到应答追加日志条目MsgAppResp消息后会将Follower回复的已复制日志最大索引更新到跟踪Follower进展的Match Index字段如下面的日志图2中的Follower C MatchIndex为6Follower A为5日志图2描述的是hello日志条目提交后的各节点Raft日志状态。
<img src="https://static001.geekbang.org/resource/image/eb/63/ebbf739a94f9300a85f21da7e55f1e63.png" alt="">
最后Leader根据Follower的MatchIndex信息计算出一个位置如果这个位置已经被一半以上节点持久化那么这个位置之前的日志条目都可以被标记为已提交。
在我们这个案例中日志图2里6号索引位置之前的日志条目已被多数节点复制那么他们状态都可被设置为已提交。Leader可通过在发送心跳消息MsgHeartbeat给Follower节点时告知它已经提交的日志索引位置。
最后各个节点的etcdserver模块可通过channel从Raft模块获取到已提交的日志条目流程图中的序号7流程应用日志条目内容到存储状态机流程图中的序号8流程返回结果给client。
通过以上流程Leader就完成了同步日志条目给Follower的任务一个日志条目被确定为已提交的前提是它需要被Leader同步到一半以上节点上。以上就是etcd Raft日志复制的核心原理。
## 安全性
介绍完Leader选举和日志复制后最后我们再来看看Raft是如何保证安全性的。
如果在上面的日志图2中Leader B在应用日志指令put hello为world到状态机并返回给client成功后突然crash了那么Follower A和C是否都有资格选举成为Leader呢
从日志图2中我们可以看到如果A成为了Leader那么就会导致数据丢失因为它并未含有刚刚client已经写入成功的put hello为world指令。
Raft算法如何确保面对这类问题时不丢数据和各节点数据一致性呢
这就是Raft的第三个子问题需要解决的。Raft通过给选举和日志复制增加一系列规则来实现Raft算法的安全性。
### 选举规则
当节点收到选举投票的时候,需检查候选者的最后一条日志中的任期号,若小于自己则拒绝投票。如果任期号相同,日志却比自己短,也拒绝为其投票。
比如在日志图2中Folllower A和C任期号相同但是Follower C的数据比Follower A要长那么在选举的时候Follower C将拒绝投票给A 因为它的数据不是最新的。
同时对于一个给定的任期号最多只会有一个leader被选举出来leader的诞生需获得集群一半以上的节点支持。每个节点在同一个任期内只能为一个节点投票节点需要将投票信息持久化防止异常重启后再投票给其他节点。
通过以上规则就可防止日志图2中的Follower A节点成为Leader。
### 日志复制规则
在日志图2中Leader B返回给client成功后若突然crash了此时可能还并未将6号日志条目已提交的消息通知到Follower A和C那么如何确保6号日志条目不被新Leader删除呢 同时在etcd集群运行过程中Leader节点若频繁发生crash后可能会导致Follower节点与Leader节点日志条目冲突如何保证各个节点的同Raft日志位置含有同样的日志条目
以上各类异常场景的安全性是通过Raft算法中的Leader完全特性和只附加原则、日志匹配等安全机制来保证的。
**Leader完全特性**是指如果某个日志条目在某个任期号中已经被提交那么这个条目必然出现在更大任期号的所有Leader中。
Leader只能追加日志条目不能删除已持久化的日志条目**只附加原则**因此Follower C成为新Leader后会将前任的6号日志条目复制到A节点。
为了保证各个节点日志一致性Raft算法在追加日志的时候引入了一致性检查。Leader在发送追加日志RPC消息时会把新的日志条目紧接着之前的条目的索引位置和任期号包含在里面。Follower节点会检查相同索引位置的任期号是否与Leader一致一致才能追加这就是**日志匹配特性**。它本质上是一种归纳法一开始日志空满足匹配特性随后每增加一个日志条目时都要求上一个日志条目信息与Leader一致那么最终整个日志集肯定是一致的。
通过以上的Leader选举限制、Leader完全特性、只附加原则、日志匹配等安全特性Raft就实现了一个可严格通过数学反证法、归纳法证明的高可用、一致性算法为etcd的安全性保驾护航。
## 小结
最后我们来小结下今天的内容。我从如何避免单点故障说起,给你介绍了分布式系统中实现多副本技术的一系列方案,从主从复制到去中心化复制、再到状态机、共识算法,让你了解了各个方案的优缺点,以及主流存储产品的选择。
Raft虽然诞生晚但它却是共识算法里面在工程界应用最广泛的。它将一个复杂问题拆分成三个子问题分别是Leader选举、日志复制和安全性。
Raft通过心跳机制、随机化等实现了Leader选举只要集群半数以上节点存活可相互通信etcd就可对外提供高可用服务。
Raft日志复制确保了etcd多节点间的数据一致性我通过一个etcd日志复制整体流程图为你详细介绍了etcd写请求从提交到Raft模块到被应用到状态机执行的各个流程剖析了日志复制的核心原理即一个日志条目只有被Leader同步到一半以上节点上此日志条目才能称之为成功复制、已提交。Raft的安全性通过对Leader选举和日志复制增加一系列规则保证了整个集群的一致性、完整性。
## 思考题
好了,这节课到这里也就结束了,最后我给你留了一个思考题。
哪些场景会出现Follower日志与Leader冲突我们知道etcd WAL模块只能持续追加日志条目那冲突后Follower是如何删除无效的日志条目呢
感谢你阅读,也欢迎你把这篇文章分享给更多的朋友一起阅读。
## 03思考题答案
在上一节课中我给大家留了一个思考题expensive request是否影响写请求性能。要搞懂这个问题我们得回顾下etcd读写性能优化历史。
在etcd 3.0中线性读请求需要走一遍Raft协议持久化到WAL日志中因此读性能非常差写请求肯定也会被影响。
在etcd 3.1中引入了ReadIndex机制提升读性能读请求无需再持久化到WAL中。
在etcd 3.2中, 优化思路转移到了MVCC/boltdb模块boltdb的事务锁由粗粒度的互斥锁优化成读写锁实现“N reads or 1 write”的并行同时引入了buffer来提升吞吐量。问题就出在这个buffer读事务会加读锁写事务结束时要升级锁更新buffer但是expensive request导致读事务长时间持有锁最终导致写请求超时。
在etcd 3.4中实现了全并发读创建读事务的时候会全量拷贝buffer, 读写事务不再因为buffer阻塞大大缓解了expensive request对etcd性能的影响。尤其是Kubernetes List Pod等资源场景来说etcd稳定性显著提升。在后面的实践篇中我会和你再次深入讨论以上问题。

View File

@@ -0,0 +1,307 @@
<audio id="audio" title="05 | 鉴权:如何保护你的数据安全?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/yy/4b/yyddcda2yyd985fb86f8ddb55b4eb64b.mp3"></audio>
你好,我是唐聪。
不知道你有没有过这样的困惑当你使用etcd存储业务敏感数据、多租户共享使用同etcd集群的时候应该如何防止匿名用户访问你的etcd数据呢多租户场景又如何最小化用户权限分配防止越权访问的
etcd鉴权模块就是为了解决以上痛点而生。
那么etcd是如何实现多种鉴权机制和细粒度的权限控制的在实现鉴权模块的过程中最核心的挑战是什么又该如何确保鉴权的安全性以及提升鉴权性能呢
今天这节课我将为你介绍etcd的鉴权模块深入剖析etcd如何解决上面的这些痛点和挑战。希望通过这节课帮助你掌握etcd鉴权模块的设计、实现精要了解各种鉴权方案的优缺点。你能在实际应用中根据自己的业务场景、安全诉求选择合适的方案保护你的etcd数据安全。同时你也可以参考其设计、实现思想应用到自己业务的鉴权系统上。
## 整体架构
在详细介绍etcd的认证、鉴权实现细节之前我先给你从整体上介绍下etcd鉴权体系。
etcd鉴权体系架构由控制面和数据面组成。
<img src="https://static001.geekbang.org/resource/image/30/4e/304257ac790aeda91616bfe42800364e.png" alt="">
上图是是etcd鉴权体系控制面你可以通过客户端工具etcdctl和鉴权API动态调整认证、鉴权规则AuthServer收到请求后为了确保各节点间鉴权元数据一致性会通过Raft模块进行数据同步。
当对应的Raft日志条目被集群半数以上节点确认后Apply模块通过鉴权存储(AuthStore)模块执行日志条目的内容将规则存储到boltdb的一系列“鉴权表”里面。
下图是数据面鉴权流程由认证和授权流程组成。认证的目的是检查client的身份是否合法、防止匿名用户访问等。目前etcd实现了两种认证机制分别是密码认证和证书认证。
<img src="https://static001.geekbang.org/resource/image/2c/55/2c8f90fd1a30fab9b9a88ba18c24c555.png" alt="">
认证通过后为了提高密码认证性能会分配一个Token类似我们生活中的门票、通信证给clientclient后续其他请求携带此Tokenserver就可快速完成client的身份校验工作。
实现分配Token的服务也有多种这是TokenProvider所负责的目前支持SimpleToken和JWT两种。
通过认证后在访问MVCC模块之前还需要通过授权流程。授权的目的是检查client是否有权限操作你请求的数据路径etcd实现了RBAC机制支持为每个用户分配一个角色为每个角色授予最小化的权限。
<img src="https://static001.geekbang.org/resource/image/8d/8a/8d18f8877ea7c8fbyybebae236a8688a.png" alt="">
好了etcd鉴权体系的整个流程讲完了下面我们就以[第三节课](https://time.geekbang.org/column/article/336766)中提到的put hello命令为例给你深入分析以上鉴权体系是如何进行身份认证来防止匿名访问的又是如何实现细粒度的权限控制以防止越权访问的。
## 认证
首先我们来看第一个问题如何防止匿名用户访问你的etcd数据呢
解决方案当然是认证用户身份。那etcd提供了哪些机制来验证client身份呢?
正如我整体架构中给你介绍的etcd目前实现了两种机制分别是用户密码认证和证书认证下面我分别给你介绍这两种机制在etcd中如何实现以及这两种机制各自的优缺点。
### 密码认证
首先我们来讲讲用户密码认证。etcd支持为每个用户分配一个账号名称、密码。密码认证在我们生活中无处不在从银行卡取款到微信、微博app登录再到核武器发射密码认证应用及其广泛是最基础的鉴权的方式。
但密码认证存在两大难点,它们分别是如何保障密码安全性和提升密码认证性能。
#### 如何保障密码安全性
我们首先来看第一个难点:如何保障密码安全性。
也许刚刚毕业的你会说直接明文存储,收到用户鉴权请求的时候,检查用户请求中密码与存储中是否一样,不就可以了吗? 这种方案的确够简单,但你是否想过,若存储密码的文件万一被黑客脱库了,那么所有用户的密码都将被泄露,进而可能会导致重大数据泄露事故。
也许你又会说自己可以奇思妙想构建一个加密算法然后将密码翻译下比如将密码中的每个字符按照字母表序替换成字母后的第XX个字母。然而这种加密算法它是可逆的一旦被黑客识别到规律还原出你的密码后脱库后也将导致全部账号数据泄密。
那么是否我们用一种不可逆的加密算法就行了呢比如常见的MD5SHA-1这方案听起来似乎有点道理然而还是不严谨因为它们的计算速度非常快黑客可以通过暴力枚举、字典、彩虹表等手段快速将你的密码全部破解。
LinkedIn在2012年的时候650万用户密码被泄露黑客3天就暴力破解出90%用户的密码原因就是LinkedIn仅仅使用了SHA-1加密算法。
**那应该如何进一步增强不可逆hash算法的破解难度**
一方面我们可以使用安全性更高的hash算法比如SHA-256它输出位数更多、计算更加复杂且耗CPU。
另一方面我们可以在每个用户密码hash值的计算过程中引入一个随机、较长的加盐(salt)参数,它可以让相同的密码输出不同的结果,这让彩虹表破解直接失效。
彩虹表是黑客破解密码的一种方法之一它预加载了常用密码使用MD5/SHA-1计算的hash值可通过hash值匹配快速破解你的密码。
最后我们还可以增加密码hash值计算过程中的开销比如循环迭代更多次增加破解的时间成本。
**etcd的鉴权模块如何安全存储用户密码**
etcd的用户密码存储正是融合了以上讨论的高安全性hash函数Blowfish encryption algorithm、随机的加盐salt、可自定义的hash值计算迭代次数cost。
下面我将通过几个简单etcd鉴权API为你介绍密码认证的原理。
首先你可以通过如下的auth enable命令开启鉴权注意etcd会先要求你创建一个root账号它拥有集群的最高读写权限。
```
$ etcdctl user add root:root
User root created
$ etcdctl auth enable
Authentication Enabled
```
启用鉴权后这时client发起如下put hello操作时 etcd server会返回"user name is empty"错误给client就初步达到了防止匿名用户访问你的etcd数据目的。 那么etcd server是在哪里做的鉴权的呢?
```
$ etcdctl put hello world
Error: etcdserver: user name is empty
```
etcd server收到put hello请求的时候在提交到Raft模块前它会从你请求的上下文中获取你的用户身份信息。如果你未通过认证那么在状态机应用put命令的时候检查身份权限的时候发现是空就会返回此错误给client。
下面我通过鉴权模块的user命令给etcd增加一个alice账号。我们一起来看看etcd鉴权模块是如何基于我上面介绍的技术方案来安全存储alice账号信息。
```
$ etcdctl user add alice:alice --user root:root
User alice created
```
鉴权模块收到此命令后它会使用bcrpt库的blowfish算法基于明文密码、随机分配的salt、自定义的cost、迭代多次计算得到一个hash值并将加密算法版本、salt值、cost、hash值组成一个字符串作为加密后的密码。
最后鉴权模块将用户名alice作为key用户名、加密后的密码作为value存储到boltdb的authUsers bucket里面完成一个账号创建。
当你使用alice账号访问etcd的时候你需要先调用鉴权模块的Authenticate接口它会验证你的身份合法性。
那么etcd如何验证你密码正确性的呢
鉴权模块首先会根据你请求的用户名alice从boltdb获取加密后的密码因此hash值包含了算法版本、salt、cost等信息因此可以根据你请求中的明文密码计算出最终的hash值若计算结果与存储一致那么身份校验通过。
#### 如何提升密码认证性能
通过以上的鉴权安全性的深入分析,我们知道身份验证这个过程开销极其昂贵,那么问题来了,如何避免频繁、昂贵的密码计算匹配,提升密码认证的性能呢?
这就是密码认证的第二个难点,如何保证性能。
想想我们办理港澳通行证的时候,流程特别复杂,需要各种身份证明、照片、指纹信息,办理成功后,下发通信证,每次过关你只需要刷下通信证即可,高效而便捷。
那么在软件系统领域如果身份验证通过了后我们是否也可以返回一个类似通信证的凭据给client后续请求携带通信证只要通行证合法且在有效期内就无需再次鉴权了呢
是的etcd也有类似这样的凭据。当etcd server验证用户密码成功后它就会返回一个Token字符串给client用于表示用户的身份。后续请求携带此Token就无需再次进行密码校验实现了通信证的效果。
etcd目前支持两种Token分别为Simple Token和JWT Token。
**Simple Token**
Simple Token实现正如名字所言简单。
Simple Token的核心原理是当一个用户身份验证通过后生成一个随机的字符串值Token返回给client并在内存中使用map存储用户和Token映射关系。当收到用户的请求时 etcd会从请求中获取Token值转换成对应的用户名信息返回给下层模块使用。
Token是你身份的象征若此Token泄露了那你的数据就可能存在泄露的风险。etcd是如何应对这种潜在的安全风险呢
etcd生成的每个Token都有一个过期时间TTL属性Token过期后client需再次验证身份因此可显著缩小数据泄露的时间窗口在性能上、安全性上实现平衡。
在etcd v3.4.9版本中Token默认有效期是5分钟etcd server会定时检查你的Token是否过期若过期则从map数据结构中删除此Token。
不过你要注意的是Simple Token字符串本身并未含任何有价值信息因此client无法及时、准确获取到Token过期时间。所以client不容易提前去规避因Token失效导致的请求报错。
从以上介绍中你觉得Simple Token有哪些不足之处为什么etcd社区仅建议在开发、测试环境中使用Simple Token呢
首先它是有状态的etcd server需要使用内存存储Token和用户名的映射关系。
其次它的可描述性很弱client无法通过Token获取到过期时间、用户名、签发者等信息。
etcd鉴权模块实现的另外一个Token Provider方案JWT正是为了解决这些不足之处而生。
**JWT Token**
JWT是Json Web Token缩写 它是一个基于JSON的开放标准RFC 7519定义的一种紧凑、独立的格式可用于在身份提供者和服务提供者间传递被认证的用户身份信息。它由Header、Payload、Signature三个对象组成 每个对象都是一个JSON结构体。
第一个对象是Header它包含alg和typ两个字段alg表示签名的算法etcd支持RSA、ESA、PS系列typ表示类型就是JWT。
```
{
&quot;alg&quot;: &quot;RS256&quot;
&quot;typ&quot;: &quot;JWT&quot;
}
```
第二对象是Payload它表示载荷包含用户名、过期时间等信息可以自定义添加字段。
```
{
&quot;username&quot;: username
&quot;revision&quot;: revision
&quot;exp&quot;: time.Now().Add(t.ttl).Unix()
}
```
第三个对象是签名首先它将header、payload使用base64 url编码然后将编码后的
字符串用"."连接在一起最后用我们选择的签名算法比如RSA系列的私钥对其计算签名输出结果即是Signature。
```
signature=RSA256(
base64UrlEncode(header) + &quot;.&quot; +
base64UrlEncode(payload)
key)
```
JWT就是由base64UrlEncode(header).base64UrlEncode(payload).signature组成。
为什么说JWT是独立、紧凑的格式呢
从以上原理介绍中我们知道它是无状态的。JWT Token自带用户名、版本号、过期时间等描述信息etcd server不需要保存它client可方便、高效的获取到Token的过期时间、用户名等信息。它解决了Simple Token的若干不足之处安全性更高etcd社区建议大家在生产环境若使用了密码认证应使用JWT Token( --auth-token 'jwt')而不是默认的Simple Token。
在给你介绍完密码认证实现过程中的两个核心挑战,密码存储安全和性能的解决方案之后,你是否对密码认证的安全性、性能还有所担忧呢?
接下来我给你介绍etcd的另外一种高性能、更安全的鉴权方案x509证书认证。
### 证书认证
密码认证一般使用在client和server基于HTTP协议通信的内网场景中。当对安全有更高要求的时候你需要使用HTTPS协议加密通信数据防止中间人攻击和数据被篡改等安全风险。
HTTPS是利用非对称加密实现身份认证和密钥协商因此使用HTTPS协议的时候你需要使用CA证书给client生成证书才能访问。
那么一个client证书包含哪些信息呢使用证书认证的时候etcd server如何知道你发送的请求对应的用户名称
我们可以使用下面的openssl命令查看client证书的内容下图是一个x509 client证书的内容它含有证书版本、序列号、签名算法、签发者、有效期、主体名等信息我们重点要关注的是主体名中的CN字段。
在etcd中如果你使用了HTTPS协议并启用了client证书认证(--client-cert-auth)它会取CN字段作为用户名在我们的案例中alice就是client发送请求的用户名。
```
openssl x509 -noout -text -in client.pem
```
<img src="https://static001.geekbang.org/resource/image/55/94/55e03b4353c9a467493a3922cf68b294.png" alt="">
证书认证在稳定性、性能上都优于密码认证。
稳定性上它不存在Token过期、使用更加方便、会让你少踩坑避免了不少Token失效而触发的Bug。性能上证书认证无需像密码认证一样调用昂贵的密码认证操作(Authenticate请求),此接口支持的性能极低,后面实践篇会和你深入讨论。
## 授权
当我们使用如上创建的alice账号执行put hello操作的时候etcd却会返回如下的"etcdserver: permission denied"无权限错误,这是为什么呢?
```
$ etcdctl put hello world --user alice:alice
Error: etcdserver: permission denied
```
这是因为开启鉴权后put请求命令在应用到状态机前etcd还会对发出此请求的用户进行权限检查 判断其是否有权限操作请求的数据。常用的权限控制方法有ACL(Access Control List)、ABAC(Attribute-based access control)、RBAC(Role-based access control)etcd实现的是RBAC机制。
### RBAC
什么是基于角色权限的控制系统(RBAC)呢?
它由下图中的三部分组成User、Role、Permission。User表示用户如alice。Role表示角色它是权限的赋予对象。Permission表示具体权限明细比如赋予Role对key范围在[keyKeyEnd]数据拥有什么权限。目前支持三种权限分别是READ、WRITE、READWRITE。
<img src="https://static001.geekbang.org/resource/image/ee/60/ee6e0a9a63aeaa2d3505ab1a37360760.png" alt="">
下面我们通过etcd的RBAC机制给alice用户赋予一个可读写[hello,helly]数据范围的读写权限, 如何操作呢?
按照上面介绍的RBAC原理首先你需要创建一个role这里我们命名为admin然后新增了一个可读写[hello,helly]数据范围的权限给admin角色并将admin的角色的权限授予了用户alice。详细如下
```
$ #创建一个admin role
etcdctl role add admin --user root:root
Role admin created
# #分配一个可读写[hellohelly]范围数据的权限给admin role
$ etcdctl role grant-permission admin readwrite hello helly --user root:root
Role admin updated
# 将用户alice和admin role关联起来赋予admin权限给user
$ etcdctl user grant-role alice admin --user root:root
Role admin is granted to user alice
```
然后当你再次使用etcdctl执行put hello命令时鉴权模块会从boltdb查询alice用户对应的权限列表。
因为有可能一个用户拥有成百上千个权限列表etcd为了提升权限检查的性能引入了区间树检查用户操作的key是否在已授权的区间时间复杂度仅为O(logN)。
在我们的这个案例中很明显hello在admin角色可读写的[hellohelly)数据范围内因此它有权限更新key hello执行成功。你也可以尝试更新key hey因为此key未在鉴权的数据区间内因此etcd server会返回"etcdserver: permission denied"错误给client如下所示。
```
$ etcdctl put hello world --user alice:alice
OK
$ etcdctl put hey hey --user alice:alice
Error: etcdserver: permission denied
```
## 小结
最后我和你总结下今天的内容从etcd鉴权模块核心原理分析过程中你会发现设计实现一个鉴权模块最关键的目标和挑战应该是安全、性能以及一致性。
首先鉴权目的是为了保证安全必须防止恶意用户绕过鉴权系统、伪造、篡改、越权等行为同时设计上要有前瞻性做到即使被拖库也影响可控。etcd的解决方案是通过密码安全加密存储、证书认证、RBAC等机制保证其安全性。
然后鉴权作为了一个核心的前置模块性能上不能拖后腿不能成为影响业务性能的一个核心瓶颈。etcd的解决方案是通过Token降低频繁、昂贵的密码验证开销可应用在内网、小规模业务场景同时支持使用证书认证不存在Token过期巧妙的取CN字段作为用户名可满足较大规模的业务场景鉴权诉求。
接着鉴权系统面临的业务场景是复杂的因此权限控制系统应当具备良好的扩展性业务可根据自己实际场景选择合适的鉴权方法。etcd的Token Provider和RBAC扩展机制都具备较好的扩展性、灵活性。尤其是RBAC机制让你可以精细化的控制每个用户权限实现权限最小化分配。
最后鉴权系统元数据的存储应当是可靠的各个节点鉴权数据应确保一致确保鉴权行为一致性。早期etcd v2版本时因鉴权命令未经过Raft模块存在数据不一致的问题在etcd v3中通过Raft模块同步鉴权指令日志指令实现鉴权数据一致性。
## 思考题
最后我给你留了一个思考题。你在使用etcd鉴权特性过程中遇到了哪些问题又是如何解决的呢
感谢你的阅读,欢迎你把思考和观点写在留言区,也欢迎你把这篇文章分享给更多的朋友一起阅读。
## 04思考题参考答案
04讲的思考题mckee同学给出了精彩回答下面是他的回答。
1.哪些场景会出现 Follower 日志与 Leader 冲突?
leader崩溃的情况下可能(如老的leader可能还没有完全复制所有的日志条目)如果leader和follower出现持续崩溃会加剧这个现象。follower可能会丢失一些在新的leader中有的日志条目他也可能拥有一些leader没有的日志条目或者两者都发生。
2.follower如何删除无效日志
leader处理不一致是通过强制follower直接复制自己的日志来解决。因此在follower中的冲突的日志条目会被leader的日志覆盖。leader会记录follower的日志复制进度nextIndex如果follower在追加日志时一致性检查失败就会拒绝请求此时leader就会减小 nextIndex 值并进行重试最终在某个位置让follower跟leader一致。
这里我补充下为什么WAL日志模块只通过追加也能删除已持久化冲突的日志条目呢 其实这里etcd在实现上采用了一些比较有技巧的方法在WAL日志中的确没删除废弃的日志条目你可以在其中搜索到冲突的日志条目。只是etcd加载WAL日志时发现一个raft log index位置上有多个日志条目的时候会通过覆盖的方式将最后写入的日志条目追加到raft log中实现了删除冲突日志条目效果你如果感兴趣可以参考下我和Google ptabor[关于这个问题的讨论](https://github.com/etcd-io/etcd/issues/12589)。

View File

@@ -0,0 +1,189 @@
<audio id="audio" title="06 | 租约:如何检测你的客户端存活?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/28/0b/28de076f6a80a6892e69dd4b53719f0b.mp3"></audio>
你好,我是唐聪。
今天我要跟你分享的主题是租约Lease。etcd的一个典型的应用场景是Leader选举那么etcd为什么可以用来实现Leader选举核心特性实现原理又是怎样的
今天我就和你聊聊Leader选举背后技术点之一的Lease 解析它的核心原理、性能优化思路希望通过本节让你对Lease如何关联key、Lease如何高效续期、淘汰、什么是checkpoint机制有深入的理解。同时希望你能基于Lease的TTL特性解决实际业务中遇到分布式锁、节点故障自动剔除等各类问题提高业务服务的可用性。
## 什么是Lease
在实际业务场景中我们常常会遇到类似Kubernetes的调度器、控制器组件同一时刻只能存在一个副本对外提供服务的情况。然而单副本部署的组件是无法保证其高可用性的。
那为了解决单副本的可用性问题我们就需要多副本部署。同时为了保证同一时刻只有一个能对外提供服务我们需要引入Leader选举机制。那么Leader选举本质是要解决什么问题呢
首先当然是要保证Leader的唯一性确保集群不出现多个Leader才能保证业务逻辑准确性也就是安全性Safety、互斥性。
其次是主节点故障后备节点应可快速感知到其异常也就是活性liveness检测。实现活性检测主要有两种方案。
方案一为被动型检测你可以通过探测节点定时拨测Leader节点看是否健康比如Redis Sentinel。
方案二为主动型上报Leader节点可定期向协调服务发送"特殊心跳"汇报健康状态若其未正常发送心跳并超过和协调服务约定的最大存活时间后就会被协调服务移除Leader身份标识。同时其他节点可通过协调服务快速感知到Leader故障了进而发起新的选举。
我们今天的主题Lease正是基于主动型上报模式**提供的一种活性检测机制**。Lease顾名思义client和etcd server之间存在一个约定内容是etcd server保证在约定的有效期内TTL不会删除你关联到此Lease上的key-value。
若你未在有效期内续租那么etcd server就会删除Lease和其关联的key-value。
你可以基于Lease的TTL特性解决类似Leader选举、Kubernetes Event自动淘汰、服务发现场景中故障节点自动剔除等问题。为了帮助你理解Lease的核心特性原理我以一个实际场景中的经常遇到的异常节点自动剔除为案例围绕这个问题给你深入介绍Lease特性的实现。
在这个案例中我们期望的效果是在节点异常时表示节点健康的key能被从etcd集群中自动删除。
## Lease整体架构
在和你详细解读Lease特性如何解决上面的问题之前我们先了解下Lease模块的整体架构下图是我给你画的Lease模块简要架构图。
<img src="https://static001.geekbang.org/resource/image/ac/7c/ac70641fa3d41c2dac31dbb551394b7c.png" alt="">
etcd在启动的时候创建Lessor模块的时候它会启动两个常驻goroutine如上图所示一个是RevokeExpiredLease任务定时检查是否有过期Lease发起撤销过期的Lease操作。一个是CheckpointScheduledLease定时触发更新Lease的剩余到期时间的操作。
Lessor模块提供了Grant、Revoke、LeaseTimeToLive、LeaseKeepAlive API给client使用各接口作用如下:
- Grant表示创建一个TTL为你指定秒数的LeaseLessor会将Lease信息持久化存储在boltdb中
- Revoke表示撤销Lease并删除其关联的数据
- LeaseTimeToLive表示获取一个Lease的有效期、剩余时间
- LeaseKeepAlive表示为Lease续期。
## key如何关联Lease
了解完整体架构后我们再看如何基于Lease特性实现检测一个节点存活。
首先如何为节点健康指标创建一个租约、并与节点健康指标key关联呢?
如KV模块的一样client可通过clientv3库的Lease API发起RPC调用你可以使用如下的etcdctl命令为node的健康状态指标创建一个Lease有效期为600秒。然后通过timetolive命令查看Lease的有效期、剩余时间。
```
# 创建一个TTL为600秒的leaseetcd server返回LeaseID
$ etcdctl lease grant 600
lease 326975935f48f814 granted with TTL(600s)
# 查看lease的TTL、剩余时间
$ etcdctl lease timetolive 326975935f48f814
lease 326975935f48f814 granted with TTL(600s) remaining(590s)
```
当Lease server收到client的创建一个有效期600秒的Lease请求后会通过Raft模块完成日志同步随后Apply模块通过Lessor模块的Grant接口执行日志条目内容。
首先Lessor的Grant接口会把Lease保存到内存的ItemMap数据结构中然后它需要持久化Lease将Lease数据保存到boltdb的Lease bucket中返回一个唯一的LeaseID给client。
通过这样一个流程就基本完成了Lease的创建。那么节点的健康指标数据如何关联到此Lease上呢
很简单KV模块的API接口提供了一个"--lease"参数你可以通过如下命令将key node关联到对应的LeaseID上。然后你查询的时候增加-w参数输出格式为json就可查看到key关联的LeaseID。
```
$ etcdctl put node healthy --lease 326975935f48f818
OK
$ etcdctl get node -w=json | python -m json.tool
{
&quot;kvs&quot;:[
{
&quot;create_revision&quot;:24
&quot;key&quot;:&quot;bm9kZQ==&quot;
&quot;Lease&quot;:3632563850270275608
&quot;mod_revision&quot;:24
&quot;value&quot;:&quot;aGVhbHRoeQ==&quot;
&quot;version&quot;:1
}
]
}
```
以上流程原理如下图所示它描述了用户的key是如何与指定Lease关联的。当你通过put等命令新增一个指定了"--lease"的key时MVCC模块它会通过Lessor模块的Attach方法将key关联到Lease的key内存集合ItemSet中。
<img src="https://static001.geekbang.org/resource/image/aa/ee/aaf8bf5c3841a641f8c51fcc34ac67ee.png" alt="">
一个Lease关联的key集合是保存在内存中的那么etcd重启时是如何知道每个Lease上关联了哪些key呢?
答案是etcd的MVCC模块在持久化存储key-value的时候保存到boltdb的value是个结构体mvccpb.KeyValue 它不仅包含你的key-value数据还包含了关联的LeaseID等信息。因此当etcd重启时可根据此信息重建关联各个Lease的key集合列表。
## 如何优化Lease续期性能
通过以上流程我们完成了Lease创建和数据关联操作。在正常情况下你的节点存活时需要定期发送KeepAlive请求给etcd续期健康状态的Lease否则你的Lease和关联的数据就会被删除。
那么Lease是如何续期的? 作为一个高频率的请求APIetcd如何优化Lease续期的性能呢
Lease续期其实很简单核心是将Lease的过期时间更新为当前系统时间加其TTL。关键问题在于续期的性能能否满足业务诉求。
然而影响续期性能因素又是源自多方面的。首先是TTLTTL过长会导致节点异常后无法及时从etcd中删除影响服务可用性而过短则要求client频繁发送续期请求。其次是Lease数如果Lease成千上万个那么etcd可能无法支撑如此大规模的Lease数导致高负载。
如何解决呢?
首先我们回顾下早期etcd v2版本是如何实现TTL特性的。在早期v2版本中没有Lease概念TTL属性是在key上面为了保证key不删除即便你的TTL相同client也需要为每个TTL、key创建一个HTTP/1.x 连接定时发送续期请求给etcd server。
很显然v2老版本这种设计因不支持连接多路复用、相同TTL无法复用导致性能较差无法支撑较大规模的Lease场景。
etcd v3版本为了解决以上问题提出了Lease特性TTL属性转移到了Lease上 同时协议从HTTP/1.x优化成gRPC协议。
一方面不同key若TTL相同可复用同一个Lease 显著减少了Lease数。另一方面通过gRPC HTTP/2实现了多路复用流式传输同一连接可支持为多个Lease续期大大减少了连接数。
通过以上两个优化实现Lease性能大幅提升满足了各个业务场景诉求。
## 如何高效淘汰过期Lease
在了解完节点正常情况下的Lease续期特性后我们再看看节点异常时未正常续期后etcd又是如何淘汰过期Lease、删除节点健康指标key的。
淘汰过期Lease的工作由Lessor模块的一个异步goroutine负责。如下面架构图虚线框所示它会定时从最小堆中取出已过期的Lease执行删除Lease和其关联的key列表数据的RevokeExpiredLease任务。
<img src="https://static001.geekbang.org/resource/image/b0/6b/b09e9d30157876b031ed206391698c6b.png" alt="">
从图中你可以看到目前etcd是基于最小堆来管理Lease实现快速淘汰过期的Lease。
etcd早期的时候淘汰Lease非常暴力。etcd会直接遍历所有Lease逐个检查Lease是否过期过期则从Lease关联的key集合中取出key列表删除它们时间复杂度是O(N)。
然而这种方案随着Lease数增大毫无疑问它的性能会变得越来越差。我们能否按过期时间排序呢这样每次只需轮询、检查排在前面的Lease过期时间一旦轮询到未过期的Lease 则可结束本轮检查。
刚刚说的就是etcd Lease高效淘汰方案最小堆的实现方法。每次新增Lease、续期的时候它会插入、更新一个对象到最小堆中对象含有LeaseID和其到期时间unixnano对象之间按到期时间升序排序。
etcd Lessor主循环每隔500ms执行一次撤销Lease检查RevokeExpiredLease每次轮询堆顶的元素若已过期则加入到待淘汰列表直到堆顶的Lease过期时间大于当前则结束本轮轮询。
相比早期O(N)的遍历时间复杂度使用堆后插入、更新、删除它的时间复杂度是O(Log N)查询堆顶对象是否过期时间复杂度仅为O(1)性能大大提升可支撑大规模场景下Lease的高效淘汰。
获取到待过期的LeaseID后Leader是如何通知其他Follower节点淘汰它们呢
Lessor模块会将已确认过期的LeaseID保存在一个名为expiredC的channel中而etcd server的主循环会定期从channel中获取LeaseID发起revoke请求通过Raft Log传递给Follower节点。
各个节点收到revoke Lease请求后获取关联到此Lease上的key列表从boltdb中删除key从Lessor的Lease map内存中删除此Lease对象最后还需要从boltdb的Lease bucket中删除这个Lease。
以上就是Lease的过期自动淘汰逻辑。Leader节点按过期时间维护了一个最小堆若你的节点异常未正常续期那么随着时间消逝对应的Lease则会过期Lessor主循环定时轮询过期的Lease。获取到ID后Leader发起revoke操作通知整个集群删除Lease和关联的数据。
## 为什么需要checkpoint机制
了解完Lease的创建、续期、自动淘汰机制后你可能已经发现检查Lease是否过期、维护最小堆、针对过期的Lease发起revoke操作都是Leader节点负责的它类似于Lease的仲裁者通过以上清晰的权责划分降低了Lease特性的实现复杂度。
那么当Leader因重启、crash、磁盘IO等异常不可用时Follower节点就会发起Leader选举新Leader要完成以上职责必须重建Lease过期最小堆等管理数据结构那么以上重建可能会触发什么问题呢
当你的集群发生Leader切换后新的Leader基于Lease map信息按Lease过期时间构建一个最小堆时etcd早期版本为了优化性能并未持久化存储Lease剩余TTL信息因此重建的时候就会自动给所有Lease自动续期了。
然而若较频繁出现Leader切换切换时间小于Lease的TTL这会导致Lease永远无法删除大量key堆积db大小超过配额等异常。
为了解决这个问题etcd引入了检查点机制也就是下面架构图中黑色虚线框所示的CheckPointScheduledLeases的任务。
<img src="https://static001.geekbang.org/resource/image/70/59/70ece2fa3bc400edd8d3b09f752ea759.png" alt="">
一方面etcd启动的时候Leader节点后台会运行此异步任务定期批量地将Lease剩余的TTL基于Raft Log同步给Follower节点Follower节点收到CheckPoint请求后更新内存数据结构LeaseMap的剩余TTL信息。
另一方面当Leader节点收到KeepAlive请求的时候它也会通过checkpoint机制把此Lease的剩余TTL重置并同步给Follower节点尽量确保续期后集群各个节点的Lease 剩余TTL一致性。
最后你要注意的是此特性对性能有一定影响目前仍然是试验特性。你可以通过experimental-enable-lease-checkpoint参数开启。
## 小结
最后我们来小结下今天的内容我通过一个实际案例为你解读了Lease创建、关联key、续期、淘汰、checkpoint机制。
Lease的核心是TTL当Lease的TTL过期时它会自动删除其关联的key-value数据。
首先是Lease创建及续期。当你创建Lease时etcd会保存Lease信息到boltdb的Lease bucket中。为了防止Lease被淘汰你需要定期发送LeaseKeepAlive请求给etcd server续期Lease本质是更新Lease的到期时间。
续期的核心挑战是性能etcd经历了从TTL属性在key上到独立抽象出Lease支持多key复用相同TTL同时协议从HTTP/1.x优化成gRPC协议支持多路连接复用显著降低了server连接数等资源开销。
其次是Lease的淘汰机制etcd的Lease淘汰算法经历了从时间复杂度O(N)到O(Log N)的演进核心是轮询最小堆的Lease是否过期若过期生成revoke请求它会清理Lease和其关联的数据。
最后我给你介绍了Lease的checkpoint机制它是为了解决Leader异常情况下TTL自动被续期可能导致Lease永不淘汰的问题而诞生。
## 思考题
好了这节课到这里也就结束了我最后给你留了一个思考题。你知道etcd lease最小的TTL时间是多少吗它跟什么因素有关呢
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。

View File

@@ -0,0 +1,320 @@
<audio id="audio" title="07 | MVCC如何实现多版本并发控制" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a8/a2/a8b1a403865a93f6330e8127975d1da2.mp3"></audio>
你好,我是唐聪。
在[01](https://time.geekbang.org/column/article/335204)课里我和你介绍etcd v2时提到过它存在的若干局限如仅保留最新版本key-value数据、丢弃历史版本。而etcd核心特性watch又依赖历史版本因此etcd v2为了缓解这个问题会在内存中维护一个较短的全局事件滑动窗口保留最近的1000条变更事件。但是在集群写请求较多等场景下它依然无法提供可靠的Watch机制。
那么不可靠的etcd v2事件机制在etcd v3中是如何解决的呢
我今天要和你分享的MVCCMultiversion concurrency control机制正是为解决这个问题而诞生的。
MVCC机制的核心思想是保存一个key-value数据的多个历史版本etcd基于它不仅实现了可靠的Watch机制避免了client频繁发起List Pod等expensive request操作保障etcd集群稳定性。而且MVCC还能以较低的并发控制开销实现各类隔离级别的事务保障事务的安全性是事务特性的基础。
希望通过本节课帮助你搞懂MVCC含义和MVCC机制下key-value数据的更新、查询、删除原理了解treeIndex索引模块、boltdb模块是如何相互协作实现保存一个key-value数据多个历史版本。
## 什么是MVCC
首先和你聊聊什么是MVCC从名字上理解它是一个基于多版本技术实现的一种并发控制机制。那常见的并发机制有哪些MVCC的优点在哪里呢
提到并发控制机制你可能就没那么陌生了,比如数据库中的悲观锁,也就是通过锁机制确保同一时刻只能有一个事务对数据进行修改操作,常见的实现方案有读写锁、互斥锁、两阶段锁等。
悲观锁是一种事先预防机制,它悲观地认为多个并发事务可能会发生冲突,因此它要求事务必须先获得锁,才能进行修改数据操作。但是悲观锁粒度过大、高并发场景下大量事务会阻塞等,会导致服务性能较差。
**MVCC机制正是基于多版本技术实现的一种乐观锁机制**,它乐观地认为数据不会发生冲突,但是当事务提交时,具备检测数据是否冲突的能力。
在MVCC数据库中你更新一个key-value数据的时候它并不会直接覆盖原数据而是新增一个版本来存储新的数据每个数据都有一个版本号。版本号它是一个逻辑时间为了方便你深入理解版本号意义在下面我给你画了一个etcd MVCC版本号时间序列图。
从图中你可以看到,随着时间增长,你每次修改操作,版本号都会递增。每修改一次,生成一条新的数据记录。**当你指定版本号读取数据时,它实际上访问的是版本号生成那个时间点的快照数据**。当你删除数据的时候,它实际也是新增一条带删除标识的数据记录。
<img src="https://static001.geekbang.org/resource/image/1f/2c/1fbf4aa426c8b78570ed310a8c9e2c2c.png" alt="">
## MVCC特性初体验
了解完什么是MVCC后我先通过几个简单命令带你初体验下MVCC特性看看它是如何帮助你查询历史修改记录以及找回不小心删除的key的。
启动一个空集群更新两次key hello后如何获取key hello的上一个版本值呢 删除key hello后还能读到历史版本吗?
如下面的命令所示第一次key hello更新完后我们通过get命令获取下它的key-value详细信息。正如你所看到的除了key、value信息还有各类版本号我后面会详细和你介绍它们的含义。这里我们重点关注mod_revision它表示key最后一次修改时的etcd版本号。
当我们再次更新key hello为world2后然后通过查询时指定key第一次更新后的版本号你会发现我们查询到了第一次更新的值甚至我们执行删除key hello后依然可以获得到这个值。那么etcd是如何实现的呢?
```
# 更新key hello为world1
$ etcdctl put hello world1
OK
# 通过指定输出模式为json,查看key hello更新后的详细信息
$ etcdctl get hello -w=json
{
&quot;kvs&quot;:[
{
&quot;key&quot;:&quot;aGVsbG8=&quot;,
&quot;create_revision&quot;:2,
&quot;mod_revision&quot;:2,
&quot;version&quot;:1,
&quot;value&quot;:&quot;d29ybGQx&quot;
}
],
&quot;count&quot;:1
}
# 再次修改key hello为world2
$ etcdctl put hello world2
OK
# 确认修改成功,最新值为wolrd2
$ etcdctl get hello
hello
world2
# 指定查询版本号,获得了hello上一次修改的值
$ etcdctl get hello --rev=2
hello
world1
# 删除key hello
$ etcdctl del hello
1
# 删除后指定查询版本号3,获得了hello删除前的值
$ etcdctl get hello --rev=3
hello
world2
```
## 整体架构
在详细和你介绍etcd如何实现MVCC特性前我先和你从整体上介绍下MVCC模块。下图是MVCC模块的一个整体架构图整个MVCC特性由treeIndex、Backend/boltdb组成。
当你执行MVCC特性初体验中的put命令后请求经过gRPC KV Server、Raft模块流转对应的日志条目被提交后Apply模块开始执行此日志内容。
<img src="https://static001.geekbang.org/resource/image/f5/2c/f5799da8d51a381527068a95bb13592c.png" alt="">
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信息的结构体。
<img src="https://static001.geekbang.org/resource/image/e7/8f/e713636c6cf9c46c7c19f677232d858f.png" alt="">
接下来我和你重点聊聊treeIndex模块的原理与核心数据结构。
## treeIndex原理
为什么需要treeIndex模块呢?
对于etcd v2来说当你通过etcdctl发起一个put hello操作时etcd v2直接更新内存树这就导致历史版本直接被覆盖无法支持保存key的历史版本。在etcd v3中引入treeIndex模块正是为了解决这个问题支持保存key的历史版本提供稳定的Watch机制和事务隔离等能力。
那etcd v3又是如何基于treeIndex模块实现保存key的历史版本的呢?
在02节课里我们提到过etcd在每次修改key时会生成一个全局递增的版本号revision然后通过数据结构B-tree保存用户key与版本号之间的关系再以版本号作为boltdb key以用户的key-value等信息作为boltdb value保存到boltdb。
下面我就为你介绍下etcd保存用户key与版本号映射关系的数据结构B-tree为什么etcd使用它而不使用哈希表、平衡二叉树
从etcd的功能特性上分析 因etcd支持范围查询因此保存索引的数据结构也必须支持范围查询才行。所以哈希表不适合而B-tree支持范围查询。
从性能上分析平横二叉树每个节点只能容纳一个数据、导致树的高度较高而B-tree每个节点可以容纳多个数据树的高度更低更扁平涉及的查找次数更少具有优越的增、删、改、查性能。
Google的开源项目btree使用Go语言实现了一个内存版的B-tree对外提供了简单易用的接口。etcd正是基于btree库实现了一个名为treeIndex的索引模块通过它来查询、保存用户key与版本号之间的关系。
下图是个最大度degree &gt; 1简称d为5的B-tree度是B-tree中的一个核心参数它决定了你每个节点上的数据量多少、节点的“胖”、“瘦”程度。
从图中你可以看到节点越胖意味着一个节点可以存储更多数据树的高度越低。在一个度为d的B-tree中节点保存的最大key数为2d - 1否则需要进行平衡、分裂操作。这里你要注意的是在etcd treeIndex模块中创建的是最大度32的B-tree也就是一个叶子节点最多可以保存63个key。
<img src="https://static001.geekbang.org/resource/image/44/74/448c8a2bb3b5d2d48dfb6ea585172c74.png" alt="">
从图中你可以看到你通过put/txn命令写入的一系列keytreeIndex模块基于B-tree将其组织起来节点之间基于用户key比较大小。当你查找一个key k95时通过B-tree的特性你仅需通过图中流程1和2两次快速比较就可快速找到k95所在的节点。
在treeIndex中每个节点的key是一个keyIndex结构etcd就是通过它保存了用户的key与版本号的映射关系。
那么keyIndex结构包含哪些信息呢下面是字段说明你可以参考一下。
```
type keyIndex struct {
key []byte //用户的key名称比如我们案例中的&quot;hello&quot;
modified revision //最后一次修改key时的etcd版本号,比如我们案例中的刚写入hello为world1时的版本号为2
generations []generation //generation保存了一个key若干代版本号信息每代中包含对key的多次修改的版本号列表
}
```
keyIndex中包含用户的key、最后一次修改key时的etcd版本号、key的若干代generation版本号信息每代中包含对key的多次修改的版本号列表。那我们要如何理解generations为什么它是个数组呢?
generations表示一个key从创建到删除的过程每代对应key的一个生命周期的开始与结束。当你第一次创建一个key时会生成第0代后续的修改操作都是在往第0代中追加修改版本号。当你把key删除后它就会生成新的第1代一个key不断经历创建、删除的过程它就会生成多个代。
generation结构详细信息如下
```
type generation struct {
ver int64 //表示此key的修改次数
created revision //表示generation结构创建时的版本号
revs []revision //每次修改key时的revision追加到此数组
}
```
generation结构中包含此key的修改次数、generation创建时的版本号、对此key的修改版本号记录列表。
你需要注意的是版本号revision并不是一个简单的整数而是一个结构体。revision结构及含义如下
```
type revision struct {
main int64 // 一个全局递增的主版本号随put/txn/delete事务递增一个事务内的key main版本号是一致的
sub int64 // 一个事务内的子版本号从0开始随事务内put/delete操作递增
}
```
revision包含main和sub两个字段main是全局递增的版本号它是个etcd逻辑时钟随着put/txn/delete等事务递增。sub是一个事务内的子版本号从0开始随事务内的put/delete操作递增。
比如启动一个空集群全局版本号默认为1执行下面的txn事务它包含两次put、一次get操作那么按照我们上面介绍的原理全局版本号随读写事务自增因此是main为2sub随事务内的put/delete操作递增因此key hello的revison为{2,0}key world的revision为{2,1}。
```
$ etcdctl txn -i
compares:
success requests (getputdel):
put hello 1
get hello
put world 2
```
介绍完treeIndex基本原理、核心数据结构后我们再看看在MVCC特性初体验中的更新、查询、删除key案例里treeIndex与boltdb是如何协作完成以上key-value操作的?
## MVCC更新key原理
当你通过etcdctl发起一个put hello操作时如下面的put事务流程图流程一所示在put写事务中首先它需要从treeIndex模块中查询key的keyIndex索引信息keyIndex中存储了key的创建版本号、修改的次数等信息这些信息在事务中发挥着重要作用因此会存储在boltdb的value中。
在我们的案例中因为是第一次创建hello key此时keyIndex索引为空。
<img src="https://static001.geekbang.org/resource/image/84/e1/84377555cb4150ea7286c9ef3c5e17e1.png" alt="">
其次etcd会根据当前的全局版本号空集群启动时默认为1自增生成put hello操作对应的版本号revision{2,0}这就是boltdb的key。
boltdb的value是mvccpb.KeyValue结构体它是由用户key、value、create_revision、mod_revision、version、lease组成。它们的含义分别如下
- create_revision表示此key创建时的版本号。在我们的案例中key hello是第一次创建那么值就是2。当你再次修改key hello的时候写事务会从treeIndex模块查询hello第一次创建的版本号也就是keyIndex.generations[i].created字段赋值给create_revision字段
- mod_revision表示key最后一次修改时的版本号即put操作发生时的全局版本号加1
- version表示此key的修改次数。每次修改的时候写事务会从treeIndex模块查询hello已经历过的修改次数也就是keyIndex.generations[i].ver字段将ver字段值加1后赋值给version字段。
填充好boltdb的KeyValue结构体后这时就可以通过Backend的写事务batchTx接口将key{2,0},value为mvccpb.KeyValue保存到boltdb的缓存中并同步更新buffer如上图中的流程二所示。
此时存储到boltdb中的key、value数据如下
<img src="https://static001.geekbang.org/resource/image/a2/ba/a245b18eabc86ea83a71349f49bdceba.jpg" alt="">
然后put事务需将本次修改的版本号与用户key的映射关系保存到treeIndex模块中也就是上图中的流程三。
因为key hello是首次创建treeIndex模块它会生成key hello对应的keyIndex对象并填充相关数据结构。
keyIndex填充后的结果如下所示
```
key hello的keyIndex:
key: &quot;hello&quot;
modified: &lt;2,0&gt;
generations:
[{ver:1,created:&lt;2,0&gt;,revisions: [&lt;2,0&gt;]} ]
```
我们来简易分析一下上面的结果。
- key为hellomodified为最后一次修改版本号&lt;2,0&gt;key hello是首次创建的因此新增一个generation代跟踪它的生命周期、修改记录
- generation的ver表示修改次数首次创建为1后续随着修改操作递增
- generation.created表示创建generation时的版本号为&lt;2,0&gt;
- revision数组保存对此key修改的版本号列表每次修改都会将将相应的版本号追加到revisions数组中。
通过以上流程一个put操作终于完成。
但是此时数据还并未持久化为了提升etcd的写吞吐量、性能一般情况下默认堆积的写事务数大于1万才在写事务结束时同步持久化数据持久化由Backend的异步goroutine完成它通过事务批量提交定时将boltdb页缓存中的脏数据提交到持久化存储磁盘中也就是下图中的黑色虚线框住的流程四。
<img src="https://static001.geekbang.org/resource/image/5d/a2/5de49651cedf4595648aeba3c131cea2.png" alt="">
## MVCC查询key原理
完成put hello为world1操作后这时你通过etcdctl发起一个get hello操作MVCC模块首先会创建一个读事务对象TxnRead在etcd 3.4中Backend实现了ConcurrentReadTx 也就是并发读特性。
并发读特性的核心原理是创建读事务对象时它会全量拷贝当前写事务未提交的buffer数据并发的读写事务不再阻塞在一个buffer资源锁上实现了全并发读。
<img src="https://static001.geekbang.org/resource/image/55/ee/55998d8a1f3091076a9119d85e7175ee.png" alt="">
如上图所示在读事务中它首先需要根据key从treeIndex模块获取版本号因我们未带版本号读默认是读取最新的数据。treeIndex模块从B-tree中根据key查找到keyIndex对象后匹配有效的generation返回generation的revisions数组中最后一个版本号{2,0}给读事务对象。
读事务对象根据此版本号为key通过Backend的并发读事务ConcurrentReadTx接口优先从buffer中查询命中则直接返回否则从boltdb中查询此key的value信息。
那指定版本号读取历史记录又是怎么实现的呢?
当你再次发起一个put hello为world2修改操作时key hello对应的keyIndex的结果如下面所示keyIndex.modified字段更新为&lt;3,0&gt;generation的revision数组追加最新的版本号&lt;3,0&gt;ver修改为2。
```
key hello的keyIndex:
key: &quot;hello&quot;
modified: &lt;3,0&gt;
generations:
[{ver:2,created:&lt;2,0&gt;,revisions: [&lt;2,0&gt;,&lt;3,0&gt;]}]
```
boltdb插入一个新的key revision{3,0}此时存储到boltdb中的key-value数据如下
<img src="https://static001.geekbang.org/resource/image/8b/f7/8bec06d61622f2a99ea9dd2f78e693f7.jpg" alt="">
这时你再发起一个指定历史版本号为2的读请求时实际是读版本号为2的时间点的快照数据。treeIndex模块会遍历generation内的历史版本号返回小于等于2的最大历史版本号在我们这个案例中也就是revision{2,0}以它作为boltdb的key从boltdb中查询出value即可。
## MVCC删除key原理
介绍完MVCC更新、查询key的原理后我们接着往下看。当你执行etcdctl del hello命令时etcd会立刻从treeIndex和boltdb中删除此数据吗还是增加一个标记实现延迟删除lazy delete
答案为etcd实现的是延期删除模式原理与key更新类似。
与更新key不一样之处在于一方面生成的boltdb key版本号{4,0,t}追加了删除标识tombstone,简写tboltdb value变成只含用户key的KeyValue结构体。另一方面treeIndex模块也会给此key hello对应的keyIndex对象追加一个空的generation对象表示此索引对应的key被删除了。
当你再次查询hello的时候treeIndex模块根据key hello查找到keyindex对象后若发现其存在空的generation对象并且查询的版本号大于等于被删除时的版本号则会返回空。
etcdctl hello操作后的keyIndex的结果如下面所示
```
key hello的keyIndex:
key: &quot;hello&quot;
modified: &lt;4,0&gt;
generations:
[
{ver:3,created:&lt;2,0&gt;,revisions: [&lt;2,0&gt;,&lt;3,0&gt;,&lt;4,0&gt;(t)]}
{empty}
]
```
boltdb此时会插入一个新的key revision{4,0,t}此时存储到boltdb中的key-value数据如下
<img src="https://static001.geekbang.org/resource/image/da/17/da4e5bc5033619dda296c022ac6yyc17.jpg" alt="">
那么key打上删除标记后有哪些用途呢什么时候会真正删除它呢
一方面删除key时会生成eventsWatch模块根据key的删除标识会生成对应的Delete事件。
另一方面当你重启etcd遍历boltdb中的key构建treeIndex内存树时你需要知道哪些key是已经被删除的并为对应的key索引生成tombstone标识。而真正删除treeIndex中的索引对象、boltdb中的key是通过压缩(compactor)组件异步完成。
正因为etcd的删除key操作是基于以上延期删除原理实现的因此只要压缩组件未回收历史版本我们就能从etcd中找回误删的数据。
## 小结
最后我们来小结下今天的内容我通过MVCC特性初体验中的更新、查询、删除key案例为你分析了MVCC整体架构、核心模块它由treeIndex、boltdb组成。
treeIndex模块基于Google开源的btree库实现它的核心数据结构keyIndex保存了用户key与版本号关系。每次修改key都会生成新的版本号生成新的boltdb key-value。boltdb的key为版本号value包含用户key-value、各种版本号、lease的mvccpb.KeyValue结构体。
当你未带版本号查询key时etcd返回的是key最新版本数据。当你指定版本号读取数据时etcd实际上返回的是版本号生成那个时间点的快照数据。
删除一个数据时etcd并未真正删除它而是基于lazy delete实现的异步删除。删除原理本质上与更新操作类似只不过boltdb的key会打上删除标记keyIndex索引中追加空的generation。真正删除key是通过etcd的压缩组件去异步实现的在后面的课程里我会继续和你深入介绍。
基于以上原理特性的实现etcd实现了保存key历史版本的功能是高可靠Watch机制的基础。基于key-value中的各种版本号信息etcd可提供各种级别的简易事务隔离能力。基于Backend/boltdb提供的MVCC机制etcd可实现读写不冲突。
## 思考题
你认为etcd为什么删除使用lazy delete方式呢 相比同步delete,各有什么优缺点当你突然删除大量key后db大小是立刻增加还是减少呢
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。

View File

@@ -0,0 +1,296 @@
<audio id="audio" title="08 | Watch如何高效获取数据变化通知" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/84/85/842443cc9ecc3ac47cf13445b39d6985.mp3"></audio>
你好,我是唐聪。
在Kubernetes中各种各样的控制器实现了Deployment、StatefulSet、Job等功能强大的Workload。控制器的核心思想是监听、比较资源实际状态与期望状态是否一致若不一致则进行协调工作使其最终一致。
那么当你修改一个Deployment的镜像时Deployment控制器是如何高效的感知到期望状态发生了变化呢
要回答这个问题得从etcd的Watch特性说起它是Kubernetes控制器的工作基础。今天我和你分享的主题就是etcd的核心特性Watch机制设计实现通过分析Watch机制的四大核心问题让你了解一个变化数据是如何从0到1推送给client并给你介绍Watch特性从etcd v2到etcd v3演进、优化过程。
希望通过这节课你能在实际业务中应用Watch特性快速获取数据变更通知而不是使用可能导致大量expensive request的轮询模式。更进一步我将帮助你掌握Watch过程中可能会出现的各种异常错误和原因并知道在业务中如何优雅处理让你的服务更稳地运行。
## Watch特性初体验
在详细介绍Watch特性实现原理之前我先通过几个简单命令带你初体验下Watch特性。
启动一个空集群更新两次key hello后使用Watch特性如何获取key hello的历史修改记录呢
如下所示你可以通过下面的watch命令带版本号监听key hello集群版本号可通过endpoint status命令获取空集群启动后的版本号为1。
执行后输出如下代码所示两个事件记录分别对应上面的两次的修改事件中含有key、value、各类版本号等信息你还可以通过比较create_revision和mod_revision区分此事件是add还是update事件。
watch命令执行后你后续执行的增量put hello修改操作它同样可持续输出最新的变更事件给你。
```
$ etcdctl put hello world1
$ etcdctl put hello world2
$ etcdctl watch hello -w=json --rev=1
{
&quot;Events&quot;:[
{
&quot;kv&quot;:{
&quot;key&quot;:&quot;aGVsbG8=&quot;,
&quot;create_revision&quot;:2,
&quot;mod_revision&quot;:2,
&quot;version&quot;:1,
&quot;value&quot;:&quot;d29ybGQx&quot;
}
},
{
&quot;kv&quot;:{
&quot;key&quot;:&quot;aGVsbG8=&quot;,
&quot;create_revision&quot;:2,
&quot;mod_revision&quot;:3,
&quot;version&quot;:2,
&quot;value&quot;:&quot;d29ybGQy&quot;
}
}
],
&quot;CompactRevision&quot;:0,
&quot;Canceled&quot;:false,
&quot;Created&quot;:false
}
```
从以上初体验中你可以看到基于Watch特性你可以快速获取到你感兴趣的数据变化事件这也是Kubernetes控制器工作的核心基础。在这过程中其实有以下四大核心问题
第一client获取事件的机制etcd是使用轮询模式还是推送模式呢两者各有什么优缺点
第二,事件是如何存储的? 会保留多久watch命令中的版本号具有什么作用
第三当client和server端出现短暂网络波动等异常因素后导致事件堆积时server端会丢弃事件吗若你监听的历史版本号server端不存在了你的代码该如何处理
第四如果你创建了上万个watcher监听key变化当server端收到一个写请求后etcd是如何根据变化的key快速找到监听它的watcher呢
接下来我就和你分别详细聊聊etcd Watch特性是如何解决这四大问题的。搞懂这四个问题你就明白etcd甚至各类分布式存储Watch特性的核心实现原理了。
## 轮询 vs 流式推送
首先第一个问题是**client获取事件机制**etcd是使用轮询模式还是推送模式呢两者各有什么优缺点
答案是两种机制etcd都使用过。
在etcd v2 Watch机制实现中使用的是HTTP/1.x协议实现简单、兼容性好每个watcher对应一个TCP连接。client通过HTTP/1.1协议长连接定时轮询server获取最新的数据变化事件。
然而当你的watcher成千上万的时即使集群空负载大量轮询也会产生一定的QPSserver端会消耗大量的socket、内存等资源导致etcd的扩展性、稳定性无法满足Kubernetes等业务场景诉求。
etcd v3的Watch机制的设计实现并非凭空出现它正是吸取了etcd v2的经验、教训而重构诞生的。
在etcd v3中为了解决etcd v2的以上缺陷使用的是基于HTTP/2的gRPC协议双向流的Watch API设计实现了连接多路复用。
HTTP/2协议为什么能实现多路复用呢
<img src="https://static001.geekbang.org/resource/image/be/74/be3a019beaf1310d214e5c9948cc9c74.png" alt="" title="引用自Google开发者文档">
在HTTP/2协议中HTTP消息被分解独立的帧Frame交错发送帧是最小的数据单位。每个帧会标识属于哪个流Stream流由多个数据帧组成每个流拥有一个唯一的ID一个数据流对应一个请求或响应包。
如上图所示client正在向server发送数据流5的帧同时server也正在向client发送数据流1和数据流3的一系列帧。一个连接上有并行的三个数据流HTTP/2可基于帧的流ID将并行、交错发送的帧重新组装成完整的消息。
通过以上机制HTTP/2就解决了HTTP/1的请求阻塞、连接无法复用的问题实现了多路复用、乱序发送。
etcd基于以上介绍的HTTP/2协议的多路复用等机制实现了一个client/TCP连接支持多gRPC Stream 一个gRPC Stream又支持多个watcher如下图所示。同时事件通知模式也从client轮询优化成server流式推送极大降低了server端socket、内存等资源。
<img src="https://static001.geekbang.org/resource/image/f0/be/f08d1c50c6bc14f09b5028095ce275be.png" alt="">
当然在etcd v3 watch性能优化的背后也带来了Watch API复杂度上升, 不过你不用担心etcd的clientv3库已经帮助你搞定这些棘手的工作了。
在clientv3库中Watch特性被抽象成Watch、Close、RequestProgress三个简单API提供给开发者使用屏蔽了client与gRPC WatchServer交互的复杂细节实现了一个client支持多个gRPC Stream一个gRPC Stream支持多个watcher显著降低了你的开发复杂度。
同时当watch连接的节点故障clientv3库支持自动重连到健康节点并使用之前已接收的最大版本号创建新的watcher避免旧事件回放等。
## 滑动窗口 vs MVCC
介绍完etcd v2的轮询机制和etcd v3的流式推送机制后再看第二个问题事件是如何存储的 会保留多久呢watch命令中的版本号具有什么作用
第二个问题的本质是**历史版本存储**etcd经历了从滑动窗口到MVCC机制的演变滑动窗口是仅保存有限的最近历史版本到内存中而MVCC机制则将历史版本保存在磁盘中避免了历史版本的丢失极大的提升了Watch机制的可靠性。
etcd v2滑动窗口是如何实现的它有什么缺点呢
它使用的是如下一个简单的环形数组来存储历史事件版本当key被修改后相关事件就会被添加到数组中来。若超过eventQueue的容量则淘汰最旧的事件。在etcd v2中eventQueue的容量是固定的1000因此它最多只会保存1000条事件记录不会占用大量etcd内存导致etcd OOM。
```
type EventHistory struct {
Queue eventQueue
StartIndex uint64
LastIndex uint64
rwl sync.RWMutex
}
```
但是它的缺陷显而易见的固定的事件窗口只能保存有限的历史事件版本是不可靠的。当写请求较多的时候、client与server网络出现波动等异常时很容易导致事件丢失client不得不触发大量的expensive查询操作以获取最新的数据及版本号才能持续监听数据。
特别是对于重度依赖Watch机制的Kubernetes来说显然是无法接受的。因为这会导致控制器等组件频繁的发起expensive List Pod等资源操作导致APIServer/etcd出现高负载、OOM等对稳定性造成极大的伤害。
etcd v3的MVCC机制正如上一节课所介绍的就是为解决etcd v2 Watch机制不可靠而诞生。相比etcd v2直接保存事件到内存的环形数组中etcd v3则是将一个key的历史修改版本保存在boltdb里面。boltdb是一个基于磁盘文件的持久化存储因此它重启后历史事件不像etcd v2一样会丢失同时你可通过配置压缩策略来控制保存的历史版本数在压缩篇我会和你详细讨论它。
最后watch命令中的版本号具有什么作用呢?
在上一节课中我们深入介绍了它的含义版本号是etcd逻辑时钟当client因网络等异常出现连接闪断后通过版本号它就可从server端的boltdb中获取错过的历史事件而无需全量同步它是etcd Watch机制数据增量同步的核心。
## 可靠的事件推送机制
再看第三个问题当client和server端出现短暂网络波动等异常因素后导致事件堆积时server端会丢弃事件吗若你监听的历史版本号server端不存在了你的代码该如何处理
第三个问题的本质是**可靠事件推送机制**要搞懂它我们就得弄懂etcd Watch特性的整体架构、核心流程下图是Watch特性整体架构图。
### 整体架构
<img src="https://static001.geekbang.org/resource/image/42/bf/42575d8d0a034e823b8e48d4ca0a49bf.png" alt="">
我先通过上面的架构图给你简要介绍下一个watch请求流程让你对全流程有个整体的认识。
当你通过etcdctl或API发起一个watch key请求的时候etcd的gRPCWatchServer收到watch请求后会创建一个serverWatchStream, 它负责接收client的gRPC Stream的create/cancel watcher请求(recvLoop goroutine)并将从MVCC模块接收的Watch事件转发给client(sendLoop goroutine)。
当serverWatchStream收到create watcher请求后serverWatchStream会调用MVCC模块的WatchStream子模块分配一个watcher id并将watcher注册到MVCC的WatchableKV模块。
在etcd启动的时候WatchableKV模块会运行syncWatchersLoop和syncVictimsLoop goroutine分别负责不同场景下的事件推送它们也是Watch特性可靠性的核心之一。
从架构图中你可以看到Watch特性的核心实现是WatchableKV模块下面我就为你抽丝剥茧看看"etcdctl watch hello -w=json --rev=1"命令在WatchableKV模块是如何处理的面对各类异常它如何实现可靠事件推送
**etcd核心解决方案是复杂度管理问题拆分。**
etcd根据不同场景对问题进行了分解将watcher按场景分类实现了轻重分离、低耦合。我首先给你介绍下synced watcher、unsynced watcher它们各自的含义。
**synced watcher**顾名思义表示此类watcher监听的数据都已经同步完毕在等待新的变更。
如果你创建的watcher未指定版本号(默认0)、或指定的版本号大于etcd sever当前最新的版本号(currentRev)那么它就会保存到synced watcherGroup中。watcherGroup负责管理多个watcher能够根据key快速找到监听该key的一个或多个watcher。
**unsynced watcher**表示此类watcher监听的数据还未同步完成落后于当前最新数据变更正在努力追赶。
如果你创建的watcher指定版本号小于etcd server当前最新版本号那么它就会保存到unsynced watcherGroup中。比如我们的这个案例中watch带指定版本号1监听时版本号1和etcd server当前版本之间的数据并未同步给你因此它就属于此类。
从以上介绍中,我们可以将可靠的事件推送机制拆分成最新事件推送、异常场景重试、历史事件推送机制三个子问题来进行分析。
下面是第一个子问题,最新事件推送机制。
### 最新事件推送机制
当etcd收到一个写请求key-value发生变化的时候处于syncedGroup中的watcher是如何获取到最新变化事件并推送给client的呢
<img src="https://static001.geekbang.org/resource/image/5y/48/5yy0cbf2833c438812086287d2ebf948.png" alt="">
当你创建完成watcher后此时你执行put hello修改操作时如上图所示请求经过KVServer、Raft模块后Apply到状态机时在MVCC的put事务中它会将本次修改的后的mvccpb.KeyValue保存到一个changes数组中。
在put事务结束时如下面的精简代码所示它会将KeyValue转换成Event事件然后回调watchableStore.notify函数流程5。notify会匹配出监听过此key并处于synced watcherGroup中的watcher同时事件中的版本号要大于等于watcher监听的最小版本号才能将事件发送到此watcher的事件channel中。
serverWatchStream的sendLoop goroutine监听到channel消息后读出消息立即推送给client流程6和7至此完成一个最新修改事件推送。
```
evs := make([]mvccpb.Event, len(changes))
for i, change := range changes {
evs[i].Kv = &amp;changes[i]
if change.CreateRevision == 0 {
evs[i].Type = mvccpb.DELETE
evs[i].Kv.ModRevision = rev
} else {
evs[i].Type = mvccpb.PUT
}
}
tw.s.notify(rev, evs)
```
注意接收Watch事件channel的buffer容量默认1024(etcd v3.4.9)。若client与server端因网络波动、高负载等原因导致推送缓慢buffer满了事件会丢失吗
这就是第二个子问题,异常场景的重试机制。
### 异常场景重试机制
若出现channel buffer满了etcd为了保证Watch事件的高可靠性并不会丢弃它而是将此watcher从synced watcherGroup中删除然后将此watcher和事件列表保存到一个名为受害者victim的watcherBatch结构中通过**异步机制重试**保证事件的可靠性。
还有一个点你需要注意的是notify操作它是在修改事务结束时同步调用的必须是轻量级、高性能、无阻塞的否则会严重影响集群写性能。
那么若因网络波动、CPU高负载等异常导致watcher处于victim集合中后etcd是如何处理这种slow watcher呢
在介绍Watch机制整体架构时我们知道WatchableKV模块会启动两个异步goroutine其中一个是syncVictimsLoop正是它负责slower watcher的堆积的事件推送。
它的基本工作原理是遍历victim watcherBatch数据结构尝试将堆积的事件再次推送到watcher的接收channel中。若推送失败则再次加入到victim watcherBatch数据结构中等待下次重试。
若推送成功watcher监听的最小版本号(minRev)小于等于server当前版本号(currentRev)说明可能还有历史事件未推送需加入到unsynced watcherGroup中由下面介绍的历史事件推送机制推送minRev到currentRev之间的事件。
若watcher的最小版本号大于server当前版本号则加入到synced watcher集合中进入上面介绍的最新事件通知机制。
下面我给你画了一幅图总结各类watcher状态转换关系希望能帮助你快速厘清之间关系。
<img src="https://static001.geekbang.org/resource/image/40/8e/40ec1087113edfc9f7yy0f32394b948e.png" alt="">
介绍完最新事件推送、异常场景重试机制后,那历史事件推送机制又是怎么工作的呢?
### 历史事件推送机制
WatchableKV模块的另一个goroutinesyncWatchersLoop正是负责unsynced watcherGroup中的watcher历史事件推送。
在历史事件推送机制中如果你监听老的版本号已经被etcd压缩了client该如何处理
要了解这个问题我们就得搞清楚syncWatchersLoop如何工作它的核心支撑是boltdb中存储了key-value的历史版本。
syncWatchersLoop它会遍历处于unsynced watcherGroup中的每个watcher为了优化性能它会选择一批unsynced watcher批量同步找出这一批unsynced watcher中监听的最小版本号。
因boltdb的key是按版本号存储的因此可通过指定查询的key范围的最小版本号作为开始区间当前server最大版本号作为结束区间遍历boltdb获得所有历史数据。
然后将KeyValue结构转换成事件匹配出监听过事件中key的watcher后将事件发送给对应的watcher事件接收channel即可。发送完成后watcher从unsynced watcherGroup中移除、添加到synced watcherGroup中如下面的watcher状态转换图黑色虚线框所示。
<img src="https://static001.geekbang.org/resource/image/a7/b4/a7a04846de2be66f1162af8845b13ab4.png" alt="">
若watcher监听的版本号已经小于当前etcd server压缩的版本号历史变更数据就可能已丢失因此etcd server会返回ErrCompacted错误给client。client收到此错误后需重新获取数据最新版本号后再次Watch。你在业务开发过程中使用Watch API最常见的一个错误之一就是未处理此错误。
## 高效的事件匹配
介绍完可靠的事件推送机制后最后我们再看第四个问题如果你创建了上万个watcher监听key变化当server端收到一个写请求后etcd是如何根据变化的key快速找到监听它的watcher呢一个个遍历watcher吗
显然一个个遍历watcher是最简单的方法但是它的时间复杂度是O(N)在watcher数较多的场景下会导致性能出现瓶颈。更何况etcd是在执行一个写事务结束时同步触发事件通知流程的若匹配watcher开销较大将严重影响etcd性能。
那使用什么数据结构来快速查找哪些watcher监听了一个事件中的key呢
也许你会说使用map记录下哪些watcher监听了什么key不就可以了吗 etcd的确使用map记录了监听单个key的watcher但是你要注意的是Watch特性不仅仅可以监听单key它还可以指定监听key范围、key前缀因此etcd还使用了如下的区间树。
<img src="https://static001.geekbang.org/resource/image/5a/88/5ae0a99629021e4a05c08yyd0df92f88.png" alt="">
当收到创建watcher请求的时候它会把watcher监听的key范围插入到上面的区间树中区间的值保存了监听同样key范围的watcher集合/watcherSet。
当产生一个事件时etcd首先需要从map查找是否有watcher监听了单key其次它还需要从区间树找出与此key相交的所有区间然后从区间的值获取监听的watcher集合。
区间树支持快速查找一个key是否在某个区间内时间复杂度O(LogN)因此etcd基于map和区间树实现了watcher与事件快速匹配具备良好的扩展性。
## 小结
最后我们来小结今天的内容我通过一个Watch特性初体验提出了Watch特性设计实现的四个核心问题分别是获取事件机制、事件历史版本存储、如何实现可靠的事件推送机制、如何高效的将事件与watcher进行匹配。
在获取事件机制、事件历史版本存储两个问题中我给你介绍了etcd v2在使用HTTP/1.x轮询、滑动窗口时存在大量的连接数、丢事件等问题导致扩展性、稳定性较差。
而etcd v3 Watch特性优化思路是基于HTTP/2的流式传输、多路复用实现了一个连接支持多个watcher减少了大量连接数事件存储也从滑动窗口优化成稳定可靠的MVCC机制历史版本保存在磁盘中具备更好的扩展性、稳定性。
在实现可靠的事件推送机制问题中我通过一个整体架构图带你了解整个Watch机制的核心链路数据推送流程。
Watch特性的核心实现模块是watchableStore它通过将watcher划分为synced/unsynced/victim三类将问题进行了分解并通过多个后台异步循环 goroutine负责不同场景下的事件推送提供了各类异常等场景下的Watch事件重试机制尽力确保变更事件不丢失、按逻辑时钟版本号顺序推送给client。
最后一个事件匹配性能问题etcd基于map和区间树数实现了watcher与事件快速匹配保障了大规模场景下的Watch机制性能和读写稳定性。
## 思考题
好了,这节课到这里也就结束了。我们一块来做一下思考题吧。
业务场景是希望agent能通过Watch机制监听server端下发给它的任务信息简要实现如下你认为它存在哪些问题呢 它一定能监听到server下发给其的所有任务信息吗欢迎你给出正确的解决方案。
```
taskPrefix := &quot;/task/&quot; + &quot;Agent IP&quot;
rsp, err := cli.Get(context.Background(), taskPrefix, clientv3.WithPrefix())
if err != nil {
log.Fatal(err)
}
// to do something
// ....
// Watch taskPrefix
rch := cli.Watch(context.Background(), taskPrefix, clientv3.WithPrefix())
for wresp := range rch {
for _, ev := range wresp.Events {
fmt.Printf(&quot;%s %q : %q\n&quot;, ev.Type, ev.Kv.Key, ev.Kv.Value)
}
}
```
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。

View File

@@ -0,0 +1,316 @@
<audio id="audio" title="09 | 事务如何安全地实现多key操作" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f2/a7/f2fe6bd7faae53297a3a389899f525a7.mp3"></audio>
你好,我是唐聪。
在软件开发过程中我们经常会遇到需要批量执行多个key操作的业务场景比如转账案例中Alice给Bob转账100元Alice账号减少100Bob账号增加100这涉及到多个key的原子更新。
无论发生任何故障我们应用层期望的结果是要么两个操作一起成功要么两个一起失败。我们无法容忍出现一个成功一个失败的情况。那么etcd是如何解决多key原子更新问题呢
这正是我今天要和你分享的主题——事务,它就是为了**简化应用层的编程模型**而诞生的。我将通过转账案例为你剖析etcd事务实现让你了解etcd如何实现事务ACID特性的以及MVCC版本号在事务中的重要作用。希望通过本节课帮助你在业务开发中正确使用事务保证软件代码的正确性。
## 事务特性初体验及API
如何使用etcd实现Alice向Bob转账功能呢
在etcd v2的时候 etcd提供了CASCompare and swap然而其只支持单key不支持多key因此无法满足类似转账场景的需求。严格意义上说CAS称不上事务无法实现事务的各个隔离级别。
etcd v3为了解决多key的原子操作问题提供了全新迷你事务API同时基于MVCC版本号它可以实现各种隔离级别的事务。它的基本结构如下
```
client.Txn(ctx).If(cmp1, cmp2, ...).Then(op1, op2, ...,).Else(op1, op2, …)
```
从上面结构中你可以看到,**事务API由If语句、Then语句、Else语句组成**这与我们平时常见的MySQL事务完全不一样。
它的基本原理是在If语句中你可以添加一系列的条件表达式若条件表达式全部通过检查则执行Then语句的get/put/delete等操作否则执行Else的get/put/delete等操作。
那么If语句支持哪些检查项呢
首先是**key的最近一次修改版本号mod_revision**简称mod。你可以通过它检查key最近一次被修改时的版本号是否符合你的预期。比如当你查询到Alice账号资金为100元时它的mod_revision是v1当你发起转账操作时你得确保Alice账号上的100元未被挪用这就可以通过mod("Alice") = "v1" 条件表达式来保障转账安全性。
其次是**key的创建版本号create_revision**简称create。你可以通过它检查key是否已存在。比如在分布式锁场景里只有分布式锁key(lock)不存在的时候你才能发起put操作创建锁这时你可以通过create("lock") = "0"来判断因为一个key不存在的话它的create_revision版本号就是0。
接着是**key的修改次数version**。你可以通过它检查key的修改次数是否符合预期。比如你期望key在修改次数小于3时才能发起某些操作时可以通过version("key") &lt; "3"来判断。
最后是**key的value值**。你可以通过检查key的value值是否符合预期然后发起某些操作。比如期望Alice的账号资金为200, value("Alice") = "200"。
If语句通过以上MVCC版本号、value值、各种比较运算符(等于、大于、小于、不等于),实现了灵活的比较的功能,满足你各类业务场景诉求。
下面我给出了一个使用etcdctl的txn事务命令基于以上介绍的特性初步实现的一个Alice向Bob转账100元的事务。
Alice和Bob初始账上资金分别都为200元事务首先判断Alice账号资金是否为200若是则执行转账操作不是则返回最新资金。etcd是如何执行这个事务的呢**这个事务实现上有哪些问题呢?**
```
$ etcdctl txn -i
compares: //对应If语句
value(&quot;Alice&quot;) = &quot;200&quot; //判断Alice账号资金是否为200
success requests (get, put, del): //对应Then语句
put Alice 100 //Alice账号初始资金200减100
put Bob 300 //Bob账号初始资金200加100
failure requests (get, put, del): //对应Else语句
get Alice
get Bob
SUCCESS
OK
OK
```
## 整体流程
<img src="https://static001.geekbang.org/resource/image/e4/d3/e41a4f83bda29599efcf06f6012b0bd3.png" alt="">
在和你介绍上面案例中的etcd事务原理和问题前我先给你介绍下事务的整体流程为我们后面介绍etcd事务ACID特性的实现做准备。
上图是etcd事务的执行流程当你通过client发起一个txn转账事务操作时通过gRPC KV Server、Raft模块处理后在Apply模块执行此事务的时候它首先对你的事务的If语句进行检查也就是ApplyCompares操作如果通过此操作则执行ApplyTxn/Then语句否则执行ApplyTxn/Else语句。
在执行以上操作过程中它会根据事务是否只读、可写通过MVCC层的读写事务对象执行事务中的get/put/delete各操作也就是我们上一节课介绍的MVCC对key的读写原理。
## 事务ACID特性
了解完事务的整体执行流程后那么etcd应该如何正确实现上面案例中Alice向Bob转账的事务呢别着急我们先来了解一下事务的ACID特性。在你了解了etcd事务ACID特性实现后这个转账事务案例的正确解决方案也就简单了。
ACID是衡量事务的四个特性由原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability组成。接下来我就为你分析ACID特性在etcd中的实现。
### 原子性与持久性
事务的原子性Atomicity是指在一个事务中所有请求要么同时成功要么同时失败。比如在我们的转账案例中是绝对无法容忍Alice账号扣款成功但是Bob账号资金到账失败的场景。
持久性Durability是指事务一旦提交其所做的修改会永久保存在数据库。
软件系统在运行过程中会遇到各种各样的软硬件故障如果etcd在执行上面事务过程中刚执行完扣款命令put Alice 100就突然crash了它是如何保证转账事务的原子性与持久性的呢
<img src="https://static001.geekbang.org/resource/image/cf/9e/cf94ce8fc0649fe5cce45f8b7468019e.png" alt="">
如上图转账事务流程图所示etcd在执行一个事务过程中任何时间点都可能会出现节点crash等异常问题。我在图中给你标注了两个关键的异常时间点它们分别是T1和T2。接下来我分别为你分析一下etcd在这两个关键时间点异常后是如何保证事务的原子性和持久性的。
#### T1时间点
T1时间点是在Alice账号扣款100元完成时Bob账号资金还未成功增加时突然发生了crash。
从前面介绍的etcd写原理和上面流程图我们可知此时MVCC写事务持有boltdb写锁仅是将修改提交到了内存中保证幂等性、防止日志条目重复执行的一致性索引consistent index也并未更新。同时负责boltdb事务提交的goroutine因无法持有写锁也并未将事务提交到持久化存储中。
因此T1时间点发生crash异常后事务并未成功执行和持久化任意数据到磁盘上。在节点重启时etcd server会重放WAL中的已提交日志条目再次执行以上转账事务。因此不会出现Alice扣款成功、Bob到帐失败等严重Bug极大简化了业务的编程复杂度。
#### T2时间点
T2时间点是在MVCC写事务完成转账server返回给client转账成功后boltdb的事务提交goroutine批量将事务持久化到磁盘中时发生了crash。这时etcd又是如何保证原子性和持久性的呢?
我们知道一致性索引consistent index字段值是和key-value数据在一个boltdb事务里同时持久化到磁盘中的。若在boltdb事务提交过程中发生crash了简单情况是consistent index和key-value数据都更新失败。那么当节点重启etcd server重放WAL中已提交日志条目时同样会再次应用转账事务到状态机中因此事务的原子性和持久化依然能得到保证。
更复杂的情况是当boltdb提交事务的时候会不会部分数据提交成功部分数据提交失败呢这个问题我将在下一节课通过深入介绍boltdb为你解答。
了解完etcd事务的原子性和持久性后那一致性又是怎么一回事呢事务的一致性难道是指各个节点数据一致性吗
### 一致性
在软件系统中到处可见一致性Consistency的表述其实在不同场景下它的含义是不一样的。
首先分布式系统中多副本数据一致性它是指各个副本之间的数据是否一致比如Redis的主备是异步复制的那么它的一致性是最终一致性的。
其次是CAP原理中的一致性是指可线性化。核心原理是虽然整个系统是由多副本组成但是通过线性化能力支持对client而言就如一个副本应用程序无需关心系统有多少个副本。
然后是一致性哈希,它是一种分布式系统中的数据分片算法,具备良好的分散性、平衡性。
最后是事务中的一致性,它是指事务变更前后,数据库必须满足若干恒等条件的状态约束,**一致性往往是由数据库和业务程序两方面来保障的**。
**在Alice向Bob转账的案例中有哪些恒等状态呢**
很明显转账系统内的各账号资金总额在转账前后应该一致同时各账号资产不能小于0。
为了帮助你更好地理解前面转账事务实现的问题,下面我给你画了幅两个并发转账事务的流程图。
图中有两个并发的转账事务Mike向Bob转账100元Alice也向Bob转账100元按照我们上面的事务实现从下图可知转账前系统总资金是600元转账后却只有500元了因此它无法保证转账前后账号系统内的资产一致性导致了资产凭空消失破坏了事务的一致性。
<img src="https://static001.geekbang.org/resource/image/1f/ea/1ff951756c0ffc427e5a064e3cf8caea.png" alt="">
事务一致性被破坏的根本原因是事务中缺少对Bob账号资产是否发生变化的判断这就导致账号资金被覆盖。
为了确保事务的一致性,一方面,业务程序在转账逻辑里面,需检查转账者资产大于等于转账金额。在事务提交时,通过账号资产的版本号,确保双方账号资产未被其他事务修改。若双方账号资产被其他事务修改,账号资产版本号会检查失败,这时业务可以通过获取最新的资产和版本号,发起新的转账事务流程解决。
另一方面etcd会通过WAL日志和consistent index、boltdb事务特性去确保事务的原子性因此不会有部分成功部分失败的操作导致资金凭空消失、新增。
介绍完事务的原子性和持久化、一致性后我们再看看etcd又是如何提供各种隔离级别的事务在转账过程中其他client能看到转账的中间状态吗(如Alice扣款成功Bob还未增加时)
### 隔离性
ACID中的I是指Isolation也就是事务的隔离性它是指事务在执行过程中的可见性。常见的事务隔离级别有以下四种。
首先是**未提交读**Read UnCommitted也就是一个client能读取到未提交的事务。比如转账事务过程中Alice账号资金扣除后Bob账号上资金还未增加这时如果其他client读取到这种中间状态它会发现系统总金额钱减少了破坏了事务一致性的约束。
其次是**已提交读**Read Committed指的是只能读取到已经提交的事务数据但是存在不可重复读的问题。比如事务开始时你读取了Alice和Bob资金这时其他事务修改Alice和Bob账号上的资金你在事务中再次读取时会读取到最新资金导致两次读取结果不一样。
接着是**可重复读**Repeated Read它是指在一个事务中同一个读操作get Alice/Bob在事务的任意时刻都能得到同样的结果其他修改事务提交后也不会影响你本事务所看到的结果。
最后是**串行化**Serializable它是最高的事务隔离级别读写相互阻塞通过牺牲并发能力、串行化来解决事务并发更新过程中的隔离问题。对于串行化我要和你特别补充一点很多人认为它都是通过读写锁来实现事务一个个串行提交的其实这只是在基于锁的并发控制数据库系统实现而已。**为了优化性能在基于MVCC机制实现的各个数据库系统中提供了一个名为“可串行化的快照隔离”级别相比悲观锁而言它是一种乐观并发控制通过快照技术实现的类似串行化的效果事务提交时能检查是否冲突。**
下面我重点和你介绍下未提交读、已提交读、可重复读、串行化快照隔离。
#### 未提交读
首先是最低的事务隔离级别未提交读。我们通过如下一个转账事务时间序列图来分析下一个client能否读取到未提交事务修改的数据是否存在脏读。
<img src="https://static001.geekbang.org/resource/image/6a/8d/6a526be4949a383fd5263484c706d68d.png" alt="">
图中有两个事务一个是用户查询Alice和Bob资产的事务一个是我们执行Alice向Bob转账的事务。
如图中所示若在Alice向Bob转账事务执行过程中etcd server收到了client查询Alice和Bob资产的读请求显然此时我们无法接受client能读取到一个未提交的事务因为这对应用程序而言会产生严重的BUG。那么etcd是如何保证不出现这种场景呢
我们知道etcd基于boltdb实现读写操作的读请求由boltdb的读事务处理你可以理解为快照读。写请求由boltdb写事务处理etcd定时将一批写操作提交到boltdb并清空buffer。
由于etcd是批量提交写事务的而读事务又是快照读因此当MVCC写事务完成时它需要更新buffer这样下一个读请求到达时才能从buffer中获取到最新数据。
在我们的场景中转账事务并未结束执行put Alice为100的操作不会回写buffer因此避免了脏读的可能性。用户此刻从boltdb快照读事务中查询到的Alice和Bob资产都为200。
从以上分析可知etcd并未使用悲观锁来解决脏读的问题而是通过MVCC机制来实现读写不阻塞并解决脏读的问题。
#### 已提交读、可重复读
比未提交读隔离级别更高的是已提交读它是指在事务中能读取到已提交数据但是存在不可重复读的问题。已提交读也就是说你每次读操作若未增加任何版本号限制默认都是当前读etcd会返回最新已提交的事务结果给你。
如何理解不可重复读呢?
在上面用户查询Alice和Bob事务的案例中第一次查出来资产都是200第二次是Alice为100Bob为300通过读已提交模式你能及时获取到etcd最新已提交的事务结果但是出现了不可重复读两次读出来的Alice和Bob资产不一致。
那么如何实现可重复读呢?
你可以通过MVCC快照读或者参考etcd的事务框架STM实现它在事务中维护一个读缓存优先从读缓存中查找不存在则从etcd查询并更新到缓存中这样事务中后续读请求都可从缓存中查找确保了可重复读。
最后我们再来重点介绍下什么是串行化快照隔离。
#### 串行化快照隔离
串行化快照隔离是最严格的事务隔离级别它是指在在事务刚开始时首先获取etcd当前的版本号rev事务中后续发出的读请求都带上这个版本号rev告诉etcd你需要获取那个时间点的快照数据etcd的MVCC机制就能确保事务中能读取到同一时刻的数据。
**同时,它还要确保事务提交时,你读写的数据都是最新的,未被其他人修改,也就是要增加冲突检测机制。**当事务提交出现冲突的时候依赖client重试解决安全地实现多key原子更新。
那么我们应该如何为上面一致性案例中,两个并发转账的事务,增加冲突检测机制呢?
核心就是我们前面介绍MVCC的版本号我通过下面的并发转账事务流程图为你解释它是如何工作的。
<img src="https://static001.geekbang.org/resource/image/3b/26/3b4c7fb43e03a38aceb2a8c2d5c92226.png" alt="">
如上图所示事务AAlice向Bob转账100元事务BMike向Bob转账100元两个事务同时发起转账操作。
一开始时Mike的版本号(指mod_revision)是4Bob版本号是3Alice版本号是2资产各自200。为了防止并发写事务冲突etcd在一个写事务开始时会独占一个MVCC大写锁。
事务A会先去etcd查询当前Alice和Bob的资产版本号用于在事务提交时做冲突检测。在事务A查询后事务B获得MVCC写锁并完成转账事务Mike和Bob账号资产分别为100300版本号都为5。
事务B完成后事务A获得写锁开始执行事务。
为了解决并发事务冲突问题事务A中增加了冲突检测期望的Alice版本号应为2Bob为3。结果事务B的修改导致Bob版本号变成了5因此此事务会执行失败分支再次查询Alice和Bob版本号和资产发起新的转账事务成功通过MVCC冲突检测规则mod("Alice") = 2 和 mod("Bob") = 5 后更新Alice账号资产为100Bob资产为400完成转账操作。
通过上面介绍的快照读和MVCC冲突检测检测机制etcd就可实现串行化快照隔离能力。
### 转账案例应用
介绍完etcd事务ACID特性实现后你很容易发现事务特性初体验中的案例问题了它缺少了完整事务的冲突检测机制。
首先你可通过一个事务获取Alice和Bob账号的上资金和版本号用以判断Alice是否有足够的金额转账给Bob和事务提交时做冲突检测。 你可通过如下etcdctl txn命令获取Alice和Bob账号的资产和最后一次修改时的版本号(mod_revision):
```
$ etcdctl txn -i -w=json
compares:
success requests (get, put, del):
get Alice
get Bob
failure requests (get, put, del):
{
&quot;kvs&quot;:[
{
&quot;key&quot;:&quot;QWxpY2U=&quot;,
&quot;create_revision&quot;:2,
&quot;mod_revision&quot;:2,
&quot;version&quot;:1,
&quot;value&quot;:&quot;MjAw&quot;
}
],
......
&quot;kvs&quot;:[
{
&quot;key&quot;:&quot;Qm9i&quot;,
&quot;create_revision&quot;:3,
&quot;mod_revision&quot;:3,
&quot;version&quot;:1,
&quot;value&quot;:&quot;MzAw&quot;
}
],
}
```
其次发起资金转账操作Alice账号减去100Bob账号增加100。为了保证转账事务的准确性、一致性提交事务的时候需检查Alice和Bob账号最新修改版本号与读取资金时的一致(compares操作中增加版本号检测),以保证其他事务未修改两个账号的资金。
若compares操作通过检查则执行转账操作否则执行查询Alice和Bob账号资金操作命令如下:
```
$ etcdctl txn -i
compares:
mod(&quot;Alice&quot;) = &quot;2&quot;
mod(&quot;Bob&quot;) = &quot;3&quot;
success requests (get, put, del):
put Alice 100
put Bob 300
failure requests (get, put, del):
get Alice
get Bob
SUCCESS
OK
OK
```
到这里我们就完成了一个安全的转账事务操作从以上流程中你可以发现自己从0到1实现一个完整的事务还是比较繁琐的幸运的是etcd社区基于以上介绍的事务特性提供了一个简单的事务框架[STM](https://github.com/etcd-io/etcd/blob/v3.4.9/clientv3/concurrency/stm.go),构建了各个事务隔离级别类,帮助你进一步简化应用编程复杂度。
## 小结
最后我们来小结下今天的内容。首先我给你介绍了事务API的基本结构它由If、Then、Else语句组成。
其中If支持多个比较规则它是用于事务提交时的冲突检测比较的对象支持key的**mod_revision**、**create_revision、version、value值**。随后我给你介绍了整个事务执行的基本流程Apply模块首先执行If的比较规则为真则执行Then语句否则执行Else语句。
接着通过转账案例四幅转账事务时间序列图我为你分析了事务的ACID特性剖析了在etcd中事务的ACID特性的实现。
<li>
原子性是指一个事务要么全部成功要么全部失败etcd基于WAL日志、consistent index、boltdb的事务能力提供支持。
</li>
<li>
一致性是指事务转账前后的,数据库和应用程序期望的恒等状态应该保持不变,这通过数据库和业务应用程序相互协作完成。
</li>
<li>
持久性是指事务提交后,数据不丢失,
</li>
<li>
隔离性是指事务提交过程中的可见性etcd不存在脏读基于MVCC机制、boltdb事务你可以实现可重复读、串行化快照隔离级别的事务保障并发事务场景中你的数据安全性。
</li>
## 思考题
在数据库事务中,有各种各样的概念,比如脏读、脏写、不可重复读与读倾斜、幻读与写倾斜、更新丢失、快照隔离、可串行化快照隔离? 你知道它们的含义吗?
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。

View File

@@ -0,0 +1,322 @@
<audio id="audio" title="10 | boltdb如何持久化存储你的key-value数据" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/99/45/9944d13c96935f676febe39aef0b9045.mp3"></audio>
你好,我是唐聪。
在前面的课程里我和你多次提到过etcd数据存储在boltdb。那么boltdb是如何组织你的key-value数据的呢当你读写一个key时boltdb是如何工作的
今天我将通过一个写请求在boltdb中执行的简要流程分析其背后的boltdb的磁盘文件布局帮助你了解page、node、bucket等核心数据结构的原理与作用搞懂boltdb基于B+ tree、各类page实现查找、更新、事务提交的原理让你明白etcd为什么适合读多写少的场景。
## boltdb磁盘布局
在介绍一个put写请求在boltdb中执行原理前我先给你从整体上介绍下平时你所看到的etcd db文件的磁盘布局让你了解下db文件的物理存储结构。
boltdb文件指的是你etcd数据目录下的member/snap/db的文件 etcd的key-value、lease、meta、member、cluster、auth等所有数据存储在其中。etcd启动的时候会通过mmap机制将db文件映射到内存后续可从内存中快速读取文件中的数据。写请求通过fwrite和fdatasync来写入、持久化数据到磁盘。
<img src="https://static001.geekbang.org/resource/image/a6/41/a6086a069a2cf52b38d60716780f2e41.png" alt="">
上图是我给你画的db文件磁盘布局从图中的左边部分你可以看到文件的内容由若干个page组成一般情况下page size为4KB。
page按照功能可分为元数据页(meta page)、B+ tree索引节点页(branch page)、B+ tree 叶子节点页(leaf page)、空闲页管理页(freelist page)、空闲页(free page)。
文件最开头的两个page是固定的db元数据meta page空闲页管理页记录了db中哪些页是空闲、可使用的。索引节点页保存了B+ tree的内部节点如图中的右边部分所示它们记录了key值叶子节点页记录了B+ tree中的key-value和bucket数据。
boltdb逻辑上通过B+ tree来管理branch/leaf page 实现快速查找、写入key-value数据。
## boltdb API
了解完boltdb的磁盘布局后那么如果要在etcd中执行一个put请求boltdb中是如何执行的呢 boltdb作为一个库提供了什么API给client访问写入数据
boltdb提供了非常简单的API给上层业务使用当我们执行一个put hello为world命令时boltdb实际写入的key是版本号value为mvccpb.KeyValue结构体。
这里我们简化下假设往key bucket写入一个key为r94value为world的字符串其核心代码如下
```
// 打开boltdb文件获取db对象
db,err := bolt.Open(&quot;db&quot; 0600 nil)
if err != nil {
log.Fatal(err)
}
defer db.Close()
// 参数true表示创建一个写事务false读事务
tx,err := db.Begin(true)
if err != nil {
return err
}
defer tx.Rollback()
// 使用事务对象创建key bucket
b,err := tx.CreatebucketIfNotExists([]byte(&quot;key&quot;))
if err != nil {
return err
}
// 使用bucket对象更新一个key
if err := b.Put([]byte(&quot;r94&quot;),[]byte(&quot;world&quot;)); err != nil {
return err
}
// 提交事务
if err := tx.Commit(); err != nil {
return err
}
```
如上所示通过boltdb的Open API我们获取到boltdb的核心对象db实例后然后通过db的Begin API开启写事务获得写事务对象tx。
通过写事务对象tx 你可以创建bucket。这里我们创建了一个名为key的bucket如果不存在并使用bucket API往其中更新了一个key为r94value为world的数据。最后我们使用写事务的Commit接口提交整个事务完成bucket创建和key-value数据写入。
看起来是不是非常简单神秘的boltdb并未有我们想象的那么难。然而其API简单的背后却是boltdb的一系列巧妙的设计和实现。
一个key-value数据如何知道该存储在db在哪个page如何快速找到你的key-value数据事务提交的原理又是怎样的呢
接下来我就和你浅析boltdb背后的奥秘。
## 核心数据结构介绍
上面我们介绍boltdb的磁盘布局时提到boltdb整个文件由一个个page组成。最开头的两个page描述db元数据信息而它正是在client调用boltdb Open API时被填充的。那么描述磁盘页面的page数据结构是怎样的呢元数据页又含有哪些核心数据结构
boltdb本身自带了一个工具bbolt它可以按页打印出db文件的十六进制的内容下面我们就使用此工具来揭开db文件的神秘面纱。
下图左边的十六进制是执行如下[bbolt dump](https://github.com/etcd-io/bbolt/blob/master/cmd/bbolt/main.go)命令所打印的boltdb第0页的数据图的右边是对应的page磁盘页结构和meta page的数据结构。
```
$ ./bbolt dump ./infra1.etcd/member/snap/db 0
```
<img src="https://static001.geekbang.org/resource/image/94/16/94a4b5yydab7yy9a3f340632274f9616.png" alt="">
一看上图中的十六进制数据你可能很懵没关系在你了解page磁盘页结构、meta page数据结构后你就能读懂其含义了。
### page磁盘页结构
我们先了解下page磁盘页结构如上图所示它由页ID(id)、页类型(flags)、数量(count)、溢出页数量(overflow)、页面数据起始位置(ptr)字段组成。
页类型目前有如下四种0x01表示branch page0x02表示leaf page0x04表示meta page0x10表示freelist page。
数量字段仅在页类型为leaf和branch时生效溢出页数量是指当前页面数据存放不下需要向后再申请overflow个连续页面使用页面数据起始位置指向page的载体数据比如meta page、branch/leaf等page的内容。
### meta page数据结构
第0、1页我们知道它是固定存储db元数据的页(meta page)那么meta page它为了管理整个boltdb含有哪些信息呢
如上图中的meta page数据结构所示你可以看到它由boltdb的文件标识(magic)、版本号(version)、页大小(pagesize)、boltdb的根bucket信息(root bucket)、freelist页面ID(freelist)、总的页面数量(pgid)、上一次写事务ID(txid)、校验码(checksum)组成。
### meta page十六进制分析
了解完page磁盘页结构和meta page数据结构后我再结合图左边的十六进数据和你简要分析下其含义。
上图中十六进制输出的是db文件的page 0页结构左边第一列表示此行十六进制内容对应的文件起始地址每行16个字节。
结合page磁盘页和meta page数据结构我们可知第一行前8个字节描述pgid(忽略第一列)是0。接下来2个字节描述的页类型 其值为0x04表示meta page 说明此页的数据存储的是meta page内容因此ptr开始的数据存储的是meta page内容。
正如你下图中所看到的第二行首先含有一个4字节的magic number(0xED0CDAED)通过它来识别当前文件是否boltdb接下来是两个字节描述boltdb的版本号0x2 然后是四个字节的page size大小0x1000表示4096个字节四个字节的flags为0。
<img src="https://static001.geekbang.org/resource/image/09/c0/09d8a9174b4539718878fcfb9da84cc0.png" alt="">
第三行对应的就是meta page的root bucket结构16个字节它描述了boltdb的root bucket信息比如一个db中有哪些bucket bucket里面的数据存储在哪里。
第四行中前面的8个字节0x3表示freelist页面ID此页面记录了db当前哪些页面是空闲的。后面8个字节0x6表示当前db总的页面数。
第五行前面的8个字节0x1a表示上一次的写事务ID后面的8个字节表示校验码用于检测文件是否损坏。
了解完db元数据页面原理后那么boltdb是如何根据元数据页面信息快速找到你的bucket和key-value数据呢
这就涉及到了元数据页面中的root bucket它是个至关重要的数据结构。下面我们看看它是如何管理一系列bucket、帮助我们查找、写入key-value数据到boltdb中。
### bucket数据结构
如下命令所示你可以使用bbolt buckets命令输出一个db文件的bucket列表。执行完此命令后我们可以看到之前介绍过的auth/lease/meta等熟悉的bucket它们都是etcd默认创建的。那么boltdb是如何存储、管理bucket的呢
```
$ ./bbolt buckets ./infra1.etcd/member/snap/db
alarm
auth
authRoles
authUsers
cluster
key
lease
members
members_removed
meta
```
在上面我们提到过meta page中的有一个名为root、类型bucket的重要数据结构如下所示bucket由root和sequence两个字段组成root表示该bucket根节点的page id。注意meta page中的bucket.root字段存储的是db的root bucket页面信息你所看到的key/lease/auth等bucket都是root bucket的子bucket。
```
type bucket struct {
root pgid // page id of the bucket's root-level page
sequence uint64 // monotonically incrementing, used by NextSequence()
}
```
<img src="https://static001.geekbang.org/resource/image/14/9b/14f9c6f5061f44ea3c1d8de4f47a5b9b.png" alt="">
上面meta page十六进制图中第三行的16个字节就是描述的root bucket信息。root bucket指向的page id为4page id为4的页面是什么类型呢 我们可以通过如下bbolt pages命令看看各个page类型和元素数量从下图结果可知4号页面为leaf page。
```
$ ./bbolt pages ./infra1.etcd/member/snap/db
ID TYPE ITEMS OVRFLW
======== ========== ====== ======
0 meta 0
1 meta 0
2 free
3 freelist 2
4 leaf 10
5 free
```
通过上面的分析可知当bucket比较少时我们子bucket数据可直接从meta page里指向的leaf page中找到。
### leaf page
meta page的root bucket直接指向的是page id为4的leaf page page flag为0x02 leaf page它的磁盘布局如下图所示前半部分是leafPageElement数组后半部分是key-value数组。
<img src="https://static001.geekbang.org/resource/image/0e/e8/0e70f52dc9752e2yy19f74a044530ee8.png" alt="">
leafPageElement包含leaf page的类型flags 通过它可以区分存储的是bucket名称还是key-value数据。
当flag为bucketLeafFlag(0x01)时表示存储的是bucket数据否则存储的是key-value数据leafPageElement它还含有key-value的读取偏移量key-value大小根据偏移量和key-value大小我们就可以方便地从leaf page中解析出所有key-value对。
当存储的是bucket数据的时候key是bucket名称value则是bucket结构信息。bucket结构信息含有root page信息通过root page基于B+ tree查找算法你可以快速找到你存储在这个bucket下面的key-value数据所在页面。
从上面分析你可以看到每个子bucket至少需要一个page来存储其下面的key-value数据如果子bucket数据量很少就会造成磁盘空间的浪费。实际上boltdb实现了inline bucket在满足一些条件限制的情况下可以将小的子bucket内嵌在它的父亲叶子节点上友好的支持了大量小bucket。
为了方便大家快速理解核心原理本节我们讨论的bucket是假设都是非inline bucket。
那么boltdb是如何管理大量bucket、key-value的呢
### branch page
boltdb使用了B+ tree来高效管理所有子bucket和key-value数据因此它可以支持大量的bucket和key-value只不过B+ tree的根节点不再直接指向leaf page而是branch page索引节点页。branch page flags为0x01。它的磁盘布局如下图所示前半部分是branchPageElement数组后半部分是key数组。
<img src="https://static001.geekbang.org/resource/image/61/9d/61af0c7e7e5beb05be6130bda29da49d.png" alt="">
branchPageElement包含key的读取偏移量、key大小、子节点的page id。根据偏移量和key大小我们就可以方便地从branch page中解析出所有key然后二分搜索匹配key获取其子节点page id递归搜索直至从bucketLeafFlag类型的leaf page中找到目的bucket name。
注意boltdb在内存中使用了一个名为node的数据结构来保存page反序列化的结果。下面我给出了一个boltdb读取page到node的代码片段你可以直观感受下。
```
func (n *node) read(p *page) {
n.pgid = p.id
n.isLeaf = ((p.flags &amp; leafPageFlag) != 0)
n.inodes = make(inodes, int(p.count))
for i := 0; i &lt; int(p.count); i++ {
inode := &amp;n.inodes[i]
if n.isLeaf {
elem := p.leafPageElement(uint16(i))
inode.flags = elem.flags
inode.key = elem.key()
inode.value = elem.value()
} else {
elem := p.branchPageElement(uint16(i))
inode.pgid = elem.pgid
inode.key = elem.key()
}
}
```
从上面分析过程中你会发现boltdb存储bucket和key-value原理是类似的将page划分成branch page、leaf page通过B+ tree来管理实现。boltdb为了区分leaf page存储的数据类型是bucket还是key-value增加了标识字段leafPageElement.flags因此key-value的数据存储过程我就不再重复分析了。
### freelist
介绍完bucket、key-value存储原理后我们再看meta page中的另外一个核心字段freelist它的作用是什么呢
我们知道boltdb将db划分成若干个page那么它是如何知道哪些page在使用中哪些page未使用呢
答案是boltdb通过meta page中的freelist来管理页面的分配freelist page中记录了哪些页是空闲的。当你在boltdb中删除大量数据的时候其对应的page就会被释放页ID存储到freelist所指向的空闲页中。当你写入数据的时候就可直接从空闲页中申请页面使用。
下面meta page十六进制图中第四行的前8个字节就是描述的freelist信息page id为3。我们可以通过bbolt page命令查看3号page内容如下所示它记录了2和5为空闲页与我们上面通过bbolt pages命令所看到的信息一致。
<img src="https://static001.geekbang.org/resource/image/4a/9a/4a4d05678cfb785618537d2f930e859a.png" alt="">
```
$ ./bbolt page ./infra1.etcd/member/snap/db 3
page ID: 3
page Type: freelist
Total Size: 4096 bytes
Item Count: 2
Overflow: 0
2
5
```
下图是freelist page存储结构pageflags为0x10表示freelist类型的页ptr指向空闲页id数组。注意在boltdb中支持通过多种数据结构数组和hashmap来管理free page这里我介绍的是数组。
<img src="https://static001.geekbang.org/resource/image/57/bb/57c6dd899c4cb56198a6092855161ebb.png" alt="">
## Open原理
了解完核心数据结构后我们就很容易搞懂boltdb Open API的原理了。
首先它会打开db文件并对其增加文件锁目的是防止其他进程也以读写模式打开它后操作meta和free page导致db文件损坏。
其次boltdb通过mmap机制将db文件映射到内存中并读取两个meta page到db对象实例中然后校验meta page的magic、version、checksum是否有效若两个meta page都无效那么db文件就出现了严重损坏导致异常退出。
## Put原理
那么成功获取db对象实例后通过bucket API创建一个bucket、发起一个Put请求更新数据时boltdb是如何工作的呢
根据我们上面介绍的bucket的核心原理它首先是根据meta page中记录root bucket的root page按照B+ tree的查找算法从root page递归搜索到对应的叶子节点page面返回key名称、leaf类型。
如果leaf类型为bucketLeafFlag且key相等那么说明已经创建过不允许bucket重复创建结束请求。否则往B+ tree中添加一个flag为bucketLeafFlag的keykey名称为bucket namevalue为bucket的结构。
创建完bucket后你就可以通过bucket的Put API发起一个Put请求更新数据。它的核心原理跟bucket类似根据子bucket的root page从root page递归搜索此key到leaf page如果没有找到则在返回的位置处插入新key和value。
为了方便你理解B+ tree查找、插入一个key原理我给你构造了的一个max degree为5的B+ tree下图是key r94的查找流程图。
那么如何确定这个key的插入位置呢
首先从boltdb的key bucket的root page里二分查找大于等于r94的key所在page最终找到key r9指向的page流程1。r9指向的page是个leaf pageB+ tree需要确保叶子节点key的有序性因此同样二分查找其插入位置将key r94插入到相关位置流程二
<img src="https://static001.geekbang.org/resource/image/e6/6e/e6d2c12de362b55c7c36c45e5b65706e.png" alt="">
在核心数据结构介绍中我和你提到boltdb在内存中通过node数据结构来存储page磁盘页内容它记录了key-value数据、page id、parent及children的node、B+ tree是否需要进行重平衡和分裂操作等信息。
因此当我们执行完一个put请求时它只是将值更新到boltdb的内存node数据结构里并未持久化到磁盘中。
## 事务提交原理
那么boltdb何时将数据持久化到db文件中呢
当你的代码执行tx.Commit API时它才会将我们上面保存到node内存数据结构中的数据持久化到boltdb中。下图我给出了一个事务提交的流程图接下来我就分别和你简要分析下各个核心步骤。
<img src="https://static001.geekbang.org/resource/image/e9/6f/e93935835e792363ae2edc5290f2266f.png" alt="">
首先从上面put案例中我们可以看到插入了一个新的元素在B+ tree的叶子节点它可能已不满足B+ tree的特性因此事务提交时第一步首先要调整B+ tree进行重平衡、分裂操作使其满足B+ tree树的特性。上面案例里插入一个key r94后经过重平衡、分裂操作后的B+ tree如下图所示。
<img src="https://static001.geekbang.org/resource/image/d3/8c/d31f483a10abeff34a8fef37941ef28c.png" alt="">
在重平衡、分裂过程中可能会申请、释放free pagefreelist所管理的free page也发生了变化。因此事务提交的第二步就是持久化freelist。
注意在etcd v3.4.9中为了优化写性能等freelist持久化功能是关闭的。etcd启动获取boltdb db对象的时候boltdb会遍历所有page构建空闲页列表。
事务提交的第三步就是将client更新操作产生的dirty page通过fdatasync系统调用持久化存储到磁盘中。
最后在执行写事务过程中meta page的txid、freelist等字段会发生变化因此事务的最后一步就是持久化meta page。
通过以上四大步骤我们就完成了事务提交的工作成功将数据持久化到了磁盘文件中安全地完成了一个put操作。
## 小结
最后我们来小结下今天的内容。首先我通过一幅boltdb磁盘布局图和bbolt工具为你解密了db文件的本质。db文件由meta page、freelist page、branch page、leaf page、free page组成。随后我结合bbolt工具和你深入介绍了meta page、branch page、leaf page、freelist page的数据结构帮助你了解key、value数据是如何存储到文件中的。
然后我通过分析一个put请求在boltdb中如何执行的。我从Open API获取db对象说起介绍了其通过mmap将db文件映射到内存构建meta page校验meta page的有效性再到创建bucket通过bucket API往boltdb添加key-value数据。
添加bucket和key-value操作本质是从B+ tree管理的page中找到插入的页和位置并将数据更新到page的内存node数据结构中。
真正持久化数据到磁盘是通过事务提交执行的。它首先需要通过一系列重平衡、分裂操作确保boltdb维护的B+ tree满足相关特性其次需要持久化freelist page并将用户更新操作产生的dirty page数据持久化到磁盘中最后则是持久化meta page。
## 思考题
事务提交过程中若持久化key-value数据到磁盘成功了此时突然掉电元数据还未持久化到磁盘那么db文件会损坏吗数据会丢失吗 为什么boltdb有两个meta page呢
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。

View File

@@ -0,0 +1,190 @@
<audio id="audio" title="11 | 压缩:如何回收旧版本数据?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/57/54/573af4a3855d0227639b48efa1686454.mp3"></audio>
你好,我是唐聪。
今天是大年初一,你过年都有什么安排?今年过年对我来说,其实是比较特别的。除了家庭团聚走亲访友外,我多了一份陪伴。感谢你和我在这个专栏里一块精进,我衷心祝你在新的一年里平安喜乐,万事胜意。
这节课是我们基础篇里的最后一节,正巧这节课的内容也是最轻松的。新年新气象,我们就带着轻松的心情开始吧!
在[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服务。
## 整体架构
<img src="https://static001.geekbang.org/resource/image/7c/21/7c5d5212fa14yy6aaf843ae3dfc5f721.png" alt="">
在了解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=&quot;json&quot; | egrep -o '&quot;revision&quot;:[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&amp;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中异步调度执行。
<img src="https://static001.geekbang.org/resource/image/9a/ff/9ac55d639f564b56324b96dc02f0c0ff.png" alt="">
为什么Compact接口需要持久化存储当前已调度的压缩版本号到boltdb中呢
试想下如果不保存这个版本号etcd在异步执行的Compact任务过程中crash了那么异常节点重启后各个节点数据就会不一致。
因此etcd通过持久化存储scheduledCompactedRev节点crash重启后会重新向FIFO Scheduled中添加压缩任务已保证各个节点间的数据一致性。
异步的执行压缩任务会做哪些工作呢?
首先我们回顾下[07](https://time.geekbang.org/column/article/340226)里介绍的treeIndex索引模块它是etcd支持保存历史版本的核心模块每个key在treeIndex模块中都有一个keyIndex数据结构记录其历史版本号信息。
<img src="https://static001.geekbang.org/resource/image/4f/dc/4f9cb015a842da0d5bd556d6b45970dc.png" alt="">
如上图所示,因此异步压缩任务的第一项工作,就是**压缩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中的废弃历史版本。
<img src="https://static001.geekbang.org/resource/image/d6/70/d625753e5a7f0f7f37987764b9204270.png" alt="">
压缩任务的第二项工作就是**删除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)有哪些区别吗?为什么碎片整理会影响服务性能呢? 你能想到哪些优化方案来降低碎片整理对服务性能的影响呢?
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。