跳至主要内容

性能与可扩展性全面释放:掌握 DynamoDB 的单表数据库设计

一张表拿下全场:简化、扩展,并为您的 NoSQL 数据库加速!
· 12分钟 阅读

使用 AI 探索

欢迎走进 DynamoDB 的世界,在这里,高效与无限扩展在一张表中交汇。在这篇全面的教程中,我们将开启一段赋能之旅,揭示 NoSQL 单表数据库设计的真正潜力。

学习如何在保持统一结构威力的同时,简化复杂的数据模型,使其能够轻松容纳您不断增长的需求。

无论您是熟悉 NoSQL 数据库的资深开发者,还是刚刚踏上数据库之路,都请准备好通过强大的数据架构,为您的应用解锁全新的可扩展性!

什么是 NoSQL?

在开始之前,理解 NoSQL 与关系型数据库的区别、各自适用的场景,以及这些技术被发明的原因非常重要。这需要我们简要回顾一下数据处理的历史。

您可以在另一篇博文中阅读更多有关NoSQL 数据库在数据科学中的重要性。

关系型数据库

关系型数据库是一种众所周知且被广泛使用的技术,自 1970 年代以来一直存在。它解决的两个主要问题是存储和参照完整性。

数据通常会被“规范化”,一般到第三范式,这样既能缩小数据占用空间,又能确保对数据的任何更新与其余数据保持一致。

历史上,持久化数据占用空间的大小至关重要,因为数据中心中最昂贵的组件是硬盘。如今情况已不同,CPU 成为最昂贵的组件,而数据存储成本已大幅下降。这并不意味着关系型数据库已被淘汰;它在以下场景仍然适用:

  • OLAP
  • 数据仓库
  • 临时查询
  • 访问模式不明确的应用
  • CMS / 无头 CMS

NoSQL 数据库

数据压力是指系统在合理的成本和/或时间内处理给定数据量的能力。

NoSQL 数据库诞生于 1990 年代末,旨在应对随着应用数据量持续增长而影响关系型数据库性能的数据压力问题。

在关系型数据库中,CPU 可被视为主要瓶颈之一,因为数据库服务器需要费力地获取并拼接规范化数据,生成应用通常消费的反规范化视图。

可以通过增加只读副本服务器来缓解这一问题,但 NoSQL 采取了不同的方法,将应用数据以反规范化形式存储,方便应用直接消费。这意味着数据占用空间会比关系型数据库更大,但能减轻 CPU 负载,使数据库服务器实际上只是一个简单的路由器,通过哈希算法指向数据中心硬盘上的存储位置。NoSQL 数据库的典型用例如下:

  • OLTP
  • 访问模式明确的应用
  • 需要水平扩展以支撑区域级或全球级高需求量的应用

大量研究为我们提供了足够的客观数据,可以用图表展示大规模下 SQL 与 NoSQL 的性能对比。

大规模操作对比图

图片来源

最后要明确的一点是,存储在 NoSQL 数据库中的数据仍然是关系数据。若数据不是关系型的,我们会将其存放在简单的文件存储中。

NoSQL 反模式

我们不会以建模关系型数据库的方式来建模 NoSQL 数据库,因此将规范化设计直接翻译为使用多表并相互连接的 NoSQL 模式属于反模式,因为 NoSQL 既没有 join 操作符,也没有参照完整性。

在 NoSQL 中,一张表等同于关系型数据库中的一个目录。应当将数据建模为反规范化的形式,便于应用直接消费。

另一个反模式是热点键(Hot Keys)。这意味着大部分(甚至全部)访问请求都会命中同一个存储节点,说明键空间设计不当。

访问热力图可能如下所示:

image14.png

DynamoDB

AWS DynamoDB 是一项全托管、无服务器的宽列键值存储,这意味着它支持在一张表中存储大量条目(或行),且这些条目不一定拥有相同的属性。支持的数据类型如下:

  • 标量类型:字符串、数字、二进制、布尔、空
  • 文档类型:具有嵌套属性的复杂结构(如 JSON)
  • 集合类型:字符串集合、数字集合、二进制集合

如前所述,当应用访问模式明确且需要支持大量 TPS 时,应使用 NoSQL。DynamoDB 能够扩展到任意工作负载,具有可预测的快速且一致的响应时间,在低延迟(10 - 20ms)下可达每秒 400 万次事务(TPS)。

DynamoDB 的工作原理

每个条目(或行)都有一个分区键,它能唯一标识该条目并决定数据在底层存储中的分布。

条目可以选择性地拥有一个排序键,它决定了在一个分区内数据在磁盘上的存储顺序。排序键允许我们对分区进行复杂的范围与过滤查询(尽管过滤操作是在客户端层执行,而非数据库层)。

分区键用于创建哈希索引,然后按照此哈希将每个条目布局到一个虚拟键空间上。这个虚拟键空间被划分为若干段,这些段映射到物理存储设备,并可根据存储数据量的变化动态扩展或收缩。

这就是为什么 DynamoDB 在任意规模下都能保持快速且一致:因为数据库引擎充当着从哈希键到物理存储的路由服务。

使用 NoSQL 进行数据建模

在本教程中,我们将为一家名为 Daintree.com 的在线商店建模。

(趣闻:位于澳大利亚昆士兰的丹特里雨林是地球上最古老的雨林之一,估计约有 1.8 亿年历史!)

首先,让我们创建一个高度简化的实体关系图(ERD),以便形象化我们将要建模的内容。

实体关系图(ERD)

从中我们可以推断,客户可以创建购物篮,并添加多个数量不同的商品。

一个商品属于某一分类,而该分类又可以拥有父分类。

购物篮可以成为订单,订单又可以拆分为多笔付款。

如前所述,这只是对构成在线购物应用的各实体关系与属性的高度简化视图,但足以满足本教程的目的。

定义访问模式

在实现关系型数据库时,我们不需要过多担心这一步。SQL 是一种非常强大的语言,只要模式设计正确,就能让我们灵活地以任意方式查询和操作数据。而在 NoSQL 的单表设计中并非如此,因为我们需要将数据以反规范化形式存储,供应用直接消费。

以下是我们可能希望为在线购物应用支持的访问模式:

  • 按分类查询所有商品
  • 查询某父分类的子分类
  • 按客户查询所有订单
  • 查询某订单的所有付款
  • 查询某位客户的特定订单
  • 查询某客户购物篮中的所有条目
  • 按客户订单历史查询所有商品

表建模

NoSQL workbench 是 AWS 提供的用于为 DynamoDB 建模 NoSQL 表的免费工具。

进入 Data modeler,创建一个新的数据模型和一张新表:

image5.png

我们将把主键属性保持为通用字符串 PK 和 SK:

image8.png

对于其他属性,我们会存储 entity_type,以及两组 GSI 的 PK 和 SK 主键属性。

GSI 是全局二级索引,它是您表的一份副本,由 DynamoDB 以异步方式保持同步,但使用另一组主键属性对进行存储。这是单表支持多种访问模式的关键机制。后文将详细介绍此主题;现在只需理解这些键会存储相关实体的主键。

我们还需要添加的其他附加属性是所有非键属性(注意,此步骤仅用于设计阶段,在实际创建表时不会显式定义):

image11.png

接下来,使用前一步定义的键添加两个 GSI:

image9.png

点击保存,然后点击可视化数据模型。此时您应能看到新建但为空的表。点击编辑表,然后在右上角编辑数据;这将允许您添加新行:

image7.png

数据前缀

在开始添加数据之前,我们需要为每个实体定义主键前缀:

product

p#

category

c#

customer (user)

u#

basket

b#

basket item

bi#

order

o#

order line

ol#

payment

py#

invoice

i#

数据建模

客户数据

大多数访问模式都围绕客户展开,因此在主索引中,我们将使用客户 ID 作为主分区键,且实际的客户记录也通过将排序键设置为客户 ID 来标识。

随后,我们可以使用相同的分区键但不同的唯一排序键,轻松添加客户与订单、客户与购物篮的关系。

我们的聚合视图现在如下所示:

image12.png

我们现在可以通过一个命令查询整个客户分区,从而一次性查询到这三条记录:

export const getAllCustomerRecords = async (id: string) =>
  dynamoClient
    .query({
      TableName: TABLE_NAME,
      KeyConditionExpression: "pk=:pk",
      ExpressionAttributeValues: {
        ":pk": valueToAttributeValue(addPrefix(id, CUSTOMER_PREFIX)),
      },
    })
    .then((result) => result.Items);

随着表的增长,这类查询可能并不高效,因为它会返回该客户有史以来的所有订单,以及其他客户记录(如购物篮和发票),但知道我们在需要时可以通过一次查询获取某客户的全部数据,依然是有价值的。

对表达式属性值做一个小调整,在排序键上使用部分匹配(begins_with),就可以分别查询:

  • 某客户的所有购物篮记录
  • 某客户的所有订单记录

关键的结论是,我们可以在 NoSQL 中建模一对一和一对多的关系。

分类

这些实体同样表示一对多的关系,即一个分类可以包含多个商品。

分类还具有层级关系,即一个分类可以有多个子分类,这意味着商品可以存在于分类层级树中的任意位置。

让我们以图书为例,将其拆分为虚构类(fiction)与非虚构类(non-fiction)。

image18.png

请注意,我们为子分类填充了 GSI 属性键,将GSI1_PK 设置为父分类 ID,将 GSI1_SK 设置为子分类 ID。这样我们就可以通过 GSI1 按分类查询所有子分类:

image19.png

同一个索引 GSI1 不仅可用于映射一对多关系。比如我们想通过邮箱查找客户;当前模型并不直接支持,除非我们在保存数据的方式上稍作修改:

image1.png

将客户邮箱地址与实体类型存为 GSI 的 PK 与 SK:

  • 确保客户邮箱地址唯一(仅对该 GSI 生效;若要在主索引上强制唯一性,需要采用其他方案)
  • 允许客户也可作为卖家出现(例如,若我们将其作为一种实体类型)
  • 允许我们通过邮箱而非 ID 查找客户

为帮助 DynamoDB 的数据分布,我们可以更进一步,将邮箱地址存为哈希值。

export const getCustomerByEmail = async (email: string) =>
  dynamoClient
    .query({
      TableName: TABLE_NAME,
      IndexName: "gsi1",
      KeyConditionExpression: "gsi1_pk = :email AND gsi1_sk = :entityType",
      ExpressionAttributeValues: {
        ":email": valueToAttributeValue(email),
        ":entityType": valueToAttributeValue(entityType),
      },
    })
    .then(({ Items }) => Items?.[0]);

到目前为止,您可能会疑惑,为何所有索引都采用通用名称,且键属性也只是通用的 PK 与 SK。答案现在应当清晰了:我们存储的数据与维护的索引是灵活的,能够根据实体类型支持多种访问模式。

到目前为止我们所使用的表,其 Terraform 定义如下:

resource "aws_dynamodb_table" "tutorial-1" {
  name = "tutorial-1"
  billing_mode = "PAY_PER_REQUEST"
  hash_key = "pk"
  range_key = "sk"


  attribute {
    name = "pk"
    type = "S"
  }


  attribute {
    name = "sk"
    type = "S"
  }


  attribute {
    name = "gsi1_pk"
    type = "S"
  }


  attribute {
    name = "gsi1_sk"
    type = "S"
  }


  attribute {
    name = "gsi2_pk"
    type = "S"
  }


  attribute {
    name = "gsi2_sk"
    type = "S"
  }


  global_secondary_index {
    name = "gsi1"
    hash_key = "gsi1_pk"
    range_key = "gsi1_sk"
    projection_type = "ALL"
  }


  global_secondary_index {
    name = "gsi2"
    hash_key = "gsi2_pk"
    range_key = "gsi2_sk"
    projection_type = "ALL"
  }
}

商品

存储商品同样遵循一对多模式,其中分区键为分类 ID,排序键为商品 ID:

image3.png

将父分类添加到 GSI1 可以让我们查询数据库中所有书籍:

image17.png

在生产应用中,我们可以使用 DynamoDB Streams 将数据推送到 Elasticsearch 集群,从而实现按书名搜索的能力,但如果仅使用现有模型,如何按商品标题查询呢?

我们已经使用了 GSI1,因此可以用 GSI2 来实现:

image2.png

请记住,查询 DynamoDB 时需要提供完整且精确的分区键,因此尽管此实现未必最优,但它说明了如何用三个索引来支持不同的访问模式。

在我的下一篇教程中,我将演示如何通过与搜索索引集成来解决这一问题。

购物篮内容与订单行

购物篮条目与订单行都存储在客户分区中;我们可以使用 GSI 来查询某个购物篮中的所有商品、某个订单中的所有商品,以及某客户有史以来订购过的所有商品。

image13.png

image4.png

image16.png

最后一个示例展示了客户的订单历史:两个不同订单 ID 对应的两件商品。

对于想了解 SQL 数据库中数据建模方式的读者,我们的SQL 中的数据建模网络研讨会提供了有价值的见解,可帮助您更好地理解 NoSQL 数据建模。

单表设计的相关挑战

在 DynamoDB 中使用单表设计有诸多优势,但也带来了一些挑战与考量:

复杂性与应用逻辑

将所有数据放在一张表中,尤其是在应用不断增长与演进时,可能会变得复杂。

您必须仔细规划并构造数据,以适配多种查询模式。此外,单表设计的有效性与您的应用逻辑紧密相关;应用逻辑在确保顺畅的数据访问与高效的查询处理中起着关键作用。

要让单表设计有效运行,必须同时慎重考虑数据结构与应用逻辑。

索引开销

虽然 DynamoDB 允许使用全局二级索引来支持不同的查询模式,但您需要预置并维护这些索引,这会增加成本与复杂度。

分区键选择

选择合适的分区键对于实现均衡的数据分布与良好的查询性能至关重要。若选择不当,可能导致热点分区与性能瓶颈。

规模与成本

随着数据量增加,单表的规模可能显著增长。这会影响存储成本以及读写成本。

无全文搜索

DynamoDB 原生不支持全文搜索,因此实现该功能需要与 Elasticsearch 等服务集成。

模式变更

对表模式进行变更可能具有挑战性,尤其是在生产系统中。您可能需要迁移现有数据,或修改应用逻辑以适配模式变化。

一致性考量

根据您选择的读取一致性(强一致或最终一致),您可能需要应对潜在的数据一致性挑战,并牢记 GSI 是异步复制的。

查询灵活性受限

DynamoDB 的查询能力强大,但不如 SQL 查询灵活。

想要更好地理解如何有效设计数据库并应对相关挑战,欢迎查阅我们的数据库设计课程。

结语

在使用 NoSQL 数据库时,必须跳出关系型数据库设计模式的舒适区;您需要“去除旧习”。

选择单表设计模式,意味着拥抱一套可在全球范围内扩展的数据库,能够以可靠且可预测的性能应对海量流量。

不过,这种性能优势并非唾手可得;它要求您在前期对数据库结构与应用逻辑进行细致规划与周全实现。

通过我们的NoSQL 概念课程继续巩固您的所学!

Gary Alway

作者

Gary Alway

我是一名全栈软件工程师兼解决方案架构师,热爱学习与数据!

主题