共用方式為

如何使用实际示例对数据进行建模和分区

适用范围: NoSQL

本文基于多个 Azure Cosmos DB 概念,例如数据建模分区预配吞吐量,演示如何完成一个真实数据设计练习。

如果你通常使用关系数据库,你可能已经习惯了设计数据模型。 由于特定的约束,但也具有 Azure Cosmos DB 的独特优势,这些最佳做法中的大多数都不太顺利,并且可能会将你拖到欠佳的解决方案中。 本文的目的是指导你完成在 Azure Cosmos DB 上对真实用例建模的完整过程,从项建模到实体归置和容器分区。

有关演示本文中概念的示例,请下载或查看此 社区生成的源代码

重要

社区参与者贡献了此代码示例。 Azure Cosmos DB 团队不支持其维护。

情景

对于本练习,我们假设有一个博客平台域,用户可在其中创建帖子。 用户还可以点赞评论这些贴子。

小窍门

某些字词以 斜体 突出显示,以识别模型纵的“事物”类型。

将更多的要求添加到我们的规格中:

  • 首页显示最近创建的帖子的源。
  • 我们可以提取用户的所有帖子、帖子的所有评论以及帖子的所有赞。
  • 帖子以作者的用户名返回,以及评论数量和赞数。
  • 注释和赞也使用创建它们的用户的用户名返回。
  • 以列表形式显示时,帖子只需显示其内容的截断的摘要。

标识主要访问模式

在开始之前,让我们通过标识解决方案的访问模式,来为初始规范提供某种结构。 设计 Azure Cosmos DB 的数据模型时,必须了解模型需要为哪些请求提供服务,以确保模型能够有效地为这些请求提供服务。

为了简化整个过程,我们将这些不同的请求分类为命令或查询,从 CQRS 借用一些词汇。

下面列出了我们的平台公开的请求:

  • [C1] 创建或编辑用户
  • [Q1] 检索用户
  • [C2] 创建或编辑帖子
  • [Q2] 检索帖子
  • [Q3] 以短格式列出用户的帖子
  • [C3] 创建评论
  • [Q4] 列出帖子的评论
  • [C4] 为帖子点赞
  • [Q5] 列出帖子的点赞数
  • [Q6] 以短格式列出最近创建的 x 个帖子(源)

在此阶段,我们还没有考虑每个实体(用户、帖子等)包含的详细信息。 针对关系存储进行设计时,此步骤往往是要处理的最初几个步骤之一。 首先从此步骤开始,因为我们必须弄清楚这些实体在表、列、外键等方面的转换方式。 与文档数据库无关,该数据库不会在写入时强制实施任何架构。

请务必从头开始识别访问模式,因为此请求列表将成为我们的测试套件。 每当循环访问数据模型时,我们都会遍历每个请求,并检查其性能和可伸缩性。 我们计算每个模型中消耗的请求单位(RU),并对其进行优化。 所有这些模型都使用默认索引策略,你可以通过为特定属性编制索引来覆盖该策略,这可以进一步改善 RU 消耗量和延迟。

V1:第一个版本

从两个容器着手:usersposts

用户容器

此容器仅存储用户项:

{
    "id": "<user-id>",
    "username": "<username>"
}

我们按 id 对此容器进行分区,这意味着,该容器中的每个逻辑分区仅包含一个项。

帖子容器

此容器承载帖子、评论和点赞等实体:

{
    "id": "<post-id>",
    "type": "post",
    "postId": "<post-id>",
    "userId": "<post-author-id>",
    "title": "<post-title>",
    "content": "<post-content>",
    "creationDate": "<post-creation-date>"
}

{
    "id": "<comment-id>",
    "type": "comment",
    "postId": "<post-id>",
    "userId": "<comment-author-id>",
    "content": "<comment-content>",
    "creationDate": "<comment-creation-date>"
}

{
    "id": "<like-id>",
    "type": "like",
    "postId": "<post-id>",
    "userId": "<liker-id>",
    "creationDate": "<like-creation-date>"
}

我们按 postId 对此容器进行分区,这意味着,该容器中的每个逻辑分区包含一个帖子、该帖子的所有评论以及该帖子的所有点赞数。

我们在此容器存储的项中引入了一个 type 属性,以区分此容器承载的三种实体类型。

此外,我们选择引用相关数据,而不是嵌入它,因为:

  • 用户可创建的帖子数没有上限。
  • 帖子可以任意长。
  • 帖子的评论数量和赞数没有上限。
  • 我们希望能够向帖子添加评论或赞,而无需更新帖子本身。

模型的性能如何?

现在,让我们评估第一个版本的性能和可伸缩性。 对于前面标识的每个请求,我们将测量其延迟,及其消耗的请求单位数。 这种测量是针对某个虚构数据集进行的。该数据集包含 100,000 个用户,每个用户发布了 5 到 50 个帖子,每个帖子产生了 25 条评论和 100 次点赞。

[C1]创建或编辑用户

可以直截了当地实现此请求,因为我们只需在 users 容器中创建或更新某个项。 得益于 id 分区键,请求将合理分散在所有分区之间。

将单个项写入用户容器的关系图。

延迟 请求单位 性能
7 毫秒 5.71

[Q1] 检索用户

通过读取 users 容器中的相应项来检索用户。

从用户容器检索单个项的关系图。

延迟 请求单位 性能
2 毫秒 1

[C2]创建或编辑帖子

类似于 [C1] ,我们只需写入到 posts 容器。

将单个帖子项写入帖子容器的关系图。

延迟 请求单位 性能
9 毫秒 8.76

[Q2] 检索帖子

首先检索 posts 容器中的相应文档。 但是,根据我们的规范,我们还需要聚合帖子作者的用户名、评论计数和帖子的赞数。 列出的聚合需要再发出三个 SQL 查询。

检索帖子并聚合其他数据的关系图。

每个查询都会对其各自的容器的分区键进行筛选,这正是我们想要最大化性能和可伸缩性的内容。 但是,我们最终需要执行四个操作才能返回一个帖子,因此,我们将在下一次迭代中改进此方法。

延迟 请求单位 性能
9 毫秒 19.54

[Q3] 以短格式列出用户的帖子

首先,必须使用一个 SQL 查询来检索所需的帖子。该查询会提取对应于该特定用户的帖子。 但是,我们还需要发出更多查询来聚合作者的用户名以及评论数和点赞数。

检索用户的所有帖子并聚合其附加数据的关系图。

此实现存在许多缺点:

  • 必须针对第一个查询返回的每个帖子发出聚合注释计数和赞的查询。
  • 主查询不会筛选容器的 posts 分区键,从而导致扇出和跨容器的分区扫描。
延迟 请求单位 性能
130 毫秒 619.41

[C3] 创建评论

通过在 posts 容器中写入相应的项来创建评论。

将单个批注项写入帖子容器的关系图。

延迟 请求单位 性能
7 毫秒 8.57

[Q4] 列出帖子的评论

首先使用一个查询提取该帖子的所有评论,同样,我们也需要单独聚合每条评论的用户名。

检索帖子的所有注释并聚合其附加数据的关系图。

尽管主查询会根据容器的分区键进行筛选,但单独聚合用户名会降低总体性能。 稍后我们将会改进。

延迟 请求单位 性能
23 毫秒 27.72

[C4] 为帖子点赞

类似于 [C3] ,我们将在 posts 容器中创建相应的项。

将单个(like)项写入帖子容器的关系图。

延迟 请求单位 性能
6 毫秒 7.05

[Q5] 列出帖子的点赞数

类似于 [Q4] ,我们将查询该帖子的点赞数,然后聚合点赞者的用户名。

检索帖子的所有赞并聚合其附加数据的关系图。

延迟 请求单位 性能
59 毫秒 58.92

[Q6] 以短格式列出最近创建的 x 个帖子(源)

我们通过查询 posts 容器来提取最近的帖子(按创建日期的降序排序),然后聚合每个帖子的用户名以及评论数和点赞数。

检索最新帖子并聚合其附加数据的关系图。

同样,我们的初始查询不会根据 posts 容器的分区键进行筛选,这会触发高开销的扇出。但这一次情况更糟,因为我们的目标是一个更大的结果集,并要使用 ORDER BY 子句将结果排序,因此会消耗更多的请求单位。

延迟 请求单位 性能
306 毫秒 2063.54

反思 V1 的性能

分析在上一部分遇到的性能问题,我们可以发现存在两类主要问题:

  • 某些请求需要发出多个查询才能收集我们需要返回的所有数据。
  • 某些查询不会筛选它们所面向的容器的分区键,从而导致扇出会妨碍我们的可伸缩性。

让我们解决上述每个问题,从第一个问题开始。

V2:引入非规范化以优化读取查询

之所以需要在某些情况下发出更多请求,是因为初始请求的结果不包含需要返回的所有数据。 使用 Azure Cosmos DB 等非关系数据存储时,非规范化数据可解决数据集中的此类问题。

在本示例中,我们将修改帖子项,以添加帖子作者的用户名,以及评论数和点赞数:

{
    "id": "<post-id>",
    "type": "post",
    "postId": "<post-id>",
    "userId": "<post-author-id>",
    "userUsername": "<post-author-username>",
    "title": "<post-title>",
    "content": "<post-content>",
    "commentCount": <count-of-comments>,
    "likeCount": <count-of-likes>,
    "creationDate": "<post-creation-date>"
}

我们还修改注释,并像项目一样添加创建它们的用户的用户名:

{
    "id": "<comment-id>",
    "type": "comment",
    "postId": "<post-id>",
    "userId": "<comment-author-id>",
    "userUsername": "<comment-author-username>",
    "content": "<comment-content>",
    "creationDate": "<comment-creation-date>"
}

{
    "id": "<like-id>",
    "type": "like",
    "postId": "<post-id>",
    "userId": "<liker-id>",
    "userUsername": "<liker-username>",
    "creationDate": "<like-creation-date>"
}

非规范化注释和计数

我们要实现的目的是,每次添加评论或点赞时,都会递增相应帖子中的 commentCountlikeCount。 由于 postId 对我们的 posts 容器进行分区,新项(评论或点赞)及其相应帖子将位于同一个逻辑分区中。 因此,我们可以使用某个存储过程来执行该操作。

创建注释([C3])时,我们调用该容器上的以下存储过程,而不是在容器中 posts 添加新项:

function createComment(postId, comment) {
  var collection = getContext().getCollection();

  collection.readDocument(
    `${collection.getAltLink()}/docs/${postId}`,
    function (err, post) {
      if (err) throw err;

      post.commentCount++;
      collection.replaceDocument(
        post._self,
        post,
        function (err) {
          if (err) throw err;

          comment.postId = postId;
          collection.createDocument(
            collection.getSelfLink(),
            comment
          );
        }
      );
    })
}

此存储过程采用帖子 ID 和新评论的正文作为参数,然后:

  • 检索帖子。
  • commentCount递增 .
  • 替换帖子。
  • 添加新注释。

由于存储过程是作为原子事务执行的,因此 commentCount 的值和实际评论数始终保持同步。

添加新的点赞来递增 likeCount 时,我们将显式调用类似的存储过程。

非规范化用户名

对于用户名,需要采用不同的方法,因为用户不仅位于不同的分区中,而且还位于不同的容器中。 如果必须反规范化不同分区和容器中的数据,可以使用源容器的更改通知

在本示例中,每当用户更新其用户名时,我们都会使用 users 容器的更改源来做出反应。 如果发生这种情况,我们会针对 posts 容器调用另一个存储过程来传播更改:

将用户名非规范化为帖子容器的关系图。

function updateUsernames(userId, username) {
  var collection = getContext().getCollection();
  
  collection.queryDocuments(
    collection.getSelfLink(),
    `SELECT * FROM p WHERE p.userId = '${userId}'`,
    function (err, results) {
      if (err) throw err;

      for (var i in results) {
        var doc = results[i];
        doc.userUsername = username;

        collection.upsertDocument(
          collection.getSelfLink(),
          doc);
      }
    });
}

此存储过程采用用户 ID 和用户的新用户名作为参数,然后:

  • 提取匹配 userId 的所有项(可以是帖子、批注或赞)。
  • 对于每个项:
    • 替换 .userUsername
    • 替换项。

重要

此操作的开销较大,因为需要针对 posts 容器的每个分区执行此存储过程。 我们假设大多数用户在注册期间选择合适的用户名,并且永远不会更改它,因此此更新很少运行。

V2 有多大的性能提升?

我们来讨论一下 V2 的一些性能提升。

[Q2] 检索帖子

完成反规范化后,只需提取单个项即可处理该请求。

从非规范化帖子容器中检索单个项的关系图。

延迟 请求单位 性能
2 毫秒 1

[Q4] 列出帖子的评论

同样,我们无需发出额外的请求来提取用户名,最终只需运行一个可以根据分区键进行筛选的查询。

检索非规范化帖子的所有注释的关系图。

延迟 请求单位 性能
4 毫秒 7.72

[Q5] 列出帖子的点赞数

列出点赞时,情况完全相同。

检索非规范化帖子的所有赞的关系图。

延迟 请求单位 性能
4 毫秒 8.92

V3:确保所有请求都是可缩放的

在我们评估整体性能提升时,仍有两个请求尚未完全优化。 这些请求为 [Q3][Q6]。 这些请求涉及到不根据其所针对的容器的分区键进行筛选的查询。

[Q3] 以短格式列出用户的帖子

此请求已受益于 V2 中引入的改进,可以免除更多查询。

显示以短格式列出用户非规范化帖子的查询的关系图。

但是,剩余的查询仍不根据 posts 容器的分区键进行筛选。

查明此问题的原因非常简单:

  • 此请求必须根据 userId 进行筛选,因为我们需要提取特定用户的所有帖子。
  • 它的性能之所以不佳,是因为它是针对 posts 容器执行的,而该容器没有 userId 对其进行分区。
  • 明白地讲,我们需要针对使用 userId 进行分区的某个容器执行此请求来解决性能问题。
  • 原来我们已有这样一个容器:users 容器!

因此,我们通过将整个帖子复制到 users 容器,来引入第二级反规范化。 这样,我们便可以有效地获取只按一个不同维度分区的帖子副本,从而可以更有效地按其 userId 进行检索。

users 容器现在包含两种类型的项:

{
    "id": "<user-id>",
    "type": "user",
    "userId": "<user-id>",
    "username": "<username>"
}

{
    "id": "<post-id>",
    "type": "post",
    "postId": "<post-id>",
    "userId": "<post-author-id>",
    "userUsername": "<post-author-username>",
    "title": "<post-title>",
    "content": "<post-content>",
    "commentCount": <count-of-comments>,
    "likeCount": <count-of-likes>,
    "creationDate": "<post-creation-date>"
}

在本示例中:

  • 我们在用户项中引入了一个 type 字段,用于区分用户与帖子。
  • 我们还在用户项中添加了一个 userId 字段,该字段与 id 字段冗余,但是必需的,因为 users 容器现在使用 userId (而不是以前那样 id )进行分区。

若要实现这种反规范化,我们将再次使用更改源。 这一次,我们将对 posts 容器的变更提要进行响应,以将任何新的或已更新的帖子发送到 users 容器。 由于列出帖子不需要返回其完整内容,我们可以在列出过程中截断它们。

将帖子非规范化到用户的容器中的关系图。

现在,可将查询路由到 users 容器,并根据该容器的分区键进行筛选。

检索非规范化用户的所有帖子的关系图。

延迟 请求单位 性能
4 毫秒 6.46

[Q6] 以短格式列出最近创建的 x 个帖子(源)

在此处必须处理类似情况:尽管实现 V2 中引入的反规范化后无需运行更多查询,但是,剩余查询仍不会根据容器的分区键进行筛选:

显示用于列出以短格式创建的 x 最新文章的查询的关系图。

在遵循相同方法的情况下,要最大化此请求的性能和可伸缩性,必须确保它只命中一个分区。 可以想象,只命中单个分区,因为我们只需要返回有限数量的项。 若要填充博客平台首页的内容,我们只需获取 100 篇最新的帖子,而无需对整个数据集进行分页处理。

为了优化这最后一个请求,我们在设计中引入了第三个容器,该容器专门为此请求提供服务。 将帖子反规范化为该新的 feed 容器:

{
    "id": "<post-id>",
    "type": "post",
    "postId": "<post-id>",
    "userId": "<post-author-id>",
    "userUsername": "<post-author-username>",
    "title": "<post-title>",
    "content": "<post-content>",
    "commentCount": <count-of-comments>,
    "likeCount": <count-of-likes>,
    "creationDate": "<post-creation-date>"
}

字段 type 对此容器进行分区,该容器在我们的项中始终是 post。 这可以确保此容器中的所有项位于同一个分区。

若要实现反规范化,我们只需挂接前面引入的更改源管道,以将帖子调度到该新容器。 请记住的一个重要事项是,我们需要确保我们只存储 100 个最新帖子:否则,容器的内容可能会超出分区的最大大小。 此限制可以通过每次在容器中添加文档时调用 post-trigger 来实现:

将帖子非规范化到源容器的示意图。

下面是用于截断集合的 post-trigger 的正文:

function truncateFeed() {
  const maxDocs = 100;
  var context = getContext();
  var collection = context.getCollection();

  collection.queryDocuments(
    collection.getSelfLink(),
    "SELECT VALUE COUNT(1) FROM f",
    function (err, results) {
      if (err) throw err;

      processCountResults(results);
    });

  function processCountResults(results) {
    // + 1 because the query didn't count the newly inserted doc
    if ((results[0] + 1) > maxDocs) {
      var docsToRemove = results[0] + 1 - maxDocs;
      collection.queryDocuments(
        collection.getSelfLink(),
        `SELECT TOP ${docsToRemove} * FROM f ORDER BY f.creationDate`,
        function (err, results) {
          if (err) throw err;

          processDocsToRemove(results, 0);
        });
    }
  }

  function processDocsToRemove(results, index) {
    var doc = results[index];
    if (doc) {
      collection.deleteDocument(
        doc._self,
        function (err) {
          if (err) throw err;

          processDocsToRemove(results, index + 1);
        });
    }
  }
}

最后一步是将查询重新路由到新的 feed 容器:

检索最新文章的关系图。

延迟 请求单位 性能
9 毫秒 16.97

结论

我们来分析一下在不同设计版本中引入的总体性能和可伸缩性改进。

V1 V2 V3
[C1] 7 毫秒/5.71 RU 7 毫秒/5.71 RU 7 毫秒/5.71 RU
[Q1] 2 毫秒/1 RU 2 毫秒/1 RU 2 毫秒/1 RU
[C2] 9 毫秒/8.76 RU 9 毫秒/8.76 RU 9 毫秒/8.76 RU
[Q2] 9 毫秒/19.54 RU 2 毫秒/1 RU 2 毫秒/1 RU
[Q3] 130 毫秒/619.41 RU 28 毫秒/201.54 RU 4 毫秒/6.46 RU
[C3] 7 毫秒/8.57 RU 7 毫秒/15.27 RU 7 毫秒/15.27 RU
[Q4] 23 毫秒/27.72 RU 4 毫秒/7.72 RU 4 毫秒/7.72 RU
[C4] 6 毫秒/7.05 RU 7 毫秒/14.67 RU 7 毫秒/14.67 RU
[Q5] 59 毫秒/58.92 RU 4 毫秒/8.92 RU 4 毫秒/8.92 RU
[Q6] 306 毫秒/2063.54 RU 83 毫秒/532.33 RU 9 毫秒/16.97 RU

我们已优化一个读取密集型方案

你可能会注意到,我们专注于提高读取请求(查询)的性能,代价是写入请求(命令)。 在许多情况下,写入操作现在会通过更改源触发后续的反规范化,因此,其计算开销更大,且具体化的时间更长。

我们通过博客平台(与大多数社交应用一样)读取性能来证明这一焦点是阅读繁重的。 读取密集型工作负载表明它必须服务的读取请求量通常比写入请求数高几个数量级。 将写入请求的执行成本提高是合适的,以便让读取请求更便宜且性能更佳。

执行极端优化后我们发现,[Q6] 的开销已从 2000 多个 RU 降到了 17 个 RU;这种改进是通过反规范化帖子实现的,每个项的开销大约为 10 个 RU。 考虑到总体节省,我们处理的信息流请求比帖子创建或更新请求要多得多,因此这种反规范化带来的开销可以忽略不计。

可以增量方式应用反规范化

本文中探讨的可伸缩性改进涉及到反规范化,以及复制整个数据集中的数据。 应注意的是,这些优化不必在第一天就位。 根据分区键筛选的查询在大规模执行时的性能更佳,但是,如果极少调用跨分区查询或者针对有限的数据集调用此类查询,则其性能也完全可接受。 如果你只是在构建一个原型,或者推出一款用户群体小且可控的产品,则可能会把这些改进留到以后。 那么,重要的是 监视模型的性能 ,以便你可以确定何时以及何时引入模型。

用于将更新分发到其他容器的更改源会持久存储所有这些更新。 这种持久性使得可以请求所有更新,因为即使系统已包含许多数据,创建容器和启动反规范化视图也是一次性的同步操作。

后续步骤

在介绍实际数据建模和分区后,可能需要查看以下文章来查看这些概念: