Ohhnews

分类导航

$ cd ..
foojay原文

MongoDB聚合优化:通过数据复制提升读取性能(第4部分)

#mongodb#聚合优化#数据复制#查询性能#数据库

以及为什么 MongoDB 可能比你想象中更适合做关系型数据库。

设计评审是一对一的会议,MongoDB 专家会就数据建模最佳实践和应用设计挑战提供建议。在本系列中,我们将探讨设计评审帮助开发者在 MongoDB 上取得切实成功的常见真实场景。


[LOADING...]

在本系列中,我们介绍了为改进一个运行缓慢的 MongoDB 聚合管道的性能所采取的步骤。该管道是一个虚构的视频流服务应用的一部分,用于将用户配置文件映射到这些用户用来访问服务的设备,其设计基于我最近在一次设计评审中遇到的真实用例。

第 1 部分中,我们拆解了基于设计评审期间所见内容的初始管道设计,并解释了管道每个阶段的设计意图。如果你还没有读过那篇文章,或者需要重温 MongoDB 聚合管道,我建议你先回顾一下再继续。

在经历前两轮改进(移除不必要的 $unwind 阶段使用嵌入来更高效地对多对多关系建模)之后,我们的性能提升了 75%。然而,我们距离低于一秒的查询响应目标仍有很大差距。

管道描述每次查询平均耗时总耗时(300 次查询迭代,15 个并发线程)
初始设计11.8 秒260 秒
移除 $unwind4.7 秒105 秒
重构多对多关系2.9 秒62.5 秒

优化,第 3 步:复制设备名称信息

纵观查询管道的整体结构,最引人注目的一个方面是,初始的 $match 阶段按城市筛选配置文件,返回的是一个包含数千个配置文件的结果集。例如,选择“Austin”作为城市会返回一组 6,763 个文档。以此为起点,管道最终会将这些文档缩减到只有 10 个,这意味着绝大多数文档被处理后就被丢弃了。

在思考如何减少被不必要处理的文档数量时,我们的第一个想法是将设备名称信息从设备文档复制到对应的配置文件文档中。这样一来,初始匹配操作就可以更新为仅识别同时满足两个搜索条件的配置文件——城市至少一种用于连接流媒体服务的目标设备类型。这会大大减少被不必要处理的文档数量。

为了实现这一点,我们在之前的优化步骤中添加的设备序列号引用数组现在被更新为也包含设备名称:

$ cat
{
  "_id": {...},
  "DOB": "0001-01-01T00:00:00Z",
  "SSN": "943-83-1203",
  "accountNum": "G173VDREI5",
  "contact": {...},
  "customerType": "S",
  "devices": [
    {
      "deviceSN": "59737620-3d81-4c13-a161-b3c4e045cfe3",
      "deviceName": "Panasonic TV"
    },
    {
      "deviceSN": "24652cac-27a7-4961-8d11-1d77a55a1774",
      "deviceName": "Sceptre TV"
    },
    {
      "deviceSN": "6ea648d9-dc75-4909-82e2-0955fffb7a94",
      "deviceName": "iPhone 12"
    }
  ],
  "firstName": "Martin",
  "lastName": "Smith",
  "profileID": "G173VDREI5-1"
}

接下来,profiles 集合中针对 city 的索引被替换为同时包含 city 和新的 deviceName 字段的复合索引:

$ cat
{"contact.address.city": 1, "devices.deviceName": 1}

最后,初始的 $match 阶段被更新为同时基于城市设备名称进行搜索,而 $sort$skip$limit 阶段被重新定位到管道中 $lookup 阶段之前(稍后会详细介绍)。

现在完整的管道如下所示:

$ node
[
  {
    $match: {
      "contact.address.city": "Austin",
      "deviceSNs.deviceName": "iPhone 12"
    }
  },
  {
    $sort: {
      profileID: 1
    }
  },
  {
    $skip: 0
  },
  {
    $limit: 10
  },
  {
    $lookup: {
      from: "Devices",
      localField: "deviceSNs.deviceSN",
      foreignField: "deviceSN",
      pipeline: [
        {
          $match: {
            deviceName: "iPhone 12"
          }
        },
        {
          $set: {
            _id: "$$REMOVE"
          }
        }
      ],
      as: "deviceData"
    }
  },
  {
    $set: {
      accountNum: "$$REMOVE",
      mappingData: "$$REMOVE",
      customerType: "$$REMOVE",
      DOB: "$$REMOVE",
      _id: "$$REMOVE"
    }
  }
]

完成这一系列最新更改后,重新测试管道揭示了性能的显著提升。单个查询的平均耗时现在为 51 毫秒,300 次查询迭代的总耗时降至 1.2 秒。

管道描述每次查询平均耗时总耗时(300 次查询迭代,15 个并发线程)
初始设计11.8 秒260 秒
移除 $unwind4.7 秒105 秒
重构多对多关系2.9 秒62.5 秒
复制设备名称51 毫秒1.2 秒

为了理解为什么这一更改会对性能产生如此巨大的影响,我们比较了进行最新更改之前和之后管道的解释计划。实施最新更改前的解释计划显示,按“Austin”进行的初始配置文件搜索返回了 6,763 个文档。所有这些文档都需要由管道中的每个后续阶段处理。

相比之下,更改后的解释计划显示,通过将设备名称添加到查询条件中,匹配文档的数量——城市“Austin”至少有一台“iPhone 12”类型设备——降到了 1,234。这显著减少了后续管道阶段需要处理的文档数量。

另一个好处是,由于我们现在拥有了直接从配置文件文档中识别匹配配置文件所需的全部信息(即城市以及他们用来连接服务的设备类型),我们可以在 $match 之后立即执行 $sort 阶段,将匹配到的配置文件文档按所需的 profileID 顺序排列,然后应用 $skip 和 $limit 阶段,进一步将文档数量缩减到仅需返回的 10 个。这意味着到 $lookup 阶段时,我们只需对 10 个文档执行操作,而不是像之前的管道设计那样——以“Austin”为例——需要对 6,763 个文档执行。这是一项巨大的胜利。

当然,这一更改中最显而易见的问题是,它确实涉及复制设备名称信息。现在这些信息同时出现在配置文件和设备中,因此如果设备名称信息需要更改,更新成本会增加。我们也在一定程度上增加了系统存储需求。在经典的关系数据建模理论中,这些经常被引用为应避免数据复制的理由,但这些理由真的成立吗?虽然存储空间并非免费,但它的成本远没有埃德加·科德(Edgar Codd)在 20 世纪 70 年代提出其范式时那么昂贵。现实情况是,如果数据的读取频率远高于更新频率,审慎地使用数据复制来提升查询性能并降低读取操作期间相关的 CPU/内存成本,通常足以抵消额外的存储和更新成本。当复制的数据很少(甚至从不)变化时尤其如此,查找值或引用值通常就属于这种情况。

在我们的场景中,复制设备名称数据被认为是可接受的,因为设备名称几乎从不更改,因此即使需要更新,更新成本相对于所实现的查询性能提升而言也是一种可接受的权衡。在衡量这一更改后,我们还发现,复制的数据量并不会显著改变系统整体的存储需求。

现在,我们已成功将查询性能降到了 1 秒目标以下。但我们还可以做一个快速而简单的改进。在本系列的第五部分(也是最后一部分)中,我们将展示如何利用索引来避免昂贵的内存排序操作。

本文《MongoDB 聚合优化:通过数据复制提升读取性能(第 4 部分)》首发于 foojay