亚马逊AWS官方博客

Amazon DynamoDB 中的单表与多表设计

对于了解 Amazon DynamoDB 的人士来说,单表设计的想法是目前最令人费解的概念之一。与每个实体有一个表的关系概念不同,DynamoDB 表通常在一个表中包含多个不同的实体。

您可以阅读 DynamoDB 文档观看 re:Invent 讲座或其他视频,或者查看我的书,了解 DynamoDB 中使用单表设计的一些设计模式。我想在更高层次上探讨这个话题,重点介绍支持和反对单表设计的论点。

在这篇博文中,我们将讨论 DynamoDB 中的单表设计。首先,我们将介绍一些有关 DynamoDB 的相关背景,这将为数据建模讨论提供信息。然后,我们将讨论单表设计何时对您的应用程序有帮助。最后,我们将总结一些在 DynamoDB 中使用多个表可能会更好的实例。

DynamoDB 的相关背景知识

在深入探讨单表与多表设计的优点之前,我们先来了解一下 DynamoDB 的背景知识。限于篇幅,我们不能在这里详尽地涵盖所有内容,但我想谈谈与单表和多表辩论有关的几个要点。

当我们介绍这些内容时,有一个总体主题将它们联系在一起:DynamoDB 希望向您展示现实,以便您可以根据应用程序的需求做出正确的决策。大多数数据库提供对低级位的抽象。这些抽象使您可以灵活且更轻松地查询数据,但它们也对您隐藏了重要的细节。由于这些细节是隐藏的,因此数据库可能会以不可预测的方式扩展,或者使您难以理解随着使用量的增加,数据库将花费多少。

考虑到这一点,让我们回顾一下 DynamoDB 的一些独特功能。

依赖两种核心机制实现一致的扩展

最重要的是,DynamoDB 希望在应用程序扩展时提供一致的性能。无论您的数据库大小或并发查询数量如何,DynamoDB 都旨在为所有操作提供相同的个位数毫秒响应时间。

为此,DynamoDB 依赖两种核心机制:分区和 B-tree。凭借这些坚实的基础,DynamoDB 能够将表扩展到 PB 级数据和数百万个并发查询。

我们从分区开始。在传统的关系数据库中,将所有项目都存储在单个节点上。随着数据量或使用量的增长,您可能会增加实例大小以跟上步伐。但是,垂直扩展有其局限性,通常您会发现关系数据库的性能会随着数据大小的增加而降低。

为避免这种情况,DynamoDB 使用分区来提供水平可扩展性。DynamoDB 表中的每个项目都将包含一个分区键。在后台,DynamoDB 将您的数据库分成称为分区的分段(如下面的图 1 所示),每个分区最多可容纳 10 GB 的数据。

图 1:DynamoDB 数据库分成三个分区

图 1:DynamoDB 数据库分成三个分区

当向 DynamoDB 发出请求时,请求路由器层会查找给定项目的分区位置,并将请求路由到相应的分区以进行处理,如下面的图 2 所示。

图 2:请求路由到相应的分区以进行处理

图 2:请求路由到相应的分区以进行处理

随着表的增长,DynamoDB 可以无缝添加新分区并重新分配数据,以随工作负载进行扩展。元数据子系统保留了分区键范围到存储节点的映射,并可快速将您的请求路由到相关分区。

虽然分区可以实现水平扩展,但我们经常需要在单个请求中获取一系列相关项目。在这种情况下,DynamoDB 的第二种核心机制就派上了用场。B-tree 是维护已排序数据的有效方法。这在许多数据应用中非常有用,例如按字母顺序对用户名进行排序或按订单时间戳对电子商务订单进行排序。

DynamoDB 将每个分区上的项目存储在 B-tree 中,这些项目根据其分区键和(如果由表使用)排序键进行排序。该 B-tree 为查找某个键提供了对数时间复杂度。在数据子集上使用 B-tree 可以对具有相同分区键的项目进行高效的范围查询。

使用针对性 API 直接访问数据结构

分区和 B-tree 很有趣,但它们并不是 DynamoDB 独有的。每个 NoSQL 数据库都使用某种形式的分区来水平扩展,世界上的每个数据库都在索引操作中使用 B-tree(或近亲)。

DynamoDB 与其他数据库之间的主要区别在于,DynamoDB 如何在本机向您公开这些数据结构。没有查询计划器用来将您的查询解析为多步骤流程,以从磁盘上的不同位置读取、联接和聚合数据。除了核心分区和 B-tree 设置之外,没有灵活的索引策略。

DynamoDB 有一个针对性 API,可让您直接访问项目及其基础数据结构。此 API 分为两大类。对各个项目执行基本的 CRUD 操作 — PutItem、GetItem、UpdateItem 和 DeleteItem。这些操作需要完整的主键,您可以将它们视为等同于哈希表中的简单查找。

DynamoDB API 的第二类包括查询操作,这是一项 fetch many 操作,允许您在单个请求中检索多个项目。您可以使用它来获取特定客户的所有订单,或获取 IoT 传感器的最新读数。

但即使是查询操作也被锁定,因为您必须提供对分区键的精确匹配,以便将其路由到单个分区来处理请求。它将基于分区键的快速定位与 B-tree 的快速搜索和轻松的顺序读取相结合,从而在您扩展时提供高效的范围查询。

请注意 DynamoDB API 没有提供的内容。不能像在关系数据库中那样使用 JOIN 操作来合并多个表。也不能使用 count、sum、min 或 max 等聚合来压缩大量记录。这些操作不透明,并且高度依赖于受查询影响的记录数,而这些记录数无法事先知道。为了在任何规模提供一致的性能,DynamoDB 删除了更高级别的构造(如联接和聚合),这些构造会显著增大变数。

基于读取字节数和写入字节数的透明计费

在上一节,我们看到,DynamoDB 在关键数据结构的基础上构建并直接向您公开这些结构,从而让您对性能一目了然。这样,它消除了使用不透明查询计划器的数据库的神秘和不可预测性。

DynamoDB 在成本方面也是如此。将数据写入磁盘会产生成本。从磁盘读回数据也会产生成本。而且,这两项成本都会随着您所读取或写入数据的增加而增加。DynamoDB 通过直接根据您写入表和从表中读取的字节数计费,使您清楚地了解这些基础成本。

DynamoDB 的计费基于写入容量单位(WCU)和读取容量单位(RCU)。快速扫一眼,可以发现,一个 WCU 允许写入 1 KB 数据,而一个 RCU 允许读取 4 KB 数据。您可以提前预置读取和写入容量单位,也可以使用按需计费方式为收到的每个读取或写入请求付费。

我喜欢这种透明度。我经常跟使用 DynamoDB 的人员说的一点是“盘算一下”。 如果您对自己将拥有多少流量有一个粗略的估计,可以盘算一下,弄清楚将要花费多少。或者,如果您要在两种数据建模方法之间做出选择,可以盘算一下,看看哪种方法更便宜。

正如我们将在下一节中看到的那样,这种透明计费模型是在数据建模中使用单表设计原则的原因之一。

为何以及何时使用单表设计

我们已经了解 DynamoDB 的一些基础知识,下面来看看为什么您可能希望在应用程序中使用单表设计。

在开始之前,我想指出的是,关于单表设计的建议适用于单项服务。如果您的应用程序中有多项服务,则每项服务都应拥有自己的 DynamoDB 表。您应考虑一个类似于 RDBMS 实例的 DynamoDB 表,只要您有单独的 RDBMS 实例,就应该有单独的 DynamoDB 表。

此外,如果存在关于何时将实体合并到单个表中的经验法则,那就是:一起访问的项目应该存储在一起。如果您将数据存储在 RDBMS 的两个不同表中,并且经常联接这两个表,则应考虑将它们存储在单个非规范化的 DynamoDB 表中。如果不是这样,您愿意的话,通常可以将它们分开。

在 DynamoDB 中使用单表设计有三个功能方面的原因,另外还有一个额外的非功能方面的原因。现在我们分别来看一下。

使用单表设计在 DynamoDB 中提供物化联接

在后台部分,我们看到 DynamoDB 有一个针对性 API,并且它删除了常见的 SQL 操作(如 JOIN)。

但是联接很有用! 如果我有一对多或多对多关系,则可能有一种访问模式,在这种模式下,我获取一个项目,但也需要一些有关相关父项目的信息。

刚开始使用 DynamoDB 时,我使用的是类似于关系数据库的多表系统。因为 DynamoDB 不提供开箱即用的联接,我只是在应用程序代码中实施了联接。例如,假设我想为一种特定访问模式获取某位客户和该客户的订单。为此,我会先获取该客户记录,获取其主键,然后获取具有外键关系的相关订单。

图 3:从多表设计中获取信息

图 3:从多表设计中获取信息

但用该方法处理这个使用案例效率低下。从应用程序到 DynamoDB 的 I/O 是应用程序处理过程中最慢的部分,我通过发出两个单独的顺序查询来执行两次该操作,如前面的图 3 所示。

如果我知道这将是我的应用程序中的常见访问模式,我可以依靠 DynamoDB 的核心数据结构和 API 来优化它。我可以预先联接相关项目并提前实现联接,而不是单独发出顺序请求。如果我为客户项目提供与相关订单项目相同的分区键,则它们将位于同一个分区并根据排序键进行排序。然后,我可以使用查询操作来获取单个高效请求中的所有项目,如下面的图 4 所示。

图 4:从单表数据库中获取信息

图 4:从单表数据库中获取信息

这是使用单表设计的典型示例。我们可以处理涉及异构项目的访问模式,同时仍能从 DynamoDB 获得我们期望的一致性能。

使用单表设计来降低大型项目的成本

使用单表设计原则的第二个原因是降低您的 DynamoDB 成本。

在许多 NoSQL 系统中,建议您创建包含相关嵌套数据的更大、非规范化的文档。这是因为您经常将相关数据一起获取,将数据作为单个记录保存在一起比将它们作为单独的记录保存更有效。

虽然这种策略可能是个不错的建议,但注意不要做得太过火。请记住,DynamoDB 使您能够清楚地了解成本,并且读取和写入成本会随着项目大小的增加而增加。

通常,大型项目具有两组不同的属性:一些移动缓慢的较大属性与快速移动的较小属性相结合。例如,想想一个代表 YouTube 上视频的项目。有很多关于视频本身数据,例如可用的各种分辨率及其位置、视频说明、字幕和信息卡。信息量大,而且几乎不会更改。

但是,YouTube 视频还有一个计数器,显示视频的观看次数。这是一个很小的属性(几位数据),但它每天可能会增加数千次。如果您将此计数器存储在与视频元数据相同的项目上,则每次想要增加观看次数时,都可能需要支付多个 WCU。这种模式如下面的图 5 所示。

图 5:将 ViewCount 属性作为项目元数据的一部分递增

图 5:将 ViewCount 属性作为项目元数据的一部分递增

相反,您可以将该项目分成两个项目:一个视频项目和一个 VideoStats 项目。录制视图时,您只需递增较小的 VideoStats 项目。显示视频时,您可以使用查询操作获取这两个项目,如下面的图 6 所示。

图 6:将 ViewCount 属性与元数据分开递增

图 6:将 ViewCount 属性与元数据分开递增

通过这种模式,我们可以利用 DynamoDB 的成本透明度和无架构特性来优化我们的应用程序需求。

使用单表设计来减轻您的运营负担

使用单个 DynamoDB 表的第三个原因是为了减轻您的运营负担。这里的数学运算很简单 — 您拥有的东西越少,需要监控的东西就越少! 这里的逻辑稍微复杂一些,特别是考虑到 AWS 对 DynamoDB 所做的改进。

我们先来看看这个论点的强有力版本。虽然 DynamoDB 表与关系数据库中的表有一些共同点,但也有许多不同之处。最重要的是,每个 DynamoDB 表都是基础设施的一个独立部分。该基础设施需要配置、监控、警报和备份。如果您的应用程序中有 15 个不同的实体,因此有 15 个不同的 DynamoDB 表,这可能会成为一种负担。

从逻辑上说,通过将数据合并到一个表中,可以减轻您的运营负担。您只需监控一个表,而不是 15 个表。此外,AWS 对 AWS 区域中的表数量以及并发控制面板操作数量也有限制。如果您有一个较大账户,或者正在按客户进行表格分段,则会达到这些限制。

对多个实体使用单个表甚至可以提高一般表的性能。DynamoDB 在分区级别提供容量暴增,使您能够尽自己所能,在短时间内超出预置吞吐量。如果您有一个更大的表,项目将分布在更多分区中,从而减小潜在的节流爆炸半径。

最后,通常情况下,少数访问模式在应用程序的读写容量中占据主导地位。通过将实体合并到单个表中,使用频率较低的访问模式可能会融入核心模式的过剩容量中。

尽管如此,我认为这个论点在我的考虑因素中只占很小的部分。DynamoDB 表的维护非常轻松,而且大部分可通过 AWS CloudFormation 或其他基础设施即代码工具实现自动化。您可以配置自动扩展,设置警报或通过时间点恢复自动启用备份。

此外,DynamoDB 进行了许多改进,进一步减少了这种争论。2018 年,DynamoDB 宣布推出自适应容量,可将预置容量分散到表中非常需要容量的分区。然后,在 2019 年,DynamoDB 宣布推出按需计费模式。如果管理容量对您来说是一种负担,您可以切换到按需模式,只需为所需的资源付费。

额外好处:它迫使您正确地思考 DynamoDB

我喜欢帮助人们学习 DynamoDB 并很好地使用它,我的最后一个理由带点私心,是关于学习过程,而不是任何特定的应用程序或运营好处。这个论点如下所示:在 DynamoDB 中强调单表设计有助于明确这样一个信息,即用 DynamoDB 建模与您在关系数据库中的建模不同。

许多 DynamoDB 新用户像我所做的那样,将他们的关系数据模型直接迁移到一堆 DynamoDB 表。然后,他们通过内存中的联接和聚合,在应用程序中编写错误版本的查询处理器。这种方法会导致应用程序运行缓慢,无法获得 DynamoDB 所能提供的可扩展性和可预测性。

告诉人们大多数服务都可以使用单个表,这表明 DynamoDB 表不能直接与关系数据库表进行比较。用户更深入地挖掘,意识到他们需要首先关注访问模式,而不是抽象的、规范化的数据版本。他们学习了对 NoSQL 数据存储进行建模以优化性能的关键策略。

在这一点上,我认为学习 DynamoDB 可以让您成为更优秀的开发人员。因为 DynamoDB 正在向您公开这些基础,您了解到之前使用的一些抽象并不是免费的。即使回到关系数据库,您也会更加仔细地研究联接和聚合等功能,因为您知道性能配置文件与按索引字段选择单个记录并不相同。

在 DynamoDB 中使用多个表的原因

在上一节,我们看到了支持 DynamoDB 中单表设计的核心论点。但在一个表和多个表之间进行选择是微妙的,某些情况下,多个表对您来说也许是不错的选择。下面我们就来探讨一下其中的一些论点。

您对 DynamoDB Streams 有多种需求

Amazon DynamoDB Streams 是我最喜欢的 DynamoDB 功能之一。我可以得到一个完全托管的更改数据捕获流,其中包括针对 DynamoDB 表的每项写入操作的记录。然后,我可以使用无服务器计算处理该更改流,以更新聚合、跨系统共享事件或提供分析系统。

DynamoDB Streams 的缺点之一是对并发使用者的数量有限制。DynamoDB 将您的 DynamoDB 流上的并发使用者限制为不超过两个。如果有其他使用者,您的流处理请求将受到限制。

在包含十个或更多实体的单表设计中,超过此限制的情况并不少见。也许新的订单项目需要触发 AWS Step Functions 工作流来处理订单,而新的客户注册需要通过 Amazon EventBridge 将注册广播到其他服务。在某些时候,您需要进行一些艰难的权衡,例如向单个流使用者添加更多逻辑,或者使用 Amazon Simple Notification Service(Amazon SNS)和 Amazon Simple Queue Service(Amazon SQS)队列将一组 FIFO SNS 主题连接起来,在保留排序语义的同时提供事件扇出。

如果是这种情况,将单个表拆分为多个针对性表可能会更容易。每个表可以容纳较少数量的实体,并有一个更有针对性的 DynamoDB 流管道。

您想要更轻松的导出以进行分析

DynamoDB 是一种在线事务处理(OLTP)系统,旨在对各项记录进行大量并发更新。想想常见的面向用户的交互 — 下订单或对讨论线程发表评论。它擅长处理这些工作负载,并且在处理请求中的少量记录时,允许原子操作、低延迟和 ACID 事务。

相反,DynamoDB 不擅长在线分析处理(OLAP)操作。这些是内部分析操作,想想看,一名业务分析师想要按类别和地区了解每周的销售情况,或者一个营销团队想要查找过去一年里最受欢迎的社交媒体帖子。这些操作不需要高吞吐量或低延迟,但确实需要高效地扫描大量数据并执行计算。

为了满足这些 OLAP 需求,大多数 DynamoDB 用户会将其数据导出到专为大规模聚合而构建的外部分析系统,如 Amazon Athena 或 Amazon Redshift。但是,将数据从 DynamoDB 传输到分析系统的机制可能因数据的具体情况而异。

一些用户使用上面讨论的 DynamoDB Streams 功能将记录流式传输到他们的分析数据库中。通常,这涉及在加载到 Amazon Redshift 之前使用 Amazon Kinesis Data Firehose 在 Amazon Simple Storage Service(Amazon S3)中缓冲数据,或者干脆使用 Data Firehose 将数据发送到 S3 以供 Athena 查询。这种模式对不可变的较大数据集效果更好,因为完全导出表也许不可行,而且数据的不可变特性适用于 OLAP 类型的系统。

但是有些数据集更小、更易变,并且有不同的需求。想想应用程序中的用户或客户实体。这些实体在您的数据仓库中非常重要,可以与其他较大的、类似事件的表进行联接,从而为事件增添色彩。由于这些实体是可变的,我们希望将数据仓库定期更新为当前版本。数据仓库不能很好地处理随机更新,因此通常最好是导出一个完整的、更新过的表,以加载到系统中。这通常使用 Redshift COPY 命令或 DynamoDB 导出到 S3 操作

如果您在单个 DynamoDB 表中包含两种类型的数据,这会使您的分析需求更难以满足。进行全表导出会比较慢,因为您将导出所有较大的不可变数据集以及较小的可变数据集。通过将不同的数据拆分为不同的表,您可以根据数据的特定形状和需求自定义分析管道。

您不需要这些好处,而且更容易推理

使用多个表的最后一个原因主要是单表设计情况的否定。

如果以上单表设计的好处对您来说都不重要(您不在单个请求中设置物化联接来获取异构项目,也不将项目分解成单独的部分,也没有被运营负担吓倒),而且如果多表设计对您来说更容易推理,那么跳过单表设计也是可以的。

我们来更深入地看看上面提到的关于在 DynamoDB 中进行联接建模的第一个好处。您确实希望避免这样一种规范化模型,即依赖于应用程序内联接和对 DynamoDB 的多个顺序请求。但这并不一定意味着我们必须使用具有物化联接的单表设计。通过构建表以并行(而不是按顺序)获取这两组数据,我们可以获得所述的大部分好处。

回想一下上面的示例,我们需要连续向 DynamoDB 发出请求:一个是通过电子邮件地址获取客户记录,另一个是通过分配的客户 ID 获取订单。切换到单表设计模型时,我们为两个实体提供了一个 CustomerEmailAddress 的分区键,这样我们就可以通过单个查询操作来获取它们。

此建模切换不需要两个不同的表。如果我们的订单表使用 CustomereMailAddress 作为分区键,我们可以在获取客户记录的同时获取订单记录。

这比发出单个请求要慢一些,因为您将等待两个请求中最慢的请求返回。而且您需要多支付一点费用,因为在计算 RCU 时,您无法获得查询操作的聚合优势。但是,对于客户端在第一页之外获取的分页实例,您也可能无论如何都需要实施类似这样的东西,即使在单表设计中也是如此。如果您和您的应用程序可以接受这些权衡,那么您可以选择多表设计。

请注意,这不是逃避了解 DynamoDB 工作原理的借口! 不应像关系数据库那样建模 DynamoDB,而应学习 DynamoDB 数据建模原则。在我多年来所做的几乎所有模型中,如果需要,我们可以将两个单独的表合并成一个表,因为它们首先关注访问模式,然后设计主键来处理这些访问模式。

结论

在这篇博文中,我们了解了使用 DynamoDB 进行单表设计。首先,我们从 DynamoDB 的一些相关背景开始,这对于单表的讨论非常重要。然后,我们看到了在 DynamoDB 中使用单表设计的一些理由。最后,我们探讨了要在应用程序中使用多个表的一些原因。

我最后的体会是:首先要确保了解使用 DynamoDB 建模的原则。DynamoDB 不是关系数据库,您不应该像使用关系数据库那样使用它。学习曲线看起来很陡峭,但实际上您只需学习三或四个关键概念,其他一切都由此而来。一旦您掌握了这些基础知识,就可以更明智地决定在应用程序中使用多少表。

如果您想了解 DynamoDB 数据建模,有很多不错的资源可供使用。我撰写了《The DynamoDB Book》,这是一本使用 DynamoDB 进行数据建模的综合指南,其中讲解了基本概念以及实际应用示例。我还强烈推荐 DynamoDB 开发人员文档,因为 DynamoDB 团队在解释如何正确思考 DynamoDB 方面做得很好。不要害怕,深入了解并尝试一下。DynamoDB 社区是一个友好且不断发展的社区,您在学习过程中会发现很多支持。

我要感谢 Joseph Idziorek、Jeff Duffy 和 Amrith Kumar,他们在我撰写这篇博客文章时提出意见并进行评论。


关于作者

Alex DeBrie 是 AWS 大侠,也是《The DynamoDB Book》的作者,这是一本使用 DynamoDB 进行数据建模的综合指南。他是一名独立顾问,与各种规模的公司合作,协助进行 DynamoDB 数据建模和无服务器 AWS 架构实施。在业余时间,他喜欢运动,并与妻子和四个孩子共度时光。欢迎在 Twitter 上关注他。