Ohhnews

分类导航

$ cd ..
foojay原文

MongoDB聚合优化:来自实践的案例研究(第一部分)

#mongodb#聚合优化#性能提升#数据建模#案例研究

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

[LOADING...]

本文作者:Graeme Robinson。你可以在LinkedIn上找到他。

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


MongoDB 常被描述为非关系型数据库,但无论我们使用何种数据库存储数据,数据内部都存在关联关系。不过,根据使用的数据库类型,我们建模这些关系的方式可能会有所不同。正如我的同事 Rick Houlihan 经常指出的那样,实际上并不存在所谓的「非关系型数据」。

MongoDB 凭借其文档数据模型(而非传统 RDBMS 的行列结构),提供了建模关系的方法,在查询数据时能带来显著的性能优势。然而,要发挥这些优势,必须使用针对文档数据模型优化的模式设计模式来建模数据,而这通常与 RDBMS 中的做法不同。

在最近的一次设计评审中,我恰好遇到了这个问题——一个相对较新使用 MongoDB 的团队,以非常「类 RDBMS」的方式建模了他们的数据,因此查询性能很差。不过,通过对数据模型和相应查询设计进行相对较小的改动,我们成功优化了查询,使其速度比初始设计提高了 800 多倍

在本系列文章中,我们将呈现一个虚构场景,该场景的数据模型和聚合管道查询设计基于评审中遇到的实际案例。我们将逐步介绍改进查询性能的每一步,并使用样本数据集展示每一步的影响。

如果你想重复本系列描述的测试,用于构建测试数据集并衡量每次修改性能的源代码可在 GitHub 上获取。

视频流服务用例:配置文件、设备与设备类型

在我们的示例场景中,我们为一家虚构的视频流服务创建了一个数据库。我们关注的系统部分涉及用户配置文件和设备,包含三个集合:

  • 用户「配置文件」代表访问流服务的单个用户。每个配置文件文档包含一个配置文件 ID、用户名、出生日期和社会安全号码 (SSN)、联系地址和电话号码,以及该配置文件所属的账户号。(每个账户可以有多个配置文件。例如,一个家庭可能有一个账户,每个家庭成员拥有独立的配置文件。)

  • 「设备」是配置文件用来访问流服务的设备。每个设备文档包含唯一标识设备的序列号、设备型号名称(例如「iPhone 12」或「Amazon Fire Tablet」)、设备最后连接时的 IP 地址,以及设备连接服务的授权需要续期的日期。

  • 设备和配置文件之间存在多对多关系——某些设备(如智能电视)往往被同一账户下的所有配置文件使用,而其他设备(如智能手机)则常被单个配置文件使用。通常,每个配置文件通过多个设备访问服务。

为了映射配置文件与设备,我们使用了中间(或「关联」)集合「映射」。该集合中的文档包含关系一端的配置文件 ID 和关系另一端的设备序列号,从而将配置文件与设备之间的多对多关系转换为配置文件与映射之间的一对多关系,以及映射与设备之间的多对一关系。

[LOADING...]

这种建模多对多关系的方法在使用关系型数据库时很常见,它绕过了表格数据模型的一个局限:无法很好地存储任意数量的引用字段值——在这里,即设备中的配置文件 ID,或配置文件中的设备 ID。

使用这个数据模型,我们尝试执行的查询旨在提供所有地址在指定城市且使用过特定设备类型访问服务的配置文件列表——例如,所有地址在奥斯汀且使用过 iPhone 12 访问服务的配置文件。查询输出需要嵌入每个匹配配置文件所用指定类型设备的详细信息。配置文件按配置文件 ID 排序,每页返回 10 个文档。输出文档示例如下:

{
  "SSN": "438-40-3508",
  "contact": {
    "address": {
      "city": "Austin",
      "stateCode": "TX",
      "street": "9590 Reagan Expressway",
      "zipCode": "84880"
    },
    "phoneNumber": "(834) 392-0946"
  },
  "firstName": "Sean",
  "lastName": "Henderson",
  "profileID": "R3G6RWT1Z4-1",
  "deviceData": [
    {
      "authorizationExpiryDate": "2025-01-30T02:13:52.738393953Z",
      "deviceName": "iPhone 12",
      "deviceSN": "b29a8e45-9b2d-40b7-a2f7-a36da8115f3e",
      "lastIP4": "30.216.230.61",
      "lastIP6": "e55c:c4a4:e79e:419c:2a3e:4de:2a2c:e443",
      "lastSeenDate": "2024-12-24T02:13:52.738392802Z",
      "parentalControls": false
    }
  ]
}
```## 理解查询聚合管道

为了执行查询,使用了 MongoDB 聚合管道。在 MongoDB 中,聚合管道用于对文档执行一系列查询、处理和转换步骤,其定义为“阶段”(stage)组成的数组。每个阶段接收一组输入文档,对这些文档执行操作,然后将输出传递给管道中的下一个阶段。

在本案例中,初始管道针对 `profiles` 集合运行,包含 10 个阶段:

![](https://foojay.io/wp-content/uploads/2026/06/tue13.png)

[**$match**](https://www.mongodb.com/docs/manual/reference/operator/aggregation/match/?utm_campaign=devrel&utm_source=third-party-content&utm_medium=cta&utm_content=foojay.io&utm_term=tony.kim#-match--aggregation-)

```json
{
  "contact.address.city": "Austin"
}

这通常是管道中的第一个阶段。$match 阶段对输入文档执行查询,仅输出匹配的文档。如果这是管道的第一个阶段,则查询会针对底层集合(此处为 profiles)运行,并且如果存在支持该查询的索引,则会利用该索引。上述示例中,我们正在搜索 profiles 集合中所有 contact.address.city"Austin" 的文档。

$lookup

$ cat
{
  from: "Mappings",
  localField: "profileID",
  foreignField: "profileID",
  as: "mappingData"
}

这是管道中的两个 $lookup 阶段中的第一个。$lookup 阶段是 MongoDB 中 SQL 连接(join)的等价物。在此案例中,管道将上一阶段识别出的“Austin”人物文档与“Mappings”集合中的对应条目进行连接。连接基于主键/外键关系:人物文档中的 profileID 字段与映射文档中的 profileID 字段。匹配到的映射文档会被添加到一个新数组 mappingData 中,存于人物文档内。例如,一个人物文档如果在 mappings 集合中有两个匹配的条目,则可能如下所示:

$ cat
{
  "_id": {"$oid": "6781d3ed41099532d431e774"},
  "DOB": "1987-06-17T00:00:00Z",
  "SSN": "592-55-1484",
  "accountNum": "VMV4AMDTCZ",
  "contact": {
    "address": {
      "city": "Austin",
      "stateCode": "TX",
      "street": "1664 Eisenhower Pike",
      "zipCode": "77229"
    },
    "phoneNumber": "(842) 836-0490"
  },
  "customerType": "S",
  "firstName": "Jack",
  "lastName": "Snyder",
  "profileID": "VMV4AMDTCZ-1",
  "mappingData": [
    {
      "_id": {"$oid":"6781d3db41099532d4242e63"},
      "deviceSN": "97670d46-5235-4a2c-90c7-10f47273606f",
      "profileID": "VMV4AMDTCZ-1"
    },
    {
      "_id": {"$oid": "6781d3db41099532d4242e64"},
      "deviceSN": "b2f255ea-6951-4ed5-bd6f-052dc2ac9880",
      "profileID": "VMV4AMDTCZ-1"
    }
  ]
}

$unwind

$ cat
{
    $unwind: "$mappingData"
}

这是管道中的两个 $unwind 阶段中的第一个。每当需要展平管道文档中的数组时,就会使用该阶段,通常是为了在后续阶段中能够按不同字段重新组织或分组数据。如果某个文档包含三个待展开的数组元素,则该文档会被替换为三个文档,每个文档中数组被替换为对应一个数组元素的子文档。

本例中,管道正在展开映射文档的数组,以便后续将它们与对应的设备文档连接。将 $unwind 应用于上一阶段示例文档(包含两个映射文档的数组),会将其转换为两个独立的文档:

$ cat
{
  "_id": {...},
  "DOB": "1987-06-17T00:00:00Z",
  "SSN": "592-55-1484",
  "accountNum": "VMV4AMDTCZ",
  "contact": {...},
  "customerType": "S",
  "firstName": "Jack",
  "lastName": "Snyder",
  "profileID": "VMV4AMDTCZ-1",
  "mappingData": {
    "_id": {"$oid":"6781d3db41099532d4242e63"},
    "deviceSN": "97670d46-5235-4a2c-90c7-10f47273606f",
    "profileID": "VMV4AMDTCZ-1"
  }
}

{
  "_id": {...},
  "DOB": "1987-06-17T00:00:00Z",
  "SSN": "592-55-1484",
  "accountNum": "VMV4AMDTCZ",
  "contact": {...},
  "customerType": "S",
  "firstName": "Jack",
  "lastName": "Snyder",
  "profileID": "VMV4AMDTCZ-1",
  "mappingData": {
    "_id": {"$oid": "6781d3db41099532d4242e64"},
    "deviceSN": "b2f255ea-6951-4ed5-bd6f-052dc2ac9880",
    "profileID": "VMV4AMDTCZ-1"
  }
}

这导致文档格式与 SQL 连接产生的结果非常相似,“父”表中的数据会在子表的每一关联行中重复出现。

$lookup

$ cat
{
  from: "Devices",
  localField: "mappingData.deviceSN",
  foreignField: "deviceSN",
  pipeline: [
    {
      $match: {
        deviceName: "iPhone 12"
      }
    },
    {
      $set: {
        _id: "$$REMOVE"
      }
    }
  ],
  as: "deviceData"
}

管道中的第二个 $lookup 阶段执行从映射文档到对应设备文档的连接。与上一个使用直接 localField 和 foreignField 进行匹配的 lookup 不同,此形式的 lookup 阶段使用了嵌入的子管道,允许我们为连接指定更复杂的条件,并根据需要重塑匹配到的文档。

本例中,除了设备文档中的 deviceSN 必须匹配映射文档中的 deviceSN 外,还要求设备文档中的 deviceName 必须等于 "iPhone 12"。同时指定移除匹配设备文档中的 _id 字段,并将匹配的设备文档添加到输出文档的一个名为 deviceData 的数组中。

如果上一 $unwind 阶段产出的两个文档都映射到一个设备名为 "iPhone 12" 的设备文档,则输出文档将如下所示:

$ cat
{
  "_id": {...},
  "DOB": "1987-06-17T00:00:00Z",
  "SSN": "592-55-1484",
  "accountNum": "VMV4AMDTCZ",
  "contact": {...},
  "customerType": "S",
  "firstName": "Jack",
  "lastName": "Snyder",
  "profileID": "VMV4AMDTCZ-1",
  "deviceData": [
    {
      "authorizationExpiryDate": "2025-01-27T02:13:44.574749489Z",
      "deviceName": "iPhone 12",
      "deviceSN": "97670d46-5235-4a2c-90c7-10f47273606f",
      "lastIP4": "242.58.202.210",
      "lastIP6": "5685:8fe2:17e1:5c5b:3170:f77b:2768:9357",
      "lastSeenDate": "2024-12-10T02:13:44.574747997Z",
      "parentalControls": true
    }
  ],
  "mappingData": {...}
}

{
  "_id": {...},
  "DOB": "1987-06-17T00:00:00Z",
  "SSN": "592-55-1484",
  "accountNum": "VMV4AMDTCZ",
  "contact": {...},
  "customerType": "S",
  "firstName": "Jack",
  "lastName": "Snyder",
  "profileID": "VMV4AMDTCZ-1",
  "deviceData": [
    {
      "authorizationExpiryDate": "2025-02-16T12:13:44.574749489Z",
      "deviceName": "iPhone 12",
      "deviceSN": "b2f255ea-6951-4ed5-bd6f-052dc2ac9880",
      "lastIP4": "242.58.202.210",
      "lastIP6": "5685:8fe2:17e1:5c5b:3170:f77b:2768:9357",
      "lastSeenDate": "2024-12-27T02:13:44.574747997Z",
      "parentalControls": false
    }
  ],
  "mappingData": {...}
}

$unwind

$ cat
  {
    $unwind: "$deviceData"
  }

$lookup(连接)阶段预期可能有多个子文档需要添加到父文档中,因此会将匹配到的子文档以数组形式放入父文档中的字段——本例为 deviceData

然而,由于上一个 $lookup 阶段是针对每个输入文档的特定设备序列号进行连接,因此最多只会找到一个设备文档,此时使用数组来存储匹配的设备文档是不必要的。因此,第二个 $unwind 阶段被用来将单元素数组 deviceData 展平为子文档。

这同时导致了那些 deviceData 数组为空的输入文档从数据集中被移除。这种情况发生在映射的设备不是 iPhone 12 时。

示例输出文档现在将如下所示(注意 deviceData 现在是子文档,而非数组):

$ cat
{
  "_id": {...},
  "DOB": "1987-06-17T00:00:00Z",
  "SSN": "592-55-1484",
  "accountNum": "VMV4AMDTCZ",
  "contact": {...},
  "customerType": "S",
  "firstName": "Jack",
  "lastName": "Snyder",
  "profileID": "VMV4AMDTCZ-1",
  "deviceData": {
    "authorizationExpiryDate": "2025-01-27T02:13:44.574749489Z",
    "deviceName": "iPhone 12",
    "deviceSN": "97670d46-5235-4a2c-90c7-10f47273606f",
    "lastIP4": "242.58.202.210",
    "lastIP6": "5685:8fe2:17e1:5c5b:3170:f77b:2768:9357",
    "lastSeenDate": "2024-12-10T02:13:44.574747997Z",
    "parentalControls": true
  },
  "mappingData": {...}
}

{
  "_id": {...},
  "DOB": "1987-06-17T00:00:00Z",
  "SSN": "592-55-1484",
  "accountNum": "VMV4AMDTCZ",
  "contact": {...},
  "customerType": "S",
  "firstName": "Jack",
  "lastName": "Snyder",
  "profileID": "VMV4AMDTCZ-1",
  "deviceData": {
    "authorizationExpiryDate": "2025-02-16T12:13:44.574749489Z",
    "deviceName": "iPhone 12",
    "deviceSN": "b2f255ea-6951-4ed5-bd6f-052dc2ac9880",
    "lastIP4": "242.58.202.210",
    "lastIP6": "5685:8fe2:17e1:5c5b:3170:f77b:2768:9357",
    "lastSeenDate": "2024-12-27T02:13:44.574747997Z",
    "parentalControls": false
  },
  "mappingData": {...}
}

$group

$ cat
{
  _id: "$profileID",
  firstName: {
    $first: "$firstName"
  },
  lastName: {
    $first: "$lastName"
  },
  contact: {
    $first: "$contact"
  },
  ssn: {
    $first: "$SSN"
  },
  deviceData: {
    $push: "$deviceData"
  }
}

此时,管道中每个匹配所选城市和设备名称的人物与设备组合都对应一个文档。现在使用 $group 阶段合并文档,使得每个人物只有一个文档,其中包含其匹配设备的数组。这同时移除了最终输出中不需要的字段。

$ cat
{
  "_id": "VMV4AMDTCZ-1",
  "SSN": "592-55-1484",
  "contact": {...},
  "firstName": "Jack",
  "lastName": "Snyder",
  "deviceData": [
    {
      "authorizationExpiryDate": "2025-01-27T02:13:44.574749489Z",
      "deviceName": "iPhone 12",
      "deviceSN": "97670d46-5235-4a2c-90c7-10f47273606f",
      "lastIP4": "242.58.202.210",
      "lastIP6": "5685:8fe2:17e1:5c5b:3170:f77b:2768:9357",
      "lastSeenDate": "2024-12-10T02:13:44.574747997Z",
      "parentalControls": true
    },
    {
      "authorizationExpiryDate": "2025-02-16T12:13:44.574749489Z",
      "deviceName": "iPhone 12",
      "deviceSN": "b2f255ea-6951-4ed5-bd6f-052dc2ac9880",
      "lastIP4": "242.58.202.210",
      "lastIP6": "5685:8fe2:17e1:5c5b:3170:f77b:2768:9357",
      "lastSeenDate": "2024-12-27T02:13:44.574747997Z",
      "parentalControls": false
    }
  ]
}

$set

$ cat
{
  $set:
    {
      profileID: "$_id",
      _id: "$$REMOVE"
    }
 }

此时添加一个 $set 阶段,将上一 $group 阶段创建的 _id 字段重命名为 profileID,以提高可读性。

$sort / $skip / $limit

$ cat
{
  $sort: {
    profileID: 1
  }
},
{
  $skip: 0
},
{
  $limit: 10
}

管道的最后几个阶段按 profileID 对文档进行排序,然后使用 $skip$limit 阶段返回所需的每页 10 条结果。## 管道性能问题

尽管最初的管道设计能够返回正确的结果,甚至从等效 SQL 查询的构建思路来看,其设计也完全可以视为合理,但它的性能却存在问题。

使用包含 100 万条个人资料、340 万条设备记录以及 500 万条映射关系的测试数据集,在 MongoDB Atlas M20 集群上运行的平均单次查询执行时间为 11.8 秒;而 300 次查询在 15 个并行线程中执行的总耗时为 260 秒。这实际上使得该查询在实际场景中无法使用。事实上,系统 SLA 要求查询执行时间需低于 1 秒。

一如既往,在调查 MongoDB 慢查询时,我们首先检查是否存在缺失的索引。MongoDB 中的索引与关系数据库中的索引功能相同,而索引缺失或定义不当是我们最常见的性能问题根源。然而,在此案例中,我们检查了每个集合上定义的索引,并审查了管道的查询计划,结果显示:对 profiles 集合的初始匹配以及后续对 mappings 和 devices 集合的查找均得到了索引的充分支持。

排除了索引缺失这一慢查询原因后,我们将注意力转向管道本身的设计,以及底层数据模型对其的支持程度。事实证明,这一方向更具成效。在完成数据模型和查询管道的优化后,仅通过相对简单的改动,我们将平均每次查询的时间降低至 14 毫秒,300 次查询的总耗时降至 655 毫秒。这包括了应用服务器与 MongoDB Atlas 集群之间的完整往返网络延迟。

管道描述平均每次查询时间总耗时(300 次查询迭代,15 个并发线程)
初始设计11.8 秒260 秒
最终设计14 毫秒655 毫秒

本文首发于 foojay,原文:《MongoDB 聚合优化:来自一线的案例研究(第一部分)》