MongoDB 性能调优教程(一)
一、系统的性能调优
性能是任何应用的关键成功因素。如果你想想你每天使用的应用,很明显你只使用性能好的应用。如果谷歌搜索需要 2 分钟,而必应几乎是即时的,你会使用谷歌吗?当然不是。事实上,研究表明,如果一个页面的加载时间超过 3 秒,大约有一半的人会放弃这个网站。 1
应用的性能取决于许多因素,但是性能差的最常见的可避免的原因是数据库。将数据从磁盘移动到数据库,然后从数据库移动到应用,涉及应用基础架构中最慢的组件—磁盘驱动器和网络。因此,对与数据库交互的应用代码和数据库本身进行优化以获得最佳性能是至关重要的。
警示故事
您的 MongoDB 调优方法对于调优工作的最终成功至关重要。想想下面这个警示故事。
一个由 MongoDB 数据库支持的重要网站表现出不可接受的性能。作为一名经验丰富的 MongoDB 专业人员,您被叫来诊断问题。当您查看关键的操作系统性能指标时,有两点非常突出:主副本集上的 CPU 和 IO 都很高。CPU 平均负载和磁盘 IO 延迟都表明 MongoDB 系统需要更多的 CPU 和 IO 容量。
经过快速计算,您建议切分 MongoDB,将负载分散到四台服务器上。美元成本是巨大的,跨碎片重新分发数据所需的停机时间也是巨大的。然而,必须做些什么,所以管理层批准了费用和停工期。在实现之后,网站的性能是可以接受的,您谦虚地认为这是您的功劳。
成功的结果?你这么认为,直到
-
几个月后,性能又成了问题——每个碎片的容量都快用完了。
-
另一个 MongoDB 专家被请来,他报告说,一个简单的索引更改就可以修复原来的问题,而不需要任何成本和停机时间。此外,她指出分片实际上损害了特定查询的性能,并建议对几个集合进行分片。
-
实施新的索引后,数据库工作负载将减少到最初项目期间观察到的十分之一。管理层准备出售易贝现在过剩的硬件,并在你的咨询记录上盖上“不要再接洽”的印记。
-
你的另一半为了一个 PHP 程序员离开了你,而你最终剃了光头出家了。
经过几个月的沉默沉思,您意识到虽然您的调优工作正确地集中在数据库中消耗时间最多的活动上,但它们未能区分原因和结果。因此,你错误地处理了一个效应——高 CPU 和 IO 率——而忽略了原因(一个缺失的索引)。
症状性能调整
上面概述的方法可以称为症状性能调优。作为一名性能调优医生,我们会问应用“哪里疼”,然后尽最大努力减轻这种痛苦。
症状性性能调优有它的用武之地:如果您处于“救火”模式——在这种模式下,由于性能问题,应用实际上是不可用的——这可能是最好的方法。但是一般来说,它会产生一些不良后果:
-
我们可能会治疗表现不佳的症状,而不是原因。
-
当配置或应用更改更具成本效益时,我们可能会倾向于寻求基于硬件的解决方案。
-
我们可能会处理今天的痛苦,但无法实现永久或可扩展的解决方案。
系统性能调整
避免错误地关注原因而不是结果的最好方法是以自上而下的方式调优数据库系统。这种方法有时被称为“分层调优”,但我们更愿意称之为“系统性能调优”
数据库请求的剖析
为了避免症状方法的缺陷,我们需要我们的调优活动遵循明确定义的阶段。这些阶段是由应用、数据库和操作系统的交互方式决定的。在非常高的层次上,数据库处理发生在“层”中,如下所示:
-
应用以调用 MongoDB API 的形式向 MongoDB 发送请求。数据库用返回代码和数据数组来响应这些请求。
-
然后,数据库必须解析请求。数据库必须计算出用户打算访问什么资源,检查用户是否被授权执行所请求的活动,确定要使用的确切访问机制,并获取相关的锁和资源。这些操作使用操作系统资源(CPU 和内存),并可能与其他并发执行的数据库会话产生争用。
-
最终,数据库请求将需要处理(创建、读取或更改)数据库中的一些数据。需要处理的确切数据量可能因数据库设计(文档模式模型和索引)和应用请求的精确编码而异。
-
一些需要的数据将在内存中。数据在内存中的机会主要取决于数据被访问的频率和可用于缓存数据的内存量。当我们访问内存中的数据库数据时,这被称为逻辑读取。
-
如果数据不在内存中,则必须从磁盘访问,从而导致一次物理读取。到目前为止,物理磁盘 IO 是所有操作中最昂贵的。因此,数据库会尽力避免这些物理读取。但是,某些磁盘活动是不可避免的。
每一层的活动都会影响下一层的需求。例如,如果提交的请求由于某种原因未能利用索引,它将需要大量的逻辑读取,这反过来将最终涉及大量的物理读取。
Tip
当您看到大量 IO 或争用时,很容易通过调整磁盘布局来直接处理症状。但是,如果您对您的调优工作进行排序,以便按顺序完成各个层,那么您就有更好的机会修复根本原因并缓解较低层的性能。
简而言之,下面是系统性能调优的三个步骤:
-
通过调优数据库请求和优化数据库设计(索引和文档建模),将应用需求降低到其逻辑最小值。
-
在前面的步骤中降低了对数据库的需求后,优化内存以尽可能避免更多的物理 IO。
-
现在,物理 IO 需求是现实的,通过提供足够的 IO 带宽并平均分配产生的负载,配置 IO 子系统以满足该需求。
MongoDB 数据库的层次
MongoDB——事实上,几乎所有的数据库管理系统——都由多层代码组成,如图 1-1 所示。
图 1-1
MongoDB 应用的关键层
第一层代码是应用层。尽管您可能认为应用代码不是数据库的一部分,但它仍然在执行数据库驱动程序代码,并且是数据库性能图中不可或缺的一部分。应用层定义了数据模型(模式)和数据访问逻辑。
下一层代码是 MongoDB 数据库服务器。数据库服务器包含处理 MongoDB 命令、维护索引和管理分布式集群的代码。
下一层是存储引擎。存储引擎是数据库的一部分,但也是不同的代码层。在 MongoDB 中,存储引擎有多种选择,比如内存、RocksDB 和 MMAP。然而,它通常以 WiredTiger 存储引擎为代表。存储引擎负责在内存中缓存数据。
最后,我们有存储子系统。存储子系统不是 MongoDB 代码库的一部分:它是在操作系统或存储硬件中实现的。在简单的单服务器配置中,它由文件系统和磁盘设备的固件表示。
Tip
应用堆栈的每一层上的负载由上面的层决定。在确定上面的层已经优化之前,调整较低层通常是错误的。
最小化应用工作负载
我们的第一个目标是最小化应用对数据库的需求。我们希望数据库以尽可能少的处理来满足应用的数据需求。换句话说,我们希望 MongoDB更聪明地工作,而不是更努力地。
我们使用两种主要技术来减少应用工作负载:
-
调优应用代码:这可能涉及更改应用代码——JavaScript、Golang 或 Java——以便它向数据库发出更少的请求(例如,通过使用客户端缓存)。然而,更常见的情况是,这将涉及重写特定于应用 MongoDB 的数据库调用,如
find()或aggregate()。 -
调整数据库设计:数据库设计是应用数据库的物理实现。优化数据库设计可能涉及修改索引或更改单个集合中使用的文档模型。
第 4 章到第 9 章详细介绍了我们可以用来最小化应用工作负载的各种技术,特别是:
-
构建应用以避免数据库过载:应用可以避免对数据库进行不必要的请求,并且可以被设计为最小化锁、热点和其他争用。可以设计和实现与 MongoDB 交互的程序,以最小化数据库往返和不必要的请求。
-
优化物理数据库设计:这包括索引和结构化文档模式模型,以减少执行 MongoDB 请求所需的工作。
-
编写高效的数据库请求:这涉及到理解如何编写和优化
find()、update()、aggregate()以及其他命令。
这些技术不仅代表了我们调优工作的逻辑起点,也代表了提供最显著的性能改进的技术。应用调优导致 100 倍甚至 1000 倍的性能提升并不罕见:这种提升在优化内存或调整物理磁盘布局时很少见到。
减少物理 IO
既然应用需求已经最小化,我们就把注意力转向减少等待 IO 的时间。换句话说,在尝试减少每个 IO 所用的时间(IO 延迟)之前,我们会尝试减少 IO 请求的数量。事实证明,无论如何,减少 IO 的数量几乎总是会减少 IO 延迟,因此首先解决 IO 的数量会事半功倍。
MongoDB 数据库中的大多数物理 IO 要么是因为应用会话请求数据来满足查询,要么是因为数据修改请求。为 WiredTiger 缓存和其他内存结构分配足够的内存是减少物理 IO 最重要的一步。第 11 章专门讨论这个话题。
优化磁盘 IO
此时,我们已经正常化了应用工作负载,特别是应用所需的逻辑 IO 量。我们还配置了可用内存,以最大限度地减少最终导致物理 IO 的逻辑 IO 数量。现在,也只有现在,确保我们的磁盘 IO 子系统能够应对挑战才是有意义的。
当然,优化磁盘 IO 子系统可能是一项复杂而专门的任务;但是基本原则很简单:
-
确保 IO 子系统有足够的带宽来应对物理 IO 需求。这是由您分配的不同磁盘设备的数量和磁盘设备的类型决定的。
-
将您的负载均匀分布在您分配的磁盘上,最好的方法是 RAID 0(条带化)。对于大多数数据库来说,最糟糕的方法是 RAID 5 或类似的方法,这会导致写 IO 的巨大损失。
-
在基于云的环境中,您通常不必担心条带化的机制。但是,您仍然需要确保您分配的总 IO 带宽是足够的。
IO 子系统压力过大的明显症状是对 IO 请求的响应过度延迟。例如,您可能有一个每秒能够支持 1000 个请求的 IO 子系统,但是在单个请求的响应时间降低之前,您可能只能将其提升到每秒 500 个请求。在配置 IO 子系统时,这种吞吐量/响应时间的权衡是一个重要的考虑因素。
第 12 章和第 13 章详细介绍了优化磁盘 IO 的过程。
集群调优
上述所有因素同样适用于单实例 MongoDB 部署和 MongoDB 集群。然而,集群化的 MongoDB 包含了额外的挑战和机遇,例如:
-
在标准副本集配置中——其中有一个主节点和多个辅助节点——我们需要在性能、一致性和数据完整性之间进行权衡。读取关注点和写入偏好参数控制如何从辅助节点写入和读取数据。调整这些方法可以提高性能,但也可能会在故障转移或读取过时数据时丢失数据。
-
在分片副本集中,有多个主节点,这为具有高事务率的超大型数据库提供了更好的可伸缩性和性能。然而,分片可能不是实现性能结果的最具成本效益的方式,并且确实涉及性能权衡。如果您使用分片,那么分片键的选择和确定要分片的集合对您的成功至关重要。
我们将在第 13 章和第 14 章详细讨论集群配置和调优。
摘要
当面对 IO 绑定的数据库时,人们很容易立即处理最明显的症状——IO 子系统。不幸的是,这通常导致治标不治本,而且往往是昂贵的,而且往往最终是徒劳的。因为一个数据库层中的问题可能是由更高层中的配置引起或解决的,所以优化 MongoDB 数据库的最有效的方法是在优化更低层之前优化更高层:
-
通过优化数据库请求和调整数据库设计(索引和文档建模),将应用需求降低到其逻辑最小值。
-
在前面的步骤中降低了对数据库的需求后,优化内存以尽可能避免更多的物理 IO。
-
现在,物理 IO 需求是现实的,通过提供足够的 IO 带宽并平均分配产生的负载,配置 IO 子系统以满足该需求。
https://developers.google.com/web/fundamentals/performance/why-performance-matters
二、MongoDB 架构和概念
本章旨在让您了解 MongoDB 架构和后续章节中提到的内部机制,这对于 MongoDB 性能调优是必要的。
MongoDB 调优专家应该对 MongoDB 技术的以下主要领域非常熟悉:
-
MongoDB 文档模型
-
MongoDB 应用通过 MongoDB API 与 MongoDB 数据库服务器交互的方式
-
MongoDB 优化器,它是与最大化 MongoDB 请求性能相关的软件层
-
MongoDB 服务器架构,包括内存、进程和文件,它们相互作用以提供数据库服务
对这份材料非常熟悉的读者可能希望略读或跳过这一章。然而,我们将在后续章节中假设您熟悉这里介绍的核心概念。
MongoDB 文档模型
正如您所知,MongoDB 是一个文档数据库。文档数据库是一系列非关系数据库,它们将数据存储为结构化文档——通常是以 JavaScript 对象符号 ( JSON )格式。
像 MongoDB 这样基于 JSON 的文档数据库在过去的十年里蓬勃发展,原因有很多。特别是,它们解决了长期困扰软件开发者的面向对象编程和关系数据库模型之间的冲突。灵活的文档模式模型支持敏捷开发和 DevOps 范例,并与主流编程模型紧密结合——尤其是那些基于 web 的现代应用。
数据
MongoDB 使用一种不同的 JavaScript 对象符号 (JSON)作为它的数据模型和通信协议。JSON 文档是由一小组基本构造组成的——值、对象和数组:
-
数组由用方括号(“[”和“]”)括起来并用逗号(“,”)分隔的值列表组成。
-
对象由一个或多个名称-值对组成,格式为“name-value”,用大括号(“{”和:}”)括起来,用逗号(“,”)分隔。
-
值可以是 Unicode 字符串、标准格式数字(可能包括科学记数法)、布尔值、数组或对象。
前面定义中的最后几个词很关键。因为值可能包含对象或数组,而对象或数组本身又包含值,所以 JSON 结构可以表示任意复杂的嵌套信息集。特别是,数组可以用来表示重复的文档组,这在关系数据库中需要单独的表。
二进制 JSON (BSON)
MongoDB 在内部以二进制 JSON ( BSON )格式存储 JSON 文档。BSON 旨在成为 JSON 数据的一种更紧凑、更高效的表示,并对数字和其他数据类型使用更高效的编码。例如,BSON 包括字段长度前缀,允许扫描操作“跳过”元素,从而提高效率。
BSON 还提供了许多 JSON 不支持的额外数据类型。例如,JSON 中的数值在 BSON 可以是 Double、Int、Long 或 Decimal128。ObjectID、Date 和 BinaryData 等其他类型也很常用。然而,大多数时候,JSON 和 BSON 之间的差异并不重要。
收集
MongoDB 允许您将“相似的”文档组织到集合中。集合类似于关系数据库中的表。通常,您将只存储特定集合中具有相似结构或目的的文档,尽管默认情况下集合中文档的结构是不强制的。
图 2-1 展示了 JSON 文档的内部结构,以及文档是如何组织成集合的。
图 2-1
JSON 文档结构
蒙戈布图式
MongoDB 文档模型允许将需要关系数据库中许多表的对象存储在单个文档中。
考虑下面的 MongoDB 文档:
{
_id: 1,
name: 'Ron Swanson',
address: 'Really not your concern',
dob: ISODate('1971-04-15T01:03:48Z'),
orders: [
{
orderDate: ISODate('2015-02-15T09:05:00Z'),
items: [
{ productName: 'Meat damper', quantity: 999 },
{ productName: 'Meat sauce', quantity: 9 }
]
},
{ otherorders }
]
};
与前面的示例一样,一个文档可能包含另一个子文档,而该子文档本身可能包含一个子文档,依此类推。有两个限制将最终停止该文档嵌套:100 层嵌套的默认限制和单个文档(包括其所有子文档)的 16MB 大小限制。
在数据库术语中,模式定义了数据库对象中的数据结构。默认情况下,MongoDB 数据库不强制模式,所以您可以在集合中存储任何您喜欢的内容。但是,可以使用createCollection方法的validator选项创建一个模式来实施文档结构,如下例所示:
db.createCollection("customers", {
"validator": {
"$jsonSchema": {
"bsonType": "object",
"additionalProperties": false,
"properties": {
"_id": {
"bsonType": "objectId"
},
"name": {
"bsonType": "string"
},
"address": {
"bsonType": "string"
},
"dob": {
"bsonType": "date"
},
"orders": {
"bsonType": "array",
"uniqueItems": false,
"items": {
"bsonType": "object",
"properties": {
"orderDate": { "bsonType": "date"},
"items": {
"bsonType": "array",
"uniqueItems": false,
"items": {
"bsonType": "object",
"properties": {
"productName": {
"bsonType": "string"
},
"quantity": {
"bsonType": "int"
}
}
}
}
}
}
}
}
}
},
"validationLevel": "strict",
"validationAction": "warn"
});
验证器采用的是 JSON 模式格式——这是一种开放标准,允许对 JSON 文档进行注释或验证。如果 MongoDB 命令导致文档与模式定义不匹配,JSON 模式文档将生成警告或错误。JSON 模式可用于定义强制属性、限制其他属性,以及定义文档属性可以采用的数据类型或数据范围。
MongoDB 协议
MongoDB 协议定义了客户机和服务器之间的通信机制。尽管协议的细节超出了我们的性能调优工作的范围,但是理解协议是很重要的,因为许多诊断工具将以 MongoDB 协议格式显示数据。
有线协议
MongoDB 的协议也被称为 MongoDB 有线协议。这是发送到 MongoDB 服务器和从 MongoDB 服务器接收的 MongoDB 包的结构。有线协议通过 TCP/IP 连接运行,默认情况下通过端口 27017 运行。
wire 协议的实际包结构超出了我们的范围,但是每个包的本质都是一个包含请求或响应的 JSON 文档。例如,如果我们从 shell 向 MongoDB 发送如下命令:
db.customers.find({FirstName:'MARY'},{Phone:1}).sort({Phone:1})
然后,shell 将通过有线协议发送一个请求,如下所示:
{ "find" : "customers",
"filter" : { "FirstName" : "MARY" },
"sort" : { "Phone" : 1.0 },
"projection" : { "Phone" : 1.0},
"$db" : "mongoTuningBook",
"$clusterTime" : { "clusterTime" : {
"$timestamp" : { "t" : 1589596899, "i" : 1 } },
"signature" : { "hash" : { "$binary" : { "base64" : ]
"4RGjzZI5khOmM9BBWLz6y9xLZ9w=", "subType" : "00" } },
"keyId" : 6826926447718825986 } },
"lsid" : { "id" : { "$binary" : { "base64" :
"JI3lUrOMRQm0Y6Pr3iQ8EQ==", "subType" : "04" } } } }
MongoDB 驱动程序
MongoDB 驱动程序将来自编程语言的请求翻译成有线协议格式。每个驱动程序都有细微的语法差异。例如,在 NodeJS 中,前面的 MongoDB shell 请求略有不同:
const docs = await db.collection('customers').
find({'FirstName': 'MARY'},
{'Phone': 1}).
sort({Phone: 1}).toArray();
因为 NodeJS 是一个 JavaScript 平台,所以语法仍然类似于 MongoDB shell。但是在其他语言中,这种差异会更加明显。例如,下面是 Go 语言中的相同查询:
collection := client.Database("MongoDBTuningBook").
Collection("customers")
filter := bson.D{{"FirstName", "MARY"}}
findOptions := options.Find()
findOptions.SetSort(map[string]int{"Phone": 1})
findOptions.SetProjection(map[string]int{"Phone": 1})
cursor, err := collection.Find(ctx, filter, findOptions)
var results []bson.M
cursor.All(ctx, &results)
然而,不管 MongoDB 驱动程序需要什么语法,MongoDB 服务器总是接收标准有线协议格式的数据包。
MongoDB 命令
从逻辑上讲,MongoDB 命令分为以下几类:
-
查询命令,如
find()和aggregate(),从数据库返回信息 -
数据操作命令,如
insert()、update()、delete(),修改数据库内的数据 -
数据定义命令,如
createCollection()、createIndex(),定义数据库中数据的结构 -
管理命令,如
createUser()、setParameter(),控制数据库的操作
数据库性能管理主要关注查询和数据操作语句的开销和吞吐量。然而,管理和数据定义命令包括一些我们用来解决性能问题的“专业工具”(见第 3 章)。
查找命令
find 命令是 MongoDB 数据访问的主力。它有一个快速和简单的语法,并具有灵活和强大的过滤能力。find()命令具有以下高级语法:
db.collection.find(
{filter},
{projection})
sort({sortCondition}),
skip(skipCount),
limit(limitCount)
前面的语法是针对 Mongo shell 显示的;特定语言驱动程序的语法可能略有不同。
find()命令的关键参数如下:
-
Filter 是一个 JSON 文档,定义了要返回的文档。
-
Projection 定义了将被返回的每个文档的属性。
-
排序定义单据返回的顺序。
-
跳过允许跳过输出中的一些初始文档。
-
限制限制要返回的文档总数。
在 wire 协议中,find()命令只返回第一批文档(通常是 1000 个),随后的几批由getMore命令获取。MongoDB 驱动程序通常代表您处理getMore处理语句,但是在许多情况下,您可以改变批处理大小来优化性能(参见第 6 章)。
聚合命令
find()可以执行各种各样的查询,但是它缺乏关系数据库的 SQL 命令的许多功能。例如,find()操作不能连接来自多个集合的数据,也不能聚合数据。当你需要比find()更多的功能时,一般会求助于aggregate()。
概括地说,aggregate 的语法看似简单:
db.collection.aggregate([pipeline]);
其中pipeline是集合命令的指令数组。Aggregate 支持二十多个管道操作符,大多数都超出了本书的范围。但是,最常用的运算符是
-
$match ,它使用类似于
find()命令的语法过滤管道中的文档 -
$group ,它将多个文档聚合到一个更小的集合中
-
$sort ,对管道内的文档进行排序
-
$project ,定义每个文档返回的属性
-
$unwind ,为数组中的每个元素返回一个文档
-
$limit ,限制要返回的文档数量
-
$lookup ,它连接另一个集合中的文档
下面是一个 aggregate 示例,它使用大多数这些操作来按类别返回电影观看次数:
db.customers.aggregate([
{ $unwind: "$views" },
{ $project: {
"filmId": "$views.filmId"
}
},
{ $group:{ _id:{ "filmId":"$filmId" },
"count":{$sum:1}
}
},
{ $lookup:
{ from: "films",
localField: "_id.filmId",
foreignField: "_id",
as: "filmDetails"
}
},
{ $group:{ _id:{
"filmDetails_Category":"$filmDetails.Category"},
"count":{$sum:1},
"count-sum":{$sum:"$count"}
}
},
{ $project: {
"category": "$_id.filmDetails_Category" ,
"count-sum": "$count-sum"
}
},
{ $sort:{ "count-sum":-1 }},
]);
聚合管道很难编写,也很难优化。我们将在第 7 章中详细介绍聚合管道优化。
数据操作命令
insert()、update()和delete()允许在集合中添加、更改或删除文档。
update()和delete()都有一个过滤器参数,它定义了要处理的文档。过滤条件与find()命令相同。
在优化更新和删除时,筛选条件的优化通常是最重要的因素。它们的性能也受写操作的配置影响(见下一节)。
以下是插入、更新和删除命令的示例:
db.myCollection.insert({_id:1,name:'Guy',rating:9});
db.myCollection.update({_id:1},{$set:{rating:10}});
db.myCollection.deleteOne({_id:1});
我们将在第 8 章中讨论数据操作语句的优化。
一致性机制
所有数据库都必须在一致性、可用性和性能之间做出权衡。像 MySQL 这样的关系数据库被认为是强一致性数据库,因为所有用户总是看到一致的数据视图。像 Amazon Dynamo 这样的非关系数据库通常被称为弱一致或最终一致数据库,因为不能保证用户看到这样一致的视图。
默认情况下,MongoDB(在一定限度内)是非常一致的,尽管可以通过配置写关注点和读偏好使其表现得像一个最终一致的数据库。
读偏好和写关注
MongoDB 应用可以控制读写操作的行为,提供一定程度的可调一致性和可用性。
-
写问题设置决定了 MongoDB 何时认为写操作已经完成。默认情况下,一旦主节点收到修改,写操作就会完成。因此,如果主服务器发生不可恢复的故障,数据可能会丢失。
但是,如果写入问题设置为“多数”,则数据库将不会完成写入操作,直到大多数辅助节点收到写入。我们还可以将写操作设置为等待,直到所有辅助节点或特定数量的辅助节点收到写操作。
写问题还可以确定写操作在被确认之前是否继续到磁盘上的日志。默认情况下是这样的。
-
读取偏好决定了客户端向何处发送读取请求。默认情况下,读取请求会发送到主节点。但是,客户端驱动程序可以配置为默认情况下向辅助服务器发送读取请求,仅在主服务器不可用时向辅助服务器发送,或者向“最近”的服务器发送后一种设置旨在支持低延迟而非一致性。
读首选项和写关注点的默认设置导致 MongoDB 表现为一个严格一致的系统:每个人都将看到同一版本的文档。允许从辅助节点满足读取会导致更一致的行为。
读偏好和写关注有明确的性能影响,我们将在第 8 和 13 章中讨论。
处理
尽管 MongoDB 最初是作为一个非事务性数据库出现的,但从 4.0 版本开始,它已经可以跨多个文档执行原子事务。例如,在本例中,我们自动将一个帐户的余额减少 100,并将另一个帐户增加相同的数量:
session.startTransaction();
mycollection.update({userId:1},{$inc:{balance:100}});
mycollection.update({userId:2},{$inc:{balance:-100}});
session.commitTransaction();
这两次更新要么都成功,要么都失败。
实际上,编码事务需要一些错误处理逻辑,事务的设计会显著影响性能。我们将在第 9 章中讨论这些考虑因素。
查询优化
像大多数数据库一样,MongoDB 命令表示对数据的逻辑请求,而不是检索数据的一系列指令。例如,find()操作指定了将要返回的数据,但没有明确指定检索数据时要使用的索引或其他访问方法。
因此,MongoDB 代码必须确定处理数据请求的最有效方式。 MongoDB 优化器是做出这些决定的 MongoDB 代码。优化器为每个命令做出的决定被称为查询计划。
当一个新的查询或命令被发送到 MongoDB 时,优化器执行以下步骤:
-
优化器在 MongoDB 计划缓存中寻找匹配的查询。匹配查询是所有筛选和操作属性都匹配的查询,即使值不匹配。这样的查询被称为具有相同的查询形状。例如,如果您对不同客户名称的
customers集合发出相同的查询,MongoDB 会认为它们具有相同的查询形状。 -
如果优化器找不到匹配的查询,那么优化器将考虑执行查询的所有可能方式。具有最低数量的工作单元的查询将会成功。工作单元是 MongoDB 必须执行的特定操作——主要与必须处理的文档数量相关。
-
MongoDB 将选择工作单元数量最少的计划,使用该计划执行查询,并将该查询计划存储在计划缓存中。
在实践中,MongoDB 倾向于尽可能使用基于索引的计划,并且通常会选择最具选择性的索引(参见第 5 章)。
MongoDB 架构
不参考 MongoDB 架构也可以做很多性能优化。然而,如果我们做好工作并完全优化工作负载,最终性能的限制因素将变成数据库服务器本身。在这一点上,如果我们想优化 MongoDB 的内部效率,我们需要了解它的架构。
蒙戈布
在一个简单的 MongoDB 实现中,MongoDB 客户端向 MongoDB 守护进程 mongod 发送有线协议消息。例如,如果您在笔记本电脑上安装 MongoDB,一个单独的mongod进程将响应所有的 MongoDB 有线协议请求。
存储引擎
一个存储引擎从底层存储介质和格式中抽象出数据库存储。例如,一个存储引擎可能将数据存储在内存中,而另一个可能被设计为将数据存储在云对象存储中,而第三个可能将数据存储在本地磁盘上。
MongoDB 可以支持多个存储引擎。最初,MongoDB 附带了一个相对简单的存储引擎,将数据存储为内存映射文件。这种存储引擎被称为 MMAP 引擎。
2014 年,MongoDB 收购了 WiredTiger 存储引擎。WiredTiger 比 MMAP 有很多优势,从 MongoDB 3.6 开始成为默认的存储引擎。在本书中,我们将主要关注 WiredTiger。
WiredTiger 为 MongoDB 提供了一个高性能的磁盘访问层,包括缓存、一致性、并发管理和其他现代数据访问设施。
图 2-2 展示了一个简单 MongoDB 部署的架构。
图 2-2
简单的 MongoDB 部署架构
副本集
MongoDB 通过使用副本集来实现容错。
副本集由一个主节点和两个或多个次节点组成。主节点接受所有同步或异步传播到辅助节点的写请求。
通过涉及所有可用节点的选举来选择主节点。为了有资格成为主节点,节点必须能够联系一半以上的副本集。这种方法确保了如果一个网络分区将一个副本集分成两个分区,只有一个分区会尝试选举一个主分区。 RAFT 协议 1 用于确定哪个节点成为主节点,目的是最大限度地减少故障转移后的任何数据丢失或不一致。
主节点将关于文档更改的信息存储在其本地数据库的集合中,该集合被称为操作日志。主实例将不断尝试将这些更改应用到辅助实例。
副本集中的成员通过心跳消息频繁通信。如果主节点发现它不能从超过一半的辅助节点接收心跳消息,那么它将放弃其主节点状态,并且将进行新的选举。图 2-3 展示了一个三成员的副本集,并展示了一个网络分区如何导致主副本集的改变。
图 2-3
MongoDB 副本集选举
MongoDB 副本集的存在主要是为了支持高可用性——允许 MongoDB 集群在单个节点出现故障时仍然存在。但是,它们也可能带来性能优势或劣势。
如果 MongoDB 写关注点大于 1,那么每个 MongoDB 写操作(插入、更新和删除)都需要由集群的多个成员确认。这将导致群集的运行速度比单节点群集慢。另一方面,如果将读取偏好设置为允许从辅助节点读取,那么通过将读取负载分散到多个服务器上,可以提高读取性能。我们将在第 13 章中讨论读偏好和写关注对性能的影响。
碎片
副本集的存在主要是为了支持高可用性,而 MongoDB 分片旨在提供向外扩展的能力。“横向扩展”允许我们通过向集群添加更多节点来增加数据库容量。
在分片的数据库集群中,所选的集合跨多个数据库实例进行分区。每个分区被称为一个“碎片”这种划分基于分片密钥值;例如,您可以共享客户标识符、客户邮政编码或出生日期。选择一个特定的分片键可以对您的性能产生积极或消极的影响;在第 14 章中,我们将介绍如何优化分片密钥。当操作特定的文档时,数据库确定哪个碎片应该包含数据,并将数据发送到适当的节点。
MongoDB 分片架构的高级表示如图 2-4 所示。每个分片都是由一个不同的 MongoDB 服务器实现的,在大多数情况下,它并不知道自己在更广泛的分片服务器中的角色(1)。一个独立的 MongoDB 服务器——config server(2)——包含元数据,用于确定数据如何跨分片分布。路由进程(3)负责将客户端请求路由到适当的碎片服务器。
图 2-4
蒙戈布沙丁
为了对集合进行分片,我们选择一个分片键,这是一个或多个索引属性,将用于确定文档在分片中的分布。请注意,并非所有集合都需要分片。非共享集合的流量将被定向到单个碎片。
共享机制
跨碎片的数据分布可以是基于范围的或基于散列的。在基于范围的分区中,每个分片都被分配了一个特定范围的分片键值。MongoDB 查询索引中键值的分布,以确保每个碎片都分配有大致相同数量的键。在基于散列的分片中,基于应用于分片密钥的散列函数来分发密钥。
参见第 14 章了解更多基于范围和散列的分片细节。
集群平衡
当实现基于散列的分片时,每个分片中的文档数量在大多数情况下趋于平衡。然而,在基于范围的分片配置中,分片很容易变得不平衡,特别是如果分片键基于连续增加的值,比如自动增加的主键 ID。
因此,MongoDB 将定期评估集群中碎片的平衡,并在需要时执行重新平衡操作。
结论
在本章中,我们简要回顾了 MongoDB 的关键架构元素,它们是 MongoDB 性能调优的必要前提。大多数读者已经大致熟悉了本章中的概念,但是确保您已经掌握了 MongoDB 的基础知识总是有好处的。
了解这些主题的最佳途径是 MongoDB 文档集——可以在 https://docs.mongodb.com/ 在线获得。
在下一章中,我们将深入探讨 MongoDB 提供的基本工具,它们应该是您调优过程中的忠实伙伴。
Footnotes [1](#Fn1_source)https://en.wikipedia.org/wiki/Raft_(computer_science)
三、贸易工具
他们说一个商人的好坏取决于他或她的工具。幸运的是,您不需要昂贵或难以找到的工具来调优 MongoDB 应用或数据库。但是,您应该非常熟悉 MongoDB 在 MongoDB 服务器中免费提供给您的工具。
在本章中,我们将回顾构成 MongoDB 性能调优基本工具包的组件,特别是:
-
explain()方法,揭示了 MongoDB 在执行命令时采取的步骤 -
分析器,它允许您捕获和分析 MongoDB 服务器上的工作负载
-
揭示 MongoDB 服务器全局状态的命令,特别是
ServerStatus()和CurrentOp() -
图形化的 MongoDB Compass 工具,它为前面列出的大部分命令行实用程序提供了一个用户友好的图形化替代工具
介绍解释( )
explain()方法允许您检查查询计划。这是调优 MongoDB 性能的重要工具。
对于几乎所有的操作,MongoDB 都有不止一种方法来检索和处理所涉及的文档。当 MongoDB 准备执行一条语句时,它必须决定哪种方法最快。确定这个“最优”数据路径的过程就是我们在第二章中介绍的查询优化的过程。
例如,考虑以下查询:
db.customers.
find(
{
FirstName: "RUTH",
LastName: "MARTINEZ",
Phone: 496523103
},
{ Address: 1, dob: 1 }
).
sort({ dob: 1 });
对于这个例子,假设在FirstName、LastName、Phone和dob上有索引。这些索引为 MongoDB 解析查询提供了以下选择:
-
扫描整个集合,寻找符合姓名和电话号码过滤条件的文档,然后按
dob对这些文档进行排序。 -
使用
FirstName上的索引找到所有的“RUTH ”,然后根据LastName和Phone过滤这些文档,然后在dob上对剩余的文档进行排序。 -
使用
LastName上的索引找到所有的“MARTINEZ ”,然后根据FirstName和Phone过滤这些文档,然后在dob上对剩余的文档进行排序。 -
使用
Phone上的索引查找电话号码匹配的所有文档。然后排除任何不是露丝·马丁内斯的,再按dob排序。 -
使用
dob上的索引按照出生日期的顺序对文档进行排序,然后排除不符合查询条件的文档。
每种方法都会返回正确的结果,但是每种方法都有不同的性能特征。MongoDB 优化器的工作是决定哪种方法最快。
explain()方法揭示了查询优化器的决策——在某些情况下——让您检查它的推理。
开始使用 explain()
为了检查优化器的决策,我们使用集合对象的explain()方法,并向该方法传递一个find()、update()、insert()或aggregate()操作。例如,为了解释我们之前介绍的查询,我们可以发出这个命令 1 :
var explainCsr=db.customers.explain().
find(
{
FirstName: "RUTH",
LastName: "MARTINEZ",
Phone: 496523103
},
{ Address: 1, dob: 1 }
).
sort({ dob: 1 });
var explainDoc=explainCsr.next();
explain()发出一个游标,返回包含查询执行信息的 JSON 文档。因为它是一个光标,我们需要在调用explain()之后通过调用next()来获取解释输出。
解释输出中最初最重要的部分是winningPlan部分,我们可以这样提取:
mongo> printjson(explainDoc.queryPlanner.winningPlan);
{
"stage": "PROJECTION_SIMPLE",
"transformBy": {
"Address": 1,
"dob": 1
},
"inputStage": {
"stage": "SORT",
"sortPattern": {
"dob": 1
},
"inputStage": {
"stage": "SORT_KEY_GENERATOR",
"inputStage": {
"stage": "FETCH",
"filter": {
"$and": [
<snip>
]
},
"inputStage": {
"stage": "IXSCAN",
"keyPattern": {
"Phone": 1
},
"indexName": "Phone_1",
"isMultiKey": false,
"multiKeyPaths": {
"Phone": [ ]
},
"isUnique": false,
"isSparse": false,
"isPartial": false,
"indexVersion": 2,
"direction": "forward",
"indexBounds": {
"Phone": [
"[496523103.0, 496523103.0]"
]
}
}
}
}
}
}
它仍然非常复杂,我们删除了一些内容来简化它。但是,您可以看到它列出了查询执行的多个阶段,每个阶段(前一步)的输入嵌套为inputStage。为了破译输出,您从嵌套最深的inputStage——从内向外读取 JSON 开始获取计划。
如果您愿意,您可以使用我们的实用程序脚本中的mongoTuning.quickExplain函数,按照执行的顺序打印出各个步骤:
Mongo Shell>mongoTuning.quickExplain(explainDoc)
1 IXSCAN Phone_1
2 FETCH
3 SORT_KEY_GENERATOR
4 SORT
5 PROJECTION_SIMPLE
这个脚本以非常简洁的格式打印执行计划。以下是对每个步骤的解释:
-
IXSCAN Phone_1: MongoDB 使用Phone_1索引来查找与Phone属性具有匹配值的文档。 -
FETCH: MongoDB 过滤掉从索引返回的不具有正确的FirstName和LastName值的文档。 -
SORT_KEY_GENERATOR: MongoDB 从FETCH操作中提取dob值,为后续的SORT操作做准备。 -
SORT: MongoDB 根据dob的值对文档进行排序。 -
PROJECTION_SIMPLE: MongoDB 将address和dob属性发送到输出流中(这是查询请求的唯一属性)。
有很多种可能的执行计划,我们将在后面的章节中看到很多。
熟悉 MongoDB 可能采用的执行步骤对于理解 MongoDB 正在做的事情至关重要。你可以在 https://github.com/gharriso/MongoDBPerformanceTuningBook/blob/master/ExplainPlanSteps.md 找到这本书的 Github 库的不同步骤的解释。您还可以在 https://docs.mongodb.com/manual/reference/explain-results/ 的 MongoDB 文档中找到大量信息。
光是explain()操作的数量就可能让人望而生畏,但是大多数时候,您将会处理一些基本程序的组合,例如
-
COLLSCAN:不使用索引扫描整个集合 -
IXSCAN:使用索引查找文件(见第 5 章关于索引的细节) -
SORT:不使用索引的文件分类
替代计划
我不仅能告诉你哪个计划被采用了,还能告诉你哪个计划被否决了。被拒绝的计划可以在queryPlanner部分的数组rejectedPlans中找到。这里,我们使用quickExplain来检查一个被拒绝的计划:
Mongo> mongoTuning.quickExplain
(explainDoc.queryPlanner.rejectedPlans[1])
1 IXSCAN LastName_1
2 IXSCAN Phone_1
3 AND_SORTED
4 FETCH
5 SORT_KEY_GENERATOR
6 SORT
7 PROJECTION_SIMPLE
这个被拒绝的计划合并了两个索引——一个在LastName上,一个在Phone上——来检索结果。为什么被拒?第一次执行这个查询时,MongoDB 查询优化器估计了执行每个候选计划所需的工作量。具有最低工作估计的计划——通常是必须处理最少数量文档的计划——胜出。queryPlanner.rejectedPlans列出被拒绝的计划。
执行统计
如果您将参数“executionStats"传递给explain(),那么explain()将执行整个请求并报告计划中每一步的执行情况。这里有一个使用executionStatistics的例子:
var explainObj = db.customers.
explain('executionStats').
find(
{FirstName: "RUTH",
LastName: "MARTINEZ",
Phone: 496523103},
{ Address: 1, dob: 1 }
).sort({ dob: 1 });
var explainDoc = explainObj.next();
执行统计包含在生成的计划文档的executionStages部分:
mongo> explainDoc.executionStats
{
"executionSuccess": true,
"nReturned": 1,
"executionTimeMillis": 0,
"totalKeysExamined": 1,
"totalDocsExamined": 1,
"executionStages": {
"stage": "PROJECTION_SIMPLE",
"nReturned": 1,
"executionTimeMillisEstimate": 0,
"works": 6,
"advanced": 1,
"needTime": 3,
"needYield": 0,
"saveState": 0,
"restoreState": 0,
"isEOF": 1,
"transformBy": {
"Address": 1,
"dob": 1
},
"inputStage": {
"stage": "SORT",
// Many, many more lines of output
}}
}
Note
为了获得执行统计数据,explain("executionStats")将完全执行相关的 MongoDB 语句。这意味着它可能需要比简单的explain()更长的时间来完成,并给 MongoDB 服务器带来很大的负载。
executionSteps子文档包含总体执行统计数据——比如executionTimeMillis——以及executionStages文档中的注释执行计划。executionStages的结构就像winningPlan,但是它有每一步的统计数据。有很多统计数据,但也许最重要的是
-
executionTimeMillisEstimate:执行相关步骤所消耗的毫秒数 -
keysExamined:该步骤读取的索引键数量 -
docsExamined:该步骤读取的文档数
很难阅读executionSteps文档——所以我们编写了mongoTuning.executionStats(),以与mongoTuning.quickExplain脚本相同的格式打印出步骤和关键统计数据:
mongo> mongoTuning.executionStats(explainDoc);
1 COLLSCAN ( ms:10427 docs:411121)
2 SORT_KEY_GENERATOR ( ms:10427)
3 SORT ( ms:10427)
4 PROJECTION_SIMPLE ( ms:10428)
Totals: ms: 12016 keys: 0 Docs: 411121
我们将在下一节中使用这个函数来调优 MongoDB 查询。
使用 explain()优化查询
既然我们已经学会了如何使用explain(),让我们来看一个简短的例子,展示如何使用它来调优一个查询。下面是我们想要优化的查询的解释命令:
mongo> var explainDoc=db.customers.
explain('executionStats').
find(
{ Country: 'United Kingdom',
'views.title': 'CONQUERER NUTS' },
{ City:1,LastName: 1, phone: 1 }
).
sort({City:1, LastName: 1 });
这个查询——针对一个假想的网飞风格的客户数据库——生成了一个在英国看过电影征服者坚果的客户列表。
让我们使用mongoTuning.executionStats来提取执行统计数据:
Mongo> mongoTuning.executionStats(explainDoc);
1 COLLSCAN ( ms:12 docs:411121)
2 SORT_KEY_GENERATOR ( ms:12)
3 SORT ( ms:12)
4 PROJECTION_SIMPLE ( ms:12)
Totals: ms: 253 keys: 0 Docs: 411121
第COLLSCAN步——对整个收藏进行全面扫描——首先检查 411,121 份文件。这只需要 253 毫秒(大约四分之一秒),但也许我们可以做得更好。这里还有一个SORT,我们想看看是否可以使用索引来避免排序。因此,让我们创建一个索引,它具有来自过滤子句的属性(Country和views.title)以及来自排序操作的属性(City和LastName):
db.customers.createIndex(
{ Country: 1, 'views.title': 1,
City: 1, LastName: 1 },
{ name: 'ExplainExample' }
);
现在,当我们生成 executionStats 时,我们的输出如下所示:
1 IXSCAN ( ExplainExample ms:0 keys:685)
2 FETCH ( ms:0 docs:685)
3 PROJECTION_SIMPLE ( ms:0)
Totals: ms: 2 keys: 685 Docs: 685
有了新索引,查询几乎立即返回,检查的文档(键)数量从 411,121 减少到 685。我们将访问的数据量减少了 97%,并将执行时间提高了几个数量级。还要注意不再有一个SORT步骤——MongoDB 能够使用索引以排序的顺序返回文档,而不需要显式排序。
Explain 本身并不能调优查询,但是如果没有explain()的话,对于 MongoDB 要做什么,你只能得到最模糊的提示。因此,在优化 MongoDB 查询时,我们将在整本书中广泛使用 explain。
可视化解释工具
有很多可视化解释输出的选项,而不必通读堆积如山的 JSON 输出或使用我们的实用程序脚本。可视化解释实用程序可能是有益的,尽管根据我们的经验,能够调试原始解释输出并能够从命令行获得解释仍然是必不可少的。
MongoDB Compass 是 MongoDB 自己的图形用户界面实用程序。图 3-1 显示了 MongoDB Compass 如何显示 explain 输出的可视化表示。
图 3-1
MongoDB Compass 中的可视化解释输出
图 3-2 显示了开源 dbKoda 产品中的可视化解释输出。 2
图 3-2
dbKoda 中的可视化解释输出
MongoDB 的其他 GUI 也包括显示解释输出的可视化选项。
请记住,虽然这些工具可以帮助可视化explain()命令的输出,但是能否解释输出并采取适当的调优措施取决于您自己!
查询探查器
explain()是调优单个 MongoDB 查询的好工具,但是不能告诉您应用中的哪些查询可能需要调优。例如,在我们在第 1 章中给出的例子中,我们描述了一个应用,在这个应用中,由于一个索引丢失,IO 过载。我们如何找到生成 IO 的语句,并从那里确定所需的索引?这就是 MongoDB 分析器的用武之地。
MongoDB profiler 允许您收集关于数据库上正在运行的命令的信息。其中explain()将使您能够确定单个命令是如何执行的,概要分析器将为您提供关于哪些命令正在运行以及哪些命令可能需要调优的更高级视图。
默认情况下,查询探查器是禁用的,可以在每个数据库上单独配置。探查器可以设置为三个级别之一:
-
0 :设置为 0 表示对数据库禁用分析。这是默认级别。
-
1 :评测器只会收集比
slowms更长时间来完成.的命令的信息 -
2 :分析器将收集所有命令的信息,无论它们是否比
slowms完成得更快。
轮廓由db.setProfilingLevel()命令控制。setProfilingLevel具有以下语法:
db.setProfilingLevel(level,
{slowms:slowMsThreshold,
sampleRate:samplingRate});
setProfilingLevel采用以下参数:
-
Level对应于前文中概述的三个等级(0、1 或 2)。0 禁用跟踪,1 为消耗超过slowms阈值的语句设置跟踪,而 2 为所有语句设置跟踪。 -
slowMsThreshold设置 1 级跟踪的毫秒执行阈值。 -
samplingRate确定随机抽样水平。例如,如果samplingRate设置为 0.5,那么将跟踪所有语句的一半。
Note
查询探查器不能用于分片实例。如果setProfilingLevel是针对分片集群发出的,它将只设置slowms和samplerate的值,这两个值决定哪些操作将被写入 MongoDB 日志。
您可以使用db.getProfilingStatus()命令检查当前的跟踪级别。
在下面的示例中,我们检查当前的性能分析级别,然后设置性能分析,以便它捕获消耗超过 2 毫秒执行时间的所有语句,最后,我们再次检查当前的性能分析级别,以观察我们的新配置:
mongo>db.getProfilingStatus();
{
"was": 0,
"slowms": 20,
"sampleRate": 1
}
mongo>db.setProfilingLevel(1,{slowms:2,sampleRate:1});
{
"was": 0,
"slowms": 20,
"sampleRate": 1,
"ok": 1
}
mongo>db.getProfilingStatus();
{
"was": 0,
"slowms": 2,
"sampleRate": 1
}
system.profile 集合
分析信息存储在system.profile集合中。system.profile是一个循环集合——集合的大小是固定的,当超过该大小时,旧的条目将被删除,以便为新的条目让路。system.profile 的默认大小只有 1MB,所以您可能希望增加它的大小。您可以通过停止分析、删除集合并以更大的大小重新创建它来实现这一点,如下例所示:
mongo>db.setProfilingLevel(0);
{
"was": 1,
"slowms": 2,
"sampleRate": 1,
"ok": 1
}
mongo >db.system.profile.drop();
true
mongo >db.createCollection(
"system.profile",
{capped: true, size:10485760 } ); // 10MB
{
"ok": 1
}
mongo >db.setProfilingLevel(1);
{
"was": 0,
"slowms": 2,
"sampleRate": 1,
"ok": 1
}
分析分析数据
我们进行分析的一般方法如下:
-
使用适当的
slowms级别、sampleRate和system.profile集合大小打开分析。 -
允许有代表性的工作负载对数据库进行操作。
-
关闭分析并分析结果。
Note
我们通常不希望概要分析一直打开,因为这会给数据库带来很大的性能负担。
为了分析system.profile中的数据,我们可以针对该集合发出 MongoDB find()或aggregate()语句。system.profile中保存了许多有用的信息,但这些信息可能会令人困惑,难以分析。有大量的属性需要检查,在某些情况下,单个语句的执行统计信息可能会分布在集合中的多个条目上。
为了准确了解特定语句给数据库带来的负担,我们需要汇总结构相同的所有语句的数据,即使它们在文本中并不完全相同。这样的语句被称为具有相同的查询形状。例如,以下两个查询可能来自同一段代码,并且具有相同的调优解决方案:
db.customers.find({"views.filmId":987}).sort({LastName:1});
db.customers.find({"views.filmId":317}).sort({LastName:1});
然而,由于该语句的每次执行在system.profile集合中都有一个单独的条目,我们需要汇总所有这些执行的统计数据。我们可以通过聚合所有对属性queryHash具有相同值的语句来实现。
对于处理大量数据的语句来说,还有一个更复杂的问题。例如,提取超过 1000 个文档的查询将有一个初始查询的条目,以及每个获取后续数据批次的getMore操作的条目。幸运的是,每个getMore操作将与其父操作共享一个cursorId属性,因此我们也可以在该属性上进行聚合。
清单 3-1 显示了一个聚合管道,它执行必要的聚合来列出数据库中消耗时间最多的语句。??
db.system.profile.aggregate([
{ $group:{ _id:{ "cursorid":"$cursorid" },
"count":{$sum:1},
"queryHash-max":{$max:"$queryHash"} ,
"millis-sum":{$sum:"$millis"} ,
"ns-max":{$max:"$ns"}
}
},
{ $group:{ _id:{"queryHash":"$queryHash-max" ,
"collection":"$ns-max" },
"count":{$sum:1},
"millis":{$sum:"$millis-sum"}
}
},
{ $sort:{ "millis":-1 }},
{ $limit: 10 },
]);
Listing 3-1Aggregating statistics from system.profile
这是该聚合的输出:
{ "_id": { "queryHash": "14C08165", "collection": "MongoDBTuningBook.customers" }, "count": 17, "millis": 6844 }
{ "_id": { "queryHash": "81BACDE0", "collection": "MongoDBTuningBook.customers" }, "count": 13, "millis": 3275 }
{ "_id": { "queryHash": "1215D594", "collection": "MongoDBTuningBook.customers" }, "count": 13, "millis": 3197 }
{ "_id": { "queryHash": "C05DC5D9", "collection": "MongoDBTuningBook.customers" }, "count": 14, "millis": 2821 }
{ "_id": { "queryHash": "B3A7D0DB", "collection": "MongoDBTuningBook.customers" }, "count": 12, "millis": 2525 }
{ "_id": { "queryHash": "F7B164E4", "collection": "MongoDBTuningBook.customers" }, "count": 12, "millis": 43 }
我们可以看到带有queryHash“14C08165”的查询在我们的调优运行中消耗了最多的时间。我们可以通过在system.profile集合中查找具有匹配哈希值的条目来获得这个查询的详细信息:
mongo>db.system.profile.findOne(
... { queryHash: '14C08165' },
... { ns: 1, command: 1, docsExamined: 1,
... millis: 1, planSummary: 1 }
... );
{
"ns": "MongoDBTuningBook.customers",
"command": {
"find": "customers",
"filter": {
"Country": "Yugoslavia"
},
"sort": {
"phone": 1
},
"projection": {
},
"$db": "MongoDBTuningBook"
},
"docsExamined": 101,
"millis": 31,
"planSummary": "IXSCAN { Country: 1, views.title: 1, City: 1, LastName: 1, phone: 1 }"
}
这个查询包含在函数mongoTuning.getQueryByHash中的mongoTuning包中。
该查询检索给定queryHash的命令、执行时间、检查的文档和执行计划摘要。system.profile包含了很多额外的属性,但是前面有限的属性集应该足以帮助您开始优化工作。下一步可能是为该命令生成完整的执行计划——包括executionStats——并确定是否可以实现更好的执行计划(提示:我们可能想对排序操作做些什么)。
请记住:explain()可以帮助您调优单个命令,而概要分析器可以帮助您找到需要调优的命令。现在,您已经准备好识别和优化有问题的 MongoDB 命令。
使用 MongoDB 日志调优
查询分析器并不是找出后台运行的查询的唯一方法。命令执行也可以在 MongoDB 日志中找到。这些日志的位置取决于您的服务器配置。您通常可以使用以下命令来确定日志文件的位置:
db.getSiblingDB("admin").
runCommand({ getCmdLineOpts: 1 } ).parsed.systemLog;
假设我们已经将日志推送到一个文件中,使用如下例所示的--logpath参数:
User> mongod --port 27017 --dbpath ./data --logpath ./mongolog.txt
我们可以用操作系统命令查看日志,比如tail,甚至可以选择文本编辑器。但是,如果我们运行一个查询,然后查看我们的日志文件,我们可能看不到任何记录查询执行的日志条目。这是因为,默认情况下,只有超过慢速操作阈值的命令才会被记录。这个慢速操作阈值与我们在上一节的查询分析器中引入的参数slowms相同。
有两种方法可以确保我们执行的查询显示在日志文件中:
-
我们可以使用
db.setProfilingLevel命令减小slowms的值。如果db.setProfilingLevel设置为 0,那么满足slowms标准的命令将被写入日志。例如,如果我们发出db.setProfilingLevel(0, {slowms: 10}),任何执行时间超过 10 毫秒的命令都会被输出到日志中。 -
我们可以使用
db.setLogLevel命令来强制记录指定类型的所有查询。
db.setLogLevel可用于控制日志输出的详细程度。该命令具有以下语法:
db.setLogLevel(Level,Component)
在哪里
-
级别是日志记录的详细程度,从 0 到 5。通常,级别 2 足以进行命令监控。
-
组件控制受影响的日志消息的类型。以下组件与此相关:
-
查询:记录所有
find()命令 -
写:日志
update、delete、insert语句 -
命令:记录其他 MongoDB 命令,包括
aggregate
-
通常,当您完成测试时,您应该将详细度设置回 0——否则,您可能会生成不可接受的日志输出。
现在我们知道了如何在日志中显示我们的命令,让我们看看它的实际操作吧!
让我们设置logLevel来捕捉find()操作,发出find(),然后恢复日志记录级别:
mongo> db.setLogLevel(2,'query')
mongo> db.listingsAndReviews.find({name: "Ribeira Charming Duplex"}).cancellation_policy;
Moderate
mongo> db.setLogLevel(0,'query');
最后,让我们通过日志文件来查看我们的操作。在本例中,我们使用grep从文件中获取日志,但是您也可以在编辑器中打开文件:
$ grep -i "Ribeira" /var/log/mongodb/mongo.log
2020-06-03T07:14:56.871+0000 I COMMAND [conn597] command sample_airbnb.listingsAndReviews appName: "MongoDB Shell" command: find { find: "listingsAndReviews", filter: { name: "Ribeira Charming Duplex" }, lsid: { id: UUID("01885ece-c731-4549-8b4f-864fe527888c") }, $db: "sample_airbnb" } planSummary: IXSCAN { name: 1 } keysExamined:1 docsExamined:1 cursorExhausted:1 numYields:0 nreturned:1 queryHash:01AEE5EC planCacheKey:4C5AEA2C reslen:29543 locks:{ ReplicationStateTransition: { acquireCount: { w: 1 } }, Global: { acquireCount: { r: 1 } }, Database: { acquireCount: { r: 1 } }, Collection: { acquireCount: { r: 1 } }, Mutex: { acquireCount: { r: 1 } } } storage:{} protocol:op_msg 0ms
您的日志位置可能不同,尤其是在 Windows 上,用于过滤日志的命令也可能不同。
让我们分解日志记录的关键元素,跳过一些不是特别有趣的字段。前几个元素是关于日志本身的:
-
2020-06-03T07:14:56.871+0000:该日志的时间戳 -
COMMAND:该日志的类别
接下来,我们有一些特定于命令的信息:
-
airbnb.listingsAndReviews:命令的命名空间——数据库和集合。此属性对于查找特定于数据库或集合的命令非常有用。 -
command: find:执行的命令类型,例如find、insert、update或delete。 -
appName: "MongoDB Shell":执行该命令的连接类型;这对于过滤特定的驱动程序或 Shell 非常有用。 -
filter: { name: "Ribeira Charming Duplex" }:提供给命令的过滤器。
然后,我们有一些关于命令如何执行的更具体的信息:
-
planSummary: IXSCAN:执行计划中最重要的部分。您可能还记得我们对explain()的讨论,即IXSCAN表示使用了索引扫描来解析查询。 -
keysExamined:1 docsExamined: 1 ... nreturned:1:与命令执行相关的统计。 -
0ms:执行时间。在这种情况下,执行时间不到一毫秒,所以四舍五入为 0。
除了这些关键指标,日志条目还包含更多关于锁定和存储的信息,您可能在更具体的用例中需要这些信息。您可能会认为,与本章中的其他一些工具相比,阅读这些日志是非常笨拙的,您可能是对的。即使使用文本编辑器提供的搜索和过滤工具,解析这些日志也会很麻烦。
减轻日志格式负担的一种方法是使用作为 mtools 实用工具套件的一部分提供的日志管理工具。Mtools 包括 mlogfilter ,它允许您过滤和子集化日志记录,而 mplotqueries 创建日志数据的图形化表示。
您可以在 https://github.com/rueckstiess/mtools 了解更多关于 mtools 的信息。
服务器统计
到目前为止,我们已经用explain()分析了单个查询的执行,并用 MongoDB profiler 检查了在给定数据库上运行的查询。为了进一步缩小范围,我们可以向 MongoDB 请求关于所有数据库、查询和命令的服务器活动的高级信息。检索这些信息的命令是db.serverStatus()。该命令生成大量指标,包括操作计数器、队列信息、索引使用、连接、磁盘 IO 和内存利用率。
db.serverStatus()命令是获取关于 MongoDB 服务器的大量高级信息的快速而强大的方法。db.serverStatus()可以帮助您识别性能问题,甚至更深入地了解在您调优时可能起作用的其他因素。如果您不知道给定查询运行如此缓慢的原因,快速检查 CPU 和内存使用情况可能会提供重要线索。在优化应用时,您可能并不总是独占使用数据库。在这些情况下,获得对影响服务器性能的外部因素的高度理解是至关重要的。
通常,这是我们详细检查命令输出的地方。然而,db.serverStatus()输出了如此多的数据(将近 1000 行),以至于试图分析原始输出会让人不知所措(并且经常不切实际)。通常,您会寻找一个特定的值或值的子集,而不是检查服务器记录的每一个指标。正如您可以从下面极度截断的输出中看到的,还有许多无关的信息可能与我们的性能调优工作没有直接关系:
mongo> db.serverStatus()
{
"host" : "Mike-MBP-3.modem",
"version" : "4.2.2",
"process" : "mongod",
"pid" : NumberLong(3750),
"uptime" : 474921,
"uptimeMillis" : NumberLong(474921813),
"uptimeEstimate" : NumberLong(474921),
"localTime" : ISODate("2020-05-13T22:04:10.857Z"),
"asserts" : {
"regular" : 0,
"warning" : 0,
"msg" : 0,
"user" : 2,
"rollovers" : 0
},
...
945 more lines here.
...
"ok" : 1,
"$clusterTime" : {
"clusterTime" : Timestamp(1589407446, 1),
"signature" : {
"hash" : BinData(0,"AAAAAAAAAAAAAAAAAAAAAAAAAAA="),
"keyId" : NumberLong(0)
}
},
"operationTime" : Timestamp(1589407446, 1)
}
由于db.serverStatus()输出的压倒性本质,简单地执行命令然后滚动到相关数据是不常见的。相反,只提取您要搜索的特定值或将数据聚合成更容易解析的格式通常更有用。
例如,要获取已经执行的各种高级命令的计数,可以执行以下操作:
mongo> db.serverStatus().opcounters
{
"insert" : NumberLong(3),
"query" : NumberLong(1148),
"update" : NumberLong(15),
"delete" : NumberLong(11),
"getmore" : NumberLong(0),
"command" : NumberLong(2584)
}
以下来自db.serverStatus()的顶级类别通常很有用:
-
连接:与服务器内的连接相关的统计
-
操作计数器:命令执行总数
-
锁:与内部锁相关的计数器
-
网络:进出服务器的网络流量汇总
-
操作时间:读写命令和事务所用的时间
-
wiredTiger : WiredTiger 存储引擎统计
-
mem :内存利用率
-
事务:事务统计
-
指标:各种指标,包括聚合阶段和特定单个命令的计数
我们可以使用这些高级类别和其中的嵌套文档来深入研究感兴趣的统计数据。例如,我们可以像这样钻取 WiredTiger 缓存大小:
mongo> db.serverStatus().wiredTiger.cache["maximum bytes configured"]
1073741824
然而,这样使用db.serverStatus()有两个问题。首先,这些计数器不能告诉我们服务器上正在发生什么,这使得我们很难确定哪些指标可能会影响我们应用的性能。其次,该方法假设您知道要寻找哪些指标,或者一次遍历一个指标来寻找线索。
如果您正在使用 MongoDB Atlas 或 Ops Manager,这两个问题可能会得到解决,因为这些工具会计算基本指标的比率并以图形方式显示它们。但是,最好理解如何从命令行获得这些指标,因为您永远不知道将来可能会使用什么类型的 MongoDB 配置。
我们的第一个问题——需要获取最近一段时间的统计数据——的解决方案是在给定的时间间隔内获取两个样本,并计算它们之间的差异。例如,让我们创建一个简单的助手函数,它将使用两个样本来查找在 10 秒钟间隔内运行的查找操作的数量:
mongo> var sample = function() {
... var sampleOne = db.serverStatus().opcounters.query;
... sleep(10000); // Wait for 10000ms (10 seconds)
... var sampleTwo = db.serverStatus().opcounters.query;
... var delta = sampleTwo - sampleOne;
... print(`There were ${delta} query operations during the sample.`);
... }
mongo> sample()
There were 6 query operations during the sample.
现在,我们可以很容易地看到在我们的采样周期中运行了哪些操作,并且我们可以用操作数除以我们的采样周期来计算每秒的操作率。尽管这是可行的,但最好构建一个助手函数来获取所有服务器状态数据,并计算所有感兴趣的指标的变化率。我们已经在mongoTuning包中包含了这样一个通用脚本。
mongoTuning.keyServerStats在感兴趣的时间段内获取serverStatus的两个样本,并打印一些关键性能指标。这里,我们打印了 60 秒间隔内的一些感兴趣的统计数据:
rs1:PRIMARY> mongoTuning.keyServerStats(60000)
{
"netKBInPS" : "743.4947",
"netKBOutPS" : 946.0005533854167,
"intervalSeconds" : 60,
"queryPS" : "2392.2833",
"getmorePS" : 0,
"commandPS" : "355.4667",
"insertPS" : 0,
"updatePS" : "118.4500",
"deletePS" : 0,
"docsReturnedPS" : "0.0667",
"docsUpdatedPS" : "118.4500",
"docsInsertedPS" : 0,
"ixscanDocsPS" : "118.4500",
"collscanDocsPS" : "32164.4833",
"scansToDocumentRatio" : 484244,
"transactionsStartedPS" : 0,
"transactionsAbortedPS" : 0,
"transactionsCommittedPS" : 0,
"transactionAbortPct" : 0,
"readLatencyMs" : "0.4803",
"writeLatencyMs" : "7.0247",
"cmdLatencyMs" : "0.0255",
我们将在后面的章节中看到使用mongoTuning脚本的例子。
从db.serverStatus()输出的原始数据量现在看起来可能令人生畏。但是不要担心,您只需要知道十几个关键指标就可以理解 MongoDB 是如何执行的,并且通过使用类似于我们的mongoTuning包中包含的帮助函数,您可以轻松地检查那些相关的统计数据。在后面的章节中,我们将看到如何利用db.serverStatus()指标来调优 MongoDB 服务器性能。
检查当前操作
在 MongoDB 中调优性能的另一个有用工具是db.currentOp()命令。该命令的工作方式与您想象的一样——它返回关于当前正在数据库上运行的操作的信息。即使您当前没有对数据库运行任何操作,该命令仍可能返回后台操作的详细列表。
当前正在执行的操作将被列在一个名为inprog的数组中。这里,我们计算操作的数量,并查看列表中第一个操作的(被截断的)详细信息:
mongo> db.currentOp().inprog.length
7
mongo> db.currentOp().inprog[0]
{
"type" : "op",
"host" : "Centos8:27017",
"desc" : "conn557",
"connectionId" : 557,
"client" : "127.0.0.1:44036",
"clientMetadata" : {
/* Info about the OS and client driver */
},
"active" : true,
"currentOpTime" : "2020-06-08T07:05:12.196+0000",
"effectiveUsers" : [
{
"user" : "root",
"db" : "admin"
}
],
"opid" : 27238315, /* Other ID info */
},
"secs_running" : NumberLong(0),
"microsecs_running" : NumberLong(35),
"op" : "update",
"ns" : "ycsb.usertable",
"command" : {
"q" : {
"_id" : "user5107998579435405958"
},
"u" : {
"$set":{"field4":BinData(0,"O1sxM..==")
}
},
"multi" : false,
"upsert" : false
},
"planSummary" : "IDHACK",
"numYields" : 0,
"locks" : {
/* Lots of lock statistics */
},
"waitingForFlowControl" : false,
"flowControlStats" : {
"acquireCount" : NumberLong(1),
"timeAcquiringMicros" : NumberLong(1)
}
}
我们可以在前面的输出中看到,有七个操作正在运行。如果我们像在前面的例子中那样检查其中的一个条目,我们会看到许多关于当前正在执行的进程的信息。
与db.serverStatus()一样,输出中有很多信息,乍一看可能太多了。但是输出中有几个部分很关键:
-
告诉我们操作已经进行了多长时间。
-
ns是操作使用的名称空间——数据库和集合。 -
op显示正在进行的操作类型,command显示当前正在执行的命令。 -
列出了 MongoDB 认为执行计划中最重要的元素。
在调优的情况下,我们可能只关心作为应用的一部分发送的操作。幸运的是,currentOp()命令支持一个额外的参数来帮助我们过滤掉我们不关心的操作。
如果您试图只识别在给定集合上运行的操作,我们可以传入一个针对ns(名称空间)的过滤器,只有匹配该过滤器的操作才会被输出:
> db.currentOp({ns: "enron.messages"})
{
"inprog" : [
{
"type" : "op",
"host" : "Centos8:27017",
"desc" : "conn213",
"connectionId" : 213,
"client" : "1.159.98.235:52456",
"appName" : "MongoDB Shell",
"clientMetadata" : {
. . .
"op" : "getmore",
"ns" : "enron.messages",
. . .
}
我们还可以通过为op字段传递一个过滤器来过滤特定类型的操作,或者组合多个字段过滤器来回答诸如“当前在特定集合上运行什么插入操作?”:
> db.currentOp({ns: "enron.messages", op: "getmore"})
还有两个特殊的操作符我们可以传递到 db.currentOp的过滤器中。第一个选项是$all。可以想象,如果$all设置为true,输出将包括所有操作,包括系统和空闲连接操作。这里,我们计算总操作数,包括空闲操作:
mongo> db.currentOp({$all: true}).inprog.length
25
另一个选项是$ownOps。如果$ownOps设置为true,则只返回用户执行db.currentOp命令的操作。如下例所示,这些选项有助于减少返回的操作数量:
mongo> db.currentOp({$ownOps: true}).inprog.length
1
> db.currentOp({$ownOps: false}).inprog.length
7
在使用currentOp识别出一个麻烦的、资源密集型的或者长时间运行的操作之后,您可能想要终止那个操作。您可以使用来自currentOp的opid字段来确定要终止的进程,然后使用db.killOp来终止该操作。
例如,假设我们发现了一个运行时间非常长的查询,该查询使用了过多的资源,并导致了其他操作的性能问题。我们可以使用currentOp来识别这个查询,使用db.killOp来终止它:
mongo> db.currentOP({$ownOps: true}).inprog[0].opid
69035
mongo> db.killOp(69035)
{ "info" : "attempting to kill op", "ok" : 1 }
mongo> db.currentOp({$ownOps: true, opid: 69035})
{ "inprog" : [ ], "ok" : 1 }
发出killOp后,我们可以看到 operation 不再运行。
操作系统监控
到目前为止,我们看到的命令阐明了 MongoDB 服务器或集群的内部状态。但是,可能导致性能问题的原因不是集群中的资源消耗过多,而是托管 MongoDB 进程的系统上的资源可用性不足。
正如我们在第 2 章中看到的,一个 MongoDB 集群可能由多个 Mongo 进程实现,而这些进程可能分布在多台机器上。此外,MongoDB 进程可能会与其他进程和工作负载共享机器资源。当 MongoDB 运行在容器化或虚拟化的主机中时尤其如此。
操作系统监控是一个很大的话题,我们在这里只能触及皮毛。但是,以下注意事项适用于所有操作系统和类型:
-
为了有效地利用 CPU 资源,让 CPU 利用率接近 100%是再好不过了。然而, CPU 运行队列——等待 CPU 可用的进程数量——应该保持尽可能低。我们希望 MongoDB 能够在需要时获得 CPU 资源。
-
MongoDB 进程——尤其是 WiredTiger 缓存——应该完全包含在真实系统内存中。如果 MongoDB 进程或内存被“换出”到磁盘,性能会迅速下降。
-
磁盘服务时间应保持在相关磁盘设备的预期范围内。不同磁盘的预期服务时间不同,尤其是固态磁盘和老式磁盘。但是,磁盘响应时间通常应该低于 5 毫秒。
大多数严重的 MongoDB 集群都运行在 Linux 操作系统上。在 Linux 上,命令行实用程序vmstat和iostat可以检索高级统计信息。
在微软 Windows 上,图形化的任务管理器和资源监控器工具可以执行一些相同的功能。
无论使用哪种方法,在检查服务器统计数据时,都要确保了解操作系统资源的使用情况。例如,很可能通过对db.serverStatus()的检查发现增加 WiredTiger 缓存大小,但是如果没有足够的空闲内存来支持这样的增加,那么当您增加缓存大小时,您实际上可能会看到性能下降。
在第 10 章中,我们将更仔细地观察操作系统资源的监控。
MongoDB 罗盘
理解如何仅使用 MongoDB shell 进行调优是一项重要的技能。但这不是唯一的方法。
MongoDB Compass 是 MongoDB 的官方 GUI(图形用户界面),它封装了我们在这里看到的许多命令,以及一些更高级的功能。它在一个易于使用的界面中呈现这些工具。MongoDB Compass 是免费的,在进行性能调优时,它是 shell 旁边的一个方便工具。
然而,重要的是要记住,你离你的核心工具越远(我们在前面的文本中已经学过的数据库方法),你就越不可能理解幕后发生的事情。我们不会在本书中详细介绍 Compass 的每一个部分,但是我们会简单看一下它是如何包装和显示我们在本章中学到的其他工具的。您可以在 www.mongodb.com/products/compass 下载 MongoDB 指南针。
我们之前看到了 MongoDB Compass 如何显示图形化的解释计划(见图 3-1 )。
MongoDB Compass 还将允许您更容易地解释我们从db.serverStatus()检索的服务器信息。在 Compass 中,当您选择一个集群时,您可以简单地切换到窗口顶部的“Performance”选项卡。Compass 将自动开始收集和绘制关于您的服务器的关键信息。还将显示有关当前操作的信息。图 3-3 显示了 MongoDB 罗盘性能选项卡。
图 3-3
MongoDB Compass 中的可视化服务器状态
摘要
本章旨在让您熟悉在调优 MongoDB 应用性能时可以在尽可能多的条件下使用的工具。当然,我们不可能在一章中涵盖所有可能的工具或方法,也不是本章描述的所有技术都适合所有问题。这些实用程序和技术有时可能只是作为一个起点,不应该依赖于解决或立即识别任何问题。
explain()方法将允许您查看、分析和改进操作在服务器上的执行方式。当您认为查询需要改进时,检查explain()输出是第一步。查询分析器识别哪些查询可能需要调优。这两个工具一起使用可以让您找到并修复 MongoDB 服务器中最有问题的查询和命令。
如果您的服务器运行缓慢或者您不确定从哪里开始,那么serverStatus()命令可以为您提供对服务器性能的高级洞察。
使用currentOp(),您可以实时查看给定名称空间上正在运行的操作,识别长期运行的事务,甚至终止有问题的操作。
既然我们已经装备了我们的工具箱,我们可以学习基本的原则和方法来有效地使用它们。正如我们在本章开始时所说的,一个商人的好坏取决于他的工具,但是如果没有使用工具的知识,工具是没有用的。
Footnotes [1](#Fn1_source)也可以将explain()操作放在最后:db.collection.find().explain()而不是db.collection.explain().find()。但是,不推荐使用前一种语法。
完全披露:Mike 和 Guy 都参与了 dbKoda 产品的开发。
清单 3-1 中的查询作为mongoTuning.profileQuery()包含在我们的调优脚本中。
四、模式建模
在数据库中,模式定义了数据的内部结构或组织。在 MySQL 或 Postgres 这样的关系数据库中,模式被实现为表和列。
MongoDB 经常被描述为无模式数据库,但这多少有些误导。默认情况下,MongoDB 不强制任何特定的文档结构,但是所有的 MongoDB 应用都将实现某种文档模型。因此,将 MongoDB 描述为支持灵活的模式更加准确。
在 MongoDB 中,模式是由集合(通常表示相似文档的集合)和这些集合中的文档结构实现的。
MongoDB 应用的性能限制很大程度上取决于应用实现的文档模型。应用检索或处理信息所需的工作量主要取决于信息在多个文档中的分布情况。此外,文档的大小将决定 MongoDB 可以在内存中缓存多少文档。这些以及许多其他的权衡将决定数据库需要做多少物理工作来满足一个数据库请求。
尽管 MongoDB 没有昂贵且耗时的 SQL ALTER TABLE语句,但是一旦文档模型被建立并部署到生产环境中,对其进行根本性的修改仍然非常困难。因此,在设计应用时,选择正确的数据模型是一项至关重要的早期任务。
你可以写一本关于数据建模的书,事实上有些人已经写了。在这一章中,我们将试着从性能的角度介绍数据建模的核心租户。
指导原则
具有讽刺意味的是,使用 MongoDB 灵活模式进行模式建模实际上比在关系数据库的固定模式中更难。
在关系数据库建模中,您对数据进行逻辑建模,消除冗余,直到达到第三范式。简而言之,当一行中的每个元素都依赖于键、整个键并且除了键之外什么都不依赖时,就实现了第三范式。 1 然后通过反规格化引入冗余来支持性能目标。最终的数据模型通常大致保持第三范式,但稍加修改以支持关键查询。
您可以将 MongoDB 文档建模为第三范式,但这几乎总是错误的解决方案。MongoDB 的设计理念是,您应该在一个文档中包含几乎所有的相关信息——而不是像在关系模型中那样将它分散到多个实体中。因此,不是基于数据结构创建模型,而是基于查询和更新的结构创建模型。
以下是 MongoDB 数据建模的主要目标:
-
避免连接:MongoDB 使用聚合框架支持简单的连接功能(参见第 7 章第 7 章)。然而,与关系数据库相比,联接应该是一个例外,而不是常规。基于聚合的连接很笨拙,更常见的是在应用代码中连接数据。一般来说,我们试图确保我们的关键查询可以在一个集合中找到它们需要的所有数据。
-
管理冗余:通过将相关数据封装到一个文档中,我们制造了一个冗余问题——我们可能在数据库中有不止一个地方可以找到某个数据元素。例如,考虑一个
products集合和一个orders集合。orders集合可能会在订单细节中包含产品名称。如果我们需要更改产品名称,我们必须在多个地方进行更改。这将使得更新操作可能非常耗时。 -
小心 16MB 的限制 : MongoDB 对单个文档的大小有 16MB 的限制。我们需要确保永远不要试图嵌入太多的信息,否则会有超出限制的风险。
-
保持一致性 : MongoDB 确实支持事务(参见第 9 章),但是它们需要特殊的编程,并且有很大的约束。如果我们希望自动更新信息集,将这些数据元素包含在单个文档中可能是有利的。
-
监控内存:我们希望确保对 MongoDB 文档的大多数操作都发生在内存中。然而,如果我们通过嵌入大量信息使我们的文档变得非常大,那么我们就减少了可以放入内存的文档数量,并可能增加 IO。因此,我们希望尽可能保持文档较小。
链接与嵌入
有各种各样的 MongoDB 模式设计模式,但是它们都涉及这两种方法的变体:
-
将所有内容嵌入到一个文档中。
-
使用指向其他集合中数据的指针链接集合。这大致相当于使用关系数据库的第三范式模型。
案例研究
链接和嵌入方法之间有很大的折衷空间,并且有许多与性能无关的原因来选择一个而不是另一个(例如,原子更新和 16M 文档限制)。然而,让我们从性能的角度来看一下这两个极端是如何比较的——至少对于特定的工作负载来说是这样。
在这个案例研究中,我们将对经典的“订单”模式进行建模。订单模式包括订单、创建订单的客户的详细信息以及组成订单的产品。在关系数据库中,我们会把这个模式绘制成图 4-1 。
图 4-1
关系形式的订单-产品模式
如果我们只使用链接范例来建模这个模式,我们将为四个逻辑实体中的每一个创建一个集合。它们可能看起来像这样:
mongo>db.customers.findOne();
{
"_id" : 3,
"first_name" : "Danyette",
"last_name" : "Flahy",
"email" : "dflahy2@networksolutions.com",
"Street" : "70845 Sullivan Center",
"City" : "Torrance",
"DOB" : ISODate("1967-09-28T04:42:22Z")
}
mongo>db.orders.findOne();
{
"_id" : 1,
"orderDate" : ISODate("2017-03-09T16:30:16.415Z"),
"orderStatus" : 0,
"customerId" : 3
}
mongo>db.lineitems.findOne();
{
"_id" : ObjectId("5a7935f97e9e82f6c6e77c2b"),
"orderId" : 1,
"prodId" : 158,
"itemCount" : 48
}
mongo>db.products.findOne();
{
"_id" : 1,
"productName" : "Cup - 8oz Coffee Perforated",
"price" : 56.92,
"priceDate" : ISODate("2017-07-03T06:42:37Z"),
"color" : "Turquoise",
"Image" : "http://dummyimage.com/122x225.jpg/cc0000/ffffff"
}
在嵌入式设计中,我们会将与订单相关的所有信息放在一个文档中,如下所示:
{
"_id": 1,
"first_name": "Rolando",
"last_name": "Riggert",
"email": "rriggert0@geocities.com",
"gender": "Male",
"Street": "6959 Melvin Way",
"City": "Boston",
"State": "MA",
"ZIP": "02119",
"SSN": "134-53-2882",
"Phone": "978-952-5321",
"Company": "Wikibox",
"DOB": ISODate("1998-04-15T01:03:48Z"),
"orders": [
{
"orderId": 492,
"orderDate": ISODate("2017-08-20T11:51:04.934Z"),
"orderStatus": 6,
"lineItems": [
{
"prodId": 115,
"productName": "Juice - Orange",
"price": 4.93,
"itemCount": 172,
"test": true
},
每个客户都有自己的文档,在该文档中有一组订单。每个订单内部都有一组订单中包含的产品(行项目)以及该行项目中包含的产品的所有信息。
在我们的示例模式中,有 1000 个客户、1000 个产品、51,116 个订单和 891,551 个行项目。定义了以下索引:
OrderExample.embeddedOrders {"_id":1}
OrderExample.embeddedOrders {"email":1}
OrderExample.embeddedOrders {"orders.orderStatus":1}
OrderExample.customers {"_id":1}
OrderExample.customers {"email":1}
OrderExample.orders {"_id":1}
OrderExample.orders {"customerId":1}
OrderExample.orders {"orderStatus":1}
OrderExample.lineitems {"_id":1}
OrderExample.lineitems {"orderId":1}
OrderExample.lineitems {"prodId":1}
让我们来看看我们可能对这些模式执行的一些典型操作,并比较两种极端情况下的性能。
获取客户的所有数据
当所有信息都嵌入在一个文档中时,获取客户的所有数据是一项简单的任务。我们可以通过如下查询从嵌入式版本中获取所有数据:
db.embeddedOrders.find({ email: 'bbroomedr@amazon.de' })
有了电子邮件上的索引,这个查询不到一毫秒就能完成。
四集版本的生活要艰难得多。我们需要使用聚合或自定义代码来实现相同的结果,并且我们需要确保在$lookup连接条件上有索引(参见第 7 章)。以下是汇总数据:
db.customers.aggregate(
[
{
$match: { email: 'bbroomedr@amazon.de' }
},
{
$lookup: {
from: 'orders',
localField: '_id',
foreignField: 'customerId',
as: 'orders'
}
},
{
$lookup: {
from: 'lineitems',
localField: 'orders._id',
foreignField: 'orderId',
as: 'lineitems'
}
},
{
$lookup: {
from: 'products',
localField: 'lineitems.prodId',
foreignField: '_id',
as: 'products'
}
}
]
)
毫不奇怪,聚合/连接比嵌入式解决方案花费的时间要长。图 4-2 展示了相对性能——嵌入式模型每秒能够提供十倍以上的读取。
图 4-2
执行 500 次客户查找所花费的时间,包括所有订单详细信息
获取所有未结订单
在典型的订单处理场景中,我们希望检索所有处于未完成状态的订单。在我们的示例中,这些订单由orderStatus=0标识。
在嵌入式案例中,我们可以获得这样的未结订单客户:
db.embeddedOrders.find({"orders.orderStatus":0})
这确实为我们提供了至少有一个未结订单的所有客户,但是如果我们只想检索未结订单,我们将需要使用聚合框架:
db.embeddedOrders.aggregate([
{ $match:{ "orders.orderStatus": 0 }},
{ $unwind: "$orders" },
{ $match:{ "orders.orderStatus": 0 }},
{ $count: "count" }
] );
您可能想知道为什么我们的聚合中有重复的$match语句。第一个$match给我们带来未结订单的客户,而第二个$match给我们自己带来订单。我们不需要第一个就能得到正确的结果,但它确实能提高性能(见第 7 章)。
在链接数据模型中获得这些订单要容易得多:
db.orders.find({orderStatus:0}).count()
毫不奇怪,越简单的链接查询性能越好。图 4-3 比较了两种解决方案的性能。
图 4-3
获得未结订单计数所花费的时间
顶级产品
大多数公司都想找出最畅销的产品。对于嵌入式模型,我们需要展开行项目并按产品名称进行聚合:
db.embeddedOrders.aggregate([
{ $unwind: "$orders" },
{ $unwind: "$orders.lineItems" },
{ $project: { "lineitems": "$orders.lineItems" }},
{ $group:{ _id:{ "prodId":"$lineitems.prodId" ,
" productName":"$lineitems.productName" },
" itemCount-sum":{$sum:"$lineitems.itemCount"}} },
{ $sort:{ "lineitems_itemCount-sum":-1 }},
{ $limit: 10 },
]);
在链接模型中,我们还需要使用 aggregate,通过行项目和产品之间的$lookup连接来获取产品名称:
db.lineitems.aggregate([
{ $group:{ _id:{ "prodId":"$prodId" },
"itemCount-sum":{$sum:"$itemCount"} }
},
{ $sort:{ "itemCount-sum":-1 }},
{ $limit: 10 },
{ $lookup:
{ from: "products",
localField: "_id.prodId",
foreignField: "_id",
as: "product"
}
},
{ $project: {
"ProductName": "$product.productName" ,
"itemCount-sum": 1 ,
"_id": 1
}
},
]);
尽管必须执行联接,但链接数据模型的性能最好。我们只需在获得前十名产品后加入,而在嵌入式设计中,我们必须扫描集合中的所有数据。图 4-4 比较了这两种方法。嵌入式数据模型花费的时间大约是链接数据模型的两倍。
图 4-4
检索前十个产品所用的时间
插入新订单
在这个工作负载示例中,我们查看了为现有客户插入新订单的情况。在嵌入式的情况下,这可以通过在客户文档中使用一个$push操作来完成:
db.embeddedOrders.updateOne(
{ _id: o.order.customerId },
{ $push: { orders: orderData } }
);
在链接数据模型中,我们必须插入到line items集合和orders集合中:
var rc1 = db.orders.insertOne(orderData);
var rc2 = db.lineItems.insertMany(lineItemsArray);
您可能会认为单次更新会轻易胜过链接模型所需的多次插入。但实际上,更新是一项非常昂贵的操作——尤其是当集合中没有足够的空闲空间来容纳新数据的时候。链接插入虽然数量更多,但操作更简单,因为它们不需要找到匹配的文档来更新。因此,在本例中,链接模型的性能优于嵌入模型。图 4-5 比较了 500 个订单插入的性能。
图 4-5
是时候插入 500 个订单了
更新产品
如果我们想更新一个产品的名称呢?在嵌入的情况下,产品名称被嵌入到行项目本身中。我们使用arrayFilters操作符在 MongoDB 的一个操作中更新所有产品的名称。这里,我们更新产品 193 的名称:
db.embeddedOrders.update(
{ 'orders.lineItems.prodId':193 },
{ $set: { 'orders.$[].lineItems.$[i].productName':
'Potatoes - now with extra sugar' } },
{ arrayFilters: [{ 'i.prodId': { $eq: 193 } }], multi: true });
当然,在链接模型中,我们可以对产品集合进行非常简单的更新:
db.products.update(
{ _id: 193 },
{ $set: { productName: 'Potatoes - now with extra sugar' } }
);
嵌入式模型比链接模型需要我们接触更多的文档。因此,在嵌入式数据模型中,10 次产品代码价格更新需要几百倍的时间。图 4-6 说明了性能。
图 4-6
是时候更新十个产品名称了
删除客户
如果我们想要删除四个集合模型中单个客户的所有数据,我们需要遍历line items、orders和customers集合。代码看起来会像这样:
db.orders.find({customerId:customerId},{_id:1}).forEach((order)=>{
db.lineitems.deleteMany({orderId:order._id});
});
db.orders.deleteMany({customerId:1});
db.customers.deleteOne({_id:1});
当然,在嵌入式情况下,事情要简单得多:
db.embeddedOrders.deleteOne({_id:1});
链接的示例表现很差——图 4-7 比较了删除 50 个客户的性能。
图 4-7
是时候删除 50 个客户了
案例研究总结
我们已经看了很多场景,如果你有点头晕,我们不会责怪你。因此,让我们将所有性能数据汇总到一个图表中。图 4-8 综合了我们六个例子的结果。
图 4-8
链接模型与嵌入模型的性能比较
正如您所看到的,虽然嵌入式模型在获取单个客户或删除客户的所有数据方面非常出色,但在其他情况下,它并不比链接模型优越。
Tip
“对我的应用来说,什么是最好的数据模型”这个问题的答案是——也一直是——视情况而定。
当读取实体的所有相关数据时,嵌入式模型提供了许多优势,但是对于更新和聚合查询来说,它通常不是最快的模型。哪种模型最适合您将取决于应用性能的哪些方面是最关键的。但是请记住,一旦部署了应用,就很难更改数据模型,因此在应用设计过程的早期获得数据模型所花费的时间可能会有所回报。
此外,请记住,很少有应用使用“全有或全无”的方法。当我们混合使用链接和嵌入方法来最大化应用的关键操作时,通常可以获得最好的结果。
高级模式
在前一节中,我们研究了 MongoDB 数据建模的两个极端:嵌入一切与链接一切。在现实生活中,您可能会结合使用这两种技术,以便在每种方法的利弊之间取得最佳平衡。让我们看看一些结合了这两种方法的建模模式。
系统增强
正如我们在上一节中看到的,当检索一个实体的所有数据时,嵌入式模型具有显著的性能优势。然而,我们需要注意两大风险:
图 4-9
混合“桶”数据模型
-
在典型的主-细节模型中——例如客户及其订单——细节文档的数量没有特定的限制。但是在 MongoDB 中,文档的大小不能超过 16MB。因此,如果有大量的详细文档,嵌入式模型可能会崩溃。例如,我们最大的客户可能会订购如此多的产品,以至于我们无法将所有订单放在一个 16MB 的文档中。
-
即使我们确定不会超过 16MB,对 MongoDB 内存的影响也可能是不可取的。随着平均文档大小的增加,可以放入内存的文档数量会减少。大量大型文档(可能充满“旧”数据)可能会降低缓存和性能。我们将在第 11 章中详细讨论这一点。
解决这一冲突最常见的方法之一是混合策略,有时也称为子集化。
在子集模式中,我们在主文档中嵌入有限数量的细节文档,并将剩余的细节存储在另一个集合中。例如,我们可能只在
customers集合中保存每个客户最近的 20 个订单,其余的保存在orders集合中。图 4-9 说明了这个概念。每个客户都嵌入了最近的 20 个订单,所有订单都在
orders集合中。
如果我们设想我们的应用在客户查找页面上显示每个客户的最新订单,那么我们可以看到这种模型的好处。我们不仅避免了触及 16M 文档大小的限制,而且现在可以从单个文档填充这个客户查找页面。
然而,解决方案是有代价的。特别是,我们现在每次添加或修改订单时,都必须打乱嵌入式订单数组中的订单。每次更新都需要对嵌入的订单执行额外的操作。以下代码实现了混合设计中customers数据的混洗:
let orders=db.hybridCustomers.
findOne({'_id':customerId}).orders;
orders.unshift(newOrder); // add new order
if (orders.length>20)
orders.pop(); // Remove the order
db.hybridCustomers.update({'_id':customerId},
{$set:{orders:orders}});
由此产生的开销可能会很大。图 4-10 显示了获取客户和最新订单以及用新订单更新客户时混合模式的影响。读取性能显著提高,但更新率几乎减半。
图 4-10
混合模式可以提高读取性能,但会降低更新速度
垂直分割
将与实体相关的所有内容放在一个文档中通常是有意义的。正如我们之前看到的,我们可以在 JSON 数组中嵌入与一个实体相关的多个细节,从而避免在 SQL 数据库中执行连接操作。
然而,有时我们可以从将一个实体的细节分割到多个集合中得到好处,这样我们可以减少每次操作中获取的数据量。这种方法类似于混合数据模型,因为它减小了核心文档的大小,但是它应用于顶级属性,而不仅仅是细节数组。
例如,假设我们在每个客户记录中包含一张客户的高分辨率照片。这些不常访问的图像增加了集合的整体大小,降低了执行集合扫描所需的时间(参见第 6 章)。它们还减少了可以保存在内存中的文档数量,这可能会增加所需的 IO 数量(参见第 11 章)。
在这种情况下,如果将二进制照片存储在单独的集合中,我们可以获得性能优势。图 4-11 示出了该布置。
图 4-11
垂直分割
属性模式
如果我们的文档包含大量相同数据类型的属性,并且我们知道我们将使用其中的许多属性来执行查找,那么我们可以通过使用属性模式来减少所需的索引数量。
考虑以下天气数据:
{
"timeStamp" : ISODate("2020-05-30T07:21:08.804Z"),
"Akron" : 35,
"Albany" : 22,
"Albuquerque" : 22,
"Allentown" : 31,
"Alpharetta" : 24,
<data for another 300 cities>
}
如果我们知道我们将支持搜索某个城市的特定值的查询(例如,在阿克伦找到超过 100 度的所有测量值),那么我们就有问题了。我们不可能创建足够的索引来支持所有的查询。更好的组织是为每个城市定义name:value对。
下面是前面的数据在属性模式中的样子:
{
"timeStamp" : ISODate("2020-05-30T07:21:08.804Z"),
"measurements" : [
{
"city" : "Akron",
"temperature" : 35
},
{
"city" : "Albany",
"temperature" : 22
},
{
"city" : "Albuquerque",
"temperature" : 22
},
{
"city" : "Allentown",
"temperature" : 31
},
<data for another 300 cities>
}
我们现在可以选择在measurements.city上定义一个索引,而不是尝试创建第一个设计中需要的数百个索引的不可能任务。
在某些情况下,您可以使用通配符索引而不是属性模式——参见第 5 章。然而,属性模式提供了一种灵活的方式来提供对任意数据项的快速访问。
摘要
尽管 MongoDB 支持非常灵活的模式建模,但是您的数据模型设计对于应用性能仍然是绝对关键的。数据模型决定了 MongoDB 为满足数据库请求而需要执行的逻辑工作量,并且一旦部署到生产环境中就很难更改。
MongoDB 建模中的两个“元模式”是嵌入和链接。嵌入包括在单个文档中包含关于逻辑实体的所有信息。链接包括将相关数据存储在单独的集合中,这种方式让人想起关系数据库。
嵌入通过避免连接提高了读取性能,但也带来了数据一致性、更新性能和 16MB 文档限制等挑战。大多数应用明智地混合了嵌入和链接,以实现“两全其美”的解决方案。
Footnotes [1](#Fn1_source)为了纪念关系模型的创建者 Edgar Codd,我们经常说“关键,整个关键,只有关键,所以请帮助我 Codd!”