一步步演进如何为非关系型数据库设计模型和分区

2020-11-28  本文已影响0人  乱七八糟谈技术

这篇文章建立在几个Azure Cosmos DB概念,像数据建模,分区,预配置的吞吐量来演示怎样处理一个真实的数据设计实例。如果你经常使用关系型数据库,你可能有怎样设计数据模型的习惯和直觉。因为特别的限制和Cosmos DB的独特的优势,关系型数据库设计上的一些好的实践不能完全的照搬到Cosmos DB的设计中,这篇文章是通过一个真实世界的完整实例来指导你完成在Cosmos DB的建模过程,包括从数据建模到实体托管和容器分区。

译者述

因为下面的实例是在Cosmos DB的设计优化,因此在学习整个建模过程之前,需要脑补一下Cosmos DB的一些概念,比如容器,逻辑分区,物理分区,索引等,这些概念可以去Cosmos的官网上去了解,跟大部分的文档数据库概念可能很类似,在了解了这些概念后,然后需要了解Cosmos DB中关键的分区设计原则,能更好的帮助理解后面的设计优化。CosmosDB的分区设计原则总结如下:

  • 作为分区属性的value不能被改变,如果一个属性被作为分区,你不能更新它的值。这种设计很好理解,数据是按照逻辑分区存储到物理分区上的,如果分区被改变了,会涉及到逻辑分区和物理分区的重新分配,因此是不合理的。
  • 分区值有一个高度的随机性,换句话,作为分区的属性取值应该是一个比较大的范围的取值。这也是考虑到性能,如果分区的取值范围很小,就会导致读写性能比较差,所有请求都落到有限的几个分区上,计算,存储和网络都会成为瓶颈。
  • 跨所有逻辑分区平均分配请求单位(RU)消耗和数据。这可确保在整个物理分区上均匀分配RU消耗和存储。
  • 如果您的容器可以增长到多个物理分区,则应确保选择一个分区键,以最大程度地减少跨分区查询。如果满足以下任一条件,则你的容器将需要多个物理分区:
    1)容器配置超过30,000 RU2)容器存储超过100 GB的数据
  • 场景定义

    在本实例中,我们将设计一个博客平台,用户可以发布帖子。用户也可以在这些帖子中点赞和添加评论。很典型的一个应用场景。系统有下列的需求定义:在我们的规范中增加了更多要求:

  • 主页显示最近创建的帖子
  • 我们可以获取用户的所有帖子,帖子的所有评论以及点赞
  • 返回的帖子将带有其作者的用户名以及评论次数和点赞次数
  • 评论和点赞也将返回对应的用户信息。
  • 当显示为列表时,帖子仅需呈现其内容的简短摘要。
  • 识别主要访问模式

    首先,我们通过识别方案的访问模式来定义我们的数据结构,当为Cosmos DB设计数据模型时,理解我们的请求模型,确保模型能有效的服务我们的请求是非常重要的。为了使整个过程更容易遵循,我们将这些不同的请求分类为命令或查询,从CQRS借用一些词汇,其中命令(C) 是写请求(即,用于更新系统),而查询(Q)是只读请求。下面是博客平台将提供的服务列表:

  • [C1] 创建/编辑用户
  • [Q1] 获取用户
  • [C2] 创建/编辑帖子
  • [Q2] 获取帖子信息
  • [Q3] 显示某个用户的帖子列表
  • [C3] 发布评论
  • [Q4] 列举一个帖子的评论
  • [C4] 点赞一个帖子
  • [Q5] 列举一个帖子的点赞
  • [Q6] 列举一个用户最近发布的帖子
  • 在这个阶段,我们没有考虑每个实体所包含的内容详情,这步通常是关系型数据库设计时的第一步,因为我们需要确定数据库的表,列,外键等。然后,对于在写时不强制执行任何模式的文档数据库,这就不是第一步设计必须的。为什么在开始时需要确定访问模式的重要原因是因为这些请求列表将成为我们的测试案例,每次我们迭代我们的数据模型时,我们将检测每个请求并检测他们的性能和扩展能力。

    V1:第一个版本

    我们首先开始两个容器,users和posts。

    用户容器(Users)

    这个容器仅存储用户.

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

    id作为分区的id,那意味着每一个逻辑分区仅包含一个用户

    帖子容器(Posts)

    这个容器包含posts和comments,likes。

    {
        "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作为分区ID,那意味着容器内每个逻辑分区将包含一个post,该帖子的所有评论和点赞信息。上篇文章,我们介绍了使用type来区分不同的实体,在该容器里使用了三种类型。而且,我们也选择了引用数据来替代嵌入数据,因为

  • 一个用户可以创建多少的帖子没有上限。
  • 帖子可以任意的长度
  • 一个帖子可以有多少条comments和点赞也没有上限
  • 当我们增加评论或点赞时,不需要更新帖子本身
  • 我们的模型执行的怎么样

    现在是时间评估我们第一个版本的性能和可扩展性。为之前定义的每一个请求,我们测量了它的延迟性和它消耗的请求单元。测试数据是模拟100,000个用户,每个用户可以发布5到50个帖子,每个帖子最多25条评论和100个点赞。

    [C1] 创建/编辑用户

    由于我们仅在users容器里创建或更新一个项,这个请求是直接的,由于id作为partition key,因此请求很好的分散在所有的分区。

    [Q1] 获取一个用户

    从users容器中获取相应的用户。

    [C2] 创建/编辑一个帖子

    类似于[C1], 我们仅写posts容器

    [Q2] 获取一个帖子

    我们开始从posts容器中获取相应的文档,但那不是足够的,由于按照我们的需求,我们不得不聚合帖子作者的用户名和评论的数量和点赞的数量,那需要三个额外的SQL查询来完成。

    每一个查询都是各自容器的partition key进行过滤,因此我们可以最大化性能和可扩展性。但是我们最后不得不执行四个操作来查询一个帖子,所以我们在下一个迭代中改进。

    [Q3] 列举一个用户的所有帖子

    [C3] 创建评论

    在posts容器里为某个帖子创建评论

    [Q4] 列举帖子的评论

    查询一个帖子的所有评论,并需要为每个评论单独的查询出用户名,虽然主查询都是按照容器的partition key进行过滤,但为每个用户名查询用户信息,会有些性能的损耗,我们下一步会改进。

    [C4] 点赞一个帖子

    仅像[C3], 我们在posts容器里创建一个点赞。

    [Q5] 显示一个帖子的点赞列表

    就像 [Q4]查询帖子的点赞列表,接着根据用户id去查询用户名。

    [Q6] 列举最近创建的前X条帖子

    我们从posts容器里根据creation date降序来获取最近的帖子,接着为每个帖子查询出用户名,评论的数量,点赞的数量。

    最初的第一个查询不是对posts容器按照partition key进行过滤,那会触发一个非常昂贵的对整个分区扫描。这个更坏的情况是我们需要对一个大的结果集按照order by从句排序,那将消耗非常昂贵的请求单元。

    在v1版本上的反思

    分析上面的性能问题,我们可以识别出两个主要的原因:

  • 为了获取所有的数据,一个请求要求多个查询
  • 一些查询不是基于容器的partition key来进行过滤,这导致对整个分区扫描咱们开始解决以上两个问题,从第一个问题开始。
  • V2: 使用非规范化数据优化读查询

    在上面的几个例子中,我们不得不增加额外查询的原因是因为一次查询不能获取用户需要的所有数据,当在非关系型数据库中遇到这种问题时,通常是通过在通过非规范化数据的方式解决。在我们的实例中,我们修改post项,增加post作者的用户名,评论的数量和点赞次数。

    {
        "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>"
    }

    非规范化评论和点赞次数

    我们可以通过在每次新增一个评论或者点赞的时候,为此帖子增加commentCount和likeCount。由于我们的post容器是以postId作为partition key,新的评论,点赞和帖子都在相同的逻辑分区中,因此,我们可以使用Cosmos DB的存储过程来执行此操作。现在当我们新增评论([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
              );
            }
          );
        })
    }

    这个存储过程使用post id和新的comment作为参数,接着
    1)根据postid获取post
    2)增加commentCount
    3)替换post
    4)增加新的评论由于存储过程执行是原子操作,commentCount和评论的真实数量总是保持同步的。我们也可以按照类似的存储过程来实现点赞,并同步增加likeCount的数量。

    非规范化usernames

    由于users和posts不仅在不同分区中,而且在不同的容器中,因此usernames的非规范化需要有不同的方法来实现。当我们不得不跨分区和容器来非规范化数据时,我们可以用源容器的change feed来实现。在我们的实例中,但用户更新用户名时,我们可以激活users容器的change feed,调用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);
          }
        });
    }

    这个存储过程以user id和新的用户名作为参数,接着
    1)获取所有此用户的posts,comments和likes
    2)为查询到的每一项,更新用户名3)替换新项

    重要这个操作也是比较耗时操作,因为它要求这个存储过程被执行在posts容器的每个分区上。我们假定大部分用户很少改变用户名,所以这个更新应该很少被执行。

    V2版本的效率执行情况

    [Q2] 获取帖子

    现在我们的非规范化数据起作用了,只需要一次查询就可以完成获取帖子的请求

    [Q4] 列举帖子的评论

    同样,我们不需要额外的请求来获取发表评论的用户名,只需要单个基于partition key来查询评论即可。

    [Q5] 获取帖子的顶帖列表

    同上

    V3: 确保所有的请求是可扩展的

    查看上面我们的性能优化情况,仍然有两个请求没有被完全优化:[Q3]和[Q6],这两个请求没有按照容器的partition key进行过滤。

    [Q3] 列举用户的帖子

    这个请求通过V2版本已经有很大改进,减少了额外的查询。

    但这剩下的查询仍然不是按照posts容器partition key来过滤。思考这种情形的方式实际上很简单,
    1)这个请求不得不按照userId过滤,因为我们想获取特定用户的所有posts
    2)这个请求执行效率不太好,因为他是执行在posts容器上,posts容器不是按照userId作为partition key。
    3)很明显,我们要解决性能问题,按照userId作为partitonkey来执行这个请求。
    4)我们已经有一个容器,以userId作为partiton key,就是users容器
    所以我们将介绍一个二级的非规范化,复制整个posts数据到users容器,这样做的话,我们可以有效的得到posts的副本,仅是按照不同的维度进行分区。
    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字段来区分users和posts。我们也在user项目里增加了userId字段,那是冗余的和id字段,但现在我们就可以使用userId字段来作为partition key而不是id作为partiton key。为了实现这个非规范化,我们需要再次使用change feed。这次,我们发布任何新的或者更新post会激活posts容器的change feed,因为列举post不要求返回所有的内容,在这个过程中我们可以删除一些数据。

    现在我们可以查询users容器,按照容器的partition key来过滤。

    [Q6] 列举最新的X个帖子

    这个我们不得不处理类似的场景:按照V2的非规范化数据已经减少了额外的查询,但剩下的查询没有按照容器的partition key进行过滤。

    按照相同的方法,最大化请求的性能和可扩展性要求查询仅命中一个partition,这是可以想象的,因为我们仅不得不返回有限数量的项目。为了在博客平台的首页上显示,我们仅需要获取前100个最新的帖子。所以为了优化这个请求,我们介绍了第三个容器的设计,完全的专注于这个请求,我们非规范化我们的posts。下面是model,

    {
        "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作为partition key,在我们的项目中类型始终是post,这样确保所有的项目都在同一个分区中。为了实现这个非规范化,我们仅不得不使用change feed,当发布新帖子时触发此change feed。一个重要的事情是我们仅存储最近100条帖子,要不然,这个容器将会超过partition所允许的最大尺寸,我们可以在每次一个项目被新增时调用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);
            });
        }
      }
    }

    最后一步来查看优化后的性能。

    结论

    咱们看一下重新设计后的所有查询和执行的性能和可扩展性。

    对读重场景的优化

    你们可能已经注意到我们以写请求耗时的代价来改进读请求的性能,在许多场景下,现在写操作通过change feeds来触发非规范化,这样会使得计算耗时。这样的调整是基于博客平台(像大部分的社交app)是一个读重的平台,那意味着读请求的数量是远远高于写请求的数量,所以使写请求花费代价大,使读请求效率更高的性能,这种设计是合理的。如果我们看我们做的最极端的优化,【Q6】从2000+RUs降到了17RUs,我们是通过花费10RUs的代价来非规范化实现的,由于我们将服务更多的读请求,这个非规范化花费的成本是可以接受的,总体是降低了成本。

    写在最后

    非规范化是可以递进的,在这篇文章中,我们探索的可扩展性的改进是不断演进非规范化和跨数据集的数据复制来实现的。也要注意这些优化也不是一天完成的。按照partiton key过滤在可扩展性方面性能最好,如果调用次数较少或者数据量较小,但跨区查询额可以接受。如果你正实现一个原型或者开发一个有少数用户产品,你可以后来再做这些优化,关键是监控你的系统性能来决定什么时候来优化你的模型。

    上一篇下一篇

    猜你喜欢

    热点阅读