first commit

This commit is contained in:
张乾
2024-10-16 06:37:41 +08:00
parent 633f45ea20
commit 206fad82a2
3590 changed files with 680090 additions and 0 deletions

View File

@ -0,0 +1,117 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 为什么你要学习etcd_
你好我是唐聪etcd活跃贡献者腾讯资深工程师欢迎你和我一起学习etcd。
开门见山今天我想和你聊聊为什么要学习etcd。随着Kubernetes成为容器编排领域霸主etcd也越来越火热越来越多的软件工程师使用etcd去解决各类业务场景中遇到的痛点。你知道吗etcd的GitHub star数已超过34.2K它的应用场景相当广泛从服务发现到分布式锁从配置存储到分布式协调等等。可以说etcd已经成为了云原生和分布式系统的存储基石。
另外etcd作为最热门的云原生存储之一在腾讯、阿里、Google、AWS、美团、字节跳动、拼多多、Shopee、明源云等公司都有大量的应用覆盖的业务可不仅仅是Kubernetes相关的各类容器产品更有视频、推荐、安全、游戏、存储、集群调度等核心业务。
我想为你解决哪些问题?
在工作和参与etcd社区贡献的过程中我经常会收到各类问题咨询同时自己也经历了各种问题。我相信你在使用Kubernetes、etcd的过程中很可能也会遇到下面这些典型问题
etcd Watch机制能保证事件不丢吗原理类
哪些因素会导致你的集群Leader发生切换? (稳定性类)
为什么基于Raft实现的etcd还可能会出现数据不一致一致性类
为什么你删除了大量数据db大小不减少为何etcd社区建议db大小不要超过8Gdb大小类
为什么集群各节点磁盘I/O延时很低写请求也会超时延时类
为什么你只存储了1个几百KB的key/value etcd进程却可能耗费数G内存? (内存类)
当你在一个namespace下创建了数万个Pod/CRD资源时同时频繁通过标签去查询指定Pod/CRD资源时APIServer和etcd为什么扛不住?(最佳实践类)
当然你在学习和使用etcd、Kubernetes过程中遇到的问题肯定远远不止这些下面我用思维导图给你总结了更多类似问题你可以对照自身的经历去看一下。
这门课就是为了帮助你解决这些问题而生。不过你可能会想你能把这些东西都讲明白么我先和你聊聊我的个人etcd经历你就知道我为什么有自信能带你学好etcd了。
我和etcd的那些事
本科毕业后,我通过校招加入了腾讯。不到一年的时间,我就主导完成了一个亿级用户的业务核心存储平滑迁移任务。
在2015到2017的这两年时间里为了满足业务大量的Redis诉求我基于Redis/Codis构建了大规模的排行榜和Redis集群平台服务支撑了公司的多个重要业务。在这期间我积累了大量的NoSQL数据库知识与经验为后面工作转岗到To B负责Kubernetes的元数据存储etcd奠定了良好的基础。
2017年后我就开始接触Docker和Kubernetes并通过Kubernetes来解决大规模Redis集群的治理问题提升服务的可用性、降低运维成本。
2018年我转岗到了腾讯云负责Kubernetes集群存储etcd治理工作。我主导构建的云原生etcd平台支持自动化的集群管理、调度、迁移、监控、巡检、备份成功解决了集群大规模增长过程中的各类etcd稳定性问题支撑了万级的Kubernetes和etcd集群。
etcd平台从解决Kubernetes etcd稳定性问题到为各类云原生产品提供etcd基础服务再到保障开箱即用的腾讯云etcd产品化服务它发挥着重要作用。在这个过程中我也见证了越来越多的软件工程师加入etcd的阵营越来越多的产品使用etcd。目前etcd作为腾讯众多产品的基础设施服务用户已达数亿。
同时我也遇到了很多问题从内存泄露到数据不一致从节点crash到性能慢再到死锁、OOM等稳定性问题等等。最令我记忆犹新的是我和小伙伴王超凡通过混沌工程发现并修复了多个数据不一致Bug其中一个Bug已经存在近3年之久而且很严重重启就可能会触发数据不一致。
从解决类似上面的棘手Bug到提交稳定性、性能优化PR从提交QoS特性设计方案、POC到给新的contributor review PR通过一点点的积累大量周末、凌晨时间的付出我成为了2020年etcd社区的全球Top3活跃贡献者与Google、AWS、阿里巴巴的小伙伴们一起推动etcd项目越来越好服务于全球开发者。
总结来说的话过去几年我一直在与Redis、etcd打交道一线的经历、解决的问题都让我收获良多所以我也非常有自信能把这些经验都交付给你。
在业务实践方面,我成功解决过众多大规模业务增长过程中,遇到的存储稳定性、可扩展性等痛点,积累了丰富的理论知识、大规模集群的实战、治理经验,能直接帮助到你今后的工作。
另外在etcd开源项目方面我深度参与etcd开源项目的贡献经历让我可以从开发者的视角为你分析问题、梳理最佳实践、解读特性设计方案、阐述社区未来演进方向等等帮助你深度理解etcd以及分布式服务。
你应该怎么学etcd
在我看来etcd学习其实可以分为大中小三个目标。最大的目标我当然是希望你能够用最低的学习成本掌握etcd核心原理与最佳实践让etcd为你所用帮助你解决业务过程中的各类痛点在工作中少踩坑、少交学费多升职、多涨薪。
但是这个大的目标怎么实现呢?
我的答案是使用拆解法。下面我给你提出了学习这个专栏的一些中等大小目标,希望你能带着这些目标进行学习,每过一段时间,回过头来看看,这些目标实现了多少?
首先你能知道什么是etcd了解它的基本读写原理、核心特性和能解决什么问题。
然后在使用etcd解决各类业务场景需求时能独立判断etcd是否适合你的业务场景并能设计出良好的存储结构避免expensive request。
其次在使用Kubernetes的过程中你能清晰地知道你的每个操作背后的etcd是如何工作的并遵循Kubernetes/etcd最佳实践让你的Kubernetes集群跑得更快更稳。
接着在运维etcd集群的时候你能知道etcd集群核心监控指标了解常见的坑制定良好的巡检、监控策略及时发现、规避问题避免事故的产生。
最后当你遇到etcd问题时能自己分析为什么会出现这样的错误并知道如何解决甚至给社区提PR优化做到知其然知其所以然。
做到以上五个目标其实也并不容易,别着急,我们接着往下拆分。为了让你实现以上五个目标,我把专栏分为了基础和实践两大主线。每个主线里都有一个一个的小目标,我们逐个攻破就容易多了。
基础篇主线是为了帮助你建立起对etcd的整体认知搞懂读写请求、各个核心特性背后的原理为我们后面的实践篇打下基础。
基础篇的学习也是一个中小型分布式存储系统从0到1的实现案例解读学习它你收获的不仅仅是etcd更是如何构建分布式存储系统的理论知识。
我把基础篇分为了以下的学习小目标:
etcd基础架构。通过为你梳理etcd前世今生、分析etcd读写流程帮助你建立起对etcd的整体认知了解一个分布式存储系统的基本模型、设计思想。
Raft算法。通过为你介绍Raft算法在etcd中是如何工作的帮助你了解etcd高可用、高可靠背后的核心原理。
鉴权模块。通过介绍etcd的鉴权、授权体系带你了解etcd是如何保护你的数据安全以及各个鉴权机制的优缺点。
租约模块。介绍etcd租约特性的实现帮助你搞懂如何检测一个进程的存活性为什么它可以用于Leader选举中。
MVCC/Watch模块。通过这两个模块帮助你搞懂Kubernetes控制器编程模型背后的原理。
在介绍etcd原理的过程中我也会从更上层的角度为你解读分布式系统存储系统的核心技术难点是什么常见的解决方案有哪些以及为什么etcd要这样设计、实现。让你对整个分布式系统有更深层次的理解明白不同存储系统只是在面对各自的业务场景的时候选择了合适的技术方案让你从本质上去理解分布式存储系统要解决的核心问题基本是一致的。
当然基础篇讲的远不止这些关于基础篇的更多内容你可以参考下面的etcd基础篇思维导图
通过基础篇掌握好etcd核心模块原理后实践篇我将为你解读实际使用etcd时可能会遇到的各种问题帮助你提前避坑、遇到类似问题时能独立分析、解决。
我把实践篇分为以下的学习小目标:
问题篇。为你分析etcd使用过程中的各类典型问题和你细聊各种异常现象背后的原理、最佳实践。
性能优化篇。通过读写链路的分析为你梳理可能影响etcd性能的每一个瓶颈。
实战篇。带你从0到1亲手参与构建一个简易的分布式KV数据库进一步提升你对分布式存储系统的认知。
Kubernetes实践篇。为你分析etcd在Kubernetes中的应用让你对Kubernetes原理有更深层次的理解。
etcd应用篇。介绍etcd在分布式锁、配置系统、服务发现场景中的应用。
更多实践篇内容你可以参考下面的思维导图:
这样一来我们的学习目标就比较明确了。最终目标是让etcd为你所用少踩坑、多升职加薪而为了实现这个目标我们需要从多方面提升自己对etcd的掌控能力也就是实现中等目标但进阶的难度还是比较大的所以我们需要把一个个小目标当作基石也就是每一节课的知识点学习来达成个人能力的提升。
现在我们不妨就带着这些目标共同开启etcd的学习之旅吧

View File

@ -0,0 +1,179 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 etcd的前世今生为什么Kubernetes使用etcd
你好,我是唐聪。
今天是专栏课程的第一讲我们就从etcd的前世今生讲起。让我们一起穿越回2013年看看etcd最初是在什么业务场景下被设计出来的
2013年有一个叫CoreOS的创业团队他们构建了一个产品Container Linux它是一个开源、轻量级的操作系统侧重自动化、快速部署应用服务并要求应用程序都在容器中运行同时提供集群化的管理方案用户管理服务就像单机一样方便。
他们希望在重启任意一节点的时候,用户的服务不会因此而宕机,导致无法提供服务,因此需要运行多个副本。但是多个副本之间如何协调,如何避免变更的时候所有副本不可用呢?
为了解决这个问题CoreOS团队需要一个协调服务来存储服务配置信息、提供分布式锁等能力。怎么办呢当然是分析业务场景、痛点、核心目标然后是基于目标进行方案选型评估是选择社区开源方案还是自己造轮子。这其实就是我们遇到棘手问题时的通用解决思路CoreOS团队同样如此。
假设你是CoreOS团队成员你认为在这样的业务场景下理想中的解决方案应满足哪些目标呢
如果你有过一些开发经验,应该能想到一些关键点了,我根据自己的经验来总结一下,一个协调服务,理想状态下大概需要满足以下五个目标:
可用性角度:高可用。协调服务作为集群的控制面存储,它保存了各个服务的部署、运行信息。若它故障,可能会导致集群无法变更、服务副本数无法协调。业务服务若此时出现故障,无法创建新的副本,可能会影响用户数据面。
数据一致性角度提供读取“最新”数据的机制。既然协调服务必须具备高可用的目标就必然不能存在单点故障single point of failure而多节点又引入了新的问题即多个节点之间的数据一致性如何保障比如一个集群3个节点A、B、C从节点A、B获取服务镜像版本是新的但节点C因为磁盘 I/O异常导致数据更新缓慢若控制端通过C节点获取数据那么可能会导致读取到过期数据服务镜像无法及时更新。
容量角度:低容量、仅存储关键元数据配置。协调服务保存的仅仅是服务、节点的配置信息(属于控制面配置),而不是与用户相关的数据。所以存储上不需要考虑数据分片,无需过度设计。
功能:增删改查,监听数据变化的机制。协调服务保存了服务的状态信息,若服务有变更或异常,相比控制端定时去轮询检查一个个服务状态,若能快速推送变更事件给控制端,则可提升服务可用性、减少协调服务不必要的性能开销。
运维复杂度可维护性。在分布式系统中往往会遇到硬件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的最新版本简单易实现。
type node struct {
Path string //节点路径
Parent *node //关联父亲节点
Value string //key的value值
ExpireTime time.Time //过期时间
Children map[string]*node //此节点的孩子节点
}
最后我们再来看可维护性。Raft算法提供了成员变更算法可基于此实现成员在线、安全变更同时此协调服务使用Go语言编写无依赖部署简单。
基于以上技术方案和架构图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关键特性。
为什么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如何构建、学习一个分布式系统要解决哪些技术点在心中有个初步认识后面的课程中我会再深入介绍。
etcd v3诞生
然而随着Kubernetes项目不断发展v2版本的瓶颈和缺陷逐渐暴露遇到了若干性能和稳定性问题Kubernetes社区呼吁支持新的存储、批评etcd不可靠的声音开始不断出现。
具体有哪些问题呢?我给你总结了如下图:
下面我分别从功能局限性、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的追求目标。
从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,223 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 基础架构etcd一个读请求是如何执行的
你好,我是唐聪。
在上一讲中我和你分享了etcd的前世今生同时也为你重点介绍了etcd v2的不足之处以及我们现在广泛使用etcd v3的原因。
今天我想跟你介绍一下etcd v3的基础架构让你从整体上对etcd有一个初步的了解心中能构筑起一幅etcd模块全景图。这样在你遇到诸如“Kubernetes在执行kubectl get pod时etcd如何获取到最新的数据返回给APIServer”等流程架构问题时就能知道各个模块由上至下是如何紧密协作的。
即便是遇到请求报错,你也能通过顶层的模块全景图,推测出请求流程究竟在什么模块出现了问题。
基础架构
下面是一张etcd的简要基础架构图我们先从宏观上了解一下etcd都有哪些功能模块。
你可以看到按照分层模型etcd可分为Client层、API网络层、Raft算法层、逻辑层和存储层。这些层的功能如下
Client层Client层包括client v2和v3两个大版本API客户端库提供了简洁易用的API同时支持负载均衡、节点间故障自动转移可极大降低业务使用etcd复杂度提升开发效率、服务可用性。
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协议。
Raft算法层Raft算法层实现了Leader选举、日志复制、ReadIndex等核心算法特性用于保障etcd多个节点间的数据一致性、提升服务可用性等是etcd的基石和亮点。
功能逻辑层etcd核心特性实现层如典型的KVServer模块、MVCC模块、Auth鉴权模块、Lease租约模块、Compactor压缩模块等其中MVCC模块主要由treeIndex模块和boltdb模块组成。
存储层:存储层包含预写日志(WAL)模块、快照(Snapshot)模块、boltdb模块。其中WAL可保障etcd crash后数据不丢失boltdb则保存了集群元数据和用户写入的数据。
etcd是典型的读多写少存储在我们实际业务场景中读一般占据2/3以上的请求。为了让你对etcd有一个深入的理解接下来我会分析一个读请求是如何执行的带你了解etcd的核心模块进而由点及线、由线到面地帮助你构建etcd的全景知识脉络。
在下面这张架构图中我用序号标识了etcd默认读模式线性读的执行流程接下来我们就按照这个执行流程从头开始说。
环境准备
首先介绍一个好用的进程管理工具goreman基于它我们可快速创建、停止本地的多节点etcd集群。
你可以通过如下go get命令快速安装goreman然后从etcd release页下载etcd v3.4.9二进制文件再从etcd源码中下载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负载尽量均衡。
关于负载均衡算法,你需要特别注意以下两点。
如果你的client 版本<= 3.3那么当你配置多个endpoint时负载均衡算法仅会从中选择一个IP并创建一个连接Pinned endpoint这样可以节省服务器总连接数。但在这我要给你一个小提醒在heavy usage场景这可能会造成server负载不均衡。
在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: "/v3/kv/range"
body: "*"
};
}
....
}
拦截器提供了在执行一个请求前后的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等)。
此时若client发起一个读取hello的请求假设此请求直接从状态机中读取 如果连接到的是C节点若C节点磁盘I/O出现波动可能导致它应用已提交的日志条目很慢则会出现更新hello为world的写命令在client读hello的时候还未被提交到状态机因此就可能读取到旧数据如上图查询hello流程所示。
从以上介绍我们可以看出在多节点etcd集群中各个节点的状态机数据一致性存在差异。而我们不同业务场景的读请求对数据是否最新的容忍度是不一样的有的场景它可以容忍数据落后几秒甚至几分钟有的场景要求必须读到反映集群共识的最新数据。
我们首先来看一个对数据敏感度较低的场景。
假如老板让你做一个旁路数据统计服务希望你每分钟统计下etcd里的服务、配置信息等这种场景其实对数据时效性要求并不高读请求可直接从节点的状态机获取数据。即便数据落后一点也不影响业务毕竟这是一个定时统计的旁路服务而已。
这种直接读状态机数据返回、无需通过Raft协议与集群进行交互的模式在etcd里叫做串行(Serializable)读,它具有低延时、高吞吐量的特点,适合对数据一致性要求不高的场景。
我们再看一个对数据敏感性高的场景。
当你发布服务更新服务的镜像的时候提交的时候显示更新成功结果你一刷新页面发现显示的镜像的还是旧的再刷新又是新的这就会导致混乱。再比如说一个转账场景Alice给Bob转账成功钱被正常扣出一刷新页面发现钱又回来了这也是令人不可接受的。
以上的业务场景就对数据准确性要求极高了在etcd里面提供了一种线性读模式来解决对数据一致性要求高的场景。
什么是线性读呢?
你可以理解一旦一个值更新成功随后任何通过线性读的client都能及时访问到。虽然集群中有多个节点但client通过线性读就如访问一个节点一样。etcd默认读模式是线性读因为它需要经过Raft协议模块反应的是集群共识因此在延时和吞吐量上相比串行读略差一点适用于对数据一致性要求高的场景。
如果你的etcd读请求显示指定了是串行读就不会经过架构图流程中的流程三、四。默认是线性读因此接下来我们看看读请求进入线性读模块它是如何工作的。
线性读之ReadIndex
前面我们聊到串行读时提到它之所以能读到旧数据主要原因是Follower节点收到Leader节点同步的写请求后应用日志条目到状态机是个异步过程那么我们能否有一种机制在读取的时候确保最新的数据已经应用到状态机中
其实这个机制就是叫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信息。
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,254 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 基础架构etcd一个写请求是如何执行的
你好,我是唐聪。
在上一节课里我通过分析etcd的一个读请求执行流程给你介绍了etcd的基础架构让你初步了解了在etcd的读请求流程中各个模块是如何紧密协作执行查询语句返回数据给client。
那么etcd一个写请求执行流程又是怎样的呢在执行写请求过程中如果进程crash了如何保证数据不丢、命令不重复执行呢
今天我就和你聊聊etcd写过程中是如何解决这些问题的。希望通过这节课让你了解一个key-value写入的原理对etcd的基础架构中涉及写请求相关的模块有一定的理解同时能触类旁通当你在软件项目开发过程中遇到类似数据安全、幂等性等问题时能设计出良好的方案解决它。
整体架构
为了让你能够更直观地理解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。
然后它会尝试去获取请求中的鉴权信息若使用了密码鉴权、请求中携带了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日志结构是怎样的呢
上图是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:"varint2optname=Term" json:"Term"`
Index uint64 `protobuf:"varint3optname=Index" json:"Index"`
Type EntryType `protobuf:"varint1optname=Typeenum=Raftpb.EntryType" json:"Type"`
Data []byte `protobuf:"bytes4optname=Data" json:"Dataomitempty"`
}
了解完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是如何找回异常提案再次执行的呢
核心就是我们上面介绍的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也就是下面简易写事务图的流程一整体架构图中的流程八。
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,242 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 Raft协议etcd如何实现高可用、数据强一致的
你好,我是唐聪。
在前面的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 它由共识模块、日志模块、状态机组成。通过共识模块保证各个节点日志的一致性,然后各个节点基于同样的日志、顺序执行指令,最终各个复制状态机的结果实现一致。
共识算法的祖师爷是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节点以维持领导者身份。
上图是节点状态变化关系图当Follower节点接收Leader节点心跳消息超时后它会转变成Candidate节点并可发起竞选Leader投票若获得集群多数节点的支持后它就可转变成Leader节点。
下面我以Leader crash场景为案例给你详细介绍一下etcd Leader选举原理。
假设集群总共3个节点A节点为LeaderB、C节点为Follower。
如上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状态发起选举流程。
因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节点的。
首先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日志状态。
我们现在就可以来回答第一个疑问了。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日志状态。
最后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,295 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 鉴权:如何保护你的数据安全?
你好,我是唐聪。
不知道你有没有过这样的困惑当你使用etcd存储业务敏感数据、多租户共享使用同etcd集群的时候应该如何防止匿名用户访问你的etcd数据呢多租户场景又如何最小化用户权限分配防止越权访问的
etcd鉴权模块就是为了解决以上痛点而生。
那么etcd是如何实现多种鉴权机制和细粒度的权限控制的在实现鉴权模块的过程中最核心的挑战是什么又该如何确保鉴权的安全性以及提升鉴权性能呢
今天这节课我将为你介绍etcd的鉴权模块深入剖析etcd如何解决上面的这些痛点和挑战。希望通过这节课帮助你掌握etcd鉴权模块的设计、实现精要了解各种鉴权方案的优缺点。你能在实际应用中根据自己的业务场景、安全诉求选择合适的方案保护你的etcd数据安全。同时你也可以参考其设计、实现思想应用到自己业务的鉴权系统上。
整体架构
在详细介绍etcd的认证、鉴权实现细节之前我先给你从整体上介绍下etcd鉴权体系。
etcd鉴权体系架构由控制面和数据面组成。
上图是是etcd鉴权体系控制面你可以通过客户端工具etcdctl和鉴权API动态调整认证、鉴权规则AuthServer收到请求后为了确保各节点间鉴权元数据一致性会通过Raft模块进行数据同步。
当对应的Raft日志条目被集群半数以上节点确认后Apply模块通过鉴权存储(AuthStore)模块执行日志条目的内容将规则存储到boltdb的一系列“鉴权表”里面。
下图是数据面鉴权流程由认证和授权流程组成。认证的目的是检查client的身份是否合法、防止匿名用户访问等。目前etcd实现了两种认证机制分别是密码认证和证书认证。
认证通过后为了提高密码认证性能会分配一个Token类似我们生活中的门票、通信证给clientclient后续其他请求携带此Tokenserver就可快速完成client的身份校验工作。
实现分配Token的服务也有多种这是TokenProvider所负责的目前支持SimpleToken和JWT两种。
通过认证后在访问MVCC模块之前还需要通过授权流程。授权的目的是检查client是否有权限操作你请求的数据路径etcd实现了RBAC机制支持为每个用户分配一个角色为每个角色授予最小化的权限。
好了etcd鉴权体系的整个流程讲完了下面我们就以第三节课中提到的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。
{
"alg": "RS256"
"typ": "JWT"
}
第二对象是Payload它表示载荷包含用户名、过期时间等信息可以自定义添加字段。
{
"username": username
"revision": revision
"exp": time.Now().Add(t.ttl).Unix()
}
第三个对象是签名首先它将header、payload使用base64 url编码然后将编码后的
字符串用”.“连接在一起最后用我们选择的签名算法比如RSA系列的私钥对其计算签名输出结果即是Signature。
signature=RSA256(
base64UrlEncode(header) + "." +
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
证书认证在稳定性、性能上都优于密码认证。
稳定性上它不存在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。
下面我们通过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关于这个问题的讨论。

View File

@ -0,0 +1,195 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 租约:如何检测你的客户端存活?
你好,我是唐聪。
今天我要跟你分享的主题是租约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模块简要架构图。
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
{
"kvs":[
{
"create_revision":24
"key":"bm9kZQ=="
"Lease":3632563850270275608
"mod_revision":24
"value":"aGVhbHRoeQ=="
"version":1
}
]
}
以上流程原理如下图所示它描述了用户的key是如何与指定Lease关联的。当你通过put等命令新增一个指定了”lease”的key时MVCC模块它会通过Lessor模块的Attach方法将key关联到Lease的key内存集合ItemSet中。
一个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任务。
从图中你可以看到目前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的任务。
一方面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,316 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 MVCC如何实现多版本并发控制
你好,我是唐聪。
在01课里我和你介绍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版本号时间序列图。
从图中你可以看到,随着时间增长,你每次修改操作,版本号都会递增。每修改一次,生成一条新的数据记录。当你指定版本号读取数据时,它实际上访问的是版本号生成那个时间点的快照数据。当你删除数据的时候,它实际也是新增一条带删除标识的数据记录。
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
{
"kvs":[
{
"key":"aGVsbG8=",
"create_revision":2,
"mod_revision":2,
"version":1,
"value":"d29ybGQx"
}
],
"count":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模块开始执行此日志内容。
Apply模块通过MVCC模块来执行put请求持久化key-value数据。MVCC模块将请求请划分成两个类别分别是读事务ReadTxn和写事务WriteTxn。读事务负责处理range请求写事务负责put/delete操作。读写事务基于treeIndex、Backend/boltdb提供的能力实现对key-value的增删改查功能。
treeIndex模块基于内存版B-tree实现了key索引管理它保存了用户key与版本号revision的映射关系等信息。
Backend模块负责etcd的key-value持久化存储主要由ReadTx、BatchTx、Buffer组成ReadTx定义了抽象的读事务接口BatchTx在ReadTx之上定义了抽象的写事务接口Buffer是数据缓存区。
etcd设计上支持多种Backend实现目前实现的Backend是boltdb。boltdb是一个基于B+ tree实现的、支持事务的key-value嵌入式数据库。
treeIndex与boltdb关系你可参考下图。当你发起一个get hello命令时从treeIndex中获取key的版本号然后再通过这个版本号从boltdb获取value信息。boltdb的value是包含用户key-value、各种版本号、lease信息的结构体。
接下来我和你重点聊聊treeIndex模块的原理与核心数据结构。
treeIndex原理
为什么需要treeIndex模块呢?
对于etcd v2来说当你通过etcdctl发起一个put hello操作时etcd v2直接更新内存树这就导致历史版本直接被覆盖无法支持保存key的历史版本。在etcd v3中引入treeIndex模块正是为了解决这个问题支持保存key的历史版本提供稳定的Watch机制和事务隔离等能力。
那etcd v3又是如何基于treeIndex模块实现保存key的历史版本的呢?
在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 > 1简称d为5的B-tree度是B-tree中的一个核心参数它决定了你每个节点上的数据量多少、节点的“胖”、“瘦”程度。
从图中你可以看到节点越胖意味着一个节点可以存储更多数据树的高度越低。在一个度为d的B-tree中节点保存的最大key数为2d - 1否则需要进行平衡、分裂操作。这里你要注意的是在etcd treeIndex模块中创建的是最大度32的B-tree也就是一个叶子节点最多可以保存63个key。
从图中你可以看到你通过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名称比如我们案例中的"hello"
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索引为空。
其次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数据如下
然后put事务需将本次修改的版本号与用户key的映射关系保存到treeIndex模块中也就是上图中的流程三。
因为key hello是首次创建treeIndex模块它会生成key hello对应的keyIndex对象并填充相关数据结构。
keyIndex填充后的结果如下所示
key hello的keyIndex:
key: "hello"
modified: <2,0>
generations:
[{ver:1,created:<2,0>,revisions: [<2,0>]} ]
我们来简易分析一下上面的结果。
key为hellomodified为最后一次修改版本号key hello是首次创建的因此新增一个generation代跟踪它的生命周期、修改记录
generation的ver表示修改次数首次创建为1后续随着修改操作递增
generation.created表示创建generation时的版本号为
revision数组保存对此key修改的版本号列表每次修改都会将将相应的版本号追加到revisions数组中。
通过以上流程一个put操作终于完成。
但是此时数据还并未持久化为了提升etcd的写吞吐量、性能一般情况下默认堆积的写事务数大于1万才在写事务结束时同步持久化数据持久化由Backend的异步goroutine完成它通过事务批量提交定时将boltdb页缓存中的脏数据提交到持久化存储磁盘中也就是下图中的黑色虚线框住的流程四。
MVCC查询key原理
完成put hello为world1操作后这时你通过etcdctl发起一个get hello操作MVCC模块首先会创建一个读事务对象TxnRead在etcd 3.4中Backend实现了ConcurrentReadTx 也就是并发读特性。
并发读特性的核心原理是创建读事务对象时它会全量拷贝当前写事务未提交的buffer数据并发的读写事务不再阻塞在一个buffer资源锁上实现了全并发读。
如上图所示在读事务中它首先需要根据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字段更新为generation的revision数组追加最新的版本号ver修改为2。
key hello的keyIndex:
key: "hello"
modified: <3,0>
generations:
[{ver:2,created:<2,0>,revisions: [<2,0>,<3,0>]}]
boltdb插入一个新的key revision{3,0}此时存储到boltdb中的key-value数据如下
这时你再发起一个指定历史版本号为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: "hello"
modified: <4,0>
generations:
[
{ver:3,created:<2,0>,revisions: [<2,0>,<3,0>,<4,0>(t)]}
{empty}
]
boltdb此时会插入一个新的key revision{4,0,t}此时存储到boltdb中的key-value数据如下
那么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 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 Watch如何高效获取数据变化通知
你好,我是唐聪。
在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
{
"Events":[
{
"kv":{
"key":"aGVsbG8=",
"create_revision":2,
"mod_revision":2,
"version":1,
"value":"d29ybGQx"
}
},
{
"kv":{
"key":"aGVsbG8=",
"create_revision":2,
"mod_revision":3,
"version":2,
"value":"d29ybGQy"
}
}
],
"CompactRevision":0,
"Canceled":false,
"Created":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协议为什么能实现多路复用呢
在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、内存等资源。
当然在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特性整体架构图。
整体架构
我先通过上面的架构图给你简要介绍下一个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的呢
当你创建完成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 = &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状态转换关系希望能帮助你快速厘清之间关系。
介绍完最新事件推送、异常场景重试机制后,那历史事件推送机制又是怎么工作的呢?
历史事件推送机制
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状态转换图黑色虚线框所示。
若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还使用了如下的区间树。
当收到创建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 := "/task/" + "Agent IP"
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("%s %q : %q\n", ev.Type, ev.Kv.Key, ev.Kv.Value)
}
}
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。

View File

@ -0,0 +1,313 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 事务如何安全地实现多key操作
你好,我是唐聪。
在软件开发过程中我们经常会遇到需要批量执行多个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”) < “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("Alice") = "200" //判断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
整体流程
在和你介绍上面案例中的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了它是如何保证转账事务的原子性与持久性的呢
如上图转账事务流程图所示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元了因此它无法保证转账前后账号系统内的资产一致性导致了资产凭空消失破坏了事务的一致性。
事务一致性被破坏的根本原因是事务中缺少对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能否读取到未提交事务修改的数据是否存在脏读。
图中有两个事务一个是用户查询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的版本号我通过下面的并发转账事务流程图为你解释它是如何工作的。
如上图所示事务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):
{
"kvs":[
{
"key":"QWxpY2U=",
"create_revision":2,
"mod_revision":2,
"version":1,
"value":"MjAw"
}
],
......
"kvs":[
{
"key":"Qm9i",
"create_revision":3,
"mod_revision":3,
"version":1,
"value":"MzAw"
}
],
}
其次发起资金转账操作Alice账号减去100Bob账号增加100。为了保证转账事务的准确性、一致性提交事务的时候需检查Alice和Bob账号最新修改版本号与读取资金时的一致(compares操作中增加版本号检测),以保证其他事务未修改两个账号的资金。
若compares操作通过检查则执行转账操作否则执行查询Alice和Bob账号资金操作命令如下:
$ etcdctl txn -i
compares:
mod("Alice") = "2"
mod("Bob") = "3"
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构建了各个事务隔离级别类帮助你进一步简化应用编程复杂度。
小结
最后我们来小结下今天的内容。首先我给你介绍了事务API的基本结构它由If、Then、Else语句组成。
其中If支持多个比较规则它是用于事务提交时的冲突检测比较的对象支持key的mod_revision、create_revision、version、value值。随后我给你介绍了整个事务执行的基本流程Apply模块首先执行If的比较规则为真则执行Then语句否则执行Else语句。
接着通过转账案例四幅转账事务时间序列图我为你分析了事务的ACID特性剖析了在etcd中事务的ACID特性的实现。
原子性是指一个事务要么全部成功要么全部失败etcd基于WAL日志、consistent index、boltdb的事务能力提供支持。
一致性是指事务转账前后的,数据库和应用程序期望的恒等状态应该保持不变,这通过数据库和业务应用程序相互协作完成。
持久性是指事务提交后,数据不丢失,
隔离性是指事务提交过程中的可见性etcd不存在脏读基于MVCC机制、boltdb事务你可以实现可重复读、串行化快照隔离级别的事务保障并发事务场景中你的数据安全性。
思考题
在数据库事务中,有各种各样的概念,比如脏读、脏写、不可重复读与读倾斜、幻读与写倾斜、更新丢失、快照隔离、可串行化快照隔离? 你知道它们的含义吗?
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。

View File

@ -0,0 +1,316 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 boltdb如何持久化存储你的key-value数据
你好,我是唐聪。
在前面的课程里我和你多次提到过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来写入、持久化数据到磁盘。
上图是我给你画的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("db" 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("key"))
if err != nil {
return err
}
// 使用bucket对象更新一个key
if err := b.Put([]byte("r94"),[]byte("world")); 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命令所打印的boltdb第0页的数据图的右边是对应的page磁盘页结构和meta page的数据结构。
$ ./bbolt dump ./infra1.etcd/member/snap/db 0
一看上图中的十六进制数据你可能很懵没关系在你了解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。
第三行对应的就是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()
}
上面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数组。
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数组。
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 & leafPageFlag) != 0)
n.inodes = make(inodes, int(p.count))
for i := 0; i < int(p.count); i++ {
inode := &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 pageleaf page通过B+ tree来管理实现boltdb为了区分leaf page存储的数据类型是bucket还是key-value增加了标识字段leafPageElement.flags因此key-value的数据存储过程我就不再重复分析了
freelist
介绍完bucketkey-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命令所看到的信息一致
$ ./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这里我介绍的是数组
Open原理
了解完核心数据结构后我们就很容易搞懂boltdb Open API的原理了
首先它会打开db文件并对其增加文件锁目的是防止其他进程也以读写模式打开它后操作meta和free page导致db文件损坏
其次boltdb通过mmap机制将db文件映射到内存中并读取两个meta page到db对象实例中然后校验meta page的magicversionchecksum是否有效若两个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流程1r9指向的page是个leaf pageB+ tree需要确保叶子节点key的有序性因此同样二分查找其插入位置将key r94插入到相关位置流程二
在核心数据结构介绍中我和你提到boltdb在内存中通过node数据结构来存储page磁盘页内容它记录了key-value数据page idparent及children的nodeB+ tree是否需要进行重平衡和分裂操作等信息
因此当我们执行完一个put请求时它只是将值更新到boltdb的内存node数据结构里并未持久化到磁盘中
事务提交原理
那么boltdb何时将数据持久化到db文件中呢
当你的代码执行tx.Commit API时它才会将我们上面保存到node内存数据结构中的数据持久化到boltdb中下图我给出了一个事务提交的流程图接下来我就分别和你简要分析下各个核心步骤
首先从上面put案例中我们可以看到插入了一个新的元素在B+ tree的叶子节点它可能已不满足B+ tree的特性因此事务提交时第一步首先要调整B+ tree进行重平衡分裂操作使其满足B+ tree树的特性上面案例里插入一个key r94后经过重平衡分裂操作后的B+ tree如下图所示
在重平衡分裂过程中可能会申请释放free pagefreelist所管理的free page也发生了变化因此事务提交的第二步就是持久化freelist
注意在etcd v3.4.9中为了优化写性能等freelist持久化功能是关闭的etcd启动获取boltdb db对象的时候boltdb会遍历所有page构建空闲页列表
事务提交的第三步就是将client更新操作产生的dirty page通过fdatasync系统调用持久化存储到磁盘中
最后在执行写事务过程中meta page的txidfreelist等字段会发生变化因此事务的最后一步就是持久化meta page
通过以上四大步骤我们就完成了事务提交的工作成功将数据持久化到了磁盘文件中安全地完成了一个put操作
小结
最后我们来小结下今天的内容首先我通过一幅boltdb磁盘布局图和bbolt工具为你解密了db文件的本质db文件由meta pagefreelist pagebranch pageleaf pagefree page组成随后我结合bbolt工具和你深入介绍了meta pagebranch pageleaf pagefreelist page的数据结构帮助你了解keyvalue数据是如何存储到文件中的
然后我通过分析一个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,192 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 压缩:如何回收旧版本数据?
你好,我是唐聪。
今天是大年初一,你过年都有什么安排?今年过年对我来说,其实是比较特别的。除了家庭团聚走亲访友外,我多了一份陪伴。感谢你和我在这个专栏里一块精进,我衷心祝你在新的一年里平安喜乐,万事胜意。
这节课是我们基础篇里的最后一节,正巧这节课的内容也是最轻松的。新年新气象,我们就带着轻松的心情开始吧!
在07里我们知道etcd中的每一次更新、删除key操作treeIndex的keyIndex索引中都会追加一个版本号在boltdb中会生成一个新版本boltdb key和value。也就是随着你不停更新、删除你的etcd进程内存占用和db文件就会越来越大。很显然这会导致etcd OOM和db大小增长到最大db配额最终不可写。
那么etcd是通过什么机制来回收历史版本数据控制索引内存占用和db大小的呢
这就是我今天要和你分享的etcd压缩机制。希望通过今天的这节课能帮助你理解etcd压缩原理在使用etcd过程中能根据自己的业务场景选择适合的压缩策略避免db大小增长失控而不可写入帮助你构建稳定的etcd服务。
整体架构
在了解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="json" | egrep -o '"revision":[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介绍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版本对周期性压缩的行为是有一定差异的具体的区别你可以参考下官方文档。
版本号压缩
了解完周期性压缩模式,我们再看看版本号压缩模式,它又适用于什么场景呢?
当你写请求比较多可能产生比较多的历史版本导致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&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中异步调度执行。
为什么Compact接口需要持久化存储当前已调度的压缩版本号到boltdb中呢
试想下如果不保存这个版本号etcd在异步执行的Compact任务过程中crash了那么异常节点重启后各个节点数据就会不一致。
因此etcd通过持久化存储scheduledCompactedRev节点crash重启后会重新向FIFO Scheduled中添加压缩任务已保证各个节点间的数据一致性。
异步的执行压缩任务会做哪些工作呢?
首先我们回顾下07里介绍的treeIndex索引模块它是etcd支持保存历史版本的核心模块每个key在treeIndex模块中都有一个keyIndex数据结构记录其历史版本号信息。
如上图所示因此异步压缩任务的第一项工作就是压缩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中的废弃历史版本。
压缩任务的第二项工作就是删除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)有哪些区别吗?为什么碎片整理会影响服务性能呢? 你能想到哪些优化方案来降低碎片整理对服务性能的影响呢?
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。

View File

@ -0,0 +1,300 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 一致性为什么基于Raft实现的etcd还会出现数据不一致
你好,我是唐聪。
今天我要和你分享的主题是关于etcd数据一致性的。
我们都知道etcd是基于Raft实现的高可用、强一致分布式存储。但是有一天我和小伙伴王超凡却遭遇了一系列诡异的现象用户在更新Kubernetes集群中的Deployment资源镜像后无法创建出新PodDeployment控制器莫名其妙不工作了。更令人细思极恐的是部分Node莫名其妙消失了。
我们当时随便找了一个etcd节点查看存储数据发现Node节点却在。这究竟是怎么一回事呢 今天我将和你分享这背后的故事以及由它带给我们的教训和启发。希望通过这节课能帮助你搞懂为什么基于Raft实现的etcd有可能出现数据不一致以及我们应该如何提前规避、预防类似问题。
从消失的Node说起
故事要从去年1月的时候说起某日晚上我们收到一个求助有人反馈Kubernetes集群出现了Deployment滚动更新异常、节点莫名其妙消失了等诡异现象。我一听就感觉里面可能大有文章于是开始定位之旅。
我首先查看了下Kubernetes集群APIServer、Controller Manager、Scheduler等组件状态发现都是正常。
然后我查看了下etcd集群各节点状态也都是健康的看了一个etcd节点数据也是正常于是我开始怀疑是不是APIServer出现了什么诡异的Bug了。
我尝试重启APIServer可Node依旧消失。百思不得其解的同时只能去确认各个etcd节点上数据是否存在结果却有了颠覆你固定思维的发现那就是基于Raft实现的强一致存储竟然出现不一致、数据丢失。除了第一个节点含有数据另外两个节点竟然找不到。那么问题就来了另外两个节点数据是如何丢失的呢
一步步解密真相
在进一步深入分析前我们结合基础篇03对etcd写流程原理的介绍如下图先大胆猜测下可能的原因。
猜测1etcd集群出现分裂三个节点分裂成两个集群。APIServer配置的后端etcd server地址是三个节点APIServer并不会检查各节点集群ID是否一致因此如果分裂有可能会出现数据“消失”现象。这种故障之前在Kubernetes社区的确也见到过相关issue一般是变更异常导致的显著特点是集群ID会不一致。
猜测2Raft日志同步异常其他两个节点会不会因为Raft模块存在特殊Bug导致未收取到相关日志条目呢这种怀疑我们可以通过etcd自带的WAL工具来判断它可以显示WAL日志中收到的命令流程四、五、六
猜测3如果日志同步没问题那有没有可能是Apply模块出现了问题导致日志条目未被应用到MVCC模块呢流程七
猜测4若Apply模块执行了相关日志条目到MVCC模块MVCC模块的treeIndex子模块会不会出现了特殊Bug 导致更新失败(流程八)?
猜测5若MVCC模块的treeIndex模块无异常写请求到了boltdb存储模块有没有可能boltdb出现了极端异常导致丢数据呢流程九
带着以上怀疑和推测,让我们不断抽丝剥茧、去一步步探寻真相。
首先还是从故障定位第一工具“日志”开始。我们查看etcd节点日志没发现任何异常日志但是当查看APIServer日志的时候发现持续报”required revision has been compacted”这个错误根据我们基础篇11节介绍我们知道原因一般是APIServer请求etcd版本号被压缩了。
于是我们通过如下命令查看etcd节点详细的状态信息
etcdctl endpoint status --cluster -w json | python -m
json.tool
获得以下结果:
[
{
"Endpoint":"A"
"Status":{
"header":{
"cluster_id":17237436991929493444
"member_id":9372538179322589801
"raft_term":10
"revision":1052950
}
"leader":9372538179322589801
"raftAppliedIndex":1098420
"raftIndex":1098430
"raftTerm":10
"version":"3.3.17"
}
}
{
"Endpoint":"B"
"Status":{
"header":{
"cluster_id":17237436991929493444
"member_id":10501334649042878790
"raft_term":10
"revision":1025860
}
"leader":9372538179322589801
"raftAppliedIndex":1098418
"raftIndex":1098428
"raftTerm":10
"version":"3.3.17"
}
}
{
"Endpoint":"C"
"Status":{
"header":{
"cluster_id":17237436991929493444
"member_id":18249187646912138824
"raft_term":10
"revision":1028860
}
"leader":9372538179322589801
"raftAppliedIndex":1098408
"raftIndex":1098428
"raftTerm":10
"version":"3.3.17"
}
}
]
从结果看,我们获得了如下信息:
第一集群未分裂3个节点A、B、C cluster_id都一致集群分裂的猜测被排除。
第二初步判断集群Raft日志条目同步正常raftIndex表示Raft日志索引号raftAppliedIndex表示当前状态机应用的日志索引号。这两个核心字段显示三个节点相差很小考虑到正在写入未偏离正常范围Raft同步Bug导致数据丢失也大概率可以排除不过最好还是用WAL工具验证下现在日志条目同步和写入WAL是否正常
第三观察三个节点的revision值相互之间最大差距接近30000明显偏离标准值。在07中我给你深入介绍了revision的含义它是etcd逻辑时钟每次写入就会全局递增。为什么三个节点之间差异如此之大呢
接下来我们就一步步验证猜测、解密真相猜测1集群分裂说被排除后猜测2Raft日志同步异常也初步被我们排除了那如何真正确认Raft日志同步正常呢
你可以使用下面这个方法验证Raft日志条目同步是否正常。
首先我们写入一个值比如put hello为world然后马上在各个节点上用WAL工具etcd-dump-logs搜索hello。如下所示各个节点上都可找到我们刚刚写入的命令。
$ etcdctl put hello world
OK
$ ./bin/tools/etcd-dump-logs ./Node1.etcd/ | grep hello
10 70 norm header:<ID:3632562852862290438 > put:<key:"hello" value:"world" >
$ ./bin/tools/etcd-dump-logs ./Node2.etcd/ | grep hello
10 70 norm header:<ID:3632562852862290438 > put:<key:"hello" value:"world" >
$ ./bin/tools/etcd-dump-logs ./Node3.etcd/ | grep hello
10 70 norm header:<ID:3632562852862290438 > put:<key:"hello" value:"world" >
Raft日志同步异常猜测被排除后我们再看下会不会是Apply模块出现了问题。但是raftAppliedIndex却显示三个节点几乎无差异那我们能不能通过这个指标来判断Apply流程是否正常呢
源码面前了无秘密etcd更新raftAppliedIndex核心代码如下所示你会发现这个指标其实并不靠谱。Apply流程出现逻辑错误时并没重试机制。etcd无论Apply流程是成功还是失败都会更新raftAppliedIndex值。也就是一个请求在Apply或MVCC模块即便执行失败了都依然会更新raftAppliedIndex。
// ApplyEntryNormal apples an EntryNormal type Raftpb request to the EtcdServer
func s *EtcdServer ApplyEntryNormale *Raftpb.Entry {
shouldApplyV3 := false
if e.Index > s.consistIndex.ConsistentIndex {
// set the consistent index of current executing entry
s.consistIndex.setConsistentIndexe.Index
shouldApplyV3 = true
}
defer s.setAppliedIndexe.Index
....
}
而三个节点revision差异偏离标准值恰好又说明异常etcd节点可能未成功应用日志条目到MVCC模块。我们也可以通过查看MVCC的相关metrics比如etcd_mvcc_put_total来排除请求是否到了MVCC模块事实是丢数据节点的metrics指标值的确远远落后正常节点。
于是我们将真凶锁定在Apply流程上。我们对Apply流程在未向MVCC模块提交请求前可能提前返回的地方都加了日志。
同时我们查看Apply流程还发现Apply失败的时候并不会打印任何日志。这也解释了为什么出现了数据不一致严重错误但三个etcd节点却并没有任何异常日志。为了方便定位问题我们因此增加了Apply错误日志。
同时我们测试发现写入是否成功还跟client连接的节点有关连接不同节点会出现不同的写入结果。我们用debug版本替换后马上就输出了一条错误日志auth: revision in header is old。
原来数据不一致是因为鉴权版本号不一致导致的节点在Apply流程的时候会判断Raft日志条目中的请求鉴权版本号是否小于当前鉴权版本号如果小于就拒绝写入。
那为什么各个节点的鉴权版本号会出现不一致呢?那就需要从可能修改鉴权版本号的源头分析。我们发现只有鉴权相关接口才会修改它,同时各个节点鉴权版本号之间差异已经固定不再增加,要成功解决就得再次复现。
然后还了解到当时etcd进程有过重启我们怀疑会不会重启触发了什么Bug手动尝试复现一直失败。然而我们并未放弃随后我们基于混沌工程不断模拟真实业务场景、访问鉴权接口、注入故障停止etcd进程等最终功夫不负有心人实现复现成功。
真相终于浮出水面原来当你无意间重启etcd的时候如果最后一条命令是鉴权相关的它并不会持久化consistent indexKV接口会持久化。consistent index在03里我们详细介绍了它具有幂等作用可防止命令重复执行。consistent index的未持久化最终导致鉴权命令重复执行。
恰好鉴权模块的RoleGrantPermission接口未实现幂等重复执行会修改鉴权版本号。一连串的Bug最终导致鉴权号出现不一致随后又放大成MVCC模块的key-value数据不一致导致严重的数据毁坏。
这个Bug影响etcd v3所有版本长达3年之久。查清楚问题后我们也给社区提交了解决方案合并到master后同时cherry-pick到etcd 3.3和3.4稳定版本中。etcd v3.3.21和v3.4.8后的版本已经修复此Bug。
为什么会不一致
详细了解完这个案例的不一致后,我们再从本质上深入分析下为什么会出现不一致,以及还有哪些场景会导致类似问题呢?
首先我们知道etcd各个节点数据一致性基于Raft算法的日志复制实现的etcd是个基于复制状态机实现的分布式系统。下图是分布式复制状态机原理架构核心由3个组件组成一致性模块、日志、状态机其工作流程如下
client发起一个写请求set x = 3
server向一致性模块假设是Raft提交请求一致性模块生成一个写提案日志条目。若server是Leader把日志条目广播给其他节点并持久化日志条目到WAL中
当一半以上节点持久化日志条目后Leader的一致性模块将此日志条目标记为已提交committed并通知其他节点提交
server从一致性模块获取已经提交的日志条目异步应用到状态机持久化存储中boltdb等然后返回给client。
从图中我们可以了解到在基于复制状态机实现的分布式存储系统中Raft等一致性算法它只能确保各个节点的日志一致性也就是图中的流程二。
而对于流程三来说server从日志里面获取已提交的日志条目将其应用到状态机的过程跟Raft算法本身无关属于server本身的数据存储逻辑。
也就是说有可能存在server应用日志条目到状态机失败进而导致各个节点出现数据不一致。但是这个不一致并非Raft模块导致的它已超过Raft模块的功能界限。
比如在上面Node莫名其妙消失的案例中就是应用日志条目到状态机流程中出现逻辑错误导致key-value数据未能持久化存储到boltdb。
这种逻辑错误即便重试也无法解决目前社区也没有彻底的根治方案只能根据具体案例进行针对性的修复。同时我给社区增加了Apply日志条目失败的警告日志。
其他典型不一致Bug
还有哪些场景可能还会导致Apply流程失败呢我再以一个之前升级etcd 3.2集群到3.3集群时,遇到的数据不一致的故障事件为例给你讲讲。
这个故障对外的表现也是令人摸不着头脑有服务不调度的、有service下的endpoint不更新的。最终我经过一番排查发现原来数据不一致是由于etcd 3.2和3.3版本Lease模块的Revoke Lease行为不一致造成。
etcd 3.2版本的RevokeLease接口不需要鉴权而etcd 3.3 RevokeLease接口增加了鉴权因此当你升级etcd集群的时候如果etcd 3.3版本收到了来自3.2版本的RevokeLease接口就会导致因为没权限出现Apply失败进而导致数据不一致引发各种诡异现象。
除了重启etcd、升级etcd可能会导致数据不一致defrag操作也可能会导致不一致。
对一个defrag碎片整理来说它是如何触发数据不一致的呢 触发的条件是defrag未正常结束时会生成db.tmp临时文件。这个文件可能包含部分上一次defrag写入的部分key/value数据。而etcd下次defrag时并不会清理它复用后就可能会出现各种异常场景如重启后key增多、删除的用户数据key再次出现、删除user/role再次出现等。
etcd 3.2.29、etcd 3.3.19、etcd 3.4.4后的版本都已经修复这个Bug。我建议你根据自己实际情况进行升级否则踩坑后数据不一致的修复工作是非常棘手的风险度极高。
从以上三个案例里我们可以看到算法一致性不代表一个庞大的分布式系统工程实现中一定能保障一致性工程实现上充满着各种挑战从不可靠的网络环境到时钟、再到人为错误、各模块间的复杂交互等几乎没有一个存储系统能保证任意分支逻辑能被测试用例100%覆盖。
复制状态机在给我们带来数据同步的便利基础上也给我们上层逻辑开发提出了高要求。也就是说任何接口逻辑变更etcd需要保证兼容性否则就很容易出现Apply流程失败导致数据不一致。
同时除了Apply流程可能导致数据不一致外我们从defrag案例中也看到了一些维护变更操作直接针对底层存储模块boltdb的也可能会触发Bug导致数据不一致。
最佳实践
在了解了etcd数据不一致的风险和原因后我们在实践中有哪些方法可以提前发现和规避不一致问题呢
下面我为你总结了几个最佳实践,它们分别是:
开启etcd的数据毁坏检测功能
应用层的数据一致性检测;
定时数据备份;
良好的运维规范(比如使用较新稳定版本、确保版本一致性、灰度变更)。
开启etcd的数据毁坏检测功能
首先和你介绍下etcd的数据毁坏检测功能。etcd不仅支持在启动的时候通过experimental-initial-corrupt-check参数检查各个节点数据是否一致也支持在运行过程通过指定experimental-corrupt-check-time参数每隔一定时间检查数据一致性。
那么它的一致性检测原理是怎样的如果出现不一致性etcd会采取什么样动作去降低数据不一致影响面呢
其实我们无非就是想确定boltdb文件里面的内容跟其他节点内容是否一致。因此我们可以枚举所有key value然后比较即可。
etcd的实现也就是通过遍历treeIndex模块中的所有key获取到版本号然后再根据版本号从boltdb里面获取key的value使用crc32 hash算法将bucket name、key、value组合起来计算它的hash值。
如果你开启了experimental-initial-corrupt-check启动的时候每个节点都会去获取peer节点的boltdb hash值然后相互对比如果不相等就会无法启动。
而定时检测是指Leader节点获取它当前最新的版本号并通过Raft模块的ReadIndex机制确认Leader身份。当确认完成后获取各个节点的revision和boltdb hash值若出现Follower节点的revision大于Leader等异常情况时就可以认为不一致发送corrupt告警触发集群corruption保护拒绝读写。
从etcd上面的一致性检测方案我们可以了解到目前采用的方案是比较简单、暴力的。因此可能随着数据规模增大出现检测耗时增大等扩展性问题。而DynamoDB等使用了merkle tree来实现增量hash检测这也是etcd未来可能优化的一个方向。
最后你需要特别注意的是etcd数据毁坏检测的功能目前还是一个试验(experimental)特性在比较新的版本才趋于稳定、成熟推荐v3.4.9以上预计在未来的etcd 3.5版本中才会变成稳定特性因此etcd 3.23.3系列版本就不能使用此方案。
应用层的数据一致性检测
那要如何给etcd 3.23.3版本增加一致性检测呢? 其实除了etcd自带数据毁坏检测我们还可以通过在应用层通过一系列方法来检测数据一致性它们适用于etcd所有版本。
接下来我给你讲讲应用层检测的原理。
从上面我们对数据不一致性案例的分析中我们知道数据不一致在MVCC、boltdb会出现很多种情况比如说key数量不一致、etcd逻辑时钟版本号不一致、MVCC模块收到的put操作metrics指标值不一致等等。因此我们的应用层检测方法就是基于它们的差异进行巡检。
首先针对key数量不一致的情况我们可以实现巡检功能定时去统计各个节点的key数这样可以快速地发现数据不一致从而及时介入控制数据不一致影响降低风险。
在你统计节点key数时记得查询的时候带上WithCountOnly参数。etcd从treeIndex模块获取到key数后就及时返回了无需访问boltdb模块。如果你的数据量非常大涉及到百万级别那即便是从treeIndex模块返回也会有一定的内存开销因为它会把key追加到一个数组里面返回。
而在WithCountOnly场景中我们只需要统计key数即可。因此我给社区提了优化方案目前已经合并到master分支。对百万级别的key来说WithCountOnly时内存开销从数G到几乎零开销性能也提升数十倍。
其次我们可以基于endpoint各个节点的revision信息做一致性监控。一般情况下各个节点的差异是极小的。
最后我们还可以基于etcd MVCC的metrics指标来监控。比如上面提到的mvcc_put_total理论上每个节点这些MVCC指标是一致的不会出现偏离太多。
定时数据备份
etcd数据不一致的修复工作极其棘手。发生数据不一致后各个节点可能都包含部分最新数据和脏数据。如果最终我们无法修复那就只能使用备份数据来恢复了。
因此备份特别重要备份可以保障我们在极端场景下能有保底的机制去恢复业务。请记住在做任何重要变更前一定先备份数据以及在生产环境中建议增加定期的数据备份机制比如每隔30分钟备份一次数据
你可以使用开源的etcd-operator中的backup-operator去实现定时数据备份它可以将etcd快照保存在各个公有云的对象存储服务里面。
良好的运维规范
最后我给你介绍几个运维规范,这些规范可以帮助我们尽量少踩坑(即便你踩坑后也可以控制故障影响面)。
首先是确保集群中各节点etcd版本一致。若各个节点的版本不一致因各版本逻辑存在差异性这就会增大触发不一致Bug的概率。比如我们前面提到的升级版本触发的不一致Bug就属于此类问题。
其次是优先使用较新稳定版本的etcd。像上面我们提到的3个不一致Bug在最新的etcd版本中都得到了修复。你可以根据自己情况进行升级以避免下次踩坑。同时你可根据实际业务场景以及安全风险来评估是否有必要开启鉴权开启鉴权后涉及的逻辑更复杂有可能增大触发数据不一致Bug的概率。
最后是你在升级etcd版本的时候需要多查看change log评估是否存在可能有不兼容的特性。在你升级集群的时候注意先在测试环境多验证生产环境务必先灰度、再全量。
小结
最后,我来总结下我们今天的内容。
我从消失的Node案例为例介绍了etcd中定位一个复杂不一致问题的思路和方法工具。核心就是根据我们对etcd读写原理的了解对每个模块可能出现的问题进行大胆猜想。
同时我们要善于借助日志、metrics、etcd tool等进行验证排除。定位到最终模块问题后如果很难复现我们可以借助混沌工程等技术注入模拟各类故障。遇到复杂Bug时请永远不要轻言放弃它一定是一个让你快速成长的机会。
其次我介绍了etcd数据不一致的核心原因Raft算法只能保证各个节点日志同步的一致性但Apply流程是异步的它从一致性模块获取日志命令应用到状态机的准确性取决于业务逻辑这块是没有机制保证的。
同时defrag等运维管理操作会直接修改底层存储数据异常场景处理不严谨也会导致数据不一致。
数据不一致的风险是非常大的轻则业务逻辑异常重则核心数据丢失。我们需要机制去提前发现和规避它因此最后我详细给你总结了etcd本身和应用层的一致性监控、定时备份数据、良好的运维规范等若干最佳实践这些都是宝贵的实践总结希望你能有所收获。
思考题
掌握好最佳实践、多了解几个已知Bug能让你少交很多昂贵的学费针对数据不一致问题你是否还有更好的建议呢 同时你在使用etcd过程中是否还有其他令你记忆深刻的问题和Bug呢欢迎你在留言区与我讨论。
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,我们下一讲见。

View File

@ -0,0 +1,229 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 db大小为什么etcd社区建议db大小不超过8G
你好,我是唐聪。
在03写流程中我和你分享了etcd Quota模块那么etcd为什么需要对db增加Quota限制以及不建议你的etcd集群db大小超过8G呢 过大的db文件对集群性能和稳定性有哪些影响
今天我要和你分享的主题就是关于db大小。我将通过一个大数据量的etcd集群为案例为你剖析etcd db大小配额限制背后的设计思考和过大的db潜在隐患。
希望通过这节课帮助你理解大数据量对集群的各个模块的影响配置合理的db Quota值。同时帮助你在实际业务场景中遵循最佳实践尽量减少value大小和大key-value更新频率避免db文件大小不断增长。
分析整体思路
为了帮助你直观地理解大数据量对集群稳定性的影响我首先将为你写入大量数据构造一个db大小为14G的大集群。然后通过此集群为你分析db大小的各个影响面db大小影响面如下图所示。
首先是启动耗时。etcd启动的时候需打开boltdb db文件读取db文件所有key-value数据用于重建内存treeIndex模块。因此在大量key导致db文件过大的场景中这会导致etcd启动较慢。
其次是节点内存配置。etcd在启动的时候会通过mmap将db文件映射内存中若节点可用内存不足小于db文件大小时可能会出现缺页文件中断导致服务稳定性、性能下降。
接着是treeIndex索引性能。因etcd不支持数据分片内存中的treeIndex若保存了几十万到上千万的key这会增加查询、修改操作的整体延时。
然后是boltdb性能。大db文件场景会导致事务提交耗时增长、抖动。
再次是集群稳定性。大db文件场景下无论你是百万级别小key还是上千个大value场景一旦出现expensive request后很容易导致etcd OOM、节点带宽满而丢包。
最后是快照。当Follower节点落后Leader较多数据的时候会触发Leader生成快照重建发送给Follower节点Follower基于它进行还原重建操作。较大的db文件会导致Leader发送快照需要消耗较多的CPU、网络带宽资源同时Follower节点重建还原慢。
构造大集群
简单介绍完db大小的六个影响面后我们下面来构造一个大数据量的集群用于后续各个影响面的分析。
首先我通过一系列如下benchmark命令向一个8核32G的3节点的集群写入120万左右key。key大小为32value大小为256到10K用以分析大db集群案例中的各个影响面。
./benchmark put --key-size 32 --val-size 10240 --total
1000000 --key-space-size 2000000 --clients 50 --conns 50
执行完一系列benchmark命令后db size达到14G总key数达到120万其监控如下图所示
启动耗时
在如上的集群中我通过benchmark工具将etcd集群db大小压测到14G后在重新启动etcd进程的时候如下日志所示你会发现启动比较慢为什么大db文件会影响etcd启动耗时呢
2021-02-15 02:25:55.273712 I | etcdmain: etcd Version: 3.4.9
2021-02-15 02:26:58.806882 I | etcdserver: recovered store from snapshot at index 2100090
2021-02-15 02:26:58.808810 I | mvcc: restore compact to 1000002
2021-02-15 02:27:19.120141 W | etcdserver: backend quota 26442450944 exceeds maximum recommended quota 8589934592
2021-02-15 02:27:19.297363 I | embed: ready to serve client requests
通过对etcd启动流程增加耗时统计我们可以发现核心瓶颈主要在于打开db文件和重建内存treeIndex模块。
这里我重点先和你介绍下etcd启动后重建内存treeIndex的原理。
我们知道treeIndex模块维护了用户key与boltdb key的映射关系boltdb的key、value又包含了构建treeIndex的所需的数据。因此etcd启动的时候会启动不同角色的goroutine并发完成treeIndex构建。
首先是主goroutine。它的职责是遍历boltdb获取所有key-value数据并将其反序列化成etcd的mvccpb.KeyValue结构。核心原理是基于etcd存储在boltdb中的key数据有序性按版本号从1开始批量遍历每次查询10000条key-value记录直到查询数据为空。
其次是构建treeIndex索引的goroutine。它从主goroutine获取mvccpb.KeyValue数据基于key、版本号、是否带删除标识等信息构建keyIndex对象插入到treeIndex模块的B-tree中。
因可能存在多个goroutine并发操作treeIndextreeIndex的Insert函数会加全局锁如下所示。etcd启动时只有一个构建treeIndex索引的goroutine因此key多时会比较慢。之前我尝试优化成多goroutine并发构建但是效果不佳大量耗时会消耗在此锁上。
func (ti *treeIndex) Insert(ki *keyIndex) {
ti.Lock()
defer ti.Unlock()
ti.tree.ReplaceOrInsert(ki)
}
节点内存配置
etcd进程重启完成后在没任何读写QPS情况下如下所示你会发现etcd所消耗的内存比db大小还大一点。这又是为什么呢如果etcd db文件大小超过节点内存规格会导致什么问题吗
在10介绍boltdb存储原理的时候我和你分享过boltdb文件的磁盘布局结构和其对外提供的API原理。
etcd在启动的时候会通过boltdb的Open API获取数据库对象而Open API它会通过mmap机制将db文件映射到内存中。
由于etcd调用boltdb Open API的时候设置了mmap的MAP_POPULATE flag它会告诉Linux内核预读文件将db文件内容全部从磁盘加载到物理内存中。
因此在你节点内存充足的情况下启动后你看到的etcd占用内存一般是db文件大小与内存treeIndex之和。
在节点内存充足的情况下启动后client后续发起对etcd的读操作可直接通过内存获取boltdb的key-value数据不会产生任何磁盘IO具备良好的读性能、稳定性。
而当你的db文件大小超过节点内存配置时若你查询的key所相关的branch page、leaf page不在内存中那就会触发主缺页中断导致读延时抖动、QPS下降。
因此为了保证etcd集群性能的稳定性我建议你的etcd节点内存规格要大于你的etcd db文件大小。
treeIndex
当我们往集群中写入了一百多万key时此时你再读取一个key范围操作的延时会出现一定程度上升这是为什么呢我们该如何分析耗时是在哪一步导致的
在etcd 3.4中提供了trace特性它可帮助我们定位、分析请求耗时过长问题。不过你需要特别注意的是此特性在etcd 3.4中因为依赖zap logger默认为关闭。你可以通过设置etcd启动参数中的logger=zap来开启。
开启之后我们可以在etcd日志中找到类似如下的耗时记录。
{
"msg":"trace[331581563] range"
"detail":"{range_begin:/vip/a; range_end:/vip/b; response_count:19304; response_revision:1005564; }"
"duration":"146.432768ms"
"steps":[
"trace[331581563] 'range keys from in-memory treeIndex' (duration: 95.925033ms)"
"trace[331581563] 'range keys from bolt db' (duration: 47.932118ms)"
]
此日志记录了查询请求”etcdctl get prefix /vip/a”。它在treeIndex中查询相关key耗时95ms从boltdb遍历key时47ms。主要原因还是此查询涉及的key数较多高达一万九。
也就是说若treeIndex中存储了百万级的key时它可能也会产生几十毫秒到数百毫秒的延时对于期望业务延时稳定在较小阈值内的业务就无法满足其诉求。
boltdb性能
当db文件大小持续增长到16G乃至更大后从etcd事务提交监控metrics你可能会观察到boltdb在提交事务时偶尔出现了较高延时那么延时是怎么产生的呢
在10介绍boltdb的原理时我和你分享了db文件的磁盘布局它是由meta page、branch page、leaf page、free list、free页组成的。同时我给你介绍了boltdb事务提交的四个核心流程分别是B+ tree的重平衡、分裂持久化dirty page持久化freelist以及持久化meta data。
事务提交延时抖动的原因主要是在B+ tree树的重平衡和分裂过程中它需要从freelist中申请若干连续的page存储数据或释放空闲的page到freelist。
freelist后端实现在boltdb中是array。当申请一个连续的n个page存储数据时它会遍历boltdb中所有的空闲页直到找到连续的n个page。因此它的时间复杂度是O(N)。若db文件较大又存在大量的碎片空闲页很可能导致超时。
同时事务提交过程中也可能会释放若干个page给freelist因此需要合并到freelist的数组中此操作时间复杂度是O(NLog N)。
假设我们db大小16Gpage size 4KB则有400万个page。经过各种修改、压缩后若存在一半零散分布的碎片空闲页在最坏的场景下etcd每次事务提交需要遍历200万个page才能找到连续的n个page同时还需要持久化freelist到磁盘。
为了优化boltdb事务提交的性能etcd社区在bbolt项目中实现了基于hashmap来管理freelist。通过引入了如下的三个map数据结构freemaps的key是连续的页数value是以空闲页的起始页pgid集合forwardmap和backmap用于释放的时候快速合并页将申请和释放时间复杂度降低到了O(1)。
freelist后端实现可以通过bbolt的FreeListType参数来控制支持array和hashmap。在etcd 3.4版本中目前还是array未来的3.5版本将默认是hashmap。
freemaps map[uint64]pidSet // key is the size of continuous pages(span)value is a set which contains the starting pgids of same size
forwardMap map[pgid]uint64 // key is start pgidvalue is its span size
backwardMap map[pgid]uint64 // key is end pgidvalue is its span size
另外在db中若存在大量空闲页持久化freelist需要消耗较多的db大小并会导致额外的事务提交延时。
若未持久化freelistbbolt支持通过重启时扫描全部page来构造freelist降低了db大小和提升写事务提交的性能但是它会带来etcd启动延时的上升。此行为可以通过bbolt的NoFreelistSync参数来控制默认是true启用此特性。
集群稳定性
db文件增大后另外一个非常大的隐患是用户client发起的expensive request容易导致集群出现各种稳定性问题。
本质原因是etcd不支持数据分片各个节点保存了所有key-value数据同时它们又存储在boltdb的一个bucket里面。当你的集群含有百万级以上key的时候任意一种expensive read请求都可能导致etcd出现OOM、丢包等情况发生。
那么有哪些expensive read请求会导致etcd不稳定性呢
首先是简单的count only查询。如下图所示当你想通过API统计一个集群有多少key时如果你的key较多则有可能导致内存突增和较大的延时。
在etcd 3.5版本之前统计key数会遍历treeIndex把key追加到数组中。然而当数据规模较大时追加key到数组中的操作会消耗大量内存同时数组扩容时涉及到大量数据拷贝会导致延时上升。
其次是limit查询。当你只想查询若干条数据的时候若你的key较多也会导致类似count only查询的性能、稳定性问题。
原因是etcd 3.5版本之前遍历index B-tree时并未将limit参数下推到索引层导致了无用的资源和时间消耗。优化方案也很简单etcd 3.5中我提的优化PR将limit参数下推到了索引层实现查询性能百倍提升。
最后是大包查询。当你未分页批量遍历key-value数据或单key-value数据较大的时候随着请求QPS增大etcd OOM、节点出现带宽瓶颈导致丢包的风险会越来越大。
问题主要由以下两点原因导致:
第一etcd需要遍历treeIndex获取key列表。若你未分页一次查询万级key显然会消耗大量内存并且高延时。
第二获取到key列表、版本号后etcd需要遍历boltdb将key-value保存到查询结果数据结构中。如下trace日志所示一个请求可能在遍历boltdb时花费很长时间同时可能会消耗几百M甚至数G的内存。随着请求QPS增大极易出现OOM、丢包等。etcd这块未来的优化点是实现流式传输。
{
"level":"info",
"ts":"2021-02-15T03:44:52.209Z",
"caller":"traceutil/trace.go:145",
"msg":"trace[1908866301] range",
"detail":"{range_begin:; range_end:; response_count:1232274; response_revision:3128500; }",
"duration":"9.063748801s",
"start":"2021-02-15T03:44:43.145Z",
"end":"2021-02-15T03:44:52.209Z",
"steps":[
"trace[1908866301] 'range keys from in-memory index tree' (duration: 693.262565ms)",
"trace[1908866301] 'range keys from bolt db' (duration: 8.22558566s)",
"trace[1908866301] 'assemble the response' (duration: 18.810315ms)"
]
}
快照
大db文件最后一个影响面是快照。它会影响db备份文件生成速度、Leader发送快照给Follower节点的资源开销、Follower节点通过快照重建恢复的速度。
我们知道etcd提供了快照功能帮助我们通过API即可备份etcd数据。当etcd收到snapshot请求的时候它会通过boltdb接口创建一个只读事务Tx随后通过事务的WriteTo接口将meta page和data page拷贝到buffer即可。
但是随着db文件增大快照事务执行的时间也会越来越长而长事务则会导致db文件大小发生显著增加。
也就是说当db大时生成快照不仅慢生成快照时可能还会触发db文件大小持续增长最终达到配额限制。
为什么长事务可能会导致db大小增长呢 这个问题我先将它作为思考题,你可以分享一下你的想法,后续我将为你详细解答。
快照的另一大作用是当Follower节点异常的时候Leader生成快照发送给Follower节点Follower使用快照重建并追赶上Leader。此过程涉及到一定的CPU、内存、网络带宽等资源开销。
同时若快照和集群写QPS较大Leader发送快照给Follower和Follower应用快照到状态机的流程会耗费较长的时间这可能会导致基于快照重建后的Follower依然无法通过正常的日志复制模式来追赶Leader只能继续触发Leader生成快照进而进入死循环Follower一直处于异常中。
小结
最后我们来小结下今天的内容。大db文件首先会影响etcd启动耗时因为etcd需要打开db文件初始化db对象并遍历boltdb中的所有key-value以重建内存treeIndex。
其次较大db文件会导致etcd依赖更高配置的节点内存规格etcd通过mmap将db文件映射到内存中。etcd启动后正常情况下读etcd过程不涉及磁盘IO若节点内存不够可能会导致缺页中断引起延时抖动、服务性能下降。
接着treeIndex维护了所有key的版本号信息当treeIndex中含有百万级key时在treeIndex中搜索指定范围的key的开销是不能忽略的此开销可能高达上百毫秒。
然后当db文件过大后boltdb本身连续空闲页的申请、释放、存储都会存在一定的开销。etcd社区已通过新的freelist管理数据结构hashmap对其进行优化将时间复杂度降低到了O(1)同时支持事务提交时不持久化freelist而是通过重启时扫描page重建以提升etcd写性能、降低db大小。
随后我给你介绍了db文件过大后count only、limit、大包查询等expensive request对集群稳定性的影响。建议你的业务尽量避免任何expensive request请求。
最后我们介绍了大db文件对快照功能的影响。大db文件意味着更长的备份时间而更长的只读事务则可能会导致db文件增长。同时Leader发送快照与Follower基于快照重建都需要较长时间在集群写请求较大的情况下可能会陷入死循环导致落后的Follower节点一直无法追赶上Leader。
思考题
在使用etcd过程中你遇到了哪些案例导致了etcd db大小突增呢 它们的本质原因是什么呢?
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。

View File

@ -0,0 +1,347 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 延时为什么你的etcd请求会出现超时
你好,我是唐聪。
在使用etcd的过程中你是否被日志中的”apply request took too long”和“etcdserver: request timed out”等高延时现象困扰过它们是由什么原因导致的呢我们应该如何来分析这些问题
这就是我今天要和你分享的主题etcd延时。希望通过这节课帮助你掌握etcd延时抖动、超时背后的常见原因和分析方法当你遇到类似问题时能独立定位、解决。同时帮助你在实际业务场景中合理配置集群遵循最佳实践尽量减少expensive request避免etcd请求出现超时。
分析思路及工具
首先,当我们面对一个高延时的请求案例后,如何梳理问题定位思路呢?
知彼知己方能百战不殆定位问题也是类似。首先我们得弄清楚产生问题的原理、流程在02、03、04中我已为你介绍过读写请求的核心链路。其次是熟练掌握相关工具借助它们可以帮助我们快速攻破疑难杂症。
这里我们再回顾下03中介绍的Leader收到一个写请求将一个日志条目复制到集群多数节点并应用到存储状态机的流程如下图所示通过此图我们看看写流程上哪些地方可能会导致请求超时呢
首先是流程四一方面Leader需要并行将消息通过网络发送给各Follower节点依赖网络性能。另一方面Leader需持久化日志条目到WAL依赖磁盘I/O顺序写入性能。
其次是流程八应用日志条目到存储状态机时etcd后端key-value存储引擎是boltdb。正如我们10所介绍的它是一个基于B+ tree实现的存储引擎当你写入数据提交事务时它会将dirty page持久化到磁盘中。在这过程中boltdb会产生磁盘随机I/O写入因此事务提交性能依赖磁盘I/O随机写入性能。
最后在整个写流程处理过程中etcd节点的CPU、内存、网络带宽资源应充足否则肯定也会影响性能。
初步了解完可能导致延时抖动的瓶颈处之后我给你总结了etcd问题定位过程中常用的工具你可以参考下面这幅图。
图的左边是读写请求链路中可能出现瓶颈或异常的点比如上面流程分析中提到的磁盘、内存、CPU、网络资源。
图的右边是常用的工具分别是metrics、trace日志、etcd其他日志、WAL及boltdb分析工具等。
接下来我基于读写请求的核心链路和其可能出现的瓶颈点结合相关的工具为你深入分析etcd延时抖动的定位方法和原因。
网络
首先我们来看看流程图中第一个提到可能瓶颈点,网络模块。
在etcd中各个节点之间需要通过2380端口相互通信以完成Leader选举、日志同步等功能因此底层网络质量吞吐量、延时、稳定性对上层etcd服务的性能有显著影响。
网络资源出现异常的常见表现是连接闪断、延时抖动、丢包等。那么我们要如何定位网络异常导致的延时抖动呢?
一方面我们可以使用常规的ping/traceroute/mtr、ethtool、ifconfig/ip、netstat、tcpdump网络分析工具等命令测试网络的连通性、延时查看网卡的速率是否存在丢包等错误确认etcd进程的连接状态及数量是否合理抓取etcd报文分析等。
另一方面etcd应用层提供了节点之间网络统计的metrics指标分别如下
etcd_network_active_peer表示peer之间活跃的连接数
etcd_network_peer_round_trip_time_seconds表示peer之间RTT延时
etcd_network_peer_sent_failures_total表示发送给peer的失败消息数
etcd_network_client_grpc_sent_bytes_total表示server发送给client的总字节数通过这个指标我们可以监控etcd出流量
etcd_network_client_grpc_received_bytes_total表示server收到client发送的总字节数通过这个指标可以监控etcd入流量。
client入流量监控如下图所示
client出流量如下图监控所示。 从图中你可以看到峰值接近140MB/s(1.12Gbps)这是非常不合理的说明业务中肯定有大量expensive read request操作。若etcd集群读写请求开始出现超时你可以用ifconfig等命令查看是否出现丢包等错误。
etcd metrics指标名由namespace和subsystem、name组成。namespace为etcd subsystem是模块名比如network、name具体的指标名。你可以在Prometheus里搜索etcd_network找到所有network相关的metrics指标名。
下面是一个集群中某节点异常后的metrics指标
etcd_network_active_peers{Local="fd422379fda50e48"Remote="8211f1d0f64f3269"} 1
etcd_network_active_peers{Local="fd422379fda50e48"Remote="91bc3c398fb3c146"} 0
etcd_network_peer_sent_failures_total{To="91bc3c398fb3c146"} 47774
etcd_network_client_grpc_sent_bytes_total 513207
从以上metrics中你可以看到91bc3c398fb3c146节点出现了异常。在etcd场景中网络质量导致etcd性能下降主要源自两个方面
一方面expensive request中的大包查询会使网卡出现瓶颈产生丢包等错误从而导致etcd吞吐量下降、高延时。expensive request导致网卡丢包出现超时这在etcd中是非常典型且易发生的问题它主要是因为业务没有遵循最佳实践查询了大量key-value。
另一方面在跨故障域部署的时候故障域可能是可用区、城市。故障域越大容灾级别越高但各个节点之间的RTT越高请求的延时更高。
磁盘I/O
了解完网络问题的定位方法和导致网络性能下降的因素后我们再看看最核心的磁盘I/O。
正如我在开头的Raft日志复制整体流程图中和你介绍的在etcd中无论是Raft日志持久化还是boltdb事务提交都依赖于磁盘I/O的性能。
当etcd请求延时出现波动时我们往往首先关注disk相关指标是否正常。我们可以通过etcd磁盘相关的metrics(etcd_disk_wal_fsync_duration_seconds和etcd_disk_backend_commit_duration_seconds)来观测应用层数据写入磁盘的性能。
etcd_disk_wal_fsync_duration_seconds简称disk_wal_fsync表示WAL日志持久化的fsync系统调用延时数据。一般本地SSD盘P99延时在10ms内如下图所示。
etcd_disk_backend_commit_duration_seconds简称disk_backend_commit表示后端boltdb事务提交的延时一般P99在120ms内。
这里你需要注意的是一般监控显示的磁盘延时都是P99但实际上etcd对磁盘特别敏感一次磁盘I/O波动就可能产生Leader切换。如果你遇到集群Leader出现切换、请求超时但是磁盘指标监控显示正常你可以查看P100确认下是不是由于磁盘I/O波动导致的。
同时etcd的WAL模块在fdatasync操作超过1秒时也会在etcd中打印如下的日志你可以结合日志进一步定位。
if took > warnSyncDuration {
if w.lg != nil {
w.lg.Warn(
"slow fdatasync",
zap.Duration("took", took),
zap.Duration("expected-duration", warnSyncDuration),
)
} else {
plog.Warningf("sync duration of %v, expected less than %v", took, warnSyncDuration)
}
}
当disk_wal_fsync指标异常的时候一般是底层硬件出现瓶颈或异常导致。当然也有可能是CPU高负载、cgroup blkio限制导致的我们具体应该如何区分呢
你可以通过iostat、blktrace工具分析瓶颈是在应用层还是内核层、硬件层。其中blktrace是blkio层的磁盘I/O分析利器它可记录IO进入通用块层、IO请求生成插入请求队列、IO请求分发到设备驱动、设备驱动处理完成这一系列操作的时间帮助你发现磁盘I/O瓶颈发生的阶段。
当disk_backend_commit指标的异常时候说明事务提交过程中的B+ tree树重平衡、分裂、持久化dirty page、持久化meta page等操作耗费了大量时间。
disk_backend_commit指标异常能说明是磁盘I/O发生了异常吗
若disk_backend_commit较高、disk_wal_fsync却正常说明瓶颈可能并非来自磁盘I/O性能也许是B+ tree的重平衡、分裂过程中的较高时间复杂度逻辑操作导致。比如etcd目前所有stable版本etcd 3.2到3.4从freelist中申请和回收若干连续空闲页的时间复杂度是O(N)当db文件较大、空闲页碎片化分布的时候则可能导致事务提交高延时。
那如何区分事务提交过程中各个阶段的耗时呢?
etcd还提供了disk_backend_commit_rebalance_duration和
disk_backend_commit_spill_duration两个metrics分别表示事务提交过程中B+ tree的重平衡和分裂操作耗时分布区间。
最后你需要注意disk_wal_fsync记录的是WAL文件顺序写入的持久化时间disk_backend_commit记录的是整个事务提交的耗时。后者涉及的磁盘I/O是随机的为了保证你etcd集群的稳定性建议使用SSD磁盘以确保事务提交的稳定性。
expensive request
若磁盘和网络指标都很正常,那么延时高还有可能是什么原因引起的呢?
从02介绍的读请求链路我们可知一个读写请求经过Raft模块处理后最终会走到MVCC模块。那么在MVCC模块会有哪些场景导致延时抖动呢时间耗在哪个处理流程上了
etcd 3.4版本之前在应用put/txn等请求到状态机的apply和处理读请求range流程时若一个请求执行超过100ms时默认会在etcd log中打印一条”apply request took too long”的警告日志。通过此日志我们可以知道集群中apply流程产生了较慢的请求但是不能确定具体是什么因素导致的。
比如在Kubernetes中当集群Pod较多的时候若你频繁执行List Pod可能会导致etcd出现大量的”apply request took too long”警告日志。
因为对etcd而言List Pod请求涉及到大量的key查询会消耗较多的CPU、内存、网络资源此类expensive request的QPS若较大则很可能导致OOM、丢包。
当然除了业务发起的expensive request请求导致延时抖动以外也有可能是etcd本身的设计实现存在瓶颈。
比如在etcd 3.2和3.3版本写请求完成之前需要更新MVCC的buffer进行升级锁操作。然而此时若集群中出现了一个long expensive read request则会导致写请求执行延时抖动。因为expensive read request事务会一直持有MVCC的buffer读锁导致写请求事务阻塞在升级锁操作中。
在了解完expensive request对请求延时的影响后接下来要如何解决请求延时较高问题的定位效率呢
为了提高请求延时分布的可观测性、延时问题的定位效率etcd社区在3.4版本后中实现了trace特性详细记录了一个请求在各个阶段的耗时。若某阶段耗时流程超过默认的100ms则会打印一条trace日志。
下面是我将trace日志打印的阈值改成1纳秒后读请求执行过程中的trace日志。从日志中你可以看到trace日志记录了以下阶段耗时
agreement among raft nodes before linearized reading此阶段读请求向Leader发起readIndex查询并等待本地applied index >= Leader的committed index 但是你无法区分是readIndex慢还是等待本地applied index > Leader的committed index慢。在etcd 3.5中新增了trace区分了以上阶段
get authentication metadata获取鉴权元数据
range keys from in-memory index tree从内存索引B-tree中查找key列表对应的版本号列表
range keys from bolt db根据版本号列表从boltdb遍历获得用户的key-value信息
filter and sort the key-value pairs过滤、排序key-value列表
assemble the response聚合结果。
{
"level":"info"
"ts":"2020-12-16T08:11:43.720+0800"
"caller":"traceutil/trace.go:145"
"msg":"trace[789864563] range"
"detail":"{range_begin:a; range_end:; response_count:1; response_revision:32011; }"
"duration":"318.774µs"
"start":"2020-12-16T08:11:43.719+0800"
"end":"2020-12-16T08:11:43.720+0800"
"steps":[
"trace[789864563] 'agreement among raft nodes before linearized reading' (duration: 255.227µs)"
"trace[789864563] 'get authentication metadata' (duration: 2.97µs)"
"trace[789864563] 'range keys from in-memory index tree' (duration: 44.578µs)"
"trace[789864563] 'range keys from bolt db' (duration: 8.688µs)"
"trace[789864563] 'filter and sort the key-value pairs' (duration: 578ns)"
"trace[789864563] 'assemble the response' (duration: 643ns)"
]
}
那么写请求流程会记录哪些阶段耗时呢?
下面是put写请求的执行trace日志记录了以下阶段耗时
process raft request写请求提交到Raft模块处理完成耗时
get keys previous created_revision and leaseID获取key上一个创建版本号及leaseID的耗时
marshal mvccpb.KeyValue序列化KeyValue结构体耗时
store kv pair into bolt db存储kv数据到boltdb的耗时
attach lease to kv pair将lease id关联到kv上所用时间。
{
"level":"info"
"ts":"2020-12-16T08:25:12.707+0800"
"caller":"traceutil/trace.go:145"
"msg":"trace[1402827146] put"
"detail":"{key:16; req_size:8; response_revision:32030; }"
"duration":"6.826438ms"
"start":"2020-12-16T08:25:12.700+0800"
"end":"2020-12-16T08:25:12.707+0800"
"steps":[
"trace[1402827146] 'process raft request' (duration: 6.659094ms)"
"trace[1402827146] 'get key's previous created_revision and leaseID' (duration: 23.498µs)"
"trace[1402827146] 'marshal mvccpb.KeyValue' (duration: 1.857µs)"
"trace[1402827146] 'store kv pair into bolt db' (duration: 30.121µs)"
"trace[1402827146] 'attach lease to kv pair' (duration: 661ns)"
]
}
通过以上介绍的trace特性你就可以快速定位到高延时读写请求的原因。比如当你向etcd发起了一个涉及到大量key或value较大的expensive request请求的时候它会产生如下的warn和trace日志。
从以下日志中我们可以看到此请求查询的vip前缀下所有的kv数据总共是250条但是涉及的数据包大小有250MB总耗时约1.85秒其中从boltdb遍历key消耗了1.63秒。
{
"level":"warn"
"ts":"2020-12-16T23:02:53.324+0800"
"caller":"etcdserver/util.go:163"
"msg":"apply request took too long"
"took":"1.84796759s"
"expected-duration":"100ms"
"prefix":"read-only range "
"request":"key:"vip" range_end:"viq" "
"response":"range_response_count:250 size:262150651"
}
{
"level":"info"
"ts":"2020-12-16T23:02:53.324+0800"
"caller":"traceutil/trace.go:145"
"msg":"trace[370341530] range"
"detail":"{range_begin:vip; range_end:viq; response_count:250; response_revision:32666; }"
"duration":"1.850335038s"
"start":"2020-12-16T23:02:51.473+0800"
"end":"2020-12-16T23:02:53.324+0800"
"steps":[
"trace[370341530] 'range keys from bolt db' (duration: 1.632336981s)"
]
}
最后,有两个注意事项。
第一在etcd 3.4中logger默认为capnslogtrace特性只有在当logger为zap时才开启因此你需要设置logger=zap。
第二trace特性并不能记录所有类型的请求它目前只覆盖了MVCC模块中的range/put/txn等常用接口。像Authenticate鉴权请求涉及到大量CPU计算延时是非常高的在trace日志中目前没有相关记录。
如果你开启了密码鉴权在连接数增多、QPS增大后若突然出现请求超时如何确定是鉴权还是查询、更新等接口导致的呢
etcd默认参数并不会采集各个接口的延时数据我们可以通过设置etcd的启动参数metrics为extensive来开启获得每个gRPC接口的延时数据。同时可结合各个gRPC接口的请求数获得QPS。
如下是某节点的metrics数据251个Put请求返回码OK其中有240个请求在100毫秒内完成。
grpc_server_handled_total{grpc_code="OK"
grpc_method="Put"grpc_service="etcdserverpb.KV"
grpc_type="unary"} 251
grpc_server_handling_seconds_bucket{grpc_method="Put"grpc_service="etcdserverpb.KV"grpc_type="unary"le="0.005"} 0
grpc_server_handling_seconds_bucket{grpc_method="Put"grpc_service="etcdserverpb.KV"grpc_type="unary"le="0.01"} 1
grpc_server_handling_seconds_bucket{grpc_method="Put"grpc_service="etcdserverpb.KV"grpc_type="unary"le="0.025"} 51
grpc_server_handling_seconds_bucket{grpc_method="Put"grpc_service="etcdserverpb.KV"grpc_type="unary"le="0.05"} 204
grpc_server_handling_seconds_bucket{grpc_method="Put"grpc_service="etcdserverpb.KV"grpc_type="unary"le="0.1"} 240
集群容量、节点CPU/Memory瓶颈
介绍完网络、磁盘I/O、expensive request导致etcd请求延时较高的原因和分析方法后我们再看看容量和节点资源瓶颈是如何导致高延时请求产生的。
若网络、磁盘I/O正常也无expensive request那此时高延时请求是怎么产生的呢它的trace日志会输出怎样的耗时结果
下面是一个社区用户反馈的一个读接口高延时案例的两条trace日志。从第一条日志中我们可以知道瓶颈在于线性读的准备步骤readIndex和wait applied index。
那么是其中具体哪个步骤导致的高延时呢通过在etcd 3.5版本中细化此流程我们获得了第二条日志发现瓶颈在于等待applied index >= Leader的committed index。
{
"level": "info"
"ts": "2020-08-12T08:24:56.181Z"
"caller": "traceutil/trace.go:145"
"msg": "trace[677217921] range"
"detail": "{range_begin:/...redacted...; range_end:; response_count:1; response_revision:2725080604; }"
"duration": "1.553047811s"
"start": "2020-08-12T08:24:54.628Z"
"end": "2020-08-12T08:24:56.181Z"
"steps": [
"trace[677217921] 'agreement among raft nodes before linearized reading' (duration: 1.534322015s)"
]
}
{
"level": "info"
"ts": "2020-09-22T12:54:01.021Z"
"caller": "traceutil/trace.go:152"
"msg": "trace[2138445431] linearizableReadLoop"
"detail": ""
"duration": "855.447896ms"
"start": "2020-09-22T12:54:00.166Z"
"end": "2020-09-22T12:54:01.021Z"
"steps": [
"trace[2138445431] read index received (duration: 824.408µs)"
"trace[2138445431] applied index is now lower than readState.Index (duration: 854.622058ms)"
]
}
为什么会发生这样的现象呢?
首先你可以通过etcd_server_slow_apply_total指标观查其值快速增长的时间点与高延时请求产生的日志时间点是否吻合。
其次检查是否存在大量写请求。线性读需确保本节点数据与Leader数据一样新 若本节点的数据与Leader差异较大本节点追赶Leader数据过程会花费一定时间最终导致高延时的线性读请求产生。
etcd适合读多写少的业务场景若写请求较大很容易出现容量瓶颈导致高延时的读写请求产生。
最后通过ps/top/mpstat/perf等CPU、Memory性能分析工具检查etcd节点是否存在CPU、Memory瓶颈。goroutine饥饿、内存不足都会导致高延时请求产生若确定CPU和Memory存在异常你可以通过开启debug模式通过pprof分析CPU和内存瓶颈点。
小结
最后小结下我们今天的内容,我按照前面介绍的读写请求原理、以及丰富的实战经验,给你整理了可能导致延时抖动的常见原因。
如下图所示,我从以下几个方面给你介绍了会导致请求延时上升的原因:
网络质量如节点之间RTT延时、网卡带宽满出现丢包
磁盘I/O抖动会导致WAL日志持久化、boltdb事务提交出现抖动Leader出现切换等
expensive request比如大包请求、涉及到大量key遍历、Authenticate密码鉴权等操作
容量瓶颈,太多写请求导致线性读请求性能下降等;
节点配置CPU繁忙导致请求处理延时、内存不够导致swap等。
并在分析这些案例的过程中给你介绍了etcd问题核心工具metrics、etcd log、trace日志、blktrace、pprof等。
希望通过今天的内容能帮助你从容应对etcd延时抖动。
思考题
在使用etcd过程中你遇到过哪些高延时的请求案例呢你是如何解决的呢
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。

View File

@ -0,0 +1,314 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 内存为什么你的etcd内存占用那么高
你好,我是唐聪。
在使用etcd的过程中你是否被异常内存占用等现象困扰过比如etcd中只保存了1个1MB的key-value但是经过若干次修改后最终etcd内存可能达到数G。它是由什么原因导致的如何分析呢
这就是我今天要和你分享的主题etcd的内存。 希望通过这节课帮助你掌握etcd内存抖动、异常背后的常见原因和分析方法当你遇到类似问题时能独立定位、解决。同时帮助你在实际业务场景中为集群节点配置充足的内存资源遵循最佳实践尽量减少expensive request避免etcd内存出现突增导致OOM。
分析整体思路
当你遇到etcd内存占用较高的案例时你脑海中第一反应是什么呢
也许你会立刻重启etcd进程尝试将内存降低到合理水平避免线上服务出问题。
也许你会开启etcd debug模式重启etcd进程等复现然后采集heap profile分析内存占用。
以上措施都有其合理性。但作为团队内etcd高手的你在集群稳定性还不影响业务的前提下能否先通过内存异常的现场结合etcd的读写流程、各核心模块中可能会使用较多内存的关键数据结构推测出内存异常的可能原因
全方位的分析内存异常现场,可以帮助我们节省大量复现和定位时间,也是你专业性的体现。
下图是我以etcd写请求流程为例给你总结的可能导致etcd内存占用较高的核心模块与其数据结构。
从图中你可以看到当etcd收到一个写请求后gRPC Server会和你建立连接。连接数越多会导致etcd进程的fd、goroutine等资源上涨因此会使用越来越多的内存。
其次基于我们04介绍的Raft知识背景它需要将此请求的日志条目保存在raftLog里面。etcd raftLog后端实现是内存存储核心就是数组。因此raftLog使用的内存与其保存的日志条目成正比它也是内存分析过程中最容易被忽视的一个数据结构。
然后当此日志条目被集群多数节点确认后在应用到状态机的过程中会在内存treeIndex模块的B-tree中创建、更新key与版本号信息。 在这过程中treeIndex模块的B-tree使用的内存与key、历史版本号数量成正比。
更新完treeIndex模块的索引信息后etcd将key-value数据持久化存储到boltdb。boltdb使用了mmap技术将db文件映射到操作系统内存中。因此在未触发操作系统将db对应的内存page换出的情况下etcd的db文件越大使用的内存也就越大。
同时,在这个过程中还有两个注意事项。
一方面其他client可能会创建若干watcher、监听这个写请求涉及的key etcd也需要使用一定的内存维护watcher、推送key变化监听的事件。
另一方面如果这个写请求的key还关联了LeaseLease模块会在内存中使用数据结构Heap来快速淘汰过期的Lease因此Heap也是一个占用一定内存的数据结构。
最后不仅仅是写请求流程会占用内存读请求本身也会导致内存上升。尤其是expensive request当产生大包查询时MVCC模块需要使用内存保存查询的结果很容易导致内存突增。
基于以上读写流程图对核心数据结构使用内存的分析,我们定位问题时就有线索、方法可循了。那如何确定是哪个模块、场景导致的内存异常呢?
接下来我就通过一个实际案例,和你深入介绍下内存异常的分析方法。
一个key使用数G内存的案例
我们通过goreman启动一个3节点etcd集群(linux/etcd v3.4.9)db quota为6G执行如下的命令并观察etcd内存占用情况
执行1000次的put同一个key操作value为1MB
更新完后并进行compact、defrag操作
# put同一个key执行1000次
for i in {1..1000}; do dd if=/dev/urandom bs=1024
count=1024 | ETCDCTL_API=3 etcdctl put key || break; done
# 获取最新revision并压缩
etcdctl compact `(etcdctl endpoint status --write-out="json" | egrep -o '"revision":[0-9]*' | egrep -o '[0-9].*')`
# 对集群所有节点进行碎片整理
etcdctl defrag --cluster
在执行操作前空集群etcd db size 20KBetcd进程内存36M左右分别如下图所示。
你预测执行1000次同样key更新后etcd进程占用了多少内存呢? 约37M 1G 2G3G 还是其他呢?
执行1000次的put操作后db大小和etcd内存占用分别如下图所示。
当我们执行compact、defrag命令后如下图所示db大小只有1M左右但是你会发现etcd进程实际却仍占用了2G左右内存。-
整个集群只有一个key为什么etcd占用了这么多的内存呢是etcd发生了内存泄露吗
raftLog
当你发起一个put请求的时候etcd需通过Raft模块将此请求同步到其他节点详细流程你可结合下图再次了解下。
从图中你可以看到Raft模块的输入是一个消息/Msg输出统一为Ready结构。etcd会把此请求封装成一个消息提交到Raft模块。
Raft模块收到此请求后会把此消息追加到raftLog的unstable存储的entry内存数组中图中流程2并且将待持久化的此消息封装到Ready结构内通过管道通知到etcdserver图中流程3
etcdserver取出消息持久化到WAL中并追加到raftLog的内存存储storage的entry数组中图中流程5
下面是raftLog的核心数据结构它由storage、unstable、committed、applied等组成。storage存储已经持久化到WAL中的日志条目unstable存储未持久化的条目和快照一旦持久化会及时删除日志条目因此不存在过多内存占用的问题。
type raftLog struct {
// storage contains all stable entries since the last snapshot.
storage Storage
// unstable contains all unstable entries and snapshot.
// they will be saved into storage.
unstable unstable
// committed is the highest log position that is known to be in
// stable storage on a quorum of nodes.
committed uint64
// applied is the highest log position that the application has
// been instructed to apply to its state machine.
// Invariant: applied <= committed
applied uint64
}
从上面raftLog结构体中你可以看到存储稳定的日志条目的storage类型是StorageStorage定义了存储Raft日志条目的核心API接口业务应用层可根据实际场景进行定制化实现。etcd使用的是Raft算法库本身提供的MemoryStorage其定义如下核心是使用了一个数组来存储已经持久化后的日志条目。
// MemoryStorage implements the Storage interface backed
// by an in-memory array.
type MemoryStorage struct {
// Protects access to all fields. Most methods of MemoryStorage are
// run on the raft goroutine but Append() is run on an application
// goroutine.
sync.Mutex
hardState pb.HardState
snapshot pb.Snapshot
// ents[i] has raftLog position i+snapshot.Metadata.Index
ents []pb.Entry
}
那么随着写请求增多内存中保留的Raft日志条目会越来越多如何防止etcd出现OOM呢
etcd提供了快照和压缩功能来解决这个问题。
首先你可以通过调整snapshot-count参数来控制生成快照的频率其值默认是100000etcd v3.4.9早期etcd版本是10000也就是每10万个写请求触发一次快照生成操作。
快照生成完之后etcd会通过压缩来删除旧的日志条目。
那么是全部删除日志条目还是保留一小部分呢?
答案是保留一小部分Raft日志条目。数量由DefaultSnapshotCatchUpEntries参数控制默认5000目前不支持自定义配置。
保留一小部分日志条目其实是为了帮助慢的Follower以较低的开销向Leader获取Raft日志条目以尽快追上Leader进度。若raftLog中不保留任何日志条目就只能发送快照给慢的Follower这开销就非常大了。
通过以上分析可知如果你的请求key-value比较大比如上面我们的案例中是1M1000次修改那么etcd raftLog至少会消耗1G的内存。这就是为什么内存随着写请求修改次数不断增长的原因。
除了raftLog占用内存外MVCC模块的treeIndex/boltdb模块又是如何使用内存的呢
treeIndex
一个put写请求的日志条目被集群多数节点确认提交后这时etcdserver就会从Raft模块获取已提交的日志条目应用到MVCC模块的treeIndex和boltdb。
我们知道treeIndex是基于google内存btree库实现的一个索引管理模块在etcd中每个key都会在treeIndex中保存一个索引项(keyIndex)记录你的key和版本号等信息如下面的数据结构所示。
type keyIndex struct {
key []byte
modified revision // the main rev of the last modification
generations []generation
}
同时你每次对key的修改、删除操作都会在key的索引项中追加一条修改记录(revision)。因此随着修改次数的增加etcd内存会一直增加。那么如何清理旧版本防止过多的内存占用呢
答案也是压缩。正如我在11压缩篇和你介绍的当你执行compact命令时etcd会遍历treeIndex中的各个keyIndex清理历史版本号记录与已删除的key释放内存。
从上面的keyIndex数据结构我们可知一个key的索引项内存开销跟你的key大小、保存的历史版本数、compact策略有关。为了避免内存索引项占用过多的内存key的长度不应过长同时你需要配置好合理的压缩策略。
boltdb
在treeIndex模块中创建、更新完keyIndex数据结构后你的key-value数据、各种版本号、lease等相关信息会保存到如下的一个mvccpb.keyValue结构体中。它是boltdb的valuekey则是treeIndex中保存的版本号然后通过boltdb的写接口保存到db文件中。
kv := mvccpb.KeyValue{
Key: key
Value: value
CreateRevision: c
ModRevision: rev
Version: ver
Lease: int64(leaseID)
}
前面我们在介绍boltdb时提到过etcd在启动时会通过mmap机制将etcd db文件映射到etcd进程地址空间并设置mmap的MAP_POPULATE flag它会告诉Linux内核预读文件让Linux内核将文件内容拷贝到物理内存中。
在节点内存足够的情况下后续读请求可直接从内存中获取。相比read系统调用mmap少了一次从page cache拷贝到进程内存地址空间的操作因此具备更好的性能。
若etcd节点内存不足可能会导致db文件对应的内存页被换出。当读请求命中的页未在内存中时就会产生缺页异常导致读过程中产生磁盘IO。这样虽然避免了etcd进程OOM但是此过程会产生较大的延时。
从以上boltdb的key-value和mmap机制介绍中我们可知我们应控制boltdb文件大小优化key-value大小配置合理的压缩策略回收旧版本避免过多内存占用。
watcher
在你写入key的时候其他client还可通过etcd的Watch监听机制获取到key的变化事件。
那创建一个watcher耗费的内存跟哪些因素有关呢?
在08Watch机制设计与实现分析中我和你介绍过创建watcher的整体流程与架构如下图所示。当你创建一个watcher时client与server建立连接后会创建一个gRPC Watch Stream随后通过这个gRPC Watch Stream发送创建watcher请求。
每个gRPC Watch Stream中etcd WatchServer会分配两个goroutine处理一个是sendLoop它负责Watch事件的推送。一个是recvLoop负责接收client的创建、取消watcher请求消息。
同时对每个watcher来说etcd的WatchableKV模块需将其保存到相应的内存管理数据结构中实现可靠的Watch事件推送。
因此watch监听机制耗费的内存跟client连接数、gRPC Stream、watcher数(watching)有关,如下面公式所示:
c1表示每个连接耗费的内存
c2表示每个gRPC Stream耗费的内存
c3表示每个watcher耗费的内存。
memory = c1 * number_of_conn + c2 *
avg_number_of_stream_per_conn + c3 *
avg_number_of_watch_stream
根据etcd社区的压测报告大概估算出Watch机制中c1、c2、c3占用的内存分别如下
每个client连接消耗大约17kb的内存(c1)
每个gRPC Stream消耗大约18kb的内存(c2)
每个watcher消耗大约350个字节(c3)
当你的业务场景大量使用watcher的时候应提前估算下内存容量大小选择合适的内存配置节点。
注意以上估算并不包括watch事件堆积的开销。变更事件较多服务端、客户端高负载网络阻塞等情况都可能导致事件堆积。
在etcd 3.4.9版本中每个watcher默认buffer是1024。buffer内保存watch响应结果如watchID、watch事件watch事件包含key、value等。
若大量事件堆积将产生较高昂的内存的开销。你可以通过etcd_debugging_mvcc_pending_events_total指标监控堆积的事件数etcd_debugging_slow_watcher_total指标监控慢的watcher数来及时发现异常。
expensive request
当你写入比较大的key-value后如果client频繁查询它也会产生高昂的内存开销。
假设我们写入了100个这样1M大小的key 通过Range接口一次查询100个key 那么boltdb遍历、反序列化过程将花费至少100MB的内存。如下面代码所示它会遍历整个key-value将key-value保存到数组kvs中。
kvs := make([]mvccpb.KeyValue limit)
revBytes := newRevBytes()
for i revpair := range revpairs[:len(kvs)] {
revToBytes(revpair revBytes)
_ vs := tr.tx.UnsafeRange(keyBucketName revBytes nil 0)
if len(vs) != 1 {
......
}
if err := kvs[i].Unmarshal(vs[0]); err != nil {
.......
}
也就是说一次查询就耗费了至少100MB的内存、产生了至少100MB的流量随着你QPS增大后很容易OOM、网卡出现丢包。
count-only、limit查询在key百万级以上时也会产生非常大的内存开销。因为它们在遍历treeIndex的过程中会将相关key保存在数组里面。当key多时此开销不容忽视。
正如我在13 db大小中讲到的在master分支我已提交相关PR解决count-only和limit查询导致内存占用突增的问题。
etcd v2/goroutines/bug
除了以上介绍的核心模块、expensive request场景可能导致较高的内存开销外还有以下场景也会导致etcd内存使用较高。
首先是etcd中使用了v2的API写入了大量的key-value数据这会导致内存飙高。我们知道etcd v2的key-value都是存储在内存树中的同时v2的watcher不支持多路复用内存开销相比v3多了一个数量级。
在etcd 3.4版本之前etcd默认同时支持etcd v2/v3 APIetcd 3.4版本默认关闭了v2 API。 你可以通过etcd v2 API和etcd v2内存存储模块的metrics前缀etcd_debugging_store观察集群中是否有v2数据导致的内存占用高。
其次是goroutines泄露导致内存占用高。此问题可能会在容器化场景中遇到。etcd在打印日志的时候若出现阻塞则可能会导致goroutine阻塞并持续泄露最终导致内存泄露。你可以通过观察、监控go_goroutines来发现这个问题。
最后是etcd bug导致的内存泄露。当你基本排除以上场景导致的内存占用高后则很可能是etcd bug导致的内存泄露。
比如早期etcd clientv3的lease keepalive租约频繁续期bug它会导致Leader高负载、内存泄露此bug已在3.2.243.3.9版本中修复。
还有最近我修复的etcd 3.4版本的Follower节点内存泄露。具体表现是两个Follower节点内存一直升高Leader节点正常已在3.4.6版本中修复。
若内存泄露并不是已知的etcd bug导致那你可以开启pprof 尝试复现通过分析pprof heap文件来确定消耗大量内存的模块和数据结构。
小节
今天我通过一个写入1MB key的实际案例给你介绍了可能导致etcd内存占用高的核心数据结构、场景同时我将可能导致内存占用较高的因素总结为了下面这幅图你可以参考一下。
首先是raftLog。为了帮助slow Follower同步数据它至少要保留5000条最近收到的写请求在内存中。若你的key非常大你更新5000次会产生较大的内存开销。
其次是treeIndex。 每个key-value会在内存中保留一个索引项。索引项的开销跟key长度、保留的历史版本有关你可以通过compact命令压缩。
然后是boltdb。etcd启动的时候会通过mmap系统调用将文件映射到虚拟内存中。你可以通过compact命令回收旧版本defrag命令进行碎片整理。
接着是watcher。它的内存占用跟连接数、gRPC Watch Stream数、watcher数有关。watch机制一个不可忽视的内存开销其实是事件堆积的占用缓存你可以通过相关metrics及时发现堆积的事件以及slow watcher。
最后我介绍了一些典型的场景导致的内存异常如大包查询等expensive requestetcd中存储了v2 API写入的key goroutines泄露以及etcd lease bug等。
希望今天的内容能够帮助你从容应对etcd内存占用高的问题合理配置你的集群优化业务expensive request让etcd跑得更稳。
思考题
在一个key使用数G内存的案例中最后执行compact和defrag后的结果是2G为什么不是1G左右呢在macOS下行为是否一样呢
欢迎你动手做下这个小实验,分析下原因,分享你的观点。
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。

View File

@ -0,0 +1,267 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 性能及稳定性如何优化及扩展etcd性能
你好,我是唐聪。
在使用etcd的过程中你是否吐槽过etcd性能差呢 我们知道etcd社区线性读压测结果可以达到14w/s那为什么在实际业务场景中有时却只有几千甚至几百、几十还会偶发超时、频繁抖动呢
我相信不少人都遇到过类似的问题。要解决这些问题不仅需要了解症结所在还需要掌握优化和扩展etcd性能的方法对症下药。因为这部分内容比较多所以我分成了两讲内容分别从读性能、写性能和稳定性入手为你详细讲解如何优化及扩展etcd性能及稳定性。
希望通过这两节课的学习能让你在使用etcd的时候设计出良好的业务存储结构遵循最佳实践让etcd稳定、高效地运行获得符合预期的性能。同时当你面对etcd性能瓶颈的时候也能自己分析瓶颈原因、选择合适的优化方案解决它而不是盲目甩锅etcd甚至更换技术方案去etcd化。
今天这节课,我将重点为你介绍如何提升读的性能。
我们说读性能差其实本质是读请求链路中某些环节出现了瓶颈。所以接下来我将通过一张读性能分析链路图为你从上至下分析影响etcd性能、稳定性的若干因素并给出相应的压测数据最终为你总结出一系列的etcd性能优化和扩展方法。
性能分析链路
为什么在你的业务场景中读性能不如预期呢? 是读流程中的哪一个环节出现了瓶颈?
在下图中,我为你总结了一个开启密码鉴权场景的读性能瓶颈分析链路图,并在每个核心步骤数字旁边,标出了影响性能的关键因素。我之所以选用密码鉴权的读请求为案例,是因为它使用较广泛并且请求链路覆盖最全,同时它也是最容易遇到性能瓶颈的场景。
接下来我将按照这张链路分析图,带你深入分析一个使用密码鉴权的线性读请求,和你一起看看影响它性能表现的核心因素以及最佳优化实践。
负载均衡
首先是流程一负载均衡。在02节时我和你提到过在etcd 3.4以前client为了节省与server节点的连接数clientv3负载均衡器最终只会选择一个sever节点IP与其建立一个长连接。
但是这可能会导致对应的server节点过载如单节点流量过大出现丢包 其他节点却是低负载最终导致业务无法获得集群的最佳性能。在etcd 3.4后引入了Round-robin负载均衡算法它通过轮询的方式依次从endpoint列表中选择一个endpoint访问(长连接)使server节点负载尽量均衡。
所以如果你使用的是etcd低版本那么我建议你通过Load Balancer访问后端etcd集群。因为一方面Load Balancer一般支持配置各种负载均衡算法如连接数、Round-robin等可以使你的集群负载更加均衡规避etcd client早期的固定连接缺陷获得集群最佳性能。
另一方面当你集群节点需要替换、扩缩容集群节点的时候你不需要去调整各个client访问server的节点配置。
选择合适的鉴权
client通过负载均衡算法为请求选择好etcd server节点后client就可调用server的Range RPC方法把请求发送给etcd server。在此过程中如果server启用了鉴权那么就会返回无权限相关错误给client。
如果server使用的是密码鉴权你在创建client时需指定用户名和密码。etcd clientv3库发现用户名、密码非空就会先校验用户名和密码是否正确。
client是如何向sever请求校验用户名、密码正确性的呢
client是通过向server发送Authenticate RPC鉴权请求实现密码认证的也就是图中的流程二。
根据我们05介绍的密码认证原理server节点收到鉴权请求后它会从boltdb获取此用户密码对应的算法版本、salt、cost值并基于用户的请求明文密码计算出一个hash值。
在得到hash值后就可以对比db里保存的hash密码是否与其一致了。如果一致就会返回一个token给client。 这个token是client访问server节点的通行证后续server只需要校验“通行证”是否有效即可无需每次发起昂贵的Authenticate RPC请求。
讲到这里不知道你有没有意识到若你的业务在访问etcd过程中未复用token每次访问etcd都发起一次Authenticate调用这将是一个非常大的性能瓶颈和隐患。因为正如我们05所介绍的为了保证密码的安全性密码认证Authenticate的开销非常昂贵涉及到大量CPU资源。
那这个Authenticate接口究竟有多慢呢
为了得到Authenticate接口的性能我们做过这样一个测试
压测集群etcd节点配置是16核32G
压测方式是我们通过修改etcd clientv3库、benchmark工具使benchmark工具支持Authenticate接口压测
然后设置不同的client和connection参数运行多次观察结果是否稳定获取测试结果。
最终的测试结果非常惊人。etcd v3.4.9之前的版本Authenticate接口性能不到16 QPS并且随着client和connection增多该性能会继续恶化。
当client和connection的数量达到200个的时候性能会下降到8 QPSP99延时为18秒如下图所示。
对此我和小伙伴王超凡通过一个减少锁的范围PR该PR已经cherry-pick到了etcd 3.4.9版本将性能优化到了约200 QPS并且P99延时在1秒内如下图所示。
由于导致Authenticate接口性能差的核心瓶颈是在于密码鉴权使用了bcrpt计算hash值因此Authenticate性能已接近极限。
最令人头疼的是Auenticate的调用由clientv3库默默发起的etcd中也没有任何日志记录其耗时等。当大家开启密码鉴权后遇到读写接口超时的时候未详细了解etcd的同学就会非常困惑很难定位超时本质原因。
我曾多次收到小伙伴的求助协助他们排查etcd异常超时问题。通过metrics定位我发现这些问题大都是由比较频繁的Authenticate调用导致只要临时关闭鉴权或升级到etcd v3.4.9版本就可以恢复。
为了帮助大家快速发现Authenticate等特殊类型的expensive request我在etcd 3.5版本中提交了一个PR通过gRPC拦截器的机制当一个请求超过300ms时就会打印整个请求信息。
讲到这里,你应该会有疑问,密码鉴权的性能如此差,可是业务又需要使用它,我们该怎么解决密码鉴权的性能问题呢?对此,我有三点建议。
第一如果你的生产环境需要开启鉴权并且读写QPS较大那我建议你不要图省事使用密码鉴权。最好使用证书鉴权这样能完美避坑认证性能差、token过期等问题性能几乎无损失。
第二确保你的业务每次发起请求时有复用token机制尽可能减少Authenticate RPC调用。
第三如果你使用密码鉴权时遇到性能瓶颈问题可将etcd升级到3.4.9及以上版本,能适当提升密码鉴权的性能。
选择合适的读模式
client通过server的鉴权后就可以发起读请求调用了也就是我们图中的流程三。
在这个步骤中读模式对性能有着至关重要的影响。我们前面讲过etcd提供了串行读和线性读两种读模式。前者因为不经过ReadIndex模块具有低延时、高吞吐量的特点而后者在牺牲一点延时和吞吐量的基础上实现了数据的强一致性读。这两种读模式分别为不同场景的读提供了解决方案。
关于串行读和线性读的性能对比,下图我给出了一个测试结果,测试环境如下:
机器配置client 16核32G三个server节点8核16G、SSD盘client与server节点都在同可用区
各节点之间RTT在0.1ms到0.2ms之间;
etcd v3.4.9版本;
1000个client。
执行如下串行读压测命令:
benchmark --endpoints=addr --conns=100 --clients=1000 \
range hello --consistency=s --total=500000
得到串行读压测结果如下32万 QPS平均延时2.5ms。
执行如下线性读压测命令:
benchmark --endpoints=addr --conns=100 --clients=1000 \
range hello --consistency=l --total=500000
得到线性读压测结果如下19万 QPS平均延时4.9ms。
从两个压测结果图中你可以看到在100个连接时串行读性能比线性读性能高近11万/s串行读请求延时2.5ms比线性读延时约低一半4.9ms)。
需要注意的是以上读性能数据是在1个key、没有任何写请求、同可用区的场景下压测出来的实际的读性能会随着你的写请求增多而出现显著下降这也是实际业务场景性能与社区压测结果存在非常大差距的原因之一。所以我建议你使用etcd benchmark工具在你的etcd集群环境中自测一下你也可以参考下面的etcd社区压测结果。
如果你的业务场景读QPS较大但是你又不想通过etcd proxy等机制来扩展性能那你可以进一步评估业务场景对数据一致性的要求高不高。如果你可以容忍短暂的不一致那你可以通过串行读来提升etcd的读性能也可以部署Learner节点给可能会产生expensive read request的业务使用实现cheap/expensive read request隔离。
线性读实现机制、网络延时
了解完读模式对性能的影响后我们继续往下分析。在我们这个密码鉴权读请求的性能分析案例中读请求使用的是etcd默认线性读模式。线性读对应图中的流程四、流程五其中流程四对应的是ReadIndex流程五对应的是等待本节点数据追上Leader的进度ApplyWait
在早期的etcd 3.0版本中etcd线性读是基于Raft log read实现的。每次读请求要像写请求一样生成一个Raft日志条目然后提交给Raft一致性模块处理基于Raft日志执行的有序性来实现线性读。因为该过程需要经过磁盘I/O所以性能较差。
为了解决Raft log read的线性读性能瓶颈etcd 3.1中引入了ReadIndex。ReadIndex仅涉及到各个节点之间网络通信因此节点之间的RTT延时对其性能有较大影响。虽然同可用区可获取到最佳性能但是存在单可用区故障风险。如果你想实现高可用区容灾的话那就必须牺牲一点性能了。
跨可用区部署时各个可用区之间延时一般在2毫秒内。如果跨城部署服务性能就会下降较大。所以一般场景下我不建议你跨城部署你可以通过Learner节点实现异地容灾。如果异地的服务对数据一致性要求不高那么你甚至可以通过串行读访问Learner节点来实现就近访问低延时。
各个节点之间的RTT延时是决定流程四ReadIndex性能的核心因素之一。
磁盘IO性能、写QPS
到了流程五影响性能的核心因素就是磁盘IO延时和写QPS。
如下面代码所示流程五是指节点从Leader获取到最新已提交的日志条目索引(rs.Index)后它需要等待本节点当前已应用的Raft日志索引大于等于Leader的已提交索引确保能在本节点状态机中读取到最新数据。
if ai := s.getAppliedIndex(); ai < rs.Index {
select {
case <-s.applyWait.Wait(rs.Index):
case <-s.stopping:
return
}
}
// unblock all l-reads requested at indices before rs.Index
nr.notify(nil)
而应用已提交日志条目到状态机的过程中又涉及到随机写磁盘详情可参考我们03中介绍过etcd的写请求原理
因此我们可以知道etcd是一个对磁盘IO性能非常敏感的存储系统磁盘IO性能不仅会影响Leader稳定性写性能表现还会影响读性能线性读性能会随着写性能的增加而快速下降如果业务对性能稳定性有较大要求我建议你尽量使用SSD盘
下表我给出了一个8核16G的三节点集群在总key数只有一个的情况下随着写请求增大线性读性能下降的趋势总结基于benchmark工具压测结果你可以直观感受下读性能是如何随着写性能下降
当本节点已应用日志条目索引大于等于Leader已提交的日志条目索引后读请求就会接到通知就可通过MVCC模块获取数据
RBAC规则数Auth锁
读请求到了MVCC模块后首先要通过鉴权模块判断此用户是否有权限访问请求的数据路径也就是流程六影响流程六的性能因素是你的RBAC规则数和锁
首先是RBAC规则数为了解决快速判断用户对指定key范围是否有权限etcd为每个用户维护了读写权限区间树基于区间树判断用户访问的范围是否在用户的读写权限区间内时间复杂度仅需要O(logN)
另外一个因素则是AuthStore的锁在etcd 3.4.9之前的校验密码接口可能会占用较长时间的锁导致授权接口阻塞etcd 3.4.9之后合入了缩小锁范围的PR可一定程度降低授权接口被阻塞的问题
expensive requesttreeIndex锁
通过流程六的授权后则进入流程七从treeIndex中获取整个查询涉及的key列表版本号信息在这个流程中影响其性能的关键因素是treeIndex的总key数查询的key数获取treeIndex锁的耗时
首先treeIndex中总key数过多会适当增大我们遍历的耗时
其次若要访问treeIndex我们必须获取到锁但是可能其他请求如compact操作也会获取锁早期的时候它需要遍历所有索引然后进行数据压缩工作这就会导致其他请求阻塞进而增大延时
为了解决这个性能问题优化方案是compact的时候会将treeIndex克隆一份以空间来换时间尽量降低锁阻塞带来的超时问题
接下来我重点给你介绍下查询key数较多等expensive read request时对性能的影响
假设我们链路分析图中的请求是查询一个Kubernetes集群所有Pod当你Pod数一百以内的时候可能对etcd影响不大但是当你Pod数千甚至上万的时候 流程七八就会遍历大量的key导致请求耗时突增内存上涨性能急剧下降你可结合13db大小14延时15内存三节一起看看这里我就不再重复描述
如果业务就是有这种expensive read request逻辑我们该如何应对呢
首先我们可以尽量减少expensive read request次数在程序启动的时候只List一次全量数据然后通过etcd Watch机制去获取增量变更数据比如Kubernetes的Informer机制就是典型的优化实践
其次在设计上评估是否能进行一些数据分片拆分等不同场景使用不同的etcd prefix前缀比如在Kubernetes中不要把Pod全部都部署在default命名空间下尽量根据业务场景按命名空间拆分部署即便每个场景全量拉取也只需要遍历自己命名空间下的资源数据量上将下降一个数量级
再次如果你觉得Watch改造大数据也无法分片开发麻烦你可以通过分页机制按批拉取尽量减少一次性拉取数万条数据
最后如果以上方式都不起作用的话你还可以通过引入cache实现缓存expensive read request的结果不过应用需维护缓存数据与etcd的一致性
大key-valueboltdb锁
从流程七获取到key列表及版本号信息后我们就可以访问boltdb模块获取key-value信息了在这个流程中影响其性能表现的除了我们上面介绍的expensive read request还有大key-value和锁
首先是大key-value我们知道etcd设计上定位是个小型的元数据存储它没有数据分片机制默认db quota只有2G实践中往往不会超过8G并且针对每个key-value大小它也进行了大小限制默认是1.5MB
大key-value非常容易导致etcd OOMserver 节点出现丢包性能急剧下降等
那么当我们往etcd集群写入一个1MB的key-value时它的线性读性能会从17万QPS具体下降到多少呢?
我们可以执行如下benchmark命令
benchmark --endpoints=addr --conns=100 --clients=1000 \
range key --consistency=l --total=10000
得到其结果如下从下图你可以看到读取一个1MB的key-value线性读性能QPS下降到1163平均延时上升到818ms可见大key-value对性能的巨大影响
同时从下面的etcd监控图上你也可以看到内存出现了突增若存在大量大key-value时可想而知etcd内存肯定暴涨大概率会OOM
其次是锁etcd为了提升boltdb读的性能从etcd 3.1到etcd 3.4版本分别进行过几次重大优化在下一节中我将和你介绍
以上就是一个开启密码鉴权场景线性读请求的性能瓶颈分析过程
小结
今天我通过从上至下的请求流程分析介绍了各个流程中可能存在的瓶颈和优化方法最佳实践等
优化读性能的核心思路是首先我们可通过etcd clientv3自带的Round-robin负载均衡算法或者Load Balancer尽量确保整个集群负载均衡
然后在开启鉴权场景时建议你尽量使用证书而不是密码认证避免校验密码的昂贵开销
其次根据业务场景选择合适的读模式串行读比线性度性能提高30%以上延时降低一倍线性读性能受节点之间RTT延时磁盘IO延时当前写QPS等多重因素影响
最容易被大家忽视的就是写QPS对读QPS的影响我通过一系列压测数据整理成一个表格让你更直观感受写QPS对读性能的影响多可用区部署会导致节点RTT延时增高读性能下降因此你需要在高可用和高性能上做取舍和平衡
最后在访问数据前你的读性能还可能会受授权性能expensive read requesttreeIndex及boltdb的锁等影响你需要遵循最佳实践避免一个请求查询大量key大key-value等否则会导致读性能剧烈下降
希望你通过本文当遇到读etcd性能问题时能从请求执行链路去分析瓶颈解决问题让业务和etcd跑得更稳更快
思考题
你在使用etcd过程中遇到了哪些读性能问题又是如何解决的呢
欢迎分享你的性能优化经历感谢你阅读也欢迎你把这篇文章分享给更多的朋友一起阅读

View File

@ -0,0 +1,250 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 性能及稳定性如何优化及扩展etcd性能_
你好,我是唐聪。
我们继续来看如何优化及扩展etcd性能。上一节课里我为你重点讲述了如何提升读的性能今天我将重点为你介绍如何提升写性能和稳定性以及如何基于etcd gRPC Proxy扩展etcd性能。
当你使用etcd写入大量key-value数据的时候是否遇到过etcd server返回”etcdserver: too many requests”错误这个错误是怎么产生的呢我们又该如何来优化写性能呢
这节课我将通过写性能分析链路图为你从上至下分析影响写性能、稳定性的若干因素并为你总结出若干etcd写性能优化和扩展方法。
性能分析链路
为什么你写入大量key-value数据的时候会遇到Too Many Request限速错误呢 是写流程中的哪些环节出现了瓶颈?
和读请求类似,我为你总结了一个开启鉴权场景的写性能瓶颈及稳定性分析链路图,并在每个核心步骤数字旁边标识了影响性能、稳定性的关键因素。
下面我将按照这个写请求链路分析图和你深入分析影响etcd写性能的核心因素和最佳优化实践。
db quota
首先是流程一。在etcd v3.4.9版本中client会通过clientv3库的Round-robin负载均衡算法从endpoint列表中轮询选择一个endpoint访问发起gRPC调用。
然后进入流程二。etcd收到gRPC写请求后首先经过的是Quota模块它会影响写请求的稳定性若db大小超过配额就无法写入。
etcd是个小型的元数据存储默认db quota大小是2G超过2G就只读无法写入。因此你需要根据你的业务场景适当调整db quota大小并配置的合适的压缩策略。
正如我在11里和你介绍的etcd支持按时间周期性压缩、按版本号压缩两种策略建议压缩策略不要配置得过于频繁。比如如果按时间周期压缩一般情况下5分钟以上压缩一次比较合适因为压缩过程中会加一系列锁和删除boltdb数据过于频繁的压缩会对性能有一定的影响。
一般情况下db大小尽量不要超过8G过大的db文件和数据量对集群稳定性各方面都会有一定的影响详细你可以参考13。
限速
通过流程二的Quota模块后请求就进入流程三KVServer模块。在KVServer模块里影响写性能的核心因素是限速。
KVServer模块的写请求在提交到Raft模块前会进行限速判断。如果Raft模块已提交的日志索引committed index比已应用到状态机的日志索引applied index超过了5000那么它就返回一个”etcdserver: too many requests”错误给client。
那么哪些情况可能会导致committed Index远大于applied index呢?
首先是long expensive read request导致写阻塞。比如etcd 3.4版本之前长读事务会持有较长时间的buffer读锁而写事务又需要升级锁更新buffer因此出现写阻塞乃至超时。最终导致etcd server应用已提交的Raft日志命令到状态机缓慢。堆积过多时则会触发限速。
其次etcd定时批量将boltdb写事务提交的时候需要对B+ tree进行重平衡、分裂并将freelist、dirty page、meta page持久化到磁盘。此过程需要持有boltdb事务锁若磁盘随机写性能较差、瞬间大量写入则也容易写阻塞应用已提交的日志条目缓慢。
最后执行defrag等运维操作时也会导致写阻塞它们会持有相关锁导致写性能下降。
心跳及选举参数优化
写请求经过KVServer模块后则会提交到流程四的Raft模块。我们知道etcd写请求需要转发给Leader处理因此影响此模块性能和稳定性的核心因素之一是集群Leader的稳定性。
那如何判断Leader的稳定性呢?
答案是日志和metrics。
一方面在你使用etcd过程中你很可能见过如下Leader发送心跳超时的警告日志你可以通过此日志判断集群是否有频繁切换Leader的风险。
另一方面你可以通过etcd_server_leader_changes_seen_total metrics来观察已发生Leader切换的次数。
21:30:27 etcd3 | {"level":"warn","ts":"2021-02-23T21:30:27.255+0800","caller":"wal/wal.go:782","msg":"slow fdatasync","took":"3.259857956s","expected-duration":"1s"}
21:30:30 etcd3 | {"level":"warn","ts":"2021-02-23T21:30:30.396+0800","caller":"etcdserver/raft.go:390","msg":"leader failed to send out heartbeat on time; took too long, leader is overloaded likely from slow disk","to":"91bc3c398fb3c146","heartbeat-interval":"100ms","expected-duration":"200ms","exceeded-duration":"827.162111ms"}
那么哪些因素会导致此日志产生以及发生Leader切换呢?
首先我们知道etcd是基于Raft协议实现数据复制和高可用的各节点会选出一个Leader然后Leader将写请求同步给各个Follower节点。而Follower节点如何感知Leader异常发起选举正是依赖Leader的心跳机制。
在etcd中Leader节点会根据heartbeart-interval参数默认100ms定时向Follower节点发送心跳。如果两次发送心跳间隔超过2*heartbeart-interval就会打印此警告日志。超过election timeout默认1000msFollower节点就会发起新一轮的Leader选举。
哪些原因会导致心跳超时呢?
一方面可能是你的磁盘IO比较慢。因为etcd从Raft的Ready结构获取到相关待提交日志条目后它需要将此消息写入到WAL日志中持久化。你可以通过观察etcd_wal_fsync_durations_seconds_bucket指标来确定写WAL日志的延时。若延时较大你可以使用SSD硬盘解决。
另一方面也可能是CPU使用率过高和网络延时过大导致。CPU使用率较高可能导致发送心跳的goroutine出现饥饿。若etcd集群跨地域部署节点之间RTT延时大也可能会导致此问题。
最后我们应该如何调整心跳相关参数以避免频繁Leader选举呢
etcd默认心跳间隔是100ms较小的心跳间隔会导致发送频繁的消息消耗CPU和网络资源。而较大的心跳间隔又会导致检测到Leader故障不可用耗时过长影响业务可用性。一般情况下为了避免频繁Leader切换建议你可以根据实际部署环境、业务场景将心跳间隔时间调整到100ms到400ms左右选举超时时间要求至少是心跳间隔的10倍。
网络和磁盘IO延时
当集群Leader稳定后就可以进入Raft日志同步流程。
我们假设收到写请求的节点就是Leader写请求通过Propose接口提交到Raft模块后Raft模块会输出一系列消息。
etcd server的raftNode goroutine通过Raft模块的输出接口Ready获取到待发送给Follower的日志条目追加消息和待持久化的日志条目。
raftNode goroutine首先通过HTTP协议将日志条目追加消息广播给各个Follower节点也就是流程五。
流程五涉及到各个节点之间网络通信因此节点之间RTT延时对其性能有较大影响。跨可用区、跨地域部署时性能会出现一定程度下降建议你结合实际网络环境使用benchmark工具测试一下。etcd Raft网络模块在实现上也会通过流式发送和pipeline等技术优化来降低延时、提高网络性能。
同时raftNode goroutine也会将待持久化的日志条目追加到WAL中它可以防止进程crash后数据丢失也就是流程六。注意此过程需要同步等待数据落地因此磁盘顺序写性能决定着性能优异。
为了提升写吞吐量etcd会将一批日志条目批量持久化到磁盘。etcd是个对磁盘IO延时非常敏感的服务如果服务对性能、稳定性有较大要求建议你使用SSD盘。
那使用SSD盘的etcd集群和非SSD盘的etcd集群写性能差异有多大呢
下面是SSD盘集群执行如下benchmark命令的压测结果写QPS 51298平均延时189ms。
benchmark --endpoints=addr --conns=100 --clients=1000 \
put --key-size=8 --sequential-keys --total=10000000 --
val-size=256
下面是非SSD盘集群执行同样benchmark命令的压测结果写QPS 35255平均延时279ms。
快照参数优化
在Raft模块中正常情况下Leader可快速地将我们的key-value写请求同步给其他Follower节点。但是某Follower节点若数据落后太多Leader内存中的Raft日志已经被compact了那么Leader只能发送一个快照给Follower节点重建恢复。
在快照较大的时候发送快照可能会消耗大量的CPU、Memory、网络资源那么它就会影响我们的读写性能也就是我们图中的流程七。
一方面, etcd Raft模块引入了流控机制来解决日志同步过程中可能出现的大量资源开销、导致集群不稳定的问题。
另一方面我们可以通过快照参数优化去降低Follower节点通过Leader快照重建的概率使其尽量能通过增量的日志同步保持集群的一致性。
etcd提供了一个名为snapshot-count的参数来控制快照行为。它是指收到多少个写请求后就触发生成一次快照并对Raft日志条目进行压缩。为了帮助slower Follower赶上Leader进度etcd在生成快照压缩日志条目的时候也会至少保留5000条日志条目在内存中。
那snapshot-count参数设置多少合适呢?
snapshot-count值过大它会消耗较多内存你可以参考15内存篇中Raft日志内存占用分析。过小则的话在某节点数据落后时如果它请求同步的日志条目Leader已经压缩了此时我们就不得不将整个db文件发送给落后节点然后进行快照重建。
快照重建是极其昂贵的操作对服务质量有较大影响因此我们需要尽量避免快照重建。etcd 3.2版本之前snapshot-count参数值是1万比较低短时间内大量写入就较容易触发慢的Follower节点快照重建流程。etcd 3.2版本后将其默认值调大到10万老版本升级的时候你需要注意配置文件是否写死固定的参数值。
大value
当写请求对应的日志条目被集群多数节点确认后就可以提交到状态机执行了。etcd的raftNode goroutine就可通过Raft模块的输出接口Ready获取到已提交的日志条目然后提交到Apply模块的FIFO待执行队列。因为它是串行应用执行命令任意请求在应用到状态机时阻塞都会导致写性能下降。
当Raft日志条目命令从FIFO队列取出执行后它会首先通过授权模块校验是否有权限执行对应的写操作对应图中的流程八。影响其性能因素是RBAC规则数和锁。
然后通过权限检查后写事务则会从treeIndex模块中查找key、更新的key版本号等信息对应图中的流程九影响其性能因素是key数和锁。
更新完索引后我们就可以把新版本号作为boltdb key 把用户key/value、版本号等信息组合成一个value写入到boltdb对应图中的流程十影响其性能因素是大value、锁。
如果你在应用中保存1Mb的value这会给etcd稳定性带来哪些风险呢
首先会导致读性能大幅下降、内存突增、网络带宽资源出现瓶颈等上节课我已和你分享过一个1MB的key-value读性能压测结果QPS从17万骤降到1100多。
那么写性能具体会下降到多少呢?
通过benchmark执行如下命令写入1MB的数据时候集群几乎不可用三节点8核16G非SSD盘事务提交P99延时高达4秒如下图所示。
benchmark --endpoints=addr --conns=100 --clients=1000 \
put --key-size=8 --sequential-keys --total=500 --val-
size=1024000
因此只能将写入的key-value大小调整为100KB。执行后得到如下结果写入QPS 仅为1119/S平均延时高达324ms。
其次etcd底层使用的boltdb存储它是个基于COW(Copy-on-write)机制实现的嵌入式key-value数据库。较大的value频繁更新因为boltdb的COW机制会导致boltdb大小不断膨胀很容易超过默认db quota值导致无法写入。
那如何优化呢?
首先如果业务已经使用了大key拆分、改造存在一定客观的困难那我们就从问题的根源之一的写入对症下药尽量不要频繁更新大key这样etcd db大小就不会快速膨胀。
你可以从业务场景考虑判断频繁的更新是否合理能否做到增量更新。之前遇到一个case 一个业务定时更新大量key导致被限速最后业务通过增量更新解决了问题。
如果写请求降低不了, 就必须进行精简、拆分你的数据结构了。将你需要频繁更新的数据拆分成小key进行更新等实现将value值控制在合理范围以内才能让你的集群跑的更稳、更高效。
Kubernetes的Node心跳机制优化就是这块一个非常优秀的实践。早期kubelet会每隔10s上报心跳更新Node资源。但是此资源对象较大导致db大小不断膨胀无法支撑更大规模的集群。为了解决这个问题社区做了数据拆分将经常变更的数据拆分成非常细粒度的对象实现了集群稳定性提升支撑住更大规模的Kubernetes集群。
boltdb锁
了解完大value对集群性能的影响后我们再看影响流程十的另外一个核心因素boltdb锁。
首先我们回顾下etcd读写性能优化历史它经历了以下流程
3.0基于Raft log read实现线性读线性读需要经过磁盘IO性能较差
3.1基于ReadIndex实现线性读每个节点只需要向Leader发送ReadIndex请求不涉及磁盘IO提升了线性读性能
3.2将访问boltdb的锁从互斥锁优化到读写锁提升了并发读的性能
3.4实现全并发读去掉了buffer锁长尾读几乎不再影响写。
并发读特性的核心原理是创建读事务对象时它会全量拷贝当前写事务未提交的buffer数据并发的读写事务不再阻塞在一个buffer资源锁上实现了全并发读。
最重要的是写事务也不再因为expensive read request长时间阻塞有效的降低了写请求的延时详细测试结果你可以参考并发读特性实现PR因篇幅关系就不再详细描述。
扩展性能
当然有不少业务场景你即便用最高配的硬件配置etcd可能还是无法解决你所面临的性能问题。etcd社区也考虑到此问题提供了一个名为gRPC proxy的组件帮助你扩展读、扩展watch、扩展Lease性能的机制如下图所示。
扩展读
如果你的client比较多etcd集群节点连接数大于2万或者你想平行扩展串行读的性能那么gRPC proxy就是良好一个解决方案。它是个无状态节点为你提供高性能的读缓存的能力。你可以根据业务场景需要水平扩容若干节点同时通过连接复用降低服务端连接数、负载。
它也提供了故障探测和自动切换能力当后端etcd某节点失效后会自动切换到其他正常节点业务client可对此无感知。
扩展Watch
大量的watcher会显著增大etcd server的负载导致读写性能下降。etcd为了解决这个问题gRPC proxy组件里面提供了watcher合并的能力。如果多个client Watch同key或者范围如上图三个client Watch同key它会尝试将你的watcher进行合并降低服务端的watcher数。
然后当它收到etcd变更消息时会根据每个client实际Watch的版本号将增量的数据变更版本分发给你的多个client实现watch性能扩展及提升。
扩展Lease
我们知道etcd Lease特性提供了一种客户端活性检测机制。为了确保你的key不被淘汰client需要定时发送keepalive心跳给server。当Lease非常多时这就会导致etcd服务端的负载增加。在这种场景下gRPC proxy提供了keepalive心跳连接合并的机制来降低服务端负载。
小结
今天我通过从上至下的写请求流程分析介绍了各个流程中可能存在的瓶颈和优化方法、最佳实践。最后我从分层的角度为你总结了一幅优化思路全景图你可以参考一下下面这张图它将我们这两节课讨论的etcd性能优化、扩展问题分为了以下几类
业务应用层etcd应用层的最佳实践
etcd内核层etcd参数最佳实践
操作系统层,操作系统优化事项;
硬件及网络层不同的硬件设备对etcd性能有着非常大的影响
扩展性能基于gRPC proxy扩展读、Watch、Lease的性能。
希望你通过这节课的学习以后在遇到etcd性能问题时能分别从请求执行链路和分层的视角去分析、优化瓶颈让业务和etcd跑得更稳、更快。
思考题
最后,我还给你留了一个思考题。
watcher较多的情况下会不会对读写请求性能有影响呢如果会是在什么场景呢gRPC proxy能安全的解决watcher较多场景下的扩展性问题吗
欢迎分享你的性能优化经历,感谢你阅读,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@ -0,0 +1,467 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 实战如何基于Raft从0到1构建一个支持多存储引擎分布式KV服务
你好,我是唐聪。
通过前面课程的学习我相信你已经对etcd基本架构、核心特性有了一定理解。如果让你基于Raft协议实现一个简易的类etcd、支持多存储引擎的分布式KV服务并能满足读多写少、读少写多的不同业务场景诉求你知道该怎么动手吗
纸上得来终觉浅,绝知此事要躬行。
今天我就和你聊聊如何实现一个类etcd、支持多存储引擎的KV服务我们将基于etcd自带的raftexample项目快速构建它。
为了方便后面描述我把它命名为metcd表示微型的etcd它是raftexample的加强版。希望通过metcd这个小小的实战项目能够帮助你进一步理解etcd乃至分布式存储服务的核心架构、原理、典型问题解决方案。
同时在这个过程中我将详细为你介绍etcd的Raft算法工程实现库、不同类型存储引擎的优缺点拓宽你的知识视野为你独立分析etcd源码夯实基础。
整体架构设计
在和你深入聊代码细节之前,首先我和你从整体上介绍下系统架构。
下面是我给你画的metcd整体架构设计它由API层、Raft层的共识模块、逻辑层及存储层组成的状态机组成。
接下来我分别和你简要分析下API设计及复制状态机。
API设计
API是软件系统对外的语言它是应用编程接口的缩写由一组接口定义和协议组成。
在设计API的时候我们往往会考虑以下几个因素
性能。如etcd v2使用的是简单的HTTP/1.x性能上无法满足大规模Kubernetes集群等场景的诉求因此etcd v3使用的是基于HTTP/2的gRPC协议。
易用性、可调试性。如有的内部高并发服务为了满足性能等诉求使用的是UDP协议。相比HTTP协议UDP协议显然在易用性、可调试性上存在一定的差距。
开发效率、跨平台、可移植性。相比基于裸UDP、TCP协议设计的接口如果你使用Protobuf等IDL语言它支持跨平台、代码自动自动生成开发效率更高。
安全性。如相比HTTP协议使用HTTPS协议可对通信数据加密更安全可适用于不安全的网络环境比如公网传输
接口幂等性。幂等性简单来说,就是同样一个接口请求一次与多次的效果一样。若你的接口对外保证幂等性,则可降低使用者的复杂度。
因为我们场景的是POC(Proof of concept)、Demo开发因此在metcd项目中我们优先考虑点是易用性、可调试性选择HTTP/1.x协议接口上为了满足key-value操作支持Get和Put接口即可。
假设metcd项目使用3379端口Put和Get接口如下所示。
Put接口设置key-value
curl -L http://127.0.0.1:3379/hello -XPUT -d world
Get接口查询key-value
curl -L http://127.0.0.1:3379/hello
world
复制状态机
了解完API设计那最核心的复制状态机是如何工作的呢
我们知道etcd是基于下图复制状态机实现的分布式KV服务复制状态机由共识模块、日志模块、状态机组成。
我们的实战项目metcd也正是使用与之一样的模型并且使用etcd项目中实现的Raft算法库作为共识模块此算法库已被广泛应用在etcd、cockroachdb、dgraph等开源项目中。
以下是复制状态机的写请求流程:
client发起一个写请求put hello = world
server向Raft共识模块提交请求共识模块生成一个写提案日志条目。若server是Leader则把日志条目广播给其他节点并持久化日志条目到WAL中
当一半以上节点持久化日志条目后Leader的共识模块将此日志条目标记为已提交committed并通知其他节点提交
server从共识模块获取已经提交的日志条目异步应用到状态机存储中boltdb/leveldb/memory然后返回给client。
多存储引擎
了解完复制状态机模型后,我和你再深入介绍下状态机。状态机中最核心模块当然是存储引擎,那要如何同时支持多种存储引擎呢?
metcd项目将基于etcd本身自带的raftexample项目进行快速开发而raftexample本身只支持内存存储。
因此我们通过将KV存储接口进行抽象化设计实现支持多存储引擎。KVStore interface的定义如下所示。
type KVStore interface {
// LookUp get key value
Lookup(key string) (string, bool)
// Propose propose kv request into raft state machine
Propose(k, v string)
// ReadCommits consume entry from raft state machine into KvStore map until error
ReadCommits(commitC <-chan *string, errorC <-chan error)
// Snapshot return KvStore snapshot
Snapshot() ([]byte, error)
// RecoverFromSnapshot recover data from snapshot
RecoverFromSnapshot(snapshot []byte) error
// Close close backend databases
Close() err
}
基于KV接口抽象化的设计我们只需要针对具体的存储引擎实现对应的操作即可
我们期望支持三种存储引擎分别是内存mapboltdbleveldb并做一系列简化设计一组metcd实例通过metcd启动时的配置来决定使用哪种存储引擎不同业务场景不同实例比如读多写少的存储引擎可使用boltdb写多读少的可使用leveldb
接下来我和你重点介绍下存储引擎的选型及原理
boltdb
boltdb是一个基于B+ tree实现的存储引擎库在10中我已和你详细介绍过原理
boltdb为什么适合读多写少
对于读请求而言一般情况下它可直接从内存中基于B+ tree遍历快速获取数据返回给client不涉及经过磁盘I/O
对于写请求它基于B+ tree查找写入位置更新key-value事务提交时写请求包括B+ tree重平衡分裂持久化ditry page持久化freelist持久化meta page流程同时ditry page可能分布在文件的各个位置它发起的是随机写磁盘I/O
因此在boltdb中完成一个写请求的开销相比读请求是大很多的正如我在16和17中给你介绍的一样一个3节点的8核16G空集群线性读性能可以达到19万QPS而写QPS仅为5万
leveldb
那要如何设计适合写多读少的存储引擎呢?
最简单的思路当然是写内存最快可是内存有限的无法支撑大容量的数据存储不持久化数据会丢失
那能否直接将数据顺序追加到文件末尾AOF因为磁盘的特点是顺序写性能比较快
当然可以Bitcask存储模型就是采用AOF模式把写请求顺序追加到文件Facebook的图片存储Haystack根据其论文介绍也是使用类似的方案来解决大规模写入痛点
那在AOF写入模型中如何实现查询数据呢
很显然通过遍历文件一个个匹配key是可以的但是它的性能是极差的为了实现高性能的查询最理想的解决方案从直接从内存中查询但是内存是有限的那么我们能否通过内存索引来记录一个key-value数据在文件中的偏移量实现从磁盘快速读取呢
是的这正是Bitcask存储模型的查询的实现它通过内存哈希表维护各个key-value数据的索引实现了快速查找key-value数据不过内存中虽然只保存key索引信息但是当key较多的时候其对内存要求依然比较高
快速了解完存储引擎提升写性能的核心思路随机写转化为顺序写之后那leveldb它的原理是怎样的呢与Bitcask存储模型有什么不一样
leveldb是基于LSM tree(log-structured merge-tree)实现的key-value存储它的架构如下图所示引用自微软博客
它提升写性能的核心思路同样是将随机写转化为顺序写磁盘WAL文件和内存结合了我们上面讨论的写内存和磁盘两种方法数据持久化到WAL文件是为了确保机器crash后数据不丢失
那么它要如何解决内存不足和查询的痛点问题呢
核心解决方案是分层的设计和基于一系列对象的转换和压缩接下来我给你分析一下上面架构图写流程和后台compaction任务
首先写请求顺序写入Log文件(WAL)
更新内存的Memtableleveldb Memtable后端数据结构实现是skiplistskiplist相比平衡二叉树实现简单却同样拥有高性能的读写
当Memtable达到一定的阈值时转换成不可变的Memtable也就是只读不可写
leveldb后台Compact任务会将不可变的Memtable生成SSTable文件它有序地存储一系列key-value数据注意SST文件按写入时间进行了分层Level层次越小数据越新Manifest文件记录了各个SSTable文件处于哪个层级它的最小与最大key范围
当某个level下的SSTable文件数目超过一定阈值后Compact任务会从这个level的SSTable中选择一个文件level>0将其和高一层级的level+1的SSTable文件合并
注意level 0是由Immutable直接生成的因此level 0 SSTable文件中的key-value存在相互重叠。而level > 0时在和更高一层SSTable合并过程中参与的SSTable文件是多个leveldb会确保各个SSTable中的key-value不重叠。
了解完写流程,读流程也就简单了,核心步骤如下:
从Memtable跳跃表中查询key
未找到则从Immutable中查找
Immutable仍未命中则按照leveldb的分层属性因level 0 SSTable文件是直接从Immutable生成的level 0存在特殊性因此你需要从level 0遍历SSTable查找key
level 0中若未命中则从level 1乃至更高的层次查找。level大于0时各个SSTable中的key是不存在相互重叠的。根据manifest记录的key-value范围信息可快递定位到具体的SSTable。同时leveldb基于bloom filter实现了快速筛选SSTable因此查询效率较高。
更详细原理你可以参考一下leveldb源码。
实现分析
从API设计、复制状态机、多存储引擎支持等几个方面你介绍了metcd架构设计后接下来我就和你重点介绍下共识模块、状态机支持多存储引擎模块的核心实现要点。
Raft算法库
共识模块使用的是etcd Raft算法库它是一个经过大量业务生产环境检验、具备良好可扩展性的共识算法库。
它提供了哪些接口给你使用? 如何提交一个提案并且获取Raft共识模块输出结果呢
Raft API
Raft作为一个库它对外最核心的对象是一个名为Node的数据结构。Node表示Raft集群中的一个节点它的输入与输出接口如下图所示下面我重点和你介绍它的几个接口功能
Campaign状态转换成Candidate发起新一轮Leader选举
Propose提交提案接口
ReadyRaft状态机输出接口它的返回是一个输出Ready数据结构类型的管道应用需要监听此管道获取Ready数据处理其中的各个消息如持久化未提交的日志条目到WAL中发送消息给其他节点等
Advance通知Raft状态机应用已处理上一个输出的Ready数据等待发送下一个Ready数据
TransferLeaderShip尝试将Leader转移到某个节点
Step向Raft状态机提交收到的消息比如当Leader广播完MsgApp消息给Follower节点后Leader收到Follower节点回复的MsgAppResp消息时就通过Step接口将此消息提交给Raft状态机驱动其工作
ReadIndex用于实现线性读。
上面提到的Raft状态机的输出Ready结构含有哪些信息呢? 下图是其详细字段,含义如下:
SoftState软状态。包括集群Leader和节点状态不需要持久化到WAL
pb.HardState硬状态。与软状态相反包括了节点当前Term、Vote等信息需要持久化到WAL中
ReadStates用于线性一致性读
Entries在向其他节点发送消息之前需持久化到WAL中
Messages持久化Entries后发送给其他节点的消息
Committed Entries已提交的日志条目需要应用到存储状态机中
Snapshot快照需保存到持久化存储中
MustSyncHardState和Entries是否要持久化到WAL中
了解完API后我们接下来继续看看代码如何使用Raft的Node API。
正如我在04中和你介绍的etcd Raft库的设计抽象了网络、Raft日志存储等模块它本身并不会进行网络、存储相关的操作上层应用需结合自己业务场景选择内置的模块或自定义实现网络、存储、日志等模块。
因此我们在使用Raft库时需要先自定义好相关网络、存储等模块再结合上面介绍的Raft Node API就可以完成一个Node的核心操作了。其数据结构定义如下
// A key-value stream backed by raft
type raftNode struct {
proposeC <-chan string // proposed messages (k,v)
confChangeC <-chan raftpb.ConfChange // proposed cluster config changes
commitC chan<- *string // entries committed to log (k,v)
errorC chan<- error // errors from raft session
id int // client ID for raft session
......
node raft.Node
raftStorage *raft.MemoryStorage
wal *wal.WAL
transport *rafthttp.Transport
}
这个数据结构名字叫raftNode它表示Raft集群中的一个节点它是由我们业务应用层设计的一个组合结构从结构体定义中你可以看到它包含了Raft核心数据结构Node(raft.Node)Raft日志条目内存存储模块(raft.MemoryStorageWAL持久化模块(wal.WAL)以及网络模块(rafthttp.Transport)
同时它提供了三个核心的管道与业务逻辑模块存储状态机交互
proposeC它用来接收client发送的写请求提案消息
confChangeC它用来接收集群配置变化消息
commitC它用来输出Raft共识模块已提交的日志条目消息
在metcd项目中因为我们是直接基于raftexample定制开发因此日志持久化存储网络都使用的是etcd自带的WAL和rafthttp模块
WAL模块中提供了核心的保存未持久化的日志条目和快照功能接口你可以参考03节写请求中我和你介绍的原理
rafthttp模块基于HTTP协议提供了各个节点间的消息发送能力metcd使用如下
rc.transport = &rafthttp.Transport{
Logger: zap.NewExample(),
ID: types.ID(rc.id),
ClusterID: 0x1000,
Raft: rc,
ServerStats: stats.NewServerStats("", ""),
LeaderStats: stats.NewLeaderStats(strconv.Itoa(rc.id)),
ErrorC: make(chan error),
}
搞清楚Raft模块的输入输出API设计好raftNode结构复用etcd的WAL网络等模块后接下来我们就只需要实现如下两个循环逻辑处理业务层发送给proposeC和confChangeC消息将Raft的Node输出Ready结构进行相对应的处理即可精简后的代码如下所示
func (rc *raftNode) serveChannels() {
// send proposals over raft
go func() {
confChangeCount := uint64(0)
for rc.proposeC != nil && rc.confChangeC != nil {
select {
case prop, ok := <-rc.proposeC:
if !ok {
rc.proposeC = nil
} else {
// blocks until accepted by raft state machine
rc.node.Propose(context.TODO(), []byte(prop))
}
case cc, ok := <-rc.confChangeC:
if !ok {
rc.confChangeC = nil
} else {
confChangeCount++
cc.ID = confChangeCount
rc.node.ProposeConfChange(context.TODO(), cc)
}
}
}
}()
// event loop on raft state machine updates
for {
select {
case <-ticker.C:
rc.node.Tick()
// store raft entries to wal, then publish over commit channel
case rd := <-rc.node.Ready():
rc.wal.Save(rd.HardState, rd.Entries)
if !raft.IsEmptySnap(rd.Snapshot) {
rc.saveSnap(rd.Snapshot)
rc.raftStorage.ApplySnapshot(rd.Snapshot)
rc.publishSnapshot(rd.Snapshot)
}
rc.raftStorage.Append(rd.Entries)
rc.transport.Send(rd.Messages)
if ok := rc.publishEntries(rc.entriesToApply(rd.CommittedEntries)); !ok {
rc.stop()
return
}
rc.maybeTriggerSnapshot()
rc.node.Advance()
}
}
}
代码简要分析如下
从proposeC中取出提案消息通过raft.Node.Propose API提交提案
从confChangeC取出配置变更消息通过raft.Node.ProposeConfChange API提交配置变化消息
从raft.Node中获取Raft算法状态机输出到Ready结构中将rd.Entries和rd.HardState通过WAL模块持久化将rd.Messages通过rafthttp模块发送给其他节点将rd.CommittedEntries应用到业务存储状态机
以上就是Raft实现的核心流程接下来我来和你聊聊业务存储状态机
支持多存储引擎
在整体架构设计时我和你介绍了为了使metcd项目能支撑多存储引擎我们将KVStore进行了抽象化设计因此我们只需要实现各个存储引擎相对应的API即可
这里我以Put接口为案例分别给你介绍下各个存储引擎的实现
boltdb
首先是boltdb存储引擎它的实现如下你也可以去10里回顾一下它的API和原理
func (s *boltdbKVStore) Put(key, value string) error {
s.mu.Lock()
defer s.mu.Unlock()
// Start a writable transaction.
tx, err := s.db.Begin(true)
if err != nil {
return err
}
defer tx.Rollback()
// Use the transaction...
bucket, err := tx.CreateBucketIfNotExists([]byte("keys"))
if err != nil {
log.Printf("failed to put key %s, value %s, err is %v", key, value, err)
return err
}
err = bucket.Put([]byte(key), []byte(value))
if err != nil {
log.Printf("failed to put key %s, value %s, err is %v", key, value, err)
return err
}
// Commit the transaction and check for error.
if err := tx.Commit(); err != nil {
log.Printf("failed to commit transaction, key %s, err is %v", key, err)
return err
}
log.Printf("backend:%s,put key:%s,value:%s succ", s.config.backend, key, value)
return nil
leveldb
其次是leveldb我们使用的是goleveldb它基于Google开源的c++ leveldb版本实现它提供的常用API如下所示
通过OpenFile API创建或打开一个leveldb数据库
db, err := leveldb.OpenFile("path/to/db", nil)
...
defer db.Close()
通过DB.Get/Put/Delete API操作数据
data, err := db.Get([]byte("key"), nil)
...
err = db.Put([]byte("key"), []byte("value"), nil)
...
err = db.Delete([]byte("key"), nil)
...
了解其接口后通过goleveldb的库client调用就非常简单了下面是metcd项目中leveldb存储引擎Put接口的实现
func (s *leveldbKVStore) Put(key, value string) error {
err := s.db.Put([]byte(key), []byte(value), nil)
if err != nil {
log.Printf("failed to put key %s, value %s, err is %v", key, value, err)
return err
}
log.Printf("backend:%s,put key:%s,value:%s succ", s.config.backend, key, value)
return nil
}
读写流程
介绍完在metcd项目中如何使用Raft共识模块支持多存储引擎后我们再从整体上介绍下在metcd中写入和读取一个key-value的流程
写流程
当你通过如下curl命令发起一个写操作时写流程如下面架构图序号所示:
curl -L http://127.0.0.1:3379/hello -XPUT -d world
client通过curl发送HTTP PUT请求到server
server收到后将消息写入到KVStore的ProposeC管道
raftNode循环逻辑将消息通过Raft模块的Propose接口提交
Raft模块输出Ready结构server将日志条目持久化后并发送给其他节点
集群多数节点持久化此日志条目后这个日志条目被提交给存储状态机KVStore执行
KVStore根据启动的backend存储引擎名称调用对应的Put接口即可
读流程
当你通过如下curl命令发起一个读操作时读流程如下面架构图序号所示
curl -L http://127.0.0.1:3379/hello
world
client通过curl发送HTTP Get请求到server
server收到后根据KVStore的存储引擎从后端查询出对应的key-value数据
小结
最后我来总结下我们今天的内容我这节课分别从整体架构设计和实现分析给你介绍了如何基于Raft从0到1构建一个支持多存储引擎的分布式key-value数据库
在整体架构设计上我给你介绍了API设计核心因素它们分别是性能易用性开发效率安全性幂等性其次我和你介绍了复制状态机的原理它由共识模块日志模块存储状态机模块组成最后我和你深入分析了多存储引擎设计重点介绍了leveldb原理它将随机写转换为顺序写日志和内存通过一系列分层创新的设计实现了优异的写性能适合读少写多
在实现分析上我和你重点介绍了Raft算法库的核心对象Node API对于一个库而言我们重点关注的是其输入输出接口业务逻辑层可通过Propose接口提交提案通过Ready结构获取Raft算法状态机的输出内容其次我和你介绍了Raft算法库如何与WAL模块Raft日志存储模块网络模块协作完成一个写请求
最后为了支持多存储引擎我们分别基于boltdbleveldb实现了KVStore相关接口操作并通过读写流程图从整体上为你介绍了一个读写请求在metcd中是如何工作的
麻雀虽小五脏俱全希望能通过这个迷你项目解答你对如何构建一个简易分布式KV服务的疑问以及让你对etcd的工作原理有更深的理解
思考题
你知道raftexample启动的时候是如何工作的吗它的存储引擎内存map是如何保证数据不丢失的呢
感谢你的阅读如果你认为这节课的内容有收获也欢迎把它分享给你的朋友我们下一讲见

View File

@ -0,0 +1,430 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 Kubernetes基础应用创建一个Pod背后etcd发生了什么
你好,我是唐聪。
今天我将通过在Kubernetes集群中创建一个Pod的案例为你分析etcd在其中发挥的作用带你深入了解Kubernetes是如何使用etcd的。
希望通过本节课帮助你从etcd的角度更深入理解Kubernetes让你知道在Kubernetes集群中每一步操作的背后etcd会发生什么。更进一步当你在Kubernetes集群中遇到etcd相关错误的时候能从etcd角度理解错误含义高效进行故障诊断。
Kubernetes基础架构
在带你详细了解etcd在Kubernetes里的应用之前我先和你简单介绍下Kubernetes集群的整体架构帮你搞清楚etcd在Kubernetes集群中扮演的角色与作用。
下图是Kubernetes集群的架构图引用自Kubernetes官方文档从图中你可以看到它由Master节点和Node节点组成。
控制面Master节点主要包含以下组件
kube-apiserver负责对外提供集群各类资源的增删改查及Watch接口它是Kubernetes集群中各组件数据交互和通信的枢纽。kube-apiserver在设计上可水平扩展高可用Kubernetes集群中一般多副本部署。当收到一个创建Pod写请求时它的基本流程是对请求进行认证、限速、授权、准入机制等检查后写入到etcd即可。
kube-scheduler是调度器组件负责集群Pod的调度。基本原理是通过监听kube-apiserver获取待调度的Pod然后基于一系列筛选和评优算法为Pod分配最佳的Node节点。
kube-controller-manager包含一系列的控制器组件比如Deployment、StatefulSet等控制器。控制器的核心思想是监听、比较资源实际状态与期望状态是否一致若不一致则进行协调工作使其最终一致。
etcd组件Kubernetes的元数据存储。
Node节点主要包含以下组件
kubelet部署在每个节点上的Agent的组件负责Pod的创建运行。基本原理是通过监听APIServer获取分配到其节点上的Pod然后根据Pod的规格详情调用运行时组件创建pause和业务容器等。
kube-proxy部署在每个节点上的网络代理组件。基本原理是通过监听APIServer获取Service、Endpoint等资源基于Iptables、IPVS等技术实现数据包转发等功能。
从Kubernetes基础架构介绍中你可以看到kube-apiserver是唯一直接与etcd打交道的组件各组件都通过kube-apiserver实现数据交互它们极度依赖kube-apiserver提供的资源变化监听机制。而kube-apiserver对外提供的监听机制也正是由我们基础篇08中介绍的etcd Watch特性提供的底层支持。
创建Pod案例
接下来我们就以在Kubernetes集群中创建一个nginx服务为例通过这个案例来详细分析etcd在Kubernetes集群创建Pod背后是如何工作的。
下面是创建一个nginx服务的YAML文件Workload是Deployment期望的副本数是1。
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
labels:
app: nginx
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80
假设此YAML文件名为nginx.yaml首先我们通过如下的kubectl create -f nginx.yml命令创建Deployment资源。
$ kubectl create -f nginx.yml
deployment.apps/nginx-deployment created
创建之后我们立刻通过如下命令带标签查询Pod输出如下
$ kubectl get po -l app=nginx
NAME READY STATUS RESTARTS AGE
nginx-deployment-756d9fd5f9-fkqnf 1/1 Running 0 8s
那么在kubectl create命令发出nginx Deployment资源成功创建的背后kube-apiserver是如何与etcd打交道的呢 它是通过什么接口安全写入资源到etcd的
同时使用kubectl带标签查询Pod背后kube-apiserver是直接从缓存读取还是向etcd发出一个线性读或串行读请求呢 若同namespace下存在大量的Pod此操作性能又是怎样的呢?
接下来我就和你聊聊kube-apiserver收到创建和查询请求后是如何与etcd交互的。
kube-apiserver请求执行链路
kube-apiserver作为Kubernetes集群交互的枢纽、对外提供API供用户访问的组件因此保障集群安全、保障本身及后端etcd的稳定性的等重任也是非它莫属。比如校验创建请求发起者是否合法、是否有权限操作相关资源、是否出现Bug产生大量写和读请求等。
下图是kube-apiserver的请求执行链路引用自sttts分享的PDF当收到一个请求后它主要经过以下处理链路来完成以上若干职责后才能与etcd交互。
核心链路如下:
认证模块校验发起的请求的用户身份是否合法。支持多种方式比如x509客户端证书认证、静态token认证、webhook认证等。
限速模块对请求进行简单的限速默认读400/s写200/s不支持根据请求类型进行分类、按优先级限速存在较多问题。Kubernetes 1.19后已新增Priority and Fairness特性取代它它支持将请求重要程度分类进行限速支持多租户可有效保障Leader选举之类的高优先级请求得到及时响应能防止一个异常client导致整个集群被限速。
审计模块,可记录用户对资源的详细操作行为。
授权模块检查用户是否有权限对其访问的资源进行相关操作。支持多种方式RBAC(Role-based access control)、ABAC(Attribute-based access control)、Webhhook等。Kubernetes 1.12版本后默认授权机制使用的RBAC。
准入控制模块提供在访问资源前拦截请求的静态和动态扩展能力比如要求镜像的拉取策略始终为AlwaysPullImages。
经过上面一系列的模块检查后这时kube-apiserver就开始与etcd打交道了。在了解kube-apiserver如何将我们创建的Deployment资源写入到etcd前我先和你介绍下Kubernetes的资源是如何组织、存储在etcd中。
Kubernetes资源存储格式
我们知道etcd仅仅是个key-value存储但是在Kubernetes中存在各种各样的资源并提供了以下几种灵活的资源查询方式
按具体资源名称查询比如PodName、kubectl get po/PodName。
按namespace查询获取一个namespace下的所有Pod比如kubectl get po -n kube-system。
按标签名标签是极度灵活的一种方式你可以为你的Kubernetes资源打上各种各样的标签比如上面案例中的kubectl get po -l app=nginx。
你知道以上这几种查询方式它们的性能优劣吗假设你是Kubernetes开发者你会如何设计存储格式来满足以上功能点
首先是按具体资源名称查询。它本质就是个key-value查询只需要写入etcd的key名称与资源key一致即可。
其次是按namespace查询。这种查询也并不难。因为我们知道etcd支持范围查询若key名称前缀包含namespace、资源类型查询的时候指定namespace和资源类型的组合的最小开始区间、最大结束区间即可。
最后是标签名查询。这种查询方式非常灵活,业务可随时添加、删除标签,各种标签可相互组合。实现标签查询的办法主要有以下两种:
方案一在etcd中存储标签数据实现通过标签可快速定位时间复杂度O(1)到具体资源名称。然而一个标签可能容易实现但是在Kubernetes集群中它支持按各个标签组合查询各个标签组合后的数量相当庞大。在etcd中维护各种标签组合对应的资源列表会显著增加kube-apiserver的实现复杂度导致更频繁的etcd写入。
方案二在etcd中不存储标签数据而是由kube-apiserver通过范围遍历etcd获取原始数据然后基于用户指定标签来筛选符合条件的资源返回给client。此方案优点是实现简单但是大量标签查询可能会导致etcd大流量等异常情况发生。
那么Kubernetes集群选择的是哪种实现方式呢?
下面是一个Kubernetes集群中的coredns一系列资源在etcd中的存储格式
/registry/clusterrolebindings/system:coredns
/registry/clusterroles/system:coredns
/registry/configmaps/kube-system/coredns
/registry/deployments/kube-system/coredns
/registry/events/kube-system/coredns-7fcc6d65dc-6njlg.1662c287aabf742b
/registry/events/kube-system/coredns-7fcc6d65dc-6njlg.1662c288232143ae
/registry/pods/kube-system/coredns-7fcc6d65dc-jvj26
/registry/pods/kube-system/coredns-7fcc6d65dc-mgvtb
/registry/pods/kube-system/coredns-7fcc6d65dc-whzq9
/registry/replicasets/kube-system/coredns-7fcc6d65dc
/registry/secrets/kube-system/coredns-token-hpqbt
/registry/serviceaccounts/kube-system/coredns
从中你可以看到一方面Kubernetes资源在etcd中的存储格式由prefix + “/” + 资源类型 + “/” + namespace + “/” + 具体资源名组成基于etcd提供的范围查询能力非常简单地支持了按具体资源名称查询和namespace查询。
kube-apiserver提供了如下参数给你配置etcd prefix并支持将资源存储在多个etcd集群。
--etcd-prefix string Default: "/registry"
The prefix to prepend to all resource paths in etcd.
--etcd-servers stringSlice
List of etcd servers to connect with (scheme://ip:port), comma separated.
--etcd-servers-overrides stringSlice
Per-resource etcd servers overrides, comma separated. The individual override format: group/resource#servers, where servers are URLs,
semicolon separated.
另一方面我们未看到任何标签相关的key。Kubernetes实现标签查询的方式显然是方案二即由kube-apiserver通过范围遍历etcd获取原始数据然后基于用户指定标签来筛选符合条件的资源返回给client资源key的value中记录了资源YAML文件内容等如标签
也就是当你执行”kubectl get po -l app=nginx”命令按标签查询Pod时它会向etcd发起一个范围遍历整个default namespace下的Pod操作。
$ kubectl get po -l app=nginx -v 8
I0301 23:45:25.597465 32411 loader.go:359] Config loaded from file /root/.kube/config
I0301 23:45:25.603182 32411 round_trippers.go:416] GET https://ip:port/api/v1/namespaces/default/pods?
labelSelector=app%3Dnginx&limit=500
etcd收到的请求日志如下由此可见当一个namespace存在大量Pod等资源时若频繁通过kubectl使用标签查询Pod等资源后端etcd将出现较大的压力。
{
"level":"debug",
"ts":"2021-03-01T23:45:25.609+0800",
"caller":"v3rpc/interceptor.go:181",
"msg":"request stats",
"start time":"2021-03-01T23:45:25.608+0800",
"time spent":"1.414135ms",
"remote":"127.0.0.1:44664",
"response type":"/etcdserverpb.KV/Range",
"request count":0,
"request size":61,
"response count":11,
"response size":81478,
"request content":"key:"/registry/pods/default/" range_end:"/registry/pods/default0" limit:500 "
}
了解完Kubernetes资源的存储格式后我们再看看nginx Deployment资源是如何由kube-apiserver写入etcd的。
通用存储模块
kube-apiserver启动的时候会将每个资源的APIGroup、Version、Resource Handler注册到路由上。当请求经过认证、限速、授权、准入控制模块检查后请求就会被转发到对应的资源逻辑进行处理。
同时kube-apiserver实现了类似数据库ORM机制的通用资源存储机制提供了对一个资源创建、更新、删除前后的hook能力将其封装成策略接口。当你新增一个资源时你只需要编写相应的创建、更新、删除等策略即可不需要写任何etcd的API。
下面是kube-apiserver通用存储模块的创建流程图
从图中你可以看到创建一个资源主要由BeforeCreate、Storage.Create以及AfterCreate三大步骤组成。
当收到创建nginx Deployment请求后通用存储模块首先会回调各个资源自定义实现的BeforeCreate策略为资源写入etcd做一些初始化工作。
下面是Deployment资源的创建策略实现它会进行将deployment.Generation设置为1等操作。
// PrepareForCreate clears fields that are not allowed to be set by end users on creation.
func (deploymentStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) {
deployment := obj.(*apps.Deployment)
deployment.Status = apps.DeploymentStatus{}
deployment.Generation = 1
pod.DropDisabledTemplateFields(&deployment.Spec.Template, nil)
}
执行完BeforeCreate策略后它就会执行Storage.Create接口也就是由它真正开始调用底层存储模块etcd3将nginx Deployment资源对象写入etcd。
那么Kubernetes是使用etcd Put接口写入资源key-value的吗如果是那要如何防止同名资源并发创建被覆盖的问题
资源安全创建及更新
我们知道etcd提供了Put和Txn接口给业务添加key-value数据但是Put接口在并发场景下若收到key相同的资源创建就会导致被覆盖。
因此Kubernetes很显然无法直接通过etcd Put接口来写入数据。
而我们09节中介绍的etcd事务接口Txn它正是为了多key原子更新、并发操作安全性等而诞生的它提供了丰富的冲突检查机制。
Kubernetes集群使用的正是事务Txn接口来防止并发创建、更新被覆盖等问题。当执行完BeforeCreate策略后这时kube-apiserver就会调用Storage的模块的Create接口写入资源。1.6版本后的Kubernete集群默认使用的存储是etcd3它的创建接口简要实现如下
// Create implements storage.Interface.Create.
func (s *store) Create(ctx context.Context, key string, obj, out runtime.Object, ttl uint64) error {
......
key = path.Join(s.pathPrefix, key)
opts, err := s.ttlOpts(ctx, int64(ttl))
if err != nil {
return err
}
newData, err := s.transformer.TransformToStorage(data, authenticatedDataString(key))
if err != nil {
return storage.NewInternalError(err.Error())
}
startTime := time.Now()
txnResp, err := s.client.KV.Txn(ctx).If(
notFound(key),
).Then(
clientv3.OpPut(key, string(newData), opts...),
).Commit
从上面的代码片段中我们可以得出首先它会按照我们介绍的Kubernetes资源存储格式拼接key。
然后若TTL非0它会根据TTL从leaseManager获取可复用的Lease ID。Kubernetes集群默认若不同key如Kubernetes的Event资源对象的TTL差异在1分钟内可复用同一个Lease ID避免大量Lease影响etcd性能和稳定性。
其次若开启了数据加密在写入etcd前数据还将按加密算法进行转换工作。
最后就是使用etcd的Txn接口向etcd发起一个创建deployment资源的Txn请求。
那么etcd收到kube-apiserver的请求是长什么样子的呢
下面是etcd收到创建nginx deployment资源的请求日志
{
"level":"debug",
"ts":"2021-02-11T09:55:45.914+0800",
"caller":"v3rpc/interceptor.go:181",
"msg":"request stats",
"start time":"2021-02-11T09:55:45.911+0800",
"time spent":"2.697925ms",
"remote":"127.0.0.1:44822",
"response type":"/etcdserverpb.KV/Txn",
"request count":1,
"request size":479,
"response count":0,
"response size":44,
"request content":"compare:<target:MOD key:"/registry/deployments/default/nginx-deployment" mod_revision:0 > success:<request_put:<key:"/registry/deployments/default/nginx-deployment" value_size:421 >> failure:<>"
}
从这个请求日志中,你可以得到以下信息:
请求的模块和接口KV/Txn
key路径/registry/deployments/default/nginx-deployment由prefix + “/” + 资源类型 + “/” + namespace + “/” + 具体资源名组成;
安全的并发创建检查机制mod_revision为0时也就是此key不存在时才允许执行put更新操作。
通过Txn接口成功将数据写入到etcd后kubectl create -f nginx.yml命令就执行完毕返回给client了。在以上介绍中你可以看到kube-apiserver并没有任何逻辑去真正创建Pod但是为什么我们可以马上通过kubectl get命令查询到新建并成功运行的Pod呢
这就涉及到了基础架构图中的控制器、调度器、Kubelet等组件。下面我就为你浅析它们是如何基于etcd提供的Watch机制工作最终实现创建Pod、调度Pod、运行Pod的。
Watch机制在Kubernetes中应用
正如我们基础架构中所介绍的kube-controller-manager组件中包含一系列WorkLoad的控制器。Deployment资源就由其中的Deployment控制器来负责的那么它又是如何感知到新建Deployment资源最终驱动ReplicaSet控制器创建出Pod的呢
获取数据变化的方案主要有轮询和推送两种方案组成。轮询会产生大量expensive request并且存在高延时。而etcd Watch机制提供的流式推送能力赋予了kube-apiserver对外提供数据监听能力。
我们知道在etcd中版本号是个逻辑时钟随着client对etcd的增、删、改操作而全局递增它被广泛应用在MVCC、事务、Watch特性中。
尤其是在Watch特性中版本号是数据增量同步的核心。当client因网络等异常出现连接闪断后它就可以通过版本号从etcd server中快速获取异常后的事件无需全量同步。
那么在Kubernetes集群中它提供了什么概念来实现增量监听逻辑呢
答案是Resource Version。
Resource Version与etcd版本号
Resource Version是Kubernetes API中非常重要的一个概念顾名思义它是一个Kubernetes资源的内部版本字符串client可通过它来判断资源是否发生了变化。同时你可以在Get、List、Watch接口中通过指定Resource Version值来满足你对数据一致性、高性能等诉求。
那么Resource Version有哪些值呢跟etcd版本号是什么关系
下面我分别以Get和Watch接口中的Resource Version参数值为例为你剖析它与etcd的关系。
在Get请求查询案例中ResourceVersion主要有以下这三种取值
第一种是未指定ResourceVersion默认空字符串。kube-apiserver收到一个此类型的读请求后它会向etcd发出共识读/线性读请求获取etcd集群最新的数据。
第二种是设置ResourceVersion=“0”赋值字符串0。kube-apiserver收到此类请求时它可能会返回任意资源版本号的数据但是优先返回较新版本。一般情况下它直接从kube-apiserver缓存中获取数据返回给client有可能读到过期的数据适用于对数据一致性要求不高的场景。
第三种是设置ResourceVersion为一个非0的字符串。kube-apiserver收到此类请求时它会保证Cache中的最新ResourceVersion大于等于你传入的ResourceVersion然后从Cache中查找你请求的资源对象key返回数据给client。基本原理是kube-apiserver为各个核心资源如Pod维护了一个Cache通过etcd的Watch机制来实时更新Cache。当你的Get请求中携带了非0的ResourceVersion它会等待缓存中最新ResourceVersion大于等于你Get请求中的ResoureVersion若满足条件则从Cache中查询数据返回给client。若不满足条件它最多等待3秒若超过3秒Cache中的最新ResourceVersion还小于Get请求中的ResourceVersion就会返回ResourceVersionTooLarge错误给client。
你要注意的是若你使用的Get接口那么kube-apiserver会取资源key的ModRevision字段填充Kubernetes资源的ResourceVersion字段v1.meta/ObjectMeta.ResourceVersion。若你使用的是List接口kube-apiserver会在查询时使用etcd当前版本号填充ListMeta.ResourceVersion字段v1.meta/ListMeta.ResourceVersion
那么当我们执行kubectl get po查询案例时它的ResouceVersion是什么取值呢? 查询的是kube-apiserver缓存还是etcd最新共识数据?
如下所示你可以通过指定kubectl日志级别为6观察它向kube-apiserver发出的请求参数。从下面请求日志里你可以看到默认是未指定Resource Version也就是会发出一个共识读/线性读请求给etcd获取etcd最新共识数据。
kubectl get po -l app=nginx -v 6
4410 loader.go:359] Config loaded from file /root/.kube/config
4410 round_trippers.go:438] GET https://*.*.*.*:*/api/v1/namespaces/default/pods?labelSelector=app%3Dnginx&limit=500 200 OK in 8 milliseconds
这里要提醒下你在规模较大的集群中尽量不要使用kubectl频繁查询资源。正如我们上面所分析的它会直接查询etcd数据可能会产生大量的expensive request请求之前我就有见过业务这样用然后导致了集群不稳定。
介绍完查询案例后我们再看看Watch案例中它的不同取值含义是怎样的呢?
它同样含有查询案例中的三种取值,官方定义的含义分别如下:
未指定ResourceVersion默认空字符串。一方面为了帮助client建立初始状态它会将当前已存在的资源通过Add事件返回给client。另一方面它会从etcd当前版本号开始监听后续新增写请求导致数据变化时可及时推送给client。
设置ResourceVersion=“0”赋值字符串0。它同样会帮助client建立初始状态但是它会从任意版本号开始监听当前kube-apiserver的实现指定ResourceVersion=0和不指定行为一致在获取初始状态后都会从cache最新的ResourceVersion开始监听这种场景可能会导致集群返回陈旧的数据。
设置ResourceVersion为一个非0的字符串。从精确的版本号开始监听数据它只会返回大于等于精确版本号的变更事件。
Kubernetes的控制器组件就基于以上的Watch特性在快速感知到新建Deployment资源后进入一致性协调逻辑创建ReplicaSet控制器整体交互流程如下所示。
Deployment控制器创建ReplicaSet资源对象的日志如下所示。
{
"level":"debug",
"ts":"2021-02-11T09:55:45.923+0800",
"caller":"v3rpc/interceptor.go:181",
"msg":"request stats",
"start time":"2021-02-11T09:55:45.917+0800",
"time spent":"5.922089ms",
"remote":"127.0.0.1:44828",
"response type":"/etcdserverpb.KV/Txn",
"request count":1,
"request size":766,
"response count":0,
"response size":44,
"request content":"compare:<target:MOD key:"/registry/replicasets/default/nginx-deployment-756d9fd5f9" mod_revision:0 > success:<request_put:<key:"/registry/replicasets/default/nginx-deployment-756d9fd5f9" value_size:697 >> failure:<>"
}
真正创建Pod则是由ReplicaSet控制器负责它同样基于Watch机制感知到新的RS资源创建后发起请求创建Pod确保实际运行Pod数与期望一致。
{
"level":"debug",
"ts":"2021-02-11T09:55:46.023+0800",
"caller":"v3rpc/interceptor.go:181",
"msg":"request stats",
"start time":"2021-02-11T09:55:46.019+0800",
"time spent":"3.519326ms",
"remote":"127.0.0.1:44664",
"response type":"/etcdserverpb.KV/Txn",
"request count":1,
"request size":822,
"response count":0,
"response size":44,
"request content":"compare:<target:MOD key:"/registry/pods/default/nginx-deployment-756d9fd5f9-x6r6q" mod_revision:0 > success:<request_put:<key:"/registry/pods/default/nginx-deployment-756d9fd5f9-x6r6q" value_size:754 >> failure:<>"
}
在这过程中也产生了若干Event下面是etcd收到新增Events资源的请求你可以看到Event事件key关联了Lease这个Lease正是由我上面所介绍的leaseManager所负责创建。
{
"level":"debug",
"ts":"2021-02-11T09:55:45.930+0800",
"caller":"v3rpc/interceptor.go:181",
"msg":"request stats",
"start time":"2021-02-11T09:55:45.926+0800",
"time spent":"3.259966ms",
"remote":"127.0.0.1:44632",
"response type":"/etcdserverpb.KV/Txn",
"request count":1,
"request size":449,
"response count":0,
"response size":44,
"request content":"compare:<target:MOD key:"/registry/events/default/nginx-deployment.16628eb9f79e0ab0" mod_revision:0 > success:<request_put:<key:"/registry/events/default/nginx-deployment.16628eb9f79e0ab0" value_size:369 lease:5772338802590698925 >> failure:<>"
}
Pod创建出来后这时kube-scheduler监听到待调度的Pod于是为其分配Node通过kube-apiserver的Bind接口将调度后的节点IP绑定到Pod资源上。kubelet通过同样的Watch机制感知到新建的Pod后发起Pod创建流程即可。
以上就是当我们在Kubernetes集群中创建一个Pod后Kubernetes和etcd之间交互的简要分析。
小结
最后我们来小结下今天的内容。我通过一个创建Pod案例首先为你解读了Kubernetes集群的etcd存储格式每个资源的保存路径为prefix + “/” + 资源类型 + “/” + namespace + “/” + 具体资源名组成。结合etcd3的范围查询可快速实现按namesapace、资源名称查询。按标签查询则是通过kube-apiserver遍历指定namespace下的资源实现的若未从kube-apiserver的Cache中查询请求较频繁很可能导致etcd流量较大出现不稳定。
随后我和你介绍了kube-apiserver的通用存储模块它通过在创建、查询、删除、更新操作前增加一系列的Hook机制实现了新增任意资源只需编写相应的Hook策略即可。我还重点和你介绍了创建接口它主要由拼接key、获取Lease ID、数据转换、写入etcd组成重点是它通过使用事务接口实现了资源的安全创建及更新。
最后我给你讲解了Resoure Version在Kubernetes集群中的大量应用重点和你分析了Get和Watch请求案例中的Resource Version含义帮助你了解Resource Version本质让你能根据业务场景和对一致性的容忍度正确的使用Resource Version以满足业务诉求。
思考题
我还给你留了一个思考题有哪些原因可能会导致kube-apiserver报“too old Resource Version”错误呢
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。

View File

@ -0,0 +1,293 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 Kubernetes高级应用如何优化业务场景使etcd能支撑上万节点集群
你好,我是唐聪。
你知道吗? 虽然Kubernetes社区官网文档目前声称支持最大集群节点数为5000但是云厂商已经号称支持15000节点的Kubernetes集群了那么为什么一个小小的etcd能支撑15000节点Kubernetes集群呢
今天我就和你聊聊为了支撑15000节点Kubernetes和etcd的做的一系列优化。我将重点和你分析Kubernetes针对etcd的瓶颈是如何从应用层采取一系列优化措施去解决大规模集群场景中各个痛点。
当你遇到etcd性能瓶颈时希望这节课介绍的大规模Kubernetes集群的最佳实践经验和优化技术能让你获得启发帮助你解决类似问题。
大集群核心问题分析
在大规模Kubernetes集群中会遇到哪些问题呢
大规模Kubernetes集群的外在表现是节点数成千上万资源对象数量高达几十万。本质是更频繁地查询、写入更大的资源对象。
首先是查询相关问题。在大集群中最重要的就是如何最大程度地减少expensive request。因为对几十万级别的对象数量来说按标签、namespace查询Pod获取所有Node等场景时很容易造成etcd和kube-apiserver OOM和丢包乃至雪崩等问题发生。
其次是写入相关问题。Kubernetes为了维持上万节点的心跳会产生大量写请求。而按照我们基础篇介绍的etcd MVCC、boltdb、线性读等原理etcd适用场景是读多写少大量写请求可能会导致db size持续增长、写性能达到瓶颈被限速、影响读性能。
最后是大资源对象相关问题。etcd适合存储较小的key-value数据etcd本身也做了一系列硬限制比如key的value大小默认不能超过1.5MB。
本讲我就和你重点分析下Kubernetes是如何优化以上问题以实现支撑上万节点的。以及我会简单和你讲下etcd针对Kubernetes场景做了哪些优化。
如何减少expensive request
首先是第一个问题Kubernetes如何减少expensive request
在这个问题中我将Kubernetes解决此问题的方案拆分成几个核心点和你分析。
分页
首先List资源操作是个基本功能点。各个组件在启动的时候都不可避免会产生List操作从etcd获取集群资源数据构建初始状态。因此优化的第一步就是要避免一次性读取数十万的资源操作。
解决方案是Kubernetes List接口支持分页特性。分页特性依赖底层存储支持早期的etcd v2并未支持分页被饱受诟病非常容易出现kube-apiserver大流量、高负载等问题。在etcd v3中实现了指定返回Limit数量的范围查询因此也赋能kube-apiserver 对外提供了分页能力。
如下所示在List接口的ListOption结构体中Limit和Continue参数就是为了实现分页特性而增加的。
Limit表示一次List请求最多查询的对象数量一般为500。如果实际对象数量大于Limitkube-apiserver则会更新ListMeta的Continue字段client发起的下一个List请求带上这个字段就可获取下一批对象数量。直到kube-apiserver返回空的Continue值就获取完成了整个对象结果集。
// ListOptions is the query options to a standard REST
list call.
type ListOptions struct {
...
Limit int64 `json:"limit,omitempty"
protobuf:"varint,7,opt,name=limit"`
Continue string `json:"continue,omitempty"
protobuf:"bytes,8,opt,name=continue"`
}
了解完kube-apiserver的分页特性后我们接着往下看Continue字段具体含义以及它是如何影响etcd查询结果的。
我们知道etcd分页是通过范围查询和Limit实现ListOption中的Limit对应etcd查询接口中的Limit参数。你可以大胆猜测下Continue字段是不是跟查询的范围起始key相关呢
Continue字段的确包含查询范围的起始key它本质上是个结构体还包含APIVersion和ResourceVersion。你之所以看到的是一个奇怪字符串那是因为kube-apiserver使用base64库对其进行了URL编码下面是它的原始结构体。
type continueToken struct {
APIVersion string `json:"v"`
ResourceVersion int64 `json:"rv"`
StartKey string `json:"start"`
}
当kube-apiserver收到带Continue的分页查询时解析Continue获取StartKey、ResourceVersionetcd查询Range接口指定startKey增加clienv3.WithRange、clientv3.WithLimit、clientv3.WithRev即可。
当你通过分页多次查询Kubernetes资源对象得到的最终结果集合与不带Limit查询结果是一致的吗kube-apiserver是如何保证分页查询的一致性呢 这个问题我把它作为了思考题,我们一起讨论。
资源按namespace拆分
通过分页特性提供机制避免一次拉取大量资源对象后接下来就是业务最佳实践上要避免同namespace存储大量资源尽量将资源对象拆分到不同namespace下。
为什么拆分到不同namespace下有助于提升性能呢?
正如我在19中所介绍的Kubernetes资源对象存储在etcd中的key前缀包含namespace因此它相当于是个高效的索引字段。etcd treeIndex模块从B-tree中匹配前缀时可快速过滤出符合条件的key-value数据。
Kubernetes社区承诺SLO达标的前提是你在使用Kubernetes集群过程中必须合理配置集群和使用扩展特性并遵循一系列条件限制比如同namespace下的Service数量不超过5000个
Informer机制
各组件启动发起一轮List操作加载完初始状态数据后就进入了控制器的一致性协调逻辑。在一致性协调逻辑中在19讲Kubernetes 基础篇中我和你介绍了Kubernetes使用的是Watch特性来获取数据变化通知而不是List定时轮询这也是减少List操作一大核心策略。
Kubernetes社区在client-go项目中提供了一个通用的Informer组件来负责client与kube-apiserver进行资源和事件同步显著降低了开发者使用Kubernetes API、开发高性能Kubernetes扩展组件的复杂度。
Informer机制的Reflector封装了Watch、List操作结合本地Cache、Indexer实现了控制器加载完初始状态数据后接下来的其他操作都只需要从本地缓存读取极大降低了kube-apiserver和etcd的压力。
下面是Kubernetes社区给出的一个控制器使用Informer机制的架构图。黄色部分是控制器相关基础组件蓝色部分是client-go的Informer机制的组件它由Reflector、Queue、Informer、Indexer、Thread safe store(Local Cache)组成。
Informer机制的基本工作流程如下
client启动或与kube-apiserver出现连接中断再次Watch时报”too old resource version”等错误后通过Reflector组件的List操作从kube-apiserver获取初始状态数据随后通过Watch机制实时监听数据变化。
收到事件后添加到Delta FIFO队列由Informer组件进行处理。
Informer将delta FIFO队列中的事件转发给Indexer组件Indexer组件将事件持久化存储在本地的缓存中。
控制器开发者可通过Informer组件注册Add、Update、Delete事件的回调函数。Informer组件收到事件后会回调业务函数比如典型的控制器使用场景一般是将各个事件添加到WorkQueue中控制器的各个协调goroutine从队列取出消息解析key通过key从Informer机制维护的本地Cache中读取数据。
通过以上流程分析你可以发现除了启动、连接中断等场景才会触发List操作其他时候都是从本地Cache读取。
那连接中断等场景为什么触发client List操作呢
Watch bookmark机制
要搞懂这个问题你得了解kube-apiserver Watch特性的原理。
接下来我就和你介绍下Kubernetes的Watch特性。我们知道Kubernetes通过全局递增的Resource Version来实现增量数据同步逻辑尽量避免连接中断等异常场景下client发起全量List同步操作。
那么在什么场景下会触发全量List同步操作呢这就取决于client请求的Resource Version以及kube-apiserver中是否还保存了相关的历史版本数据。
在08Watch特性中我和你提到实现历史版本数据存储两大核心机制滑动窗口和MVCC。与etcd v3使用MVCC机制不一样的是Kubernetes采用的是滑动窗口机制。
kube-apiserver的滑动窗口机制是如何实现的呢?
它通过为每个类型资源Pod,Node等维护一个cyclic buffer来存储最近的一系列变更事件实现。
下面Kubernetes核心的watchCache结构体中的cache数组、startIndex、endIndex就是用来实现cyclic buffer的。滑动窗口中的第一个元素就是cache[startIndex%capacity]最后一个元素则是cache[endIndex%capacity]。
// watchCache is a "sliding window" (with a limited capacity) of objects
// observed from a watch.
type watchCache struct {
sync.RWMutex
// Condition on which lists are waiting for the fresh enough
// resource version.
cond *sync.Cond
// Maximum size of history window.
capacity int
// upper bound of capacity since event cache has a dynamic size.
upperBoundCapacity int
// lower bound of capacity since event cache has a dynamic size.
lowerBoundCapacity int
// cache is used a cyclic buffer - its first element (with the smallest
// resourceVersion) is defined by startIndex, its last element is defined
// by endIndex (if cache is full it will be startIndex + capacity).
// Both startIndex and endIndex can be greater than buffer capacity -
// you should always apply modulo capacity to get an index in cache array.
cache []*watchCacheEvent
startIndex int
endIndex int
// store will effectively support LIST operation from the "end of cache
// history" i.e. from the moment just after the newest cached watched event.
// It is necessary to effectively allow clients to start watching at now.
// NOTE: We assume that <store> is thread-safe.
store cache.Indexer
// ResourceVersion up to which the watchCache is propagated.
resourceVersion uint64
}
下面我以Pod资源的历史事件滑动窗口为例和你聊聊它在什么场景可能会触发client全量List同步操作。
如下图所示kube-apiserver启动后通过List机制加载初始Pod状态数据随后通过Watch机制监听最新Pod数据变化。当你不断对Pod资源进行增加、删除、修改后携带新Resource Version简称RV的Pod事件就会不断被加入到cyclic buffer。假设cyclic buffer容量为100RV1是最小的一个Watch事件的Resource VersionRV 100是最大的一个Watch事件的Resource Version。
当版本号为RV101的Pod事件到达时RV1就会被淘汰kube-apiserver维护的Pod最小版本号就变成了RV2。然而在Kubernetes集群中不少组件都只关心cyclic buffer中与自己相关的事件。
比如图中的kubelet只关注运行在自己节点上的Pod假设只有RV1是它关心的Pod事件版本号在未实现Watch bookmark特性之前其他RV2到RV101的事件是不会推送给它的因此它内存中维护的Resource Version依然是RV1。
若此kubelet随后与kube-apiserver连接出现异常它将使用版本号RV1发起Watch重连操作。但是kube-apsierver cyclic buffer中的Pod最小版本号已是RV2因此会返回”too old resource version”错误给clientclient只能发起List操作在获取到最新版本号后才能重新进入监听逻辑。
那么我们能否定时将最新的版本号推送给各个client来解决以上问题呢?
是的这就是Kubernetes的Watch bookmark机制核心思想。即使队列中无client关注的更新事件Informer机制的Reflector组件中Resource Version也需要更新。
Watch bookmark机制通过新增一个bookmark类型的事件来实现的。kube-apiserver会通过定时器将各类型资源最新的Resource Version推送给kubelet等client在client与kube-apiserver网络异常重连等场景大大降低了client重建Watch的开销减少了relist expensive request。
更高效的Watch恢复机制
虽然Kubernetes社区通过Watch bookmark机制缓解了client与kube-apiserver重连等场景下可能导致的relist expensive request操作然而在kube-apiserver重启、滚动更新时它依然还是有可能导致大量的relist操作这是为什么呢 如何进一步减少kube-apiserver重启场景下的List操作呢
如下图所示在kube-apiserver重启后kubelet等client会立刻带上Resource Version发起重建Watch的请求。问题就在kube-apiserver重启后watchCache中的cyclic buffer是空的此时watchCache中的最小Resource Version(listResourceVersion)是etcd的最新全局版本号也就是图中的RV200。
在不少场景下client请求重建Watch的Resource Version是可能小于listResourceVersion的。
比如在上面的这个案例图中集群内Pod稳定运行未发生变化kubelet假设收到了最新的RV100事件。然而这个集群其他资源如ConfigMap被管理员不断的修改它就会导致导致etcd版本号新增ConfigMap滑动窗口也会不断存储变更事件从图中可以看到它记录最大版本号为RV200。
因此kube-apiserver重启后client请求重建Pod Watch的Resource Version是RV100而Pod watchCache中的滑动窗口最小Resource Version是RV200。很显然RV100不在Pod watchCache所维护的滑动窗口中kube-apiserver就会返回”too old resource version”错误给clientclient只能发起relist expensive request操作同步最新数据。
为了进一步降低kube-apiserver重启对client Watch中断的影响Kubernetes在1.20版本中又进一步实现了更高效的Watch恢复机制。它通过etcd Watch机制的Notify特性实现了将etcd最新的版本号定时推送给kube-apiserver。kube-apiserver在将其转换成ResourceVersion后再通过bookmark机制推送给client避免了kube-apiserver重启后client可能发起的List操作。
如何控制db size
分析完Kubernetes如何减少expensive request我们再看看Kubernetes是如何控制db size的。
首先我们知道Kubernetes的kubelet组件会每隔10秒上报一次心跳给kube-apiserver。
其次Node资源对象因为包含若干个镜像、数据卷等信息导致Node资源对象会较大一次心跳消息可能高达15KB以上。
最后etcd是基于COW(Copy-on-write)机制实现的MVCC数据库每次修改都会产生新的key-value若大量写入会导致db size持续增长。
早期Kubernetes集群由于以上原因当节点数成千上万时kubelet产生的大量写请求就较容易造成db大小达到配额无法写入。
那么如何解决呢?
本质上还是Node资源对象大的问题。实际上我们需要更新的仅仅是Node资源对象的心跳状态而在etcd中我们存储的是整个Node资源对象并未将心跳状态拆分出来。
因此Kuberentes的解决方案就是将Node资源进行拆分把心跳状态信息从Node对象中剥离出来通过下面的Lease对象来描述它。
// Lease defines a lease concept.
type Lease struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`
Spec LeaseSpec `json:"spec,omitempty" protobuf:"bytes,2,opt,name=spec"`
}
// LeaseSpec is a specification of a Lease.
type LeaseSpec struct {
HolderIdentity *string `json:"holderIdentity,omitempty" protobuf:"bytes,1,opt,name=holderIdentity"`
LeaseDurationSeconds *int32 `json:"leaseDurationSeconds,omitempty" protobuf:"varint,2,opt,name=leaseDurationSeconds"`
AcquireTime *metav1.MicroTime `json:"acquireTime,omitempty" protobuf:"bytes,3,opt,name=acquireTime"`
RenewTime *metav1.MicroTime `json:"renewTime,omitempty" protobuf:"bytes,4,opt,name=renewTime"`
LeaseTransitions *int32 `json:"leaseTransitions,omitempty" protobuf:"varint,5,opt,name=leaseTransitions"`
}
因为Lease对象非常小更新的代价远小于Node对象所以这样显著降低了kube-apiserver的CPU开销、etcd db sizeKubernetes 1.14版本后已经默认启用Node心跳切换到Lease API。
如何优化key-value大小
最后我们再看看Kubernetes是如何解决etcd key-value大小限制的。
在成千上万个节点的集群中一个服务可能背后有上万个Pod。而服务对应的Endpoints资源含有大量的独立的endpoints信息这会导致Endpoints资源大小达到etcd的value大小限制etcd拒绝更新。
另外kube-proxy等组件会实时监听Endpoints资源一个endpoint变化就会产生较大的流量导致kube-apiserver等组件流量超大、出现一系列性能瓶颈。
如何解决以上Endpoints资源过大的问题呢
答案依然是拆分、化大为小。Kubernetes社区设计了EndpointSlice概念每个EndpointSlice最大支持保存100个endpoints成功解决了key-value过大、变更同步导致流量超大等一系列瓶颈。
etcd优化
Kubernetes社区在解决大集群的挑战的同时etcd社区也在不断优化、新增特性提升etcd在Kubernetes场景下的稳定性和性能。这里我简单列举两个一个是etcd并发读特性一个是Watch特性的Notify机制。
并发读特性
通过以上介绍的各种机制、策略虽然Kubernetes能大大缓解expensive read request问题但是它并不是从本质上来解决问题的。
为什么etcd无法支持大量的read expensive request呢
除了我们一直强调的容易导致OOM、大流量导致丢包外etcd根本性瓶颈是在etcd 3.4版本之前expensive read request会长时间持有MVCC模块的buffer读锁RLock。而写请求执行完后需升级锁至Lockexpensive request导致写事务阻塞在升级锁过程中最终导致写请求超时。
为了解决此问题etcd 3.4版本实现了并发读特性。核心解决方案是去掉了读写锁每个读事务拥有一个buffer。在收到读请求创建读事务对象时全量拷贝写事务维护的buffer到读事务buffer中。
通过并发读特性显著降低了List Pod和CRD等expensive read request对写性能的影响延时不再突增、抖动。
改善Watch Notify机制
为了配合Kubernetes社区实现更高效的Watch恢复机制etcd改善了Watch Notify机制早期Notify消息发送间隔是固定的10分钟。
在etcd 3.4.11版本中新增了experimental-watch-progress-notify-interval参数使Notify间隔时间可配置最小支持为100ms满足了Kubernetes业务场景的诉求。
最后你要注意的是默认通过clientv3 Watch API创建的watcher是不会开启此特性的。你需要创建Watcher的时候设置clientv3.WithProgressNotify选项这样etcd server就会定时发送提醒消息给client消息中就会携带etcd当前最新的全局版本号。
小结
最后我们来小结下今天的内容。
首先我和你剖析了大集群核心问题即expensive request、db size、key-value大小。
针对expensive request我分别为你阐述了Kubernetes的分页机制、资源按namespace拆分部署策略、核心的Informer机制、优化client与kube-apiserver连接异常后的Watch恢复效率的bookmark机制、以及进一步优化kube-apiserver重建场景下Watch恢复效率的Notify机制。从这个问题优化思路中我们可以看到优化无止境。从大方向到边界问题Kubernetes社区一步步将expensive request降低到极致。
针对db size和key-value大小Kubernetes社区的解决方案核心思想是拆分通过Lease和EndpointSlice资源对象成功解决了大规模集群过程遇到db size和key-value瓶颈。
最后etcd社区也在努力提升、优化相关特性etcd 3.4版本中的并发读特性和可配置化的Watch Notify间隔时间就是最典型的案例。自从etcd被redhat捐赠给CNCF后etcd核心就围绕着Kubernetes社区展开工作努力打造更快、更稳的etcd。
思考题
最后我给你留了两个思考题。
首先在Kubernetes集群中当你通过分页API分批多次查询得到全量Node资源的时候它能保证Node全量数据的完整性、一致性所有节点时间点一致如果能是如何保证的呢?
其次你在使用Kubernetes集群中是否有遇到一些稳定性、性能以及令你困惑的问题呢欢迎留言和我一起讨论。
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。

View File

@ -0,0 +1,321 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 分布式锁为什么基于etcd实现分布式锁比Redis锁更安全
你好,我是唐聪。
在软件开发过程中我们经常会遇到各种场景要求对共享资源进行互斥操作否则整个系统的数据一致性就会出现问题。典型场景如商品库存操作、Kubernertes调度器为Pod分配运行的Node。
那要如何实现对共享资源进行互斥操作呢?
锁就是其中一个非常通用的解决方案。在单节点多线程环境,你使用本地的互斥锁就可以完成资源的互斥操作。然而单节点存在单点故障,为了保证服务高可用,你需要多节点部署。在多节点部署的分布式架构中,你就需要使用分布式锁来解决资源互斥操作了。
但是为什么有的业务使用了分布式锁还会出现各种严重超卖事故呢?分布式锁的实现和使用过程需要注意什么?
今天我就和你聊聊分布式锁背后的故事我将通过一个茅台超卖的案例为你介绍基于Redis实现的分布锁优缺点引出分布式锁的核心要素对比分布式锁的几种业界典型实现方案深入剖析etcd分布式锁的实现。
希望通过这节课让你了解etcd分布式锁的应用场景、核心原理在业务开发过程中优雅、合理的使用分布式锁去解决各类资源互斥、并发操作问题。
从茅台超卖案例看分布式锁要素
首先我们从去年一个因Redis分布式锁实现问题导致茅台超卖案例说起在这个网友分享的真实案例中因茅台的稀缺性事件最终定级为P0级生产事故后果影响严重。
那么它是如何导致超卖的呢?
首先和你简单介绍下此案例中的Redis简易分布式锁实现方案它使用了Redis SET命令来实现。
SET key value [EX seconds|PX milliseconds|EXAT timestamp|PXAT milliseconds-timestamp|KEEPTTL] [NX|XX]
[GET]
简单给你介绍下SET命令重点参数含义
EX 设置过期时间,单位秒;
NX 当key不存在的时候才设置key
XX 当key存在的时候才设置key。
此业务就是基于Set key value EX 10 NX命令来实现的分布式锁并通过JAVA的try-finally语句执行Del key语句来释放锁简易流程如下
# 对资源key加锁key不存在时创建并且设置10秒自动过期
SET key value EX 10 NX
业务逻辑流程1校验用户身份
业务逻辑流程2查询并校验库存(get and compare)
业务逻辑流程3库存>0扣减库存(Decr stock),生成秒杀茅台订单
# 释放锁
Del key
以上流程中其实存在以下思考点:
NX参数有什么作用?
为什么需要原子的设置key及过期时间
为什么基于Set key value EX 10 NX命令还出现了超卖呢?
为什么大家都比较喜欢使用Redis作为分布式锁实现
首先来看第一个问题NX参数的作用。NX参数是为了保证当分布式锁不存在时只有一个client能写入此key成功获取到此锁。我们使用分布式锁的目的就是希望在高并发系统中有一种互斥机制来防止彼此相互干扰保证数据的一致性。
因此分布式锁的第一核心要素就是互斥性、安全性。在同一时间内不允许多个client同时获得锁。
再看第二个问题假设我们未设置key自动过期时间在Set key value NX后如果程序crash或者发生网络分区后无法与Redis节点通信毫无疑问其他client将永远无法获得锁。这将导致死锁服务出现中断。
有的同学意识到这个问题后使用如下SETNX和EXPIRE命令去设置key和过期时间这也是不正确的因为你无法保证SETNX和EXPIRE命令的原子性。
# 对资源key加锁key不存在时创建
SETNX key value
# 设置KEY过期时间
EXPIRE key 10
业务逻辑流程
# 释放锁
Del key
这就是分布式锁第二个核心要素活性。在实现分布式锁的过程中要考虑到client可能会出现crash或者网络分区你需要原子申请分布式锁及设置锁的自动过期时间通过过期、超时等机制自动释放锁避免出现死锁导致业务中断。
再看第三个问题为什么使用了Set key value EX 10 NX命令还出现了超卖呢
原来是抢购活动开始后加锁逻辑中的业务流程1访问的用户身份服务出现了高负载导致阻塞在校验用户身份流程中(超时30秒)然而锁10秒后就自动过期了因此其他client能获取到锁。关键是阻塞的请求执行完后它又把其他client的锁释放掉了导致进入一个恶性循环。
因此申请锁时写入的value应确保唯一性随机值等。client在释放锁时应通过Lua脚本原子校验此锁的value与自己写入的value一致若一致才能执行释放工作。
更关键的是库存校验是通过get and compare方式它压根就无法防止超卖。正确的解决方案应该是通过LUA脚本实现Redis比较库存、扣减库存操作的原子性或者在每次只能抢购一个的情况下通过判断Redis Decr命令的返回值即可。此命令会返回扣减后的最新库存若小于0则表示超卖
从这个问题中我们可以看到,分布式锁实现具备一定的复杂度,它不仅依赖存储服务提供的核心机制,同时依赖业务领域的实现。无论是遭遇高负载、还是宕机、网络分区等故障,都需确保锁的互斥性、安全性,否则就会出现严重的超卖生产事故。
再看最后一个问题为什么大家都比较喜欢使用Redis做分布式锁的实现呢?
考虑到在秒杀等业务场景上存在大量的瞬间、高并发请求加锁与释放锁的过程应是高性能、高可用的。而Redis核心优点就是快、简单是随处可见的基础设施部署、使用也及其方便因此广受开发者欢迎。
这就是分布式锁第三个核心要素,高性能、高可用。加锁、释放锁的过程性能开销要尽量低,同时要保证高可用,确保业务不会出现中断。
那么除了以上案例中人为实现问题导致的锁不安全因素外基于Redis实现的以上分布式锁还有哪些安全性问题呢
Redis分布式锁问题
我们从茅台超卖案例中为你总结出的分布式核心要素(互斥性、安全性、活性、高可用、高性能)说起。
首先如果我们的分布式锁跑在单节点的Redis Master节点上那么它就存在单点故障无法保证分布式锁的高可用。
于是我们需要一个主备版的Redis服务至少具备一个Slave节点。
我们又知道Redis是基于主备异步复制协议实现的Master-Slave数据同步如下图所示若client A执行SET key value EX 10 NX命令redis-server返回给client A成功后Redis Master节点突然出现crash等异常这时候Redis Slave节点还未收到此命令的同步。
若你部署了Redis Sentinel等主备切换服务那么它就会以Slave节点提升为主此时Slave节点因并未执行SET key value EX 10 NX命令因此它收到client B发起的加锁的此命令后它也会返回成功给client。
那么在同一时刻集群就出现了两个client同时获得锁分布式锁的互斥性、安全性就被破坏了。
除了主备切换可能会导致基于Redis实现的分布式锁出现安全性问题在发生网络分区等场景下也可能会导致出现脑裂Redis集群出现多个Master进而也会导致多个client同时获得锁。
如下图所示Master节点在可用区1Slave节点在可用区2当可用区1和可用区2发生网络分区后部署在可用区2的Redis Sentinel服务就会将可用区2的Slave提升为Master而此时可用区1的Master也在对外提供服务。因此集群就出现了脑裂出现了两个Master都可对外提供分布式锁申请与释放服务分布式锁的互斥性被严重破坏。
主备切换、脑裂是Redis分布式锁的两个典型不安全的因素本质原因是Redis为了满足高性能采用了主备异步复制协议同时也与负责主备切换的Redis Sentinel服务是否合理部署有关。
有没有其他方案解决呢?
当然有Redis作者为了解决SET key value [EX] 10 [NX]命令实现分布式锁不安全的问题提出了RedLock算法。它是基于多个独立的Redis Master节点的一种实现一般为5。client依次向各个节点申请锁若能从多数个节点中申请锁成功并满足一些条件限制那么client就能获取锁成功。
它通过独立的N个Master节点避免了使用主备异步复制协议的缺陷只要多数Redis节点正常就能正常工作显著提升了分布式锁的安全性、可用性。
但是它的实现建立在一个不安全的系统模型上的它依赖系统时间当时钟发生跳跃时也可能会出现安全性问题。你要有兴趣的话可以详细阅读下分布式存储专家Martin对RedLock的分析文章Redis作者的也专门写了一篇文章进行了反驳。
分布式锁常见实现方案
了解完Redis分布式锁的一系列问题和实现方案后我们再看看还有哪些典型的分布式锁实现。
除了Redis分布式锁其他使用最广的应该是ZooKeeper分布式锁和etcd分布式锁。
ZooKeeper也是一个典型的分布式元数据存储服务它的分布式锁实现基于ZooKeeper的临时节点和顺序特性。
首先什么是临时节点呢?
临时节点具备数据自动删除的功能。当client与ZooKeeper连接和session断掉时相应的临时节点就会被删除。
其次ZooKeeper也提供了Watch特性可监听key的数据变化。
使用Zookeeper加锁的伪代码如下
Lock
1 n = create(l + “/lock-”, EPHEMERAL|SEQUENTIAL)
2 C = getChildren(l, false)
3 if n is lowest znode in C, exit
4 p = znode in C ordered just before n
5 if exists(p, true) wait for watch event
6 goto 2
Unlock
1 delete(n)
接下来我重点给你介绍一下基于etcd的分布式锁实现。
etcd分布式锁实现
那么基于etcd实现的分布式锁是如何确保安全性、互斥性、活性的呢
事务与锁的安全性
从Redis案例中我们可以看到加锁的过程需要确保安全性、互斥性。比如当key不存在时才能创建否则查询相关key信息而etcd提供的事务能力正好可以满足我们的诉求。
正如我在09中给你介绍的事务特性它由IF语句、Then语句、Else语句组成。其中在IF语句中支持比较key的是修改版本号mod_revision和创建版本号create_revision。
在分布式锁场景你就可以通过key的创建版本号create_revision来检查key是否已存在因为一个key不存在的话它的create_revision版本号就是0。
若create_revision是0你就可发起put操作创建相关key具体代码如下:
txn := client.Txn(ctx).If(v3.Compare(v3.CreateRevision(k),
"=", 0))
你要注意的是实现分布式锁的方案有多种比如你可以通过client是否成功创建一个固定的key来判断此client是否获得锁你也可以通过多个client创建prefix相同名称不一样的key哪个key的revision最小最终就是它获得锁。至于谁优谁劣我作为思考题的一部分留给大家一起讨论。
相比Redis基于主备异步复制导致锁的安全性问题etcd是基于Raft共识算法实现的一个写请求需要经过集群多数节点确认。因此一旦分布式锁申请返回给client成功后它一定是持久化到了集群多数节点上不会出现Redis主备异步复制可能导致丢数据的问题具备更高的安全性。
Lease与锁的活性
通过事务实现原子的检查key是否存在、创建key后我们确保了分布式锁的安全性、互斥性。那么etcd是如何确保锁的活性呢? 也就是发生任何故障,都可避免出现死锁呢?
正如在06租约特性中和你介绍的Lease就是一种活性检测机制它提供了检测各个客户端存活的能力。你的业务client需定期向etcd服务发送”特殊心跳”汇报健康状态若你未正常发送心跳并超过和etcd服务约定的最大存活时间后就会被etcd服务移除此Lease和其关联的数据。
通过Lease机制就优雅地解决了client出现crash故障、client与etcd集群网络出现隔离等各类故障场景下的死锁问题。一旦超过Lease TTL它就能自动被释放确保了其他client在TTL过期后能正常申请锁保障了业务的可用性。
具体代码如下:
txn := client.Txn(ctx).If(v3.Compare(v3.CreateRevision(k), "=", 0))
txn = txn.Then(v3.OpPut(k, val, v3.WithLease(s.Lease())))
txn = txn.Else(v3.OpGet(k))
resp, err := txn.Commit()
if err != nil {
return err
}
Watch与锁的可用性
当一个持有锁的client crash故障后其他client如何快速感知到此锁失效了快速获得锁呢最大程度降低锁的不可用时间呢
答案是Watch特性。正如在08 Watch特性中和你介绍的Watch提供了高效的数据监听能力。当其他client收到Watch Delete事件后就可快速判断自己是否有资格获得锁极大减少了锁的不可用时间。
具体代码如下所示:
var wr v3.WatchResponse
wch := client.Watch(cctx, key, v3.WithRev(rev))
for wr = range wch {
for _, ev := range wr.Events {
if ev.Type == mvccpb.DELETE {
return nil
}
}
}
etcd自带的concurrency包
为了帮助你简化分布式锁、分布式选举、分布式事务的实现etcd社区提供了一个名为concurrency包帮助你更简单、正确地使用分布式锁、分布式选举。
下面我简单为你介绍下分布式锁concurrency包的使用和实现它的使用非常简单如下代码所示核心流程如下
首先通过concurrency.NewSession方法创建Session本质是创建了一个TTL为10的Lease。
其次得到session对象后通过concurrency.NewMutex创建了一个mutex对象包含Lease、key prefix等信息。
然后通过mutex对象的Lock方法尝试获取锁。
最后使用结束可通过mutex对象的Unlock方法释放锁。
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// create two separate sessions for lock competition
s1, err := concurrency.NewSession(cli, concurrency.WithTTL(10))
if err != nil {
log.Fatal(err)
}
defer s1.Close()
m1 := concurrency.NewMutex(s1, "/my-lock/")
// acquire lock for s1
if err := m1.Lock(context.TODO()); err != nil {
log.Fatal(err)
}
fmt.Println("acquired lock for s1")
if err := m1.Unlock(context.TODO()); err != nil {
log.Fatal(err)
}
fmt.Println("released lock for s1")
那么mutex对象的Lock方法是如何加锁的呢
核心还是使用了我们上面介绍的事务和Lease特性当CreateRevision为0时它会创建一个prefix为/my-lock的key /my-lock + LeaseID),并获取到/my-lock prefix下面最早创建的一个keyrevision最小分布式锁最终是由写入此key的client获得其他client则进入等待模式。
详细代码如下:
m.myKey = fmt.Sprintf("%s%x", m.pfx, s.Lease())
cmp := v3.Compare(v3.CreateRevision(m.myKey), "=", 0)
// put self in lock waiters via myKey; oldest waiter holds lock
put := v3.OpPut(m.myKey, "", v3.WithLease(s.Lease()))
// reuse key in case this session already holds the lock
get := v3.OpGet(m.myKey)
// fetch current holder to complete uncontended path with only one RPC
getOwner := v3.OpGet(m.pfx, v3.WithFirstCreate()...)
resp, err := client.Txn(ctx).If(cmp).Then(put, getOwner).Else(get, getOwner).Commit()
if err != nil {
return err
}
那未获得锁的client是如何等待的呢?
答案是通过Watch机制各自监听prefix相同revision比自己小的key因为只有revision比自己小的key释放锁我才能有机会获得锁如下代码所示其中waitDelete会使用我们上面的介绍的Watch去监听比自己小的key详细代码可参考concurrency mutex的实现。
// wait for deletion revisions prior to myKey
hdr, werr := waitDeletes(ctx, client, m.pfx, m.myRev-1)
// release lock key if wait failed
if werr != nil {
m.Unlock(client.Ctx())
} else {
m.hdr = hdr
}
小结
最后我们来小结下今天的内容。
今天我通过一个Redis分布式锁实现问题——茅台超卖案例给你介绍了分布式锁的三个主要核心要素它们分别如下
安全性、互斥性。在同一时间内不允许多个client同时获得锁。
活性。无论client出现crash还是遭遇网络分区你都需要确保任意故障场景下都不会出现死锁常用的解决方案是超时和自动过期机制。
高可用、高性能。加锁、释放锁的过程性能开销要尽量低,同时要保证高可用,避免单点故障。
随后我通过这个案例继续和你分析了Redis SET命令创建分布式锁的安全性问题。单Redis Master节点存在单点故障一主多备Redis实例又因为Redis主备异步复制当Master节点发生crash时可能会导致同时多个client持有分布式锁违反了锁的安全性问题。
为了优化以上问题Redis作者提出了RedLock分布式锁它基于多个独立的Redis Master节点工作只要一半以上节点存活就能正常工作同时不依赖Redis主备异步复制具有良好的安全性、高可用性。然而它的实现依赖于系统时间当发生时钟跳变的时候也会出现安全性问题。
最后我和你重点介绍了etcd的分布式锁实现过程中的一些技术点。它通过etcd事务机制校验CreateRevision为0才能写入相关key。若多个client同时申请锁则client通过比较各个key的revision大小判断是否获得锁确保了锁的安全性、互斥性。通过Lease机制确保了锁的活性无论client发生crash还是网络分区都能保证不会出现死锁。通过Watch机制使其他client能快速感知到原client持有的锁已释放提升了锁的可用性。最重要的是etcd是基于Raft协议实现的高可靠、强一致存储正常情况下不存在Redis主备异步复制协议导致的数据丢失问题。
思考题
这节课到这里也就结束了,最后我给你留了两个思考题。
第一死锁、脑裂、惊群效应是分布式锁的核心问题你知道它们各自是怎么一回事吗ZooKeeper和etcd是如何应对这些问题的呢
第二若你锁设置的10秒如果你的某业务进程抢锁成功后执行可能会超过10秒才成功在这过程中如何避免锁被自动释放而出现的安全性问题呢?
感谢你的阅读,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@ -0,0 +1,327 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 配置及服务发现解析etcd在API Gateway开源项目中应用
你好,我是唐聪。
在软件开发的过程中,为了提升代码的灵活性和开发效率,我们大量使用配置去控制程序的运行行为。
从简单的数据库账号密码配置到confd支持以etcd为后端存储的本地配置及模板管理再到Apache APISIX等API Gateway项目使用etcd存储服务配置、路由信息等最后到Kubernetes更实现了Secret和ConfigMap资源对象来解决配置管理的问题。
那么它们是如何实现实时、动态调整服务配置而不需要重启相关服务的呢?
今天我就和你聊聊etcd在配置和服务发现场景中的应用。我将以开源项目Apache APISIX为例为你分析服务发现的原理带你了解etcd的key-value模型Watch机制鉴权机制Lease特性事务特性在其中的应用。
希望通过这节课让你了解etcd在配置系统和服务发现场景工作原理帮助你选型适合业务场景的配置系统、服务发现组件。同时在使用Apache APISIX等开源项目过程中遇到etcd相关问题时你能独立排查、分析并向社区提交issue和PR解决。
服务发现
首先和你聊聊服务发现,服务发现是指什么?为什么需要它呢?
为了搞懂这个问题,我首先和你分享下程序部署架构的演进。
单体架构
在早期软件开发时使用的是单体架构,也就是所有功能耦合在同一个项目中,统一构建、测试、发布。单体架构在项目刚启动的时候,架构简单、开发效率高,比较容易部署、测试。但是随着项目不断增大,它具有若干缺点,比如:
所有功能耦合在同一个项目中修复一个小Bug就需要发布整个大工程项目增大引入问题风险。同时随着开发人员增多、单体项目的代码增长、各模块堆砌在一起、代码质量参差不齐内部复杂度会越来越高可维护性差。
无法按需针对仅出现瓶颈的功能模块进行弹性扩容,只能作为一个整体继续扩展,因此扩展性较差。
一旦单体应用宕机,将导致所有服务不可用,因此可用性较差。
分布式及微服务架构
如何解决以上痛点呢?
当然是将单体应用进行拆分,大而化小。如何拆分呢? 这里我就以一个我曾经参与重构建设的电商系统为案例给你分析一下。在一个单体架构中,完整的电商系统应包括如下模块:
商城系统,负责用户登录、查看及搜索商品、购物车商品管理、优惠券管理、订单管理、支付等功能。
物流及仓储系统,根据用户订单,进行发货、退货、换货等一系列仓储、物流管理。
其他客服系统、客户管理系统等。
因此在分布式架构中,你可以按整体功能,将单体应用垂直拆分成以上三大功能模块,各个功能模块可以选择不同的技术栈实现,按需弹性扩缩容,如下图所示。
那什么又是微服务架构呢?
它是对各个功能模块进行更细立度的拆分,比如商城系统模块可以拆分成:
用户鉴权模块;
商品模块;
购物车模块;
优惠券模块;
支付模块;
……
在微服务架构中,每个模块职责更单一、独立部署、开发迭代快,如下图所示。
那么在分布式及微服务架构中,各个模块之间如何及时知道对方网络地址与端口、协议,进行接口调用呢?
为什么需要服务发现中间件?
其实这个知道的过程,就是服务发现。在早期的时候我们往往通过硬编码、配置文件声明各个依赖模块的网络地址、端口,然而这种方式在分布式及微服务架构中,其运维效率、服务可用性是远远不够的。
那么我们能否实现通过一个特殊服务就查询到各个服务的后端部署地址呢? 各服务启动的时候就自动将IP和Port、协议等信息注册到特殊服务上当某服务出现异常的时候特殊服务就自动删除异常实例信息
是的当然可以这个特殊服务就是注册中心服务你可以基于etcd、ZooKeeper、consul等实现。
etcd服务发现原理
那么如何基于etcd实现服务发现呢?
下面我给出了一个通用的服务发现原理架构图,通过此图,为你介绍下服务发现的基本原理。详细如下:
整体上分为四层client层、proxy层(可选)、业务server、etcd存储层组成。引入proxy层的原因是使client更轻、逻辑更简单无需直接访问存储层同时可通过proxy层支持各种协议。
client层通过负载均衡访问proxy组件。proxy组件启动的时候通过etcd的Range RPC方法从etcd读取初始化服务配置数据随后通过Watch接口持续监听后端业务server扩缩容变化实时修改路由。
proxy组件收到client的请求后它根据从etcd读取到的对应服务的路由配置、负载均衡算法比如Round-robin转发到对应的业务server。
业务server启动的时候通过etcd的写接口Txn/Put等注册自身地址信息、协议到高可用的etcd集群上。业务server缩容、故障时对应的key应能自动从etcd集群删除因此相关key需要关联lease信息设置一个合理的TTL并定时发送keepalive请求给Leader续租以防止租约及key被淘汰。
当然,在分布式及微服务架构中,我们面对的问题不仅仅是服务发现,还包括如下痛点:
限速;
鉴权;
安全;
日志;
监控;
丰富的发布策略;
链路追踪;
……
为了解决以上痛点各大公司及社区开发者推出了大量的开源项目。这里我就以国内开发者广泛使用的Apache APISIX项目为例为你分析etcd在其中的应用了解下它是怎么玩转服务发现的。
Apache APISIX原理
Apache APISIX它具备哪些功能呢
它的本质是一个无状态、高性能、实时、动态、可水平扩展的API网关。核心原理就是基于你配置的服务信息、路由规则等信息将收到的请求通过一系列规则后正确转发给后端的服务。
Apache APISIX其实就是上面服务发现原理架构图中的proxy组件如下图红色虚线框所示。
Apache APISIX详细架构图如下引用自社区项目文档。从图中你可以看到它由控制面和数据面组成。
控制面顾名思义就是你通过Admin API下发服务、路由、安全配置的操作。控制面默认的服务发现存储是etcd当然也支持consul、nacos等。
你如果没有使用过Apache APISIX的话可以参考下这个example快速、直观的了解下Apache APISIX是如何通过Admin API下发服务和路由配置的。
数据面是在实现基于服务路由信息数据转发的基础上,提供了限速、鉴权、安全、日志等一系列功能,也就是解决了我们上面提的分布式及微服务架构中的典型痛点。
那么当我们通过控制面API新增一个服务时Apache APISIX是是如何实现实时、动态调整服务配置而不需要重启网关服务的呢
下面我就和你聊聊etcd在Apache APISIX项目中的应用。
etcd在Apache APISIX中的应用
在搞懂这个问题之前我们先看看Apache APISIX在etcd中都存储了哪些数据呢它的数据存储格式是怎样的
数据存储格式
下面我参考Apache APISIX的example案例apisix:2.3通过Admin API新增了两个服务、路由规则后执行如下查看etcd所有key的命令
etcdctl get "" --prefix --keys-only
etcd输出结果如下
/apisix/consumers/
/apisix/data_plane/server_info/f7285805-73e9-4ce4-acc6-a38d619afdc3
/apisix/global_rules/
/apisix/node_status/
/apisix/plugin_metadata/
/apisix/plugins
/apisix/plugins/
/apisix/proto/
/apisix/routes/
/apisix/routes/12
/apisix/routes/22
/apisix/services/
/apisix/services/1
/apisix/services/2
/apisix/ssl/
/apisix/ssl/1
/apisix/ssl/2
/apisix/stream_routes/
/apisix/upstreams/
然后我们继续通过etcdctl get命令查看下services都存储了哪些信息呢
root@e9d3b477ca1f:/opt/bitnami/etcd# etcdctl get /apisix/services --prefix
/apisix/services/
init_dir
/apisix/services/1
{"update_time":1614293352,"create_time":1614293352,"upstream":{"type":"roundrobin","nodes":{"172.18.5.12:80":1},"hash_on":"vars","scheme":"http","pass_host":"pass"},"id":"1"}
/apisix/services/2
{"update_time":1614293361,"create_time":1614293361,"upstream":
{"type":"roundrobin","nodes":{"172.18.5.13:80":1},"hash_on":"vars","scheme":"http","pass_host":"pass"},"id":"2"}
从中我们可以总结出如下信息:
Apache APSIX 2.x系列版本使用的是etcd3。
服务、路由、ssl、插件等配置存储格式前缀是/apisix + “/” + 功能特性类型routes/services/ssl等我们通过Admin API添加的路由、服务等配置就保存在相应的前缀下。
路由和服务配置的value是个Json对象其中服务对象包含了id、负载均衡算法、后端节点、协议等信息。
了解完Apache APISIX在etcd中的数据存储格式后那么它是如何动态、近乎实时地感知到服务配置变化的呢
Watch机制的应用
与Kubernetes一样它们都是通过etcd的Watch机制来实现的。
Apache APISIX在启动的时候首先会通过Range操作获取网关的配置、路由等信息随后就通过Watch机制获取增量变化事件。
使用Watch机制最容易犯错的地方是什么呢
答案是不处理Watch返回的相关错误信息比如已压缩ErrCompacted错误。Apache APISIX项目在从etcd v2中切换到etcd v3早期的时候同样也犯了这个错误。
去年某日收到小伙伴求助说使用Apache APISIX后获取不到新的服务配置了是不是etcd出什么Bug了
经过一番交流和查看日志发现原来是Apache APISIX未处理ErrCompacted错误导致的。根据我们07Watch原理的介绍当你请求Watch的版本号已被etcd压缩后etcd就会取消这个watcher这时你需要重建watcher才能继续监听到最新数据变化事件。
查清楚问题后小伙伴向社区提交了issue反馈随后Apache APISIX相关同学通过PR 2687修复了此问题更多信息你可参考Apache APISIX访问etcd相关实现代码文件。
鉴权机制的应用
除了Watch机制Apache APISIX项目还使用了鉴权毕竟配置网关是个高危操作那它是如何使用etcd鉴权机制的呢 etcd鉴权机制中最容易踩的坑是什么呢
答案是不复用client和鉴权token频繁发起Authenticate操作导致etcd高负载。正如我在17和你介绍的一个8核32G的高配节点在100个连接时Authenticate QPS仅为8。可想而知你如果不复用token那么出问题就很自然不过了。
Apache APISIX是否也踩了这个坑呢
Apache APISIX是基于Lua构建的使用的是lua-resty-etcd这个项目访问etcd从相关issue反馈看的确也踩了这个坑。社区用户反馈后随后通过复用client、更完善的token复用机制解决了Authenticate的性能瓶颈详细信息你可参考PR 2932、PR 100。
除了以上介绍的Watch机制、鉴权机制Apache APISIX还使用了etcd的Lease特性和事务接口。
Lease特性的应用
为什么Apache APISIX项目需要Lease特性呢
服务发现的核心工作原理是服务启动的时候将地址信息登录到注册中心,服务异常时自动从注册中心删除。
这是不是跟我们前面05节介绍的应用场景很匹配呢
没错Apache APISIX通过etcd v2的TTL特性、etcd v3的Lease特性来实现类似的效果它提供的增加服务路由API支持设置TTL属性如下面所示
# Create a route expires after 60 seconds, then it's deleted automatically
$ curl http://127.0.0.1:9080/apisix/admin/routes/2?ttl=60 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -i -d '
{
"uri": "/aa/index.html",
"upstream": {
"type": "roundrobin",
"nodes": {
"39.97.63.215:80": 1
}
}
}'
当一个路由设置非0 TTL后Apache APISIX就会为它创建Lease关联key相关代码如下
-- lease substitute ttl in v3
local res, err
if ttl then
local data, grant_err = etcd_cli:grant(tonumber(ttl))
if not data then
return nil, grant_err
end
res, err = etcd_cli:set(prefix .. key, value, {prev_kv = true, lease = data.body.ID})
else
res, err = etcd_cli:set(prefix .. key, value, {prev_kv = true})
end
事务特性的应用
介绍完Lease特性在Apache APISIX项目中的应用后我们再来思考两个问题。为什么它还依赖etcd的事务特性呢简单的执行put接口有什么问题
答案是它跟Kubernetes是一样的使用目的。使用事务是为了防止并发场景下的数据写冲突比如你可能同时发起两个Patch Admin API去修改配置等。如果简单地使用put接口就会导致第一个写请求的结果被覆盖。
Apache APISIX是如何使用事务接口提供的乐观锁机制去解决并发冲突的问题呢
核心依然是我们前面课程中一直强调的mod_revision它会比较事务提交时的mod_revision与预期是否一致一致才能执行put操作Apache APISIX相关使用代码如下
local compare = {
{
key = key,
target = "MOD",
result = "EQUAL",
mod_revision = mod_revision,
}
}
local success = {
{
requestPut = {
key = key,
value = value,
lease = lease_id,
}
}
}
local res, err = etcd_cli:txn(compare, success)
if not res then
return nil, err
end
关于Apache APISIX事务特性的引入、背景以及更详细的实现你也可以参考PR 2216。
小结
最后我们来小结下今天的内容。今天我给你介绍了服务部署架构的演进,我们从单体架构的缺陷开始、到分布式及微服务架构的诞生,和你分享了分布式及微服务架构中面临的一系列痛点(如服务发现,鉴权,安全,限速等等)。
而开源项目Apache APISIX正是一个基于etcd的项目它为后端存储提供了一系列的解决方案我通过它的架构图为你介绍了其控制面和数据面的工作原理。
随后我从数据存储格式、Watch机制、鉴权机制、Lease特性以及事务特性维度和你分析了它们在Apache APISIX项目中的应用。
数据存储格式上APISIX采用典型的prefix + 功能特性组织格式。key是相关配置idvalue是个json对象包含一系列业务所需要的核心数据。你需要注意的是Apache APISIX 1.x版本使用的etcd v2 API2.x版本使用的是etcd v3 API要求至少是etcd v3.4版本以上。
Watch机制上APISIX依赖它进行配置的动态、实时更新避免了传统的修改配置需要服务重启等缺陷。
鉴权机制上APISIX使用密码认证进行多租户认证、授权防止用户出现越权访问保护网关服务的安全。
Lease及事务特性上APISIX通过Lease来设置自动过期的路由规则解决服务发现中的节点异常自动剔除等问题通过事务特性的乐观锁机制来实现并发场景下覆盖更新等问题。
希望通过本节课的学习让你从etcd角度更深入了解APISIX项目的原理了解etcd各个特性在其中的应用学习它的最佳实践经验和经历的各种坑避免重复踩坑。在以后的工作中在你使用APISIX等开源项目遇到etcd相关错误时能独立分析、排查甚至给社区提交PR解决。
思考题
好了,这节课到这里也就结束了,最后我给你留了一个开放的配置系统设计思考题。
假设老板让你去设计一个大型配置系统,满足公司各个业务场景的诉求,期望的设计目标如下:
高可靠。配置系统的作为核心基础设施期望可用性能达到99.99%。
高性能。公司业务多,规模大,配置系统应具备高性能、并能水平扩容。
支持多业务、多版本管理、多种发布策略。
你认为etcd适合此业务场景吗如果适合分享下你的核心想法、整体架构如果不适合你心目中的理想存储和架构又是怎样的呢
欢迎大家留言一起讨论后面我也将在答疑篇中分享我的一些想法和曾经大规模TO C业务中的实践经验。
感谢你阅读,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@ -0,0 +1,247 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 选型etcd_ZooKeeper_Consul等我们该如何选择
你好,我是唐聪。
在软件开发过程中当我们需要解决配置、服务发现、分布式锁等业务痛点在面对etcd、ZooKeeper、Consul、Nacos等一系列候选开源项目时我们应该如何结合自己的业务场景选择合适的分布式协调服务呢
今天,我就和你聊聊主要分布式协调服务的对比。我将从基本架构、共识算法、数据模型、重点特性、容灾能力等维度出发,带你了解主要分布式协调服务的基本原理和彼此之间的差异性。
希望通过这节课让你对etcd、ZooKeeper、Consul原理和特性有一定的理解帮助你选型适合业务场景的配置系统、服务发现组件。
基本架构及原理
在详细和你介绍对比etcd、ZooKeeper、Consul特性之前我们先从整体架构上来了解一下各开源项目的核心架构及原理。
etcd架构及原理
首先是etcdetcd我们知道它是基于复制状态机实现的分布式协调服务。如下图所示由Raft共识模块、日志模块、基于boltdb持久化存储的状态机组成。
以下是etcd基于复制状态机模型的写请求流程
client发起一个写请求put x = 3
etcdserver模块向Raft共识模块提交请求共识模块生成一个写提案日志条目。若server是Leader则把日志条目广播给其他节点并持久化日志条目到WAL中
当一半以上节点持久化日志条目后Leader的共识模块将此日志条目标记为已提交committed并通知其他节点提交
etcdserver模块从Raft共识模块获取已经提交的日志条目异步应用到boltdb状态机存储中然后返回给client。
更详细的原理我就不再重复描述你可以参考02读和03写两节原理介绍。
ZooKeeper架构及原理
接下来我和你简要介绍下ZooKeeper原理下图是它的架构图。
如下面架构图所示你可以看到ZooKeeper中的节点与etcd类似也划分为Leader节点、Follower节点、Observer节点对应的Raft协议的Learner节点。同时写请求统一由Leader处理读请求各个节点都能处理。
不一样的是它们的读行为和共识算法。
在读行为上ZooKeeper默认读可能会返回stale data而etcd使用的线性读能确保读取到反应集群共识的最新数据。
共识算法上etcd使用的是RaftZooKeeper使用的是Zab。
那什么是Zab协议呢
Zab协议可以分为以下阶段
Phase 0Leader选举Leader Election)。一个节点只要求获得半数以上投票就可以当选为准Leader
Phase 1发现Discovery。准Leader收集其他节点的数据信息并将最新的数据复制到自身
Phase 2同步Synchronization。准Leader将自身最新数据复制给其他落后的节点并告知其他节点自己正式当选为Leader
Phase 3广播Broadcast。Leader正式对外服务处理客户端写请求对消息进行广播。当收到一个写请求后它会生成Proposal广播给各个Follower节点一半以上Follower节点应答之后Leader再发送Commit命令给各个Follower告知它们提交相关提案
ZooKeeper是如何实现的Zab协议的呢
ZooKeeper在实现中并未严格按论文定义的分阶段实现而是对部分阶段进行了整合分别如下
Fast Leader Election。首先ZooKeeper使用了一个名为Fast Leader Election的选举算法通过Leader选举安全规则限制确保选举出来的Leader就含有最新数据 避免了Zab协议的Phase 1阶段准Leader收集各个节点数据信息并复制到自身也就是将Phase 0和Phase 1进行了合并。
Recovery Phase。各个Follower发送自己的最新数据信息给LeaderLeader根据差异情况选择发送SNAP、DIFF差异数据、Truncate指令删除冲突数据等确保Follower追赶上Leader数据进度并保持一致。
Broadcast Phase。与Zab论文Broadcast Phase一致。
总体而言从分布式系统CAP维度来看ZooKeeper与etcd类似的是它也是一个CP系统在出现网络分区等错误时它优先保障的数据一致性牺牲的是A可用性。
Consul架构及原理
了解完ZooKeeper架构及原理后我们再看看Consul它的架构和原理是怎样的呢
下图是Consul架构图引用自HashiCorp官方文档
从图中你可以看到它由Client、Server、Gossip协议、Raft共识算法、两个数据中心组成。每个数据中心内的Server基于Raft共识算法复制日志Server节点分为Leader、Follower等角色。Client通过Gossip协议发现Server地址、分布式探测节点健康状态等。
那什么是Gossip协议呢
Gossip中文名称叫流言协议它是一种消息传播协议。它的核心思想其实源自我们生活中的八卦、闲聊。我们在日常生活中所看到的劲爆消息其实源于两类一类是权威机构如国家新闻媒体发布的消息另一类则是大家通过微信等社交聊天软件相互八卦一传十十传百的结果。
Gossip协议的基本工作原理与我们八卦类似在Gossip协议中如下图所示各个节点会周期性地选择一定数量节点然后将消息同步给这些节点。收到消息后的节点同样做出类似的动作随机的选择节点继续扩散给其他节点。
最终经过一定次数的扩散、传播整个集群的各个节点都能感知到此消息各个节点的数据趋于一致。Gossip协议被广泛应用在多个知名项目中比如Redis Cluster集群版Apache CassandraAWS Dynamo。
了解完Gossip协议我们再看看架构图中的多数据中心Consul支持数据跨数据中心自动同步吗
你需要注意的是虽然Consul天然支持多数据中心但是多数据中心内的服务数据并不会跨数据中心同步各个数据中心的Server集群是独立的。不过Consul提供了Prepared Query功能它支持根据一定的策略返回多数据中心下的最佳的服务实例地址使你的服务具备跨数据中心容灾。
比如当你的API网关收到用户请求查询A服务API网关服务优先从缓存中查找A服务对应的最佳实例。若无缓存则向Consul发起一个Prepared Query请求查询A服务实例Consul收到请求后优先返回本数据中心下的服务实例。如果本数据中心没有或异常则根据数据中心间 RTT 由近到远查询其它数据中心数据,最终网关可将用户请求转发给最佳的数据中心下的实例地址。
了解完Consul的Gossip协议、多数据中心支持我们再看看Consul是如何处理读请求的呢?
Consul支持以下三种模式的读请求
默认default。默认是此模式绝大部分场景下它能保证数据的强一致性。但在老的Leader出现网络分区被隔离、新的Leader被选举出来的一个极小时间窗口内可能会导致stale read。这是因为Consul为了提高读性能使用的是基于Lease机制来维持Leader身份避免了与其他节点进行交互确认的开销。
强一致性consistent。强一致性读与etcd默认线性读模式一样每次请求需要集群多数节点确认Leader身份因此相比default模式读性能会有所下降。
弱一致性stale)。任何节点都可以读无论它是否Leader。可能读取到陈旧的数据类似etcd的串行读。这种读模式不要求集群有Leader因此当集群不可用时只要有节点存活它依然可以响应读请求。
重点特性比较
初步了解完etcd、ZooKeeper、Consul架构及原理后你可以看到他们都是基于共识算法实现的强一致的分布式存储系统并都提供了多种模式的读机制。
除了以上共性,那么它们之间有哪些差异呢? 下表是etcd开源社区总结的一个详细对比项我们就从并发原语、健康检查及服务发现、数据模型、Watch特性等功能上详细比较下它们功能和区别。
并发原语
etcd和ZooKeeper、Consul的典型应用场景都是分布式锁、Leader选举以上场景就涉及到并发原语控制。然而etcd和ZooKeeper并未提供原生的分布式锁、Leader选举支持只提供了核心的基本数据读写、并发控制API由应用上层去封装。
为了帮助开发者更加轻松的使用etcd去解决分布式锁、Leader选举等问题etcd社区提供了concurrency包来实现以上功能。同时在etcdserver中内置了Lock和Election服务不过其也是基于concurrency包做了一层封装而已clientv3并未提供Lock和Election服务API给Client使用。 ZooKeeper所属的Apache社区提供了Apache Curator Recipes库来帮助大家快速使用分布式锁、Leader选举功能。
相比etcd、ZooKeeper依赖应用层基于API上层封装Consul对分布式锁就提供了原生的支持可直接通过命令行使用。
总体而言etcd、ZooKeeper、Consul都能解决分布式锁、Leader选举的痛点在选型时你可能会重点考虑其提供的API语言是否与业务服务所使用的语言一致。
健康检查、服务发现
分布式协调服务的另外一个核心应用场景是服务发现、健康检查。
与并发原语类似etcd和ZooKeeper并未提供原生的服务发现支持。相反Consul在服务发现方面做了很多解放用户双手的工作提供了服务发现的框架帮助你的业务快速接入并提供了HTTP和DNS两种获取服务方式。
比如下面就是通过DNS的方式获取服务地址
$ dig @127.0.0.1 -p 8600 redis.service.dc1.consul. ANY
最重要的是它还集成了分布式的健康检查机制。与etcd和ZooKeeper健康检查不一样的是它是一种基于client、Gossip协议、分布式的健康检查机制具备低延时、可扩展的特点。业务可通过Consul的健康检查机制实现HTTP接口返回码、内存乃至磁盘空间的检测。
Consul提供了多种机制给你注册健康检查如脚本、HTTP、TCP等。
脚本是怎么工作的呢介绍Consul架构时我们提到过的Agent角色的任务之一就是执行分布式的健康检查。
比如你将如下脚本放在Agent相应目录下当Linux机器内存使用率超过70%的时候,它会返回告警状态。
{
"check":
"id": "mem-util"
"name": "Memory utilization"
"args":
"/bin/sh"
"-c"
"/usr/bin/free | awk '/Mem/{printf($3/$2*100)}' | awk '{ print($0); if($1 > 70) exit 1;}'
]
"interval": "10s"
"timeout": "1s
}
}
相比Consuletcd、ZooKeeper它们提供的健康检查机制和能力就非常有限了。
etcd提供了Lease机制来实现活性检测。它是一种中心化的健康检查依赖用户不断地发送心跳续租、更新TTL。
ZooKeeper使用的是一种名为临时节点的状态来实现健康检查。当client与ZooKeeper节点连接断掉时ZooKeeper就会删除此临时节点的key-value数据。它比基于心跳机制更复杂也给client带去了更多的复杂性所有client必须维持与ZooKeeper server的活跃连接并保持存活。
数据模型比较
从并发原语、健康检查、服务发现等维度了解完etcd、ZooKeeper、Consul的实现区别之后我们再从数据模型上对比下三者。
首先etcd正如我们在07节MVCC和10节boltdb所介绍的它是个扁平的key-value模型内存索引通过B-tree实现数据持久化存储基于B+ tree的boltdb支持范围查询、适合读多写少可容纳数G的数据。
ZooKeeper的数据模型如下。
如上图所示它是一种层次模型你可能已经发现etcd v2的内存数据模型与它是一样的。ZooKeeper作为分布式协调服务的祖师爷早期etcd v2的确就是参考它而设计的。
ZooKeeper的层次模型中的每个节点叫Znode它分为持久性和临时型两种。
持久性顾名思义除非你通过API删除它否则它将永远存在。
临时型是指它与客户端会话绑定若客户端会话结束或出现异常中断等它都将被ZooKeeper server自动删除被广泛应用于活性检测。
同时你创建节点的时候,还可以指定一个顺序标识,这样节点名创建出来后就具有顺序性,一般应用于分布式选举等场景中。
那ZooKeeper是如何实现以上层次模型的呢
ZooKeeper使用的是内存ConcurrentHashMap来实现此数据结构因此具有良好的读性能。但是受限于内存的瓶颈一般ZooKeeper的数据库文件大小是几百M左右。
Consul的数据模型及存储是怎样的呢
它也提供了常用key-value操作它的存储引擎是基于Radix Tree实现的go-memdb要求value大小不能超过512个字节数据库文件大小一般也是几百M左右。与boltdb类似它也支持事务、MVCC。
Watch特性比较
接下来我们再看看Watch特性的比较。
正在我在08节Watch特性中所介绍的etcd v3的Watch是基于MVCC机制实现的而Consul是采用滑动窗口实现的。Consul存储引擎是基于Radix Tree实现的因此它不支持范围查询和监听只支持前缀查询和监听而etcd都支持。
相比etcd、ConsulZooKeeper的Watch特性有更多的局限性它是个一次性触发器。
在ZooKeeper中client对Znode设置了Watch时如果Znode内容发生改变那么client就会获得Watch事件。然而此Znode再次发生变化那client是无法收到Watch事件的除非client设置了新的Watch。
其他比较
最后我们再从其他方面做些比较。
线性读。etcd和Consul都支持线性读而ZooKeeper并不具备。
权限机制比较。etcd实现了RBAC的权限校验而ZooKeeper和Consul实现的ACL。
事务比较。etcd和Consul都提供了简易的事务能力支持对字段进行比较而ZooKeeper只提供了版本号检查能力功能较弱。
多数据中心。在多数据中心支持上只有Consul是天然支持的虽然它本身不支持数据自动跨数据中心同步但是它提供的服务发现机制、Prepared Query功能赋予了业务在一个可用区后端实例故障时可将请求转发到最近的数据中心实例。而etcd和ZooKeeper并不支持。
小结
最后我们来小结下今天的内容。首先我和你从顶层视角介绍了etcd、ZooKeeper、Consul基本架构及核心原理。
从共识算法角度上看etcd、Consul是基于Raft算法实现的数据复制ZooKeeper则是基于Zab算法实现的。Raft算法由Leader选举、日志同步、安全性组成而Zab协议则由Leader选举、发现、同步、广播组成。无论Leader选举还是日志复制它们都需要集群多数节点存活、确认才能继续工作。
从CAP角度上看在发生网络分区时etcd、Consul、ZooKeeper都是一个CP系统无法写入新数据。同时etcd、Consul、ZooKeeper提供了各种模式的读机制总体上可分为强一致性读、非强一致性读。
其中etcd和Consul则提供了线性读ZooKeeper默认是非强一致性读不过业务可以通过sync()接口等待Follower数据追赶上Leader进度以读取最新值。
接下来我从并发原语、健康检查、服务发现、数据模型、Watch特性、多数据中心比较等方面和你重点介绍了三者的实现与区别。
其中Consul提供了原生的分布式锁、健康检查、服务发现机制支持让业务可以更省心不过etcd和ZooKeeper也都有相应的库帮助你降低工作量。Consul最大的亮点则是对多数据中心的支持。
最后如果业务使用Go语言编写的国内一般使用etcd较多文档、书籍、最佳实践案例丰富。Consul在国外应用比较多中文文档及实践案例相比etcd较少。ZooKeeper一般是Java业务使用较多广泛应用在大数据领域。另外Nacos也是个非常优秀的开源项目支持服务发现、配置管理等是Java业务的热门选择。
思考题
好了,这节课到这里也就结束了,最后我给你留了一个思考题。
越来越多的业务要求跨可用区乃至地区级的容灾,如果你是核心系统开发者,你会如何选型合适的分布式协调服务,设计跨可用区、地区的容灾方案呢? 如果选用etcd又该怎么做呢?
感谢你阅读,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@ -0,0 +1,394 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 运维如何构建高可靠的etcd集群运维体系
你好,我是唐聪。
在使用etcd过程中我们经常会面临着一系列问题与选择比如
etcd是使用虚拟机还是容器部署各有什么优缺点
如何及时发现etcd集群隐患项比如数据不一致
如何及时监控及告警etcd的潜在隐患比如db大小即将达到配额
如何优雅的定时、甚至跨城备份etcd数据
如何模拟磁盘IO等异常来复现Bug、故障
今天我就和你聊聊如何解决以上问题。我将通过从etcd集群部署、集群组建、监控体系、巡检、备份及还原、高可用、混沌工程等维度带你了解如何构建一个高可靠的etcd集群运维体系。
希望通过这节课让你对etcd集群运维过程中可能会遇到的一系列问题和解决方案有一定的了解帮助你构建高可靠的etcd集群运维体系助力业务更快、更稳地运行。
整体解决方案
那要如何构建高可靠的etcd集群运维体系呢?
我通过下面这个思维脑图给你总结了etcd运维体系建设核心要点它由etcd集群部署、成员管理、监控及告警体系、备份及还原、巡检、高可用及自愈、混沌工程等维度组成。
集群部署
要想使用etcd集群我们面对的第一个问题是如何选择合适的方案去部署etcd集群。
首先是计算资源的选择,它本质上就是计算资源的交付演进史,分别如下:
物理机;
虚拟机;
裸容器如Docker实例
Kubernetes容器编排。
物理机资源交付慢、成本高、扩缩容流程费时一般情况下大部分业务团队不再考虑物理机除非是超大规模的上万个节点的Kubernetes集群对CPU、内存、网络资源有着极高诉求。
虚拟机是目前各个云厂商售卖的主流实例无论是基于KVM还是Xen实现都具有良好的稳定性、隔离性支持故障热迁移可弹性伸缩被etcd、数据库等存储业务大量使用。
在基于物理机和虚拟机的部署方案中我推荐你使用ansible、puppet等自动运维工具构建标准、自动化的etcd集群搭建、扩缩容流程。基于ansible部署etcd集群可以拆分成以下若干个任务:
下载及安装etcd二进制到指定目录
将etcd加入systemd等服务管理
为etcd增加配置文件合理设置相关参数
为etcd集群各个节点生成相关证书构建一个安全的集群
组建集群版(静态配置、动态配置,发现集群其他节点);
开启etcd服务启动etcd集群。
详细你可以参考digitalocean这篇博客文章它介绍了如何使用ansible去部署一个安全的etcd集群并给出了对应的yaml任务文件。
容器化部署则具有极速的交付效率、更灵活的资源控制、更低的虚拟化开销等一系列优点。自从Docker诞生后容器化部署就风靡全球。有的业务直接使用裸Docker容器来跑etcd集群。然而裸Docker容器不具备调度、故障自愈、弹性扩容等特性存在较大局限性。
随后为了解决以上问题诞生了以Kubernetes、Swarm为首的容器编排平台Kubernetes成为了容器编排之战中的王者大量业务使用Kubernetes来部署etcd、ZooKeeper等有状态服务。在开源社区中也诞生了若干个etcd的Kubernetes容器化解决方案分别如下
etcd-operator
bitnami etcd/statefulset
etcd-cluster-operator
openshit/cluster-etcd-operator
kubeadm。
etcd-operator目前已处于Archived状态无人维护基本废弃。同时它是基于裸Pod实现的要做好各种备份。在部分异常情况下存在集群宕机、数据丢失风险我仅建议你使用它的数据备份etcd-backup-operator。
bitnami etcd提供了一个helm包一键部署etcd集群支持各个云厂商支持使用PV、PVC持久化存储数据底层基于StatefulSet实现较稳定。目前不少开源项目使用的是它。
你可以通过如下helm命令快速在Kubernete集群中部署一个etcd集群。
helm repo add bitnami https://charts.bitnami.com/bitnami
helm install my-release bitnami/etcd
etcd-cluster-operator和openshit/cluster-etcd-operator比较小众目前star不多但是有相应的开发者维护你可参考下它们的实现思路与etcd-operator基于Pod、bitnami etcd基于Statefulset实现不一样的是它们是基于ReplicaSet和Static Pod实现的。
最后要和你介绍的是kubeadm它是Kubernetes集群中的etcd高可用部署方案的提供者kubeadm是基于Static Pod部署etcd集群的。Static Pod相比普通Pod有其特殊性它是直接由节点上的kubelet进程来管理无需通过kube-apiserver。
创建Static Pod方式有两种分别是配置文件和HTTP。kubeadm使用的是配置文件也就是在kubelet监听的静态Pod目录下一般是/etc/kubernetes/manifests放置相应的etcd Pod YAML文件即可如下图所示。
注意在这种部署方式中部署etcd的节点需要部署docker、kubelet、kubeadm组件依赖较重。
集群组建
和你聊完etcd集群部署的几种模式和基本原理后我们接下来看看在实际部署过程中最棘手的部分那就是集群组建。因为集群组建涉及到etcd成员管理的原理和节点发现机制。
在特别放送里超凡已通过一个诡异的故障案例给你介绍了成员管理的原理并深入分析了etcd集群添加节点、新建集群、从备份恢复等场景的核心工作流程。etcd目前通过一次只允许添加一个节点的方式可安全的实现在线成员变更。
你要特别注意当变更集群成员节点时节点的initial-cluster-state参数的取值可以是new或existing。
new一般用于初始化启动一个新集群的场景。当设置成new时它会根据initial-cluster-token、initial-cluster等参数信息计算集群ID、成员ID信息。
existing表示etcd节点加入一个已存在的集群它会根据peerURLs信息从Peer节点获取已存在的集群ID信息更新自己本地配置、并将本身节点信息发布到集群中。
那么当你要组建一个三节点的etcd集群的时候有哪些方法呢?
在etcd中无论是Leader选举还是日志同步都涉及到与其他节点通信。因此组建集群的第一步得知道集群总成员数、各个成员节点的IP地址等信息。
这个过程就是发现Discovery。目前etcd主要通过两种方式来获取以上信息分别是static configuration和dynamic service discovery。
static configuration是指集群总成员节点数、成员节点的IP地址都是已知、固定的根据我们上面介绍的initial-cluster-state原理有如下两个方法可基于静态配置组建一个集群。
方法1三个节点的initial-cluster-state都配置为new静态启动initial-cluster参数包含三个节点信息即可详情你可参考社区文档。
方法2第一个节点initial-cluster-state设置为new独立成集群随后第二和第三个节点都为existing通过扩容的方式不断加入到第一个节点所组成的集群中。
如果成员节点信息是未知的你可以通过dynamic service discovery机制解决。
etcd社区还提供了通过公共服务来发现成员节点信息组建集群的方案。它的核心是集群内的各个成员节点向公共服务注册成员地址等信息各个节点通过公共服务来发现彼此你可以参考官方详细文档。
监控及告警体系
当我们把集群部署起来后在业务开始使用之前部署监控是必不可少的一个环节它是我们保障业务稳定性提前发现风险、隐患点的重要核心手段。那么要如何快速监控你的etcd集群呢
正如我在14和15里和你介绍延时、内存时所提及的etcd提供了丰富的metrics来展示整个集群的核心指标、健康度。metrics按模块可划分为磁盘、网络、MVCC事务、gRPC RPC、etcdserver。
磁盘相关的metrics及含义如下图所示。
网络相关的metrics及含义如下图所示。
mvcc相关的较多我在下图中列举了部分其含义如下所示。
etcdserver相关的如下集群是否有leader、堆积的proposal数等都在此模块。
更多metrics你可以通过如下方法查看。
curl 127.0.0.1:2379/metrics
了解常见的metrics后我们只需要配置Prometheus服务采集etcd集群的2379端口的metrics路径。
采集的方案一般有两种,静态配置和动态配置。
静态配置是指添加待监控的etcd target到Prometheus配置文件如下所示。
global:
scrape_interval: 10s
scrape_configs:
- job_name: test-etcd
static_configs:
- targets:
['10.240.0.32:2379','10.240.0.33:2379','10.240.0.34:2379']
静态配置的缺点是每次新增集群、成员变更都需要人工修改配置,而动态配置就可解决这个痛点。
动态配置是通过Prometheus-Operator的提供ServiceMonitor机制实现的当你想采集一个etcd实例时若etcd服务部署在同一个Kubernetes集群你只需要通过Kubernetes的API创建一个如下的ServiceMonitor资源即可。若etcd集群与Promehteus-Operator不在同一个集群你需要去创建、更新对应的集群Endpoint。
那Prometheus是如何知道该采集哪些服务的metrics信息呢?
答案ServiceMonitor资源通过Namespace、Labels描述了待采集实例对应的Service Endpoint。
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: prometheus-prometheus-oper-kube-etcd
namespace: monitoring
spec:
endpoints:
- bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token
port: http-metrics
scheme: https
tlsConfig:
caFile: /etc/prometheus/secrets/etcd-certs/ca.crt
certFile: /etc/prometheus/secrets/etcd-certs/client.crt
insecureSkipVerify: true
keyFile: /etc/prometheus/secrets/etcd-certs/client.key
jobLabel: jobLabel
namespaceSelector:
matchNames:
- kube-system
selector:
matchLabels:
app: prometheus-operator-kube-etcd
release: prometheus
采集了metrics监控数据后下一步就是要基于metrics监控数据告警了。你可以通过Prometheus和Alertmanager组件实现那你应该为哪些核心指标告警呢
当然是影响集群可用性的最核心的metric。比如是否有Leader、Leader切换次数、WAL和事务操作延时。etcd社区提供了一个丰富的告警规则你可以参考下。
最后为了方便你查看etcd集群运行状况和提升定位问题的效率你可以基于采集的metrics配置个grafana可视化面板。下面我给你列出了集群是否有Leader、总的key数、总的watcher数、出流量、WAL持久化延时的可视化面板。
-
-
-
备份及还原
监控及告警就绪后,就可以提供给业务在生产环境使用了吗?
当然不行,数据是业务的安全红线,所以你还需要做好最核心的数据备份工作。
如何做呢?
主要有以下方法首先是通过etcdctl snapshot命令行人工备份。在发起重要变更的时候你可以通过如下命令进行备份并查看快照状态。
ETCDCTL_API=3 etcdctl --endpoints $ENDPOINT
snapshot save snapshotdb
ETCDCTL_API=3 etcdctl --write-out=table snapshot status snapshotdb
其次是通过定时任务进行定时备份建议至少每隔1个小时备份一次。
然后是通过etcd-backup-operator进行自动化的备份类似ServiceMonitor你可以通过创建一个备份任务CRD实现。CRD如下
apiVersion: "etcd.database.coreos.com/v1beta2"
kind: "EtcdBackup"
metadata:
name: example-etcd-cluster-periodic-backup
spec:
etcdEndpoints: [<etcd-cluster-endpoints>]
storageType: S3
backupPolicy:
# 0 > enable periodic backup
backupIntervalInSecond: 125
maxBackups: 4
s3:
# The format of "path" must be: "<s3-bucket-name>/<path-to-backup-file>"
# e.g: "mybucket/etcd.backup"
path: <full-s3-path>
awsSecret: <aws-secret>
最后你可以通过给etcd集群增加Learner节点实现跨地域热备。因Learner节点属于非投票成员的节点因此它并不会影响你集群的性能。它的基本工作原理是当Leader收到写请求时它会通过Raft模块将日志同步给Learner节点。你需要注意的是在etcd 3.4中目前只支持1个Learner节点并且只允许串行读。
巡检
完成集群部署、了解成员管理、构建好监控及告警体系并添加好定时备份策略后这时终于可以放心给业务使用了。然而在后续业务使用过程中你可能会遇到各类问题而这些问题很可能是metrics监控无法发现的比如如下
etcd集群因重启进程、节点等出现数据不一致
业务写入大 key-value 导致 etcd 性能骤降;
业务异常写入大量key数稳定性存在隐患
业务少数 key 出现写入 QPS 异常,导致 etcd 集群出现限速等错误;
重启、升级 etcd 后,需要人工从多维度检查集群健康度;
变更 etcd 集群过程中,操作失误可能会导致 etcd 集群出现分裂;
……
因此为了实现高效治理etcd集群我们可将这些潜在隐患总结成一个个自动化检查项比如
如何高效监控 etcd 数据不一致性?
如何及时发现大 key-value?
如何及时通过监控发现 key 数异常增长?
如何及时监控异常写入 QPS?
如何从多维度的对集群进行自动化的健康检测,更安心变更?
……
如何将这些 etcd 的最佳实践策略反哺到现网大规模 etcd 集群的治理中去呢?
答案就是巡检。
参考ServiceMonitor和EtcdBackup机制你同样可以通过CRD的方式描述此巡检任务然后通过相应的Operator实现此巡检任务。比如下面就是一个数据一致性巡检的YAML文件其对应的Operator组件会定时、并发检查其关联的etcd集群各个节点的key差异数。
apiVersion: etcd.cloud.tencent.com/v1beta1
kind: EtcdMonitor
metadata:
creationTimestamp: "2020-06-15T12:19:30Z"
generation: 1
labels:
clusterName: gz-qcloud-etcd-03
region: gz
source: etcd-life-cycle-operator
name: gz-qcloud-etcd-03-etcd-node-key-diff
namespace: gz
spec:
clusterId: gz-qcloud-etcd-03
metricName: etcd-node-key-diff
metricProviderName: cruiser
name: gz-qcloud-etcd-03
productName: tke
region: gz
status:
records:
- endTime: "2021-02-25T11:22:26Z"
message: collectEtcdNodeKeyDiff,etcd cluster gz-qcloud-etcd-03,total key num is
122143,nodeKeyDiff is 0
startTime: "2021-02-25T12:39:28Z"
updatedAt: "2021-02-25T12:39:28Z"
高可用及自愈
通过以上机制我们已经基本建设好一个高可用的etcd集群运维体系了。最后再给你提供几个集群高可用及自愈的小建议
若etcd集群性能已满足业务诉求可容忍一定的延时上升建议你将etcd集群做高可用部署比如对3个节点来说把每个节点部署在独立的可用区可容忍任意一个可用区故障。
逐步尝试使用Kubernetes容器化部署etcd集群。当节点出现故障时能通过Kubernetes的自愈机制实现故障自愈。
设置合理的db quota值配置合理的压缩策略避免集群db quota满从而导致集群不可用的情况发生。
混沌工程
在使用etcd的过程中你可能会遇到磁盘、网络、进程异常重启等异常导致的故障。如何快速复现相关故障进行问题定位呢
答案就是混沌工程。一般常见的异常我们可以分为如下几类:
磁盘IO相关的。比如模拟磁盘IO延时上升、IO操作报错。之前遇到的一个底层磁盘硬件异常导致IO延时飙升最终触发了etcd死锁的Bug我们就是通过模拟磁盘IO延时上升后来验证的。
网络相关的。比如模拟网络分区、网络丢包、网络延时、包重复等。
进程相关的。比如模拟进程异常被杀、重启等。之前遇到的一个非常难定位和复现的数据不一致Bug我们就是通过注入进程异常重启等故障最后成功复现。
压力测试相关的。比如模拟CPU高负载、内存使用率等。
开源社区在混沌工程领域诞生了若干个优秀的混沌工程项目如chaos-mesh、chaos-blade、litmus。这里我重点和你介绍下chaos-mesh它是基于Kubernetes实现的云原生混沌工程平台下图是其架构图引用自社区
为了实现以上异常场景的故障注入chaos-mesh定义了若干种资源类型分别如下
IOChaos用于模拟文件系统相关的IO延时和读写错误等。
NetworkChaos用于模拟网络延时、丢包等。
PodChaos用于模拟业务Pod异常比如Pod被杀、Pod内的容器重启等。
StressChaos用于模拟CPU和内存压力测试。
当你希望给etcd Pod注入一个磁盘IO延时的故障时你只需要创建此YAML文件就好。
apiVersion: chaos-mesh.org/v1alpha1
kind: IoChaos
metadata:
name: io-delay-example
spec:
action: latency
mode: one
selector:
labelSelectors:
app: etcd
volumePath: /var/run/etcd
path: '/var/run/etcd/**/*'
delay: '100ms'
percent: 50
duration: '400s'
scheduler:
cron: '@every 10m'
小结
最后我们来小结下今天的内容。
今天我通过从集群部署、集群组建、监控及告警体系、备份、巡检、高可用、混沌工程几个维度和你深入介绍了如何构建一个高可靠的etcd集群运维体系。
在集群部署上当你的业务集群规模非常大、对稳定性有着极高的要求时推荐使用大规格、高性能的物理机、虚拟机独占部署并且使用ansible等自动化运维工具进行标准化的操作etcd避免人工一个个修改操作。
对容器化部署来说Kubernetes场景推荐你使用kubeadm其他场景可考虑分批、逐步使用bitnami提供的etcd helm包它是基于statefulset、PV、PVC实现的各大云厂商都广泛支持建议在生产环境前多验证各个极端情况下的行为是否符合你的预期。
在集群组建上各个节点需要一定机制去发现集群中的其他成员节点主要可分为static configuration和dynamic service discovery。
static configuration是指集群中各个成员节点信息是已知的dynamic service discovery是指你可以通过服务发现组件去注册自身节点信息、发现集群中其他成员节点信息。另外我和你介绍了重要参数initial-cluster-state的含义它也是影响集群组建的一个核心因素。
在监控及告警体系上我和你介绍了etcd网络、磁盘、etcdserver、gRPC核心的metrics。通过修改Prometheues配置文件添加etcd target你就可以方便的采集etcd的监控数据。我还给你介绍了ServiceMonitor机制你可通过它实现动态新增、删除、修改待监控的etcd实例灵活的、高效的采集etcd Metrcis。
备份及还原上重点和你介绍了etcd snapshot命令etcd-backup-operator的备份任务CRD机制推荐使用后者。
最后是巡检、混沌工程它能帮助我们高效治理etcd集群及时发现潜在隐患低成本、快速的复现Bug和故障等。
思考题
好了,这节课到这里也就结束了,最后我给你留了一个思考题。
你在生产环境中目前是使用哪种方式部署etcd集群的呢若基于Kubernetes容器化部署的是否遇到过容器化后的相关问题
感谢你的阅读,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@ -0,0 +1,254 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
特别放送 成员变更:为什么集群看起来正常,移除节点却会失败呢?
你好我是王超凡etcd项目贡献者腾讯高级工程师。目前我主要负责腾讯公有云大规模Kubernetes集群管理和etcd集群管理。
受唐聪邀请我将给你分享一个我前阵子遇到的有趣的故障案例并通过这个案例来给你介绍下etcd的成员变更原理。
在etcd的日常运营过程中大部分同学接触到最多的运维操作就是集群成员变更操作无论是节点出现性能瓶颈需要扩容还是节点故障需要替换亦或是需要从备份来恢复集群都离不开成员变更。
然而如果你对etcd不是非常了解在变更时未遵循一定的规范那么很容易在成员变更时出现问题导致集群恢复时间过长进而造成业务受到影响。今天这节课我们就从一次诡异的故障说起来和你聊聊etcd成员变更的实现和演进看看etcd是如何实现动态成员变更的。希望通过这节课帮助你搞懂etcd集群成员管理的原理安全的变更线上集群成员从容的应对与集群成员管理相关的各类问题。
从一次诡异的故障说起
首先让我们来看一个实际生产环境中遇到的案例。
某天我收到了一个小伙伴的紧急求助有一个3节点集群其中一个节点发生了故障后由于不规范变更没有先将节点剔除集群而是直接删除了数据目录然后重启了节点。
之后该节点就不停的panic此时其他两个节点正常。诡异的是此时执行member remove操作却报错集群没有Leader但是用endpoint status命令可以看到集群是有Leader存在的。更加奇怪的是过了几个小时后该节点又自动恢复了如下图
你可以先自己思考下,可能是什么原因导致了这几个问题?有没有办法能够在这种场景下快速恢复集群呢?
如果暂时没什么思路,不要着急,相信学完这节课的成员变更原理后,你就能够独立分析类似的问题,并快速地提供正确、安全的恢复方式。
静态配置变更 VS 动态配置变更
接下来我们就来看下,要实现成员变更,都有哪些方案。
最简单的方案就是将集群停服,更新所有节点配置,再重新启动集群。但很明显,这个方案会造成变更期间集群不可用。对于一个分布式高可用的服务来说,这是不可接受的。而且手工变更配置很容易因为人为原因造成配置修改错误,从而造成集群启动失败等问题发生。
既然将所有节点同时关闭来更新配置我们无法接受那么我们能否实现一个方案通过滚动更新的方式增删节点来逐个更新节点配置尽量减少配置更新对集群的影响呢zookeeper 3.5.0之前就是采用的这个方案来降低配置更新对集群可用性的影响。
但这种方案还是有一定的缺点。一是要对存量节点配置进行手动更新,没有一个很好的校验机制,如果配置更新错误的话很容易对集群造成影响。二是滚动更新配置的过程中节点要进行重启,存量的连接要断开重连。在连接数和负载较高的场景下,大量连接重连也会对集群稳定性造成一定的影响。
针对这两个问题,有没有进一步的优化空间呢?作为程序员,我们的目标肯定是要尽量消除人工操作,将手工操作自动化,这样才能避免人为错误。
那么我们能否能够在配置实际应用之前通过程序来做好一系列的检查工作当所有检查通过后再实际应用新的配置呢同样为了避免重启节点我们能否通过API和共识算法将新的配置动态同步到老的节点呢
etcd目前采用的正是上面这种实现方式。它将成员变更操作分为了两个阶段如下图
第一个阶段通过API提交成员变更信息在API层进行一系列校验尽量避免因为人为原因造成的配置错误。如果新的配置通过校验则通过Raft共识算法将新的配置信息同步到整个集群等到整个集群达成共识后再应用新的配置。
第二个阶段,启动新的节点,并实际加入到集群中(或者移除老的节点,然后老节点自动退出)。
接下来我们就先来看下。etcd如何基于Raft来实现成员信息同步。
如何通过Raft实现成员信息同步
成员变更流程
在04节课中我们已经了解到Raft将一致性问题拆分成了3个子问题即Leader选举、日志复制以及安全性。基于日志复制我们可以将成员变更信息作为一个日志条目通过日志同步的方式同步到整个集群。那么问题来了日志同步后我们应该什么时候应用新的配置呢直接应用新的配置会造成什么问题吗
如上图所示参考自Raft论文当我们将3个节点集群扩展到5个节点的时候我们可以看到对于老的3节点配置来说它的多数派是2个节点。而对于新的5节点配置集群来说它的多数派是3个节点。
在箭头指向的时刻新老配置同时生效老的配置中Server1和Server2组成了多数派新的配置中Server3、Server4、Server5组成了新的多数派。此时集群中存在两个多数派可能会选出两个Leader违背了安全性。
那么有没有方式能避免这个问题,保证变更的安全性呢?一方面我们可以引入两阶段提交来解决这个问题,另一方面我们可以通过增加一定约束条件来达到目标。如下图所示,当我们一次只变更一个节点的时候我们可以发现,无论是从奇数节点扩缩到偶数节点,还是从偶数节点扩缩到奇数节点,扩缩容前后配置中的多数派必然有一个节点存在交叉(既存在于老的配置的多数派中,也存在于新的配置的多数派中)。
我们知道在Raft里竞选出的Leader必须获得一半以上节点投票这就保证了选出的Leader必然会拥有重叠节点的投票。而一个节点在一轮投票中只能投票给一个候选者这就保证了新老配置最终选出的Leader必然是一致的。
因此,我们通过增加一次只变更一个成员这个约束,就可以得到一个很简单的成员变更实现方式:
在一次只变更一个节点的场景下每个节点只需要应用当前收到的日志条目中最新的成员配置即可即便该配置当前还没有commit
在一个变更未结束时,禁止提交新的成员变更。
这样就保证了一个成员变更可以安全地进行,同时在变更的过程中,不影响正常的读写请求,也不会造成老的节点重启,提升了服务的稳定性。
需要注意的是etcd并没有严格按照Raft论文来实现成员变更它应用新的配置时间点是在应用层apply时通知Raft模块进行ApplyConfChange操作来进行配置切换而不是在将配置变更追加到Raftlog时立刻进行切换。
到目前为止etcd就完整地实现了一个成员信息同步的流程。如果是扩容的话接下来只需要启动之前配置的新节点就可以了。
为什么需要Learner
那么这个实现方案有没有什么风险呢?我们一起来分析下。
举个例子当我们将集群从3节点扩容到4节点的时候集群的法定票数quorum就从2变成了3。而我们新加的节点在刚启动的时候是没有任何日志的这时就需要从Leader同步快照才能对外服务。
如果数据量比较大的话快照同步耗时会比较久。在这个过程中如果其他节点发生了故障那么集群可用节点就变成了2个。而在4节点集群中日志需要同步到3个以上节点才能够写入成功此时集群是无法写入的。
由于法定票数增加,同时新节点同步日志时间长不稳定,从而增大了故障的概率。那么我们是否能通过某种方式来尽量缩短日志同步的时间呢?
答案就是Learner节点在Raft论文中也叫catch up。etcd 3.4实现了Leaner节点的能力新节点可以以Learner的形式加入到集群中。Learner节点不参与投票即加入后不影响集群现有的法定票数不会因为它的加入而影响到集群原有的可用性。
Learner节点不能执行写操作和一致性读Leader会将日志同步给Learner节点当Learner节点的日志快追上Leader节点时etcd 3.4 Learner已同步的日志条目Index达到Leader的90%即认为ready它就成为Ready状态可被提升为Voting Member。此时将Learner提升为Voting Member可以大大缩短日志同步时间降低故障的概率。
另外由于Learner节点不参与投票因此即使因为网络问题同步慢也不会影响集群读写性能和可用性可以利用这个特性来方便的实现异地热备的能力。
联合一致性joint consensus
虽然一次添加一个节点在实现上可以降低很大的复杂度,但它同样也有一些缺陷。
例如在跨zone容灾的场景下假设一个集群有三个节点ABC分别属于不同的zone你无法在不影响跨多zone容灾能力的情况下替换其中一个节点。假设我们要用同一个zone的D节点来替换C节点如下图
如果我们采用先增后减的形式先将D加到集群中此时集群节点数变为了4法定票数变为了3。如果CD所在的zone挂掉则集群只剩下两个可用节点变为不可用状态。
如果我们采用先减后增的形式先将C节点移除此时集群中剩2个节点法定票数为2。如果A或者B所在的zone挂掉了集群同样不可用。
当然通过Learner节点可以很大程度上降低这个问题发生的概率。但我们如果能够实现多节点成员变更的话则可以从根本上解决这个问题。
多节点成员变更也是Raft论文中最初提到的实现成员变更的方式为了保证成员变更的安全性我们可以通过两阶段提交来实现同时变更多个成员两阶段提交的实现方式有多种在Raft中是通过引入一个过渡配置来实现的即引入联合一致性joint consensus来解决这个问题。如下图引用自Raft论文所示
我们可以看到Raft引入了一个过渡配置Cold,new。当新的配置提案发起时Leader会先生成Cold,new状态的配置。当集群处于这个配置时需要Cold和Cnew的多数派都同意commit新的提案才能被commit。当Cold,new被commit后就可以安全切换到新的配置Cnew了当Cnew被提交后整个变更操作就完成了。
通过引入joint consensus我们可以看到不会存在Cold和Cnew同时独立做决定的情况保证了成员变更的安全性。
进一步推广的话通过引入joint consensus我们可以在多个成员变更过程中继续提交新的配置。但这么做不仅会带来额外的复杂度而且基本上不会带来实际的收益。因此在工程实现上我们一般还是只允许同一时间只能进行一次成员变更并且在变更过程中禁止提交新的变更。
etcd 3.4的Raft模块实现了joint consensus可以允许同时对多个成员或单个成员进行变更。但目前应用层并未支持这个能力还是只允许一次变更一个节点。它的实现仍然同Raft论文有一定的区别Raft论文是在配置变更提案追加到Raftlog时就切换配置而etcd的Raft实现是在apply过程才进行配置切换。当Cold,new配置apply之后就可以返回给客户端成功了。但此时变更还未完全结束新的日志条目仍然需要Cold和Cnew多数派都同意才能够提交Raft模块会通过追加一个空的配置变更条目将配置从Cold,new切换到Cnew。当Cnew apply后新的日志条目就只需要Cnew多数派同意即可整个成员变更信息同步完成。
集群扩容节点完整流程
上边讲完了成员信息同步流程,我们就可以来看下向一个已有集群扩容一个新节点的整体流程是怎样的(整体流程如下图)。
首先我们可以通过etcdctl或者clientv3库提供的API来向成员管理模块发起一个MemberAdd请求。成员管理模块在收到请求后会根据你提供的peer-urls地址来构建一个Member成员注意此时构建的Member成员的Name为空然后请求etcdserver进行添加操作。
ETCDCTL_API=3 etcdctl --endpoints=http://1.1.1.1:2379
member add node-4 --peer-urls=http://4.4.4.4:2380
在开启strict-reconfig-check的情况下默认开启etcdserver会先进行一系列检查比如检查当前集群启动的节点数是否满足法定票数要求当前集群所有投票节点是否都存活等。
检查通过后则向Raft模块发起一个ProposeConfChange提案带上新增的节点信息。提案在apply时会通知Raft模块切换配置同时更新本节点server维护的member和peer节点信息如果是移除节点操作的话被移除节点apply之后延时1s etcd进程会主动退出并将当前的成员信息更新到etcdserver维护的ConfState结构中。在snapshot的时候会进行持久化具体作用我们后边会介绍然后返回给客户端成功。
如果你用的是etcdctl的话应该可以看到如下输出
Member 96af95420b65e5f5 added to cluster 81a549bdbfd5c3a8
ETCD_NAME="node-4"
ETCD_INITIAL_CLUSTER="node-1=http://1.1.1.1:2380,node-2=http://2.2.2.2:2380,node-3=http://3.3.3.3:2380,node-4=https://4.4.4.4:2380"
ETCD_INITIAL_ADVERTISE_PEER_URLS="https://4.4.4.4:2380"
ETCD_INITIAL_CLUSTER_STATE="existing"
通过使用命令返回的环境变量参数,我们就可以启动新的节点了(注意,这里一定要保证你的启动参数和命令返回的环境变量一致)。
新节点启动时会先校验一系列启动参数根据是否存在WAL目录来判断是否是新节点根据initial-cluster-state参数的值为new或existing来判断是加入新集群还是加入已存在集群。
如果是已存在集群添加新节点的情况也就是不存在WAL目录且initial-cluster-state值为existing。如果存在WAL目录则认为是已有节点会忽略启动参数中的initial-cluster-state和initial-cluster等参数直接从snapshot中和WAL中获取成员列表信息则会从配置的peerURLs中获取其他成员列表连接集群来获取已存在的集群信息和成员信息更新自己的本地配置。
然后会启动RaftNode进行一系列的初始化操作后etcdserver就可以启动了。启动时会通过goroutine异步执行publish操作通过Raft模块将自己发布到集群中。
在发布之前该节点在集群内的Name是空etcd会认为unstarted发布时会通过Raft模块更新节点的Name和clientURLs到集群中从而变成started状态。发布之后该节点就可以监听客户端端口对外提供服务了。在执行publish的同时会启动监听peer端口用于接收Leader发送的snapshot和日志。
新集群如何组建
上边介绍了已存在集群扩容的场景,那么新建集群时又是怎样的呢?
新建集群和加节点的启动流程大体上一致,这里有两个不同的点:
一个是在新集群创建时构建集群的member信息会直接从启动参数获取区别于加节点场景从已存在集群查询。这就要求新集群每个节点初始化配置的initial-cluster、initial-cluster-state、initial-cluster-token参数必须相同因为节点依赖这个来构建集群初始化信息参数相同才能保证编码出来的MemberId和ClusterId相同。
另一个需要注意的点是在启动Raft Node的过程中如果是新建集群的话会多一步BootStrap流程。该流程会将initial-cluster中声明的Peer节点转换为ConfChangeAddNode类型的ConfChange日志条目追加到Raftlog中并设置为commited状态。然后直接通过applyConfChange来应用该配置并在应用层开始apply流程时再次apply该配置变更命令这里重复应用相同配置不会有其他影响
你知道etcd为什么要这么做吗这么做的一个好处是命令会通过WAL持久化集群成员状态也会通过snapshot持久化。当我们遇到后续节点重启等场景时就可以直接应用snapshot和WAL中的配置进行重放来生成实际的成员配置而不用再从启动参数读取。因为启动参数可能因为动态重配置而不再准确而snapshot和WAL中的配置可以保证最新。
如何从备份恢复集群
除了新建集群和集群扩缩容外,备份恢复同样十分重要。在集群一半以上节点挂掉后,就只能从备份来恢复了。
我们可以通过etcdctl snapshot save命令或者clientv3库提供的snapshot API来对集群进行备份。备份后的数据除了包含业务数据外还包含一些集群的元数据信息例如成员信息
有了备份之后我们就可以通过etcdctl snapshot restore命令来进行数据恢复。这个命令的参数你一定不要搞错我建议你对照官方文档来。每个节点恢复数据时的name和initial-advertise-peer-urls是有区别的如果所有节点都用一样的话最后你可能会恢复成多个独立的集群我曾经就见到有业务这样搞出过问题。
我们接着来看下snapshot restore都干了哪些事情如下图
首先它会根据你提供的参数进行一系列校验检查snapshot的hash值等。如果检查通过的话会创建snap目录并将snapshot拷贝到v3的db文件设置consistentIndex值为当前提供的initial-cluster参数中包含的成员数量并从db中删除老的成员信息。
然后它会根据你提供的参数信息来构建WAL文件和snap文件。从你提供的配置中来获取peer节点信息并转换为ConfChangeAddNode类型的ConfChange日志条目写入WAL文件同时更新commit值并将term设置为1。
之后snapshot restore会将peer节点作为Voters写入snapshot metadata的ConfState中并更新Term和Index。snapshot保存后WAL会随后保存当前snapshot的Term和Index用于索引snapshot文件。
当每个节点的数据恢复后我们就可以正常启动节点了。因为restore命令构造了WAL和snapshot因此节点启动相当于一个正常集群进行重启。在启动Raft模块时会通过snapshot的ConfState来更新Raft模块的配置信息并在应用层apply时会重放从WAL中获取到的ConfChangeAddNode类型的ConfChange日志条目更新应用层和Raft模块配置。
至此,集群恢复完成。
故障分析
了解完etcd集群成员变更的原理后我们再回到开篇的问题不晓得现在你有没有一个大概的思路呢接下来就让我们运用这节课和之前学习的内容一起来分析下这个问题。
首先这个集群初始化时是直接启动的3节点集群且集群创建至今没有过成员变更。那么当删除数据重启时异常节点会认为自己是新建集群第一次启动所以在启动Raft模块时会将peer节点信息转换成ConfChangeAddNode类型的ConfChange日志条目追加到Raftlog中然后设置committed Index为投票节点数量。我们是3节点集群所以此时committed Index设置为3并设置term为1然后在本地apply该日志条目应用初始化配置信息然后启动etcdserver。
Leader在检测到该节点存活后会向该节点发送心跳信息同步日志条目。Leader本地会维护每个peer节点的Match和Next IndexMatch表示已经同步到该节点的日志条目IndexNext表示下一次要同步的Index。
当Leader向Follower节点发送心跳时会从Match和Leader当前的commit Index中选择一个较小的伴随心跳消息同步到Follower节点。Follower节点在收到Leader的commit Index时会更新自己本地的commit Index。
但Follower节点发现该commit Index比自己当前最新日志的Index还要新按照我们之前的分析异常节点当前最新的Index为3日志也证明了这一点而Leader发送的commit Index是之前节点正常时的commit值肯定比3这个值要大便认为raftlog肯定有损坏或者丢失于是异常节点就会直接panic退出。最后就出现了我们之前看到的不停重启不停panic的现象。
那么为什么执行member remove操作会报没有Leader呢我们之前提到过执行成员变更前会进行一系列前置检查如下图。在移除节点时etcd首先会检查移除该节点后剩余的活跃节点是否满足集群法定票数要求。满足要求后会检查该节点是否宕机连接不通。如果是宕机节点则可以直接移除。
但由于我们的节点不停重启每次重启建立peer连接时会激活节点状态因此没有统计到宕机的节点中。
最后会统计集群中当前可用的节点该统计方式要求节点必须在5s前激活因为节点刚启动5s内认为etcd还没有ready所以不会统计到可用节点中即当前可用节点数为2。
然后再判断移除一个可用节点后,当前剩余节点是否满足法定票数要求,我们这个案例中为 2 - 1 < 1+ ((3-1)/2)不满足法定票数要求所以服务端会返回ErrUnhealthy报错给客户端我们这个场景其实是由于etcd针对不可用节点的判断没有排除异常的要移除节点导致
由于用户当时使用的是etcdctl v2的API所以客户端最终会将该错误转换成http code 503客户端识别到503就会认为当前集群没Leader这里v2客户端代码对v3 grpc错误码转换判断不是很准确有误导性打印我们之前看到的no Leader错误
最后一个问题为什么后来panic节点会自动恢复呢答案是中间由于IO高负载发生了心跳超时造成了Leader选举
新的Leader选举出来后会重置自己维护的peer节点的Match Index为0因此发送给异常Follower心跳时带上的commit Index即为0所以Follower不会再因为commit Index小于自己最新日志而panic而Leader探测到Follower的Index和自己差距太大后就发送snapshot给FollowerFollower接收snapshot后恢复正常
这个case了解原理后如果希望快速恢复的话也很简单完全停掉异常Follower节点后再执行member remove然后将节点移除清理数据再重新加入到集群或者通过move-leader命令手动触发一次Leader切换但该方式比较trick并不通用
以上就是这个案例的完整分析希望通过这个case能让你认识到规范变更的重要性在不了解原理的情况下一定要按照官方文档来操作不要凭感觉操作
小结
最后我们来小结下今天的内容今天我从一个诡异的成员变更故障案例讲起为你介绍了etcd实现成员变更的原理分别为你分析了etcd成员变更在Raft层和应用层的实现并分析了各个实现方案的优缺点
其次我带你过了一遍etcd成员变更的演进方案从只支持Member变更到支持Learner节点non-voting MemberRaft层从只支持单节点变更到支持多节点变更成员变更的方案越来越完善稳定运维人员在变更期间发生故障的概率也越来越低
之后我以新增节点为例深入为你分析了从配置提交到节点启动对外服务的完整流程以及新集群启动和恢复过程中涉及到的成员变更原理
最后通过我们这节课和之前的课程学到的原理我和你一步一步深入分析了下开篇的故障问题可能发生的原因以及快速恢复的方法希望通过这节课让你对etcd成员变更方案有一个深入的了解在遇到类似的问题时能够快速定位问题并解决提升业务的稳定性
思考题
在组建etcd集群时你是习惯于在initial-cluster参数中直接指定所有节点的配置启动还是说先指定一个节点配置启动然后再将剩余节点用添加到已存在集群的方式依次加入到集群中呢这两种方式各存在哪些优缺点欢迎把你的经验和想法分享到留言区我们可以一起讨论下
感谢你的阅读如果你认为这节课的内容有所收获也欢迎把它分享给更多的朋友一起阅读

View File

@ -0,0 +1,84 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 搞懂etcd掌握通往分布式存储系统之门的钥匙
你好,我是唐聪。
时间过得真快这就到了我们的定期更新的最后一节课了。从筹备、上线到今天专栏完结过去了将近7个多月的时间。
说句实在话,刚开始筹备专栏的时候,我没想过战线会拉得如此之长。当时就是简单地觉得,我的经验也比较丰富了,输出应该很简单。但是其实做专栏耗费的心力远超我的预期:每一节课的构思写作都会花费我大量的时间,而且写完后还得考虑文章逻辑是否有优化的空间,怎样加配图、加一个什么样的配图可以更加形象,甚至部分文章写完自己不满意我还会重写一遍。
细心的你应该能发现其实这个专栏每一节课的内容都是比较多的。一开始的筹划是每篇文章3500字左右但最后为了讲清楚、讲明白每一节课大部分都是到了6000字到7000字的内容有的文章字数是破万了。在此特别感谢我的“好基友”王超凡非常用心地和我一块深度review每一篇文章因为平时工作也很忙还经常得封闭式开发所以录音只能放在凌晨。
在这里和你分享一件有意思的小事,专栏上线的前一天凌晨,我们和编辑正霖都激动得睡不着,在群里预览文章,聊上线后会是怎么样的一番景象。我们甚至想,会不会上线后被各位疯狂吐槽,以至于不得不录一个“负荆请罪”视频。
现在回想起来,真的是做好了被大家吐槽的准备。但你们给我的是超出预期的热情。不少同学从上线到结束,都在时刻关注、学习每一节课,并留下优质的提问以及鼓励、认可。
有的同学是比较资深的etcd使用者会独立分析源码撰写高质量的技术博客并给出精彩的回答
有的同学是刨根问底的etcd兴趣用户会细致思考每一个异常场景给出精彩的提问
有的同学刚刚入门etcd用户正因为你们的提问让我意识到需要在基础篇中多去增加一些特性初体验的案例
还有的同学着急说面试要用,所以春节期间我们没有筹划春节特别活动,而是正常更新课程正文;
……
当然在这过程中我也收获满满。为了解答你们的疑问我必须得更加深入地阅读etcd源码也是倒逼着我去进一步成长。
编辑正霖半开玩笑地和我说,我们是以百米冲刺的速度去跑马拉松。这段经历真的很难忘,你们的评论和收藏证明了我们的付出是值得的。
在这最后一节课里我想最后和你再分享下我个人的etcd学习经验以及这整个专栏设计和写作思路。
如果要用一个核心词来总结这个专栏,那我希望是问题及任务式驱动。
从我的个人经验上来看我每次进一步学习etcd的动力其实都是源于某个棘手的问题。数据不一致、死锁等一系列棘手问题它们会倒逼我走出舒适区实现更进一步成长。
从专栏目录中你也可以看到,每讲都是围绕着一个问题深入展开。在具体写作思路上,我会先从整体上给你介绍整体架构、解决方案,让你有个全局的认识。随后围绕每个点,按照由浅入深的思路给你分析各种解决方案。
另外任务式驱动也是激励你不断学习的一个非常好的手段通过任务实践你可以获得满满的成就感建立正向反馈。你在学习etcd专栏的过程中可结合自己的实际情况为自己设立几个进阶任务下面我给你列举了部分
从0到1搭建一个etcd集群可以先从单节点再到多节点并进行节点的增删操作
业务应用中使用etcd的核心API
自己动手实现一个分布式锁的包;
阅读etcd的源码写篇源码分析博客 可从早期的etcd v2开始
基于raftexample实现一个支持多存储引擎的KV服务
基于Kubernetes的Operator机制实现一个etcd operator创建一个CRD资源就可新建一个集群
……
我希望带给你的不仅仅是etcd原理与实践案例更希望你收获的是一系列分布式核心问题解决方案它们不仅能帮助你搞懂etcd背后的设计思想与实现更像是一把通往分布式存储系统之门的钥匙让你更轻松地学习、理解其他存储系统。
那你可能会问了为什么搞懂etcd就能更深入理解分布式存储系统呢
因为etcd相比其他分布式系统如HBase等它足够简洁、轻量级又涵盖了分布式系统常见的问题和核心概念如API、数据模型、共识算法、存储引擎、事务、快照、WAL等非常适合新人去学习。
上图我为你总结了etcd以及其他分布式系统的核心技术点下面我再和你简要分析一下几个分布式核心问题及解决方案并以Redis Cluster集群模式作为对比案例希望能够帮助你触类旁通。
首先是服务可用性问题。分布式存储系统的解决方案是共识算法、复制模型。etcd使用的是Raft共识算法一个写请求至少要一半以上节点确认才能成功可容忍少数节点故障具备高可用、强一致的目标。Redis Cluster则使用的是主备异步复制和Gossip协议基于主备异步复制协议可将数据同步到多个节点实现高可用。同时通过Gossip协议发现集群中的其他节点、传递集群分片等元数据信息等操作不依赖于元数据存储组件实现了去中心化降低了集群运维的复杂度。
然后是数据如何存取的问题。分布式存储系统的解决方案是存储引擎。除了etcd使用的boltdb常见的存储引擎还有我们实践篇18中所介绍bitcask、leveldb、rocksdbleveldb优化版等。不同的分布式存储系统在面对不同业务痛点时读写频率、是否支持事务等所选择的解决方案不一样。etcd v2使用的是内存treeetcd v3则使用的是boltdb而Redis Cluster则使用的是基于内存实现的各类数据结构。
最后是如何存储大量数据的问题。分布式存储系统的解决方案是一系列的分片算法。etcd定位是个小型的分布式协调服务、元数据存储服务因此etcd v2和etcd v3都不支持分片每个节点含有全量的key-value数据。而Redis Cluster定位是个分布式、大容量的分布式缓存解决方案因此它必须要使用分片机制将数据打散在各个节点上。目前Redis Cluster使用的分片算法是哈希槽它根据你请求的key基于crc16哈希算法计算slot值每个slot分配给对应的node节点处理。
HASH_SLOT = CRC16(key) mod 16384
etcd 作为最热门的云原生存储之一在腾讯、阿里、Google、AWS、美团、字节跳动、拼多多、Shopee、明源云等公司都有大量的应用覆盖的业务不仅仅是 Kubernetes 相关的各类容器产品,更有视频、推荐、安全、游戏、存储、集群调度等核心业务。
更快、更稳是etcd未来继续追求的方向etcd社区将紧密围绕Kubernetes社区做一系列的优化工作提供集群降级、自动将Non-Voting的Learner节点提升为Voting Member等特性彻底解决饱受开发者诟病的版本管理等问题。
希望这个专栏一方面能帮助你遵循最佳实践高效解决核心业务中各类痛点问题另一方面能轻松帮你搞定面试过程中常见etcd问题拿到满意的offer。
当然我发现很多同学只是默默地收藏一直在“潜水”。我希望在这最后一课里大家一块来“灌灌水”分享一下你自己的etcd学习方法以及你对这门课的感受。我为你准备了一份问卷希望你花两分钟填一下说不定你就是我们这门课的“小锦鲤”~
最后,再次感谢,我们留言区和加餐见!