1. 概述
Dynamo以很简单的键值方式存储数据,不支持复杂的查询。Dynamo中存储的是数据值的原始形式,不解析数据的具体内容。Dynamo主要用于Amazon购物车和S3云存储服务。
Dynamo通过组合P2P的各种技术打造了线上可运行的分布式键值系统,下表列出了Dynamo设计时面临的问题及最终采取的解决方案:
2. 数据分布
Dynamo采用一致性哈希将数据分布到多个存储节点中(博文:一天不学习我浑sen难受(一)—一致性哈希/Hash环学习笔记),概括来说:给系统中的每个节点分配一个随机token,这些token构成一个哈希环。执行数据存放操作时,先计算主键的哈希值,然后存放到顺时针方向的第一个大于或者等于该哈希值的token所在的节点。一致性哈希的有点在于节点加入/删除只会影响到在哈希环相邻的节点,而对其他节点没影响。
考虑到节点的异构性,不同节点的处理能力差别很大,Dynamo使用了改进的一致性哈希算法:每个物理节点根据其性能的差异分配多个token,每个token对应一个虚拟节点,每个虚拟节点的处理能力基本相当,并随机分布在哈希空间中。存储时,数据按照哈希值落到某个虚拟节点负责的区域,然后被存储到该虚拟节点所对应的物理节点。
如下图,某Dynamo集群中原有3个节点,每个节点分配3个token。存放数据时,首先计算主键的哈希值,并根据哈希值将数据存放到对应token所在的节点。假设增加节点4,节点token分配情况发生变化,这就实现了自动负载均衡。
为了找到数据所属的节点,要求每个节点维护一定的集群信息用于定位。Dynamo系统中每个节点维护整个集群的信息,客户端也缓存整个集群的信息,因此,绝大部分请求能够一次定位到目标节点。
由于机器或者认为的因素,系统中节点成员的加入和删除经常发生,为了保证每个节点存储的都是Dynamo集群中最新的成员信息,所有节点每隔固定时间,通过Gossip协议的方式从其他节点任意选择一个与之通信的节点。如果连接成功,双方交换各自保存的集群信息。
Gossip协议用于P2P系统中自治的节点协调对整个集群的认识,比如集群的节点状态,负载情况。节点A和B以这样的方式交换对世界的认识:
- A告诉B其管理的所有节点的版本(包括DOWN状态的节点和UP状态的节点)
- B告诉A哪些版本比较旧了,那些版本B有最新的,然后把最新的哪些节点发给A(出于DOWN状态的绩点由于版本没有发生更新所以不会被关注)
- A将B中比较旧的节点发送给B,同时将B发送来的最新节点信息做本地更新
- B受到A发来的最新节点信息后,对本地缓存的比较旧的节点做更新
由于种子节点的存在,新节点加入可以做的比较简单。新节点加入时首先与种子节点交换集群信息,从而对集群有了认识。DHT(Distributed Hash Table,一致性哈希表)环中的原有其他节点也会定期和种子节点交换集群信息,从而发现新节点的加入。
集群不断变化,可能随时有几期下线,因此,么个节点还需要定义通过Gossip协议同其他节点交换集群信息。如果发现某个基点很长时间状态都没有更新,比如距离上次更新的时间间隔超过一定的阈值,则认为该节点已经下线了。
3. 一致性与复制
为了处理节点失效的情况(DHT环中删除节点),需要对节点的数据进行复制:假设数据存储N份,DHT定位到的数据所属节点为K,则数据存储在节点K,K+1,…,K+N-1上。如果第K+i(0<=i<=N-1)台机器宕机,则往后找一台机器K+N临时替代。如果第K+i台机器重启,临时替代的机器K+N能够通过Gossip协议发现,它会将这些临时数据归还K+i,这个过程在Dynamo中叫做数据回传。机器K+i宕机的这段时间里,所有的读写均落入机器【K,K+i-1】和【K+i+1,K+N】中。如果机器K+i永久失效,机器K+N需要进行数据同步操作。一般来说,从机器K+i宕机开始到被认定为永久失效的时间不会太长,积累的写操作也不会太多,可以利用Merkle树对机器的数据文件进行快读同步。
NWR是Dynamo的一个亮点。其中N表示复制的备份数,R表示成功读操作的最少节点数,W表示成功写操作的最少节点数。只要满足W+R>N,就可以保证当存在不超过一台机器故障时候,至少能够读到一份有效的数据。如果重视读效率,可以设置W=N,R=1;如果需要在读写之间权衡,一般可设置N=3,W=2,R=2;当然,如果丢失最后的一些更新也不会有影响的话,也可以选择W=1,R=1,N=4.
NWR看似完美,其实在Dynamo这样的P2P集群中,由于每隔几点存储的集群信息有所不同,可能出现同一条记录被多个节点同时更新的情况,无法保证多个节点之间的更新顺序。为此Dynamo引入向量时钟的技术手段来解决冲入,如下:
Dynamo中的向量时钟用一个【nodes,counter】对表示。其中,nodes表示节点,counter是一个计数器,初识为0,节点每次更新操作加1.首先,Sx对某个对象进行一次写操作,产生一个对象版本D1([Sx,1]),接着Sx再次操作,counter值更新为2,产生第二个版本D2([Sx,2]);之后,Sy和Sz同时对该对象进行写操作,Sy将自身的信息加入向量时钟产生了新的版本D3([Sx,2],[Sy,1]),Sz同样产生了新的版本信息D4([Sx,2],[Sz,1]),这是系统就有了两个冲突的版本。最常见的冲突解决方法有两种,一种是通过客户端逻辑来解决,比如购物车应用;另一种常见的策略是“last write wins”,即选择时间戳最新的副本,然而,这个策略依赖集群内节点之间的时钟同步算法,不能完全保证准确性。
向量时钟不能完美解决冲突,即使N+W>R,Dynamo也只能保证每个读取操作能读到所有的更新版本,这些版本可能冲突,需要进行版本合并。Dynamo只保证最终一致性,如果多个节点之间的更新顺序不一致,客户端可能读取不到期望的结果。这个不一致问题需要注意,因为影响到了应用程序设计和对整个系统的测试工作。
4. 容错
Dynamo把异常分为两种类型:临时性的异常和永久一场。有一些异常是临时性的,比如机器家私;其他异常,如硬盘保修或机器报废,由于其持续时间太长,称为永久性的,容错机制如下:
- 数据回传:在Dynamo设计中,一份数据被写到K,K+1,…,K+N-1这N台机器上,如果机器K+i(0<=i<=N-1)宕机,原本写入该机器的数据转移到机器K+N,如果在指定的时间T内K+i重新提供服务,机器K+N将通过Gossip协议发现,并将启动传输任务,将暂存的数据回传给机器K+i
- Merkle树同步:如果超过了时间T机器K+i还是出于宕机状态,这种异常被认为是永久性的,这时需要借助Merkle树机制从其他副本进行数据同步。Merkle树的同步原理很简单,每个非叶子节点对应多个文件,为其所有的子节点值组合以后的哈希值;叶子节点对应单个数据文件,为文件内容的哈希值。这样,任何一个数据文件不匹配都将导致从该文件对应的叶子节点到根节点的所有节点值不同,每台机器对每一段范围的数据维护一颗Merkle树,机器同步时首先传输Merkle树信息,并且只需要同步根到叶子节点值均不相同的文件
- 读取修复:假设N=3,W=2,R=2,机器K宕机,可能有部分写操作已经返回客户端成功了但是没有完全同步到所有的副本,如果机器K出现永久性异常,比如磁盘故障,三个副本之间的数据一直都不一致。客户端的读取操作如果发现了某些副本版本太老,则启动异步的读取修复任务。该人物合并多个副本的数据,并使用合并后的结构更新过期的副本,从而使得副本之间保持一致。
5. 负载均衡
Dynamo的负载均衡取决于如何给每台机器分配虚拟节点号,即token。由于集群环境的异构性,每台物理机器包含多个虚拟节点,一般有如下两种分配节点号的方法:
- 随机分配:每台物理节点加入时根据其配置情况随机分配S个token。这种方法的负载平衡效果还是不错的,因为自然界的数据大致是比较随机的,虽然可能出项某段范围的数据特别多的情况(如,baidu、sina等域名下的网页特别多),但是只要切分足够细,即S足够大,负载还是比较均衡的。这个方法的问题是可控性比较差,新节点加入/离开系统时,集群中的原有节点都需要扫描所有的数据从而找出数据新节点的数据,Merkle树也需要全部更新,另外,增量归档/备份变得几乎不可能。
- 数据范围等分+随机分配:为了解决上中方法的问题,首先将数据的哈希空间等分为Q=NxS份(N是机器个数,S是每台机器的虚拟节点数),然后每台机器随机选择S个分割点作为token,和这种方法一样,这样方法的负载也比较均衡,并且每台机器都可以对属于每个范围的数据维护一颗逻辑上的Merkle树,新节点加入/离开时只需要扫描部分数据进行同步,并更新这部分数据对应的逻辑Merkle树,增量归档也变得简单。
另外,Dynamo对单机的前后台任务资源分配也做了一些工作。Dynamo中同步操作、写操作重试等后台任务较多。为了不影响正常的读写服务,需要对后台任务能够使用的资源做出限制。Dynamo中维护一个资源授权系统。改系统将整个机器的资源切分成多个片,监控60s内的磁盘读写响应时间,事务超时时间及锁冲突情况,根据监控信息算出机器负载从而动态调整分配给后台任务的资源片个数。
6. 读写流程
Dynamo的写入流程如图:
Dynamo写入数据时,首先,根据一致性哈希算法计算出每个数据副本所在的存储节点,其中一个副本作为本次写操作的协调者,接着,协调者并发地往所有其他副本发送写请求,每个副本将接收到的数据写入本地,协调者也将数据写入本地,当某个副本的写请求失败,协调者会将它加入重试列表不断重试。等到W-1个副本回复写入成功后(即加上协调者共W个副本写入成功),协调者回复客户端写入成功。协调者回复客户端成功后,还会继续等待或者重试,知道所有的副本都写入成功。
Dynamo读取数据流程如下:
Dynamo读取数据时,首先根据一致性哈希算法计算出每个副本所在的存储节点,其中一个副本作为本次读操作的协调者。接着,协调者根据负载均衡策略选择R个副本,并发地向他们发送读请求。每个副本读取本地数据,协调者也读取本地数据。当某个副本读取成功后,回复协调者读取结果。等到R-1个副本回复读取成功后(即加上协调者工R个副本成功),协调者可以回复客户端。这里分为两种情况:如果R个副本返回的数据完全一致,将某个副本的读取结果回复客户端;否则,需要根据冲突处理规则合并多个副本的读取结果。Dynamo系统默认的策略是根据修改时间戳选择最新的数据,当然用户也可以自定义冲突处理方法。读取过程中如果发现某些副本上的数据版本太久,Dynamo内部会异步发起一次读取修复操作,使用冲突解决后的结果修正错误的副本。
7. 单机实现
Dynamo的存储节点包含三个组件:请求协调,成员和故障检测,存储引擎
Dynamo设计支持可插拔的存储引擎,比如Berkerly DB(BDB),MySQL InnorDB等。存储的需求很多,设计成可插拔的形式允许用户根据应用特点选择合适的存储引擎,比如BDB存储的对象大小一般在几十KB之内,而MySQL可以处理更大的对象。用户会根据应用对象大小选择存储引擎,默认为BDB
请求协调组建采用基于事件驱动的设计,每个客户端的读写请求对应一个状态机,系统根据发生的时间及状态机中的状态决定下一步的操作。比如读取操作对应的状态包括:
· 协调者发送读请求到其他节点
· 等待其他节点返回读取结果,最少需要R-1个
· 如果请求其他节点返回失败,需要按照一定的策略重试
· 如果到达时间限制成功的节点仍然小于R-1个,返回客户端请求超时
· 合并协调者及其他R-1个节点的读取结果,并返回客户端,合并的结果可能包含多个冲突版本;如果设置了冲突解决方法,协调者还需要解决冲突
读操作成功返回客户端以后对应的状态机不会立即被销毁,而是等待一小段时间,这段时间内可能还有一些节点会返回过期的数据,协调者将更新这些节点的数据到最新版本,这个过程策划称为读取修复。
8. 总结
Dynamo采用无中心节点的P2P设计,增加了系统可扩展性,但同时带来了一致性问题影响了上层应用。另外,一致性问题也使得异常情况下的测试变得更加困难,由于Dynamo只保证最基本的最终一致性,多客户端并发操作的时候很难预测操作结果,也很难预测不一致的时间窗口,影响测试用例的设计。
总体上看,Dynamo在Amazon的使用场景有限,后续的很多系统,如Simpledb,采用其他设计思路以提供更好的一致性,主流的分布式系统一般都带有中心节点,这样能够简化设计,而且中心节点只维护少量元数据,一般不会成为性能瓶颈。
从Amazon、Facebook等公司的时间经验可以得出,Dynamo及其开元实现Cassandra在实践中受到的关注逐渐减少,无中心节点的设计短期之内难以成为主流。另一方面,Dynamo综合使用了各种分布式技术,在实践过程中可以选择性借鉴。