DynamoDB是Amazon的一个高可用的键-值存储系统。用以提供一个“永远在线”可用存储。为了达到这个级别的可用性,DynamoDB在某些故障场景中将牺牲一致性。它大量使用对象版本和应用程序协助的冲突协调方式以提供一个开发人员可以使用的新颖的接口。
在一个分布式的存储系统中,除了数据持久化组件,系统还需要有以下的考虑:
描述解决方案的每一个细节不太可能,所以本文重点是核心技术在分布式系统DynamoDB中的使用:
以下是DynamoDB使用的技术概要和优势:
问题 | 技术 | 优势 |
划分(partitioning) | 一致性哈希 | 增量可伸缩性 |
写的高可用性 | 矢量时钟与读取过程中的协调(reconciliation) | 版本大小与更新操作速率脱钩 |
暂时性的失败处理 | 草率仲裁(Sloppy Quorum)并暗示移交(hinted handoff) | 提供高可用性和耐用性的保证,即使一些副本不可用时。 |
永久故障恢复 | 使用Merkle树的反熵(Anti-entropy) | 在后台同步不同的副本 |
会员和故障检测 | Gossip的成员和故障检测协议 | 保持对称性并且避免了一个用于存储会员和节点活性信息的集中注册服务节点 |
划分算法(Partitioning)
DynamoDB的关键设计要求之一是必须增量可扩展性。这就需要一个机制,用来将数据动态划分到系统中的节点(即存储主机)上去。DynamoDB的分区方案依赖于 一致性哈希 将负载分发到多个存储主机。DynamoDB采用了一致性哈希的变体:每个节点被分配到环多点而不是映射到环上的一个单点。
一致性Hash算法满足以下几个方面:
一致性Hash的基本原理:
简单来说,一致性哈希将整个哈希值空间组织成一个虚拟的圆环,如假设某哈希函数H的值空间为0-2^32-1(即哈希值是一个32位无符号整形),整个哈希空间环如下:
整个空间按顺时针方向组织。0和2^32-1在零点中方向重合。
下一步将各个服务器使用Hash进行一个哈希,具体可以选择服务器的ip或主机名作为关键字进行哈希,这样每台机器就能确定其在哈希环上的位置,这里假设将上文中四台服务器使用ip地址哈希后在环空间的位置如下:
接下来使用如下算法定位数据访问到相应服务器:将数据key使用相同的函数Hash计算出哈希值,并确定此数据在环上的位置,从此位置沿环顺时针“行走”,第一台遇到的服务器就是其应该定位到的服务器。
例如我们有Object A、Object B、Object C、Object D四个数据对象,经过哈希计算后,在环空间上的位置如下:
根据一致性哈希算法,数据A会被定为到Node A上,B被定为到Node B上,C被定为到Node C上,D被定为到Node D上。
下面分析一致性哈希算法的容错性和可扩展性。现假设Node C不幸宕机,可以看到此时对象A、B、D不会受到影响,只有C对象被重定位到Node D。一般的,在一致性哈希算法中,如果一台服务器不可用,则受影响的数据仅仅是此服务器到其环空间中前一台服务器(即沿着逆时针方向行走遇到的第一台服务器)之间数据,其它不会受到影响。
下面考虑另外一种情况,如果在系统中增加一台服务器Node X,如下图所示:
此时对象Object A、B、D不受影响,只有对象C需要重定位到新的Node X 。一般的,在一致性哈希算法中,如果增加一台服务器,则受影响的数据仅仅是新服务器到其环空间中前一台服务器(即沿着逆时针方向行走遇到的第一台服务器)之间数据,其它数据也不会受到影响。
综上所述,一致性哈希算法对于节点的增减都只需重定位环空间中的一小部分数据,具有较好的容错性和可扩展性。
另外,一致性哈希算法在服务节点太少时,容易因为节点分部不均匀而造成数据倾斜问题。例如系统中只有两台服务器,其环分布如下,
此时必然造成大量数据集中到Node A上,而只有极少量会定位到Node B上。为了解决这种数据倾斜问题,一致性哈希算法引入了虚拟节点机制,即对每一个服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称为虚拟节点。具体做法可以在服务器ip或主机名的后面增加编号来实现。例如上面的情况,可以为每台服务器计算三个虚拟节点,于是可以分别计算 “Node A#1”、“Node A#2”、“Node A#3”、“Node B#1”、“Node B#2”、“Node B#3”的哈希值,于是形成六个虚拟节点:
同时数据定位算法不变,只是多了一步虚拟节点到实际节点的映射,例如定位到“Node A#1”、“Node A#2”、“Node A#3”三个虚拟节点的数据均定位到Node A上。这样就解决了服务节点少时数据倾斜的问题。在实际应用中,通常将虚拟节点数设置为32甚至更大,因此即使很少的服务节点也能做到相对均匀的数据分布。
写的高可用性:复制(replication)
为了实现高可用性和耐用性,DynamoDB将数据复制到多台主机上。每个数据项被复制到N台主机。其中N是每实例(per-instance)的配置参数。每个键,K,被分配到一个协调器(coordinator)节点。协调器节点掌控其负责范围内的复制数据项。除了在本地存储范围内的每个Key外,协调器节点复制这些Key到环上顺时针方向的N-1后继节点。这样的结果是,系统中每个节点负责环上的从其自己到第 N 个前继节点间的一段区域。在下图中,节点 B 除了在本地存 储键 K 外,在节点 C 和 D 处复制键 K。节点 D 将存储落在范围(A,B],(B,C]和(C,D]上的所有键。
一个负责存储一个特定的键的节点列表被称为首选列表(preference list)。该系统的设计让系统中每一 个节点可以决定对于任意 key 哪些节点应该在这个清单中。出于对节点故障的考虑,首选清单可以包含起过 N 个节点。请注意, 因使用虚拟节点,对于一个特定的 key 的第一个 N 个后继位置可能属于少于 N 个物理所节点(即节点可以持有多个第一个 N 个 位置)。为了解决这个问题,一个 key 首选列表的构建将跳过环上的一些位置,以确保该列表只包含不同的物理节点。
写的高可用性:版本化数据
DynamoDB提供最终一致性,从而允许更新操作可以异步的传播到所有的副本。put()调用可能在更新操作被所有的副本执行之前就 返回给调用者,这可能会导致一个场景:在随后的 get()操作可能会返回一个不是最新的对象。如果没有失败,那么更新操作的 传播时间将有一个上限。但是,在某些故障情况下(如服务器故障或网络 partitions),更新操作可能在一个较长时间内无法到达 所有的副本。
在 Amazon 的平台,有一种类型的应用可以容忍这种不一致,并且可以建造并操作在这种条件下。例如,购物车应用程序要求 一个“添加到购物车“动作从来没有被忘记或拒绝。如果购物车的最近的状态是不可用,并且用户对一个较旧版本的购物车做了 更改,这种变化仍然是有意义的并且应该保留。但同时它不应取代当前不可用的状态,而这不可用的状态本身可能含有的变化 也需要保留。请注意在 DynamoDB 中“添加到购物车“和”从购物车删除项目“这两个操作被转成 put 请求。当客户希望增加一个项 目到购物车(或从购物车删除)但最新的版本不可用时,该项目将被添加到旧版本(或从旧版本中删除)并且不同版本将在后来协调 (reconciled)。
为了提供这种保证,Dynamo 将每次数据修改的结果当作一个新的且不可改变的数据版本。它允许系统中同一时间出现多个版 本的对象。大多数情况,新版本包括(subsume)老的版本,且系统自己可以决定权威版本(语法协调 syntactic reconciliation)。然而,版本分支可能发生在并发的更新操作与失败的同时出现的情况,由此产生冲突版本的对象。在这种情 况下,系统无法协调同一对象的多个版本,那么客户端必须执行协调,将多个分支演化后的数据崩塌(collapse)成一个合并的 版本(语义协调)。一个典型的崩塌的例子是“合并”客户的不同版本的购物车。使用这种协调机制,一个“添加到购物车”操作是永 远不会丢失。但是,已删除的条目可能会”重新浮出水面”(resurface)。 重要的是要了解某些故障模式有可能导致系统中相同的数据不止两个,而是好几个版本。在网络分裂和节点故障的情况下,可 能会导致一个对象有不同的分历史,系统将需要在未来协调对象。这就要求我们在设计应用程序,明确意识到相同数据的多个 版本的可能性(以便从来不会失去任何更新操作)。
DynamoDB 使用矢量时钟来捕捉同一不同版本的对象的因果关系。矢量时钟实际上是一个(node,counter)对列表(即(节点, 计数器)列表)。矢量时钟是与每个对象的每个版本相关联。通过审查其向量时钟,我们可以判断一个对象的两个版本是平 行 分 枝 或 有 因 果 顺 序 。如果第一个时钟对象上的计数器在第二个时钟对象上小于或等于其他所有节点的计数器,那么第一个是 第二个的祖先,可以被人忽略。否则,这两个变化被认为是冲突,并要求协调。
在 dynamo 中,当客户端更新一个对象,它必须指定它正要更新哪个版本。这是通过传递它从早期的读操作中获得的上下文对 象来指定的,它包含了向量时钟信息。当处理一个读请求,如果 Dynamo 访问到多个不能语法协调(syntactically reconciled)的分支,它将返回分支叶子处的所有对象,其包含与上下文相应的版本信息。使用这种上下文的更新操作被认为已 经协调了更新操作的不同版本并且分支都被倒塌到一个新的版本。
为了说明使用矢量时钟,让我们考虑上图所示的例子。
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)]。
关于向量时钟一个可能的问题是,如果许多服务器协调对一个对象的写,向量时钟的大小可能会增长。实际上,这是不太可能 的,因为写入通常是由首选列表中的前 N 个节点中的一个节点处理。在网络分裂或多个服务器故障时,写请求可能会被不是首 选列表中的前 N 个节点中的一个处理的,因此会导致矢量时钟的大小增长。在这种情况下,值得限制向量时钟的大小。为此, Dynamo 采用了以下时钟截断方案:伴随着每个(节点,计数器)对,Dynamo 存储一个时间戳表示最后一次更新的时间。当向 量时钟中(节点,计数器)对的数目达到一个阈值(如 10),最早的一对将从时钟中删除。显然,这个截断方案会导至在协调时效 率低下,因为后代关系不能准确得到。不过,这个问题还没有出现在生产环境,因此这个问题没有得到彻底研究。
Lamport时间戳与矢量时钟
Lamport timestamps原理如下:
1. 每个事件对应一个timestamp时间戳,初始值为0
2. 如果事件在节点内发生,时间戳+1
3. 如果事件属于发送事件,时间戳+1并在消息中带上盖时间戳
4. 如果事件属于接受事件,时间戳=Max(本地时间戳,消息中的时间戳)+1
Vector clock
Lamport时间戳帮助我们得到时间的顺序关系,但还有一种顺序关系不能用Lamport时间戳很好的表示出来,那就是同时发生关系(concurrent)。
Vector clock是在Lamport时间戳基础上严谨的另一种逻辑时钟方法,它通过vector结构不但记录本节点的Lamport时间戳,同时也记录了其他节点的Lamport时间戳。
Version vector用于发现数据冲突。
Vector clock只用于发现数据冲突,不能解决数据冲突。如何解决数据冲突因场景而异,具体方法有last write win,交给client端处理,通过quorum决议事先避免数据冲突等。
故障处理:暗示移交(Hinted Handoff)
Dynamo 如果使用传统的仲裁(quorum)方式,在服务器故障和网络分裂的情况下它将是不可用,即使在最简单的失效条件下也 将降低耐久性。为了弥补这一点,它不严格执行仲裁,即使用了“马虎仲裁”(“sloppy quorum”),所有的读,写操作是由首选 列表上的前 N 个健康的节点执行的,它们可能不总是在散列环上遇到的那前N个节点。使用暗示移交,Dynamo 确保读取和写入操作不会因为节点临时或网络故障而失败。需要最高级别的可用性的应用程序可以设 置 W 为 1,这确保了只要系统中有一个节点将 key 已经持久化到本地存储 , 一个写是可以接受(即一个写操作完成即意味着 成功)。因此,只有系统中的所有节点都无法使用时写操作才会被拒绝。然而,在实践中,大多数 Amazon 生产服务设置了更 高的 W 来满足耐久性极别的要求。 一个高度可用的存储系统具备处理整个数据中心故障的能力是非常重要的。数据中心由于断电,冷却装置故障,网络故障和自 然灾害发生故障。Dynamo 可以配置成跨多个数据中心地对每个对象进行复制。从本质上讲,一个 key 的首选列表的构造是基 于跨多个数据中心的节点的。这些数据中心通过高速网络连接。这种跨多个数据中心的复制方案使我们能够处理整个数据中心 故障。
处理永久性故障:副本同步
Hinted Handoff 在系统成员流动性(churn)低,节点短暂的失效的情况下工作良好。有些情况下,在 hinted 副本移交回原来 的副本节点之前,暗示副本是不可用的。为了处理这样的以及其他威胁的耐久性问题,Dynamo 实现了反熵(anti-entropy, 或叫副本同步)协议来保持副本同步。Dynamo 在反熵中这样使用 MerkleTree:每个节点为它承载的每个 key 范围(由一个虚拟节点覆盖 key 集合)维护一个单独的 MerkleTree。这使得节点可以比较 key range 中的 key 是否是最新。在这个方案中,两个节点交换 MerkleTree 的根,对应 于它们承载的共同的键范围。其后,使用上面所述树遍历方法,节点确定他们是否有任何差异和执行适当的同步行动。方案的 缺点是,当节点加入或离开系统时有许多 key rangee 变化,从而需要重新对树进行计算。通过更精炼 partitioning 方案,这个问题得到解决。
会员和故障检测
环会员(Ring Membership)
Amazon 环境中,节点中断(由于故障和维护任务)常常是暂时的,但持续的时间间隔可能会延长。一个节点故障很少意味着一 个节点永久离开,因此应该不会导致对已分配的分区重新平衡(rebalancing)和修复无法访问的副本。同样,人工错误可能导致 意外启动新的 Dynamo 节点。基于这些原因,应当适当使用一个明确的机制来发起节点的增加和从环中移除节点。管理员使用 命令行工具或浏览器连接到一个节点,并发出成员改变(membership change)指令指示一个节点加入到一个环或从环中删除 一个节点。接收这一请求的节点写入成员变化以及适时写入持久性存储。该成员的变化形成了历史,因为节点可以被删除,重 新添加多次。一个基于 Gossip 的协议传播成员变动,并维持成员的最终一致性。每个节点每间隔一秒随机选择随机的对等节点, 两个节点有效地协调他们持久化的成员变动历史。当一个节点第一次启动时,它选择它的 Token(在虚拟空间的一致哈希节点) 并将节点映射到各自的 Token 集(Token set)。该 映射被持久到磁盘上,最初只包含本地节点和 Token 集。在不同的节点中存储的映射(节点到 token set 的映射)将在协调成 员的变化历史的通信过程中一同被协调。因此,划分和布局信息也是基于 Gossip 协议传播的,因此每个存储节点都了解对等节 点所处理的标记范围。这使得每个节点可以直接转发一个 key 的读/写操作到正确的数据集节点。
外部发现
上述机制可能会暂时导致逻辑分裂的 Dynamo 环。例如,管理员可以将节点 A 加入到环,然后将节点 B 加入环。在这种情况下,节点 A 和 B 各自都将认为自己是环的一员,但都不会立即了解到其他的节点(也就是A不知道 B 的存在,B 也不知道 A 的存在,这叫逻辑分裂)。为了防止逻辑分裂,有些 Dynamo 节点扮演种子节点的角色。种子的发现(discovered)是通过外 部机制来实现的并且所有其他节点都知道(实现中可能直接在配置文件中指定 seed node 的 IP,或者实现一个动态配置服 务,seed register)。因为所有的节点,最终都会和种子节点协调成员关系,逻辑分裂是极不可能的。种子可从静态配置或配置 服务获得。通常情况下,种子在 Dynamo 环中是一个全功能节点。
故障检测
Dynamo 中,故障检测是用来避免在进行 get()和 put()操作时尝试联系无法访问节点,同样还用于分区转移(transferring