CRAQ


摘要(Abstract)

大型存储系统通常会在许多可能出故障的组件上进行数据复制和数据分区,从而保证可靠性和可扩展性。但是许多商业部署系统为了实现更高的可用性和吞吐量,牺牲了强一致性,特别是那些实时交互系统。

本论文介绍了CRAQ的设计、实现和评估。CRAQ是一个挑战上述不灵活的权衡的分布式对象存储系统。我们的基本方法是对链式复制进行改进,在保证强一致性的同时,大幅提高读取吞吐量。通过在所有对象副本上分配负载,CRAQ可以随链的大小线性扩展,而无需增加一致性协调。同时,为了满足某些应用程序的需求,CRAQ提供了弱一致性保证,这在系统处于高故障时期尤其有用。本文探讨了为跨多个数据中心进行地理复制的CRAQ存储而进行的额外设计和实现,从而提供优化局部性的操作。本文也讨论了多对象原子更新和大对象更新的多播优化。

1、引言(Introduction)

许多在线服务需要基于对象的存储,将数据作为整个单元呈现给应用程序。对象存储支持两个基本原语:(或查询)操作返回以对象名称存储的数据块,(或更新)操作修改单个对象的状态。这一类基于对象的存储由键值数据库(例如BerkeleyDB或Apache的CouchDB)支持,并部署到商业数据中心(例如Amazon的Dynamo,Facebook的Cassandra以及Memcached)。为了在这类系统中实现可靠性、负载平衡和可扩展性,对象命名空间在许多机器上进行了分区,每个数据对象都被复制了几次。

当应用程序具有某些需求时,基于对象的系统比文件系统更有吸引力。与层次目录结构相反,对象存储更适合水平命名空间,例如键值数据库。对象存储简化了支持整个对象修改的过程。而且,他们通常只需要推理对特定对象的修改顺序,而不是整个存储系统;为每个对象提供一致性保证要比为所有操作和/或对象提供一致性保证代价要低得多。

水平命名空间(Flat namespace)指没有层次结构的命名空间,其中所有对象都处于同一级别。如键值数据库

层次目录结构(hierarchical directory structures)将对象组织成树形结构,每个节点都有一个唯一的路径。如文件系统

在构建作为众多应用程序基础的存储系统时,商业站点将高性能和高可用的需求放在首位。复制数据是为了承受单个节点甚至整个数据中心的故障带来的威胁,无论这个故障是计划内的还是计划外的。确实在新闻媒体中处处可见数据中心离线导致期间整个网站都被关闭了的例子。对可用性和性能的高度关注,导致许多商业系统由于感知成本而牺牲了强一致性语义(例如Google、Amazon、eBay、Facebook等)。

Van Renesse和Schneider近期提出了一种为对象存储在故障停止服务器上的链式复制方法,该方法旨在提供强一致性的同时提高吞吐量。基本方法是将所有存储对象的节点组织在一条链中,其中链的尾节点处理所有读取请求,而链的头节点处理所有写入请求。在客户端收到确认之前,写操作沿链向下传播,因此尾节点可以得到所有对象操作的执行顺序,具有强一致性。该方法没有任何复杂或多轮通信的协议,但是提供了简单、高吞吐量和容易故障恢复的特性。

不幸的是,基础的链复制方法有一些局限性。对一个对象的所有读取都在头节点,从而导致潜在的热点问题。虽然可以通过一致性哈希方法或更中心化的目录方法将集群中的节点组织到多个链中,以实现更好的负载均衡,但是如果特定对象访问较少,这些算法仍然可能会负载不平衡,这在实践中是一个真实的问题。当尝试跨多个数据中心构建链式,甚至可能出现更严重的问题,因为所有的读取操作都可能会由一个远距离节点(链的尾节点)处理。

本文介绍了CRAQ的设计、实现和评估,CRAQ是一个对象存储系统,在保持链式复制的强一致性特性的同时,通过支持分配查询为读取操作提供了较低的延迟和较高的吞吐量;分配查询指的是将读取操作分配给链中的所有节点执行,而不是所有操作都由单个主节点处理。本文的主要贡献如下:

  1. CRAQ使任何链节点都能在保持强一致性的同时处理读操作,从而支持存储对象在所有节点之间的负载平衡。此外,当大多数工作负载是读取操作时,(例如GFS和Memcached系统中做的假设),CRAQ的性能可以和仅提供最终一致性的系统相媲美。
  2. 除了强一致性外,CRAQ的设计还自然支持读操作之间的最终一致性,从而降低写操作期间的等待时间,并在短暂的分区期间降级为只读。CRAQ允许应用程序指定读取操作可接受的最大过期时间。
  3. 利用负载均衡的特性,我们介绍了一种广域系统设计,用于在跨地理位置的集群中构建CRAQ链,并保留了强局部性。具体而言,读操作可以由本地集群进行处理,在最坏情况下(高写争用的时候),需要在广域网中传输简短的元数据信息。我们还介绍了使用Zookeeper(一种类似于PAXOS的组成员系统)来管理部署。

最后,我们讨论了CRAQ的其他扩展,包括将微事务集成到多对象原子更新中,以及使用多播来提高大对象更新的写入性能。但是,我们尚未完成这些优化的实现。

CRAQ的初步性能评估显示,与基础的链式复制方法相比,它具有更高的吞吐量,在大多数负载都是读操作的情况下,吞吐量与节点的数量成正比:三节点的链可以提升约200%的吞吐量,七节点的链可以提升约600%的吞吐量。在高写争用的情况下,CRAQ在三节点的链中的读取吞吐量仍然比基础的链式复制高出两倍,并且读取延迟较低。我们总结了CRAQ在各种工作负载和故障情况下的性能。最后,我们评估了CRAQ在跨地域复制方面的性能,证明其延迟远低于基础链式复制方法的延迟。

本文的剩余部分安排如下,第2节介绍了基础链式复制与CRAQ协议之前的对比,以及CRAQ的最终一致性支持。第3节介绍了CRAQ在单数据中心和跨数据中心扩展到多条链的方法,以及管理链和节点的组成员服务。第4节涉及到诸如多对象更新和利用多播等扩展。第5节介绍了CRAQ的实现,第6节展示了CRAQ的性能评估,第7节回顾了相关工作,第8节进行总结。

2、基础系统模型(Basic System Model)

本节介绍了我们基于对象的接口和一致性模型,简要概述了标准的链式复制模型,然后介绍了强一致的CRAQ模型及其变体。

2.1、接口和一致性模型(Interface and Consistency Model)

基于对象的存储系统为用户提供了两个简单的原语:

  • write(objID,V):写(更新)操作存储与对象标识符objID关联的值V
  • V <- read(objID):读(查询)操作检索与对象标识符objID关联的值V

我们将讨论关于单个对象的两种主要的一致性类型

  • 强一致性 我们系统中的强一致性保证对于单个对象的所有的读和写操作均按一定顺序执行,并且对于单个对象的读始终能看到最新的值。
  • 最终一致性 我们系统中的最终一致性意味着对单个对象的写入仍然按一定顺序应用于所有的节点,但是对于不同的节点,最终一致性读可能在一段时间内返回旧数据(即在写入被应用到所有节点之前)。但是,一旦所有的副本接收到写入请求后,读操作将永远不会返回比最近提交的写入版本更旧的版本。实际上,如果客户端保持与一个特定节点的会话(尽管不是与不同的节点的会话),则会看到单调的读一致性(注:即对于一个对象的读取将返回相同的先前的值或一个更新的值,但是绝不会返回旧版本的值)。

接下来,我们介绍一下链式复制和CRAQ是如何提供强一致性的。

2.2、链式复制(Chain Replication)

链式复制(CR)是一种在多节点之间复制数据的方法,提供了强一致性的存储接口。节点组成一条长度为C的。链的头节点处理来自客户端的所有操作。当节点收到写操作的请求时,他会继续传播给链中的下一个节点。一旦写操作请求到达尾节点,该操作就已经被应用到了链中的所有副本中,此时认为该写操作已提交。尾节点处理所有的读操作,因此只有已提交的值才会被返回。

图1提供了一个长度为四的链的实例。所有读请求的到达和处理都在尾节点。写请求到达链的头部,并向下传播到尾部。当尾节点提交写操作后,向客户端发送回复。CR论文中介绍由尾节点直接向客户端发送消息;由于我们使用TCP,因此我们的实现实际由头部节点复用之前与客户端的连接,在收到尾节点的确认后直接进行响应。确认回传在图中用虚线表示。

CR简单的拓扑结构使写操作比其他提供强一致性的协议成本更低。多个并发写入可以在链中进行流水线传输,传输成本平摊在所有节点上。之前工作的模拟结果显示,与主/备复制相比,CR具有更高的吞吐量,同时还能更快、更容易地恢复。

链式复制实现了强一致性:由于所有的读都在尾部进行,并且所有的写入只有当到达尾部后才提交,因此链的尾部可以按序应用所有的操作。然而,这的确要付出一些代价,因为只有一个节点处理读操作,因此降低了读操作的吞吐量,无法随着链的长度增加而进行扩展。但是这是有必要的,因为查询中间节点可能为违反强一致性保证;特别是,在传播过程中,对不同节点的并发读取可能会看到不同的写入值。

尽管CR专注于提供存储服务,但也可以将其查询/更新协议视为复制状态机的接口。尽管本文的剩余部分仅从读/写对象存储接口两个角度考虑问题,但可以用类似的角度看待CRAQ。

2.3、分摊查询的链式复制(Chain Replication with Apportioned Queries)

受到只读工作负载环境的流行的推动,CRAQ试图通过允许链中的任意节点都来处理读操作,同时仍提供强一致性保证,来提高吞吐量。CRAQ主要的扩展如下:

  1. CRAQ中的单个节点允许存储对象的多个版本,每个版本都包含一个单调递增的版本号以及一个附加属性:该版本是脏的还是干净的。所有的版本初始化标记为干净。
  2. 当节点收到对象的新版本时(通过沿链路向下传播的写操作),该节点将此最新版本附加到该对象的列表中。
    • 如果该节点不是尾节点,则将该版本标记为脏,并将写操作传播到后继节点。
    • 如果该节点是尾节点,则将版本标记为干净,此时我们将对象版本称为已提交。然后,尾节点可以通过在链中反向传播确认来通知所有其他节点此次提交。
  3. 当节点接收到某个对象版本的确认消息时,该节点会将该对象版本标记为干净。然后,该节点就可以删除该对象的所有先前版本。
  4. 当节点收到对对象的读取请求时:
    • 如果最新已知的版本是干净的,则节点将返回该值。
    • 如果最新已知的版本是脏的,该节点会与尾节点进行通信,查询尾节点最后提交的版本号。然后节点返回该对象的版本;按照规则,可以确保该节点存储了该版本的对象。我们注意到,尽管尾节点可以在它回复版本请求和中间节点向客户端发送回复之前提交新版本,但是这不违反强一致性的定义,因为读操作从尾节点来说是序列化的。

请注意,如果节点收到写提交的确认后立即删除旧版本,则也可以隐式确定节点上的对象的状态是脏还是干净。也就是说如果节点中的对象只有一个版本,那么该对象是干净的;否则,对象是脏的,必须从尾节点检索正确的版本。

图2显示了处于初始干净状态的CRAQ链。每个节点都存储对象的相同副本,因此到达链中任何节点的任何读请求都将返回相同的值。除非收到写请求,否则所有节点都将保持在干净状态。

图3显示了写操作的传播过程(由紫色虚线显示)。头节点收到写入该对象的新版本(V2)的初始消息,因此头节点的对象状态是脏的。然后,头节点将写消息沿着链向下传播到第二个节点,该节点也将该对象标记为脏(对象K有多个版本[V1,V2])。如果一个处于干净状态的节点收到读请求,它们将立即返回该对象的旧版本:这是正确的,因为新版本尚未在尾节点提交新版本。但是,如果两个脏节点中的其中一个收到读请求,它们会向尾节点发送版本查询请求(图中使用蓝色的虚线箭头显示),尾节点将返回被请求对象的已知版本号。然后,脏节点返回与指定版本号相关联的旧对象值(V1)。因此,即使有多个未完成的写操作在链中传播,链中的所有节点仍将返回同一版本的对象。

当尾节点收到并接受写入请求时,它会在链上发送包含此写入版本号的确认消息。每个前继节点收到确认后,将此版本号标记为干净(可能删除所有较旧的版本)。当其最新的版本状态变成干净后,节点就可以在本地处理读请求了。这种方式利用了写操作是串行传播的事实,因此尾节点总是最后一个收到写入请求的节点。

CRAQ在以下两种场景下吞吐量会比CR有所提升:

  • Read-Mostly Workloads 该场景下大多数都是读请求,这些读取请求由C−1个非尾节点进行处理。因此,在这类场景下吞吐量与链长度C呈线性关系。
  • Write-Heavy Workloads 该场景下有许多对非尾节点的大多数读请求数据为脏,因此需要对尾节点进行版本查询。但是,我们认为这些版本查询并完整读取更轻量,允许尾节点在它饱和之前以更高的速率处理它们。这使得总的读取吞吐量仍高于CR。

第六节中的性能数据可以支持以上两个主张,即使对于小对象也是如此。对于持续写请求繁重的较长链,即使我们不评估这种优化,也可以想象通过使尾部结点仅处理版本查询而不是处理所有的读请求的方式,可以优化读取吞吐量。

2.4、CRAQ上的一致性模型(Consistency Models on CRAQ)

某些应用程序或许可以以较弱的一致性保证来运行,并且它们可能会试图避免版本查询的性能开销(根据3.3节,在广域部署中是很重要的),或者它们可能希望当系统无法提供强一致性时继续运行(例如在分区期间)。为了支持这类需求的变化,CRAQ同时支持三种不同的一致性模型。读取操作使用哪一类一致性模型是可选的。

  • 强一致性(默认)上面的模型中描述了强一致性。所有对象读取都与最后一次提交的写入一致。
  • 最终一致性 允许对链中的节点的读操作返回已知的最新对象版本。因此,另一个点的后续读取操作可能返回比先前返回的对象更旧的版本。因此,尽管对单个链节点的读取操作的确在本地,但它不满足单调读一致性。
  • 最大范围不一致的最终一致性 允许读操作在写操作提交前将存储的新对象返回,但只允许在某些条件下这样做。施加的条件可以基于时间或是基于绝对版本号。在该模型中,保证读操作返回的值具有最大的不一致性周期。如果链仍然是可用的,这种不一致性实际上是因为返回的版本比上次提交的版本新。如果系统被分区,并且节点无法参与写入,那么版本可能比当前提交的版本旧。

2.5、CRAQ中的故障恢复(Failure Recovery in CRAQ)

由于CRAQ的基本结构与CR相似,因此CRAQ使用相同的技术进行故障恢复。每个链节点需要知道它的前继节点和后继节点,以及链的头部和尾部。当头部节点故障了,它的后继节点将接任新的链的头部。同样,当尾节点出现故障时,它的前继节点也会接任成为新的尾节点。需要加入到链中间的节点要像双链表一样插入到两个节点之间。处理系统故障的正确性证明与CR相似;由于篇幅所限,这里不展开说明。第5节介绍了CRAQ中故障恢复的细节以及协作服务的集成。特别是CRAQ允许节点加入到链中的任何位置(而不是仅在尾部),以在恢复过程中正确处理故障的选择都需要详细介绍。

3、CRAQ的扩展(Scaling CRAQ)

在本节中,我们讨论应用程序如何在单数据中心以及跨多数据中心的条件下,设计CRAQ中链的布局方案。然后,我们讨论如何使用协作服务来存储链的元信息和组成员身份信息。

3.1、链布局策略(Chain Placement Strategies)

使用分布式存储服务的应用程序的要求可能会有所不同。一些常见的情况如下:

  • 对对象的大部分或全部的写入操作可能源自单个数据中心
  • 一些对象可能只存放在一个数据中心的某些节点中
  • 热点对象可能需要大量复制,而非热点对象可能较少

CRAQ提供了灵活的链配置策略,通过使用对象的两级命名结构来满足这些变化的需求。对象的标识符包括链标识符键标识符。链标识符决定CRAQ中的哪些节点将存储该链中的所有键,而键标识符为每条链提供唯一命名。我们介绍了多种满足应用程序定制化需求的方法:

1、隐式数据中心和全局链长度

此方法中定义了存储链的数据中心的数量,但没有显式地定义存储在哪些数据中心。为了准确地确定哪个数据中心存储链,使用一致性哈希结合唯一的数据中心标识符。

2、显示数据中心和全局链长度

该方法中每个数据中心使用同样的链长度在数据中心中存储副本。链的头节点位于数据中心dc1中,链的尾节点位于数据中心dcn中,链基于数据中心列表进行排序。为了确定数据中心中的哪些节点存储分配给链的对象,对链标识符做一致性哈希。每个数据中心dci都有一个连接到数据中心dci−1尾节点的节点和一个连接到数据中心dci+1头节点的节点。另一个额外的功能是允许chain_size为0,表示该链使用每个数据中心内的所有节点。

3、显示数据中心和不同链长度

这里每个数据中心的链长度是独立的。这允许链负载均衡是非均匀的。每个数据中心的链节点的选择方式与之前的方式相同,并且chain_size也可以设置为0。

在上述方法2和方法3中,dc1可以设置为主数据中心。如果一个数据中心是链的主数据中心,那么对于链的写入将仅在短暂故障期间被该数据中心接受。否则,如果dc1与链的其他节点断开连接,则dc2可能会成为新的头节点,并接管写操作,直到dc1恢复在线。如果未设置主节点,写操作将仅在包含全局链中大多数节点的分区中继续进行。否则,如第2.4节中定义的那样,对于最大范围不一致的读取操作,该分区会变成只读。

CRAQ可以轻松支持其他更复杂的链配置方法。例如可能需要指定一个显式备份数据中心,仅当另一个数据挂了的时候开始加入链中。还可以设置一组数据中心(例如东海岸数据中心),其中的任意一个都可以填充到上述方法2的有序列表中。为简便起见,我们不再详细介绍更复杂的方法。

可以写入单个链的键标识符的数量没有限制,这样可以根据应用需求对链进行灵活的配置。

3.2、单个数据中心的CRAQ(CRAQ within a Datacenter)

在最初的链式复制工作中,已经研究了如何在多个数据中心分布多个链。在CRAQ的当前实现中,我们使用一致性哈希将链放置在数据中心内,将潜在的链标识符映射到头节点上。这类似于基于数据中心的对象存储。GFS采用并在CR中推广的另一种方式是在分配和存储随机链成员时,使用成员管理服务作为目录服务,即每个链可以包含一些随机服务器的集合。这种方式提高了并行系统恢复的能力。但是,这是以增加集中度为代价的。CRAQ可以轻松的使用这种设计,但是它将需要在协作服务中存储更多的元信息。

3.3、跨多个数据中心的CRAQ(CRAQ Across Multiple Datacenters)

当链延伸到广域网时,CRAQ能够从任何节点进行读取的能力可以降低它的延迟:客户端在选择节点时具有灵活性,它们可以选择物理距离较近的节点(或者轻负载的节点)。只要链的状态是干净的,那么节点可以直接返回本地副本的值,而不用发送任何广域请求。而在传统的CR中,所有读取都需要由可能距离较远的尾节点处理。实际上,由于对象可能处于不同的位置,因此多种设计可能会基于数据中心在链中选择头结点和/或尾节点。实际上雅虎的新分布式数据库PNUTS就是受其数据中心中的高写入局部性的影响而进行设计的。

也就是说应用程序可能会进一步优化广域网下链的选择,从而最大程度地减少写入延迟,降低网络成本。当然,在所有节点集合中使用一致性哈希这种朴素的方式来构建链可能会导致链的前继和后继是随机的,前继和后继可能距离很远。此外,一条链可能会多次跨入和跨出一个数据中心。而通过我们的链优化,应用程序可以通过谨慎选择组成链的数据中心的顺序来最小化写延迟,并且可以确保一条链只单向跨越数据中心的网络边界一次。

即使使用优化后的链,随着越来越多的数据中心被添加到链中,广域网中的链的写操作延迟也会增加。尽管与以并行方式分发写操作的主/备方法相比,这种方式显著地增加了延迟,但是它允许将写操作在链中流水线进行,这极大的提高了写操作的吞吐量。

3.4、ZooKeeper 协作服务(ZooKeeper Coordination Service)

众所周知,为分布式应用程序构建一个容错的协作服务很容易出错。CRAQ的早期版本包含一个非常简单、集中控制的协作服务,用于维护成员管理。后来,我们选择利用Zookeeper为CRAQ提供一种健壮的、分布式的、高性能的方式来管理组成员,并提供一种简单的方式来存储链的元数据。通过Zookeeper,当组内添加节点或删除节点时,CRAQ节点一定会收到通知。同样当节点关注的元数据发送变化时,该节点也可以收到通知。

Zookeeper为客户端提供类似于文件系统的分层命名空间。文件系统存储在内存中,并且在日志中为每个Zookeeper实例进行备份,文件系统状态会在多个Zookeeper节点之间进行复制,从而提高可靠性和可扩展性。为了达成一致,Zookeeper使用类似两阶段提交的原子广播协议。经过优化后,Zookeeper能够为大量读的小型工作负载提供出色的性能,因为它可以直接在内存中响应大部分的服务请求。

与传统的文件系统命名空间类似,Zookeeper客户端可以罗列目录的内容、读取文件、写入文件以及在文件或目录被修改或删除时收到通知。Zookeeper的原始操作允许客户端实现许多更高级别的语义,例如组成员、领导选举、事件通知、锁和队列。

跨多数据中心进行管理成员和链的元信息的确带来了一些挑战。实际上,Zookeeper并未针对在多数据中心环境中运行进行优化:将多个Zookeeper节点放在单个数据中心,可以提高Zookeeper在该数据中心的读取可扩展性,但是在广域网下的性能会受损。因为原始实现并不知道数据中心的拓扑和层次结构,所以Zookeeper节点之间进行消息交换会通过广域网进行传输。尽管如此,我们当前的实现仍然确保了CRAQ节点总是能收到本地Zookeeper节点的通知,并且与它们相关的关于链和节点列表的消息也会进行通知。我们在第5.1节使用Zookeeper进行了扩展。

为了消除Zookeeper在跨数据中心时产生的流量冗余,可以构建一个Zookeeper实例的层次结构:每个数据中心可以拥有自己本地的Zookeeper实例(由多个节点组成),并拥有一个全局Zookeeper实例的代表(可以通过本地实例的领导选举选出)。然后独立的功能可以协调两者之间的数据共享。一种替代设计是修改Zookeeper本身,就像CRAQ一样让节点知道网络拓扑结构。我们尚未重复研究这两种方法,将其留给以后的工作。

4、扩展(Extensions)

本节讨论对CRAQ的一些其他扩展,包括微事务功能、使用多播优化写操作。我们目前正在实现这些扩展。

4.1、CRAQ上的微事务(Mini-Transactions on CRAQ)

在一些应用程序中,对于对象存储中的整个对象的读/写接口可能会受限。例如BitTorrent或其他目录服务可能需要支持列表的添加或删除。分析服务可能需要存储计数器。或者应用程序可能希望提供对某些对象的条件访问。这些需求都不是仅仅提供纯粹的对象存储接口就可以满足的,但是CRAQ提供了支持事务操作的关键扩展。

4.1.1、单键操作(Single-Key Operations)

单键操作很容易实现,CRAQ已经支持以下操作:

  • 前置/追加:(Prepend/Append) 将数据添加到当前对象值的开头或结尾。
  • 增加/减小:(Increment/Decrement) 在键的对象上增加或减少,以整数形式表示。
  • 测试并设置:(Test-and-Set) 仅在键的当前版本号等于操作中执行的版本号时,才更新键的对象。

对于前置/追加和增加/减小操作,存储键对象的链的头节点可以简单地将操作应用于对象的最新版本,即使最新的版本是不干净的,然后在链中向后传播替换写操作。此外,如果这些操作很频繁,则头节点可以缓存请求然后批量更新。如果使用传统的两阶段提交协议,实现这些功能付出的代价会很高。

对于测试并设置操作,链的头节点检查其最近提交的版本号是否等于操作中执行的版本号,如果没有该对象最近未提交的版本,头节点接受该操作并在链中传播更新。如果有未完成的写操作,则拒绝该操作,并且如果连续被拒绝,客户端需要考虑降低请求速度。还有另一种方案,头节点可以通过禁止写入直到对象干净为止并重新检查最新的版本号来锁定对象,但是由于未提交的写入被中止是非常少见的,以及锁定对象会显著影响性能,因此我们选择不采用该方案。

测试并设置操作也可以设计为接受值而不是版本号,但是当存在未提交的版本时,会引入额外的复杂性。如果头节点与对象的最新提交版本(通过与尾节点通信)比较发现不同,则当前进行中的任何写入都将被拒绝。而如果头节点与最新未提交版本比较,就违反了一致性保证。为了实现一致性,头节点将需要通过禁止写入直到对象干净为止来暂时地锁住对象。这不会违反一致性保证,并确保不会丢失任何更新,但是会显著影响写入性能。

4.1.2、单链操作(Single-Chain Operations)

Sinfonia最近提出的“微事务”提供了一种具有吸引力方法,它能够较为轻量地在单个链的多个键上执行事务。微事务由比较、读取和写入集合定义;Sinfonia提出了一种跨越多个内存节点的线性地址空间。比较集测试指定地址位置的值,如果它们与提供的值匹配,则执行读取和写入操作。Sinfonia提出的微事务使用乐观的两阶段提交协议,专为较低的写争用的情况而设计。准备消息尝试在指定的内存地址上获取锁。如果所有的地址都被锁了,则协议提交;否则,参与者释放所有的锁并稍后重试。

CRAQ的链拓扑结构对于支持类似微事务有特殊的优势,因为应用程序可以指定多个对象存储在同一条链上,从而保持了局部性。共享同一个chainid的对象被分配在同一个链头节点上,由于只有一个头节点,因此可以避免在一次通信中发生两阶段提交。CRAQ的独特之处在于,在涉及单个链的微事务中就可以仅使用头节点来接受访问,因为头节点控制对链所有键的写访问。唯一的缺点就是如果头节点需要等待事务中的所有节点变干净(如4.1.1节所述),那么写吞吐量会收到影响。但是这个问题在Sinfonia中更为严重,因为它需要等待跨多个节点的键解锁。同样,在CRAQ中从故障恢复也很容易。

4.1.3、多链操作(Multi-Chain Operations)

即使在多对象更新涉及到多个链时,乐观两阶段提交协议也仅需使用链头节点来实现,而不是所有涉及的节点。链头节点可以锁住任何微事务中涉及的键,直到事务完全提交为止。

当然,应用程序写进程在使用昂贵的锁和微事务时需要小心:由于写同一个对象无法再流水线化执行(链式复制极其重要的优势),CRAQ的写吞吐量会被降低。

4.2、多播降低写入延迟(Lowering Write Latency with Multicast)

CRAQ可以利用多播协议来提高写入性能,特别是对于大规模的更新或是长链而言。由于链成员在节点成员修改期间是稳定的,因此可以为每个链创建一个多播组。在一个数据中心内,可以采用网络层多播协议的形式,而应用程序层多播可能更适用于广域网中的链。这些多播协议不需要顺序或可靠性保证。

多播协议(multicast protocols)指的是一种网络协议,可以将数据包从一个源节点发送到多个目标节点。这种协议可以提高写入性能,特别是对于大型更新或长链。

然后,实际的值可以多播到整个链,而不是在链上顺序传播完整写入,增加与链长度成正比的延迟。与此同时,只有较小的元数据信息需要在链中传播,以确保所有尾节点前的副本都能收到写操作。如果节点因为任何原因而未收到多播的消息,节点可以在接收到写提交消息之后和向下传播提交消息之前,与它的前继节点进行通信获取对象。

此外,当尾节点收到传播的写请求时,多播确认信息可以发送到多播组中,而不是将其沿链向后传播。这样既减少了节点对象在写入后等待重新进入干净状态的时间,又减少了客户端感知的写入延迟。同样,在多播确认时不需要顺序或可靠性保证——如果链中的节点没收到确认消息,它会在下一个读取操作要求它查询尾节点时重新进入干净状态。

5、管理与实施(Management and Implementation)

我们链复制和CRAQ的原型实现是用大约3000行c++编写的,使用Tame扩展[31]到SFS异步I/O和RPC库[38]。CRAQ节点之间的所有网络功能都是通过Sun RPC接口公开的。

5.1、ZooKeeper集成(Integrating ZooKeeper)

如§3.4所述,CRAQ需要一种组成员服务的功能。我们使用ZooKeeper文件结构来维护每个数据中心中的节点列表成员。当客户端在ZooKeeper中创建一个文件时,它可以被标记为临时文件。如果创建临时文件的客户端与ZooKeeper断开连接,临时文件将被自动删除。在初始化过程中,CRAQ节点在/nodes/dc_name/node_id中创建一个临时文件,其中dc_name是其数据中心的唯一名称(由管理员指定),node_id是对于节点数据中心的唯一的节点标识符。该文件的内容包含节点的IP地址和端口号。

CRAQ节点可以通过查询/nodes/dc_name来确定其数据中心的成员列表,但是ZooKeeper为进程提供了在文件上创建watch的能力,而不必定期检查列表是否有变化。CRAQ节点在创建临时文件以通知其他节点它已加入系统之后,在/nodes/dc_name的子列表上创建一个watch,从而保证在添加或删除节点时接收到通知。

当CRAQ节点接收到创建新链的请求时,在/chains/chain_id中创建一个文件,其中echain_id是链的160位唯一标识符。链的放置策略(在§3.1中定义)决定了文件的内容,但它只包括这个链的配置信息,不包括链的当前节点列表。参与链的任何节点都将查询链文件,并在其上创建一个watch,以便在链元数据发生变化时得到通知。

尽管这种方法要求节点跟踪整个数据中心的CRAQ节点列表,我们依然选择了这种方法,而不是另一种方法,在这种方法中,节点为它们所属的每个链注册它们的成员(即,链元数据显式地命名链的当前成员)。我们假设链的数量通常至少比系统中的节点数量大一个数量级,或者链的动态性可能比加入或离开系统的节点大得多(回想一下,CRAQ是为托管数据中心而不是点对点设置设计的)。采用替代假设的部署可以采用在协调服务中显式跟踪每个链成员关系的另一种方法。如果有必要,当前方法的可伸缩性还可以通过让每个节点只跟踪数据中心节点的一个子集来提高:我们可以根据node_id前缀将节点列表划分到/nodes/dc_name/中的单独目录中,节点只监视自己的和相近的前缀。

值得注意的是,我们能够利用类 tame 技术的包装器函数(注:tame不好翻译,指的是将异步代码转换为同步代码的一种编程技术)将ZooKeeper的异步API函数集成到我们的代码库中。这允许我们等待ZooKeeper包装器函数,这极大地降低了代码的复杂性。

5.2、链节点功能(Chain Node Functionality)

我们的chainnode程序实现了CRAQ的大部分功能。由于Chain Replication和CRAQ的大部分功能是相似的,因此该程序基于运行时配置设置作为Chain Replication节点或CRAQ节点操作。

节点在加入系统时会生成一个随机标识符,每个数据中心内的节点使用这些标识符组织成一个一跳DHT [29, 45]。一个节点的链前驱和后继被定义为其在DHT环中的前驱和后继。链也由160位标识符命名。对于链Ci,Ci的DHT后继节点被选为该数据中心中该链的第一个节点。接下来,这个节点的SDHT后继形成数据中心子链,其中S在链元数据中指定。(注:这句话的意思是,这个链的第一个节点在该数据中心内被选为DHT环上的第一个节点。然后,这个节点的SDHT后继被选为DHT环上的下一个节点,并负责存储该链的下一个副本。接着,这个SDHT后继节点的SDHT后继被选为DHT环上的下一个节点,并负责存储该链的下一个副本。以此类推,直到所有存储该链副本的节点都被组织成了一个子链。其中,S是在链元数据中指定的参数,用于指定每个数据中心内子链包含多少个副本。)如果这个数据中心是该链的第一个(或最后一个),那么这个第一个(或最后一个)节点就是该链的最终头(或尾)。

目前,所有节点之间或节点与客户端之间基于rpc的通信都是通过TCP连接进行的(Nagle的算法被关闭)。每个节点维护一个连接的TCP连接池,其中包含其链的前一个节点、后一个节点和尾节点。请求在这些连接之间被流水线化和循环处理。所有对象目前都只存储在内存中,尽管我们的存储抽象非常适合使用进程内键值存储,例如BerkeleyDB[40],我们正在对它进行集成。

对于跨越多个数据中心的链,一个数据中心的最后一个节点维护到其后续数据中心的第一个节点的连接。任何维护到其数据中心外部节点的连接的节点还必须在外部数据中心的节点列表上放置一个监视。但是请注意,当外部数据中心的节点列表发生变化时,订阅更改的节点只会收到来自本地ZooKeeper实例的通知,避免了额外的跨数据中心流量。

5.3、解决成员变更(Handling Memberships Changes)

对于正常的写入传播,CRAQ 节点遵循第 2.3 节中的协议。在恢复期间有时需要第二种传播,称为反向传播,但是:它有助于保持响应节点添加和故障的一致性。例如,如果一个新节点将 CRAQ 连接为现有链的头部(给定其在 DHT 中的位置),则链的前一个头部需要向后传播其状态。但是系统在恢复过程中也需要对后续故障具有鲁棒性(注:健壮性),这可能会级联对链更远的向后传播的需求(例如,如果现在的第二个链节点在完成到当前头节点的反向传播之前发生故障)。最初Chain Replication论文没有考虑这样的恢复问题,这可能是因为它只描述了更集中控制和静态配置的链成员版本,其中新节点总是添加到链的尾部。

由于这些可能的故障条件,当一个新节点加入系统时,新节点既要接收来自其前身的传播消息,也要接收来自其后继节点的反向传播消息,以确保其正确性。新节点拒绝客户端对特定对象的读请求,直到它与后继节点达成协议。在这两种传播方法中,节点都可以使用集合协调算法(注:集合协调算法是一种用于比较两个集合之间差异的算法。在CRAQ系统中,节点使用集合协调算法来比较它们各自拥有的对象,并仅传播缺失或更新的对象。)来确保在恢复期间只传播需要的对象。

反向传播消息总是包含节点关于对象的完整状态。这意味着不只是发送最新版本,而是将最新的干净版本与所有未完成的(更新的)脏版本一起发送。这对于使刚刚加入系统的新节点能够响应未来的确认消息是必要的。前向传播支持这两种方法。对于沿链传播的正常写操作,只发送最新版本,但当从故障中恢复或添加新节点时,将传输完整状态对象。

现在让我们从节点N的角度考虑以下情况,其中LC是N负责的链C的长度。

节点添加(Node Additions)系统中增加了一个新节点A

  • 如果 A 是 N 的后继,N 将 C 中的所有对象传播到 A。如果 A 之前已经在系统中,N 可以首先执行对象集和协调,以标识达到与链其余部分一致性所需的指定对象版本。
  • 如果 A 是 N 的前驱:
    • N 将 C 中的所有对象反向传播到 N 不是头部的 A。
    • 如果 N 是前一个尾部,则 A 将超过 C 的尾部。
    • 如果 N 的后继者之前是尾部,则 N 变为 C 的尾部。
    • 如果 N 之前是头部,并且 A 的标识符落在 DHT 中的 C 和 N 的标识符之间,则 A 成为 C 的新头部。
  • 如果 A 在 N 的 LC 前驱中:
    • 如果 N 是 C 的尾部,它会放弃尾部职责并停止参与链。N现在可以将其 C 对象的本地副本标记为可删除,尽管它只有在稍后重新连接链 C 时才惰性地恢复这个空间以支持更快的状态协调。
    • 如果 N 的后继是 C 的尾部,N 承担尾部职责。

节点删除(Node Deletions)节点D从系统中移除。

  • 如果 D 是 N 的后继,N 将 C 中的所有对象传播到 N 的新后继对象(同样,最小化仅转移到未知、新的对象版本)。N 必须传播其对象,即使该节点已经属于链,因为 D 在传播未完成的写操作之前可能会失败。
  • 如果 D 是 N 的前驱:
    • N 将所有所需的对象反向传播到 N 的新前驱,因为它不是头部。N需要反向传播它的键,因为D在向其前身发送未完成的确认之前可能会失败,或者在完成自己的反向传播之前失败。
    • 如果D是C的头部,N承担头部职责。
    • 如果N是C的尾部,它会放弃尾部职责,并将C中的所有对象传播到N的新后继者。
  • 如果 D 在 N 的 LC 前驱中,N 是 C 的尾部,N 放弃尾部职责并将 C 中的所有对象传播到 N 的新后继者。
  • 如果上述任何一个都成立,则不需要行动。

6、评估(Evaluation)

本节评估我们的链复制(CR)和CRAQ实现的性能。在高层次上,我们感兴趣的是量化CRAQ分配读的能力所带来的读吞吐量收益。另一方面,对于脏对象,版本查询仍然需要分派到尾部,因此我们也对评估工作负载混合变化时的渐近行为感兴趣。我们还简要地评估了CRAQ对广域部署的优化。

所有评估均在 Emulab(受控网络测试平台)上执行。实验使用具有 3GHz 处理器和 2GB RAM 的 pc3000 类型机器运行。节点在 100MBit 网络上连接。对于以下测试,除非另有说明,否则我们使用三个节点的链大小,存储单个对象连接在一起,而不增加任何合成延迟。此设置旨在更好地隔离单链的性能特征。除非另有说明,所有图的数据点都是中值;目前,误差条对应于第 99 个百分位值。

为了确定这两个系统中的最大只读吞吐量,我们首先改变图4中的客户机数量,图4显示了CR和CRAQ的总读吞吐量。由于CR必须从单个节点读取,因此吞吐量保持不变。CRAQ能够从链中的所有三个节点读取数据,因此CRAQ吞吐量增加到CR的三倍。在这些实验中,客户端保持了未完成请求的最大窗口(50),因此系统从未进入潜在的活锁场景。

image-20230503161705844

图5显示了读、写和test-and-set操作的吞吐量。在这里,我们将CRAQ链从3个节点更改为7个节点,同时保持只读、只写和只处理事务的工作负载。我们看到,读吞吐量与预期的链节点数量呈线性增长。写吞吐量随着链长度的增加而下降,但幅度很小。一次只能执行一个test-and-set操作,因此吞吐量远低于写操作。test-and-set吞吐量也会随着链长度的增加而降低,因为单个操作的延迟会随着链长度的增加而增加。

Chain Replication不受写操作的影响,因为所有读请求都由tail处理。尽管CRAQ的吞吐量开始时大约是CR速率的三倍(中位数为59,882 reads/s vs. 20,552 reads/s),但正如预期的那样,这个速率逐渐降低并趋于平缓,大约是CR速率的两倍(39,873 reads/s vs. 20,430 reads/s)。为了了解CRAQ在混合读/写工作负载期间的性能,我们设置了10个客户端连续地从链中读取一个500字节的对象,而单个客户端改变其对同一对象的写速率。图6显示了总的读吞吐量相对于写速率的函数。请注意,链非尾部节点总是脏的,要求它们总是首先向尾部执行版本请求。但是,在这种情况下,CRAQ仍然享有性能优势,因为尾部的读和版本请求组合的饱和点仍然高于单独读请求的饱和点。

图7重复了相同的实验,但是使用了一个5 KB的对象而不是一个500字节的对象。这个值被选为小型Web图像等对象的常用大小,而500字节可能更适合较小的数据库条目(例如,博客评论、社会网络状态信息等)。同样,CRAQ在只读设置中的性能明显优于链大小为3的CR(6,808对2,275读/秒),即使在高写速率下(4,416对2,259读/秒),它也保持了良好的性能。该图还包括七节点链的CRAQ性能。在这两种情况下,即使尾部的请求已经饱和,它响应小版本查询的速度也比发送大版本的读响应要快得多,这使得总读吞吐量明显高于CR。

图 8 隔离了构成图 6 的脏读和干净读。随着写入的增加,干净请求的数量下降到其原始值的 25.4%,因为当写操作使链饱和时,只有尾部是干净的。尾部不能保持自己的最大仅读取吞吐量(即总共 33.3%),因为它现在还处理来自其他链节点的版本查询。另一方面,如果总吞吐量保持不变,脏请求的数量将接近原始干净读取率的三分之二,但由于脏请求速度较慢,此脏请求的数量在42.3%时趋于平缓。这两个速率重建了观察到的总读取率,在链上的高写争用期间,它收敛到只读吞吐量的67.7%。

图9中的表显示了干净读取、脏读取、向3节点链写入和向6节点链写入的延迟(以毫秒为单位),所有这些都在单个数据中心内。当操作是唯一的未完成请求(无负载)和当我们用许多请求使CRAQ节点饱和(高负载)时,显示500字节和5 KB对象的延迟。正如预期的那样,在高负载下延迟会更高,并且延迟会随着键大小而增加。由于产生了额外的往返时间,脏读取总是比干净读取慢,并且写入延迟随着链的大小大致呈线性增加。

图10展示了CRAQ从故障中恢复的能力。我们将显示长度为3、5和7的链随时间的只读吞吐量损失。每次测试15秒后,链中的一个节点被杀死。几秒钟后,节点超时并被ZooKeeper认为死亡的时间,一个新节点加入链,吞吐量恢复到原始值。图上绘制的水平线对应于长度为1到7的链的最大吞吐量。这有助于说明故障期间的吞吐量损失大致等于1/C,其中C是链的长度。

为了测量故障对读写操作延迟的影响,图11和图12显示了长度为3的链发生故障时这些操作的延迟。在尝试读取对象时收到错误的客户端会选择一个新的随机副本进行读取,因此失败对读取的影响很小。但是,在副本失败和由于超时从链中删除之间的时间段内,不能提交写操作。这导致写入延迟会增加完成故障检测所需的时间。我们注意到,这与任何其他需要所有活动副本参与提交的主/备份复制策略中的情况相同。此外,客户端可以选择配置写请求,一旦链头接受并将请求传播到链中,就立即返回,而不是等待它提交。这减少了不需要强一致性的客户端的延迟。

最后,图13演示了CRAQ在跨数据中心的广域部署中的实用程序。在这个实验中,在三个节点上构建了一个链,每个节点之间的往返延迟为80ms(大约是美国沿海地区之间的往返时间),使用Emulab的合成延迟进行控制。读客户端不是链尾的本地客户端(否则可能会像以前一样只有本地性能)。该图评估了随着工作负载组合的变化而产生的读取延迟;平均延迟现在以标准偏差显示为误差条(而不是其他位置的中位数和第99%)。因为尾部不是本地的,所以CR的延迟一直很高,因为它总是会引起广域读请求。另一方面,当没有发生写入时,CRAQ几乎不会产生延迟,因为读取请求可以在本地得到满足。然而,随着写入速率的提高,CRAQ读取变得越来越脏,因此平均延迟也会增加。一旦写入速率达到约15次写入/秒,沿广域链向下传播写入消息所涉及的延迟会导致客户端的本地节点100%处于脏状态,从而导致广域版本查询。(CRAQ的最大延迟一直如此-略低于CR,因为只有元数据在大范围内传输,这种差异只会随着较大的对象而增加,特别是在启动缓慢的情况下。)尽管这种收敛到 100% 的脏状态发生的写入率远低于之前,我们注意到仔细的进行链布局可以使得尾部数据中心中的任何客户端享受本地区域性能。此外,非尾部数据中心的客户端可以满足一定程度的最大边界不一致(参见§2.4),也可以避免广域请求。

7、相关工作(Related Work)

分布式系统的强一致性

分布式系统的强一致性。分布式服务器之间的强一致性可以通过使用主/备份存储[3]和两阶段提交协议[43]来提供。该领域的早期工作没有提供面对故障(例如,事务管理器的故障)时的可用性,这导致引入视图更改协议(例如,通过领导者共识[33])来协助恢复。在这一领域有大量的后续工作;最近的例子包括Chain Replication和Guerraoui et al.[25]的基于环的协议,后者使用两阶段写入协议,并在未提交的写入期间延迟读取。与其到处复制内容,不如在强一致性仲裁系统中探索重叠读写集之间的其他权衡[23,28]。共识协议也被扩展到恶意设置,包括状态机复制[10,34]和仲裁系统[1,37]。这些协议为系统的所有操作提供了线性一致性。本文没有考虑拜占庭式故障——并且很大程度上限制了对影响单个对象的操作的考虑——尽管将链复制扩展到恶意设置是未来有趣的工作。

有很多分布式文件系统提供了强大的一致性保证,比如早期基于主/备份的Harp文件系统[35]。最近,Boxwood[36]探索了各种更高层的数据抽象,比如b树,同时提供严格的一致性。Sinfonia[2]提供轻量级的“微事务”,允许对存储节点中暴露的内存区域进行原子更新,这是一种优化的两阶段提交协议,非常适合低写争用的设置。CRAQ对多链多对象更新的乐观锁的使用受到sinonia的严重影响。

CRAQ和Chain Replication[47]都是基于对象的存储系统的例子,它们公开整个对象的写(更新)和平面对象命名空间。该接口类似于键值数据库[40]提供的接口,将每个对象视为这些数据库中的一行。因此,CRAQ和Chain Replication侧重于对每个对象的操作排序的强一致性,但通常不描述对不同对象的操作排序。(我们在§4.1中对多对象更新的扩展是一个明显的例外。)因此,它们可以被看作是极端的偶然一致性,只有对同一对象的操作是因果相关的。本文研究了数据库[7]的乐观并发控制和分布式系统[8]的有序消息传递层的因果一致性。YaHoo!新的数据托管服务PNUTs[12]也提供了每个对象的写序列化(他们称之为每个记录的线性一致性)。在单个数据中心内,它们通过具有完全有序交付的消息传递服务实现一致性;为了提供跨数据中心的一致性,所有更新都被发送到本地记录主服务器,然后由本地记录主服务器按照提交的顺序将更新交付给其他数据中心的副本。

我们使用的链自组织技术是基于DHT社区开发的技术[29,45]。专注于点对点设置,CFS在DHT[14]之上提供了一个只读文件系统;Carbonite探索了如何提高可靠性,同时最大限度地减少在瞬态故障[11]下的副本维护。强一致性可变数据由OceanStore[32](在核心节点使用BFT复制)和Etna[39](使用Paxos将DHT划分为更小的副本组和用于一致性的仲裁协议)考虑。CRAQ的广域解决方案更侧重于数据中心,因此比这些系统具有拓扑意识。Coral[20]和Canon[21]都考虑了分层DHT设计。

降低一致性而保证可用性

TACT[49]考虑一致性和可用性之间的权衡,认为当系统约束不那么严格时,可以支持较弱的一致性。eBay使用了类似的方法:在拍卖还远未结束时,消息传递和存储最终是一致的,但在拍卖结束前使用强一致性——即使是以可用性为代价。

许多文件系统和对象存储为了分区下的可伸缩性或操作而放弃了一致性。谷歌文件系统(GFS)[22]是一个基于集群的对象存储,在设置上类似于CRAQ。然而,GFS牺牲了强一致性:GFS中的并发写不序列化,读操作也不与写操作同步。采用较弱一致性语义设计的文件系统包括Sprite[6]、Coda[30]、Ficus[27]和Bayou[42],后者使用epidemic协议执行数据协调。在Amazon的Dynamo对象服务[15]中使用了类似的疏散式反熵协议(gossip-style antientropy protocol),以支持“始终在线”的写入和分区时的继续操作。Facebook的新Cassandra存储系统[16]也只提供最终的一致性。在关系数据库中使用memcached[18]并不提供任何一致性保证,而是依赖于正确的程序员实践;在多个数据中心之间保持松散的缓存一致性一直是个问题[44]。

Gossip-style antientropy protocol是一种用于分布式系统中数据同步的协议。它通过随机选择节点之间进行信息交换,从而实现数据的分发和同步。具体来说,每个节点都会定期向其他随机选择的节点发送自己拥有的数据摘要信息,同时接收其他节点发送过来的数据摘要信息。如果两个节点之间存在数据不一致,它们将交换缺失或更新的数据块,以使它们保持同步。Gossip-style antientropy protocol可以提高系统的可靠性和可扩展性,并减少网络带宽和存储开销。在分布式数据库、分布式文件系统等应用场景中,Gossip-style antientropy protocol被广泛应用于数据同步和备份。

CRAQ的强一致性协议不支持分区操作下的写操作,尽管分区链段可以退回到只读操作。BASE[19]和Brewer的CAP猜想[9]考虑了一致性、可用性和分区容忍性之间的权衡。

8、结论(Conclusions)

本文介绍了CRAQ的设计和实现,它是强一致性链复制方法的接替。CRAQ侧重于扩展对象存储的读吞吐量,特别是对于以读为主的工作负载。它通过支持分配查询来实现这一点:也就是说,将读取操作划分到链的所有节点上,而不是要求它们全部由单个主节点处理。虽然看起来很简单,但CRAQ展示了具有显著可伸缩性改进的性能结果:与链长度成正比,几乎没有写争用。三节点链的吞吐量提高了200%,七节点链的吞吐量提高了600%,令人惊讶的是,当对象更新很常见时,吞吐量仍然有显著提高。

除了这种改进链式复制的基本方法之外,本文还重点介绍了在各种高级应用程序中使用链式复制作为基础的实际设置和需求。随着我们继续开发用于多站点部署和多对象更新的CRAQ,我们正在努力将CRAQ集成到我们正在构建的其他几个需要可靠对象存储的系统中。其中包括支持动态服务迁移的DNS服务、对等辅助CDN[5]的集合服务器以及大型虚拟世界环境。探索这些应用程序在使用CRAQ的基本对象存储、广域优化和用于单键和多对象更新的高级原语方面的功能仍然是未来有趣的工作。


文章作者: wck
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 wck !
评论
  目录