1. 写在最前面
之前在看 《Designing Data-Intensive Applications》 的时候,涉及到了很多分布式系统相关的知识。当时看的时候,只是脑海里有了些许概念,看到 Dynamo 的论文的时候,感觉很多概念性的知识转变成为了实际应用。觉得很有趣,就按照自己的逻辑将论文拆解一番。拆解涉及到的与书中相关的分布式知识点包括:
- 第五章 — Replication(复制)
- 第六章 — Partitioning (分区)
- 第七章 — Transactions (事务)
- 第八章 — The Trouble with Distributed Systems (分布式系统的麻烦)
- 等……
2. 背景
最近在看《如何阅读一本书》的时候,学到了一个被反复提及的观点—「尽信书则不如无书」。论文一般都应该是最严谨的文章,所以如果能存论文描述的文字之下,抽象出观点和观点论据,才能算是读懂了这篇论文。
虽然只看了 2/3 ,但也是不妨碍笔者用书中的思维拆解的。(ps 可能后面回来的看的时候,观点又会被自己推翻,但那有什么关系呢?反正知识总是需要不断地翻新
2.1 陈述
Amazon 峰值用户达到几千万。
支持这样体量用户规模的底层系统由高度去中心化、松耦合的、面向服务的架构的几百个服务组成。每个服务可能都会面临各种各种的底层问题。
上述几百个服务,可能部署在多个数据中心,每个数据中心,每时每刻都会有磁盘挂掉、路由抖动的问题。更糟糕的状况是数据中心直接被摧毁。
目标:Amazon 的软件系统要将故障视为正常的、可预期的行为,不应因为设备故障而影响可用性和性能。比如,服务自身需要通过各种技术方案的组合,确保就是数据中心被飓风摧毁,仍要保证用户能够正常进行购物车的添加和删除。
2.2 推导
需要一个「永远可用」(always available)的存储服务,来支撑保持永远可读写的请求。Amazon 开发的存储技术:
- S3 (Simple Storage Service)
- Dynamo — 本文的主角
注:写到这里,突然想到可以对比下 S3 和 Dynamo 的区别,不过这个又是另外一个故事了,留给后面有时间在写吧。
「一个系统的可靠性和可扩展性取决于如何管理它的应用状态」,Dynamo 服务是这一点的实践总结的实践者。
2.3 特性总结
Dynamo 的依赖以下技术方案的总结来实现其可扩展性和高可用性:
- 数据通过一致性哈希分散和复制
- 通过对象版本化实现一致性
- 副本之间的一致性有一种类似仲裁的技术和一个去中心化的副本同步协议保证
- gossip-based 分布式故障检测和成员检测协议
3. 为什么要设计 Dynamo
上一小节,只是针对于 Dynamo 的需求做了大概的总结陈述。下面将会对需求如何被发现,以及设计方案时的取舍做描述。
在生产系统使用的关系型数据库来存储状态,并不是理想的方式。因为对很多持久状态的存储需求来说,关系型数据库提供了很多功能,比如复杂查询和管理功能。这些功能可能并不被用户所需要,但是用户却必须为这些功能提供昂贵的硬件支持和人员维护。
注:关系型数据库一般是通过牺牲可用性来换一致性,且其复制功能很受限。
Dynamo 在设计之初,就致力于设计为用户可配置的存储系统。用户可以通过配置文件,在可用性和一致性之间做取舍。即一个系统的可靠性和可扩展性取决于如何管理它的应用状态。
注:每个使用 Dynamo 的服务,使用的都是它们独立的一套 Dynamo 系统。不懂就问,这种独立集群的部署方式,难道不会增加运维成本吗?
3.1 需求假设
3.1.1 KV 存储模型
查询模型:通过唯一的 Key 对数据进行读写。Value 存储为二进制对象(binary objects, eg:blobs)的格式。
注:任何查询操作都不会跨多个数据单元,存储的都应该是相对较小的文件(一般小于 1MB)
3.1.2 ACID 特性抉择
如果能够给可用性带来很大的提升,那么牺牲一些一致性是符合设计哲学的。
注:Dynamo 不支持 ACID 特性,不提供任何隔离保证,只允许带单个 Key 的更新操作。
3.1.3 服务指标
基于对 Amazon 其他服务指标的参考,Dynamo 指标的衡量需要在百分位值在 P99.9 分位进行。
注:使用 Dynamo 的服务要有配置 Dynamo 的能力,以便其在性能、成本效率、可用性和持久性之间取得折中。
3.1.4 安全方面的考虑
Dynamo 定位是 Amazon 内部使用的,内网环境可以视为安全的,因此不需要考虑认证和鉴权等安全方面的问题。
3.2 SLA 的选择
对于面向性能的 SLA,业内一般使用平均值、中位数和方差来描述。但是上述的指标无法解决长尾分布的问题。例如,如果使用了个性化推荐技术,那么随着用户访问历史变多,他被判断推荐的结果也会边长,那么其性能分布最终最终会落入长尾区。
Amazon 为了解决上面长尾的问题,使用了 P99.9 分布。99.9% 这个精度是经过大量实验分析得出,其兼顾了成本和性能的两方面考虑。
注:Amazon 的生产环境实验显示,P99.9 的分布,比均值或中位数有着更好的用户体验。
3.3 设计的考虑点
商业系统中的数据复制算法一般都是同步的,以提供一个强一致性的数据访问接口。为了达到强一致的目的,必须被迫放弃某些故障场景下的可用性。例如,如果数据有冲突,会禁止访问这个数据,直到数据的不一致性完全得到解决。
注:分布式系统无法同时 CAP 三个特性,因此系统在设计时,必须考虑什么场景下选择满足什么特性。
在服务器和网络故障较高的场景下,可以通过乐观复制来增强其可用性,即后台将数据同步到其他节点,允许并发更新及失联的情况。但这会带来两个问题:
- 并发写入的冲突问题,何时被解决掉?
- 并发写入的冲突问题,谁来解决掉?
注:Dynamo 被设计为一个最终一致的数据仓库,即,最终所有的更新会应用到所有的副本。
3.3.1 何时解决冲突
设计时的一个重要考虑是:何时解决更新冲突,比如,是读的时候还是写的时候。
-
传统的数据仓库是在写的时候解决冲突,这样可以保证读的时候复杂度很低。但是带来的限制是如果数据仓库不能访问所有副本(或大多数的),写就会被拒绝。
-
Dynamo 的设计目标是提供一个「永远可写」的数据仓库。这个需求使解决冲突的复杂度放到了读操作,以保证写永远不会被拒绝。
注:对很多 Amazon 服务来说,拒绝写入会造成很差的用户体验,即使发生服务器或网络故障,也应该允许用户往购物车添加或删除商品。
3.3.2 谁来解决冲突
考虑到解决冲突的对象时,数据仓库和应用都可以做这件事。下面分析不同解决对象的优缺点:
-
数据仓库来解决,选择受限,数据仓库只能使用一些简单策略,比如「最后一次写有效」,但这种简单策略往往不具有很高的通用性
-
应用来解决,应用可以选择对用户体验最好的冲突解决算法,比如,购物车应用可以选择「合并」冲突的版本,返回一个合并后购物车。
注:但是实际的应用开发者,可能受限于开发周期的限制,并不想自己实现一套冲突解决机制,因此这种情况下,解决冲突的问题就放给了数据仓库。
3.3.3 其他设计原则
-
增量扩展性:应当支持逐机器(节点)扩容,而且对系统及运维人员带来的影响尽量少。
-
**对称性:**每个节点的职责应该是相同的,不应当出现某些节点承担特殊职责或特殊角色的情况。对称性简化了系统的交付和运维。
-
去中心化:「去中心化」是「对称性」的进一步扩展,系统应该是去中心化的,点对点的,而不应该是集中式控制的。
注:我们应该在集中化的故障率和去中心化的可用性之间做权衡。
-
**异构性:**系统要能够利用到基础设施的异构性。例如,负载的分布要和存储节点的能力成比例,节点配置越高的负载分布也应该越高。
4 对比现存的分布式系统
4.1 从点对点系统分析
可以从点对点系统解决数据存储和分散的思路入手,为后续设计存储方案提供参考。
4.1.1 P2P 系统
在第一代 P2P 系统中,链路都是随机建立的。缺点是,一次查询请求通常是泛洪(flood)到整张网络,找到尽量多的共享这个数据的节点。
4.1.2 结构化 P2P 系统
P2P 网络的下一代是结构化 P2P 网络。这种网络使用了全局一致性协议,保证任何一个节点可以高效地将查询请求路由到存储这个数据的节点。
注:Pastry 和 Chord 系统利用路由机制可以保证查询在若干(有上限)跳之内收到应答。
为了减少多跳路由带来的额外延迟,一些 P2P 系统使用了 O(1) 路由机制,在这种机制中,每个节点维护了足够多的路由信息,因此它可以将请求在常量跳数内路由到合适的对端节点。
注:很多存储系统在设计的时候,都参考了这种思路,比如 Oceanstore 和 PAST。
Oceanstore 处理冲突的方式是:对并发更新进行排序,将排好序的若干个更新作为原子操作应用到所有副本。Oceanstore 是为在不受信的基础设施上做数据复制的场景设计的。
4.2 分布式文件系统与数据库
和 P2P 存储系统只支持扁平命名空间相比,典型的分布式文件系统都支持层级化的命名空间。
- Ficus 和 Coda 这样的系统通过文件复制来提高可用性,代价是牺牲一致性。
- Farsite 是不使用中心式服务器(例如 NFS)的分布式文件系统,它也是通过复制来实现高可用和高扩展。
- Google File System 是另一个分布式文件存储,用于存储 Google 内部应用的状态数据。GFS 的设计很简单,一个主节点管理所有元数据,数据进行分片,存储到不同数据节点。
- Bayou 是一个分布式关系型数据库系统,允许在失联情况下进行操作,提供最终一致性。
注:Bayou、Coda 和 Ficus 都支持失联情况下进行操作,因此对网络分割和宕机都有很强的弹性。在解决冲突方面。Coda 和 Ficus 在系统层面解决,Bayou 则是在引用层面解决。提供最终一致性。
传统的复制型关系数据库系统都将关注点放在保持副本的强一致性。虽然强一致性可以给应用的写操作提供方便的编程模型,但是导致系统的扩展性和可用性非常受限,无法处理网络分割的情况。
4.3 讨论
总结下来,Dynamo 要解决的需要与前面提到的集中式存储系统都不同:
- Dynamo 针对的主要是需要「永远可写的」数据仓库应用,即使发生故障或并发更新,写也不应该被拒绝。
- Dynamo 构建在受信的、单一管理域的基础设施上。
- 使用 Dynamo 的应用没有层级命名空间的需求,也没有复杂的关系型 schema 的需求。
- Dynamo 是为延迟敏感型应用设计的,至少 99.9% 的读写操作都要在几百毫秒内完成。
注:为了达到如此严格的响应要求,需要使用零跳分布式哈希表(zero hop DHT),每个节点在本地维护了足够多的路由信息,能够将请求直接路由到合适节点。
5. 生产级系统的考虑点
生产级别的存储系统的架构是很复杂的。除了最终存储数据的组件之外,还有针对以下问题做设计:
负载均衡、成员管理、故障检测、故障恢复、副本同步、过载处理、状态转移、并发和任务调度、请求 marshlling 、请求路由(routing)、系统监控和告警、以及配置管理等。
在一篇文章里详细介绍上述提及的每一个点,是不现实的,本文主要关注以下几项 Dynamo 用到的分布式系统核心技术:
- partitioning (分区,经哈希决定将数据存储到哪个节点)
- replication(复制)
- versioning(版本化)
- membership(成员管理)
- failure handing(故障处理)
- scaling(规模扩展)
以下总结了 Dynamo 解决问题使用的技术及每项技术的好处:
- 分区:
- 技术:一致性哈希
- 好处: 增量可扩展性
- 高可用
- 技术:读时协调的向量时钟
- 好处:版本大小与更新速率解耦
- 短暂故障处理:
- 技术:宽松的选举和 hinted handoff (移交给其他节点处理,附带提示信息)
- 好处:部分副本不可用时,仍可以提供高可用性和持久性
- 永久故障恢复:
- 技术: 基于 Merkle tree 的逆熵
- 好处:在后台同步不同的副本
- 成员管理和故障检测
- 技术:基于 Gossip 的成员管理协议和故障检测
- 好处:保持架构的对称性,无需一个中心组件来存储成员和节点的状态等信息
5.1 系统接口设计
Dynamo 存储键值对象的接口非常简单,它提供两个操作:
-
put()
-
get ()
get ():会定位到存储系统中的 key 对应的所有对象的副本,返回对象 —可能是单个对象,也可能是一个对象列表+ 一个 context (上下文)
put():确定对象应该存放的位置,然后写到相应的磁盘
注:context 包含了系统中对象的元数据,例如对象的版本,context 和对象存储在一起,这样的系统很容易验证 put 请求的 context 是否合法。
Dynamo 将调用方提供的 key 和 value 都视为不透明的字节序列。它对 key 引用 MD5 哈希得到一个 128bit 的 ID,并根据这个 ID 计算应该存储到哪些节点。
5.2 数据分散算法
Dynamo 的核心需求之一是:系统必须支持增量扩展。这就要求有一种机制能够将数据分散到系统的不同节点上。
一致性哈希:哈希函数输出的是一个固定的范围,通常作为一个循环空间,或称环。每个节点都会随机分配一个在这个循环空间内的值,这个值代表了节点在这个环上的位置。比如:
- 首先对的 key 做哈希得到一个哈希值
- 然后,在环上沿着顺时针方向找到第一个所带的值比这个哈希值更大的节点。
即,每个节点都要负责环上从它自己到它的下一个节点之间的区域。一致性哈希的好处是:添加或删除节点只会影响相邻节点,其他节点不受影响。
注:初级的一致性哈希算法没有考虑节点异构的问题,可能会导致数据和负载非均匀分布。Dynamo 在设计的时候,使用了虚拟节点(virtual node )的概念,解决这个问题。
5.3 数据复制
为了实现高可用性和持久性,Dynamo 将数据复制到多台机器上,每个数据会被复制到 N 台机器,这里的 N 是每套 Dynamo 可以自己配置的。
每个 key K,会被分配一个 coordinator (协调者)节点。coordinator 负责落到它管理的范围内的数据的复制。它除了自己存储一份之外,还会在环上顺时针方向的其他 N-1 个节点存储一份副本。因此在系统中,每个节点要负责从它自己往后的一共 N 个节点。
如下图:Node B、C、D 负责存储落在 [A、B] 范围内的键值对
存储某个特定 key 的所有节点组成一个列表,称为 preference list (优先列表)。Dynamo 的设计是,对于给定的 key,每个节点都能决定哪些节点可以进入这个列表。为了应对节点失败的清理,preference list 会包含多余 N 个节点。
注:由于 dynamo 在设计上引入了虚拟节点,preference list 在选择节点的时候会跳过一些位置,以保证 list 里面的节点都在不同的物理节点上。
5.4 数据版本化
Dynamo 提供最终一致性,所有更新操作会异步的传给所有的副本。
注:put 操作返回时,更新的数据可能还没有应用到所有副本,因此有可能出现 get 操作拿不到最新数据的情况。在没有故障的情况下,传递更新的耗时有一个上限。但是在特定故障场景下,更新可能会在限定的时间内无法传递到多有副本。
5.4.1 如何解决更新冲突
为了满足以上需求,Dynamo 将每次修改结果都作为一个新的、不可变的版本。即,允许系统中同时存在多个不同版本。
冲突调和(使一致化)方式
- 基于句法的调和(syntactic reconciliation)
- 基于语义的调和(syntactic reconciliation)
**在大部分情况下,新版本都包含老版本的数据,而且系统自己可以判断哪个是权威版本。**但是,在发生故障并且存在并发更新的场景下,版本会发生分叉。系统本身无法处理这种情况,需要客户端介入,将多个分支合并成一个。
向量时钟
Dynamo 使用向量时钟来跟踪同一个对象不同版本的因果性。
一个向量时钟就是一个(node, counter)列表。一个向量时钟关联了一个对象的所有版本,可以通过它来判断对象的两个版本是否在并发的分支上,或者它们是否有因果关系。
例子:如果对象的第一个时钟上的所有 counter 都小于它的第二个时钟上的 counter,那么第一个时钟就是第二个时钟的祖先,可以安全的删除;否则,这个两个修改就是有冲突的,必须显示的解决。
在 Dynamo 中读写都必须带上 context :
-
写时,必须指明基于哪个版本进行更新。流程是执行读操作,拿到 context,其中包含了 vector clock 信息,然后写的时候带上这个 context
-
读的时候,如果有多个版本,那它就会返回所有版本,并在 context 中附带各自的 vector clock 信息。基于 context 指定版本更新方式解决冲突,将多个分支重新合并为一个唯一的新分支
注:这个思路跟 git 的多分支合并很像。
一个具体的例子
通过下图来展示 vector clock 是如何工作的:
在 D5 中客户端把 D3 和 D4 都读到了,并根据 context 综合 D3 和 D4 的 clock,然后得到唯一一致的顺序即[(Sx, 2), (Sy, 1), (Sz, 1)]
Vector clock 的潜在问题
一个潜在的问题是:如果多个节点先后 coordinate 同一个对象来执行写操作,那么这个对象的 clock vector 会变得很长。
注:在实际情况中这不太可能发生,因为写操作只会由 preference list 中前 N 个节点中的一个来执行。只有当网络分裂或者多台服务器挂掉的情况下,才会出现上述问题,但是可以通过限制 vector clock 的长度来解决这个问题。
5.5 get() 和 put() 的执行过程
在 Dynamo 中,任何存储节点都可以接受任何 key 的 get
和 put
操作请求。
注:本节先介绍无故障情况下的读写操作执行过程,下一节介绍有故障的情况:
get 和 put 操作由 Amazon 基础设施相关的请求处理框架发起,使用 HTTP。 客户端有两种选择:
- 将请求路由到负载均衡器,由后者根据负载信息选择一个后端节点
- 使用能感知 partition 的客户端,直接将请求路由到某 coordinator 节点
注:第一种方案的好处是不需要了解任何 Dynamo 相关的代码,第二种方案的好处是延迟更低,因为跳过了一次潜在转发步骤。
读写操作仲裁算法
为了保证副本的一致性,Dynamo 使用了一种类似仲裁系统(quorum systems)的一致性协议。 这个协议有两个配置参数:R
和 W
:
R
:允许执行一次读操作所需的最少投票者W
:允许执行一次写操作所需的最少投票者
设置 R + W > N
,就得到了一 个类似仲裁的系统。
注:
R
或W
至少有一个超过半数 N/2,在这种模型下,一次get
(或put
)的延迟由R
(或W
)个副本中最慢的一个决定。因此,为了降低延迟,R
和W
通常设置的比N
小。
读和写过程
-
当收到一个 put() 请求后,coordinator 会为新版本生成 vector clock,并将其保存到节点本机;然后将新版本发送给 N 个排在后面的、可到达的节点。只要至少 w-1 个节点返回,这次写操作就认为是成功的。
-
当收到一个 get() 请求后,coordinator 会向排在后面的 N 个可访问的节点请求这个 key 对应的数据版本。等到 R 个响应之后,就将结果返回给客户端。
注:如果 coordinator 收到了多个版本,则会返回给客户端由客户端来解决冲突。
5.6 短时故障处理
Dynamo 采用了一种宽松的仲裁机制(sloppy quorum):所有读和写操作在 preference list 的前 N 个健康节点上执行
注:这 N 个节点不一定就是前 N 个节点, 因为遇到不健康的节点,会沿着一致性哈希环的顺时针方向顺延。
以上图为例,其中 N=3。
-
如果 A 临时不可用,正常情况下应该到达 A 的写请求就 会发送到 D。发送到 D 的副本的元 数据中会提示(hint)这个副本本来应该发送给谁这里是 A)
-
这个数据会被 D 保存到本地的一个独立数据库中,并且有一个定期任务不断扫描,一旦 A 可用了,就将 这个数据发送回 A,然后 D 就可以从本地数据库中将其删除了,这样系统内的副本数还 是保持不变。
5.7 永久故障处理:副本跨数据中心同步
Dynamo 实现一种逆熵「副本同步」协议来保证副本之间是一致的。
注:可能会出现临时副本在同步时不可用的情况。
5.7.1 Merkle Tree
为了实现快速检测副本之间的不一致性,以及最小化转移的数据量,Dynamo 使用了 Merkle trees
一个 Merkle tree 就是一个哈希树,其叶子节点是 key 对应的 value 的哈希值。 父节点是其子节点的哈希。
Merkle tree 的主要优点是:
- 每个分支都可以独立查看(check),节点无需下载整棵树或者整个数据集
- 减少检查副本一致性时所需传输的数据量
即:
- 如果两棵树的根节点的哈希值相同,那这两棵树的叶子节点必然相同,这两台 node 之间就无需任何同步
- 否则,就说明两台 node 之间的某些副本是不同的,这种情 况下两台 node 就需要交换树的子节点哈希值,直到到达叶子节点,就找到了未同步(out of sync)的 key。Merkle tree 最小化了同步时需要转移的数据量,减少了逆熵过程中 读取磁盘的次数。
5.8 节点成员管理和故障检测
管理员通过命令行或 web 方式连接到 Dynamo node,然后下发 一个成员变更命令,来将这个 node 添加到 ring 或从 ring 删除。
负责处理这个请求的 node 将成员变动信息和对应的时间写入持久存储。成员变动会形成历史记录,因为一个节 点可能会多次从系统中添加和删除。Dynamo 使用一个 gossip-based 的算法通告成员变动信息,维护成员的一份最终一致视图。
5.8.1 系统外部发现和种子节点
以上机制可能导致 Dynamo ring 在逻辑上临时分裂:
例如,管理员先联系 node A,将 A 将入 ring,然后又连续将 node B 加入 ring。在这种情况下,A 和 B 都会认为它们自己是 ring 的成员,但不会立即感知到对方。
为了避免逻辑分裂,我们会将一些 Dynamo 节点作为种子节点。种子节点是通过外部机 制(external mechanism)发现的,所有节点都知道种子节点的存在。因为所有节点最终都会和种子节点 reconcile 成员信息,所以逻辑分裂就几乎不可能发生了。
注:种子或者从静态配置文件中获取,或者从一个配置中心获取。
5.8.2 故障检测
故障检测在 Dynamo 中用于如下场景下跳过不可达的节点:
get()
和put()
操作时- 转移 partition 和 hinted replica 时
要避免尝试与不可达节点通信,一个纯本地概念(pure local notion)的故障检测就 足够了:节点 B 只要没有应答节点 A 的消息,A 就可以认为 B 不可达(即使 B 可以应答 C 的消息)。
注:在没有持续的客户端请求的情况下,两个节点都不需要知道另一方是否可达。
5.9 添加/移除存储节点
当一个新节点 X
加入到系统后,它会获得一些随机分散在 ring 上的 token。对每 个分配给 X
的 key range,当前可能已经有一些(小于等于 N
个)节点在负责处理了 。因此,将 key range 分配给 X
后,这些节点就不需要处理这些 key 对应的请求了,而 要将 keys 转给 X
。
6. 碎碎念
就写到这里吧,今天有其他的事情要做,剩余的几章等有空的时候继续读一下:
- 渐渐成长、温柔、安静、努力。
- 放弃自己的力量最常见的方式,就是认为自己毫无力量
- 会有沮丧失意的时候,但经常会跟自己讲,如果在这一件事情上运气不好,那一定会在别的什么事情上还回来,所以没什么好难过的,人要学会往前走,走下去,就总能看到更多更好的风景了。