Amazon Dynamo:一个高可用性的键值服务

引言

Amazon Dynamo描述了一个满足高可用性的分布式键值系统,这是一篇非常精彩的论文,这一个不同于Redis集群的分布式键值系统,但它们都有一个共同点,就是它们都有着非常优雅的设计,都完美的解决了摆在作者眼前的现实问题,值得仔细推敲。而且感觉DDIA第五章的无主节点部分虽然没有明说,但是确实是围绕着这篇论文讨论的,这也是个复习的好机会;除此之外这也是我第一次看到实际工程中应用一致性哈希,这样也可以更加深入的看看当时有疑惑的地方;并且其中满足高可用的设计实在可以说是精巧绝伦,如果感兴趣的话非常建议大家阅读这篇论文。

设计需求

对于亚马逊这个体量的公司来说,对于系统的追求不在停留于单单的一致性,极致的可靠性也是一个挑战,Dynamo的设计目标就是提供一个“永远在线”的用户体验,且效率不低,因为论文中提到需要99.9%的响应时间为300毫秒,这显然在保证一定的一致性下是非常困难的一件事情,我们来总结下Dynamo需要满足条件有哪些:

  1. 近乎苛刻的可用性
  2. 稳定的低响应时间
  3. 可扩展,且对服务器影响较小
  4. 简易的关系模型,即键值查询
  5. 操作环境被假定为不怀恶意
  6. 可以利用服务器的异构性
  7. 为了简化配置,每个Dynamo节点应该与它的对等节点有一样的责任
  8. 数据的高可用性大于数据的一致性,短时间的数据不一致是可以容忍的,采用最终一致性来保证数据的高可用

不妨让我们先忘掉论文,来自主设计一个高可用性的键值服务,最后再和Dynamo对比,这样可以更为深入的理解其设计的精妙。

首先最先想到的是主从复制,假设有五个副本,如果要满足在任何情况下都可以写入的话,我们设定W为1,也就是有一个节点收到信息就可以(主节点),但是这样会让我们的一致性非常差,因为根本没有任何一致性的保证,除非在读时也从主节点读,这是最容易想到也是最简单的一种思路。这样有两个问题,单机瓶颈和主节点宕机,这两点都违法了我们的设计前提。因为单主节点总会在主宕机时有一段时间的服务不可用,所以换种思路,不再局限于单主节点,引入无主节点,即分片,我们可以像Redis集群一样构造一个哈希槽,把键映射为一个哈希值,客户端可把请求发送给任何一个节点,每个节点负责一个槽区间,请求根据key值进行转发,发送到应该处理这个key的服务器上,此时我们避免了单机瓶颈,把请求分散到多个服务器上,保证了效率。但是还是有一个问题,就是每一个服务器的仍然是主从复制模型,请求仍然是发向leader,如果leader宕机或者出现分区(没有分区有一半以上节点),服务仍然会不可用,这还是违反了我们的前提。如果我们每一个哈希槽服务器把容错模型从主从复制换为分散查询的链式复制呢?这样我们极大的提升了读的吞吐量,这里有一个很妙的小点子,就是一般CRAQ出现分区时由一个协调中心(zk)选头,在这个我们设计的kv服务中我们可以规定写入节点在的边提供服务,这样在绝大多数情况下(分区或者除了主节点以外的节点宕机)服务不会下线,仅有主节点宕机时服务下线,我们已经很大程度上改善了我们的模型,但可惜还是不满足条件,就是每个槽范围服务器集群的主节点(写入节点)下线还是会使得服务下线。

黔驴技穷了,我的小脑袋瓜子已经干干净净,还是让我们来看看亚马逊是如何完美的解决这个问题的吧!

系统架构

在Dynamo的论文中并没有给出全部的细节设计,毕竟这在当时属于人家的核心竞争力,但是仍给出了很多部分的设计细节,具体如下:

问题解决方案优势
划分一致性哈希可扩展性
写的高可用矢量时钟和读取过程中的协调版本大小于更新速率无关
暂时性的失败Sloppy Quorum and hinted handoff即使在大于一半的副本不可用时仍提供高可用性
成员关系和故障检测Gossip协议保证了对称性,避免了一个职能过高的服务发现节点(类似于Redis)
永久故障恢复使用Merkle tree的反熵用于在后台减少副本同步的数据量

我们讲一一讨论每一个技术点的细节:

一致性哈希

Dynamo中的一致性哈希其实解决了数据划分,容错和容忍服务器的异构性三点问题,我们不再解释什么是一致性哈希,所以不再提及数据划分。其实在第一次见到一致性哈希时很奇怪这如何进行容错呢?最容易想到的是采用每一个节点的主从复制,但这在某一个主节点宕机是还是会造成服务的不可用,不满足我们的要求,解决方案就是每个节点负责环上的从其自己到第N个前继节点间的一段区域,如图:
在这里插入图片描述
在上图中,节点B除了在本地存储键K外,在节点C和D处复制键K。节点D将存储落在范围(A,B],(B,C]和(C,D]上的所有键。对于所有的put操作,所属key范围的N个节点都可以执行,这就保证了在小于系统二分之一的节点宕机时服务(包括主节点)仍然不间断的可用。

还有一个问题,就是服务器节点的异构性,因为每一个服务器的处理能力不一样,如果都代表环上的一个节点,就无视了节点的性能的异质性了,而且仅根据哈希的话化导致节点不均匀的数据分配,进而导致负载均衡的问题,所以Dynamo使用了虚拟节点的概念,它有以下优势:

  1. 如果一个节点不可用(由于故障或日常维护),这个节点处理的负载将均匀地分散在剩余的可用节点
  2. 当一个节点再次可用,或一个新的节点添加到系统中,新的可用节点接受来自其他可用的每个节点的负载量大致相当
  3. 一个节点负责的虚拟节点的数目可以根据其处理能力来决定,顾及到物理基础设施的异质性。

其实我以前一直以为带有“虚拟节点”一致性哈希只有顾及节点异构性的作用,我们来看看前两条是什么意思(图片来源于[2] 侵删):
在这里插入图片描述
上图每一个物理节点都包含两个逻辑节点,每一个颜色代表一个节点,我们来看看加入新节点或者去除节点会发生什么事情:

在这里插入图片描述
我们可以看出原本两个节点之间的数据迁移不再局限于两个节点,所以效率是要高于不带有虚拟节点的一致性哈希的,随着每个节点配置的虚拟节点的数量增加,效率还会再提升,因为一个迁移过程中的参与节点更多了,这意味着迁移过程可以并行执行。

Get与Put操作

我们再来看看在Dynamo中Get与Put操作的过程是怎么样的。

首先因为Dynamo是一个无主节点的架构,所以Dynamo中的任何存储节点都有资格接收客户端的任何对key的get和put操作,这也降低了写操作的响应时间

客户端有两种策略选择一个目标节点:

  1. 通过一个普通的负载平衡器路由请求,它将根据负载信息选择一个节点,客户端和Dynamo解耦,但如果负载均衡的节点不是目标的N个节点之一的话需要转发(像Redis一样)
  2. 使用一个分区敏感的客户端库直接路由请求到适当的协调程序节点,不存在转发过程,但需要客户端维护一个目标节点的视图

N:存储数据冗余副本的节点数
W:在更新结束前,需要发出更新到达信号的冗余副本数
R:一个数据对象进行读操作需要建立的冗余副本的数量

为了保证数据的一致性,Dynamo选择了R + W > N,这在单主节点中已经足够,但是在多主节点中可以会出现问题,为了写操作的低延迟,我们通常不会让W == N,也就是选择一个最终一致性模型,但是这会使得写入出现冲突。如果下次写入的节点恰好是上次操作为得到回复的节点,这两个节点之间就出现了冲突,如何解决呢?

Dynamo使用矢量时钟来解决这个问题。

矢量时钟其实是一个<updater, version>的二元组

updater:更新的执行者
version:本次更新的版本号

这就涉及到一个何时进行版本协调的问题,因为我们的系统中允许出现多个版本,因为我们要保证写的低延迟,我们可以把协调操作交给Get,一次返回几个数据版本,让客户端自己制定合并策略,当然最简单去重了,论文中提到,这会使得添加操作永不丢失,但是已删除的条目可能会”重新浮出水面”,其实我认为后者可以避免,因为操作有因果关系,很容易确定删除的数版本低于还是等于并发的版本。

而且很容易证明当R + W > N时读操作一定可以读到最新版本:

  1. 假设N为奇数,有两个Put操作分别在两个节点进行
  2. 如果2W > N
    1. 2W - N为两个操作的交集,拥有两个版本数据
    2. (N - ( 2W - N )) /2 -> N - W 为仅有一个版本的值
    3. R > N - W,所以至少读到两个版本的数据
  3. 如果2W < N
    1. N - 2W 为最大可能没有任何更新版本的节点
    2. 要读到两个版本,必然需要 R > N-2W + W
    3. R > N - W,所以至少读到两个版本的数据
  4. 所以当R + W > N时操作一定可以读到最新数据

我们来看看一个读操作执行合并的过程:
在这里插入图片描述

  1. 客户端写入一个新的对象。节点(比如说Sx),它处理对这个key的写:序列号递增,并用它来创建数据的向量时钟。该系统现在有对象D1和其相关的时钟[(Sx,1)]。
  2. 客户端更新该对象。假定也由同样的节点处理这个要求。现在该系统有对象D2和其相关的时钟[(Sx,2)]。D2继承自D1,因此覆写D1,但是节点中或许存在还没有看到D2的D1的副本。
  3. 让我们假设,同样的客户端更新这个对象但不同的服务器(比如Sy)处理了该请求。目前该系统具有数据D3及其相关的时钟[(Sx,2),(Sy,1)]。
  4. 接下来假设不同的客户端读取D2,然后尝试更新它,并且另一个服务器节点(如Sz)进行写操作。该系统现在具有D4(D2的子孙),其版本时钟[(Sx,2),(Sz,1)]。一个对D1或D2有所了解的节点可以决定,在收到D4和它的时钟时,新的数据将覆盖D1和D2,可以被垃圾收集。一个对D3有所了解的节点,在接收D4时将会发现,它们之间不存在因果关系。换句话说,D3和D4都有更新操作,但都未在对方的变化中反映出来。这两个版本的数据都必须保持并提交给客户端(在读时)进行语义协调。
  5. 现在假定一些客户端同时读取到D3和D4(上下文将反映这两个值是由read操作发现的)。读的上下文包含有D3和D4时钟的概要信息,即[(Sx,2),(Sy,1),(Sz,1)]的时钟总结。如果客户端执行协调,且由节点Sx来协调这个写操作,Sx将更新其时钟的序列号。D5的新数据将有以下时钟:[(Sx,3),(Sy,1),(Sz,1)]。
    (以上图与操作过程描述来自于论文4.4)

sloppy quorum 和 Hinted Handoff

我们知道Dynamo需要满足“任何情况下的可用性”这一苛刻条件,但是上一节中又说了需要满足quorum才允许写入成功,这不是自相矛盾吗?为了解决这个问题,Dynamo引入了sloppy quorum,即不严格遵守quorum条件,在大于N - W个节点宕机时可以把数据写到不是N个节点里面的节点,参考本文图1:

给定N=3。在这个例子中,如果写操作过程中节点A暂时Down或无法连接,然后通常本来在A上的一个副本现在将发送到节点D。这样做是为了保持期待的可用性和耐用性。发送到D的副本在其原数据中将有一个暗示,表明哪个节点才是在副本预期的接收者(在这种情况下A)。接收暗示副本的节点将数据保存在一个单独的本地存储中,他们被定期扫描。在检测到了A已经复苏,D会尝试发送副本到A。一旦传送成功,D可将数据从本地存储中删除而不会降低系统中的副本总数。

当我们把W配置为1的时候,仅所有节点都宕机才会使得写入服务不可用,可以说完美的满足了对于Dynamo的要求。

gossip协议

虽然论文中没有说,但是我认为每一个节点其实都是存储了key与全局所有节点的映射关系的,这其实是为了准确的数据定位,我们知道一般数据定位有两种情况:

  1. 维护一个全局数据的映射信息,当然在数据量极大时会成为瓶颈,查数据时先查询映射表(当然存在客户端缓存),这样会使得设计简单,例如GFS,BigTable。
  2. 请求任意发送给节点信息,由系统内部计算转发到合适的节点,优点是于客户端完全解耦合,但是系统复杂度增加。例如Redis Cluster。

Dynamo支持后者(当然论文中也提到可以维护一个客户端的映射)。当加入或删除一个元素时不进行广播,而是依靠Gossip协议传播集群信息,一段事件过后全局都会得到这个集群节点改变的消息,这与Redis Cluster如出一辙。

我们可以在论文的4.8.3中看到gossip协议在Dynamo中有三个作用:

  1. 避免在进行get()和put()操作时尝试联系无法访问节点
  2. 分区转移(transferring partition)
  3. 暗示副本的移交

细看这三者,发现它们有一个共通之处,就是都与成员关系有联系,无法访问的节点不应该被转发请求;在成员变更时需要分区转移;当非正常下线的机器恢复时需要进行暗示副本的移交。

所以我们可以得出结论,gossip在Dynamo中的主要作用就是维护集群成员关系。

我并不了解Merkle tree,所以就不讨论它了,以后学了这个来补充这篇文章。


2020.8.29:补充Merkle tree相关

Merkle tree

首先我们不再描述Merkle tree是一个什么样的结构,需要的朋友可参考[6]。

我们知道因为Dynamo中使用了马虎仲裁(sloppy quorum)来实现极致的可用性,而这会带来在某个宕机节点恢复以后的副本间数据的不一致,这是一个非常现实的问题,我们当然不能直接传递所有的数据,因为不一致的数据可能很少,也可能根本没有,这会造成很大的带宽浪费,也很浪费时间。当然我们可以把全部的数据搞成一个哈希,与其他进行对比,在没有不一致的时候没有问题,但在出现不一致的时候我们也只能传递所有的数据,此时使用Merkle tree就可以使我们很快的定位到出现不一致的一些key,这使我们减少了为同步而需要转移的数据量,而且减少在反熵过程中磁盘执行读取的次数(不需要读取全部的数据就可以判断是否一致)。

这里其实有一个问题,就是Dynamo在初始的分片上选择了虚拟节点机制,我们上面提到过这个问题,确实有很多优点,但是会使得在出现某个节点加入或者离开集群的时候会使得很多节点的Merkle tree重新计算,因为其虚拟节点可以遍布整个哈希环上,这里在论文6.2中描述了新的分片机制,也就是把Token(我理解为N个随机值,像虚拟节点一样)和分片机制(以前是根据token划分,现在分为平均的分区,token代表着一系列分区,可能理解有误)解耦合,使得在集群成员改变的时候,只需要转移这些token。这里论文6.2中没有提Merkle tree的事,但4.7却说到6.2描述的机制可以解决集群成员改变时Merkle tree的计算,但是其实很好想,4.8中提到Dynamo对Merkle tree的使用方法是每个节点为它承载的每个key范围(由一个虚拟节点覆盖 key 集合)维护一个单独的MerkleTree,在token和分区解耦以后,集群成员改变这些分区当然不需要改变Merkle tree了,当出现马虎仲裁的时候我们只需要记录下这个key原token所属分区的Merkle tree,待宕机的机器恢复以后比较Merkle tree就可以了。

没想到第一遍看时忽略的Merkle tree 竟然导致了对带有虚拟节点的一致性哈希的抛弃(进而优化),不知平时的学习过程中还有多少像这样一样遗落的知识点。

行之高远,唯心存敬畏而已。

总结

Dynamo给了我一个重新审视分布式架构的角度,随着数据体量的上升,无主的架构几乎是必然的,因为单点必然会带来单点瓶颈,因为垂直扩展是有限度的,但是现在对于无主节点分布式架构资料很少,目前知道的就只有DDIA和Redis cluster和这篇论文了(当然我知道的也不多),所以确实是很有意思且值得深入研究的一篇文章。

参考:

  1. 博文《[导入]Amazon Dynamo论文阅读笔记(序)
  2. 博文《知乎专栏:Dynamo: amazon’s highly available key-value store
  3. 论文《Dynamo: Amazon’s Highly Available Key-value Store》
  4. 博文《分布式键值存储 Dynamo 的实现原理
  5. 博文《Amazon Dynamo论文解读 - Merkle Tree的使用
  6. 博文《Merkle Tree(默克尔树)算法解析
posted @ 2022-07-02 13:17  李兆龙的博客  阅读(233)  评论(0编辑  收藏  举报