分布式存储研究——DynamoDB

这篇博客,分享另一个云端巨头:Amazon的DynamoDB数据库的实现论文,“Dynamo: Amazon’s Highly Available Key-value Store”。这也是整个专题的第三篇。

Background

System Assumptions and Requirements

与GFS,Bigtable等一样,DynamoDB有一些适用的场景。文章做出以下假设,并根据这些假设进行系统的设计,会更有针对性:

  • Query Model:读写操作简单,且均基于主键。没有跨多行的操作,也不需要关系语义。存储对象通常在1MB以下。
  • ACID Properties:Dynamo提供的一致性较弱,不保障隔离性(isolation),且只执行单主键(单行)的更新。
  • Efficiency:系统需要满足高效率,需要在性能、成本效益、可用性和Durability保障之间进行权衡。
  • 数据库用于内部系统,不会被针对性攻击,不需要进行鉴权授权。
  • 数据库的目标是扩展到上百个数据节点。

Service Level Agreements (SLA)

在做数据库的性能评估时,亚马逊更倾向于使用99.9%的分布值来进行衡量,代替常用的平均值、中位数、方差期望等等。这和我们在做一些性能测试时很相像,TP999更能反应系统的一般情况,覆盖近乎所有人,而非大部分人的体验。

Design Considerations

CAP理论说明,在区间通信失败或一些网络失败时,数据一致性和高可用性无法同时保证。DynamoDB期望保障数据的最终一致性,牺牲了一定的一致性等级来维护服务高可用。此时就需要对一些修改冲突进行处理。很多传统数据库通常在写时进行冲突处理,保证读的逻辑简洁,这会导致一些无法写到所有分片的写操作失败。而DynamoDB希望能有一个始终可写的数据存储,不希望驳回用户的写请求。那么此时就会把一些冲突处理迁移到读的过程中去。

另一个设计理念,是确立由谁来解决写冲突。DynamoDB设计为可以由服务调用方来使用不同的规则处理修改冲突,也可以将这一职责下放到数据库,由数据库执行一些简单的策略,如“last write wins”。

Dynamo还应该被设计为可以持续扩展,即在原有集群上能水平扩展一个节点,同时不对原系统产生太多的影响。

Dynamo的所有节点职责应该是一致的,也就是没有“主节点”的概念,简化系统的运维工作。这称为“Symmetry”

比Symmentry更进一步,是Decentralization,去中心化,使用点对点而非中心控制的模式。

最后介绍的一个设计理念是“Heterogeneity”,异质性,系统应当有利用基础硬件异质性的能力,如在机器间根据其实际能力进行均衡负载。

异质性这部分更多的是我个人的理解,还需要在后续的阅读中明确是否一致。

Related Work

第一代P2P系统,称为非结构化P2P网络,如Freenet,Gnutella,常用于文件分享,会做分布式存储,但每个请求会尽可能请求所有节点来获取数据。第二代P2P系统,称为结构化网络,可以进行合理正确地路由,快速访问有所需要数据的节点。代表如Pastry和Chord。在此基础上,搭建了不少数据存储系统,如Oceanstore和PAST。

除了P2P系统,也有很多分布式文件存储系统,例如Bayou,Coda,Ficus,Farsite system以及之前研读过的GFS,Bigtable,还有FAB,Antiquity等等。

与所有列举的系统不一样地方:

  • Dynamo强调始终可写;
  • 系统被预期部署在一个所有节点可信任的环境中;
  • 使用Dynamo的应用不需要支持结构性的命名空间或是复杂的关系语义,只需要简单的key-value;
  • Dynamo针对时延敏感应用,需要保证TP999在几百毫秒的级别。

System Architecture

Dynamo针对关键功能所采用的策略见下表:

Dynamo技术

System Interface

系统提供简单的put和get接口来操作数据。get可以获取到一个数据,也可以是一组冲突的数据,交由应用来解决冲突。put则需要加上context信息,包含了一些版本信息等。系统通过对key进行128位的MD5 hash来决定存储数据的服务器。

Partitioning Algorithm

分区算法要求能够动态地将数据平衡分布到各个节点。Dynamo主要通过对key进行hash,值域得到一个环,然后分布到各个节点中去。每个数据会放在hash得到对应的节点,以及其后一个节点上。

这是最基本的哈希一致性算法,其问题是随机分配会导致数据和位置在环上不均匀,同时也忽略了节点性能的异质性。为此Dynamo做了hash算法的变种,每个实体节点会分配多个环中的虚拟节点。这样有几点好处:

  • 如果一个真实节点不可用,其负载会被剩余节点平分(想象每个数据有两个副本,真实节点1可能存储的是虚拟1、4、7节点,挂掉时会访问对应的2、5、8节点分布在三个不同的真实节点上)。
  • 当一个节点恢复可用,或加入到系统中时,各个其他可用节点可以分担数据迁移的压力。
  • 一个节点具体负责的虚拟节点数量可以根据其容量,性能动态调整,可以感受到硬件节点的异质性。

Replication

为了保障数据的高可用性,每条数据都会保留N个副本。当一个数据被分配到一个节点上时,这个节点还会负责将这份数据放到它的前N-1个虚拟节点上。

负责保存同一份数据的节点列表被称为一个“preference list”,为了保障节点故障时数据仍可用,preference list数量通常会多于N。另外,由于使用了虚拟节点的概念,几个虚拟节点可能处在同一个物理节点上,为了解决这一问题,preference list会在环中跳跃式分配,保证整个列表包含不同的物理节点。

此处的跳跃分配方式也不是很清楚。有待后续补充。

Data Versioning

Dynamo保障数据的最终一致性,也就意味着对同一份数据的多个副本传播修改可以是异步的,通常不需要等待所有分片都写完即可向用户返回结果。造成的问题之前也提到过,在一些特定的错误场景,如网络分区波动或服务器宕机等情况下,改动可能无法传递到所有分片。

在亚马逊平台中,有不少应用其实是可以容忍这种错误的,例如添加购物车场景,当用户添加时,请求不会被拒绝,如果此时最新版本的购物车数据不可达,那么就将数据保存在老一些版本的数据中,这两份不同版本的数据会在获取时进行融合。当然,此时会出现的另一个问题是,融合过程中被删除的item可能重新出现在列表中。这个过程要求设计的应用程序能感知并处理修改冲突。

Dynamo使用向量钟(vector clocks)来确定不同版本之间的先后关系。那么此时各个副本中不仅保留了各自的数据,还会保留该数据的多个版本。保留的版本数量有限(如10个),当感知到其他分片上的修改时,判断自己分片上的版本全部低于该修改的最新版本,那么自己分片的内容就会被覆盖。否则,则仍会保留多个版本,待读取时进行冲突解决。

Execution of get() and put() Operations

get和put操作有两种方式来找到对应需要操作的节点:

  • 将请求发送到一个通用的负载均衡器,由它来根据内容进行路由。这种方法应用不需要自己维护连接Dynamo的代码。
  • 使用一个可以感知分区的client库来将自己的请求路由到对应的节点。这种方法延迟更低,因为少了一层转发(见后续描述)。

通常接受get和put操作的是preference list中的第一个节点(如果是通过负载均衡的请求,可能会路由到list中任意一个节点,此时请求会再被转发到第一个节点)。写和读操作涉及N个节点,和很多仲裁系统(quorum system)一样,需要保证 R + W > N。R即读请求时需要收到的分片响应数量,相应的,W即写请求时需要收到的分片回复数量(包含本身处理请求的节点本地读写)。在这种模型中,请求的延迟取决于最慢的R个读或W个写节点。也因此,R和W可以取得尽可能小。

Handling Failures: Hinted Handoff

Dynamo实际上不需要系统保障所有的quorum节点可用。它使用一种“sloppy quorum”的策略。简单来说,一份数据储存在ABC三个节点中。当A节点宕机或不可达时,就临时储存在D节点中。D节点单独有一块区域存储这些本不属于自己的数据,并进行定时轮询。如果发现A节点可用,就将数据传输回去并删掉本地的副本。始终保持三个节点的数据副本。(注意此处选择D不是任意的,应该是在环中最近一个健康节点,这样才能保证故障时读写数据能找到该节点)。

与此同时,Amazon为了保障Dynamo的高可用性,还会将数据存储节点分布在多个数据中心中,数据中心间通过高速通道连接,避免自然灾害等导致一个数据中心同时宕机的风险。当然这样的部署对于小型机构来说是基本不可能的,仍需要依赖于Amazon平台的基础设施。

Handling Permanet Failures: Replica Synchronization

Dynamo使用Merkle tree来进行节点同步,以尽量减少同步时需要传输的数据。Merkle tree是一棵hash树,简单来说,每个节点就是一个单独key的value的hash值,而父节点又是其子节点的hash值。那么在比较两个节点是否不一致时,只需要比较最顶层节点,然后根据比较结果依次向下比较,直到同步所有的不一致叶子节点。而不需要将所有数据拿出来进行比较。每个物理节点会对其上每一个虚拟节点维护一棵单独的Merkle tree。

这个方案的缺点是当有节点加入或离开时,需要重新计算整棵树的hash值。该问题后续有解决方案。

仍然要弄清楚,Hinted Handoff和Replica Synchronization两者的适用场景有什么不同及联系:相较而言,Hinted Handoff适用于节点临时不可用的场景,节点还是会回到集群中来,这一过程几乎是透明的。而Replica Synchronization则适用于节点永久不可用,需要进行数据同步。

Membership and Failure Detection

  • Ring Membership:
    管理员可以通过控制台或浏览器去连接一个Dynamo节点,并发出添加或删除一个节点的指令。处理该指令的节点会将指令写入持久存储的节点改动历史中,一个基于Gossip的协议将改动传播到各个节点,并最终维持各个节点上信息一致:每个节点每秒都会选择一个随机节点,这两个节点之间协商永久存储的节点改动历史。通过协商,每个节点都能更新自己需要负责存储的区域数据。
  • External Discovery:
    在之前介绍的动态添加删除的节点过程中,可能出现逻辑分区,如A,B同时添加进环,它们都认为自己是环中的一员,但还不能立即感知到对方。此时会有一个由外部机制(静态文件定义,或者外部的配置服务)决定的种子节点,所有其他节点会和种子节点交流自己的membership,使得逻辑分区基本不可能出现。
  • Failure Detection:
    当节点A发现节点B不响应其请求时,就会认为节点B失效了,从而将请求发送到可选的其他节点中去,实施Hinted Handoff。同时A节点会不断轮询B,来查看其是否已经恢复。而去中心化的节点失效探查协议使用了简单的类似gossip的协议。这适用于节点的临时失效。而对于明确的节点增加和删除,则不在此协商范围内。

Adding/Removing Storage Nodes

当一个节点被加入到环中时,它就会被随机分配到一系列的token,表示它所承担的存储的key。此时有一批节点就不再需要储存这些key的信息,从而将这些数据传输到新增节点上来。而当节点退出系统时,会实施一个逆过程,将退出节点上的数据重新传输到新计算负责的节点上去。

Dyanmo的实际操作经验表明,这种方法将负载均匀地平摊到各个节点,从而降低延迟,提高效率。另外,通过在起始和目标节点之间加入一个确认回路,可以保证不会收到重复的数据传输。

Implementation

Dynamo中,每个存储节点都有三个关键的软件组成部分:

  • request coordination:使用状态机处理节点接收到的请求(存储N份数据节点的第一个节点进行请求处理),包括将请求发往数据存储的各个节点并收集响应等。系统还会对写请求做一些优化,例如通常写请求紧跟在读请求后,那么响应写请求的节点就协调为前一次读请求响应最快的节点。
  • membership and failure detection
  • local persistence engine:使用插件的方式,允许应用根据自己的数据量来选择本地持久化引擎,包括BDB Transactional Data Store,BDB Java Edition,MySQL,内存buffer。

Experiences & Lessons Learned

不同的应用会使用不同配置的Dynamo,最主要的差别在版本协调逻辑,和读写仲裁节点数量。有以下几个主要模式:

  • Business logic specific reconciliation:基于特定业务逻辑的版本协调逻辑,例如之前举例的购物车应用,需要按照业务来merge或使用其它处理方式处理不同版本的数据结果。
  • Timestamp based reconciliation:与前一点的主要区别在于版本协调逻辑,使用简单的时间戳比较,“last write wins”,后修改的生效。例如用户的session信息。
  • High performance read engine:一些特定场景下,读多写很少,为提高效率,修改R=1,W=N。例如权限管理缓存等。

Dynamo的N,R,W可以随意调节,通常N值设置为3。不少应用的(N,R,W)值设置为(3,2,2)。

Balancing Performance and Durability

Dynamo的TP999为300ms。由于硬件系统的I/O性能一般,以及服务性能取决于最差的那台机器,提供一致高性能的服务不是一件简单的工作。

在一次为期30天(2006年12月)的实验中,服务延迟出现了很明显的每日波动,每天白天请求多,延迟高,夜间请求少,延迟低。同时写请求的延时要高于读请求。TP999在200ms左右。这一结果已经符合预期,但一些系统对性能要求更高,那么就会牺牲一些持久性:在节点中开辟一个内存buffer,所有写请求操作buffer,并通过一个线程定时写到磁盘。读请求也先检查buffer中有没有,没有在读存储引擎。这种方法,即使内存空间很小(容纳一千个对象),也可以成功地将最大延迟从200ms降低到100ms以下。但缺点也显而易见:一旦机器宕机,就会丢失掉buffer中未处理的写请求。那此时的进一步优化就是在多个写的分片中,指定一个进行持久化的写,其它分片写到buffer,保证数据不丢失。同时因为写请求时只需要W个节点响应,W<N时也能享受buffer带来的性能提升。

Ensuring Uniform Load Distribution

Dynamo的负载均衡策略是在不断演进的,分为三个阶段:

Dynamo的分片和放置策略

  1. T random tokens per node and partition by node value:也就是之前介绍的策略。每个节点T个随机token。token是hash空间中的随机值,两个连续token定义了一个range,由于token是随机选取的,它们所代表的range大小可能不同。这个策略的根本问题是数据分片和分片放置的位置纠缠在了一起,具体表现为几个问题:
  • 当一个节点加入时,它需要从其它节点偷来它自己的key range。此时老节点就需要扫描自己的存储,取出合适的数据进行传递。这是比价影响性能的操作,为了保证用户体验,启动一个节点的时间就会变得很漫长(繁忙时需要一天)
  • 当节点加入/离开时,其它节点的key range就会改变,Merkle tree需要重新计算。
  • 这种策略下,key range的随机性导致无法轻松地对整个key space进行snapshot。
  1. T random tokens per node and equal sized partitions:这个策略将hash空间分为Q个大小相等的部分,每个节点被分配到T个随机的token。设置Q >> N且Q >> S*T(S是系统节点数量)。token只是用来创建value和hash空间的映射函数,不决定分割。一个分片被放置在一致性哈希环中遇到的前N个不同节点上。这一策略的好处是:
  • 解耦了数据分片和分片放置的位置。
  • 允许在运行时动态改变放置位置。
  1. Q/S tokens per node, equal-sized partitions:第三种策略同样解耦了分片和放置的位置。每个节点被分配到Q/S个token。当节点退出时,它的token被随机分布到剩余的节点。节点加入时,就从其它节点偷来自己的token。它有以下好处:
  • Faster bootstrapping/recovery:当partition range修复了之后,它们会被存在独立的文件中,一个分片的恢复只需要将对应的文件传输即可,避免了去定位特定的item。
  • Ease of archival:此时分片文件可以独立的进行归档。

Divergent Versions: When and How Many?

判断系统一致性错误的一个有效度量方式是观察应用获取到的,版本有分歧的数据数量。版本分歧通常有两种场景:一是系统中节点宕机,二是系统处理大规模的并发写。

在观察购物车应用的一次24h实验中,99.94%的请求只有1个version,0.00057%有2个version,0.00047%有3个,0.00009%有4个。(实验数据有些对不上100%?)。观察到的主要原因是并发写入,通常触发者是一些自动机器人而非人。

Client-driven or Server-driven Coordination

之前提到Dynamo的数据节点有一个coordination component通过状态机来处理请求。另一种可行的策略是将状态机移到Client端。client定期(经验值是10s)向Dynamo获取节点的membership状态,用这个来直接访问特定的节点。因为数据基本平均地分配在各个节点,也不需要做额外的负载均衡。实验中TP999的优化效果很明显。主要优化点是减少了不必要的负载均衡和随机分配节点后,节点再向数据对应节点发送请求的一跳。

Balancing Background vs. Foreground Tasks

一些后台操作的执行需要保证前台put/get请求的用户体验不受影响。因此Dynamo将所有的后台操作统一集成到一个控制机制中。该controller会去收集前台操作的一些性能指标,以此判断此时是否能将时间片分配给后台请求。

Conclusions

读这篇文章我最大的收获是两点:

  1. 技术上关于一致性哈希的了解,最突出的就是Dynamo数据分片和数据放置位置的策略演进。
  2. 对于理念上的更新:Dynamo将它的目标设置为不拒绝任何写请求,而在读请求时处理冲突,这是一种反向的思维,区别于我们平时希望数据写时尽可能一致,能够进行快速读取。这一点值得学习。

Six Questions

这个技术出现的背景、初衷和要达到什么样的目标或是要解决什么样的问题。

根据论文,DynamoDB需要处理的最直接的挑战是Amazon庞大电商系统的“always-on”体验。Amazon系统的底层是上万台服务器和海量的数据,其中硬件故障不可避免且时时发生。DynamoDB需要为用户营造出一个“永远在服务”的状态。

这个技术的优势和劣势分别是什么,或者说,这个技术的trade-off是什么。

既然说到“always on”,那么在分布式系统的CAP原理中,DynamoDB无疑更注重的是AP模型。当然也就缺乏了一致性C,需要通过一系列软件操作解决冲突,或者保证最终一致性。这是DynamoDB最主要的trade off。而在解决冲突时,DynamoDB将冲突解决放在了读一端,而非写一端。舍弃了更简洁的代码逻辑,增加了用户体验。

同时DynamoDB也是一个典型的NoSQL数据库,使用主键响应大部分需求。

此外DynamoDB的响应速度也做了非常多的优化,因为其出身是响应庞大且即时要求高的电商系统。为此它的成本一直是我们不使用它的一大阻碍。

这个技术使用的场景。

最典型的使用场景,当然也就是Amazon的主要场景:电商。其特点是:规模大,响应时间要求高,可用性要求高,但同时大部分的查询只需要基于Key就可以,没有复杂的关系查询。

技术的组成部分和关键点。

Dynamo实际上是一系列组成集群的,对等的存储节点。存储节点的关键组成部分有三个:

  1. request coodination:协调请求。
  2. membership and failure detection:维持集群,处理错误。
  3. local persistence engine:使用插件来根据需求选择本地持久化引擎。

技术的底层原理和关键实现。

System Architecture一章的表中已经罗列了各关键技术:

  1. 分区:一致性哈希(Consistent Hashing)
  2. 高可用的写:向量钟
  3. 处理临时错误:Sloppy Quorum以及hinted handoff。
  4. 错误恢复:Merkle trees
  5. 错误检查:基于Gossip的协议

已有的实现和它之间的对比。

可比较的系统包括P2P系统:Freenet,Gnutella等,第二代P2P网络:Pastry,Chord,另外还有分布式文件存储系统,包括Bayou,Coda,Ficus,Farsite system,GFS,Bigtable,FAB,Antiquity等。

DynamoDB与它们最主要的一些区别包括:

  1. 强调始终可写
  2. 内部系统,不强调安全性
  3. 使用key-value
  4. 针对时延敏感请求,TP999在300-500ms。