MongoDB设计评论:应用模式设计最佳实践如何改善了60倍的性能
#database #nosql #mongodb

从与旧版关系数据库系统合作到NOSQL数据库(例如MongoDB)的过渡要求开发人员改变模型的方式并表示数据,如果他们要实现进行切换的全部好处。

有时将MongoDB称为“示意图”,但现实是,模式设计在MongoDB中与任何数据库系统一样重要,而选择的选择也是如此。您作为模式设计师和数据建模者将在MongoDB中(即使不是Moreso)进行或破坏应用程序的性能,而不是在任何传统的RDBMS中。

作为MongoDB战略帐户团队的开发人员倡导者,我通过提供量身定制的数据建模会话(也称为设计评论)来协助正在过渡现有工作负载或在MongoDB创建新工作负载的客户开发团队。在这些会议期间,我们会审查客户的特定工作量,并提供有关如何最好地在工作量中建模数据以获得最佳性能的反馈,建议和建议。

在设计评论结束时,客户将根据MongoDB开发的最佳实践模式设计模式为其工作量量身定制的框架模式设计。

在本文中,我们讨论了一项特定的设计审查,并展示了如何通过模式设计和查询优化的组合,会议使客户看到了一种聚合管道之一的性能有60倍,并允许其应用满足其SLA目标。

客户投资组合活动应用程序

几个月前,我们收到了金融服务行业客户的请求,以对他们在MongoDB Atlas构建的应用程序进行设计审查。除其他事项外,该应用程序旨在为区域客户经理提供在特定地区所需的时间段中客户投资组合中每个股票的汇总性能数据。

当客户与我们联系时,他们旨在生成数据的aggregation pipeline需要20到40秒,以完成应用SLA要求进行两秒钟的响应时间的地方。就此汇总而言,数据库设计相对简单,仅由两个集合组成。

第一个集合中的文档包含客户信息,包括客户所属的区域,以及一系列库存符号,其投资组合中的每个股票都有一个条目。

{
  "_id": "US4246774937",
  "region": "US",
  "firstname": "Jack",
  "lastname": "Bateman",
  "portfolio": [
    "NMAI",
    "CALA",
    "MNA"
  ]
}

第二个集合中的文档包含库存数据,其中涵盖了一分钟的交易活动,每个股票都会生产一份文件。这些文件中的信息包括股票符号,该股票交易的股票,开盘价,高价,低价,收盘价以及覆盖分钟的开始和完成时间戳。

{
  "_id": {
    "$oid": "63e15e9ad0c75e43cd1831db"
  },
  "symbol": "CALA",
  "volume": 10464,
  "opening": 0.14,
  "high": 0.14,
  "low": 0.14,
  "closing": 0.14,
  "start": {
    "$date": "2023-02-06T19:54:00.000Z"
  },
  "end": {
    "$date": "2023-02-06T19:55:00.000Z"
  }
}

Entity relationship diagrams showing a many-to-many relationship between documents in a Customer collection and documents in the Stock Data collection. Documents in the Customer collection contain an array of stock_symbols, linking them to the corresponding documents in the Stock Data collection

正在针对客户收集执行聚合管道,其输出旨在提供以下内容:

对于所选区域中的每个客户,为该客户投资组合中每个股票的指定时间段提供开头的价格,交易量,高价,低价和收盘价。

为了提供此输出,聚合管道已有五个阶段:

  1. 最初的koude0阶段,仅选择了所需区域中客户的文档

  2. 一个koude1阶段,将为客户投资组合中的每个股票复制所选客户文档一次,即如果客户在其投资组合中有10个股票,则此阶段将创建10个文档 - 每个股票的一个替代原始文件,该文件在数组中列出了所有10个股票

  3. a $ lookup阶段,对于先前的$unwind阶段创建的每个文档,将对库存数据收集进行第二个聚合管道,以股票符号和日期范围作为输入,并返回该股票的汇总数据

  4. 第二个$unwind阶段可以将前的$lookup阶段创建的数组(在这种情况下,总是只包含一个条目)到一个嵌入式对象

  5. 最终的koude5阶段,将每个客户的文档重组回到一个文档

在其投资组合中有两个股票的客户的输出文档看起来像这样:

{
  "_id": "US1438229432",
  "region": "WEST",
  "firstname": "Otto",
  "lastname": "Cast",
  "portfolio": [
    "ISR",
    "CTS",
  ],
  "stockActivity": [
    {
      "opening": 0.42,
      "high": 0.42,
      "low": 0.4003,
      "closing": 0.4196,
      "volume": {
        "$numberLong": "40611"
      },
      "symbol": "ISR"
    },
    {
      "opening": 42.7,
      "high": 42.98,
      "low": 41.62,
      "closing": 42.93,
      "volume": {
        "$numberLong": "45294"
      },
      "symbol": "CTS"
    }
  ]
}

对管道的explain plan的检查表明,两个$match阶段 - 在主管线中一个,一个在$lookup阶段内的子管道中的一个阶段 - 都使用设置的索引来支持该索引管道。这消除了丢失或错误定义的索引 - 在这种情况下,我们在MongoDB中看到的最常见的绩效问题来源之一。

评估工作量

每当我们为MongoDB设计数据模型或架构时,最佳实践都需要从理解和量化目标工作负载开始。在设计评论开始时,我们能够确定以下内容:

  • 该应用程序包含大约10,000个客户的数据,在美国境内的六个地区分布均匀分布。

  • 每个客户的投资组合中平均有10个股票,任何一个客户的股票数量最多。

  • 正在跟踪大约16,000个股票的活动数据。

  • 每天正在生成大约200万个股票活动记录。并非每个股票每分钟都会产生更新,并且仅在周一至周五的八个小时的美国市场日收集数据。

  • 在市场时间内,每分钟大约收到4,200个新股票更新。

  • 最近四个完整季度的库存活动数据,以及当前待定的日期。这转化为约6.5亿股票活动文件,占35 GB的存储空间。

  • 数据从系统中清除了四个以上的旧季度,因此数据量非常稳定。

  • 经理使用了聚合管道产生的数据,以生成一天的结束,月底和季后报告。不到一天的期限并未要求数据,并且仅在美国市场关闭后才获得数据。

  • 经理平均每天生成150次报告。

  • 这些报告没有用于做出实时交易决策。

该应用程序正在MongoDB Atlas M40三节点群集上运行,并且上述指标表明该工作负载不应过分尺寸对于集群,唯一的问号是,在16GB时是否有足够的内存为了在内存中维护足够尺寸的工作集以处理管道请求,而无需进行数据交换。

了解建立的工作量的性质和规模,我们将注意力转向了聚合管道的结构。

评估聚合管道

最初由应用程序开发团队设计的聚合管道如下:

[
  {$match: {region: "WEST"}},
  {$unwind:{path: "$portfolio"}},
  {$lookup:{
    from: "stockData",
    let: {
      symbol: "$portfolio",
      start: ISODate("2022-11-07T00:00:00.000+00:00"),
      end: ISODate("2022-11-08T00:00:00.000+00:00")
    },
    pipeline: [
      {$match:{
        $expr:{ $and: [
          {$eq: ["$symbol", "$$symbol"]},
          {$gte: ["$start", "$$start"]},
          {$lt: ["$end", "$$end"]},
        ]}
      },
      {$group:{
        _id: "$symbol",
        opening: {$first: "$opening"},
        high: {$max: "$high"},
        low: {$min: "$low"},
        closing: {$last: "$closing"},
        volume: {$sum: "$volume"}
      }},
      {$set:{
        "symbol": "$_id",
        "_id": "$$REMOVE"
      }}
    ],
    as: "stockData"
  }},
  {$unwind: {path: "$stockData"}},
  {$group:{ 
      _id: "$_id",
      region:{$first: "$region"},
      firstname:{$first: "$firstname"},
      lastname:{$first: "$lastname"},
      portfolio:{$addToSet: "$portfolio"},
      stockActivity:{$push: "$stockData"}
  }}
]

在运行测试查询以检索西部所有客户的一日交易活动窗口的数据时,我们看到的响应时间 29秒

这条管道中有两个物品立即引起了我们的注意。

首先,该管道使用$unwind阶段,使随后的$lookup阶段可以在每个客户的投资组合中为每个股票运行一次。实际上,该$unwind及其随后在最终$group阶段重建数据是不必要的。如果将数组作为$lookup阶段作为localfield值传递,则该数组中的每个条目将自动运行$lookup。重构管道以采用这种方法将其减少到两个阶段:初始$match阶段和随后的$lookup阶段。修订后的管道看起来像这样:

[ 
 {$match:{region: "WEST"}},
 {$lookup:{
    from: "stockData",
    localField: "portfolio",
    foreignField: "symbol",
    let: {
       start: ISODate(
          "2022-11-07T00:00:00.000+00:00"
       ),
       end: ISODate(
          "2022-11-08T00:00:00.000+00:00"
       ),
    },
    pipeline: [
      {$match:{
        $expr:{ $and: [
          {$eq: ["$symbol", "$$symbol"]},
          {$gte: ["$start", "$$start"]},
          {$lt: ["$end", "$$end"]},
        ]}
      },
      {$group:{
        _id: "$symbol",
        opening: {$first: "$opening"},
        high: {$max: "$high"},
        low: {$min: "$low"},
        closing: {$last: "$closing"},
        volume: {$sum: "$volume"}
      }},
      {$set:{
        "symbol": "$_id",
        "_id": "$$REMOVE"
      }}
    ],
    as: "stockActivity",
 }}
]

尤其证明消除$group阶段是有益的,我们的测试查询给出了 19秒的响应时间。这是一个显着的改进,但仍然远远不及目标子两秒钟的响应时间。

我们在管道中看到的第二个问题是使用$lookup阶段。 $lookup基本上进行了相当于关系数据库中的外部加入。加入任何数据库系统或NOSQL是计算昂贵的操作。 MongoDB使用的文档模型的关键好处之一是它的能力使我们能够避免使用嵌入和分层文档加入。但是,在这种情况下,应用程序开发团队正确地确定了将股票活动文件嵌入每个客户的文件中将导致过度尺寸的文档和大型阵列 - 两个MongoDB反图案。经常鼓励数据否定化和一定程度的重复以提高MongoDB的查询性能。但是,在这项工作量中,写入操作的数量超过了读取操作,将股票活动数据复制到客户文件的程度和后续更新成本被确定为不良权衡。

尽管将股票活动文档嵌入到客户文件中被排除在于一种方法,并确切地检查了$lookup阶段所发生的事情,这是在理解为什么管道要长时间执行的原因方面所揭示的。例如,运行管道以生成一个日历季度的数据为西部地区的所有客户生成数据,从而导致以下指标:

  1. 初始$match阶段返回了1,725个客户文档。

  2. 每个客户的投资组合中平均有10个股票,随后的$unwind阶段将管道中的文档数量扩展到18,214。

  3. 然后,为18,214个记录中的每一个执行了$lookup阶段。

  4. 对于查找阶段的每个执行,需要汇总给定股票的一个日历季度。这导致了大约25,000个一分钟的股票活动记录,需要在$lookup子Pipeline的18,214个执行中进行汇总。

  5. 由于相同的股票可能出现在多个客户的投资组合中,在许多情况下,$lookup子船舶被多次执行。

在实际执行过程中,MongoDB聚合引擎将能够应用一些优化 - 尤其是$lookup阶段先前运行的缓存结果,允许通过提供相同参数的后续运行来重复使用它们 - 因此整体性能不够。就像指标最初建议的那样高,但仍在执行很多工作,其中一些是重复的。

有了这种理解,我们的设计评论的下一个阶段是寻找如何应用模式设计模式来优化管道性能。

应用模式设计模式 - 计算的模式

我们要解决的第一个问题是每次执行$lookup子pipeline汇总的库存活动文档数量。

库存活动文件是按一分钟的时间写入数据库的,但是在设计审查开始时的工作量评估期间,我们确定用户永远不会以每天的粒度来查询任何内容。考虑到这一点,我们决定调查是否可以应用computed design pattern

计算的设计模式强调预先计算和保存常见的数据,因此每次请求数据时都不会重复相同的计算。在我们的情况下,该管道反复将相同的每分钟数据汇总为每日,每月,季度或每年的总计。因此,我们决定查看这些总计预计并将其存储在新集合中的影响,并让$lookup管道访问那些预算值的价值。

为此,我们建议在应用程序中添加以下过程:

在每次美国交易会结束时,每个股票的每分钟文件都会汇总,以提供每日交易量的每日文件,并开始,开始,收盘,高价和低价。这些每日文档将存储在一个新的集合中,并从原始收藏中删除的每分钟文件中存储,这意味着它从未包含超过一天的每分钟文件。

  1. 在每个月开始时,每个股票的每日文件都会汇总为每股股票的每月文件。每月文档将与每日文件一起存储在相同的新藏品中。

  2. 在每个季度开始时,每个股票的每月文件都将汇总为每股股票的季度文件。季度文件也将与每日和每月的文件一起存储在相同的新藏品中。

  3. 为了区分新集合中的文档的类型,它们将包括一个类型字段,其值为d',m',或分别为“每日”或“每月”或“季度”。这是库存符号和所涵盖期间的起始日期,将为每个文档形成一个复合_id值。

  4. 将维护前四个完整季度的数据,以及当前季度的数据。在每个新季度开始时,将删除最古老的季度的数据,以防止收集的大小及其相关索引无限期地增长。

新文档设计的一个示例看起来像这样:

{
  "_id": {
    "symbol": "MDB",
    "time": {"$date": "2022-11-06T00:00:00.000Z"},
    "type": "D"
  },
  "closing": 218.51,
  "high": 218.9599,
  "low": 216.0501,
  "opening": 218.7,
  "volume": 336998
}

有了这些更改,可以在数据集中的任何几天,几个月或四分之一的范围内形成查询。新系列设计的指标也令人鼓舞。跟踪与以前相同16,000个股票的数据:

  • 新系列最多包含大约540万个文件(即在四分之一结束之前)。与原始股票活动集合中的每分钟大约6.4亿个文档相比。

  • 原始的股票活动集合仍将用于收集每分钟的更新,最多只能保存200万个文档(目前的更新),而不是640以前数百万个文件。

  • 最坏的情况查询要求在季度结束前一天的第二天,需要30个每日文件,两个每月文件,和四个季度文件 - 总计每股汇总的36个文件。将此与大约154,000个文档进行比较,每股票需要汇总以使用每分钟文档进行相同的计算。

修改管道使用此新结构,现在看起来如下:

[
  {$match:{region: "WEST"}},
  {$lookup:{
    from: "stockDataEnhanced",
    localField: "portfolio",
    foreignField: "_id.symbol",
    pipeline: [
      {$match: {
        $and: [
          {"_id.type": "D"},
          {"_id.time": ISODate("2022-11-07T00:00:00.000+00:00")}
        ],
      },
      {$sort:{"_id.time": 1}},
      {$group:{
        _id: "$symbol",
        opening: {$first: "$opening"},
        high: {$max: "$high"},
        low: {$min: "$low"},
        closing: {$last: "$closing"},
        volume: {$sum: "$volume"}
      }},
      {$set:{
        symbol: "$_id",
        "_id": "$$REMOVE"
      }}
    ],
    as: "stockActivity",
  }
]

执行修订的管道的响应时间为 1800 ms 低于我们两秒钟的目标SLA!但是,设计审查团队认为可以做出进一步的改进。

应用模式设计模式 - 扩展参考和单一收集模式

解决了每次执行$lookup阶段子pipeline需要汇总的大量文档的问题,同时将数据库的整体大小降低了几乎98%,我们将注意力转向了另一个重要的重要性原始管道中的问题:在任何给定的执行中,任何给定股票都可以多次进行相同的聚合计算。

为了解决这个问题,我们重新审视了我们对数据中关系的理解以及我们想要表示这些关系的一步,该步骤以及量化工作量并评估其访问模式并应用最佳实践模式设计模式<构成了我们在MongoDB中进行数据建模方法的基础。

在这种情况下,我们正在使用客户文档上的$match阶段开始管道,以在给定区域中找到所有客户,因为我们需要他们的投资组合信息,并且在其中存储了一个看似逻辑的信息设计。
但是,查看访问数据的方式,如果我们可以添加和维护需要计算给定股票的区域列表,并将其添加到该股票的每个预计的股票活动文件中,那么我们就可以启动我们的管道,以预先计算的股票活动收集,重要的是,仅对每个必需的股票进行汇总数据。确定需要与哪些股票相关联的区域将涉及计算所有持有该股票属于其投资组合所属的股票的区域集。

将区域数据嵌入预先计算的库存活动文档中是扩展参考模式设计模式的变体。这种模式强调了父文档中相关文档的一部分字段,以便可以通过单个查询检索所有相关数据,并避免使用基于$lookup的加入。该模式没有嵌入整个子弹文档,而是鼓励仅嵌入满足查询谓词或查询返回中包含的那些字段。这有助于将父文档的整体规模保持在合理的限制范围内。

使用扩展参考模式是需要将儿童数据传播到多个父文档的需要的成本,因此,当引用数据不经常更改时,该模式特别有用。在高度归一化的RDBMS设计中,看到连接反复执行的查找表并不少见

在我们的工作量中,每当客户的投资组合更改时,该模式都会将可能更新的成本与每个股票的区域相关联。但是,由于这种情况相对相对,鉴于潜在的查询性能改善,成本被认为是可以接受的。

应用这些更改,现有计算的股票活动文件现在看起来像:

{
  "_id": {
    "symbol": "MDB",
    "time": {"$date": "2022-11-06T00:00:00.000Z"},
    "type": "D"
  },
  "closing": 218.51,
  "high": 218.9599,
  "low": 216.0501,
  "opening": 218.7,
  "regions": [
    {"region": "WEST"},
    {"region": "NORTH_EAST"},
    {"region": "CENTRAL"}
  ],
  "volume": 336998
}

下一个问题是,如果我们从库存活动数据中启动管道,那么我们将如何将其与客户数据联系起来?申请团队的第一个想法是另一个$lookup阶段。但是,在进一步的审查中,我们建议他们使用MongoDB集合的多态性性质,并使用single-collection模式设计模式将客户文档存储在与预计的股票活动数据相同的集合中。

单收集模式强调存储不同类型的文档,但在同一集合中是相关并访问的。通过在集合中的所有文档类型上使用一组常见的属性,并适当地索引这些属性,单个数据库搜索可以通过单个数据库操作来检索所有相关文档,保存网络往返/de--宣布开销。

在我们的情况下,我们选择使用以下文档形状将客户文档添加到股票活动收集中:

{
  "_id": {
    "customerID": "US4246774937",
    "symbol": "NMAI",
    "type": "C"
  },
  "firstname": "Jack",
  "lastname": "Bateman",
  "portfolio": [
    "NMAI",
    "PUBM",
    "MNA"
  ],
  "regions": [
    {
      "region": "US"
    }
  ]
}

使用这些文档要注意的关键是“化合物”中的'符号和类型字段,客户的区域被移至区域大批。这使现场名称和数据类型与每日,每月和季度文件一致。请注意,我们还为客户投资组合中的每个股票添加了一个客户文档。这允许在集合中有效地索引数据,但以某些数据重复为代价。但是,由于客户数据的变化相对频繁,因此被认为是可以接受的。

有了这些更改,我们现在可以定义一条避免重复库存聚合计算的管道,并避免使用昂贵的$lookup阶段。修订管道中的阶段是:

  1. 一个$match阶段,可以找到所有文档,其中区域数组包括目标区域,而“ _id.type”领域是客户的,或_id.type和_id.Time表示这是我们计算时间段的库存活动文件。 (比赛阶段查询可以更新以包括涵盖所请求的任何时间段所需的季度,每月和每日活动文件的任何组合。)

  2. $group阶段,用于汇总每种股票的库存活动数据,并为每个股票构建一系列客户文档。作为数据汇总的一部分,构建了每个个人活动文件中的一系列开放和关闭价格,并依靠集合上的索引来确保按时间顺序排列每个数组。

    <。

    < /li>
  3. 一个$set阶段,用于替换前$group阶段构建的开放价格阵列和收盘价阵列,分别在每个阵列中的第一个和最后一个条目,以提供每个阵列的总开头和收盘价库存。

  4. 最后,$unwind$group阶段组合以通过客户而不是库存重组数据,并将其塑造为我们所需的输出设计。

经过修订的管道,反对预定的库存活动收集,现在看起来像这样:

[
  $match: {
    $and: [
      {"regions.region": "WEST"},
      {$or:[
        {"_id.type": "customer"},
        {
          "_id.type": "day",
          "_id.time": ISODate("2023-02-07T00:00:00.000+00:00")
        }
      ]}
    ]
  }}
  {$group:{
    _id: "$_id.symbol",
    volume: {$sum: "$volume"},
    opening: {$push: "$opening"},
    high: {$max: "$high"},
    low: {$min: "$low"},
    closing: {$push: "$closing"},
    customers: {
      $addToSet: {
        $cond: {
          if: {$eq: ["$_id.type", "customer"]},
          then: "$$ROOT",
          else: "$$REMOVE"
        }
      }
    }
  }},
  {$set:{
    closing: {$last: "$closing"},
    opening: {$first: "$opening"}
  }},
  {$unwind: {path: "$customers"}},
  {$group:{
    _id: "$customers._id.customerID",
    region: {
      $first: {
        $getField: {
          field: "region",
          input: {$arrayElemAt: ["$customers.regions",0]}
        }
      }
    },
    firstname: {$first: "$customers.firstname"},
    lastname: {$first: "$customers.lastname"},
    portfolio: {$first: "$customers.portfolio"},
    stockActivity: { $addToSet: {
      symbol: "$_id",
      volume: "$volume",
      opening: "$opening",
      high: "$high",
      low: "$low",
      closing: "$closing",
    }}
  }}
]

此版本的管道的最终测试执行给出了 377 ms 的响应时间,比应用程序目标响应时间快四倍以上。

结论

正如我们对设计评论的正常实践一样,我们为应用程序开发团队提供了有关其工作量的性质和规模的列表,他们提前准备了会议。

完成此准备后,设计审查会话本身持续了一个小时,在此期间,我们进行了:

的标准数据建模过程
  • 评估工作量及其访问模式。
  • 查看数据中的关系。
  • 应用最佳实践架构设计模式。

在会议结束时,我们已经共同设法将聚合管道的性能提高了60倍,而其原始设计则易于超过应用程序目标SLA,同时大大降低了应用程序的存储要求。每个人都同意这是一个非常有生产力的会议。

认为您的团队可以通过与MongoDB的数据建模专家进行设计审查会议受益?请与您的帐户代表联系,以了解有关与我们的数据建模专家预订会议的更多信息,无论是实际上还是在您的城市选择MongoDB .local events

如果您想了解有关MongoDB数据建模和聚合管道的更多信息,我们建议使用以下资源: