摘要
面向用户的在线服务采用地理分布的数据存储来减少延迟并容忍部分故障,以提供快速且始终可用的体验。然而,地理分布并非没有代价;应用程序开发者需要处理弱一致性行为,并且缺乏构建高级复制数据类型的抽象机制,这导致了复杂的应用程序逻辑,并不可避免地将不一致性暴露给用户。一些商业分布式数据存储和多个学术提案提供了一致性级别的层次结构,更强的一致性保证会带来更高的延迟和吞吐量成本。然而,正确为操作分配合适的一致性级别需要细致的推理,并且通常是一个容易出错的任务。
在本文中,我们提出了 QUELEA,一种针对最终一致性的数据存储(ECDS)的声明式编程模型,它配备了一种契约语言,能够指定细粒度的应用层一致性属性。一个契约执行系统分析这些契约,并自动生成受保护方法所需的一致性协议。我们在一个现成的支持无协调事务的 ECDS 之上实现了 QUELEA。通过包括两个大型网络应用在内的几个基准测试,展示了我们的方法的有效性。
**关键词:**最终一致性、可用性、CRDT、公理合约、合约分类、分布式事务、SMT 求解器、可判定逻辑、Quelea、Cassandra、Haskell
作者:
- KC Sivaramakrishnan*, University of Cambridge, UK, sk826@cl.cam.ac.uk
- Gowtham Kaki, Purdue University, USA, gkaki@cs.purdue.edu
- Suresh Jagannathan, Purdue University, USA, suresh@cs.purdue.edu
Introduction
许多现实世界的网络服务(例如由 Amazon、Facebook、Google、Twitter 等公司构建和维护的网络服务)在数据中心内和数据中心之间跨多个副本复制应用程序状态和逻辑。复制不仅旨在提高应用程序吞吐量并减少用户感知的延迟,而且还旨在容忍部分故障而不损害整体服务可用性。
传统上,程序员依靠强一致性保证(例如线性化 [15] 或可序列化 [22])来构建正确的应用程序。虽然强一致性是一种容易表述的属性,但它掩盖了大规模分布式系统在非均匀延迟、可用性和网络分区方面的现实 [8, 14]。事实上,旨在提供“始终在线”体验的现代网络服务绝大多数都倾向于可用性和分区容忍度而不是强一致性。为此,已经提出了几种弱一致性模型,例如最终一致性、因果一致性、会话保证和时间线一致性。
在弱一致性下,开发人员需要注意并发的冲突更新,并且必须小心谨慎以避免不必要的不一致(例如,银行账户余额为负数,或购物车中出现已移除的商品 [13])。通常,这些不一致会从应用程序中泄露出来并被用户看到。最终,开发人员必须决定适合特定操作的一致性级别;可以理解,这是一个容易出错的过程,需要对应用程序以及底层数据存储的语义和实现有复杂的了解,而这些通常只有非正式的描述。尽管如此,选择正确的一致性级别不仅对正确性至关重要,而且对应用程序的可扩展性也至关重要。虽然选择比要求的更弱的一致性级别可能会引入程序错误和异常,但选择比必要更强的一致性级别可能会对程序的可扩展性和性能产生负面影响。
弱一致性还会阻碍程序的组合推理。尽管应用程序可能自然地以易于理解和富有表现力的数据类型(如 map、树、queue 或 graph)来表达,但地理分布式存储通常仅提供一组最小的数据类型,并带有内置冲突解决策略,如最后写入者获胜(LWW)寄存器、计数器和集合 [17, 26]。此外,虽然传统数据库系统通过事务实现可组合性,但地理分布式存储通常缺乏对数据的无限制可序列化事务访问。因此,在这种环境中工作需要适当地强制应用程序状态仅使用存储的功能来运行。
为了解决这些问题,我们描述了 QUELEA,一种用于 ECDS 的声明式编程模型和实现。QUELEA 的主要创新之处在于它是一种表达性合约语言,用于声明和验证细粒度的应用程序级一致性属性。程序员使用契约语言公理化地指定允许在复制数据类型上执行的合法执行集。
契约是使用原始一致性关系(例如可见性和会话顺序)以及标准逻辑和关系运算符构建的。契约执行系统将数据类型上的操作静态映射到存储上可用的特定一致性级别,并可证明地验证映射的正确性。本文做出了以下贡献:
- 我们引入了 QUELEA,这是 Haskell 的浅层扩展,支持对 ECDS 中发现的复制数据类型的描述和验证。契约用于指定细粒度的应用程序级一致性属性,并进行静态分析以将最有效和最合理的存储一致性级别分配给相应的操作。
- QUELEA 支持任意数据类型上的无协调事务。我们扩展了我们的契约语言以表达细粒度事务隔离保证,并利用契约执行系统自动为事务分配正确的隔离级别。
- 我们提供元理论来证明我们的合同执行系统的健全性,并确保只有在满足一致性的必要条件时才执行操作。
- 我们描述了 QUELEA 的实现,它是 Cassandra [17](一种著名的通用数据存储)上的透明垫片层。对一组真实世界的应用程序(包括类似 Twitter 的微博网站和类似 eBay 的拍卖网站)进行的实验评估说明了我们的方法的实用性。
本文的其余部分安排如下。下一节介绍系统模型。我们描述了最终一致性下编程的挑战,并在
System Model
在本节中,我们将描述系统模型,并介绍我们的契约语言所基于的基本关系。图 1 展示了我们系统模型的示意图。分布式存储由一组副本组成,每个副本存储一组对象(
每个对象都关联着一组操作。客户端通过调用对象上的操作与存储进行交互。特定客户端在存储上的一系列操作称为会话。通常,大量的客户端(因此也有许多会话)并发地访问数据存储。重要的是,客户端不知道某个操作被应用到了哪个副本;为了最小化延迟、负载均衡等原因,数据存储可以选择将操作路由到任意副本。例如,同一会话在同一对象上调用的 foo 和 bar 操作,可能会最终被应用到不同的副本上,因为当客户端调用
当在副本 1 上对对象
两个来自同一会话的效果
这个模型包含了与最终一致性相关的所有不一致性。这项工作的目标是为每个操作确定精确的一致性级别,以确保应用程序级约束不被违反(与鲁棒性相一致)。在下一节中,我们将具体描述在 ECDS 之上构建一致银行账户的相关挑战。随后,我们将说明我们的契约和规范语言如何利用基本关系
Motivation
考虑一下我们如何在 ECDS 上实现高可用性银行账户,并具有完整性约束,即余额必须为非负数。我们首先在 QUELEA 中实现银行账户复制数据类型(RDT),然后描述获得所需正确性保证的机制。
RDT Specification
QUELEA 的一个关键创新之处在于它允许向存储添加新的 RDT,这样就无需强制应用程序逻辑来利用存储提供的数据类型。此外,QUELEA 将数据类型的收敛语义(即如何解决冲突更新)与其一致性属性(即更新何时可见)分开处理。这种关注点分离允许对冲突解决进行操作推理,对一致性进行声明性推理。这些技术的结合增强了存储的可编程性。
我们假设银行账户对象提供三种操作:存款 deposit
、取款 withdraw
和获取余额 getbalance
,并假设如果账户余额不足,取款将失败。QUELEA 中的每个操作都属于以下类型,以 Haskell 语法编写:
1 |
|
操作采用效果列表(该对象的更新历史记录)和输入参数,并返回结果以及可选效果(只读操作不返回任何内容)。新效果(如果发出)将添加到当前副本的对象状态中,并异步发送到其他副本。QUELEA 中的银行帐户操作的实现如图 2 所示。
数据类型 Acc
表示银行帐户的效果类型。函数 sum
返回列表中元素的总和,sel1
返回元组的第一个元素。对于每个操作,hist
是某个副本中对象状态的快照。从这个意义上讲,RDT 上的每个操作都是原子的,因此适合顺序推理。在这里,getBalance
是一个只读操作,deposit
总是发出效果,而 withdraw
仅在帐户中有足够的余额时才会发出效果。我们以这种风格用几十行代码实现了大量用于现实基准的 RDT,包括购物车、拍卖和微博网站等。
Summarization
请注意,getBalance
的定义减少了对帐户的整个更新历史记录。如果我们要实现此银行帐户 RDT 的有效实施,我们需要帐户历史记录的摘要。直观地说,当前帐户余额总结了帐户的状态。具有历史记录 [Deposit 10, Withdraw 5]
的银行帐户明显等同于具有单个存款操作 [Deposit 5]
的银行帐户;我们可以用后者替换较早的历史记录,而商店的客户将无法分辨两者之间的区别。
这种可观察等价性的概念也可以推广到其他 RDT。例如,具有多次更新的最后写入者获胜寄存器等效于仅具有最后一次写入的寄存器。类似地,具有添加和删除操作集合的集合等效于具有从原始集合中添加一系列活动元素的集合。由于可观察等价性的概念特定于每个 RDT,因此程序员可以提供一个汇总函数 - 类型为 [e] -> [e]
- 作为 RDT 规范的一部分。银行账户的汇总函数为:
1 |
|
给定一个银行账户历史记录,汇总函数将返回一个新的历史记录,其中包含当前账户余额的单笔存款。我们的实现调用与 RDT 关联的汇总函数来减少副本维护的效果集的大小。
Anomalies under Eventual Consistency
我们的目标是为每个银行账户操作选择正确的一致性级别,以便 (1) 余额保持非负数,以及 (2) getBalance
操作永远不会错误地返回负余额。
考虑图 3(a) 中所示的执行。假设图中的所有操作都在同一个银行账户对象上进行,初始余额为零。会话 1 执行了一笔 100 的存款,随后在同一会话中执行了一笔 80 的取款。取款操作见证了存款并成功完成。随后,会话 2 执行了一笔取款操作,但重要的是,由于最终一致性,它只见证了会话 1 的存款,而没有见证随后的取款。因此,这笔取款也错误地成功了,违反了完整性约束。随后的 getBalance
操作,如果见证了所有之前的操作,将报告一个负余额。
很容易看出,防止并发取款操作可以消除这种异常。这可以通过坚持取款操作作为强一致性操作来实现。尽管进行了这种强化,getBalance
操作仍然可能错误地向用户报告一个负余额。考虑图 3(b) 中所示的执行,它由三个并发会话组成,分别执行存款、取款和 getBalance
操作,都在同一个银行账户对象上。如 vis
边所示,会话 2 中的取款操作 withdraw(50)
见证了会话 1 中存款 deposit(100)
的效果,得出余额充足,并成功完成。然而,getBalance
操作可能只见证了这次成功的取款,而没有见证因果前的存款,并向用户报告负 50 的余额。
在最终一致性下,用户还可能暴露于其他形式的不一致性。图 3(c) 显示了一个执行,其中会话中的 getBalance
操作没有见证同一会话中较早执行的取款操作的效果,可能是因为它是由一个尚未合并 withdraw
效果的副本提供的。此异常会导致用户错误地认为 withdraw
操作失败。
虽然很容易理解上述异常发生的原因,但找到适当的修复方法却并不容易。使 getBalance
成为强一致性操作肯定足以避免异常,但这真的有必要吗?考虑到强制执行强一致性的成本 [25, 28],除非没有可行的替代方案,否则最好避免施加如此严格的条件。探索这些替代方案的空间需要了解各种弱一致性替代方案在语义上的细微差别。
Contracts
QUELEA 通过让程序员声明应用程序级别的一致性约束作为合约(图 4)来帮助将操作映射到适当的一致性级别,这些合约公理化地指定了涉及该操作的允许执行集。在银行账户的情况下,任何不表现出前一节描述的异常的执行都是银行账户对象上的良好执行。通过指定每种数据类型的操作调用跟踪的合法执行集,QUELEA 确保该类型的所有执行都是良好范式的。
在我们的运行示例中,很明显,为了保持关键的完整性约束,withdraw
操作必须是强一致性的。也就是说,给定两个 withdraw
操作 withdraw
的合约
这里,withdraw
操作发出的效果。语法 withdraw
操作发出的效果,即 withdraw
操作发出的操作 withdraw
操作的合约,则认为是从 withdraw
的正确实现派生的。
为了防止 getBalance
操作永远显示负余额,有必要防止图 3(b) 中描绘的情景。设 getBalance
操作发出的效果。如果 withdraw
操作的效果 deposit
操作的效果 withdraw
操作的效果 getBalance
合约
为了防止图 3(c) 中描述的缺失更新异常,银行账户上的 getBalance
操作必须见证同一会话中在同一银行账户上执行的所有先前 deposit
和 withdraw
操作的效果。我们可以表达一个额外的 getBalance
合约
我们的合约语言提供了用于组合关系的运算符。语法
上述合约 getBalance
操作之前执行,并且在与 getBalance
操作相同的对象上应用,则 getBalance
操作必须见证这些前置操作的效果。
getBalance
操作的最终合约
直观上,这一规范禁止了图 3(b) 和 3(c) 中描述的 getBalance
异常的发生。
最后,由于对存款操作何时或如何执行没有限制,其合约简单为真。
From Contracts to Implementation
请注意,withdraw
和 getBalance
的合约仅表达应用程序级别的一致性要求,并不涉及底层存储的语义。编写合约时,程序员只需考虑在 QUELEA 系统模型下的应用程序语义。应用程序级别一致性要求到适当的存储级别保证的映射是自动在后台完成的。如何确保执行符合合约?挑战在于合约提供了一个声明性的(公理化的)执行规范,而需要的是一个操作过程来强制执行其隐含的约束。
一种策略是投机性地(speculatively)执行操作。在这里,操作在接收到客户端或其他副本时暂时应用。我们可以维护执行的运行时表现,并在运行时检查良好形成条件,如果执行不良好则回滚。然而,状态维护的开销和用户定义合约的复杂性可能会使这种技术在实践中不可行。
我们设计了一种静态方法。合约在定理证明器的帮助下进行分析,并静态地映射到特定的存储级别一致性属性,该证明器保证保留合约语义。我们称此过程为合约分类。鉴于存储级别一致性属性的多样性和复杂性,系统实现者通过在相同的合约语言中描述存储语义来参数化分类过程,该语言与用于表达操作合约的语言相同。在下一节中,我们将详细介绍合约语言,并描述针对特定存储语义的分类过程。
Contract Language
Syntax
我们的核心合约语言的语法如图 4 所示。该语言基于一阶逻辑 (FOL),并允许对类型化和非类型化效应变量进行前缀全称量化。我们使用特殊效应变量(
请注意,在契约中 withdraw
)。我们允许在类型中使用析取,以让效果变量覆盖多个操作名称。契约
在我们的契约语言中,量化自由命题是谓词表达的效果变量对之间的关系的合取、析取和蕴含。谓词关系的语法类以原始的
Semantics
QUEUELA 契约是程序执行的公理定义上的约束。图 5 总结了定义公理执行的相关工件。我们将公理执行形式化为一个元组
请注意,公理执行(
定义 1
一个公理执行
满足契约 当且仅当 。
Capturing Store Semantics
我们的契约语言的一个重要方面是它能够捕获存储级一致性保证以及应用程序级一致性要求。与 [10] 类似,我们可以严格定义各种存储语义,包括结合任何会话子集和因果关系保证以及多个一致性级别的语义。但是,为了我们的目的,我们确定了三个特定的一致性级别——最终一致性、因果一致性和强一致性,许多分布式存储通常提供这三个级别,具有可调(tunable)一致性,但延迟和可用性方面的开销会增加。
Eventual consistency
只要客户端可以访问至少一个副本,就可以满足最终一致性操作。在银行账户示例中,存款是最终一致性操作。虽然 ECDS 通常提供具有所有可能异常的基本最终一致性,但我们假设我们的存储提供了更强大的语义,可以保持高可用性 [2, 19];存储始终公开更新的因果关系。可以根据以下合约定义正式捕获此语义:
上述契约规定,如果因果关系上继 a 之后的某个效应 b 对
Causal consistency
因果一致的操作需要看到对象状态的一个因果一致的快照,包括在同一会话中执行的动作。后一个要求意味着,如果来自同一会话的两个操作 getBalance
操作需要因果一致性,因为它要求来自同一会话的操作必须是可见的,这在最终一致性下无法保证。相应的存储语义由以下定义的契约
Strong consistency
强一致的操作在网络分区下可能会无限期地阻塞。一个例子是对 withdraw
操作的全序契约。相应的存储语义由以下定义的契约
Contract Classification
我们的目标是将应用级别的操作一致性约束映射到适当的存储级别一致性保证,这些保证能够满足这些约束。在我们的契约语言中,能够表达这两种约束作为契约,使我们能够比较并确定一个操作(
定义 2
一个契约
被称为弱于 (表示为 ),当且仅当 。
在该蕴含式中的量化符绑定在
定义 3
一个公理执行
是形式良好的,如果以下公理( )成立:
顺序关系是无环的: ; 仅关联同一对象上的操作: ; 是一个传递关系: ; 是一个等价关系:
; ; 。
如果操作的契约
观察到契约
分类方案在图 6 中正式给出,定义了判断契约一致性级别的规则。例如,方案将 getBalance
契约(
除了将契约分类为一致性级别的三个简单规则外,分类方案还提供了一个判断契约是否形式良好的规则。契约是形式良好的当且仅当它在
Generality of Contracts
值得注意的是,我们的契约语言提供了一种通用方法来捕获应用程序级一致性属性,并且不依赖于特定的存储语义。特别是,相同的应用程序级契约可以轻松映射到具有不同一致性格的不同存储。为了说明这一点,让我们考虑 Terry 等人 [27] 提出的基于会话保证的一致刻画。Terry 等人提出了以下四个无法比较的会话保证,其语义在以下契约中捕获:
在该方案中,操作的一致性级别是上述保证的任意组合,它们形成图 7 所示的部分有序一致性格。该格中的每个元素对应于一个存储一致性级别,并由其契约表示。从上级元素到下级元素的边对应于相应契约之间的弱于关系。根据该方案对契约进行分类是在格中进行定向搜索,从底部开始,确定可以满足契约的最弱一致性级别。确定在最弱的一致性级别下契约可以被满足。根据这个方案,deposit
操作不需要任何保证,getBalance
需要 withdraw
不能被满足(
Soundness of Contract Classification
Operational Semantics
Soundness of Operational Semantics
Transaction Contracts
虽然针对单个操作的合约提供了程序员对象级别的声明式推理,但现实场景中经常涉及跨越多个对象的操作。为了解决这个问题,最近的几个系统 [2, 9, 26] 提出了最终一致的事务,以便组合多个对象上的操作。然而,考虑到经典的事务模型(如可串行性和快照隔离)需要跨副本协调,这些系统提倡在网络分区下仍可用的无协调事务(coordination-free transactions),但仅提供较弱的隔离保证。无协调事务具有复杂的一致性语义和广泛变化的运行时开销。与操作级别的一致性类似,选择正确的事务类型的责任在于程序员。这一选择进一步被单个操作的一致性语义所复杂化。
Syntax and Semantics Extensions
QUELEA 自动化了选择正确和最有效的事务隔离级别的过程。类似于针对单个操作的合约,程序员将合约与事务关联,声明式地表达一致性规范。我们扩展了合约语言,在无量词命题下引入了一个新的术语 sametxn
,该关系对来自同一事务的效果有效。
我们假设不属于任何事务的操作属于它们自己的唯一事务。虽然事务可能具有不同的隔离保证,但我们假设所有事务都提供原子性。因此,我们在
该原子性实际上保证了
该合约的语义如图 9(a) 所示。
Transactional Bank Account
为了说明声明式推理对于事务的实用性,考虑我们运行示例的扩展,使用两个账户(对象)——当前账户 (withdraw
、deposit
和 getBalance
操作,具有之前定义的相同合约。我们考虑两个事务——save(amt)
,将 amt
从当前账户转移到储蓄账户,以及 totalBalance
,返回各个账户余额的总和。我们的目标是确保 totalBalance
返回的对象状态一致快照的结果。QUELEA 代码如下:
classify
静态地将这些契约分配到存储提供的事务隔离级别之一;$()
是元编程语法,用于将结果拼接到程序中。atomically
构造在给定的隔离级别
虽然使两个事务可序列化可以确保正确性,但分布式存储很少提供可序列化的事务,因为它们的可用性要求和可扩展性的含义 [2]。幸运的是,这些事务可以通过更弱的隔离保证来满足。然而,尽管事务提供了原子性,异常仍然可能发生。例如,在 totalBalance
事务中,两个 getBalance
操作可能由不同的副本提供服务,每个副本有一组不同的已提交的 save
事务。如果第一个(第二个) getBalance
操作见证了第二个(第一个) getBalance
操作未见证的 save
事务,那么返回的余额将小于(大于)实际余额。如何选择最弱的隔离保证以防止这种异常并不立即明显。
相反,QUELEA 要求程序员简单地将一致性要求作为契约声明。由于我们希望两个 getBalance
操作见证同一组 save
事务,我们定义 totalBalance
事务的约束
上述定义中的关键思想是 txn
原语允许我们将不同对象上的操作关联起来。
save
事务只需要确保它执行的两个写操作以原子方式可见。由于这通过将它们组合在一个事务中来保证,save
不需要任何额外的约束,因此
Coordination-free Transactions
为了说明事务契约分类的实用性,我们识别了三种广为人知的无协调事务语义——已提交读取(RC)[7]、单调原子视图(MAV)[2] 和可重复读取(RR)[7],并说明了分类策略。我们的技术确实可以应用于不同的隔离级别格。
具有 ANSI RC 语义的事务仅见证已提交的操作。假设一个副本将缓冲事务更新,直到同一事务的所有更新都可用。一旦事务的所有更新都可用,缓冲的更新将对后续的客户端请求可见。这确保了事务的原子性。重要的是,RC 不包含任何其他保证。因此,实现 RC 的存储不需要副本间的协调。我们可以将 RC 表达如下:
请注意,上述定义与 save
操作是一个 RC 事务的例子。
MAV 语义确保如果事务 $ T_1 $ 中的某个操作见证了另一个事务 $ T_2 $ 的效果,那么 $ T_1 $ 中的后续操作也将见证 $ T_2 $ 的效果。MAV 语义对于维护外键约束、物化视图和二级更新的完整性非常有用 [2]。为了实现 MAV,存储只需跟踪运行事务见证的事务集 $ S_t $,并在某个副本上执行操作之前,确保该副本包含了 $ S_t $ 中的所有事务。因此,MAV 是无协调的。MAV 语义通过以下契约捕获:
其语义如图 9(b) 所示。
ANSI RR 语义要求事务见证数据存储状态的快照。重要的是,这个快照可以从任何副本中获得,因此 RR 也是无协调的。一个这样的事务的例子是 totalBalance
事务。RR 语义通过以下契约捕获:
其语义如图 9(c) 所示。
Classification
类似于操作级别的契约,在
Implementation
QUELEA 实现为 GHC Haskell 的浅层扩展,并在 Cassandra 上运行,Cassandra 是一个现成的最终一致性分布式数据(或备份)存储,负责所有数据管理问题(即,复制、容错、可用性和收敛性)。使用 Template Haskell 实现静态合约分类,借助 Z3 [31] SMT 解决器来完成证明义务。图 9 展示了整体系统架构。
复制的数据类型和各种一致性语义在 shim 层实现和强制执行。我们的实现支持数据类型操作的最终一致性、因果一致性和强一致性,以及事务的 RC、MAV 和 RR 语义。此功能完全基于 Cassandra 暴露的标准接口实现。从工程角度来看,利用现成的数据存储使实现仅包含大约 2500 行 Haskell 代码,这些代码被封装为一个库。
Operation Consistency
垫片层通过显式跟踪由于可见性、会话和相同事务关系而引入的影响之间的依赖关系,维护后备存储中对象子集的因果一致的内存快照。依赖关系跟踪类似于 [3] 和 [21] 中介绍的技术。由于 Cassandra 提供持久性、收敛性和容错性,因此每个垫片层节点仅充当软状态缓存,没有节点间通信,并且可以随时安全终止。同样,可以根据需求生成新的垫片层节点。