1、Motivation
- 高扩展性
- 简单的key-value存储查询
- 高可用,提供“always on”的服务
- 服务器级别的协议保证(Guarantee Service Level Agreements)这种协议类似于:在峰值为每秒500个请求时,保证99.9%的请求响应时间300ms内
2、Design Consideration
- 为了达到高可用,牺牲一致性;
- 在读数据的时候处理数据不一致的冲突;
- 根据应用层的不同需求,指定不同的NRW值,协调可用性和一致性。
3、主要的技术
问题 |
技术 |
优势 |
数据分布(partitioning) |
一致性哈希表 |
高扩展性 |
写的高可用 |
矢量时钟(vector clocks), 读时处理冲突 |
|
节点临时性失效的处理 |
sloppy Quorum & hinted handoff |
一些副本不可用时,提供高可用和持久性的保证 |
节点永久性失效的恢复 |
反熵 & Merkle trees |
后台副本恢复 |
节点成员关系和失效检测 |
基于Gossip的成员协议和失效检测 |
避免用中心节点管理节点成员关系 |
4、数据分布
Dynamo采用改进的consistent hashing。首先介绍一致性哈希表的原理,如下图所示。图1中每个数据对象object和节点node都hash到一个环形的地址空间。数据对象在环形空间上顺时针找到第一个节点,就存放在这个节点上。如图2所示,如果集群中加入一个节点Node D,只需要将object 2从Node C上迁移到Node D即可,其他的数据对象和节点的对应关系保持不变。同理,图3表示节点NodeB离开集群的情况。一致性哈希表的优势显而易见,即尽可能的减少了节点集变化时数据的迁移。
图 1 图 2 图 3
改进1:虚拟节点。
由于节点hash到环形地址空间中,这种随机的映射往往造成,每个节点管理的地址空间大小不一,使得节点负载不均衡。为了缓解这种不均,将一个节点分成多个虚拟节点hash到环形地址空间,数据先找到它对应的虚拟节点,再找到该虚拟节点所属的节点。我们可以看到,上图3中4个object,有3个存放到NodeC,只有一个存放在NodeA。我们将每个节点求多次hash值,即确定多个虚拟节点的位置。如下图4所示,NodeA和NodeC都分成两个虚拟节点分散在环形地址空间中。此时,object1和object2会映射到nodeA的虚拟节点VnodeA2和VnodeA1上,即都存放到NodeA上。这种策略缓解了节点随机分布造成了地址空间大小的不均。
图 4
改进2:固定所有虚拟节点的大小和位置,只改变虚拟节点和节点的对应关系。
上面的方案主要的缺点是,由于虚拟节点位置随机大小随机,如果有新节点加入,需要扫描所有节点上的所有数据对象,判断全部数据对象是否需要迁移,这种全局的扫描造成很大的开销。Dynamo的改进方法是,将整个地址空间平均分成Q个虚拟节点。每个节点分配Q/S个虚拟节点(假设一共有S个节点)。当有节点加入时,从现有节点每个拿出等量的虚拟节点分给新节点;当有节点离开时,将此节点的所有虚拟节点平均分配给余下的节点,保证系统中每个节点始终都有Q/S个虚拟节点。
5、数据副本和Sloppy Quorum
每个数据对象有N个副本,分别存放在N个不同的节点上面。某个数据对象在地址环上顺时针找到N个不同的节点,这N个节点被称为这个数据的preference list。如下图5所示,key 为k的数据对象,它的preference list为节点B、C、D。
图 5
Dynamo保证最终一致性而非强一致性。主要的思路是,对于一个写操作,系统将这个写请求发给所有N个副本,只要W个写请求返回成功,就认为写成功;对于一个读操作,系统将这个读请求发给N个副本,只要R个请求返回成功,就认为读成功。为了保证最终一致性,必须保证R+W>N。可以简单的理解为读操作至少能读到一个最新的数据副本。不同的应用可以根据自己的需求设置不同的R、W、N。因为读写操作的时延取决于R(or W)个副本中最慢的一个,所以通常将R和W设置为小于N的数,来达到提高性能的效果。
6、数据版本和冲突处理
上面讲到一个写操作只要成功写了W个副本就被认为写成功,并且通常W
Dynamo将对数据对象的每一个写操作都作为一次修改,每次修改会产生新的数据版本。通常来说,系统能区分数据的新版本和老版本并自动的合并它们,但是节点失效时的写操作可能会造成多个版本分支,即版本冲突。这种冲突只有应用层才能解决。总的来说,Dynamo保证所有的写操作都写到了某W个副本,应用层负责版本冲突的合并。
矢量时钟(Vector Clocks)用来确定数据版本。vector clocks是一个向量(node,counter)。其中node是发送写请求的节点,即preference list的第一个节点;counter代表写操作的时间,即clocks。每个数据对象,都有各自的vector clocks。如果数据对象的一个版本中的所有vector clocks的counter都小于另一个版本中的,那么这个前一个版本就是后一个版本的祖先。否则,这两个版本为分支版本有冲突。下图是一个vector clocks应用的例子,D1是D2的祖先,D3和D4是两个分支版本,形成版本冲突,在下次写操作时,应用层将它们合并。
应用层在读数据时,获取到冲突的多个版本,就会合并这些版本,并将合并的结果写下去。这种最终一致性,只有一部分应用可以忍受,文章中举例说明了amazon的购物车应用是怎么合并冲突的(吐槽:貌似很难想到还有哪些应用能忍受这样的结果并提供这样的冲突合并功能)。
图 6
7、临时性失效处理
为了保证每次都能写到W个副本,读到R个副本,我们每次读和写都是发送给N个节点。如果这N个节点有节点失效,那么往后继续找一个不同的节点,暂时的代替失效的节点。比如,N=3,某个数据的preference list是节点A、B、C,这时A节点失效。那么对于该数据的写请求,将发送到节点B、C、D上。D暂时取代了A的角色,那些原本应该写到A上的数据存放在D中的一个特定的文件夹中,放在这个特定文件夹中意味着这些数据,不是D本该拥有的,而是别的节点的。D上会启动一个线程,定期的检查A的状态,当发现A恢复后,就将D上存放的那些A上的数据写回到A。这个技术被称为Hinted Handoff。这种策略,保证了节点失效时系统的高可用和数据持久性。
8、永久性失效处理
在节点永久性失效时,需要进行副本同步。为了快速检测副本间的差异,并最小化数据传输量,Dynamo使用Merkle tree。Merkle tree是一个hash tree。这个树的叶子的值是一个数据对象的key,上层节点的值是他的儿子的值的hash。比较两个Merkle tree时,只用比较根节点,根节点相同则这两个树相同,如果根节点不同再比较他的儿子节点,并以log(n)的速度找到哪些叶子节点不相同。
Dynamo的每个节点对它的每一个虚拟节点数据集求一个Merkle tree。某个数据集发生变化时只用比较相应的虚拟节点的Merkle tree。Merkle tree减小了节点间比较数据时的对磁盘的读和网络的传输。
9、节点成员和失效探测
节点成员:
Amazon环境中,节点中断(由于故障和维护任务)常常是暂时的,因此应该不会导致对已分配的分区重新平衡。同样,人工错误可能导致意外启动新的Dynamo节点。基于这些原因,应当适当使用一个明确的机制来发起节点的增加和从环中移除节点。Dynamo使用一个基于Gossip的协议传播成员变动,并维持成员的最终一致性。每个节点每间隔一秒随机选择另一个节点,两个节点协调他们保存的成员变动历史。当一个新节点加入时,它选择它负责的虚拟节点,并将它的虚拟节点表保存到磁盘,之后与其他的节点通过Gossip协议交换协调他们上的虚拟节点表,让每个节点都知道全局的虚拟节点表。
外部发现:
如果A节点和B节点同时加入到集群,根据上面的做法,A和B互相不会知道对方的存在,这种错误称为逻辑分裂。Dynamo中有一些种子节点,Dynamo中的每个节点都知道种子节点,每个节点都与种子节点进行虚拟节点表的协调,这样就避免了逻辑分裂错误。
失效检测:
Dynamo中,失效检测是用来避免在进行读写操作时尝试联系无法访问节点,同样还用于分区转移和临时性失效处理。为了避免在通信失败的尝试,一个纯本地概念的失效检测完全足够了。如果节点B不对节点A的信息进行响应(即使B响应节点C的消息),节点A可能会认为节点B失败。在一个客户端请求速率相对稳定并产生节点间通信的Dynamo环中,一个节点A可以快速发现另一个节点B不响应,并启动临时性失效处理。在没有客户端请求推动两个节点之间流量的情况下,节点双方并不真正需要知道对方是否可以访问或可以响应。去中心化的故障检测协议使用一个简单的Gossip式的协议,使系统中的每个节点可以了解其他节点到达或离开。