速来报名!AICon北京站鸿蒙专场~ 了解详情
写点什么

无损性能的 Amazon DynamoDB 分布式事务:TransactGetItems 和 TransactWriteItems 详解

作者:Akshat Vig

  • 2023-12-15
    北京
  • 本文字数:5765 字

    阅读完需:约 19 分钟

大小:2.88M时长:16:46
无损性能的Amazon DynamoDB分布式事务:TransactGetItems和TransactWriteItems详解

我们是否能够以可预测的性能支持大规模的事务呢?本文将探讨为何事务被认为与 NoSQL 数据库的扩展性相冲突,并带你了解如何为Amazon DynamoDB添加事务支持。

NoSQL 数据库


NoSQL 数据库(如 DynamoDB)因其灵活的数据模型、简单的接口、支持大规模的数据和高性能而获得了广泛采用。为了提供无限扩展的自动分区、容错副本以及可预测性能的低延迟访问,它们一般都牺牲了关系型数据库的核心特性,如 SQL 查询和事务。

 

Amazon DynamoDB(请不要与 Dynamo 混淆)为数十万个客户的应用程序和多个高流量的亚马逊系统(包括 Alexa、Amazon.com 站点和所有的亚马逊的运营中心)提供了支持。

 

2023 年,在 Prime Day 期间,亚马逊系统对 DynamoDB API 进行了数万亿次的调用,DynamoDB 保持了高可用性,同时提供了个位数毫秒的响应,峰值达到了每秒钟 1.26 亿次请求。

 

当 DynamoDB 的消费者请求 ACID 时,我们所面临的挑战是如何在不牺牲这一关键基础架构的决定性特性(即高可扩展性、高可用性和大规模下的可预测性能)的情况下集成事务操作。

 

为了阐述事务为何如此重要,我们介绍一个样例,它会使用不支持事务的 NoSQL 数据库构建一个应用程序,这个过程中仅会使用基本的PutGet操作。

事务


事务单元是作为单一逻辑单元一起执行的一组读写操作。事务与 ACID 属性密切相关:

 

  1. 原子性(Atomicity)确保事务中的操作要么全部执行,要么全部不执行,从而提供一种全有或全无的语义。

  2. 一致性(Consistency)确保操作结果对数据库产生一致和正确的状态。

  3. 隔离性(Isolation)允许多个开发人员同时读取和写入数据,确保并发操作的序列化。

  4. 持久性(Durability)确保在事务中写入的任何数据都是永久性的。

 

我们为何需要 NoSQL 数据库的事务呢?事务的价值在于它们能够帮助我们构建正确和可靠的应用程序,这些应用程序需要维护多项不变量(invariant)。这些性质的不变量通常会在各种应用程序中都能遇到。假设在一个电子商务应用程序中,某个用户 Mary 可以将一本图书和一支笔放到一个订单中一起购买。在这种情况下,其中的一些不变量可能是这样的:图书如果缺货的话,将会无法出售,同样,笔如果缺货的话,也会无法出售,而且 Mary 必须是合法的用户才能购买图书和笔。



图 1 简单的电子商务场景

 

但是,维持这些不变量很具挑战性,尤其是当应用程序的多个实例并行运行并同时访问数据时。此外,在发生节点故障等意外情况时,维持多项不变量会变得很具挑战性。事务为应用程序解决并发访问和部分区域故障这两个难题提供了解决方案,使得开发人员不必为了应对这两个难题编写过多额外的代码。

 

假设我们正在开发一个电子商务应用的客户端,该应用依赖于一个不支持事务的数据库来创建 Mary 的订单。我们的应用程序有三个表,分别是 inventory、customer 和 orders 表。当要执行购买操作的时候,我们都需要考虑什么呢?



图 2 三个独立的 NoSQL 表,分别为表示库存、消费者和订单

 

首先,我们需要确保 Mary 是经过验证的消费者。然后,需要检查图书是否有库存,并处于可销售的状态。还要对笔进行同样的检查,然后创建新的订单并更新库存中图书和笔的状态和数量。实现这一点的方法之一是在客户端编写所有必要的逻辑。

 

最关键的一点是,所有的操作必须以原子的方式执行,以确保最终状态具有正确的值,并在创建购买订单时,其他读取者不会看到数据库中不一致的状态。在没有事务的情况下,如果多个用户同时访问相同的数据,就有可能遇到不一致的数据。例如,一本图书被标记为已出售给 Mary,但是订单创建可能发生失败。事务提供了将这些操作作为一个逻辑单元来执行的方法,确保它们要么全部成功,要么全部失败,同时能够防止消费者观察到不一致的状态。



图 3 当遇到崩溃场景时,如果没有事务的话,我们会经历什么?

 

如果没有事务,在构建应用程序时,还需要克服其他潜在的隐患,比如网络故障和应用程序崩溃。为了缓解这些挑战,有必要实现额外的客户端逻辑来实现健壮的错误处理和韧性。开发人员需要实现回滚逻辑,删除未完成的事务。多用户的场景引入了另一层复杂性,需要确保存储在表中的数据在所有用户间保持一致。

事务与 NoSQL 的关注点


人们经常担心在数据库中实现事务需要面临一定的取舍。按照预期,NoSQL 数据库会提供低延迟的性能和可扩展性,但通常只提供具有一致延迟的GetPut操作。



图 4 事务能否提供可预测的性能?

 

很多 NoSQL 数据库没有提供事务,常见的问题包括破坏非事务性的工作负载、API 的复杂性和一些系统问题,比如死锁、竞争以及非事务性和事务性工作负载之间的干扰。有些数据库试图通过提供限制性的特性(如隔离级别或限制事务的范围)来解决这些问题,允许事务在单个分区(partition)中执行。其他的数据库则是对主键或散列键实施限制,或要求预先识别出会成为事务一部分的所有分区。

 

这些限制旨在提升系统的可预测性并降低复杂性,但它们是以牺牲可扩展性为代价的。随着数据库的增长并分割为多个分区,将数据限制在单个分区可能会导致可用性问题。

DynamoDB 事务的目标


当我们开始为 DynamoDB 添加事务支持时,团队的目标是为客户提供在特定区域(region)内对跨表的数据项执行原子和可序列化操作的能力,同时保证性能的可预测性,并且不影响非事务性的工作负载。

用户体验


让我们聚焦体验,探讨一下 DynamoDB 中提供事务支持的可选方案。传统上,事务以“begin transaction”语句启动,以“commit transaction”结束。在两者之间,用户可以写入所有的GetPut操作。在这种方式中,对单个项目的现有操作可以简单地视为隐式事务,它们均有单个操作组成。为了确保隔离,可以使用两阶段锁,同时通过两阶段提交实现原子性。

 

但是,DynamoDB 是一个多租户系统,允许长时间运行的事务可能会无限期地占用系统资源。对单例的 Get 和 Put 操作强制执行完整的事务提交协议会对不打算使用事务的现有用户的性能产生不利的影响。此外,引入锁会带来死锁的风险,从而严重影响系统的可用性。

 

因此,我们引入了两个新的单请求操作,即TransactGetItemsTransactWriteItems。与其他的 DynamoDB 操作相比,这两个操作会以原子和可序列化的顺序执行。TransactGetItems是为只读事务设计的,它能够实现从一致的快照中检索多个条目。这意味着只读事务相对于其他写事务是序列化的。

TransactWriteItems是一个同步和幂等的写入操作,允许在一个或多个表中原子地创建、删除或更新多个条目。

 

这样的事务允许在条目的当前值上包含一个或多个可选的前置条件。前置条件能够检查条目属性是否满足特定的条件,比如是否存在、是否为特定值或是否在数字范围内。如果不满足所有的前置条件,DynamoDB 将拒绝TransactWriteItems请求。我们不仅可以为修改的条目添加前置条件,还可以为事务中未修改的条目添加前置条件。

 

这些操作不限制并发性,不需要版本控制,不会影响单例操作的性能,并允许对单个条目进行乐观并发控制。所有的事务和单例操作都是序列化的,以确保一致性。通过TransactGetItemsTransactWriteItem,DynamoDB 提供了一个可扩展且经济高效的解决方案,满足了 ACID 合规性的要求。

 

我们考虑另外一个样例,它展示了在银行汇款场景中对事务的使用。假设 Mary 想给 Bob 转账。传统的事务包括读取 Mary 和 Bob 的账户余额、检查资产的可用性以及在TxBeginTxCommit代码块中执行事务。在 DynamoDB 中,我们可以使用TransactWriteItems操作,通过单个请求完成相同的事务行为:检查余额,并使用TransactWriteItems执行转账,这个过程无需TxBeginTxCommit

事务的高层级架构


为了更好地理解事务是如何实现的,我们深入了解一下 DynamoDB 请求的工作流。当应用程序请求Put/Get操作时,请求会被路由到一个由前端负载均衡器随机选择的请求路由器上。请求路由器利用元数据服务将表名和主键映射到一组存储节点集合,该节点集合中包含了要访问的数据条目。



图 5 路由器

 

DynamoDB 中的数据会在多个可用区中以多个副本的形式存储,其中一个副本会作为领导者。在执行Put操作时,请求会被路由到领导者存储节点,然后该节点会将数据传播到不同可用区的其他存储节点。当大多数的副本成功写入条目后,就会向应用程序发送完成响应。Get操作与之类似,只不过它会由单个存储节点来进行处理。在一致性读取的情况下,领导者副本会为读取请求提供服务。不过,对于最终一致性读取,三个副本中的任意一个均可为请求提供服务。

 

为了实现事务,我们引入了一个专门的事务协调者群(fleet)。群中的所有事务协调者均可负责任意的事务。当收到事务性的请求时,请求路由器会对请求进行必要的认证和鉴权,并将请求发送给其中的一个事务协调者。这些协调者会将请求路由到相应的存储节点,这些节点负责处理事务中相关的条目。收到存储节点的响应后,协调者会为客户端生成一个事务性的响应,表明事务成功或失败。



图 6 事务协调器

 

事务协议分为两个阶段,以确保原子性。在第一阶段,事务协调者会为要写入的条目向领导者存储节点发送一条Prepare消息。收到Prepare消息后,每个存储节点都会验证是否满足条目的前置条件。如果所有的节点都接受了Prepare消息,事务处理就会进入第二阶段。

 

在这个阶段,事务协调者提交事务并指示存储节点执行它们的写入操作。事务进入第二阶段后,会确保它在全局执行一次。协调者会重试每个写操作,直到所有的写入都成功为止。因为写入操作是幂等的,在遇到超时等场景时,协调者可以重新发送写入请求。



图 7 当事务失败时的场景

 

如果Prepare消息没有被某个参与的存储节点所接受,那么事务协调者就会取消事务。为了取消事务,事务协调者发送一个Release消息给所有参与的存储节点并发送响应给客户端,表明事务已经被取消。因为在第一个阶段没有数据写入,因此不需要回滚过程。

事务恢复


为了确保事务的原子性,并在出现故障时确保事务完成,协调者会在其总账(ledger)中维持每个事务及其输出的持久化记录。恢复管理器会定期扫描总账,找出尚未完成的事务。这样的事务会被分配给新的事务协调者,由其恢复事务协议的执行。由于提交和释放操作是幂等的,因此多个协调者同时处理同一个事务是可以接受的。

 


图 8 事务协调者和故障处理

 

一旦事务处理成功,它就会在总账中标记为已完成,表明无需采取后续的行动。在事务完成十分钟后,事务信息会从总账中删除,以支持幂等的TransactWriteItems请求。如果在十分钟的时间窗口内客户端再次发送相同的请求,则会从总账中查找相关信息,以确保该请求时幂等的。



图 9 事务协调者和总账

确保序列化

 

在事务处理过程中,使用了时间戳排序来定义事务的逻辑执行顺序。当收到事务请求时,事务协调者会使用其当前的时钟值为事务分配一个时间戳。分配了时间戳之后,参与事务的节点就可以无需协调地执行其操作。每个存储节点负责确保条目中涉及的请求请求按正确的顺序执行,并拒绝可能不按顺序执行的冲突事务。如果每个事务都按照分配的时间戳执行,就实现了可序列化。



图 10 使用基于时间戳的排序协议

 

为了处理事务的负载,大量的事务协调器会并行运行。为防止时钟不同步导致的不必要的事务中止,系统使用AWS提供的时间同步服务,使协调者群中的时钟保持同步。不过,即便时钟完全同步,由于延迟、网络故障和其他问题,事务到达存储节点的顺序也可能不一致。存储节点会使用存储的时间戳来处理以任何顺序到达的事务。

TransactGetItems


TransactGetItems API 的工作原理与TransactWriteItems类似,但不会使用总账,以避免延迟和节省开销。TransactGetItems为执行读取事务实现了无写入的两阶段协议。在第一阶段,事务协调者会读取事务所涉及的读集合中的所有条目。如果这些条目中有任何一项正在被其他事务写入,那么读取事务将会被拒绝。否则,读取事务将进入第二阶段。

 

在对事务协调者的响应中,存储节点不仅会返回条目的值,还会返回其当前已提交的日志序列号(log sequence number,LSN),代表了存储节点最后一次确认的写入。在第二阶段,条目会被重新读取。如果条目在两个阶段之间没有变化,读取事务会成功返回已获取的条目值。但是,如果在这两个阶段有任何的条目更新,读取事务就会被拒绝。

事务性与非事务性工作负载


为了确保不使用事务的应用程序避免出现性能下降,非事务性的操作会绕过事务协调器和两阶段协议。这些操作直接从请求路由器路由到存储节点,不会对性能造成影响。

重新审视事务的目标


我们刚开始提出的可扩展性问题又会怎样呢?我们看一下往 DynamoDB 添加事务后取得了哪些成果:

  • 传统的Get/Put操作没有受到影响,性能与非事务性工作负载相同。

  • TransactGetItems API 的工作原理与TransactWriteItems API 类似,但是不使用总账,以避免延迟和额外的成本。

  • 所有的操作在系统扩展时都保持相同的延迟。

  • 借助单请求事务和时间戳排序,我们同时实现了事务和可扩展性。



图 11 可预测的事务延迟

最佳实践


在 DynamoDB 上使用事务的最佳实践是什么呢?

  1. 幂等的写入事务:当调用 TransactWriteItems 时,我们可以包含客户端的令牌,以确保请求是幂等的。在事务中加入幂等性有助于防止无意中多次提交相同的操作而出现潜在的应用程序错误。使用 AWS SDK 时,该特性默认是可用的。

  2. 自动缩放或按需缩放:推荐启用自动缩放或使用按需缩放(on-demand)的表。这可以确保提供必要的容量,以便于有效处理事务性的工作负载。

  3. 避免批量加载事务:对于批量加载的场景,建议使用 DynamoDB 的批量导入特性,它比依赖事务更经济高效。

 

DynamoDB 事务在很大程度上受到了消费者宝贵反馈的影响,他们激励我们为了客户不断进行创新。我非常感激有这样一支优秀的团队陪伴我走过这段旅程。特别感谢 Elizabeth Solomon、Prithvi Ramanathan 和 Somu Perianayagam 审阅本文并分享他们的反馈意见以完善本文。你可以在 USENIX ATC 2022 上发表的论文中了解有关 DynamoDB 的更多信息,也可以在 USENIX ATC 2023 上发表的论文中了解有关 DynamoDB 事务的更多信息。

作者介绍


Akshat Vig 是 AWS 高级首席工程师。Akshat 自 DynamoDB 诞生以来就一直从事相关的工作。他是在 USENIX 上发表的 DynamoDB 论文的主要作者之一。DynamoDB 是世界上最大、最重要的分布式系统之一,是 AWS、亚马逊和当今互联网生态系统的基础。作为首席工程师,Akshat 解决了亚马逊多个服务中最棘手的分布式系统问题。他申请了近 100 项专利,曾在 IEEE 项目委员会任职,并在世界各地发表过主题演讲。他很高兴能解决分布式系统的下一项重大挑战。

 

原文链接:

Distributed Transactions at Scale in Amazon DynamoDB

2023-12-15 08:005930

评论

发布
暂无评论
发现更多内容

学习总结(训练营第九课)

看山是山

获得高手的精英思维,从写作开始。

叶小鍵

高手 万维钢 得到精英日课

Week 09 命题作业

Jeremy

「查缺补漏」巩固你的Redis知识体系

Kerwin

Java redis

Week9-课后总结 & 课后作业 JVM垃圾回收机制

Geek_165f3d

第9周作业

小胖子

9-2 秒杀系统的挑战和问题

burner

未来云原生世界的“领头羊”:容器批量计算项目Volcano 1.0版本发布

华为云开发者联盟

Kubernetes 容器 华为云 Volcano 元原生

《RabbitMQ》如何保证消息不被重复消费

Java旅途

RabbitMQ 消息队列

week9 学习总结

任小龙

架构师培训 -08 总结 JVM、秒杀

刘敏

9-1垃圾回收原理和秒杀系统

burner

产品经理【三句半】,说清你的【酸甜苦辣】

Java_若依框架教程

产品经理 产品设计

架构师训练营-week09 作业

GunShotPanda

第 09 周作业

Jam

JVM 垃圾回收原理及秒杀系统设计思路

Acker飏

week09 小结

Geek_196d0f

《深度工作》学习笔记(5)

石云升

读书笔记 专注 深度工作

JVM原理

朱月俊

week09总结

Safufu

换一种方式构建镜像

北漂码农有话说

第九周命题作业

菲尼克斯

第九周学习总结

菲尼克斯

一文教会你嵌入式网络模块的联网操作

良知犹存

物联网 网络 嵌入式

架构师训练营 -week09 学习总结

GunShotPanda

Week 09 学习总结

Jeremy

JVM系列之:从汇编角度分析NullCheck

程序那些事

Java JVM JIT

如何保存我们的资产

不在调上

架构师训练营 第九周 总结

CR

8.5小结

朱月俊

第九周总结

Jam

无损性能的Amazon DynamoDB分布式事务:TransactGetItems和TransactWriteItems详解_亚马逊云科技_InfoQ精选文章