etcd

引言

前面的文章中,介绍了基于Paxos的ZooKeeper,本文将介绍另一种分布式一致性算法Raft的工业级实现————etcd,它们虽然实现方案不同,但是最终的实现效果都很像,而且etcd相较于ZooKeeper来说,更轻,更容易理解和使用,接下来就让我们一起来看一下etcd的实现思想。更多关于分布式系统的文章均收录于<分布式系列文章>中。

简介

etcd 是一个 Go 语言编写的分布式、高可用的一致性键值存储系统,用于提供可靠的分布式键值(key-value)存储、配置共享和服务发现等功能。 etcd可以用于存储关键数据和实现分布式调度,它在现代化的集群运行中能够起到关键性的作用。

etcd基于Raft协议,通过复制日志文件的方式来保证数据的强一致性。当客户端应用写一个key时,首先会存储到etcd的Leader上,然后再通过Raft协议复制到etcd集群的所有成员中,以此维护各成员(节点)状态的一致性与实现可靠性。虽然etcd是一个强一致性的系统,但也支持从非Leader节点读 取数据以提高性能,而且写操作仍然需要Leader的支持,所以当发生网络分区时,写操作仍可能失败 。

etcd具有一定的容错能力,假设集群中共有n个节点,即便集群中(n-1)/2个节点发生了故障,只要剩下的 (n+1) / 2个节点达成一致,也能操作成功。因此,它能够有效地应对网络分区和机器故障带来的数据丢失风险。

etcd默认数据一更新就落盘持久化,数据持久化存储使用WAL (write ahead log,预写式日志)格式。WAL记录了数据变化的全过程,在etcd中所有数据在提交之前都要先写人WAL中。etcd的Snapshot (快照)文件则存储了某一时刻etcd的所有数据,默认设置为每10000条记录做一次快照,经过快照后WAL文件即可删除。

设计要素

简单

  • 支持 RESTful风格的HTTP+JSON的API。
  • 从性能角度考虑,etcd 增加了对gRPC的支持,同时也提供rest gateway进行转化。
  • 使用 Go 语言编写,跨平台,部署和维护简单。
  • 使用Raft算法保证强一致性, Raft算法可理解性好。

安全

支持 TLS 客户端安全认证。

性能

单实例支持每秒一千次以上的写操作(v2),极限写性能可达10K+Qps(v3)。

可靠

使用Raft算法充分保证了分布式系统数据的强一致性。etcd集群是一个分布式系统,由多个节点相互通信构成整体的对外服务,每个节点都存储了完整的数据,并且通过Raft协议保证了每个节点维护的数据都是一致的。

架构

etcd (server)大体上可以分为网络层(http(s) server)、Raft模块、复制状态机和存储模块。etcd的架构如图所示。
models

  • 网络层:提供网络数据读写功能,监听服务端口,完成集群节点之间数据通信,收发客户端数据。
  • Raft模块: Raft强一致性算法的具体实现。
  • 存储模块:涉及KV存储、WAL文件、Snapshot管理等,用于处理etcd支持的各类功能的事务,包括数据索引、节点状态变更、监控与反馈、事件处理与执行等,是etcd对用户提供的大多数API功能的具体实现。
  • 复制状态机:这是一个抽象的模块,状态机的数据维护在内存中,定期持久化到磁盘,每次写请求都会持久化到WAL文件,并根据写请求的内容修改状态机数据。除了在内存中存有所有数据的状态以及节点的索引之外,etcd还通过WAL进行持久化存储。基于WAL的存储系统其特点就是所有的数据在提交之前都会事先记录日志。Snapshot是为了防止数据过多而进行的状态快照。

应用场景

服务发现

服务发现要解决的也是分布式系统中最常见的问题之一,即在同一个分布式集群中的进程或服务,要如何才能找到对方并建立连接。本质上来说,服务发现就是想要了解集群中是否有进程在监听 udp 或 tcp 端口,并且通过名字就可以查找和连接。要解决服务发现的问题,需要有下面三大支柱,缺一不可。

  • 一个强一致性、高可用的服务存储目录。基于 Raft 算法的 etcd 天生就是这样一个强一致性高可用的服务存储目录。
  • 一种注册服务和监控服务健康状态的机制。用户可以在 etcd 中注册服务,并且对注册的服务设置key TTL,定时保持服务的心跳以达到监控健康状态的效果。
  • 一种查找和连接服务的机制。通过在 etcd 指定的主题下注册的服务也能在对应的主题下查找到。为了确保连接,我们可以在每个服务机器上都部署一个 Proxy 模式的 etcd,这样就可以确保能访问 etcd 集群的服务都能互相连接。

service-discovery
微服务协同工作架构中,服务动态添加。随着 Docker 容器的流行,多种微服务共同协作,构成一个相对功能强大的架构的案例越来越多。透明化的动态添加这些服务的需求也日益强烈。通过服务发现机制,在 etcd 中注册某个服务名字的目录,在该目录下存储可用的服务节点的 IP。在使用服务的过程中,只要从服务目录下查找可用的服务节点去使用即可。
micro-service-use-etcd
PaaS 平台中应用多实例与实例故障重启透明化。PaaS 平台中的应用一般都有多个实例,通过域名,不仅可以透明的对这多个实例进行访问,而且还可以做到负载均衡。但是应用的某个实例随时都有可能故障重启,这时就需要动态的配置域名解析(路由)中的信息。通过 etcd 的服务发现功能就可以轻松解决这个动态配置的问题。
proxy-load-balance

消息发布/订阅

在分布式系统中,最适用的一种组件间通信方式就是消息发布与订阅。即构建一个配置共享中心,数据提供者在这个配置中心发布消息,而消息使用者则订阅他们关心的主题,一旦主题有消息发布,就会实时通知订阅者。通过这种方式可以做到分布式系统配置的集中式管理与动态更新。

  • 应用中用到的一些配置信息放到 etcd 上进行集中管理。这类场景的使用方式通常是这样:应用在启动的时候主动从 etcd 获取一次配置信息,同时,在 etcd 节点上注册一个 Watcher 并等待,以后每次配置有更新的时候,etcd 都会实时通知订阅者,以此达到获取最新配置信息的目的。
  • 分布式搜索服务中,索引的元信息和服务器集群机器的节点状态存放在 etcd 中,供各个客户端订阅使用。使用 etcd 的key TTL功能可以确保机器状态是实时更新的。
  • 分布式日志收集系统。这个系统的核心工作是收集分布在不同机器的日志。收集器通常是按照应用(或主题)来分配收集任务单元,因此可以在 etcd 上创建一个以应用(主题)命名的目录 P,并将这个应用(主题相关)的所有机器 ip,以子目录的形式存储到目录 P 上,然后设置一个 etcd 递归的 Watcher,递归式的监控应用(主题)目录下所有信息的变动。这样就实现了机器 IP(消息)变动的时候,能够实时通知到收集器调整任务分配。
  • 系统中信息需要动态自动获取与人工干预修改信息请求内容的情况。通常是暴露出接口,例如 JMX 接口,来获取一些运行时的信息。引入 etcd 之后,就不用自己实现一套方案了,只要将这些信息存放到指定的 etcd 目录中即可,etcd 的这些目录就可以通过 HTTP 的接口在外部访问。

notify-subscribe

负载均衡

在场景一中也提到了负载均衡,本文所指的负载均衡均为软负载均衡。分布式系统中,为了保证服务的高可用以及数据的一致性,通常都会把数据和服务部署多份,以此达到对等服务,即使其中的某一个服务失效了,也不影响使用。由此带来的坏处是数据写入性能下降,而好处则是数据访问时的负载均衡。因为每个对等服务节点上都存有完整的数据,所以用户的访问流量就可以分流到不同的机器上。

  • etcd 本身分布式架构存储的信息访问支持负载均衡。etcd 集群化以后,每个 etcd 的核心节点都可以处理用户的请求。所以,把数据量小但是访问频繁的消息数据直接存储到 etcd 中也是个不错的选择,如业务系统中常用的二级代码表(在表中存储代码,在 etcd 中存储代码所代表的具体含义,业务系统调用查表的过程,就需要查找表中代码的含义)。
  • 利用 etcd 维护一个负载均衡节点表。etcd 可以监控一个集群中多个节点的状态,当有一个请求发过来后,可以轮询式的把请求转发给存活着的多个状态。类似 KafkaMQ,通过ZooKeeper来维护生产者和消费者的负载均衡。同样也可以用 etcd 来做ZooKeeper的工作。

load-balance

分布式通知/协调

这里说到的分布式通知与协调,与消息发布和订阅有些相似。都用到了 etcd 中的 Watcher 机制,通过注册与异步通知机制,实现分布式环境下不同系统之间的通知与协调,从而对数据变更做到实时处理。实现方式通常是这样:不同系统都在 etcd 上对同一个目录进行注册,同时设置 Watcher 观测该目录的变化(如果对子目录的变化也有需要,可以设置递归模式),当某个系统更新了 etcd 的目录,那么设置了 Watcher 的系统就会收到通知,并作出相应处理。

  • 通过 etcd 进行低耦合的心跳检测。检测系统和被检测系统通过 etcd 上某个目录关联而非直接关联起来,这样可以大大减少系统的耦合性。
  • 通过 etcd 完成系统调度。某系统有控制台和推送系统两部分组成,控制台的职责是控制推送系统进行相应的推送工作。管理人员在控制台作的一些操作,实际上是修改了 etcd 上某些目录节点的状态,而 etcd 就把这些变化通知给注册了 Watcher 的推送系统客户端,推送系统再作出相应的推送任务。
  • 通过 etcd 完成工作汇报。大部分类似的任务分发系统,子任务启动后,到 etcd 来注册一个临时工作目录,并且定时将自己的进度进行汇报(将进度写入到这个临时目录),这样任务管理者就能够实时知道任务进度。

notify-coordinated

分布式锁

因为 etcd 使用 Raft 算法保持了数据的强一致性,某次操作存储到集群中的值必然是全局一致的,所以很容易实现分布式锁。锁服务有两种使用方式,一是保持独占,二是控制时序。

  • 保持独占即所有获取锁的用户最终只有一个可以得到。etcd 为此提供了一套实现分布式锁原子操作 CAS(CompareAndSwap)的 API。通过设置prevExist值,可以保证在多个节点同时去创建某个目录时,只有一个成功。而创建成功的用户就可以认为是获得了锁。
  • 控制时序,即所有想要获得锁的用户都会被安排执行,但是获得锁的顺序也是全局唯一的,同时决定了执行顺序。etcd 为此也提供了一套 API(自动创建有序键),对一个目录建值时指定为POST动作,这样 etcd 会自动在目录下生成一个当前最大的值为键,存储这个新的值(客户端编号)。同时还可以使用 API 按顺序列出所有当前目录下的键值。此时这些键的值就是客户端的时序,而这些键中存储的值可以是代表客户端的编号。

lock

分布式队列

分布式队列的常规用法与场景五中所描述的分布式锁的控制时序用法类似,即创建一个先进先出的队列,保证顺序。

另一种比较有意思的实现是在保证队列达到某个条件时再统一按顺序执行。这种方法的实现可以在 /queue 这个目录中另外建立一个 /queue/condition 节点。

  • condition 可以表示队列大小。比如一个大的任务需要很多小任务就绪的情况下才能执行,每次有一个小任务就绪,就给这个 condition 数字加 1,直到达到大任务规定的数字,再开始执行队列里的一系列小任务,最终执行大任务。
  • condition 可以表示某个任务在不在队列。这个任务可以是所有排序任务的首个执行程序,也可以是拓扑结构中没有依赖的点。通常,必须执行这些任务后才能执行队列中的其他任务。
  • condition 还可以表示其它的一类开始执行任务的通知。可以由控制程序指定,当 condition 出现变化时,开始执行队列任务。

queue

集群监控/Leader选举

通过 etcd 来进行监控实现起来非常简单并且实时性强。

  • 前面几个场景已经提到 Watcher 机制,当某个节点消失或有变动时,Watcher 会第一时间发现并告知用户。
  • 节点可以设置TTL key,比如每隔 30s 发送一次心跳使代表该机器存活的节点继续存在,否则节点消失。

这样就可以第一时间检测到各节点的健康状态,以完成集群的监控要求。

另外,使用分布式锁,可以完成 Leader 竞选。这种场景通常是一些长时间 CPU 计算或者使用 IO 操作的机器,只需要竞选出的 Leader 计算或处理一次,就可以把结果复制给其他的 Follower。从而避免重复劳动,节省计算资源。

这个的经典场景是搜索系统中建立全量索引。如果每个机器都进行一遍索引的建立,不但耗时而且建立索引的一致性不能保证。通过在 etcd 的 CAS 机制同时创建一个节点,创建成功的机器作为 Leader,进行索引计算,然后把计算结果分发到其它节点。
leader

对比ZooKeeper

etcd 实现的这些功能, ZooKeeper都能实现。那么为什么要用 etcd 而非直接使用ZooKeeper呢?相较之下,ZooKeeper有如下缺点:

  • 复杂。ZooKeeper的部署维护复杂,管理员需要掌握一系列的知识和技能;而 Paxos 强一致性算法也是素来以复杂难懂而闻名于世;另外,ZooKeeper的使用也比较复杂,需要安装客户端,官方只提供了 Java 和 C 两种语言的接口。
  • Java 编写。这里不是对 Java 有偏见,而是 Java 本身就偏向于重型应用,它会引入大量的依赖。而运维人员则普遍希望保持强一致、高可用的机器集群尽可能简单,维护起来也不易出错。
  • 发展缓慢。Apache 基金会项目特有的“Apache Way”在开源界饱受争议,其中一大原因就是由于基金会庞大的结构以及松散的管理导致项目发展缓慢。

而 etcd 作为一个后起之秀,其优点也很明显。

  • 简单。使用 Go 语言编写部署简单;使用 HTTP 作为接口使用简单;使用 Raft 算法保证强一致性让用户易于理解。
  • 数据持久化。etcd 默认数据一更新就进行持久化。
  • 安全。etcd 支持 SSL 客户端安全认证。

最后,etcd 作为一个年轻的项目,真正告诉迭代和开发中,这既是一个优点,也是一个缺点。优点是它的未来具有无限的可能性,缺点是无法得到大项目长时间使用的检验。然而,目前 CoreOS、Kubernetes 和 CloudFoundry 等知名项目均在生产环境中使用了 etcd,所以总的来说,etcd 值得你去尝试。

v2 & v3

etcd原本的定位就是解决分布式系统的协调问题,现在etcd已经广泛应用于分布式网络、服务发现、配置共享、分布式系统调度和负载均衡等领域。etcd v2的大部分设计和决策已在实践中证明是非常正确的:专注于key-value存储而不是一个完整的数据库,通过HTTP+JSON的方式暴露给外部API,观察者( watch)机制提供持续监听某个key变化的功能,以及基于TTL的key的自动过期机制等。这些特性和设计很好地满足了etcd的初步需求。

然而,在实际使用过程中我们也发现了一些问题,比如,客户端需要频繁地与服务端进行通信,集群即使在空闲时间也要承受较大的压力,以及垃圾回收key的时间不稳定等。另外,虽然etcd v2可以基本满足分布式协调的功能,但是当今的“微服务”架构要求etcd能够单集群支撑更大规模的并发。

鉴于以上问题和需求,etcd充分借鉴了etcd v2的经验,吸收了etcd v2的教训,做出了如下改进和优化。

  • 使用gRPC+protobuf取代HTTP+JSON通信,提高通信效率,减少TCP连接消耗,另外通过gRPC gateway来继续保持对HTTP JSON接口的支持。
  • 使用更轻量级的基于租约(lease)的key自动过期机制,取代了基于TTL的key的自动过期机制,可以多个key共用一个lease,减少资源浪费。
  • 观察者(watcher)机制也进行了重新设计。etcd v2的观察者机制是基于HTTP长连接的事件驱动机制。而etcd v3的观察者机制是基于HTTP/2的server push,并且对事件进行了多路复用(multiplexing)优化。
  • etcd v3的数据模型也发生了较大的改变,etcd v2是一个简单的key­value的内存数据库,而etcd v3则是支持事务和多版本并发控制的磁盘数据库。etcd v2数据不直接落盘,落盘的是日志和快照文件,这些只是数据的中间格式而非最终形式,系统通过回放日志文件来构建数据的最终形态。etcd v3落盘的是数据的最终形态,日志和快照的主要作用是进行分布式的复制。

数据存储

etcd是一个key-value数据库,etcd v2只保存了key的最新的value,之前的value直接被覆盖了。但是有的应用需要知道一个key的所有value的历史变更记录,因此etcd v2维护了一个全局的key的历史记录变更的窗口,默认保存最新的1000个变更,而且这1000个变更不是某一个key的,而是整个数据库全局的历史变更记录。由于etcd v2最多只能保存1000个历史变更,因此在很短的时间内如果有频繁的写操作的话,那么变更记录会很快超过1000。如果watch过慢就会无法得到之前的变更,带来的后果就是watch丢失事件。etcd v3为了支持多记录,抛弃了这种不稳定的“滑动窗口”式的设计,通过引人 MVCC (多版本并发控制),采用了从历史记录为主索引的存储结构,保存了key的所有历史变更记录。etcd v3可以存储上十万个记录进行快速查询,并且支持根据用户的要求进行压缩合并。

多版本键值可以减轻用户设计分布式系统的难度。 通过对多版本的控制,用户可以获得一个一致的键值空间的快照。用户可以在无锁的状态下查询快照上的键值,从而帮助做出下一步决定。

客户端在GET一个key的value时,可以指定一个版本号,服务器端会返回紧接着这个版本之后的value。 这样的话,有需要的应用就可以知道key的所有历史变更记录。客户端也可以指定版本号进行watch,服务端会连续不断地把该版本号之后的变更都通知给客户端。

etcd v3 除了保存key的所有历史变更记录之外,它还在存储的实现上摒弃了etcd v2的目录式层级化设计,代之以一个扁平化的设计。这是因为有的应用会针对单个key进行操作,而有的应用则会递归地对一个目录下的所有key进行操作。在实现上,维护一个目录式的层级化存储会带来一些额外的开销,而扁平化的设计也可以支持用户的这些操作,同时还会更加轻量级。 etcd v3使用扁平化的设计,用一个线段树(interval tree)来支持范围查询、前缀查询等。对目录的查询操作,在实现上其实是将目录看作是对相同前缀的key的查询操作。

由于etcd v3 实现了MVCC,保存了每个key-value pair的历史版本,数据量大了很多,不能将整个数据库都放在内存里了。 因此etcd v3摒弃了内存数据库,转为磁盘数据库,整个数据库都存储在磁盘上,底层的存储引擎使用的是BoltDB。

迷你事务

在etcd v2中提供了CAS操作,但是它只能对单个key进行CAS,为了支持多个key的CAS,etcd v3引入了迷你事务。迷你事务支持多个key的比对与赋值。下面就是一个简单的例子:

1
Tx(compare: A=l && B=2, success: C=3, D =3, fail: C=O, D=O)

迁移v3方案

  • 线下迁移:该方案比较简单,需要关闭写服务,然后将etcd v2的数据挨个执行命令导入etcd v3。
  • 线上迁移:启动后台程序,进行v2->v3的迁移,api使用者写操作都向v3的数据源发,读操作先从v3查,如果没查到再从v2查。

技术内幕

etcd使用到的Raft协议之前已经介绍过了,所以这里就不再赘述,所以我们只介绍那些etcd中比较关键的技术点。

MVCC

物理视图

MVCC的每一个写操作都会创建一个新版本的数据,读操作会从有限多个版本的数据中挑选一个“最合适”(要么是最新版本,要么是指定版本) 的结果直接返回。通过这种方式,读写操作之间的冲突就不再需要受到关注。MVCC能最大化地实现高效的读写并发,尤其是高效的读,因此其非常适合etcd这种“读多写少”的场景。

前面已经说过,etcd v3存储的逻辑视图是一个扁平的二进制键空间。该键空间对key有一个词法排序索引,因此范围查询的成本很低。

etcd的键空间可维护多个revision。每个原子的修改操作(例如,一个事务操作可能包含多个操作)都会在键空间上创建一个新的revision。之前revision的所有数据均保持不变。旧版本(version)的key仍然可以通过之前的revision进行访问。同样,revision也是被索引的,因此Watcher可以实现高效的范围watch。revision在etcd中可以起到逻辑时钟的作用。revision在群集的生命周期内是单调递增的。如果因为要节省空间而压缩键空间,那么在此revision之前的所有revision都将被删除,只保留该revision 之后的。

我们将key的创建和删除过程称为一个生命周期。在etcd中,每个key都可能有多个生命周期,也就是说被创建、删除多次。创建一个新key时,如果在当前revision中该key不存在(即之前也没有创建过),那么它的version就会被设置成1。删除key会生成一个key的墓碑,可通过将其version重置为0来结束key的当前生命周期。对key的每一次修改都会增加其version,因此,key的version在key的一次生命周期中是单调递增的。

revison是集群存储状态的版本号,存储状态的每一次更新(例如,写 、删除、事务等)都会让revison 的值加1。ResponseHeader.Revision代表该请求成功执行之后etcd的revision。KeyValue.CreateRevision代表etcd的某个key最后一次创建时etcd的revison, KeyValue.ModRevision则代表etcd的某个key最后一次更新时etcd的revison。verison特指etcd键空间某个key从创建开始被修改的次数,即 KeyValue.Versiono etcd v3 支持的 Get ( …, WithRev(rev)) 操作会获取etcd处于rev这个revision时的数据,就好像etcd的revision还是rev 的时候一样。

etcd将物理数据存储为一棵持久B+树中的键值对。 为了高效,每个revision的存储状态都只包含相对于之前revision的增量。一个revision可能对应于树中的多个key。

B+树中键值对的key即revision, revision是一个2元组(main, sub),其中main是该revision的主版本号(每次事务发起时+1),sub是同一revision的副版本号(事务中每次修改命令+1),其用于区分同一个revision的不同key。 B+树中键值对的value包含了相对于之前revision的修改,即相对于之前revision的一个增量。

B+树按key的字典字节序进行排序。这样,etcd对revision增量的范围查询(range query,即从某个 revision 到 另一个 revision)会很快一一因为我们已经记录了从一个特定revision到其他revision的修改量。etcd v3 的压缩操作会删除过时的键值对。

etcd v3还在内存中维护了一个基于B树的二级索引来加快对key的范围查询。该B树索引的key是向用户暴露的etcd存储的key,而该B树索引的value则是一个指向上文讨论的持久化B+树的增量的指针。etcd的压缩操作会删除指向B树索引的无效指针。

存储实现

我们知道,etcd v3当前使用BoltDB将数据存储在磁盘中。etcd在BoltDB中存储的key是reversion, value是etcd自己的key-value组合,也就是说etcd会在BoltDB中保存每个版本,从而实现多版本机制。

这样的实现方式有一个很明显的问题,那就是如果保存一个key的所有历史版本,那么整个数据库就会越来越大,最终超出磁盘的容量。因此MVCC还需要定期删除老的版本,etcd提供了命令行工具以及配置选项,供用户手动删除老版本数据,或者每隔一段时间定期删除老版本数据,etcd中称这个删除老版本数据的操作为数据压缩(compact)。

了解了etcd v3的磁盘存储之后 ,可以看到要想从BoltDB中查询数据,必须通过reversion,但是客户端都是通过key来查询value的,所以etcd在内存中还维护了一个kvindex,保存的就是key与reversion之间的映射关系,用来加速查询的。kvindex,是基于Google开源的GoJang的B树实现的,也就是前文提到的etcdv3在内存中维护的二级索引。这样当客户端通过key来查询value的时候,会先在kvindex中查询这个key的所有revision,然后再通过revision从BoltDB中查询数据。

为什么用BoltDB

BoltDB是基于B树和mmap的数据库,基本原理是用mmap将磁盘的page映射到内存的page,而操作系统则是通过COW (copy-on-write) 技术进行page管理,通过cow技术,系统可实现无锁的读写并发,但是无法实现无锁的写写并发,这就注定了这类数据库读性能超高,但写性能一般,因此非常适合于“读多写少”的场景。同时BoltDB支持完全可序列化的ACID事务。因此最适合作为etcd的底层存储引擎。

日志与快照

etcd对数据的持久化,采用的是binlog(日志,也称为WAL, 即Write-Ahead-Log)加Snapshot(快照)的方式。

在计算机科学中,预写式日志(Write-Ahead-Log,WAL)是关系数据库系统中用于提供原子性和持久性的一系列技术。在使用WAL的系统中,所有的修改在提交之前都要先写人log文件中。

log文件中通常包括redo信息和undo信息。假设一个程序在执行某些操作的过程中机器掉电了。在重新启动时,程序可能需要知道当时执行的操作是完全成功了还是部分成功了或者是完全失败了。如果使用了WAL,那么程序就可以检查log文件,并对突然掉电时计划执行的操作内容与实际上执行的操作内容进行比较。在这个比较的基础上,程序就可以决定是撤销已做的操作还是继续完成己做的操作,或者只是保持原样。

etcd数据库的所有更新操作都需要先写到binlog中,而binlog是实时写到磁盘上的,因此这样就可以保证不会丢失数据,即使机器断电,重启以后etcd也能通过读取并重放binlog里的操作记录来重建整个数据库。

etcd数据的高可用和一致性是通过Raft来实现的,Master节点会通过Raft协议向Slave节点复制binlog, Slave节点根据binlog对操作进行重放,以维持数据的多个副本的一致性。也就是说binlog不仅仅是实现数据库持久化的一种手段,其实还是实现不同副本间一致性协议的最重要手段。客户端对数据库发起的所有写操作都会记录在binlog中,待主节点将更新日志在集群多数节点之间完成同步以后,便在内存中的数据库中应用该日志项的内容,进而完成一次客户的写请求。

如果一个etcd集群运行了很久,那么就会有很多binlog,这样在故障恢复时,需要花很多时间来复原数据,这时候就需要快照系统,它会把当前存储的当前数据存储下来。然后删除生成快照之前的log内容,这样只需要重现少量的log就能恢复数据了。

etcd v3的日志管理和快照管理的流程与v2的基本一致,区别是做快照的时候etcd v2是把内存里的数据库序列化成JSON,然后持久化到磁盘,而etcd v3是读取磁盘里的数据库的当前版本(从BoltDB中读取),然后序列化到磁盘。

事务

etcd v2只提供了针对单个key的条件更新操作,即CAS(Compare-And-Swap)操作。也就是说,etcd v2只针对单个key提供了原子操作,并不支持对多个key的原子操作,假如有如下这样的场景:客户端需要同时对多个key进行操作,这些操作要么同时成功,要么同时失败,etcd v2将会无法处理。而etcd v3在etcd v2的基础上引人transaction的支持,可以支持涉及多个key的原子操作。

像etcd这样的分布式系统,经常会有客户端进行并发访问。etcd v3的Serializability(可串行化)的事务隔离级别可以保证多个事务并行执行的效果,其与 以某种顺序来执行这多个事务的效果是一样的,因此Serializability可以避免脏读、重复读和幻读的发生。注意,Serializability只保证了以某种顺序执行事务,并不能保证一定要以某个确定的顺序来执行。

etcd v3的API引进了对事务的支持的功能,允许客户端对多个key进行原子操作。etcd v3的事务API类似于下面的代码:

1
Txn().If(condl, cond2, ...).Then(op1, op2, ...).Else(op1, op2,...)

一个事务由以下三部分组成:条件判断语句、条件判断成功则执行的语句、条件判断失败则执行的语句。etcd v3的事务能够保证事务中对多个key进行的操作,要么同时成功,要么同时失败;一个事务中读到的所有数据,在整个事务的生命周期中是不会发生变化的。

实现

etcd的软件事务内存(Software Transactional Memory, STM) API对基于版本号的冲突解决逻辑进行了封装:它自动检测内存访问时的冲突,并自动尝试在冲突的时候对事务进行回退和重试。etcd v3的软件事务内存也是乐观的冲突控制的思路:在事务最终提交的时候检测是否有冲突,如果有则回退和重试;而悲观的冲突控制则是在事务开始之前就检测是否有冲突,如果有则暂不执行。

P1更新a和b的同时,P2在读a和b,当P1的事务提交以后,etcd里数据的版本号会变成{a:2, b:2},然后P2的事务通过STM提交的时候发现,P2的事务刚开始的时候读到a的版本号是1,提交的时候a的版本号却变成了2,所以可以得出如下结论:P2的事务执行过程中一定有其他事务的执行修改了a的数据。进行回退和重试,直到没有冲突为止。
stm

STM系统可以确保的事项具体如下。

  • 事务是原子的,一个事务提交以后,如果该事务涉及了对多个key的操作,那么对多个key的操作要么都成功,要么都不成功。
  • 事务至少具有可重复读取隔离型,以保证不会读到脏数据。
  • 数据是一致的,提交的时候STM会自动检测到数据冲突并重试事务以解决这些冲突。

STM的思路也很简单,它的整个生命周期就是一个乐观锁循环,首先提取condition中的key然后比较condition并保存key的version,然后进行更新逻辑,最后比较前面condition中用到的key是否version发生了变化,如果没有发生变化,则将更新的内容刷到磁盘,否则重试。

Watch

etcd v2的Watch API实际上是一个标准的HTTP GET请求,与一般的请求不同的是,它多了一个”?wait=true”的URL参数。当etcd v2的Server看到这个参数的时候,就知道这是一个watch请求,并且不会立即返回response,而是一直会等到被watch的这个key有了更新以后该请求才会返回。

1
curl http://127.0.0.1:2379/v2/keys/foo&wait=true

值得注意的是,客户端还可以指定版本号来watch。如果客户端指定了版本号,那么服务器端会返回大于该版本号的第一个更新的数据。例如watch的时候可以指定index=7,示例代码如下所示:

1
curl http://127.0.0.1:2379/v2/keys/foo?wait=true&waitindex=7'

上文提到过,客户端可以指定版本号watch,然而服务器端只保留了最新的1000个变更记录。也就是说,如果客户端指定的版本号,是1000个变更记录之前的,则会watch不到。

etcd v2的watch是基于HTTP的long poll实现的,其请求本质上是一个HTTP1.1的长连接。因此一个watch请求需要维持一个TCP连接。这就导致了服务端需要耗费很多资源用于维持TCP长连接。

watch只能watch某一个key以及其子节点(通过参数recursive设置),一个watch请求不能同时watch多个不同的key。

由于watch的历史记录最多只有1000条,因此很难通过watch机制来实现完整的数据同步(有丢失变更的风险),所以当前的大多数使用方式是通过watch来得知变更,然后通过GET来重新获取数据,并不是完全依赖于watch的变更event。

etcd v3 的watch机制在etcd v2的基础上做了很多改进,一个显著的优化是减小了每个watch所带来的资源消耗,从而能够支持更大规模的watch。首先etcd v3的API采用了gRPC,而gRPC又利用了HTTP/2的TCP链接多路复用(multiple stream per tcp connection),这样同一个Client的不同watch可以共享同一个TCP连接。

etcd会保存每个客户端发来的watch请求,watch请求可以关注一个key (单key),或者一个key前缀(区间),所以watchGroup包含两种Watcher:一种是key Watchers,数据结构是每个key对应一组Watcher,另外一种是range Watchers,数据结构是一个线段树,可以方便地通过区间查找到对应的Watcher。

etcd会有一个线程持续不断地遍历所有的watch请求,每个watch对象都会负责维护其监控的key事件,看其推送到了哪个revision。etcd会根据这个revision.main ID去BoltDB中继续向后遍历,实际上BoltDB类似于leveldb,是一个按key有序排列的Key-Value(K-V)引擎,而BoltDB中的key是由revision.main+revision.sub组成的,所以遍历就会依次经过历史上发生过的所有事务(tx)的记录。

对于遍历经过的每个K-V, etcd会反序列化其中的value,也就是实际etcd存储的Key Value,然后判断其中的key是否为watch请求关注的key,如果是就发送给客户端。

然而每次都对单个的watch对象进行扫描效率太差了,实际上etcd在实现的时候会将watch对象分组,然后根据组内的最小revision去检查,这样一次性可以处理多个watcher,减少扫描次数。

参考内容

[1]《云原生分布式存储基石 etcd深入解析》
[2] etcd:从应用场景到实现原理的全方位解读

贝克街的流浪猫 wechat
您的打赏将鼓励我继续分享!
  • 本文作者: 贝克街的流浪猫
  • 本文链接: https://www.beikejiedeliulangmao.top/distributed/etcd/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
  • 创作声明: 本文基于上述所有参考内容进行创作,其中可能涉及复制、修改或者转换,图片均来自网络,如有侵权请联系我,我会第一时间进行删除。