引言
前面的文章中,介绍了基于 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 的架构如图所示。
网络层:提供网络数据读写功能,监听服务端口,完成集群节点之间数据通信,收发客户端数据。
Raft 模块: Raft 强一致性算法的具体实现。
存储模块:涉及 KV 存储、WAL 文件、Snapshot 管理等,用于处理 etcd 支持的各类功能的事务,包括数据索引、节点状态变更、监控与反馈、事件处理与执行等,是 etcd 对用户提供的大多数 API 功能的具体实现。
复制状态机:这是一个抽象的模块,状态机的数据维护在内存中,定期持久化到磁盘,每次写请求都会持久化到 WAL 文件,并根据写请求的内容修改状态机数据。除了在内存中存有所有数据的状态以及节点的索引之外,etcd 还通过 WAL 进行持久化存储。基于 WAL 的存储系统其特点就是所有的数据在提交之前都会事先记录日志。Snapshot 是为了防止数据过多而进行的状态快照。
应用场景
服务发现
服务发现要解决的也是分布式系统中最常见的问题之一,即在同一个分布式集群中的进程或服务,要如何才能找到对方并建立连接。本质上来说,服务发现就是想要了解集群中是否有进程在监听 udp 或 tcp 端口,并且通过名字就可以查找和连接。要解决服务发现的问题,需要有下面三大支柱,缺一不可。
一个强一致性、高可用的服务存储目录。基于 Raft 算法的 etcd 天生就是这样一个强一致性高可用的服务存储目录。
一种注册服务和监控服务健康状态的机制。用户可以在 etcd 中注册服务,并且对注册的服务设置 key TTL,定时保持服务的心跳以达到监控健康状态的效果。
一种查找和连接服务的机制。通过在 etcd 指定的主题下注册的服务也能在对应的主题下查找到。为了确保连接,我们可以在每个服务机器上都部署一个 Proxy 模式的 etcd,这样就可以确保能访问 etcd 集群的服务都能互相连接。
微服务协同工作架构中,服务动态添加。随着 Docker 容器的流行,多种微服务共同协作,构成一个相对功能强大的架构的案例越来越多。透明化的动态添加这些服务的需求也日益强烈。通过服务发现机制,在 etcd 中注册某个服务名字的目录,在该目录下存储可用的服务节点的 IP。在使用服务的过程中,只要从服务目录下查找可用的服务节点去使用即可。
PaaS 平台中应用多实例与实例故障重启透明化。PaaS 平台中的应用一般都有多个实例,通过域名,不仅可以透明的对这多个实例进行访问,而且还可以做到负载均衡。但是应用的某个实例随时都有可能故障重启,这时就需要动态的配置域名解析(路由)中的信息。通过 etcd 的服务发现功能就可以轻松解决这个动态配置的问题。
消息发布/订阅
在分布式系统中,最适用的一种组件间通信方式就是消息发布与订阅。即构建一个配置共享中心,数据提供者在这个配置中心发布消息,而消息使用者则订阅他们关心的主题,一旦主题有消息发布,就会实时通知订阅者。通过这种方式可以做到分布式系统配置的集中式管理与动态更新。
应用中用到的一些配置信息放到 etcd 上进行集中管理。这类场景的使用方式通常是这样:应用在启动的时候主动从 etcd 获取一次配置信息,同时,在 etcd 节点上注册一个 Watcher 并等待,以后每次配置有更新的时候,etcd 都会实时通知订阅者,以此达到获取最新配置信息的目的。
分布式搜索服务中,索引的元信息和服务器集群机器的节点状态存放在 etcd 中,供各个客户端订阅使用。使用 etcd 的 key TTL 功能可以确保机器状态是实时更新的。
分布式日志收集系统。这个系统的核心工作是收集分布在不同机器的日志。收集器通常是按照应用(或主题)来分配收集任务单元,因此可以在 etcd 上创建一个以应用(主题)命名的目录 P,并将这个应用(主题相关)的所有机器 ip,以子目录的形式存储到目录 P 上,然后设置一个 etcd 递归的 Watcher,递归式的监控应用(主题)目录下所有信息的变动。这样就实现了机器 IP(消息)变动的时候,能够实时通知到收集器调整任务分配。
系统中信息需要动态自动获取与人工干预修改信息请求内容的情况。通常是暴露出接口,例如 JMX 接口,来获取一些运行时的信息。引入 etcd 之后,就不用自己实现一套方案了,只要将这些信息存放到指定的 etcd 目录中即可,etcd 的这些目录就可以通过 HTTP 的接口在外部访问。

负载均衡
在场景一中也提到了负载均衡,本文所指的负载均衡均为软负载均衡。分布式系统中,为了保证服务的高可用以及数据的一致性,通常都会把数据和服务部署多份,以此达到对等服务,即使其中的某一个服务失效了,也不影响使用。由此带来的坏处是数据写入性能下降,而好处则是数据访问时的负载均衡。因为每个对等服务节点上都存有完整的数据,所以用户的访问流量就可以分流到不同的机器上。
etcd 本身分布式架构存储的信息访问支持负载均衡。etcd 集群化以后,每个 etcd 的核心节点都可以处理用户的请求。所以,把数据量小但是访问频繁的消息数据直接存储到 etcd 中也是个不错的选择,如业务系统中常用的二级代码表(在表中存储代码,在 etcd 中存储代码所代表的具体含义,业务系统调用查表的过程,就需要查找表中代码的含义)。
利用 etcd 维护一个负载均衡节点表。etcd 可以监控一个集群中多个节点的状态,当有一个请求发过来后,可以轮询式的把请求转发给存活着的多个状态。类似 KafkaMQ,通过 ZooKeeper 来维护生产者和消费者的负载均衡。同样也可以用 etcd 来做 ZooKeeper 的工作。

分布式通知/协调
这里说到的分布式通知与协调,与消息发布和订阅有些相似。都用到了 etcd 中的 Watcher 机制,通过注册与异步通知机制,实现分布式环境下不同系统之间的通知与协调,从而对数据变更做到实时处理。实现方式通常是这样:不同系统都在 etcd 上对同一个目录进行注册,同时设置 Watcher 观测该目录的变化(如果对子目录的变化也有需要,可以设置递归模式),当某个系统更新了 etcd 的目录,那么设置了 Watcher 的系统就会收到通知,并作出相应处理。
通过 etcd 进行低耦合的心跳检测。检测系统和被检测系统通过 etcd 上某个目录关联而非直接关联起来,这样可以大大减少系统的耦合性。
通过 etcd 完成系统调度。某系统有控制台和推送系统两部分组成,控制台的职责是控制推送系统进行相应的推送工作。管理人员在控制台作的一些操作,实际上是修改了 etcd 上某些目录节点的状态,而 etcd 就把这些变化通知给注册了 Watcher 的推送系统客户端,推送系统再作出相应的推送任务。
通过 etcd 完成工作汇报。大部分类似的任务分发系统,子任务启动后,到 etcd 来注册一个临时工作目录,并且定时将自己的进度进行汇报(将进度写入到这个临时目录),这样任务管理者就能够实时知道任务进度。

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

分布式队列
分布式队列的常规用法与场景五中所描述的分布式锁的控制时序用法类似,即创建一个先进先出的队列,保证顺序。
另一种比较有意思的实现是在保证队列达到某个条件时再统一按顺序执行。这种方法的实现可以在 queue 这个目录中另外建立一个 /queue/condition 节点。
condition 可以表示队列大小。比如一个大的任务需要很多小任务就绪的情况下才能执行,每次有一个小任务就绪,就给这个 condition 数字加 1,直到达到大任务规定的数字,再开始执行队列里的一系列小任务,最终执行大任务。
condition 可以表示某个任务在不在队列。这个任务可以是所有排序任务的首个执行程序,也可以是拓扑结构中没有依赖的点。通常,必须执行这些任务后才能执行队列中的其他任务。
condition 还可以表示其它的一类开始执行任务的通知。可以由控制程序指定,当 condition 出现变化时,开始执行队列任务。

集群监控/Leader 选举
通过 etcd 来进行监控实现起来非常简单并且实时性强。
前面几个场景已经提到 Watcher 机制,当某个节点消失或有变动时,Watcher 会第一时间发现并告知用户。
节点可以设置 TTL key,比如每隔 30s 发送一次心跳使代表该机器存活的节点继续存在,否则节点消失。
这样就可以第一时间检测到各节点的健康状态,以完成集群的监控要求。
另外,使用分布式锁,可以完成 Leader 竞选。这种场景通常是一些长时间 CPU 计算或者使用 IO 操作的机器,只需要竞选出的 Leader 计算或处理一次,就可以把结果复制给其他的 Follower。从而避免重复劳动,节省计算资源。
这个的经典场景是搜索系统中建立全量索引。如果每个机器都进行一遍索引的建立,不但耗时而且建立索引的一致性不能保证。通过在 etcd 的 CAS 机制同时创建一个节点,创建成功的机器作为 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 是一个简单的 keyvalue 的内存数据库,而 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 的比对与赋值。下面就是一个简单的例子:
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 的条件更新操作,即 CAW( 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 类似于下面的代码:
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 系统可以确保的事项具体如下。
事务是原子的,一个事务提交以后,如果该事务涉及了对多个 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 有了更新以后该请求才会返回。
curl http://127.0.0.1:2379/v2/keys/foo&wait=true
复制
值得注意的是,客户端还可以指定版本号来 watch。如果客户端指定了版本号,那么服务器端会返回大于该版本号的第一个更新的数据。例如 watch 的时候可以指定 index=7,示例代码如下所示:
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]https://www.infoq.cn/article/etcd-interpretation-application-scenario-implement-principle