MongoDB-性能调优教程-全-
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 子系统以满足该需求。
二、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 提供的基本工具,它们应该是您调优过程中的忠实伙伴。
三、贸易工具
他们说一个商人的好坏取决于他或她的工具。幸运的是,您不需要昂贵或难以找到的工具来调优 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()
,您可以实时查看给定名称空间上正在运行的操作,识别长期运行的事务,甚至终止有问题的操作。
既然我们已经装备了我们的工具箱,我们可以学习基本的原则和方法来有效地使用它们。正如我们在本章开始时所说的,一个商人的好坏取决于他的工具,但是如果没有使用工具的知识,工具是没有用的。
四、模式建模
在数据库中,模式定义了数据的内部结构或组织。在 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 文档限制等挑战。大多数应用明智地混合了嵌入和链接,以实现“两全其美”的解决方案。
五、索引
索引是一个数据库对象,它有自己的存储,提供了一个快速访问集合的路径。索引的存在主要是为了提高性能,因此在优化 MongoDB 性能时,理解和有效地使用索引是至关重要的。
b 树索引
B 树(“平衡树”)索引是 MongoDB 的默认索引结构。图 5-1 显示了 B 树索引结构的高级概述。
图 5-1
b 树索引结构
B 树索引具有分层的树结构。树的顶部是标题块。对于任何给定范围的键值,该块包含指向适当分支块的指针。对于更具体的范围,分支块通常指向适当的叶块,或者对于更大的索引,指向另一个分支块。叶块包含一个键值列表和指向磁盘上文档位置的指针。
检查图 5-1 ,让我们想象一下 MongoDB 将如何遍历这个索引。如果我们需要访问“BAKER”的记录,我们将首先查阅标题块。header 块告诉我们,从 A 到 K 开始的键值存储在最左边的分支块中。访问这个分支块,我们发现从 A 到 D 开始的键值存储在最左边的叶块中。参考这个叶块,我们找到值“BAKER”及其相关的磁盘位置,然后我们将使用它来获得相关的文档。
叶块包含到上一个和下一个叶块的链接。这允许我们以升序或降序扫描索引,并允许使用索引处理使用$gt
或$lt
操作符的范围查询。
与其他索引策略相比,b 树索引具有以下优势:
-
因为每个叶节点都在相同的深度,所以性能是非常可预测的。理论上,集合中的任何文档都不会超过三个或四个 io。
-
b 树为大型集合提供了出色的性能,因为深度最多为四(一个头块、两级分支块和一级叶块)。一般来说,没有一个文档需要四个以上的 io 来定位。事实上,因为头块几乎总是在内存中,而分支块通常在内存中,所以实际的物理磁盘读取次数通常只有一两次。
-
B 树索引支持范围查询和精确查找。这是可能的,因为链接到前一个和下一个叶块。
B 树索引提供了灵活高效的查询性能。然而,在改变数据时维护 B 树可能是昂贵的。例如,考虑将一个带有键值“NIVEN”的文档插入到图 5-1 所示的集合中。要插入文档,我们必须在“L-O”块中添加一个新条目。如果在这个块中有空闲空间,那么成本是相当大的,但可能不会过高。但是如果块中没有空闲空间会发生什么呢?
如果叶块内没有空闲空间用于新条目,则需要索引拆分。分配一个新的块,并将现有块中的一半条目移动到新块中。此外,还需要在分支块中添加一个指向新创建的叶块的新条目。如果分支块中没有空闲空间,那么分支块也必须被分割。
这些索引拆分是一项开销很大的操作:分配新的块,并将索引条目从一个块移动到另一个块。因此,索引会显著降低插入、更新和删除操作的速度。
Caution
索引加速了数据检索,但是增加了插入、更新和删除操作的负担。
索引选择性
索引的选择性是对有多少文档与特定索引键值相关联的度量。如果属性或索引具有大量的唯一值和很少的重复值,则它们是选择性的。例如,dateOfBirth
属性将是非常有选择性的(大量的可能值和相对较少的重复值),而gender
属性将不是有选择性的(少量的可能值和大量的重复值)。
选择性索引比非选择性索引更有效,因为它们更直接地指向特定的值。因此,MongoDB 将尝试使用最具选择性的索引。
唯一索引
唯一索引是防止组成索引的属性的任何重复值的索引。如果您尝试在包含此类重复值的集合上创建唯一索引,将会收到一个错误。同样,如果您尝试插入包含重复唯一索引键值的文档,也会收到错误。
创建唯一索引通常是为了防止重复值,而不是为了提高性能。然而,唯一索引通常非常有效——它们只指向一个文档,因此非常有选择性。
所有 MongoDB 集合都有一个内置的隐式惟一索引——在“_id
”属性上。
索引扫描
除了能够找到特定的值,索引还可以优化部分字符串匹配和数据范围。这些索引扫描是可能的,因为 B 树索引结构包含到前一个和下一个叶块的链接。这些链接允许我们按升序或降序浏览索引。
例如,考虑以下查询,该查询检索两个日期之间出生的所有客户:
db.customers. find({
$and: [
{ dateOfBirth: { $gt: ISODate('1980-01-01T00:00:00Z') } },
{ dateOfBirth: { $lt: ISODate('1990-01-01T00:00:00Z') } }
]
});
如果在dateOfBirth
上有一个索引,我们可以使用该索引来查找相关的客户。MongoDB 将导航到较低日期的索引条目,然后扫描整个索引,直到到达一个索引条目,其中dateOfBirth
大于较高日期。叶块之间的链接允许这种扫描有效地发生。
如果我们检查这个查询的explain()
输出中的IXSCAN
步骤,我们可以看到一个indexBounds
条目,它显示了如何使用索引在两个值之间进行扫描:
"inputStage" : {
"keyPattern" : {
"dateOfBirth" : 1},
"indexName" : "dateOfBirth_1",
. . .
"direction" : "forward",
"indexBounds" : {
"dateOfBirth" : [
"(new Date(315532800000),
new Date(631152000000))"
]
}
}
当我们对字符串条件进行部分匹配时,也会执行索引扫描。例如,在下面的查询中,将扫描LastName
上的索引,查找名称大于或等于“HARRIS”且小于或等于 HARRIT 的所有条目。实际上,这仅匹配名称 HARRIS 和 HARRISON,但是从 MongoDB 的角度来看,这与在高值和低值之间扫描是一样的。
mongo> var explainObj=db.customers.explain('executionStats')
.find({LastName:{$regex:/^HARRIS(.*)/}});
mongo> mongoTuning.executionStats(explainObj);
1 IXSCAN ( LastName_1 ms:0 keys:1366)
2 FETCH ( ms:0 docs:1365)
Totals: ms: 4 keys: 1366 Docs: 1365
索引扫描并不总是一件好事。如果范围很大,那么索引扫描可能比根本不使用索引更糟糕。在图 5-2 中,我们看到如果值的范围很宽(在本例中,与所有可能的值一样宽),那么最好进行集合扫描,而不是索引查找。但是,如果范围较窄,则该索引会提供更好的性能。我们将在第六章中详细讨论优化索引范围扫描。
图 5-2
索引扫描性能和扫描宽度
不区分大小写的搜索
搜索不确定大小写的文本字符串并不少见。例如,如果我们不知道输入的姓氏是“Smith”还是“SMITH”,我们可以像这样进行不区分大小写的搜索(正则表达式后面的“I”指定不区分大小写的匹配):
mongo> var e=db.customers.explain('executionStats')
.find({LastName:/^SMITH$/i},{}) ;
mongo> mongoTuning.quickExplain(e);
1 IXSCAN LastName_1
2 FETCH
您可能会惊喜地看到使用了一个索引来解析查询——那么也许 MongoDB 索引可以用于不区分大小写的搜索?唉,不尽然。如果我们得到executionStats
,我们看到虽然使用了索引,但是它扫描了所有 410,000 个键。是的,索引用于查找匹配的名称,但是必须扫描整个索引。
mongo> var e=db.customers.explain('executionStats')
.find({LastName:/^SMITH$/i},{}) ;
mongo> mongoTuning.executionStats(e);
1 IXSCAN ( LastName_1 ms:8 keys:410071)
2 FETCH ( ms:8 docs:711)
Totals: ms: 293 keys: 410071 Docs: 711
如果您想进行不区分大小写的搜索,那么有一个技巧可以使用。首先,创建一个不区分大小写的排序规则序列的索引。这是通过指定强度为 1 或 2 的归类序列来实现的(级别 1 忽略大小写和音调符号——特殊字符,如元音变音等。):
db.customers.createIndex(
{ LastName: 1 },
{ collation: { locale: 'en', strength: 2 } }
);
现在,如果在查询中也指定了相同的排序规则,查询将返回不区分大小写的结果。例如,对“Smith”的查询现在也返回“SMITH ”:
mongo> db.customers.
... find({ LastName: 'SMITH' }, { LastName: 1,_id:0 }).
... collation({ locale: 'en', strength: 2 }).
... limit(1);
{
"LastName": "Smith"
}
如果我们查看executionStats
,我们会看到索引现在只正确地检索符合条件的文档(在本例中,有 700 多个“Smith”和“Smith”):
mongo> var e = db.customers.
... explain('executionStats').
... find({ LastName: 'SMITH' }).
... collation({ locale: 'en', strength: 2 });
mongo> mongoTuning.executionStats(e);
1 IXSCAN ( LastName_1 ms:0 keys:711)
2 FETCH ( ms:0 docs:711)
Totals: ms: 2 keys: 711 Docs: 711
复合索引
一个复合索引就是一个包含不止一个属性的索引。复合索引最显著的优势是,它通常比单一关键索引更具选择性。多个属性的组合将指向比由单一属性组成的索引数量更少的文档。包含所有包含在find()
或$match
子句中的属性的复合索引将特别有效。
如果经常查询集合中的多个属性,那么为这些属性创建一个复合索引是一个很好的主意。例如,我们可以通过LastName
和FirstName
查询customers
集合。在这种情况下,我们可能希望创建一个包含LastName
和FirstName
的复合索引。
使用这样的索引,我们可以快速找到匹配给定的LastName
和FirstName
组合的所有customers
。这样的索引将远比单独在LastName
上的索引或者在LastName
和FirstName
上的单独索引更有效。
如果复合索引只能在所有键都显示为find()
或$match
时使用,那么复合索引的用途可能会非常有限。幸运的是,如果查询中请求了任何一个初始或前导属性,那么复合索引就可以有效地使用。前导属性是那些在索引定义中最早指定的属性。
复合索引性能
一般来说,当您向索引添加更多的属性时,您会看到索引性能的提高——前提是这些属性包含在查询过滤条件中。
例如,考虑以下查询:
db.people.find(
{
"LastName" : "HENNING",
"FirstName" : "ALBERTO",
dateOfBirth: ISODate("1953-12-23T00:00:00Z")
},
{ _id: 0, Phone: 1 }
);
我们通过提供FirstName
、LastName
和dateOfBirth
来检索客户电话号码。
图 5-3 显示了当我们给索引添加属性时,文档访问次数是如何减少的。如果没有索引,我们必须扫描所有 411,121 个文档。仅索引LastName
一项就减少到 6918 个文档——实际上是集合中所有的“HENNING”。添加FirstName
将文档数量减少到 15 个。通过添加dateOfBirth,
,我们减少了两次访问:一次是读取索引条目,从那里,我们读取集合中的文档以获取电话号码。我们最后的优化是将电话号码(“tel
”)属性添加到索引中。现在我们根本不需要访问集合——我们需要的一切都在索引中。
图 5-3
复合索引表现(对数标度)
复合索引键顺序
复合索引的一个优点是,它们可以支持不包含索引中所有键的查询。如果查询中包含一些主要属性,可以使用复合索引。
例如,指定为{LastName:1, FirstName:1, dateOfBirth:1}
的索引可以用来优化对单独的LastName
或者对LastName
和FirstName
的查询。然而,当单独针对FirstName
或者针对dateOfBirth
优化查询时,这将是无效的。为了使索引有用,查询中必须至少出现第一个或前导键中的一个。
Tip
复合索引可用于加速在索引表达式中包含任何或所有前导(第一个)键的查询。但是,它们无法优化索引表达式中至少不包含第一个键的查询。
复合索引指南
以下准则将有助于决定何时使用复合索引,以及如何确定要包含哪些属性以及包含的顺序:
-
为集合中出现在
find()
或$match
条件下的属性创建复合索引。 -
如果属性有时在
find()
或$match
条件中单独出现,那么将它们放在索引的开头。 -
如果复合索引还支持未指定所有属性的查询,那么它会更有用。例如,
createIndex({"LastName":1,"FirstName":1})
比createIndex({"FirstName":1,"LastName":1})
更有用,因为只针对LastName
的查询比只针对FirstName
的查询更有可能发生。 -
属性的选择性越强,它在索引的前端就越有用。但是,请注意,WiredTiger 索引压缩可以从根本上收缩索引。当前导列的选择性小于时,索引压缩最有效。这可能意味着这样的索引更小,因此更可能适合内存。我们将在第 11 章中对此进行更多的讨论。
覆盖索引
覆盖索引的是可用于完全解析查询的索引。类似地,可以完全通过索引解决的查询被称为覆盖查询。
我们在图 5-3 中看到了一个覆盖索引的例子。使用关于LastName
、FirstName
、dateOfBirth
和Phone
的索引来解析查询,而无需从集合中检索数据。覆盖索引是优化查询的强大机制。因为索引通常远小于集合,所以不需要将集合中的文档放入内存的查询具有很高的内存和 IO 效率。
索引合并
前面,我们强调了在查询中的所有条件上创建复合索引通常是最有效的。
例如,在如下查询中:
db.iotData.find({a:1,b:1})
我们可能需要一个关于{a:1,b:1}.
的索引,但是,如果这个集合有很多属性,并且查询有很多可能的组合,那么创建我们需要的所有复合索引可能是不切实际的。1
但是,如果我们在 a 上有一个索引,在 b 上有另一个索引,MongoDB 可以执行两个索引的交集。最终的计划如下所示:
1 IXSCAN a_1
2 IXSCAN b_1
3 AND_SORTED
4 FETCH
AND_SORTED 步骤表示已经执行了索引交集。
$and
条件的索引交叉点不常见。但是,MongoDB 会频繁地为$or
条件执行索引合并。例如,在这个查询中:
db.iotData.find({$or:[{a:100},{b:100}]});
默认情况下,MongoDB 会合并这两个索引:
1 IXSCAN a_1
2 IXSCAN b_1
3 OR
4 FETCH
5 SUBPLAN
OR
和SUBPLAN
步骤表示索引合并。
Note
对于$and
条件,复合索引优于索引合并。然而,对于一个$or
条件,索引合并通常是最好的解决方案。
部分索引和稀疏索引
正如我们将在第 11 章中看到的,当所有数据都保存在内存中时,通常会获得最佳的 MongoDB 性能。然而,对于非常大的集合,MongoDB 可能很难在内存中保存所有的索引。在某些情况下,我们只想使用索引来扫描最近的或活动的信息。在这些场景中,我们可能想要创建一个部分或稀疏索引。
部分索引
部分索引是只为信息子集维护的索引。例如,假设我们有一个推文数据库,正在寻找我们账户中转发次数最多的推文:
db.tweets.
find({ 'user.name': 'Mean Magazine Bot' }, { text: 1 }).
sort({ retweet_count: -1 }).
limit(1);
一个关于user.name
和retweet_count
的索引可以达到这个目的,但是它会是一个相当大的索引。由于大多数推文没有被转发,我们可以只对那些被转发的推文创建部分索引:
db.tweets.createIndex(
{ 'user.name': 1, retweet_count: 1 },
{ partialFilterExpression: { retweet_count: { $gt: 0 } } }
);
当我们寻找从未被转发的推文时,这个索引将是无用的,但假设这不是我们试图做的,部分索引将比完整索引小得多,并且更有效地存储。
注意,为了利用这个索引,我们需要在查询中指定一个过滤条件,确保 MongoDB 知道我们需要的所有数据都在索引中。在我们当前的例子中,我们可以在retweet_count
上添加一个条件:
db.tweets.find(
{ 'user.name': 'Mean Magazine Bot',
retweet_count: { $gt: 0 } },
{ text: 1 }
).
sort({ retweet_count: -1 }).
limit(1);
稀疏索引
稀疏 索引类似于部分索引,因为它们不索引集合中的所有文档。具体来说,稀疏索引不包括不包含索引属性的文档。
大多数时候,稀疏索引和普通索引一样好,而且可能要小得多。但是,稀疏索引不支持对索引属性进行$exists:true
搜索:
mongo> var exp=db.customers.explain()
.find({updateFlag:{$exists:false}});
mongo> mongoTuning.quickExplain(exp);
1 COLLSCAN
但是,稀疏索引可以搜索$exists:true
:
mongo> var exp=db.customers.explain()
.find({updateFlag:{$exists:true}});
mongo> mongoTuning.quickExplain(exp);
1 IXSCAN updateFlag_1
2 FETCH
使用索引进行排序和连接
索引可用于支持按排序顺序返回数据,也可用于支持多个集合之间的连接。
整理
MongoDB 可以使用索引按排序顺序返回数据。因为每个叶节点都包含到后续叶节点的链接,所以 MongoDB 可以按排序的顺序扫描索引条目,返回数据而不必显式地对数据进行排序。我们将在第 6 章中研究如何使用索引来支持排序。
对联接使用索引
MongoDB 可以在聚合框架中使用$lookup
和$graphLookup
操作符连接多个集合中的数据。对于任何非平凡大小的连接,索引查找应该支持这些连接,以避免随着连接大小的增加而出现索引级下降。该主题在第 7 章中有详细介绍。
索引开销
尽管索引可以极大地提高查询性能,但它们确实会降低插入、更新和删除操作的性能。当插入或删除文档时,通常会修改集合的所有索引,并且当更新改变了出现在索引中的任何属性时,也必须修改索引。插入、更新和删除期间的索引维护通常是 MongoDB 在这些操作中必须完成的大部分工作。
因此,我们所有的索引对查询性能都有贡献是很重要的,因为这些索引会不必要地降低插入、更新和删除的性能。特别是,在对频繁更新的属性创建索引时,应该特别小心。一个文档只能插入或删除一次,但可以更新多次。因此,对频繁更新的属性或具有非常高的插入/删除率的集合进行索引将需要特别高的成本。
在第 8 章中,我们将详细介绍索引开销以及识别可能没有发挥其作用的索引的方法。
通配符索引
通配符索引是一种开销特别大的索引类型。
通配符索引是在子文档的每个属性上创建的索引。举例来说,我们有一些类似这样的数据:
{
"_id" : 1,
"data" : {
"a" : 1728,
"b" : 6740,
"c" : 6481,
"d" : 2066,
"e" : 3173,
"f" : 1796,
"g" : 8112
}
}
可以针对data
子文档中的任何一个属性发出查询。此外,应用可能会添加一些我们无法预料的新属性。为了优化性能,我们需要为每个属性创建一个单独的索引:
db.mycollection.createIndex({"data.a":1});
db.mycollection.createIndex({"data.b":1});
db.mycollection.createIndex({"data.c":1});
db.mycollection.createIndex({"data.d":1});
db.mycollection.createIndex({"data.e":1});
db.mycollection.createIndex({"data.f":1});
db.mycollection.createIndex({"data.g":1});
索引太多!但是即使这样也不行,除非我能确定属性是什么。如果创建了属性“h
”会发生什么?
在这种情况下,通配符索引会出手相救。 2
顾名思义,我们可以通过在属性表达式中指定通配符占位符来创建通配符索引,例如:
db.mycollection.createIndex({"data.$**":1});
该语句为data
文档中的每个属性创建一个索引:即使新属性是在索引创建后由应用创建的。
太好了!但显然,这是有成本的。让我们来看看通配符索引在 insert、find、update 和 delete 语句中的表现
-
完全没有索引
-
单一属性的单一索引
-
所有属性的独立索引
对于查找操作,我们看到通配符索引的性能与单属性索引一样好——不管我们创建了多少个索引,索引都提供了对相关数据的快速访问。图 5-4 说明了结果。
图 5-4
通配符索引与查找操作的其他方法
尽管通配符索引与常规索引具有相似的配置文件,但是当我们研究更新、删除和插入操作时,它们具有非常不同的开销。
图 5-5 显示了当我们有通配符索引、每个属性有单独的索引、单个属性有单个索引或者根本没有索引时,执行插入、更新和删除操作所花费的时间。
图 5-5
通配符索引与传统索引相比的开销
正如我们所料,我们看到多个索引的开销比单个索引高得多。然而,我们也看到通配符索引带来的开销至少与为每个属性创建单独的索引一样大。
Warning
不要出于懒惰而创建通配符索引。通配符索引的开销很高,只有在没有替代策略时才应该使用它们。
如果一些属性从来没有被搜索过,那么通配符索引将会增加不值得的开销。和往常一样,只创建必要的索引:所有索引都会影响性能,通配符索引更是如此。
通配符索引对您的索引库是一个非常有用的补充。但是,不要把它们仅仅作为编程的捷径:它们会给插入、更新和删除性能带来很大的开销,只有当要索引的属性不可预测时才应该使用它们。
文本索引
在现代应用中,允许用户执行自由形式的项目搜索已经成为标准,比如电影、购物项目或租赁物业的列表。用户不想填写复杂的表单来指定要搜索哪些属性,当然也不想学习 MongoDB find()
语法。
要构建这类应用,您可能需要一些搜索词,然后在成千上万个文档中搜索大型文本字段,以找到最佳匹配。这就是文本索引有用的地方。
在调优或创建文本索引时,理解 MongoDB 将如何解释该索引以及该索引将如何影响查询非常重要。
MongoDB 使用一种叫做后缀词干的方法来构建搜索索引。
后缀词干包括在构成搜索树的根的每个单词的开头找到一个公共元素(前缀)。每一个不同的后缀都“派生”到它自己的节点上,这个节点可以进一步派生。这个过程创建了一个树,可以有效地从根(最常见的共享元素)向下搜索到叶节点,从根到叶节点的路径构成了一个完整的单词。
例如,假设我们在文档的某个地方有单词" finder 、 finding 、 findable "。通过使用后缀词干,我们可以在这些单词中找到一个共同的词根" find ," er , ing , able。
MongoDB 使用同样的方法。当您在给定字段上创建文本索引时,MongoDB 将解析该字段中包含的文本,并为给定文档中生成的每个唯一词干术语创建一个索引条目。这将对每个索引字段和每个文档重复,直到该集合中的所有指定字段都有完整的文本索引。
理解这个理论很好,但是有时理解文本索引如何工作的最好方法是开始与它们交互,所以让我们创建一个新的文本索引。该命令非常简单,使用与创建任何其他类型的索引相同的语法。您只需指定要为其创建索引的字段,索引的类型指定为"text"
:
> db.listingsAndReviews.createIndex({description: "text"})
{
"createdCollectionAutomatically" : false,
"numIndexesBefore" : 4,
"numIndexesAfter" : 5,
"ok" : 1
}
创建文本索引就像这样简单。与我们的其他索引一样,我们可以在多个属性上创建一个文本索引:
> db.listingsAndReviews.createIndex({summary: "text", space: "text"})
{
"createdCollectionAutomatically" : false,
"numIndexesBefore" : 4,
"numIndexesAfter" : 5,
"ok" : 1
}
尽管可以在多个索引上创建文本索引,但在任何集合上只能有一个文本索引。因此,如果您一个接一个地运行上面的两个命令,您将会收到一个错误。
Note
每个收藏只能有个文本索引。因此,要创建新的文本索引或包含文本索引的复合索引,必须先使用db.collection.
dropIndex
("index_name")
删除旧索引。
我们还可以创建复合索引,包括文本和传统索引的混合:
> db.listingsAndReviews.createIndex({summary: "text", beds: 1})
{
"createdCollectionAutomatically" : false,
"numIndexesBefore" : 4,
"numIndexesAfter" : 5,
"ok" : 1
}
创建文本索引时的另一个重要方面是为每个字段指定权重。字段的权重是指该字段相对于其他索引字段的重要性。MongoDB 将在确定返回哪些结果以在$text
查询中使用时使用它。创建文本索引时,可以将权重指定为一个选项。
> db.listingsAndReviews.createIndex({summary: "text", description: "text"}, {weights: {summary: 3, description: 2}})
{
"createdCollectionAutomatically" : false,
"numIndexesBefore" : 4,
"numIndexesAfter" : 5,
"ok" : 1
}
现在我们的集合上有了一个文本索引,我们可以使用$
text
操作符来访问它。$text
接受一个$search
操作符,该操作符接受一个单词列表(通常由空格分隔):
> db.listingsAndReviews.findOne({$text: {$search: "oven kettle and microwave"}}, {summary: 1})
{
"_id" : "6785160",
"summary" : "Large home with that includes a bedroom with TV , hanging and shelf space for clothing, comfortable double bed and air conditioning. Additional private sitting room includes sofa, kettle, bar fridge and toaster. Exclusive use of large bathroom with shower, bath, double sinks and toilet. LGBTQI friendly"
}
当使用文本索引来投影在文本搜索期间生成的给定文档的得分时,这通常是有用的。您可以使用显示textScore
字段的$
meta
projection
来完成此操作。您通常也希望对这个投影进行排序,以确保您首先获得最相关的搜索结果。
mongo> db.listingsAndReviews.
... find(
... { $text: { $search: 'oven kettle and microwave' } },
... { score: { $meta: 'textScore' }, summary: 1 }
... ).
... sort({ score: { $meta: 'textScore' } }).
... limit(3);
{
"_id": "25701117",
"summary": "Totally refurbished penthouse apartment ...",
"score": 3.5587606837606836
}
{
"_id": "13324467",
"summary": "Everything, absolutely EVERYTHING NEW and ... ",
"score": 3.5549853372434015
}
搜索文本索引时,您可能希望使用的另外两种重要方法是排除和精确匹配。排除项用–符号标记,完全匹配项用双引号标记。例如,查询
> db.listingsAndReviews.find(
{$text: {$search:
"\"luggage storage\" kettle and -microwave"}})
将在索引中搜索短语“行李寄存的精确匹配,以及排除短语“微波炉”的文档。“以这种方式使用文本索引非常强大,尤其是在大量文本的数据集上。但是,要记住文本索引有一些限制:
-
为文本索引指定
sparse
没有任何效果。文本索引总是稀疏的。 -
如果您的复合索引包含一个文本索引,它不能包含多键或地理空间字段。您必须为这些特殊的索引类型创建单独的索引。
-
正如前面创建文本索引的例子中提到的,每个集合只能创建一个文本索引。额外的文本索引创建将引发错误。
文本索引性能
使用传统索引,您可以使用集合扫描而不是索引来解析查询。但是,如果没有文本索引,就根本无法执行全文搜索。因此,对于全文索引的使用,您没有太多的选择。
但是,您应该记住全文索引的一些性能特征。首先,您应该知道 MongoDB 将对搜索标准中的每个术语执行索引扫描。例如,这里我们搜索五个唯一的单词,并因此执行五次文本索引扫描:
mongo> var exp = db.bigEnron.
... explain('executionStats').
... find( { $text: { $search:
'Confirmation Rooms Credit card tax email ' } },
... { score: { $meta: 'textScore' }, body: 1 } ).
... sort({ score: { $meta: 'textScore' } }).
... limit(3);
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( body_text ms:229 keys:53068)
2 IXSCAN ( body_text ms:764 keys:217480)
3 IXSCAN ( body_text ms:748 keys:229382)
4 IXSCAN ( body_text ms:1376 keys:398325)
5 IXSCAN ( body_text ms:362 keys:108996)
6 IXSCAN ( body_text ms:181 keys:93970)
7 TEXT_OR ( ms:494636 docs:843437)
8 TEXT_MATCH ( ms:494709)
9 TEXT ( body_text ms:494746)
10 SORT_KEY_GENERATOR ( ms:494795)
11 SORT ( ms:495015)
12 PROJECTION_DEFAULT ( ms:495072)
因此,如图 5-6 所示,我们拥有的搜索词越多,文本搜索所需的时间就越长。
图 5-6
文本索引性能与搜索词数量的关系
Note
MongoDB 文本索引性能与搜索中的术语数量成正比。必要时,限制搜索词的数量,以保持响应时间可控。
请注意,即使您搜索一个精确的短语,您仍将对短语中的每个单词执行一次扫描,因为索引本身不知道单词是如何按顺序使用的。如果您正在搜索一个很长的精确文本短语,您最好执行正则表达式查询和完全集合扫描。例如,此查询查找文本“你今晚会去看比赛吗”:
mongo> var exp = db.bigEnron.
... explain('executionStats').
find( { $text: {
$search: '"are you going to be at the game tonight"' } });
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( body_text ms:354 keys:62838)
2 IXSCAN ( body_text ms:2136 keys:515760)
3 IXSCAN ( body_text ms:146 keys:39721)
4 OR ( ms:2767)
5 FETCH ( ms:379793 docs:563201)
6 TEXT_MATCH ( ms:383409)
7 TEXT ( body_text ms:383517)
Totals: ms: 414690 keys: 618319 Docs: 563201
MongoDB 执行三次索引扫描(只有单词“game”、“going”和“tonight”被认为值得扫描)。在不到一半的运行时间内完成了完全集合扫描:
mongo> var exp = db.bigEnron.
... explain('executionStats').
find({body:/are you going to be at the game tonight/});
mongo> mongoTuning.executionStats(exp);
1 COLLSCAN ( ms:102289 docs:2897816)
Totals: ms: 145925 keys: 0 Docs: 2897816
Tip
如果您正在搜索一个精确的短语,您可能最好进行基于集合扫描的常规查询——MongoDB 文本索引不是为高效的多词短语搜索而设计的。
以下是关于文本索引的一些额外的重要性能注意事项:
-
由于前面描述的词干方法,文本索引可能非常大,并且可能需要很长时间来创建。
-
MongoDB 建议您的系统拥有足够的内存来保存文本索引,因为否则在搜索过程中可能会涉及大量的 IO。
在查询中使用排序时,您将无法利用文本索引来确定顺序,即使在复合文本索引中也是如此。在对文本查询结果进行排序时,请记住这一点。
文本索引非常强大,可以服务于各种各样的现代应用,但是在依赖它们时要小心,否则您会发现您的$text
查询变成了一个恼人的性能瓶颈。
MongoDB Atlas 提供了使用流行的 Lucene 平台进行文本搜索的能力。与 MongoDB 的内部文本搜索功能相比,这个工具有很多优点。
地理空间索引
今天的位置感知应用通常需要在地图数据中执行搜索。这些搜索可能包括搜索某个地区的租赁物业,查找附近的场地,甚至按拍摄地点对照片进行分类。如今,无论我们走到哪里,许多设备都在被动捕捉大量的位置数据。这通常被称为地理空间数据:关于地球上位置的数据。
MongoDB 既提供了查询这些数据的方法,也提供了特定的索引类型来优化查询。
以下是一些地理空间数据的示例:
{
"_id" : ObjectId("578f6fa2df35c7fbdbaed8c4"),
"recrd" : "",
"vesslterms" : "",
"feature_type" : "Wrecks - Visible",
"chart" : "US,U1,graph,DNC H1409860",
"latdec" : 9.3547792,
"londec" : -79.9081268,
"gp_quality" : "",
"depth" : "",
"sounding_type" : "",
"history" : "",
"quasou" : "",
"watlev" : "always dry",
"coordinates" : [
-79.9081268,
9.3547792
]
}
数据本身可能非常简单,尽管您可能会将大量元数据与坐标一起存储。前面的数据采用传统格式,数据表示为简单的坐标对。MongoDB 还支持 GeoJSON 格式。
{
"_id" : ObjectId("578f6fa2df35c7fbdbaed8c4"),
"recrd" : "",
"vesslterms" : "",
"feature_type" : "Wrecks - Visible",
"chart" : "US,U1,graph,DNC H1409860",
"latdec" : 9.3547792,
"londec" : -79.9081268,
"gp_quality" : "",
"depth" : "",
"sounding_type" : "",
"history" : "",
"quasou" : "",
"watlev" : "always dry",
"location" : {
"type" : "Point",
"coordinates" : [
-79.9081268,
9.3547792
]
}
}
GeoJSON 格式指定了数据类型以及值本身,可以是单个点,也可以是许多坐标对的数组。GeoJSON 允许您定义更复杂的空间信息,如线和面,但出于本章的目的,我们将重点关注传统格式的简单点数据。
下面是一个地理空间查询,可以使用$near
操作符在目标点的某个半径范围内查找文档:
> db.shipwrecks.find(
... {
... coordinates:
... { $near :
... {
... $geometry: { type: "Point",
coordinates: [ -79.908, 9.354 ] },
... $minDistance: 1000,
... $maxDistance: 10000
... }
... }
... }
... ).limit(1).pretty();
{
"_id" : ObjectId("578f6fa2df35c7fbdbaed8c8"),
"recrd" : "",
"vesslterms" : "",
"feature_type" : "Wrecks - Submerged, dangerous",
"chart" : "US,U1,graph,DNC H1409860",
"latdec" : 9.3418808,
"londec" : -79.9103851,
"gp_quality" : "",
"depth" : "",
"sounding_type" : "",
"history" : "",
"quasou" : "depth unknown",
"watlev" : "always under water/submerged",
"coordinates" : [
-79.9103851,
9.3418808
]
}
在前面的示例中,此查询的匹配地理空间索引已经存在。在使用$near
操作符的情况下,运行查询需要地理空间索引。如果您试图在没有索引的情况下运行这个查询,MongoDB 将返回一个错误:
Error: error: {
"ok" : 0,
"errmsg" : "error processing query: ns=sample_geospatial.shipwrecks limit=1Tree: GEONEAR field=coordinates maxdist=10000 isNearSphere=0\nSort: {}\nProj: {}\n planner returned error :: caused by :: unable to find index for $geoNear query",
"code" : 291,
"codeName" : "NoQueryExecutionPlans"
}
事实上,几乎所有的地理空间操作者都需要一个合适的地理空间索引。
在该查询的执行计划中,我们将第一次看到以下阶段—“GEO_NEAR_2DSPHERE
”:
mongo> var exp=db.shipwrecks.explain('executionStats').
... find(
... {
... coordinates:
... { $near :
... {
... $geometry: { type: "Point",
coordinates: [ -79.908, 9.354 ] },
... $minDistance: 1000,
... $maxDistance: 10000
... }
... }
... }
... ).limit(1);
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( coordinates_2dsphere ms:0 keys:12)
2 FETCH ( ms:0 docs:0)
3 IXSCAN ( coordinates_2dsphere ms:0 keys:18)
4 FETCH ( ms:0 docs:1)
5 GEO_NEAR_2DSPHERE ( coordinates_2dsphere ms:0)
6 LIMIT ( ms:0)
Totals: ms: 0 keys: 30 Docs: 1
这表明我们正在使用一个2dsphere
索引来帮助我们查询这些地理空间数据。
您可以在 MongoDB 中创建两种不同类型的地理空间索引:
-
2dsphere :用于索引存在于类似地球的球体上的数据
-
2d :用于索引像传统地图一样存在于二维平面上的数据
您选择使用哪个索引将取决于数据本身的上下文。选择索引类型时要小心。你可以在球形数据上使用一个2d
索引;但是,结果会被扭曲。想想地图上相对两侧的两个点的例子;这两点在球面上可能很近,但在二维平面上可能很远。
要创建地理空间索引,只需指定2dsphere
或2d
索引类型作为值,关键字是包含位置数据的字段,可以是传统坐标数据或 GeoJSON 数据:
> db.shipwrecks.createIndex({"coordinates" : "2dsphere"})
Warning
如果试图在不包含 GeoJSON 对象或坐标对形式的适当数据的字段上创建地理空间索引,MongoDB 将返回错误。因此,在创建这个索引之前,请检查您的数据。
地理空间索引性能
当讨论确保索引提高性能的方法时,地理空间索引是一个例外。因为您必须拥有这些索引(除了$geoWithin
操作符之外),它们不一定像允许它们运行那样给查询带来性能提升。这使得提高地理空间查询性能成为一项更具挑战性的任务,而不是创建或调整匹配索引;关于地理空间索引,您可以考虑以下几个方面:
-
与其他地理空间操作符不同,
$geoWithin
可以在没有地理空间索引的情况下使用。添加匹配索引是提高$geoWithin
性能最简单的方法。 -
$near
和$nearSphere
将自动按照距离(从最近到最远)对结果进行排序,所以如果您在查询中添加一个sort()
操作,那么最初的排序就被浪费了。如果您计划对结果进行排序,您可以通过使用$geoWithin
或$geoNear
聚合阶段来提高性能,这不会自动对结果进行排序。 -
当使用
$near
、$nearSphere
或$geoNear
操作符时,尽可能利用minDistance
和maxDistance
参数。这将限制 MongoDB 检查的文档数量。对于附近有许多数据点的查询,这可能不会影响性能。然而,如果附近没有匹配的值,在maxDistance
中的查询可能会搜索整个世界!
地理空间元数据正被添加到越来越多的数据中,从图像到浏览器日志。越来越有可能在生产数据集中的某个地方,您可能有一些地理空间数据。与其他索引类型一样,您仍然应该考虑维护索引的开销是否值得提高性能。如果您不希望应用查询地理空间数据,那么地理空间索引可能没有好处。
地理空间索引限制
对于2dsphere
和2d
两种索引类型,不可能创建一个覆盖的查询。由于地理空间操作符的性质,必须检查文档以满足查询,所以不要期望仅仅通过创建地理空间索引来创建覆盖的查询。
此外,当使用分片的集合时(将在第 14 章第 14 章中介绍),地理空间索引不能用作分片键,您将无法通过 GeoJSON 或坐标数据进行分片。但是,如果您希望在一个分片集合上有一个地理空间索引,您仍然可以创建它,因为分片键引用了一个不同于索引的字段。同样值得注意的是,和文本索引一样,2d
和2dsphere
索引总是稀疏的。
2d
索引类型不能用于更高级的 GeoJSON 数据;只有支持传统坐标对。
MongoDB 允许在一个集合上创建多个地理空间索引。但是,创建后续地理空间索引时要小心,因为这将影响地理空间聚合的行为,甚至可能破坏现有的应用代码。例如,如果使用$geoNear
聚合管道阶段的查询存在多个地理空间索引,则必须指定您希望使用的键。如果集合中存在多个2dsphere
或2d
索引,并且没有指定键,那么聚合将无法确定使用哪个索引,从而导致聚合失败。
Note
如果您最多有一个2d
索引和一个2dsphere
索引,您将不会收到错误。相反,查询将尝试使用2d
索引,如果它存在的话;如果没有找到2d
索引,它将尝试使用2dsphere
索引。
实际上,不太可能在一个集合中创建许多不同的地理空间索引。和往常一样,在创建索引之前,请仔细考虑您可能会遇到哪些查询。
摘要
在这一章中,我们学习了什么是索引,它们是如何工作的,以及为什么它们是至关重要的。很多时候,正确地识别和创建与查询匹配的索引会给你带来最大的“性价比”,从而提高性能。此外,我们还学习了一些更具体的索引来帮助地理空间或文本查询。
然而,正如我们在本章中了解到的,索引并不是解决所有性能问题的通用创可贴。在某些情况下,使用不当的索引会降低性能。在决定实现哪种索引之前,考虑来自应用或用户的预期负载和数据结构是至关重要的。
索引可能是您提高 MongoDB 性能的最健壮的方法之一,但是在创建它们的时候不要偷懒;花一点时间在正确的索引上可以节省你很多时间来调整曲目。
六、查询调优
在几乎所有的应用中,大部分数据库时间都花在数据检索上。一个文档只能被插入或删除一次,但在两次更新之间通常会被多次读取,即使是更新也必须在执行其工作之前检索数据。因此,我们大部分的 MongoDB 调优工作都集中在查找数据上,特别是find()
语句,它是 MongoDB 数据检索的主力。
缓存结果
回到 Guy 主要处理基于 SQL 的数据库的黑暗日子,一位智者曾经告诉他“最快的 SQL 语句是你永远不会发送到数据库的语句。”换句话说,如果可以避免,就不要向数据库发送请求。即使是最简单的请求也需要一次网络往返,可能还需要一次 IO——所以除非万不得已,否则不要与数据库交互。
这个原则同样适用于 MongoDB。我们经常不止一次地向数据库请求相同的信息——即使我们知道信息没有改变。
例如,考虑下面的简单函数:
function recordView(customerId,filmId) {
let filmTitle=db.films.findOne({_id:filmId},{Title:1}).Title;
db.customers.update({_id:customerId},
{$push:{views:{filmId,title:filmTitle,
viewDate:new ISODate()}}});
}
我们在电影收藏中查找电影名称——很公平。但是电影的名字从来不会变,而且在任何一天,有些电影都会被看很多遍。那么,为什么要回到数据库去获取我们已经处理过的电影的片名呢?
这个公认更复杂的代码将电影标题缓存在本地内存中。我们再也不会向数据库查询电影片名了:
var cacheDemo={};
cacheDemo.filmCache={};
cacheDemo.getFilmId=function(filmId) {
if (filmId in cacheDemo.filmCache) {
return(cacheDemo.filmCache[filmId]);
}
else
{
let filmTitle=db.films.findOne({_id:filmId},
{Title:1}).Title;
cacheDemo.filmCache[filmId]=filmTitle;
return(filmTitle);
}
};
cacheDemo.recordView= function(customerId,filmId) {
let filmTitle=cacheDemo.getFilmId(filmId);
db.customers.update({_id:customerId},
{$push:{views:{filmId,title:filmTitle,
viewDate:new ISODate()}}});
}
缓存的实现要快得多。图 6-1 显示了在随机输入的情况下执行每个功能 1000 次所用的时间。
图 6-1
简单缓存带来的性能提升
缓存特别适合包含静态“查找”值的小型、频繁访问的集合。
以下是实现缓存时需要记住的一些注意事项:
-
缓存消耗客户端程序的内存。在许多环境中,内存是充足的,考虑缓存的表相对较小。但是,对于大型集合和内存受限的环境,缓存策略的实现可能会导致应用层或客户端内存不足,从而降低性能。
-
当缓存相对较小时,顺序扫描(即从第一个条目到最后一个条目检查缓存中的每个条目)可能会产生足够的性能。但是,如果缓存较大,顺序扫描可能会降低性能。为了保持良好的性能,可能有必要实现高级搜索技术,如哈希或二进制斩波。在我们前面的例子中,高速缓存实际上是通过电影 ID 索引的,因此,无论涉及多少部电影,高速缓存都将保持高效。
-
如果正在缓存的集合在程序执行期间被更新,那么这些更改可能不会反映在您的缓存中,除非您实现一些复杂的同步机制。因此,本地缓存最好在静态集合上执行。
Tip
缓存中小型静态集合中频繁访问的数据对于提高程序性能非常有效。但是,要注意内存利用和程序复杂性问题。
优化网络往返
数据库通常是应用中最慢的部分,原因之一是它们必须通过网络链接移动数据。每次应用从数据库中访问一些数据时,这些数据都必须通过网络传输。在极端情况下(比如当你的数据库在另一个大洲的云服务器上时),这个距离可能是几千英里。
网络传输需要时间——通常比 CPU 周期花费的时间要多得多。因此,减少网络传输——或网络往返——是减少查询时间的基础。
我们喜欢把网络传输想象成过河的划艇。我们有一定数量的人在河的一边,我们想让他们用船渡到对岸。每次渡河时,我们能让船上的人越多,往返的次数就越少,我们就能越快让他们全部渡河。如果人代表文档,船代表单个网络包,那么同样的逻辑适用于数据库网络流量:我们的目标是将最大数量的文档打包到每个网络包中。
有两种将文档打包成网络包的基本方法:
-
通过使每个文档尽可能小
-
通过确保网络数据包没有空白空间
预测
投影允许我们指定应该包含在查询结果中的属性。MongoDB 程序员通常不会费心指定投影,因为应用通常会丢弃不需要的数据。但是对网络往返的影响可能是巨大的。考虑以下查询:
db.customers.find().forEach((customer)=>{
if (customer.LastName in results )
results[customer.LastName]++;
else
results[customer.LastName]=1;
});
我们正在统计顾客的姓氏。注意,我们使用的来自customers
集合的唯一属性是LastName
。因此,我们可以添加一个投影,以确保结果中只包含姓氏:
db.customers.find({},{LastName:1,_id:0}).forEach((customer)=>{
if (customer.LastName in results )
results[customer.LastName]++;
else
results[customer.LastName]=1;
});
在慢速网络上,性能差异是惊人的-投影将吞吐量提高了 10 倍。即使我们在与数据库服务器相同的主机上运行查询(因此减少了往返时间),性能差异仍然很大。图 6-2 展示了通过增加一个投影所获得的性能提升。
图 6-2
使用投影减少网络开销
Tip
每当获取批量数据时,在find()
操作中包含投影。预测减少了 MongoDB 需要通过网络传输的数据量,因此可以减少网络往返。
成批处理
MongoDB 自动管理响应查询的每个网络包中包含的文档数量。批处理被限制为 16MB 的 BSON 文档大小,但是因为网络数据包比这个小得多,所以这个限制通常不重要。然而,默认情况下,MongoDB 在初始批次中只返回 101 个文档,这意味着有时数据可能会被分成两个网络传输,而一个网络传输就足够了。
当使用游标检索数据时,可以使用batchSize
子句指定每个操作中提取的行数。例如,下面我们有一个游标,其中变量batchSize
控制每个网络请求中从 MongoDB 数据库检索的文档数量:
var myCursor=db.millions.find({},{n:1,_id:0})
.batchSize(batchsize);
while (myCursor.hasNext()) {
myCursor.next();
count+=1;
}
注意,batchSize
操作符实际上并不改变返回给程序的数据量——它只是控制每次网络往返中检索到的文档数量。从你的程序的角度来看,这一切都发生在“幕后”。
修改batchSize
的有效性很大程度上取决于底层驱动程序的实现。在 MongoDB shell 中,默认的batchSize
已经被设置得尽可能高了。但是,在 NodeJS 驱动程序中,batchSize
被设置为默认值 1000。因此,在 NodeJS 程序中调整batchSize
可能会提高性能。
在图 6-3 中,我们看到了使用 NodeJS 驱动程序为从远程数据库中检索行的查询操作batchSize
的效果。低于 1000 的batchSize
设置会使性能变差——有时甚至更差!但是大于 1000 的设置确实提高了性能。
图 6-3
更改batchSize
对 NodeJS 中查询性能的影响
请注意,如果您使用 MongoDB shell 重复这个实验,您将不会看到随着您增加batchSize
而带来的性能提升。每个驱动程序和客户端实现batchSize
都有些不同。节点驱动程序使用默认大小 1000,而 Mongo shell 使用更高的值。
Warning
调整batchSize
很可能会降低性能,而不是提高性能。只有当您通过慢速网络拉取大量小文档时,才增加batchSize
,并始终进行测试以确保您获得了性能提升。
在代码中避免过多的网络往返
batchSize()
帮助我们在 MongoDB 驱动中透明地减少网络开销。但是有时优化网络往返的唯一方法是调整应用逻辑。例如,考虑以下逻辑:
for (i = 1; i < max; i++) {
//console.log(i);
if ((i % 100) == 0) {
cursor = useDb.collection(mycollection).find({
_id: i
});
const doc = await cursor.next();
counter++;
}
}
我们从 MongoDB 集合中取出每一百个文档。如果收集量很大,那么将会有很多网络往返。此外,这些请求中的每一个都将通过索引查找来满足,并且所有这些索引查找的总和将会很高。
或者,我们可以在一次操作中获取整个集合,然后提取我们想要的文档。
const cursor = useDb.collection(mycollection).find()
.batchSize(10000);
for (let doc = await cursor.next();
doc != null;
doc = await cursor.next()) {
if (doc._id % divisor === 0) {
counter++;
}
}
直觉上,你可能认为第二种方法需要更长的时间。毕竟,我们现在要从 MongoDB 中检索 100 多倍的文档,对吗?但是因为光标在每一批数以千计的文档中移动(在引擎盖下),第二种方法实际上对网络的占用要少得多。如果数据库位于慢速网络上,那么第二种方法会快得多。
在图 6-4 中,我们看到了本地服务器(例如,Guy 的笔记本电脑)与远程(Atlas)服务器的两种方法的性能。当 Mongo 服务器在 Guy 的笔记本电脑上时,第一种方法要快一点。但是当服务器是远程的时候,在一次操作中获取所有数据要快得多。
图 6-4
在客户端代码中优化网络往返
批量插入
正如我们希望批量从 MongoDB 中取出数据一样,我们也希望批量插入数据——至少在我们有大量数据要插入的情况下。虽然优化原理是一样的,但是实现却大不相同。由于 MongoDB 服务器或驱动程序不可能知道您将要插入多少个文档,所以由您来组织您的代码以显式地批量插入。我们将在第 8 章中探讨批量插入的原则和实践。
应用架构
还记得我们对划艇和河流的类比吗?确保划艇满载是我们减少过河次数的方法。但是,河的宽度是我们平时控制不了的。但是在应用中,我们必须移动的距离是我们可以控制的。应用服务器和数据库服务器之间的“距离”是决定每次网络往返所用时间的主要因素。
因此,应用代码离数据库服务器越近,消耗在网络开销上的时间就越少。只要有可能,您应该努力将应用服务器与数据库服务器放在同一个数据中心,甚至放在同一个网络机架上。
Tip
让您的应用代码尽可能靠近数据库服务器。两者之间的距离越远,数据库请求的平均网络延迟就越高。
当我们利用基于云的 MongoDB Atlas 服务器时,优化应用代码的位置可能会有问题。然而,我们确实对 Atlas 数据库的位置有很多控制,我们将在第 13 章中详细讨论这一点。
选择索引还是扫描
到目前为止,我们已经了解了如何减少网络流量消耗的时间。现在让我们看看如何减少 MongoDB 服务器本身所需的工作量。
我们拥有的用于查询调优的最重要的工具是索引。第 5 章专门讨论索引,我们在那一章花了很多时间学习如何创建最好的索引。
但是,索引可能并不总是查询的最佳选择。
如果你正在阅读一整本书,你不会先跳到索引,然后在每个索引条目和它所指的书的章节之间切换。那将是愚蠢的,而且极其浪费时间。你从第一页开始读一本书,然后按顺序读后面的几页。如果你想在一本书里找到一个特定的条目,这时你就要使用索引。
同样的逻辑也适用于 MongoDB 查询——如果您正在读取整个集合,那么您不希望使用索引。如果您正在阅读少量的文档,那么索引是首选。但是在什么情况下索引会比集合扫描更有效呢?例如,如果我正在阅读一半的收藏,我应该使用索引吗?
不幸的是,答案是视情况而定。影响索引检索的“盈亏平衡点”的一些因素有
-
缓存效果:索引检索在 WiredTiger 缓存中往往会获得非常好的命中率,而全收集扫描通常会获得很差的命中率。但是,如果所有集合都在缓存中,那么集合扫描的执行速度将接近索引速度。
-
文档大小:大多数情况下,文档将在单个 IO 中被检索,因此文档的大小对索引性能没有太大影响。但是,较大的文档意味着较大的集合,这将增加集合扫描所需的 IO 量。
-
数据分布:如果集合中的文档按照索引属性的顺序存储(如果文档按照键的顺序插入,就会发生这种情况),那么索引可能需要访问更少的块来检索给定键值的所有文档,从而获得更高的命中率。按排序顺序存储的数据有时被称为高度聚集的。
图 6-5 显示了聚集数据和非聚集数据的索引扫描和集合扫描所用的时间,相对于正在检索的集合的百分比绘制。在一个测试中,数据按排序顺序加载到集合中,有利于索引查找。在另一项测试中,数据实际上是随机排列的。
图 6-5
索引和集合扫描性能相对于被访问的集合百分比绘制(对数标度)
对于随机分布的数据,如果检索到超过 8%的集合,则集合扫描比索引扫描完成得更快。但是,如果数据是高度聚集的,索引扫描的性能会超过集合扫描,达到 95%的水平。
尽管不可能为索引检索指定一个“一刀切”的截止点,但以下陈述通常是有效的:
-
如果需要访问集合中的所有文档或大部分文档,那么全集合扫描将是最快的访问路径。
-
如果要从一个大集合中检索单个文档,那么基于该属性的索引将提供更快的检索路径。
-
在这两个极端之间,可能很难预测哪条访问路径会更快。
Note
对于索引扫描访问和集合扫描访问,不存在“一刀切”的平衡点。如果只有几个文档被访问,那么索引将是首选。如果几乎所有的文档都被访问,那么全集合扫描将是首选。在这两个极端之间,你的“里程”会有所不同。
用提示覆盖优化器
在决定最佳访问路径时,MongoDB 优化器使用启发式规则和“实验”的组合。在为特定的查询“形状”确定一个计划之前,它通常会尝试一些不同的计划。然而,当索引存在时,优化器偏向于使用索引。例如,以下查询检索集合中的每个文档,因为没有出生于 19 世纪的客户!然而,尽管所有的文档都被检索,MongoDB 还是选择了一个索引路径。
mongo> var exp=db.customers.explain('executionStats').
find({dateOfBirth:{
$gt:new Date("1900-01-01T00:00:00.000Z")}});
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( dateOfBirth_1 ms:16 keys:411121)
2 FETCH ( ms:53 docs:411121)
Totals: ms: 805 keys: 411121 Docs: 411121
执行计划显示,IXSCAN 步骤检索集合的所有 411,121 行:在这种情况下使用索引并不理想。
我们可以通过添加一个提示来改变 force 这个查询使用集合扫描。如果我们追加.
hint
({$
natural
:1})
,我们指示 MongoDB 执行集合扫描来解析查询:
mongo> var exp=db.customers.explain('executionStats').
... find({dateOfBirth:{
$gt:new Date("1900-01-01T00:00:00.000Z")}}).
... hint({$natural:1});
mongo> mongoTuning.executionStats(exp);
1 COLLSCAN ( ms:16 docs:411121)
Totals: ms: 383 keys: 0 Docs: 411121
我们还可以使用一个提示来指定我们希望 MongoDB 使用的索引。例如,在这个查询中,我们看到 MongoDB 选择了一个关于国家的索引:
mongo> var exp=db.customers.explain('executionStats').
... find({Country:'India',
dateOfBirth:{$gt:new Date("1990-01-01T00:00:00.000Z") }});
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( Country_1 ms:0 keys:41180)
2 FETCH ( ms:7 docs:41180)
Totals: ms: 78 keys: 41180 Docs: 41180
如果我们认为 MongoDB 选择了错误的索引,那么我们可以在提示中指定希望 MongoDB 使用的索引键。这里,我们在dateOfBirth
上强制使用一个索引:
mongo> var exp=db.customers.explain('executionStats').
... find({Country:'India',
dateOfBirth:{$gt:new Date("1990-01-01T00:00:00.000Z") }}).hint({dateOfBirth:1});
mongo>
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( dateOfBirth_1 ms:6 keys:63921)
2 FETCH ( ms:13 docs:63921)
Totals: ms: 143 keys: 63921 Docs: 63921
在应用代码中使用提示不是最佳做法。一个提示可能会阻止查询利用添加到数据库中的新索引,并且可能会阻止 MongoDB 在引入新版本的服务器时引入的优化。但是,如果所有其他方法都失败了,提示可能是强制 MongoDB 使用正确索引或强制 MongoDB 使用集合扫描的唯一方法。
Warning
考虑在查询中使用提示作为最后的手段。一个提示可能会阻止 MongoDB 利用新索引或响应数据分布的变化。
优化排序操作
如果一个查询包含一个排序指令,而排序后的属性上没有索引,那么 MongoDB 必须获取所有数据,然后在内存中对结果数据进行排序。在对所有行进行排序之前,查询中的第一行无法返回——因为在对所有文档进行排序之前,我们无法识别排序结果中的第一个文档。因此,非索引排序通常被称为阻塞排序。
如果您需要整个数据集的排序,那么块排序实际上可能比索引排序更快。但是,使用索引几乎可以立即获得前几个文档,而且在许多应用中,用户希望快速看到排序数据的第一页,而可能永远不会翻阅整个集合。在这些情况下,索引排序是非常理想的。
此外,如果内存不足,阻塞排序将会失败。对于阻塞排序 1 ,您可能会得到这样的错误:
Executor error during find command: OperationFailed: Sort operation used more than the maximum 33554432 bytes of RAM. Add an index, or specify a smaller limit.
指定了sort()
选项并执行阻塞排序的find()
操作将显示执行计划中的SORT_KEY_GENERATOR
步骤,随后是SORT
步骤:
mongo> var plan=db.customers.explain()
.find().sort({dateOfBirth:1});
mongo> mongoTuning.quickExplain(plan);
1 COLLSCAN
2 SORT_KEY_GENERATOR
3 SORT
如果我们根据排序标准创建一个索引,那么我们只会看到一个 IXSCAN 和 FETCH:
mongo> var plan=db.customers.explain()
.find().sort({dateOfBirth:1});
mongo> mongoTuning.quickExplain(plan);
1 IXSCAN dateOfBirth_1
2 FETCH
如果我们有一个先执行过滤然后执行排序的查询,那么我们将需要在过滤条件和排序条件上都有一个索引——按照这个顺序。
例如,如果我们有这样一个查询:
Mongo> db.customers.find({Country:'Japan'})
.sort({dateOfBirth:1});
最初,我们可能会很高兴看到该计划使用该索引得到解决:
mongo> var plan=db.customers.explain()
.find({Country:'Japan'}).sort({dateOfBirth:1});
mongo> mongoTuning.quickExplain(plan);
1 IXSCAN dateOfBirth_1
2 FETCH
然而,该索引仅支持排序。如果我们希望索引支持排序和查询过滤器,那么我们需要创建一个这样的索引:
db.customers.createIndex({Country:1,dateOfBirth:1});
Tip
要创建同时支持筛选和排序的索引,首先创建带有筛选条件的索引,然后创建排序属性。
使用索引以特定顺序返回文档并不总是最佳选择。如果你正在寻找前个个文档,那么索引会比分块排序更好。但是,如果您需要所有按排序顺序返回的文档,那么阻塞排序可能更好。
图 6-6 显示了索引如何从根本上减少检索第一个排序文档的响应时间,但实际上降低了获取集合中最后一个排序文档所需的时间。
图 6-6
检索所有文档或仅检索第一个文档时,索引对排序的影响(注意对数标度)
Tip
如果您只对排序中的前几个文档感兴趣,使用索引来优化排序是一个好策略。当您需要按排序顺序返回所有文档时,分块(非索引)排序通常会更快。
如果要对大量数据进行分块排序,可能需要为排序分配更多的内存。您可以通过调整内部参数internalQueryExecMaxBlockingSortBytes
来实现。例如,要将排序内存大小设置为 100MB,可以发出以下命令:
db.getSiblingDB("admin").
runCommand({ setParameter: 1, internalQueryExecMaxBlockingSortBytes: 1001048576 });
但是要注意,增加这个限制将允许 MongoDB 将那么多的额外数据加载到内存中,从而利用更多的系统资源。如果服务器没有足够的可用内存,查询本身也可能需要更长的时间来执行。这将在第 11 章中进一步讨论。
挑选或创建正确的索引
正如我们在上一章和本章前面所看到的,可能最有效的查询优化工具是索引。当查看一个查询时——至少是一个没有获取全部或大部分集合的查询——我们的第一个问题通常是“我有支持这个查询的正确索引吗?”
正如我们所见,索引可以对查询执行三个级别的优化:
-
索引可以快速定位符合过滤条件的匹配文档。
-
索引可以避免阻塞排序。
-
覆盖索引的可以解析一个查询,而根本不涉及任何集合访问。
因此,任何查询的理想索引应该是
-
包括过滤条件的所有属性
-
然后包括
sort()
标准的属性 -
然后——可选——投影子句中的所有属性
当然,在一个投影中添加所有属性只有在只有几个属性被投影的情况下才是可行的。
Tip
一个完美的查询索引将包含来自过滤条件的所有属性、来自任何排序条件的所有属性,以及(如果可行的话)包含在查询投影中的属性。
如果您有这样一个完美的索引,您将在执行计划中看到一个IXSCAN
后跟PROJECTION_COVERED
。下面是一个包含索引支持排序的完全覆盖查询的示例:
mongo>db.customers.createIndex(
{Country:1,'views.title':1,LastName:1,Phone:1},
{name:'CntTitleLastPhone_ix'});
mongo> var exp = db.customers.
... explain('executionStats').
... find(
... { Country: 'Japan', 'views.title': 'MUSKETEERS WAIT' },
... { Phone: 1, _id: 0 }
... ).
... sort({ LastName: 1 });
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( CntTitleLastPhone_ix ms:0 keys:770)
2 PROJECTION_COVERED ( ms:0)
在下面的例子中,查询中没有指定投影,所以我们不能期望看到PROJECTION_COVERED
。相反,我们有一个FETCH
操作——但是请注意,FETCH
中处理的行数与IXSCAN
中的文档数完全相同——这表明索引检索到了我们需要的所有文档。
mongo> var exp = db.customers.
... explain('executionStats').
... find(
... { Country: 'Japan', 'views.title': 'MUSKETEERS WAIT' }
... ).
... sort({ LastName: 1 });
mongo>
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( CntTitleLastPhone_ix ms:0 keys:770)
2 FETCH ( ms:0 docs:770)
Totals: ms: 3 keys: 770 Docs: 770
Tip
如果在FETCH
步骤中处理的文档数量与在IXSCAN,
步骤中处理的文档数量相同,则索引成功检索到所有需要的文档。
过滤策略
在这一节中,我们将讨论一些特定过滤场景的策略,比如那些涉及“不等于”和范围查询的场景。
不等于条件
有时,您会发布基于$ne
(不等于)条件的过滤条件。最初,您可能会高兴地发现 MongoDB 将使用索引来解决这类查询。例如,在下面的查询中,我们检索除来自“Eric Bass”的电子邮件之外的所有电子邮件:
mongo> var exp = db.enron_messages.
... explain('executionStats').
... find({ 'headers.From': { $ne: 'eric.bass@enron.com' } });
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( headers.From_1 ms:251 keys:481269)
2 FETCH ( ms:4863 docs:481268)
Totals: ms: 6432 keys: 481269 Docs: 481268
MongoDB 可以使用索引来满足不等于条件。如果我们查看原始的执行计划,我们可以看到 MongoDB 是如何使用索引的。indexBounds
部分显示,我们从最低键值扫描到所需的值,然后从该值扫描到索引中的最大键值。
mongo> exp.queryPlanner.winningPlan;
{
"stage": "FETCH",
"inputStage": {
"stage": "IXSCAN",
"keyPattern": {
"headers.From": 1
},
"indexName": "headers.From_1",
. . .
"direction": "forward",
"indexBounds": {
"headers.From": [
"[MinKey, \"eric.bass@enron.com\")",
"(\"eric.bass@enron.com\", MaxKey]"
]
}
}
}
如果不等于条件匹配集合的一小部分,这种“不等于”索引扫描可能是有效的,但如果不匹配,那么我们可能会使用索引来检索集合的大部分。正如我们之前看到的,这可能是非常无效的。事实上,对于我们刚刚检查的查询,我们最好进行集合扫描:
mongo> var exp = db.enron_messages.
... explain('executionStats').
... find({'headers.From': {$ne:'eric.bass@enron.com'}}).
... hint({ $natural: 1 });
mongo> var exp = exp.next();
mongo> mongoTuning.executionStats(exp);
1 COLLSCAN ( ms:9 docs:481908)
Totals: ms: 377 keys: 0 Docs: 481908
图 6-7 比较了不存在索引扫描和集合扫描的性能。请记住,您的结果将取决于不等于值在您的集合中出现的频率。但是,您可能经常会发现,MongoDB 选择了一个索引,而集合扫描是首选。
图 6-7
有时,索引扫描可能比集合扫描差得多
Hint
当心支持索引的$ne
查询。它们解析为多个索引范围扫描,这可能不如集合扫描有效。
范围查询
我们之前看到了如何通过索引范围扫描解决$ne
条件。B 树索引就是为了支持这种扫描而设计的,只要有可能,MongoDB 就会乐意使用这种索引扫描。但是,如果范围覆盖了索引中的大部分数据,这可能不是最佳解决方案。
在下面的例子中,iotData
集合有 1,000,000 个文档,属性“a
”取 0 到 1000 之间的值。即使我们构建了一个查找所有文档的范围查询,MongoDB 也会默认使用一个索引:
mongo> var exp=db.iotData.explain('executionStats').
find({a:{$gt:0}});
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( a_1 ms:83 keys:1000000)
2 FETCH ( ms:193 docs:1000000)
Totals: ms: 2197 keys: 1000000 Docs: 1000000
当扫描如此广泛的范围时,我们最好使用集合扫描:
mongo> var exp=db.iotData.explain('executionStats').
find({a:{$gt:990}}).hint({$natural:1});
mongo> mongoTuning.executionStats(exp);
1 COLLSCAN ( ms:1 docs:1000000)
Totals: ms: 465 keys: 0 Docs: 1000000
但是,如果该范围包含的值较少,则索引是最佳选择:
mongo> var exp=db.iotData.explain('executionStats').
find({a:{$gt:990}});
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( a_1 ms:0 keys:10434)
2 FETCH ( ms:1 docs:10434)
Totals: ms: 23 keys: 10434 Docs: 10434
图 6-8 说明了这些结果。当范围扫描覆盖所有或大部分数据时,集合扫描将比索引扫描快。但是,对于狭窄范围的数据,索引扫描更优越。
图 6-8
索引范围扫描性能
Tip
仅对相对较窄范围的收集数据扫描使用索引。如果集合的大部分正在被访问,请使用集合扫描。
或在运营中
针对单个索引属性的$or
查询将以与$in
查询相同的方式解析。例如,这两个查询实际上是等价的:
db.enron_messages.
find({ 'headers.To': { $in: ['ebass@enron.com',
'eric.bass@enron.com']
} });
db.enron_messages.find({
$or: [
{ 'headers.To': 'ebass@enron.com' },
{ 'headers.To': 'eric.bass@enron.com' }
]
});
然而,当一个$or
条件引用多个属性时,事情就变得更有趣了。如果所有条件都被索引,那么 MongoDB 通常会对每个相关的索引执行索引扫描,然后合并结果:
mongo> var exp=db.enron_messages.explain('executionStats').
find({
... $or: [
... { 'headers.To': 'eric.bass@enron.com' },
... { 'headers.From': 'eric.bass@enron.com' }
... ]
... });
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( headers.From_1 ms:0 keys:640)
2 IXSCAN ( headers.To_1 ms:0 keys:832)
3 OR ( ms:0)
4 FETCH ( ms:0 docs:1472)
5 SUBPLAN ( ms:0)
Totals: ms: 3 keys: 1472 Docs: 1472
MongoDB 从两次索引扫描中检索数据,然后在执行计划的OR
阶段组合它们(消除重复)。
然而,这只有在所有属性都被索引的情况下才有效。如果我们向$or
添加一个未索引的条件,MongoDB 将恢复到集合扫描:
mongo> var exp=db.enron_messages.explain('executionStats').
find({
... $or: [
... { 'headers.To': 'eric.bass@enron.com' },
... { 'headers.From': 'eric.bass@enron.com' },
... {"X-To": "EBASS@ENRON.COM"}
... ]
... });
mongo> mongoTuning.executionStats(exp);
1 COLLSCAN ( ms:69 docs:481908)
2 SUBPLAN ( ms:69)
Totals: ms: 873 keys: 0 Docs: 481908
Tip
要完全优化一个$or
查询,需要索引$or 数组中的所有属性。
$nor
操作符返回不满足任何一个条件的文档,通常不会利用索引。
数组查询
MongoDB 提供了针对数组元素的丰富查询操作,这些操作能够通过索引有效地解析。例如,以下查询查找发给 Jim Schwieger 和 Thomas Martin2的电子邮件:
mongo> var exp = db.enron_messages.explain('executionStats').find({
... 'headers.To': {
... $eq: ['jim.schwieger@enron.com',
'thomas.martin@enron.com']
... }
... });
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( headers.To_1 ms:0 keys:2130)
2 FETCH ( ms:1 docs:2128)
Totals: ms: 10 keys: 2130 Docs: 2128
相同的索引可以支持该查询,该查询查找 Thomas 和 Jim 是收件人的所有电子邮件,包括具有其他收件人的电子邮件:
mongo> var exp = db.enron_messages.
... find({
... 'headers.To': {
... $all: ['jim.schwieger@enron.com',
'thomas.martin@enron.com']
... }
... }).
... explain('executionStats');
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( headers.To_1 ms:0 keys:2128)
2 FETCH ( ms:1 docs:2128)
Totals: ms: 11 keys: 2128 Docs: 2128
同一个索引可以支持 elemMatch∗∗查询。然而,∗∗size 操作符查找具有特定数量元素的数组,并不能从数组的索引中获益:
mongo> var exp = db.enron_messages.
... explain('executionStats').
... find({
... 'headers.To': { $size: 1 }});
mongo> mongoTuning.executionStats(exp);
1 COLLSCAN ( ms:788 docs:481908)
Note
MongoDB 索引可以用来搜索数组的元素。
正则表达式
正则表达式允许我们对字符串执行高级匹配。例如,以下查询使用正则表达式来查找姓氏中包含字符串“HARRIS”的客户:
mongo> var exp=db.customers.explain('executionStats').
find({LastName:/HARRIS/});
mongo>
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( LastName_1 ms:9 keys:410071)
2 FETCH ( ms:12 docs:1365)
Totals: ms: 273 keys: 410071 Docs: 1365
虽然这个查询很有用,但是效率不高。我们实际上扫描了所有 410,000 个索引条目,因为正则表达式理论上可以包含姓氏,如“MACHARRISON”。如果我们实际上要做的是只匹配以 HARRIS 开头的名字(比如 HARRIS 和 HARRISON),那么我们应该使用“^
”正则表达式来表示该字符串要匹配目标的第一个字符。如果我们这样做,那么索引扫描是有效的——只扫描 1366 个索引条目:
mongo> var exp=db.customers.explain('executionStats').
find({LastName:/^HARRIS/});
mongo>
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( LastName_1 ms:0 keys:1366)
2 FETCH ( ms:0 docs:1365)
Totals: ms: 3 keys: 1366 Docs: 1365
Tip
要执行有效的支持索引的正则表达式搜索,请确保正则表达式使用“^”操作符锚定在目标字符串的开头。
正则表达式通常用于执行不区分大小写的搜索。例如,该查询搜索姓氏“Harris ”,而不管它如何拼写。正则表达式中尾随的“I”指定不区分大小写的搜索:
mongo> var e = db.customers.
... explain('executionStats').
... find({ LastName: /^Harris$/i }, {});
mongo> mongoTuning.executionStats(e);
1 IXSCAN ( LastName_1 ms:4 keys:410071)
2 FETCH ( ms:6 docs:635)
Totals: ms: 282 keys: 410071 Docs: 635
正如我们在第 5 章中所解释的,这种不区分大小写的查询只有在所涉及的索引不区分大小写的情况下才是有效的——参见第 5 章中关于不区分大小写的索引的部分了解更多细节。
Tip
为了执行有效的不区分大小写的索引搜索,您必须创建一个不区分大小写的索引,如第 5 章所述。
$exists 查询
使用$exists
操作的查询可以利用索引:
mongo> var exp=db.customers.explain('executionStats').
find({updateFlag: {$exists:true}});
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( updateFlag_1 ms:11 keys:411121)
2 FETCH ( ms:32 docs:411121)
Totals: ms: 525 keys: 411121 Docs: 411121
但是,请注意,这可能是一个特别昂贵的操作,因为 MongoDB 将扫描整个索引,以找到包含该键的所有条目:
"indexBounds": {
"updateFlag": [
"[MinKey, MaxKey]"
]
}
您最好为该列寻找一个特定的值:
mongo> var exp=db.customers.explain('executionStats').
find({updateFlag:true});
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( updateFlag_1 ms:0 keys:1)
2 FETCH ( ms:0 docs:1)
Totals: ms: 0 keys: 1 Docs: 1
或者,您可以考虑创建一个稀疏索引,只对存在值的文档进行索引:
mongo> db.customers.createIndex({updateFlag:1},{sparse:true});
{
"createdCollectionAutomatically": false,
"numIndexesBefore": 1,
"numIndexesAfter": 2,
"ok": 1
}
mongo> var exp=db.customers.explain('executionStats').find({
... updateFlag: {$exists:true}});
mongo>
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( updateFlag_1 ms:0 keys:1)
2 FETCH ( ms:0 docs:1)
Totals: ms: 0 keys: 1 Docs: 1
稀疏索引的缺点是它不能用于查找属性不存在的文档:
mongo> var exp=db.customers.explain('executionStats').
find({updateFlag: {$exists:false}});
mongo> mongoTuning.executionStats(exp);
1 COLLSCAN ( ms:10 docs:411121)
Totals: ms: 295 keys: 0 Docs: 411121
Tip
可以通过相关属性的稀疏索引来优化$exists:true
查找。然而,这样的索引不能优化一个$exists:false
查询。
优化集合扫描
我们在 MongoDB 查询调优中对索引的强调倾向于扭曲我们的思维——我们有陷入思维陷阱的风险,即执行查询的唯一好方法是通过索引查找。
然而,在这一章中,我们已经看到了许多例子,在这些例子中,索引访问不如集合扫描有效。因此,如果集合扫描不可避免,有没有优化这些扫描的选项?
答案是肯定的!如果您发现您有一个不可避免的集合扫描,并且您需要提高扫描的性能,那么主要的技术是使集合变得更小。
减小集合大小的一种方法是将大型的、不常访问的元素移到另一个集合中。我们在第 4 章的中看到了这种垂直分区技术。
对集合进行分片可以通过允许多个集群协作进行扫描来提高集合扫描的性能。我们将在第 14 章讨论分片性能的各个方面。
随着时间的推移,经过大量更新和删除的集合也可能变得臃肿。MongoDB 将尝试重用当文档被删除或大小收缩时创建的空空间,但它不会释放分配回磁盘的空间,并且您的集合可能比它需要的要大。一般来说,WiredTiger 可以有效地重用空间,但是在某些极端情况下,您可以考虑运行 compact 命令来恢复浪费的空间。
请注意,compact 命令会阻止对包含相关集合的数据库的操作,因此您只能在停机时间内发出 compact 命令。
摘要
在这一章中,我们已经了解了如何优化涉及到find()
命令的 MongoDB 查询,这是 MongoDB 数据访问的主力。
避免数据访问开销的最佳方式是避免不必要的数据访问——我们讨论了如何在客户端缓存数据来实现这一点。
可以通过使用投影、利用批处理以及在代码中避免不必要的网络往返来减少网络开销。
索引在查询优化中非常有效,但主要是在检索集合数据的子集时。我们研究了如何使用提示来强制 MongoDB 使用您选择的索引或执行集合扫描。
索引可用于优化排序操作,尤其是当您试图优化排序中的第一个文档时。如果您试图优化整个排序结果集,则可能需要进行集合扫描。
集合扫描的性能最终取决于集合的大小,如果集合扫描不可避免,我们研究了一些缩小集合的策略。
七、优化聚合管道
当开始使用 MongoDB 时,大多数开发者将从他们熟悉的来自其他数据库的基本 CRUD 操作(创建-读取-更新-删除)开始。insert
、find
、update
和delete
操作确实将构成大多数应用的主干。然而,在几乎所有的应用中,复杂的数据检索和操作需求将会存在,这超出了基本 MongoDB 命令的能力范围。
MongoDB find()
命令功能多样且易于使用,但是聚合框架允许您将其提升到下一个级别。聚合管道可以做任何find()
操作可以做的事情,甚至更多。正如 MongoDB 自己喜欢在博客、营销材料甚至 t 恤上说的那样:聚合是新发现。
聚合管道通过减少可能需要多次查找操作和复杂数据操作的逻辑来简化应用代码。如果利用得当,单个聚合可以取代许多查询及其相关的网络往返时间。
您可能还记得前面的章节,调优应用的一个重要部分是确保尽可能多的工作发生在数据库上。聚合允许您将通常位于应用中的数据转换逻辑移动到数据库中。因此,经过适当调整的聚合管道可以大大超越替代解决方案。
然而,尽管使用聚合带来了诸多好处,但它也带来了一系列新的调优挑战。在本章中,我们将确保您掌握利用和调整聚合管道所需的所有知识。
优化聚合管道
为了有效地优化聚合管道,我们必须首先能够有效地确定哪些聚合需要优化,以及哪些方面可以改进。与find()
查询一样,explain()
命令是我们最好的朋友。您可能还记得前面的章节,为了检查查询的执行计划,我们在集合名称后添加了.explain()
方法。例如,为了解释一个find()
,我们可以使用下面的命令:
db.customers.
explain().
find(
{ Country: 'Japan', LastName: 'Smith' },
{ _id: 0, FirstName: 1, LastName: 1 }
).
sort({ FirstName: -1 }).
limit(3);
我们可以用同样的方式来解释聚合管道:
db.customers.explain().aggregate([
{ $match: {
Country: 'Japan',
LastName: 'Smith',
} },
{ $project: {
_id: 0,
FirstName: 1,
LastName: 1,
} },
{ $sort: {
FirstName: -1,
} },
{ $limit: 3 } ] );
然而,来自find()
命令的执行计划和来自aggregate()
命令的执行计划有很大的不同。
当针对标准的 find 命令运行explain()
时,我们可以通过查看queryPlanner.winningPlan
对象来查看关于执行的信息。
聚合管道的explain()
输出是相似的,但也有很大的不同。首先,我们以前使用的queryPlanner
对象现在驻留在一个新对象中,这个新对象驻留在一个名为stages
的数组中。stages
数组包含作为单独对象的每个聚合阶段。例如,我们前面看到的聚合将具有以下简化的解释输出:
{
"stages": [
{"$cursor": {
"queryPlanner": {
// . . .
"winningPlan": {
"stage": "PROJECTION_SIMPLE",
// . . .
"inputStage": {
"stage": "FETCH",
// . . .
"inputStage": {
"stage": "IXSCAN",
. . .
} } },
"rejectedPlans": []
} } },
{ "$sort": {
"sortKey": {
"FirstName": -1
},
"limit": 3
} } ],
. . .
}
在聚合管道的执行计划中,queryPlanner
阶段揭示了将数据引入管道所需的初始数据访问操作。这通常代表支持初始$match
操作的操作,或者——如果没有指定$match
条件——从集合中检索所有数据的集合扫描。
stages
数组显示了聚合管道中每个后续步骤的信息。请注意,MongoDB 可以在执行期间合并和重新排序聚合阶段,因此这些阶段可能与原始管道定义中的阶段不匹配——下一节将详细介绍。
我们已经编写了一个助手脚本来简化优化脚本包中聚合执行计划的解释。 1 方法mongoTuning.aggregationExecutionStats()
将提供每一步所用时间的顶级汇总。这里有一个使用aggregationExecutionSteps
的例子:
mongo> var exp = db.customers.explain('executionStats').aggregate([
... { $match:{
... "Country":{ $eq:"Japan" }}
... },
... { $group:{ _id:{ "City":"$City" },
... "count":{$sum:1} }
... },
... { $sort:{ "_id.City":-1 }},
... { $limit: 10 },
... ] );
mongo> mongoTuning.aggregationExecutionStats(exp);
1 IXSCAN ( Country_1_LastName_1 ms:0 keys:21368 nReturned:21368)
2 FETCH ( ms:13 docsExamined:21368 nReturned:21368)
3 PROJECTION_SIMPLE ( ms:15 nReturned:21368)
4 $GROUP ( ms:70 returned:31)
5 $SORT ( ms:70 returned:10)
Totals: ms: 72 keys: 21368 Docs: 21368
优化聚合排序
聚合是由一系列阶段构成的,由一组文档表示,这些文档按照从第一个到最后一个的顺序执行。每个阶段的输出被传递到下一个阶段进行处理,初始输入是整个集合。
这些阶段的顺序性质是聚合被称为管道的原因:数据通过管道流动,在每个阶段被过滤和转换,直到最终退出管道。优化这些管道最简单的方法是尽早减少数据量;这将减少每个后续步骤所做的工作量。从逻辑上讲,聚合中执行最多工作的阶段应该对尽可能少的数据进行操作,并在早期阶段执行尽可能多的筛选。
Tip
构建聚合管道时,早过滤,勤过滤!越早从管道中移除数据,MongoDB 的总体数据处理负载就越低。
MongoDB 将自动对管道中的操作顺序进行重新排序,以优化性能——我们将在下一节看到一些优化的示例。但是,对于复杂的管道,您可能需要自己设置顺序。
自动重新排序不可能的一种情况是使用$lookup
进行聚合。$lookup
阶段允许您加入两个系列。如果您要连接两个集合,您可以选择在连接之前或之后进行过滤,在这种情况下,在连接操作之前尝试减少数据的大小是非常重要的,因为对于传递给查找操作的每个文档,MongoDB 必须尝试在单独的集合中找到匹配的文档。我们可以在 lookout 之前过滤掉的每个文档都将减少需要进行的查找次数。这是一个明显但关键的优化。
让我们看一个生成“前 5 名”产品购买列表的聚合示例:
db.lineitems.aggregate([
{ $group:{ _id:{ "orderId":"$orderId" ,"prodId":"$prodId" },
"itemCount-sum":{$sum:"$itemCount"} } },
{ $lookup:
{ from: "orders", localField:"_id.orderId",
foreignField: "_id", as:"orders"
} },
{ $lookup:
{ from: "customers", localField:"orders.customerId",
foreignField: "_id", as:"customers"
} },
{ $lookup:
{ from: "products", localField:"_id.prodId",
foreignField: "_id", as:"products"
} },
{ $sort:{ "count":-1 }},
{ $limit: 5 },
],{allowDiskUse: true});
这是一个相当大的聚合管道。实际上,如果没有allowDiskUse:true
标志,就会产生内存不足错误;我们将在本章的后面解释为什么会出现这个错误。
注意,我们在之前加入了orders
、customers,
和products
、??,对结果进行排序并限制输出。因此,我们必须为每个行项目执行所有三个连接查找。我们可以——也应该——在$group
操作之后直接定位$sort
和$limit
:
db.lineitems.aggregate([
{ $group:{ _id:{ "orderId":"$orderId" ,"prodId":"$prodId" },
"itemCount-sum":{$sum:"$itemCount"} } },
{ $sort:{ "count":-1 }},
{ $limit: 5 },
{ $lookup:
{ from: "orders", localField:"_id.orderId",
foreignField: "_id", as:"orders"
} },
{ $lookup:
{ from: "customers", localField:"orders.customerId",
foreignField: "_id", as:"customers"
} },
{ $lookup:
{ from: "products", localField:"_id.prodId",
foreignField: "_id", as:"products"
} }
],{allowDiskUse: true});
性能上的差异是惊人的。通过将$sort
和$limit
提前几行,我们已经创建了一个更加高效和可伸缩的解决方案。图 7-1 展示了通过在管道中提前移动$limit
获得的性能提升。
图 7-1
在$lookup
管道中提前移动限制子句的效果
Tip
注意对聚合管道进行排序,以尽早而不是推迟删除文档。越早从管道中消除数据,后面管道中需要的工作就越少。
自动流水线优化
MongoDB 将对聚合管道进行一些优化,以提高性能。具体的优化因版本而异,当通过驱动程序或 MongoDB shell 运行聚合时,没有明显的迹象表明优化已经发生。事实上,唯一确定的方法是使用explain()
检查查询计划。如果您惊讶地发现您的聚合解释与您刚刚发送到 MongoDB 的内容不匹配,不要惊慌。这是优化器的工作。
让我们运行一些聚合,观察 MongoDB 如何决定使用explain()
来改进管道。这是一个非常糟糕的聚合管道:
> var explain = db.listingsAndReviews.explain("executionStats").
aggregate([
{$match: {"property_type" : "House"}},
{$match: {"bedrooms" : 3}},
{$limit: 100},
{$limit: 5},
{$skip: 3},
{$skip: 2}
]);
你大概能猜到这里会发生什么。多个$match
、$limit
和$skip
阶段,当一个接一个地放置时,可以合并成单个阶段而不改变结果。使用$and
可以合并两个$match
阶段。两个$limit
阶段的结果总是较小的极限值,两个$skips
的效果是$skip
值之和。尽管来自管道的结果没有改变,但是我们可以观察到查询计划中的优化效果。下面是从我们的mongoTuning.aggregationExecutionStats
命令输出的合并阶段的简化视图:
1 COLLSCAN ( ms:0 docsExamined:525 nReturned:5)
2 LIMIT ( ms:0 nReturned:5)
3 $SKIP ( ms:0 returned:0)
Totals: ms: 1 keys: 0 Docs: 525
如您所见,MongoDB 将我们管道中的六个步骤合并成了三个操作。
MongoDB 还可以代表您执行其他一些智能合并。如果您有一个$lookup
阶段,在那里您立即$unwind
连接的文档,MongoDB 会将$unwind
合并到$lookup
中。例如,此聚合将用户与其博客评论结合在一起:
> var explain = db.users.explain("executionStats").aggregate([
{ $lookup: {
from: "comments",
as: "comments",
localField: "email",
foreignField: "email"
}},
{ $unwind: "$comments"}
]);
$lookup
和$unwind
将成为执行中的单个阶段,这将消除创建大型连接文档,这些文档将立即展开为较小的文档。执行计划将类似于下面的代码片段:
> mongoTuning.aggregationExecutionStats(explain);
1 COLLSCAN ( ms:9 docsExamined:183 nReturned:183)
2 $LOOKUP ( ms:4470 returned:50146)
Totals: ms: 4479 keys: 0 Docs: 183
类似地,$sort
和$limit
阶段将被合并,允许$sort
只维护有限数量的文档,而不是它的全部输入。下面是这种优化的一个例子。该查询
> var explain = db.users.explain("executionStats").
aggregate([
{ $sort: {year: -1}},
{ $limit: 1}
]);
> mongoTuning.aggregationExecutionStats(explain);
将在解释输出中产生一个阶段:
1 COLLSCAN ( ms:0 docsExamined:183 nReturned:183)
2 SORT ( ms:0 nReturned:1)
Totals: ms: 0 keys: 0 Docs: 183
还有另一个重要的优化,它不涉及合并或移动管道中的阶段。如果您的聚合只需要文档属性的子集,MongoDB 可能会添加一个投影来删除所有未使用的字段。这减少了通过管道的数据集的大小。例如,以下聚合实际上只使用了两个字段-国家和城市:
mongo> var exp = db.customers.
... explain('executionStats').
... aggregate([
... { $match: { Country: 'Japan' } },
... { $group: { _id: { City: '$City' } } }
... ]);
MongoDB 在执行计划中插入一个投影,以消除所有不需要的属性:
mongo> mongoTuning.aggregationExecutionStats(exp);
1 IXSCAN ( Country_1_LastName_1 ms:4 keys:21368 nReturned:21368)
2 FETCH ( ms:12 docsExamined:21368 nReturned:21368)
3 PROJECTION_SIMPLE ( ms:12 nReturned:21368)
4 $GROUP ( ms:61 returned:31)
Totals: ms: 68 keys: 21368 Docs: 21368
因此,我们现在知道 MongoDB 将有效地添加和合并阶段,以改善您的管道。在某些情况下,优化器会对您的阶段重新排序。其中最重要的是$match
操作的重新排序。
如果一个管道在一个将把新字段投射到文档中的阶段(例如$group
、$project
、$unset
、$addFields
或$set
)之后包含一个$match
,并且如果$match
阶段不需要投射的字段,MongoDB 将把那个$match
阶段移到管道的前面。这减少了后期必须处理的文档数量。
例如,考虑以下聚合:
var exp=db.customers.explain("executionStats").aggregate([
{ '$group': {
'_id': '$Country',
'numCustomers': {
'$sum': 1
} } },
{ '$match': {
'$or': [
{ '_id': 'Netherlands' },
{ '_id': 'Sudan' },
{ '_id': 'Argentina' } ] } }
]);
在 MongoDB 4.0 之前,MongoDB 将执行管道中指定的确切步骤——执行一个$group
操作,然后使用$match
来排除指定国家以外的国家。这是一种浪费,特别是因为我们有一个国家索引,可以用来快速找到所需的文件。
然而,在 MongoDB 的现代版本中,$match
操作将被重新定位在$group
操作之前,减少了需要分组的文档数量,并允许使用索引。以下是生成的执行计划:
mongo> mongoTuning.aggregationExecutionStats(exp);
1 IXSCAN ( Country_1_LastName_1 ms:1 keys:13720 nReturned:13717)
2 PROJECTION_COVERED ( ms:1 nReturned:13717)
3 SUBPLAN ( ms:1 nReturned:13717)
4 $GROUP ( ms:20 returned:3)
MongoDB 自动优化是最近 MongoDB 版本中的无名英雄之一,它提高了性能,而不需要您做任何工作。了解这些优化是如何工作的,将使您能够在创建聚合时做出正确的决策,并了解执行计划中的异常情况
有关任何给定 MongoDB 版本的优化中会发生什么的更多信息,请参考位于 http://bit.ly/MongoAggregatePerf
的官方文档。
优化多集合联接
聚合框架提供的真正重要的功能之一是合并来自多个集合的数据的能力。最重要和最成熟的功能是在$lookup
操作符中,它允许两个集合之间的连接。
在第 4 章中,我们试验了一些可选的模式设计,其中一些经常需要连接来组装信息。例如,我们创建了一个模式,其中客户和订单保存在不同的集合中。在这种情况下,我们将使用$lookup
来连接客户数据和订单数据,如下所示:
db.customers.aggregate([
{ $lookup:
{ from: "orders",
localField: "_id",
foreignField: "customerId",
as: "orders"
}
},
]);
该语句在每个客户文档中嵌入了一组订单。客户文档中的_id
属性与orders
集合中的customerId
属性相匹配。
使用$lookup
构造一个连接并不太困难,但是有一些关于连接性能的潜在问题。因为对源数据中的每个文档执行一次$lookup
函数,所以$lookup
必须快速。实际上,这意味着$lookup
应该由一个索引来支持。在前面的例子中,我们需要确保在orders
集合中的customerId
属性上有一个索引。
不幸的是,explain()
命令不能帮助我们确定连接是否有效或者是否使用了索引。例如,下面是前面操作的解释输出(使用mongoTuning.aggregationExecutionStats
):
1 COLLSCAN ( ms:10 docsExamined:411121 nReturned:411121)
2 $LOOKUP ( ms:5475 returned:411121)
explain 输出告诉我们,我们使用了集合扫描来执行客户的初始检索,但是没有显示我们是否在$lookup
阶段使用了索引。
但是,如果您没有一个支持索引,您几乎肯定会注意到由此导致的性能下降。图 7-2 显示了当越来越多的文档参与到一个连接中时,性能是如何下降的。有了索引,连接性能就变得高效且可预测。如果没有索引,随着更多的文档添加到连接中,连接性能会急剧下降。
图 7-2
$lookup
绩效-索引化与非索引化
Tip
总是在一个$lookup
中的foreignField
属性上创建一个索引,除非集合很小。
加入订单
当加入集合时,我们有时可以选择加入的顺序。例如,该查询将来自客户的连接到订单:
db.customers.aggregate([
{ $lookup:
{ from: "orders",
localField: "_id",
foreignField: "customerId",
as: "orders"
}
},
{ $unwind: "$orders" },
{ $count: "count" },
]);
以下查询返回相同的数据,但是将来自订单的连接到客户:
db.orders.aggregate([
{ $lookup:
{ from: "customers",
localField: "customerId",
foreignField: "_id",
as: "customer"
}
},
{ $count: "count" },
] );
这两个查询具有非常不同的性能特征。尽管每个查询中都有支持$lookup
操作的索引,但是从订单到客户的连接会导致更多的$lookup
调用——仅仅因为订单比客户多。因此,从订单到客户的连接比反过来需要更长的时间。图 7-3 显示了相对性能。
图 7-3
加入顺序和$lookup
性能
决定连接顺序时,请遵循以下准则:
-
在连接之前,您应该尽可能地减少要连接的数据量。因此,如果要过滤其中一个集合,该集合应该在连接顺序中排在第一位。
-
如果您只有一个索引来支持两个连接顺序中的一个,那么您应该使用具有支持索引的连接顺序。
-
如果两个连接顺序都满足前面的两个条件,那么您应该尝试从最小的集合连接到最大的集合。
提示在其他条件相同的情况下,从小集合加入到大集合,而不是从大集合加入到小集合。
优化图形查找
Neo4J 等图形数据库专门遍历关系图——就像你可能在社交网络中找到的那些关系图。许多非图形数据库已经整合了图形计算引擎来执行类似的任务。使用旧版本的 MongoDB,您可能不得不通过网络获取大量的图形数据,并在应用级别上运行一些计算。这个过程将会是缓慢而繁琐的。幸运的是,从 MongoDB 3.4 开始,我们可以使用$graphLookup
聚合框架阶段执行简单的图遍历。
假设您在 MongoDB 中存储了代表社交网络的数据。在这个网络中,单个用户作为朋友连接到大量其他用户。这类网络是图形数据库的常见用途。让我们用下面的样本数据来看一个例子:
db.getSiblingDB("GraphTest").socialGraph.findOne();
{
"_id" : ObjectId("5a739cda0c31c5f5afcff87f"),
"person" : 561596,
"name" : "User# 561596",
"friends" : [
94230,
224410,
387968,
406744,
707890,
965522,
1189677,
1208173
]
}
使用带有$graphLookup
阶段的聚合管道,我们可以为个人用户扩展我们的社交网络。下面是一个管道示例:
db.socialGraph.aggregate([
{$match:{person:1476767}},
{$graphLookup: {
from: "socialGraph",
startWith: [1476767],
connectFromField: "friends",
connectToField: "person",
maxDepth: 2,
depthField: "Depth",
as: "GraphOutput"
}
},{$unwind:"$GraphOutput"}
], {allowDiskUse: true});
我们在这里做的是从 person 1476767
开始,然后沿着 friends 数组的元素到两个层次,本质上是寻找“朋友的朋友”
增加maxDepth
字段的值会成倍增加我们必须处理的数据量。你可以认为每一层深度都需要某种自我连接到集合中。对于初始数据集中的每个文档,我们读取集合来寻找朋友;然后,对于数据集中的每个文档,读取集合以找到那些朋友;等等。一旦达到maxDepth
个连接,我们就停止。
很明显,如果每个自连接都需要一次集合扫描,那么随着网络深度的增加,性能将会迅速下降。因此,在遍历连接时,确保有一个索引可供 MongoDB 使用是很重要的。该索引应该在connectToField
属性上。
图 7-4 显示了有和没有步进的$graphLookup
操作的性能。如果没有索引,随着操作深度的增加,性能会迅速下降。有了索引,图形查找的可伸缩性和效率都大大提高了。
图 7-4
$graphLookup
带或不带索引的性能
Tip
当执行$graphLookup
操作时,确保在connectToField
属性上有一个索引。
聚合内存利用率
在 MongoDB 中执行聚合时,有两个重要的限制需要记住,这两个限制适用于所有聚合,不管管道是从哪个阶段构建的。除此之外,在调优您的应用时,还需要考虑一些特定的限制。您必须牢记的两个限制是文档大小限制和内存使用限制。
在 MongoDB 中,单个文档的大小限制是 16MB。对于聚合也是如此。执行聚合时,如果任何输出文档超过此限制,将会引发错误。当执行简单的聚合时,这可能不是问题。但是,在对多个集合中的文档进行分组、操作、展开和连接时,您必须考虑输出文档不断增长的大小。这里一个重要的区别是,这个限制只适用于结果中的文档。例如,如果一个文档在管道中超过了这个限制,但是在结束之前又减少到这个限制以下,那么就不会抛出错误。此外,MongoDB 在内部结合了一些操作来避免限制。例如,如果一个$lookup
返回一个大于限制的数组,但是$lookup
后面紧跟着一个$unwind
,那么就不会出现文档大小错误。
要记住的第二个限制是内存使用限制。在聚合管道的每个阶段,默认情况下都有 100MB 的内存限制。如果超过这个限制,MongoDB 将产生一个错误。
MongoDB 确实提供了一种在聚合期间绕过这个限制的方法。allowDiskUse
选项可用于取消几乎所有阶段的限制。正如您可能已经猜到的,当设置为 true 时,这允许 MongoDB 在磁盘上创建一个临时文件来保存聚合时的一些数据,绕过内存限制。在前面的一些例子中,您可能已经注意到了这一点。以下是在我们之前的一个聚合中将此限制设置为 true 的示例:
db.customers.aggregate([
{ '$group': {
'_id': '$Country',
'numCustomers': {
'$sum': 1
} } },
{ '$match': {
'$or': [
{ '_id': 'Netherlands' },
{ '_id': 'Sudan' },
{ '_id': 'Argentina' } ] } }
],{allowDiskUse:true});
正如我们所说的,allowDiskUse
选项将绕过几乎所有阶段的限制。不幸的是,即使allowDiskUse
设置为真,仍然有几个阶段被限制在 100MB。两个累加器$addToSet
和$push
不会溢出到磁盘,因为如果不进行适当的优化,这些累加器会将大量数据添加到下一阶段。
对于这三个有限的阶段,目前没有明显的解决办法,这意味着您必须优化查询和管道本身,以确保您不会遇到这个限制并从 MongoDB 收到错误。
为了避免触及这些内存限制,您应该考虑实际需要获取多少数据。问问自己是否使用了查询返回的所有字段,数据是否可以更简洁地表示?从中间文档中删除不必要的属性是减少内存使用的一种简单而有效的方法。
如果所有这些都失败了,或者如果您想避免数据溢出到磁盘时的性能下降,您可以尝试增加这些操作的内部内存限制。这些内存限制由“internal*Bytes
”形式的未记录参数控制。其中最重要的三个是
-
internalQueryMaxBlockingSortMemoryUsageBytes
:a$sort
可用的最大内存(详见下一节) -
internalLookupStageIntermediateDocumentMaxSizeBytes
:一个$lookup
操作可用的最大内存 -
internalDocumentSourceGroupMaxMemoryBytes
:一个$group
操作可用的最大内存
您可以使用setParameter
命令调整这些参数。例如,要增加排序内存,您可以发出以下命令:
db.getSiblingDB("admin").
runCommand({ setParameter: 1,
internalQueryMaxBlockingSortMemoryUsageBytes: 1048576000 });
我们将在下一节的排序优化中进一步讨论这一点。但是,在调整内存限制时要非常小心,因为如果超过了服务器的内存容量,可能会损害 MongoDB 集群的整体性能。
聚合管道中的排序
我们在第 6 章中看到了在find()
操作中优化排序。聚合管道中的排序在几个重要方面不同于排序:
-
通过执行“磁盘排序”,聚合可以超过阻塞排序的内存限制在磁盘排序中,在排序操作过程中,多余的数据被写入磁盘或从磁盘中取出。
-
聚合可能无法利用索引排序选项,除非排序在管道中处于非常早期的位置。
索引聚合排序
与find()
类似,聚合能够使用索引来解析排序,从而避免高内存利用率或磁盘排序。然而,这通常只有在$sort
足够早地出现在流水线中以滚动到初始数据访问操作时才会发生。
例如,考虑这个操作,其中我们对一些数据进行排序并添加一个字段:
mongo> var exp=db.baseCollection.explain('executionStats').
... aggregate([
... { $sort:{ d:1 }},
... {$addFields:{x:0}}
... ],{allowDiskUse: true});
mongo> mongoTuning.aggregationExecutionStats(exp);
1 IXSCAN ( d_1 ms:97 keys:1000000 nReturned:1000000)
2 FETCH ( ms:500 docsExamined:1000000 nReturned:1000000)
3 $ADDFIELDS ( ms:3316 returned:1000000)
Totals: ms: 3358 keys: 1000000 Docs: 1000000
排序后的属性有一个索引,我们可以使用这个索引来优化排序。但是,如果我们在排序之前移动$addFields
操作,那么聚集就不能利用索引,并且会发生代价高昂的“磁盘排序”:
mongo> var exp=db.baseCollection.explain('executionStats').
... aggregate([
... {$addFields:{x:0}},
... { $sort:{ d:1 }},
... ],{allowDiskUse: true});
mongo> mongoTuning.aggregationExecutionStats(exp);
1 COLLSCAN ( ms:16 docsExamined:1000000 nReturned:1000000)
2 $ADDFIELDS ( ms:1164 returned:1000000)
3 $SORT ( ms:12125 returned:1000000)
Totals: ms: 12498 keys: 0 Docs: 1000000
图 7-5 比较了两种聚合的性能。通过将排序移到聚合管道的开始,避免了成本高昂的磁盘排序,并显著减少了运行时间。
图 7-5
聚合管道中的磁盘排序与索引排序
Tip
在聚合管道中尽可能早地移动具有支持索引的排序,以避免昂贵的磁盘排序。
磁盘排序
如果没有支持排序的索引,并且排序超过了 100MB 的限制,那么您将收到一个QueryExceededMemoryLimitNoDiskUseAllowed
错误:
mongo>var exp=db.baseCollection.
... aggregate([
... { $sort:{ d:1 }},
... {$addFields:{x:0}}
... ],{allowDiskUse: false});
2020-08-22T15:36:01.890+1000 E QUERY [js] uncaught exception: Error: command failed: {
"operationTime" : Timestamp(1598074560, 3),
"ok" : 0,
"errmsg" : "Error in $cursor stage :: caused by :: Sort exceeded memory limit of 104857600 bytes, but did not opt in to external sorting.",
"code" : 292,
"codeName" : "QueryExceededMemoryLimitNoDiskUseAllowed",
如果有可能使用索引来支持这种排序,如前一节所述,那么这通常是最好的解决方案。然而,在复杂的聚合管道中,这并不总是可能的,因为要排序的数据可能是先前管道阶段的结果。在这种情况下,我们有两个选择:
-
通过指定
allowDiskUse:true
使用“磁盘排序”。 -
通过更改
internalQueryMaxBlockingSortMemoryUsageBytes
参数增加阻塞排序的全局限制。
更改 MongoDB 默认内存参数应该非常小心,因为存在导致服务器内存不足的风险,这会使全局性能更差。然而,在当今世界,100MB 并不是很大的内存,因此增加该参数可能是最好的选择。这里,我们将最大排序内存增加到 1GB:
mongo>db.getSiblingDB("admin").
... runCommand({ setParameter: 1,
internalQueryMaxBlockingSortMemoryUsageBytes: 1048576000 });
{
"was" : 104857600,
"ok" : 1,
…
图 7-6 显示了当我们增加internalQueryMaxBlockingSortMemoryUsageBytes
来避免磁盘排序时,示例查询的性能是如何提高的。
图 7-6
聚合中的磁盘排序与内存排序
使用磁盘排序要考虑的另一个问题是可伸缩性。如果您设置了diskUsage:true
,那么您可以放心,即使没有足够的内存来完成排序,您的查询也会运行。但是,当查询从内存排序切换到磁盘排序时,性能会突然下降。在生产环境中,您的应用可能会突然“碰壁”
图 7-7 显示了当有足够的内存支持排序时,与相对线性的趋势相比,切换到磁盘排序如何导致执行时间的突然增加。
图 7-7
聚合中的磁盘排序如何影响可伸缩性
Tip
聚合管道中的磁盘排序既昂贵又缓慢。如果希望提高大型聚合排序的性能,您可能希望增加聚合排序的默认内存限制。
优化视图
如果您以前使用过 SQL 数据库,您可能对视图的概念很熟悉。在 MongoDB 中,视图是一种包含聚合管道结果的合成集合。从查询的角度来看,视图看起来和感觉上就像一个普通的集合,只是它们是只读的。
创建视图的主要优点是通过在数据库中存储复杂的管道定义来简化和统一应用逻辑。
就性能而言,重要的是理解当创建一个视图时,结果不会存储在内存中或复制到一个新的集合中。查询视图时,您仍然在查询原始集合。MongoDB 将获取为视图定义的聚合管道,然后附加您的附加查询参数,创建一个新管道。这看起来像是在查询视图,但实际上,复杂的聚合管道仍然在发布。
因此,与执行定义视图的管道相比,创建视图不会给您带来性能优势。
因为一个视图本质上只是一个集合的集合,所以我们优化视图的方法与任何集合都是一样的。如果您的视图性能不佳,请使用本章前面介绍的技术优化定义视图的管道。
针对视图编写查询时,请记住,针对视图执行查询时,通常不能利用基础集合上的索引。例如,考虑这个视图,它按订单计数汇总产品代码:
db.createView('productTotals', 'lineitems', [
{ $group: {
_id: { prodId: '$prodId' },
'itemCount-sum': { $sum: '$itemCount' }
}
},
{ $project: {
ProdId: '$_id.prodId',
OrderCount: '$itemCount-sum',
_id: 0
}
}]);
我们可以使用此视图查找特定产品代码的总数:
mongo> db.productTotals.find({ ProdId: 83 });
{
"ProdId": 83,
"OrderCount": 460051
}
然而,即使在lineItems
集合中的prodId
上有一个索引,从视图中查询时也不会使用该索引。即使我们只要求一个产品代码,MongoDB 也会在返回结果之前聚合所有产品的数据。
虽然这要繁琐得多,但是这个聚合管道将使用ProdId
上的索引,因此返回数据的速度会快得多:
db.lineitems.aggregate(
[ { $match: { prodId: 83 }},
{ $group: {
_id: { prodId: '$prodId' },
'itemCount-sum': { $sum: '$itemCount' } }
},
{ $project: {
ProdId: '$_id.prodId',
OrderCount: '$itemCount-sum',
_id: 0
}
}
] );
Tip
在解析查询时,视图不能总是利用基础集合上的索引。如果从视图中查询在基础集合中编入索引的属性,绕过视图直接查询基础集合可能会获得更好的性能。
物化视图
正如我们已经讨论过的,MongoDB 视图不会提高查询性能,在某些情况下,可能会因为抑制索引而损害性能。即使视图只包含几个文档,查询仍然需要很长时间,因为每次查询视图时都需要重新构建数据。
物化视图在这里提供了一个解决方案——特别是当一个视图从大量的源集合中返回少量的聚合信息时。实体化视图是一个集合,它包含由视图定义返回的文档,但是将视图结果存储在数据库中,以便不必在每次读取数据时都执行视图。
在 MongoDB 中,我们可以使用$merge
或$out
聚合操作符来创建一个物化视图。$out
用聚合的结果完全替换目标集合。$merge
提供了一种对现有集合的“向上插入”,允许对目标进行增量更改。我们会在第八章和中更多地关注$merge
。
要创建一个物化视图,我们只需运行一个通常用于定义视图的聚合管道,但是,作为聚合的最后一步,我们使用$merge
将结果文档输出到一个集合中。通过运行这个聚合管道,我们可以创建一个新的集合,在执行时反映另一个集合中的数据聚合。然而,与视图不同的是,这个集合可能要小得多,从而可以提高性能。
让我们来看一个例子。下面是一个复杂的管道,它按产品和城市创建了一个销售汇总:
db.customers.aggregate([
{ $lookup:
{ from: "orders",
localField: "_id",
foreignField: "customerId",
as: "orders" } },
{ $unwind: "$orders" },
{ $lookup:
{ from: "lineitems",
localField: "orders._id",
foreignField: "orderId",
as: "lineItems" } },
{ $unwind: "$lineItems" },
{ $group:{ _id:{ "City":"$City" ,
"lineItems_prodId":"$lineItems.prodId" },
"count":{$sum:1},
"lineItems_itemCount-sum":{$sum:"$lineItems.itemCount"} } },
{ $project: {
"CityName": "$_id.City" ,
"ProductId": "$_id.lineItems_prodId" ,
"OrderCount": "$lineItems_itemCount-sum" ,
"_id": 0
} } ] );
如果我们将下面的$merge
操作添加到管道中,那么我们将创建一个集合salesByCityMV
,它包含聚合的输出: 2
{$merge:
{ into:"salesByCityMV"}}
图 7-8 显示了物化视图查询与普通视图查询的执行时间对比。如您所见,物化视图的性能要优越得多。这是因为在发送最终的 find 查询时,大部分工作已经完成。
图 7-8
物化视图与直接视图
这种方法有一个明显的缺点:当原始集合中的第二个数据发生变化时,物化视图就会过时。应用或数据库管理员有责任确保物化视图以有意义的时间间隔刷新。例如,实体化视图可能包含前一天的 access 销售记录。聚合可以在每晚午夜运行,确保每天的数据都是正确的。
Tip
对于查询速度比绝对时间点准确性更重要的复杂聚合,物化视图提供了一种强大的方法来提供对聚合输出的快速访问。
创建实体化视图时,请确保视图的刷新不会比该视图上的查询运行得更频繁。数据库仍然需要使用资源来创建视图,所以没有理由每小时刷新一次物化视图,因为物化视图一天只查询一次。
如果源表很少更新,可以安排在检测到更新时自动刷新实体化视图。MongoDB 变更流工具允许您监听集合中的变更。当收到更改通知时,您可以触发实体化视图的重建。
我们将在第 8 章中看到$merge
操作符的更多用法。
摘要
MongoDB 创建了一个非常强大的方法来用聚合框架构造复杂的查询。多年来,他们扩展了这个框架,以支持更广泛的用例,甚至负责一些以前可能发生在应用级别的数据转换。如果从过去可以看出,aggregate
命令将随着时间的推移而增长,以适应越来越复杂的功能。记住所有这些,如果您希望创建一个高级的高性能 MongoDB 应用,您应该利用aggregate
所提供的一切。
但是,随着聚合管道的强大功能,确保管道得到优化的责任也随之而来。在本章中,我们概述了在创建聚合时需要牢记的一些关键性能问题。
过滤和阶段排序将允许您最大限度地减少流经管道的数据。索引$lookup
和$graphLookup
的相关字段将确保快速检索相关文件。您还需要确保在获取大型结果时使用allowDiskUse
选项,以避免触及内存限制,或者更改这些内存限制,以避免昂贵的“磁盘排序”
在下一章中,我们将讨论 CRUD 的 C、U 和 D——创建、更新和删除——并考虑数据操作语句的优化,如insert
、update
和delete
。
八、插入、更新和删除
在这一章中,我们来看看与数据操作语句的性能相关的问题。这些语句(insert
、update
和delete
)改变了包含在 MongoDB 数据库中的信息。
即使在事务处理环境中,大多数数据库活动都与数据检索有关。为了更改或删除数据,您必须找到数据,甚至插入操作也经常涉及查询以获取查找键或嵌入其他集合中保存的数据。因此,大多数调优工作通常都涉及到查询优化。
然而,在 MongoDB 中有一些特定于数据操作的优化,我们将在本章中介绍它们。
基本原则
所有数据操作语句的开销都直接受到以下因素的影响:
-
语句中包含的任何筛选条件子句的效率
-
作为语句的结果,必须执行的索引维护量
过滤器优化
修改和删除文档所涉及的大量开销是定位要处理的文档所引起的。Delete
和update
语句通常包含一个筛选子句,用于标识要删除或更新的文档。优化这些语句性能的第一步显然是使用前面章节中讨论的原则来优化这些筛选子句。特别是,考虑对筛选条件中包含的属性创建索引。
Tip
如果 update 或 delete 语句包含过滤条件,请确保使用第 6 章中概述的原则优化过滤条件。
解释数据操作语句
在数据操作语句中使用explain()
是完全可能的,也是绝对可取的。对于delete
和update
命令,explain()
将揭示 MongoDB 如何找到要处理的文档。例如,这里我们看到一个更新,它将使用集合扫描来查找要处理的行:
mongo> var exp=db.customers.explain().
update({viewCount:{$gt:50}},
{$set:{discount:10}},{multi:true});
mongo> mongoTuning.quickExplain(exp);
1 COLLSCAN
2 UPDATE
您也可以安全地使用explain().
的executionStats
模式,尽管executionStats
确实执行相关的语句,并将报告将要修改的文档数量,但它实际上并不修改任何文档。
在以下示例中,explain()
报告有 45 个文档符合过滤条件并被更新:
mongo> var exp=db.customers.explain('executionStats').
... update({viewCount:{$gt:50}},
... {$set:{discount:10}},{multi:true});
mongo> mongoTuning.executionStats(exp);
1 COLLSCAN ( ms:29 docs:411121)
2 UPDATE ( ms:31 upd:45)
Totals: ms: 385 keys: 0 Docs: 411121
索引开销
尽管索引可以极大地提高查询性能,但它们确实会降低更新、插入和删除的性能。当插入或删除文档时,通常会更新集合的所有索引,并且当更新改变了索引中出现的任何属性时,也必须修改索引。
因此,我们所有的索引对查询性能的贡献是很重要的,因为这些索引会不必要地降低update
、insert,
和delete
的执行。特别是,在对频繁更新的属性创建索引时,应该特别小心。一个文档只能插入或删除一次,但可以更新多次。因此,对频繁更新的属性或具有非常高的插入/删除率的集合进行索引将需要特别高的成本。
图 8-1 展示了索引对插入和删除性能的影响。它显示了随着更多的索引被添加到集合中,插入然后删除 100,000 个文档所花费的时间是如何变化的。
图 8-1
索引对插入/删除性能的影响
Tip
索引总是会增加insert
和delete
语句的开销,并且可能会增加update
语句的开销。避免过度索引,尤其是在频繁更新的列上。
查找未使用的索引
查询调优过程导致大量索引创建是很常见的,有时可能会有冗余和未使用的索引。您可以使用$indexStats
aggregation 命令来查看索引利用率:
mongo>db.customers.aggregate([
... { $indexStats: {} },
... { $project: { name: 1,
'accesses.ops': 1 } }]);
{ "name" : "LastName_1_FirstName_1",
"accesses" : { "ops" : NumberLong(2068) } }
{ "name" : "_id_", "accesses" : { "ops" : NumberLong(1442414) } }
{ "name" : "updateFlag_1", "accesses" : { "ops" : NumberLong(0) } }
从这个输出中,我们可以看到自从 MongoDB 服务器最后一次启动以来,updateFlag_1
索引没有对任何操作做出贡献。我们可能会考虑删除该索引。但是,请记住,如果服务器最近重新启动过,或者此索引支持上次在重新启动之前发生的定期查询,则此操作计数器可能会产生误导。
Tip
定期使用$indexStats
来识别任何未使用或未充分利用的索引。这些索引可能会降低数据操作的速度,而不会加快查询速度。
该指南有一些例外:
-
唯一索引的存在可能纯粹是为了防止创建重复值,因此即使对查询性能没有帮助,它也有一定的用途。
-
类似地,生存时间 (TTL)索引可能用于清除旧数据,而不是加速查询。
写关注
在操作集群中的数据时, write concern 控制集群中有多少成员必须在将控制权返回给应用之前确认该操作。指定大于 1 的写关注级别通常会增加延迟并降低吞吐量,但会导致更可靠的写,因为它消除了在单个副本集节点出现故障时丢失写的可能性。我们将在第 13 章中详细讨论写关注点。
通常,您不应该为了获得性能提升而牺牲数据完整性。然而,值得记住的是,writeConcern 对数据操作语句的性能有直接影响。图 8-2 显示了插入 100,000 个文档时不同 writeConcern 设置的效果。我们将在第 13 章中详细讨论这个问题。
图 8-2
写操作对插入性能的影响
Warning
调整 writeConcern 可以提高性能,但可能会以牺牲数据完整性或安全性为代价。除非您完全了解这些权衡,否则不要调整 writeConcern 来提高性能。
插入
将数据放入 MongoDB 数据库是取出数据的必要先决条件,插入数据容易受到各种瓶颈和调优机会的影响。
成批处理
在第 6 章中,我们看到了如何使用批处理来优化从 MongoDB 服务器获取数据。我们使用批处理来确保我们不会执行不必要的网络往返,通过确保每个网络传输都有一个“满”负载。如果我们使用的批量大小为 1000,我们的网络传输量比使用的批量大小为 10 少 100 倍。
同样的原理也适用于插入数据。我们希望确保将数据批量推送到 MongoDB,这样就不会执行不必要的网络往返。不幸的是,虽然当我们发出一个find()
时,MongoDB 可以自动向我们发送成批的信息,但是为一个insert
构造成批的信息是由我们自己决定的。
例如,考虑下面的代码:
myDocuments.forEach((document)=>{
db.batchInsert.insert(document);
});
对于myDocuments
中的每个文档,我们发出一个 MongoDB insert
语句。如果有 10,000 个文档,我们将发出 10,000 个 MongoDB 调用,因此有 10,000 次网络往返。这样会表现很差。
在一次数据库调用中插入所有文档会好得多。这可以简单地通过发出一个insertMany
命令来完成:
db.batchInsert.insertMany(db.myDocuments.find().toArray());
这个表现好很多。在一个简单的测试案例中,它返回的时间不到“一次一个”方法所用时间的 10%。
然而,我们不能总是一次插入所有数据。如果我们有一个流应用,或者如果要插入的数据量很大,我们可能无法在插入之前将数据全部累积到内存中。在这种情况下,我们可以使用 MongoDB bulk 操作。
bulk 对象是由集合方法创建的。您可以增量地插入 bulk 对象,然后发出 bulk 对象的execute
方法,将批处理推入数据库。下面的代码对前面示例中使用的数据数组执行此任务。数据以 1000 为一批插入:
var bulk = db.batchInsert.initializeUnorderedBulkOp();
var i=0;
myDocuments.forEach((document)=>{
bulk.insert(document);
i++;
if (i%1000===0) {
bulk.execute();
bulk = db.batchInsert.initializeUnorderedBulkOp();
}
});
bulk.execute;
图 8-3 显示了“一次一个”插入、“一次全部”插入和批量插入的相对性能。
图 8-3
通过批量插入获得的性能提升(10,000 个文档)
Tip
不要一次插入一个文档的重要数据量。尽可能使用批量插入来减少网络开销。
克隆数据
有时,您可能希望将集合中一组文档的数据复制或克隆到同一个集合或另一个集合中。
例如,在一个电子商务应用中,您可能会实现一个“重复订单”按钮——它会将一个订单中的所有行项目复制到一个新订单中。
我们可以使用如下逻辑实现这样一个工具:
function repeatOrder(orderId) {
let newOrder = db.orders.findOne({ _id: orderId },
{ _id: 0 });
let orderInsertRC = db.orders.insertOne(newOrder);
let newOrderId = orderInsertRC.insertedId;
let newLineItems = db.lineitems.
find({ orderId: orderId },
{ _id: 0 }).toArray();
for (let li = 0; li < newLineItems.length; li++) {
newLineItems[li].orderId = newOrderId;
}
db.lineItems.insertMany(newLineItems);
return newOrderId;
}
该函数检索现有的行项目,用新的订单 Id 修改,然后将项目重新插入到集合中。
如果有很多行项目,那么最大的瓶颈将是从数据库中提取行项目,然后将这些行项目放入新订单的网络延迟。
从 MongoDB 4.4 开始,我们可以使用一种替代技术,包括聚合框架管道来克隆数据。这种方法的优点是不需要将数据移出数据库——克隆发生在数据库服务器内,没有任何网络开销。$merge
操作符允许我们根据聚合管道的输出执行插入。
以下是聚合备选方案的一个示例:
function repeatOrder(orderId) {
let newOrder = db.orders.findOne({ _id: orderId }, { _id: 0 });
let orderInsertRC = db.orders.insertOne(newOrder);
let newOrderId = orderInsertRC.insertedId;
db.lineitems.aggregate([
{
$match: {
orderId: { $eq: orderId }
}
},
{
$project: {
_id: 0,
orderId: 0
}
},
{ $addFields: { orderId: newOrderId } },
{
$merge: {
into: 'lineitems'
}
}
]);
return newOrderId;
}
这个函数使用$merge
管道操作符将管道的输出推回到集合中。图 8-4 比较了两种方法的性能——超过 500 个数据克隆操作,使用聚合$merge
方法所用时间大约减半。
图 8-4
使用聚合$merge
管道加速数据克隆(500 个文档)
MongoDB $out
聚合操作符提供了与$merge
类似的功能,尽管它不能插入到源集合中,并且——我们将在本章后面看到——执行 upsert 类型合并的选项较少。
Tip
当插入来自集合中数据的批量数据时,使用聚合框架$out
和$merge
操作符来避免跨网络移动数据。
从文件加载
MongoDB 提供了mongoimport
和mongorestore
命令来从 JSON 或 CSV 文件或者从mongodump
的输出中加载数据。
无论您使用哪种方法,这类数据负载中最重要的因素通常是网络延迟。压缩一个文件,通过网络将它移动到 MongoDB 服务器主机,解压缩,然后运行导入,几乎总是比直接从另一个服务器导入要快。
在 MongoDB Atlas 中,您无法将文件直接移动到 Atlas 服务器上。但是,您可能会发现,在同一区域创建一个虚拟机并从该虚拟机转移负载可以显著提升性能。
更新
文档只能插入或删除一次,但可以多次更新。因此,更新优化是 MongoDB 性能调优的一个重要方面。
动态值批量更新
有时,您可能需要更新集合中的多行,其中要设置的值取决于文档中的其他属性或另一个集合中的值。
例如,假设我们想要在视频流customers
集合中插入一个“观看计数”。为每个客户设置的值是不同的,因此我们可以检索每个客户文档,然后使用views
数组中的元素数更新同一个客户文档。逻辑可能是这样的:
db.customers.find({}, { _id: 1, views: 1 }).
forEach(customer => {
let updRC=db.customers.update(
{ _id: customer['_id'] },
{ $set: { viewCount: customer.views.length } }
);
});
这种解决方案很容易编码,但是性能很差:我们必须通过网络获取大量数据,并且我们必须发出与客户一样多的 update 语句。然而,在 MongoDB 4.2 之前,这可能是可用的最佳解决方案。
然而,从 MongoDB 4.2 开始,我们能够在更新语句中嵌入聚合框架管道。这些管道允许我们设置一个从文档中的其他值派生或依赖于其他值的值。例如,我们可以用这条语句填充viewCount
属性:
db.customers.update(
{},
[{ $set: { viewCount: { $size: '$views' } } }],
{multi: true});
图 8-5 比较了两种方法的性能。聚合管道减少了大约 95%的执行时间。
图 8-5
使用聚合管道与多次更新(大约 411,000 个文档)
Tip
当需要根据现有值动态更新数据时,可以考虑在 update 语句中使用嵌入式聚合管道。
多:真标志
MongoDB update 命令接受一个multi
参数,该参数决定是否在操作中更新多个文档。当设置了multi:false
时,MongoDB 将在单个文档更新后立即停止处理。
以下示例显示了不带multi
标志的 update 语句:
mongo> var exp = db.customers.
... explain('executionStats').
... update({ flag: true }, { $set: { flag: false } });
mongo> mongoTuning.executionStats(exp);
1 COLLSCAN ( ms:1 docs:9999)
2 UPDATE ( ms:1 upd:1)
Totals: ms: 10 keys: 0 Docs: 9999
MongoDB 扫描整个集合,直到找到匹配的值,然后执行更新。一旦找到单个文档,扫描就结束。
如果我们知道只有一个值需要更新,但是无论如何都要包括multi:true
,我们将看到这个执行计划:
mongo> var exp = db.customers.
... explain('executionStats').
... update({ flag: true }, { $set: { flag: false } },
... {multi:true});
mongo> mongoTuning.executionStats(exp);
1 COLLSCAN ( ms:35 docs:411119)
2 UPDATE ( ms:35 upd:1)
Totals: ms: 368 keys: 0 Docs: 411119
更新的文件数量相同,但处理的文件数量要高得多(411,000 对 999)。因此,该语句的运行时间要长得多。在初始更新之后,更新继续扫描集合,寻找更多符合条件的文档。
Tip
如果你知道你只需要更新一个文档,不要设置multi:true
。如果涉及到索引或集合扫描,MongoDB 可能会执行不必要的工作,寻找其他要更新的文档。
冷门
Upserts 允许您发出一条语句,如果存在匹配的文档,则执行 update,否则执行 insert。当您试图将文档合并到一个集合中,并且不想明确检查文档是否存在时,Upserts 可以提高性能。
例如,如果我们将数据加载到一个集合中,但不知道我们是否需要插入或替换,我们可以实现类似这样的逻辑:
db.source.find().forEach(doc => {
let matchingDocs = db.target.count({ _id: doc['_id'] });
if (matchingDocs === 0) {
db.target.insert(doc);
inserts++;
} else {
db.target.update({ _id: doc['_id'] }, doc,
{ multi: false });
updates++;
}
});
我们寻找匹配的值,如果找到,就执行更新;否则,执行插入。
Upsert 允许我们将插入和更新操作合并到一个操作中,并且消除了首先检查匹配值的需要。这是 upsert 逻辑:
db.source.find().forEach(doc => {
let returnCodes = db.target.update({ _id: doc['_id'] }, doc,
{upsert: true});
inserts += returnCodes.nUpserted;
updates += returnCodes.nModified;
});
新的逻辑更加简单,并且减少了需要处理的数据库命令的数量。通过远程网络连接,upsert 解决方案要快得多。图 8-6 比较了两种结果的性能。
图 8-6
与查找/插入/更新相比,插入性能提高(10,000 个文档)
Tip
如果不确定是插入还是更新文档,请使用 upsert 而不是条件 insert/update 语句。
使用$merge 批量增加插入
图 8-6 中比较的解决方案一次插入或更新一个文档。正如我们已经看到的,单个文档处理比批量处理花费的时间更长,所以如果我们能够在一次操作中插入或更新多个文档就更好了。
从 MongoDB 4.2 开始,我们可以使用$merge
聚合操作符来实现这一点,前提是我们的输入数据已经在 MongoDB 集合中。$merge
的操作与upsert
非常相似,允许我们在匹配时更新文档,否则插入一个文档。上一节的逻辑可以用下面的语句在单个$merge
操作中实现:
db.source.aggregate([{$merge:
{ into:"target",
on: "_id",
whenMatched:"replace",
whenNotMatched:"insert"}}]);
聚合管道的速度快得惊人。除了减少必须执行的 MongoDB 语句的数量并允许批量处理之外,聚合管道还避免了跨网络移动数据。图 8-7 显示了通过$merge
可以实现的性能提升。
图 8-7
多个升级对比单个$merge
语句(10,000 个文档)
删除优化
像插入一样,删除必须修改集合中存在的所有索引。因此,对于处理大量临时流数据的系统来说,从大量索引集合中删除数据通常会成为一个严重的问题。
在这种情况下,通过设置删除标志来“逻辑地”删除相关文档可能是有用的。删除标志可用于向应用指示应该忽略文档。这些文档可以在维护窗口中定期被物理删除。
如果您采用这种“逻辑删除”策略,那么您需要使删除标志成为所有索引中的一个属性,并在针对该集合的所有查询中包含删除标志。
摘要
在这一章中,我们已经了解了如何优化数据操作语句—insert
、update
和delete
。
数据操作吞吐量在很大程度上取决于集合中的索引数量。用来加快查询速度的索引会降低数据操作语句的速度,所以要确保每个索引都物有所值。
Update
和delete
语句接受过滤条件,优化这些过滤条件的原则与find()
和聚合$match
操作的原则相同。
插入时,请确保成批插入,如果插入来自另一个集合的数据,请尽可能使用聚合管道。聚合管道还可以极大地改善依赖于 MongoDB 中已有数据的批量更新操作。
九、事务
事务在 MongoDB 中是新的,但在 SQL 数据库中已经存在了 30 多年。事务用于维护数据库系统中的一致性和正确性,这些数据库系统会受到多个用户发出的并发更改的影响。
事务通常会以降低并发性为代价来提高一致性。因此,事务对数据库性能有很大影响。
本章并不打算作为事务的教程。要了解如何对事务进行编程,请参阅 MongoDB 手册中关于事务的部分。 1 在本章中,我们将集中讨论最大化事务吞吐量和最小化事务等待时间。
事务理论
数据库通常使用两种主要的架构模式来满足一致性需求: ACID 事务和多版本并发控制 ( MVCC )。
ACID 事务模型是在 20 世纪 80 年代开发的。ACID 事务应
-
Atomic :事务是不可分割的——要么将事务中的所有语句应用于数据库,要么不应用任何语句。
-
一致:数据库在事务执行前后保持一致状态。
-
隔离:虽然多个事务可以由一个或多个用户同时执行,但是一个事务不应该看到其他正在进行的事务的影响。
-
持久:一旦事务被保存到数据库中(通常通过 COMMIT 命令),即使操作系统或硬件出现故障,它的更改也将持续。
实现 ACID 一致性最简单的方法是使用锁。使用基于锁的一致性,如果一个会话正在读取一个项目,其他任何会话都不能修改它;如果一个会话正在修改一个项目,其他任何会话都不能读取它。然而,基于锁的一致性会导致不可接受的高争用和低并发性。
为了在没有过多锁定的情况下提供 ACID 一致性,现代数据库系统几乎普遍采用了多版本并发控制 ( MVCC )模型。在 MVCC 模型中,数据的多个副本标有时间戳或更改标识符,允许数据库在给定时间点构建数据库的快照。这样,MVCC 在最大化并发性的同时提供了事务隔离和一致性。
例如,在 MVCC,如果数据库表在会话开始读取表和会话结束之间被修改,数据库将使用以前版本的表数据来确保会话看到一致的版本。MVCC 还意味着,在事务提交之前,其他会话看不到事务的修改——其他会话会查看数据的旧版本。这些较旧的数据副本也用于回滚未成功完成的事务。
图 9-1 展示了 MVCC 模型。数据库会话在时间 t1 (1)启动一个事务。在时间 t2,会话更新文档(2):这导致该文档的新版本被创建(3)。大约在同一时间,第二个数据库会话查询文档,但是因为第一个会话的事务还没有提交,所以他们看到的是文档的前一个版本(4)。在第一个会话提交事务(5)之后,第二个数据库会话将从文档的修改版本中读取(6)。
图 9-1
多版本一致性控制
MongoDB 事务
您可能在其他数据库中使用过事务——MySQL、PostgreSQL 或其他 SQL 数据库——并且对这些基本原则有合理的理解。MongoDB 事务表面上类似于 SQL 数据库事务;然而,在幕后,实现是明显不同的。
SQL 数据库和 MongoDB 中的事务之间的两个重要区别是
-
最初——在 MongoDB 4.4 之前——MongoDB 没有在磁盘上维护多个版本的块来支持 MVCC。相反,块保存在 WiredTiger 缓存中。
-
MongoDB 不使用阻塞锁来防止事务之间的冲突。相反,它发出
TransientTransactionErrors
来中止可能导致冲突的事务。
事务限额
MongoDB 使用图 9-1 中概述的 MVCC 机制来确保事务看到数据库的独立和一致的表示。这种快照隔离确保事务看到一致的数据视图,并且会话不会观察到未提交的事务。这种 MongoDB 隔离机制被称为快照读取问题。
大多数实现 MVCC 系统的关系数据库使用基于磁盘的“镜像前”或“回滚”段来存储创建这些数据库快照所需的数据。在这些数据库中,快照的“年龄”仅受磁盘上可用磁盘空间的限制。
然而,最初的 MongoDB 实现依赖于保存在 WiredTiger 基于内存的缓存中的数据副本。因此,MongoDB 无法为长期运行的事务可靠地维护数据快照。为了避免 WiredTiger 内存的内存压力,默认情况下,事务的持续时间限制为 60 秒。可通过改变transactionLifetimeLimitSeconds
参数来修改该限值。在 MongoDB 4.4 中,snaphot 数据可以写入磁盘,但是默认的事务时间限制仍然是 60 秒。
transientstransactionerrors
几乎毫无例外,PostgreSQL 或 MySQL 等关系数据库使用锁来实现事务一致性。图 9-2 说明了这是如何工作的。当会话修改表中的某一行时,它会锁定该行以防止并发修改。如果第二个会话试图修改同一行,它必须等到原始事务提交时锁被释放。
图 9-2
关系数据库事务中的锁
许多开发者熟悉关系数据库的阻塞锁,并可能认为 MongoDB 也做同样的事情。然而,MongoDB 的方法完全不同。在 MongoDB 中,当第二个会话试图修改在另一个事务中修改过的文档时,它不会等待锁被释放。相反,它接收一个TransientTransactionError
事件。然后,第二个会话必须重试该事务(理想情况下是在第一个事务完成之后)。
图 9-3 展示了 MongoDB 范例。当会话更新文档时,它不会锁定文档。但是,如果第二个会话试图修改事务中的文档,就会发出一个TransientTransactionError
。
图 9-3
蒙戈布·德·特拉斯潘瑟罗
由应用决定对TransientTransactionError
做什么,但是推荐的方法是简单地重试事务,直到它最终成功。
下面是一些说明TransientTransactionError
范例的代码。该代码片段创建了两个会话,每个会话都在自己的事务中。然后,我们尝试在每个事务中更新同一个文档。
var session1=db.getMongo().startSession();
var session2=db.getMongo().startSession();
var session1Collection=session1.getDatabase(db.getName())
.transTest;
var session2Collection=session2.getDatabase(db.getName())
.transTest;
session1.startTransaction();
session2.startTransaction();
session1Collection.update({_id:1},{$set:{value:1}});
session2Collection.update({_id:1},{$set:{value:2}});
session1.commitTransaction();
session2.commitTransaction();
当遇到第二个 update 语句时,MongoDB 会发出一个错误:
mongo>session1Collection.update({_id:1},{$set:{value:1}});
WriteCommandError({
"errorLabels" : [
"TransientTransactionError"
],
"operationTime" : Timestamp(1596785629, 1),
"ok" : 0,
"errmsg" : "WriteConflict error: this operation conflicted with another operation. Please retry your operation or multi-document transaction.",
"code" : 112,
"codeName" : "WriteConflict",
MongoDB 驱动程序中的事务
从 MongoDB 4.2 开始,MongoDB 驱动程序通过自动重试事务来对您隐藏transientTransationErrors
。例如,您可以同时运行这个 NodeJS 代码的多个副本,而不会遇到任何TransientTransactionErrors:
async function myTransaction(session, db, fromAcc,
toAcc, dollars) {
try {
await session.withTransaction(async () => {
await db.collection('accounts').
updateOne({ _id: fromAcc },
{ $inc: { balance: -1*dollars } },
{ session });
await db.collection('accounts').
updateOne({ _id: toAcc },
{ $inc: { balance: dollars } },
{ session });
}, transactionOptions);
} catch (error) {
console.log(error.message);
}
}
NodeJS 驱动程序——以及 Java、Python、Go 等其他语言的驱动程序——自动处理任何TransientTransactionErrors
并重新提交任何中止的事务。但是,MongoDB 服务器仍然会发出错误,您可以在 MongoDB 日志中看到这些错误的记录:
~$ grep -i 'assertion.*writeconflict' \
/usr/local/var/log/mongodb/mongo.log \
|tail -1|jq
{
"t": {
"$date": "2020-08-08T14:04:47.643+10:00"
},
…
"msg": "Assertion while executing command",
"attr": {
"command": "update",
"db": "MongoDBTuningBook",
"commandArgs": {
"update": "transTest",
"updates": [
{
"q": {
"_id": 1
},
"u": {
"$inc": {
"value": 2
}
},
"upsert": false,
"multi": false
}
],
/* Other transaction information */
},
"error": "WriteConflict: WriteConflict error: this operation conflicted with another operation. Please retry your operation or multi-document transaction."
}
}
在 NodeJS 驱动程序中,您还可以记录服务器级的调试消息 2 来查看正在幕后进行的中止的事务。当一个事务在幕后中止时,您将在输出流中看到以下消息:
[DEBUG-Server:20690] 1596872732041 executing command [{"ns":"admin.$cmd","cmd":{"abortTransaction":1,"writeConcern":{"w":"majority"}},"options":{}}] against localhost:27017 {
type: 'debug',
message: 'executing command [{"ns":"admin.$cmd","cmd":{"abortTransaction":1,"writeConcern":{"w":"majority"}},"options":{}}] against localhost:27017',
className: 'Server',
pid: 20690,
date: 1596872732041
}
其他驱动程序可能会提供类似的方法来查看事务重试次数。
在全局级别,重试在db.serverStatus
计数器transactions.totalAborted
中可见。我们可以使用以下函数来检查启动、中止和提交的事务数量:
function txnCounts() {
var ssTxns = db.serverStatus().transactions;
print(ssTxns.totalStarted + 0, 'transactions started');
print(ssTxns.totalAborted + 0, 'transactions aborted');
print(ssTxns.totalCommitted + 0, 'transactions committed');
print(Math.round(ssTxns.totalAborted * 100 /
ssTxns.totalStarted) + '% txns aborted');
}
mongo> txnCounts();
203628 transactions started
167989 transactions aborted
35639 transactions committed
82% txns aborted
TransientTransactionErrors 的性能影响
由TransientTransactionErrors
引起的重试是昂贵的——它们不仅包括丢弃事务中迄今为止所做的任何工作,还包括将数据库状态恢复到事务开始时的状态。使 MongoDB 事务变得昂贵的最大原因是事务重试的影响。图 9-4 显示,随着事务中止百分比的增加,事务的运行时间迅速减少。
图 9-4
中止的事务对性能的影响
Note
MongoDB 事务模型包括中止与其他事务冲突的事务。这些中止是昂贵的操作,是 MongoDB 事务性能的主要瓶颈。
事务优化
鉴于TransientTransactionError
重试对事务性能有如此严重的影响,因此我们需要尽一切可能减少这些重试。我们可以采用几个策略:
-
完全避免事务。
-
对操作进行排序,以尽量减少冲突操作的数量。
-
对容易发生高级写冲突的“热”文档进行分区。
避免事务
您可能不需要使用 MongoDB 事务来实现事务性结果。例如,考虑这个事务,它在一个假设的银行应用中的分支机构之间转移资金:
try {
await session.withTransaction(async () => {
await db.collection('branches').
updateOne({ _id: fromBranch },
{ $inc: { balance: -1*dollars } },
{ session });
await db.collection('branches').
updateOne({ _id: toBranch },
{ $inc: { balance: dollars } },
{ session });
}, transactionOptions);
} catch (error) {
console.log(error.message);
}
这当然看起来像是事务的候选——两个 update 语句作为一个单元应该同时成功或失败。但是,如果分支的数量相对较少——小到足以容纳一个文档——那么我们可以将所有余额存储在一个文档中的嵌入式数组中,如下所示:
mongo> db.embeddedBranches.findOne();
{
"_id": 1,
"branchTotals": [
{
"branchId": 0,
"balance": 101208675
},
{
"branchId": 1,
"balance": 98409758
},
{
"branchId": 2,
"balance": 99407654
},
{
"branchId": 3,
"balance": 98807890
}
]
}
然后,我们可以使用相对简单的 update 语句在分支之间自动移动数据。我们的新“事务”将如下所示:
try {
let updateString =
`{"$inc":{
"branchTotals.`+fromBranch+`.balance":`+dollars+`,
"branchTotals.`+toBranch +`.balance":`+dollars+`}}`;
let updateClause = JSON.parse(updateString);
await db.collection('embeddedBranches').updateOne(
{_id: 1 }, updateClause);
} catch (error) {
console.log(error.message);
}
我们已经将四条语句减少到一条,并且完全消除了任何TransientTransactionErrors
的可能性。图 9-5 比较了性能——非事务性方法比事务性方法快 100 多倍。
图 9-5
MongoDB 事务与嵌入式数组
Tip
对于 MongoDB 事务,可能有替代的应用策略,这些策略可能比正式的事务执行得更好,尤其是在写入冲突的可能性很高的情况下。
操作排序
从本质上讲,事务会向 MongoDB 数据库发出多个操作。其中一些操作可能比其他操作更容易产生写冲突。在这些场景中,更改操作顺序可能会给您带来性能优势。
例如,考虑以下事务:
await session.withTransaction(async () => {
await db.collection('txnTotals').
updateOne({ _id: 1 },
{ $inc: { counter: 1 } },
{ session });
await db.collection('accounts').
updateOne({ _id: fromAcc },
{ $inc: { balance: -1*dollars } },
{ session });
await db.collection('accounts').
updateOne({ _id: toAcc },
{ $inc: { balance: dollars } },
{ session });
}, transactionOptions);
这个事务在两个账户之间转移资金,但是首先,它更新一个全局“事务计数器”试图发出该事务的每个事务都将试图更新该计数器,结果许多事务将遇到TransientTransactionError
次重试。
如果我们将有争议的语句移到事务的末尾,那么发生TransientTransactionError
的机会将会减少,因为冲突的窗口将会减少到事务执行的最后几个时刻。修改后的代码如下所示——我们只是将txnTotals
更新移到了事务的末尾:
await session.withTransaction(async () => {
await db.collection('accounts').
updateOne({ _id: fromAcc },
{ $inc: { balance: -1*dollars } },
{ session });
await db.collection('accounts').
updateOne({ _id: toAcc },
{ $inc: { balance: dollars } },
{ session });
await db.collection('txnTotals').
updateOne({ _id: 1 },
{ $inc: { counter: 1 } },
{ session });
}, transactionOptions);
图 9-6 提供了更改示例事务的事务顺序的效果示例。将“热”操作放在最后可以减少争用,并显著缩短事务执行时间。
图 9-6
事务中重新排序操作的影响
Tip
考虑将“热”操作——那些可能遇到TransientTransactionErrors
的操作——放在事务的最后,以减少冲突时间窗口。
划分热文档
当多个事务试图修改一个特定的文档时发生。这些“热”文档成为事务瓶颈。在某些情况下,我们可以通过将文档中的数据划分为多个不同的文档来缓解瓶颈。
例如,考虑我们在上一节中看到的事务。该事务更新了事务计数器文档:
await db.collection('txnTotals').
updateOne({ _id: 1 },
{ $inc: { counter: 1 } },
{ session });
这是一个“热”文档的完美例子——每个事务都想更新的文档。如果我们真的需要在一个事务中保存类似这样的运行总数,我们可以将总数拆分到多个文档中。例如,以下替代语法将总计拆分为十个文档:
let id=Math.floor(Math.random()*10);
await db.collection('txnTotals').
updateOne({ _id: id },
{ $inc: { counter: 1 } },
{ session });
当然,如果我们想要得到一个总计,我们将需要聚集来自十个小计的数据,但是这对于提高我们的事务性能来说是一个很小的代价。
图 9-7 显示了这种分区带来的性能提升。通过对热文档进行分区,我们将平均事务时间减少了近 90%。
图 9-7
对“热”文档进行分区以缩短事务时间
Tip
考虑将“热”文档——由多个事务同时更新的文档——划分为多个文档。
结论
事务是许多应用的基本要求,MongoDB 4.0 中事务支持的引入是 MongoDB 的一大进步。
不幸的是,与 MongoDB 的其他新特性不同,事务本身并不能提高性能。通过在会话之间引入争用,事务本质上会降低并发性,从而降低吞吐量并增加响应时间。
MongoDB 事务架构没有利用大多数 SQL 数据库使用的阻塞锁。相反,它中止试图同时修改文档的事务。这些中止和重试由 MongoDB 驱动程序“秘密”处理。然而,事务中止和重试是 MongoDB 事务的一个关键性能拖累,应该是事务调优工作的重点。
在本章中,我们研究了几种减少争用从而提高事务吞吐量的方法:
-
我们有时可以完全避免事务,例如,通过在单个文档中嵌入必须自动更新的数据。
-
我们可以通过将高争用操作移到事务的末尾来减少事务中止的机会。
-
我们可以将“热”文档划分为多个文档,从而减少这些文档中的数据争用。
十、服务器监控
到目前为止,我们一直致力于通过优化应用代码和数据库设计来管理性能。在理想情况下,这是我们开始调优工作的地方,通过优化应用,我们使 MongoDB 工作得更智能,而不是更困难。通过优化我们的模式、应用代码和索引,我们减少了 MongoDB 完成一项任务所需的工作量。
然而,可能有一天你已经完成了所有可能的实际应用调优。此外,有些情况下,您根本没有能力返工应用代码——例如,当您使用第三方应用时。
现在是时候查看您的服务器配置,并确保服务器针对应用工作负载进行了优化。理想情况下,这种服务器端调优分四个阶段进行:
-
确保服务器主机上有足够的内存和 CPU 来支持工作负载
-
确保有足够的正确配置的内存来减少 IO 需求
-
优化磁盘 IO 以确保磁盘请求返回时不会有过长的延迟
-
确保群集配置得到优化,以避免群集协调中的延迟,并最大限度地利用群集资源
这些话题是本书接下来四章的主题。在这一章中,我们将了解监控服务器性能的基础知识和一些有助于这一过程的有用工具。
主机级监控
所有的 MongoDB 服务器都运行在一个操作系统中,而这个操作系统又托管在某个硬件平台中。在当今由虚拟机、容器和云基础设施组成的世界中,硬件拓扑可能会变得模糊不清。但是,即使您不能直接观察底层硬件,您也可以观察提供支持 MongoDB 服务器的原始资源的操作系统容器。
在最基本的层面上,操作系统提供了四种基本资源:
-
网络带宽,允许数据进出机器
-
CPU ,允许执行程序代码
-
内存,允许快速访问非永久性数据
-
磁盘 IO ,允许永久存储海量数据
有各种各样的工具可以帮助您监控主机利用率,包括商业的和免费的。根据我们的经验,最好理解如何使用内置的性能实用程序,因为它们总是可用的。
在 Linux 上,您应该熟悉以下命令:
-
top
-
uptime
-
vmstat
-
iostat
-
netstat
-
bwm-ng
在 Windows 上,您可以使用资源监控器应用获得图形视图,并从 PowerShell Get-Counter
命令获得原始统计数据。
网络
网络负责将数据从服务器传输到应用,并在组成集群的服务器之间传输。
我们已经在第 6 章中讨论了网络往返的作用,我们将在第 13 章中更多地讨论集群优化背景下的网络流量。
MongoDB 服务器中的网络接口成为瓶颈并不常见——网络瓶颈更常见于服务器和各种客户端之间的许多网络跳跃。也就是说,MongoDB 服务器可以处理的数据量通常小于通过典型网络接口可以传输的数据量。您可以使用bwm-ng
命令监控通过网络接口传输的流量:
bwm-ng v0.6.2 (probing every 5.200s), press 'h' for help
input: /proc/net/dev type: rate
iface Rx Tx Total
=================================================================
lo: 0.00 B/s 0.00 B/s 0.00 B/s
eth0: 173.52 KB/s 8.84 MB/s 9.01 MB/s
virbr0: 0.00 B/s 0.00 B/s 0.00 B/s
-----------------------------------------------------------------
total: 173.52 KB/s 8.84 MB/s 9.01 MB/s
现代服务器中的网络接口通常是 10 或 100 千兆以太网卡,这些网卡限制客户机和服务器之间传输的数据量的可能性很小。然而,如果您有一台使用 10GbE 卡以下的旧服务器,那么升级您的网卡是一种廉价的优化。
然而,虽然服务器上的网络接口不太可能成为问题,但是客户端和服务器之间的网络很可能包含各种具有不同性能特征的路由和交换机。此外,客户端和服务器之间的距离造成了不可避免的延迟。应用和 MongoDB 服务器之间的网络往返时间通常是整体性能的限制因素。
您可以使用 ping 或 traceroute 等命令来测量两台服务器之间的往返时间。这里,我们测量三个广泛分布的副本集成员的网络延迟:
$ traceroute mongors01.eastasia.cloudapp.azure.com --port=27017 -T
traceroute to mongors01.eastasia.cloudapp.azure.com (23.100.91.199), 30 hops max, 60 byte packets
1 * * *
. . .
18 * * 23.100.91.199 (23.100.91.199) 118.392 ms
$ traceroute mongors02.japaneast.cloudapp.azure.com --port=27017 -T
traceroute to mongors02.japaneast.cloudapp.azure.com (20.46.164.146), 30 hops max, 60 byte packets
1 * * *
. . .
19 * 20.46.164.146 (20.46.164.146) 128.611 ms
$ traceroute mongors03.koreacentral.cloudapp.azure.com --port=27017 -T
traceroute to mongors03.koreacentral.cloudapp.azure.com (20.194.1.136), 30 hops max, 60 byte packets
1 * * *
. . .
26 * * *
27 20.194.1.136 (20.194.1.136) 152.857 ms
测量响应一个非常简单的 MongoDB 命令(如rs.isMaster()
)所需的时间也很有用。当我们从服务器主机上的 shell 运行rs.isMaster()
时,我们会看到一个最小的延迟:
mongo> var start=new Date();
mongo> var isMaster=rs.isMaster();
mongo> print ('Elapsed time', (new Date())-start);
Elapsed time 14
当我们从远程主机运行rs.isMaster()
时,由于网络延迟,运行时间要长几百毫秒:
mongo> var start=new Date();
mongo> var isMaster=rs.isMaster();
mongo> print (‘Elapsed time’, (new Date())-start);
Elapsed time 316
如果您的网络延迟过高——超过几个 100 毫秒——那么您可能需要检查您的网络配置。您的网络管理员或 ISP 可能需要参与跟踪延迟的原因。
但是,在复杂的网络拓扑中,网络延迟的原因可能超出了您的控制范围。一般来说,处理网络延迟的最佳方法是
-
将您的应用工作负载“移近”您的数据库服务器。理想情况下,应用服务器应该与您的 MongoDB 服务器位于同一个区域、数据中心甚至同一个机架中。
Tip
超过几百毫秒的网络延迟令人担忧。调查您的网络硬件和拓扑,并考虑将您的应用代码“移近”您的 MongoDB 服务器。在这两种情况下,请确保使用本书前面讨论的技术来最小化网络往返。
中央处理器
CPU 瓶颈通常会导致性能下降。MongoDB 服务器进程在解析请求、访问缓存中的数据以及用于许多其他目的时会消耗 CPU。
当调查 CPU 利用率时,可以理解大多数人从 CPU 繁忙百分比指标开始。但是,这个指标只有在 CPU 利用率低于 100%时才有用。一旦 CPU 利用率达到 100%,更重要的指标是运行队列。
运行队列——有时称为平均负载——反映了想要使用一个 CPU 的进程的平均数量,但是当其他进程正在独占该 CPU 时,这些进程必须等待。与 CPU 繁忙百分比相比,运行队列是衡量 CPU 负载的更好方法,因为即使 CPU 得到充分利用,对 CPU 的需求仍会增加,因此运行队列仍会增长。大型运行队列几乎总是与较差的响应时间有关。
我们喜欢把 CPU 和运行队列想象成超市收银台。即使所有的收银台都很忙,只要收银台前没有大排长龙,你仍然可以快速走出超市。当队伍开始变长时,你就开始担心了。
图 10-1 说明了运行队列、CPU 繁忙百分比和响应时间之间的关系。随着工作量的增加,这三个指标都会增加。然而,CPU 繁忙百分比达到 100%,而运行队列和响应时间继续以高度相关的方式增加。因此,运行队列是 CPU 利用率的最佳衡量标准。
理想情况下,运行队列不应该超过系统中 CPU 数量的两倍。例如,在图 10-1 中,主机系统有四个 CPUs 因此,大约 8–10 的运行队列代表最大的 CPU 利用率。
图 10-1
运行队列、CPU 繁忙百分比和响应时间之间的关系
Tip
“CPU 运行队列”或“平均负载”是衡量 CPU 负载的最佳指标。运行队列应该保持在系统上可用 CPU 数量的两倍以下。
要获得 Linux 上的运行队列值,可以发出 uptime 命令:
$ uptime
06:38:39 up 42 days … load average: 12.77, 3.66, 1.37
该命令报告过去 1、5 和 15 分钟的平均运行队列长度(平均负载)。
在 Windows 上,您可以在 PowerShell 提示符下发出以下Get-Counter
命令:
PS C:\Users\guy> Get-Counter '\System\Processor Queue Length' -MaxSamples 5
Timestamp CounterSamples
--------- --------------
29/08/2020 1:32:20 PM \\win10\system\processor queue length :
4
29/08/2020 1:32:21 PM \\win10\system\processor queue length :
1
记忆
所有计算机应用都使用内存来存储正在处理的数据。数据库是内存的特别大的用户,因为它们通常在内存中缓存数据以避免执行过多的磁盘 IO。
我们将在下一章专门讨论 MongoDB 内存管理。请查看第 11 章,了解更多关于内存监控和 MongoDB 内存管理的知识。
磁盘 IO
磁盘 IO 对数据库性能至关重要,因此我们在第 12 章和第 13 章讨论了这个主题。我们将在这些章节中讨论磁盘 IO 性能管理的所有方面。
MongoDB 服务器监控
db.serverStatus()
命令是理解 MongoDB 服务器性能所需的大多数原始指标的最终来源。我们在第三章中介绍了db.serverStatus()
。然而,原始数据很难解释,因此有各种各样的调优工具以更容易理解的格式呈现信息。
Compass
MongoDB Compass(图 10-2 )是使用 MongoDB 的官方 GUI,可以在 mongodb.com 免费获得。尽管 Compass performance dashboard 相对简单,但它是一个有用的入门点。如果你已经下载了 MongoDB 社区版,你可能已经有了 Compass。
图 10-2
MongoDB 指南针监控
Free Monitoring
MongoDB 还提供了一种简单的方法来访问任何 MongoDB 服务器的基于云的性能仪表板。与 Compass 仪表板类似,免费的监控仪表板(图 10-3 )提供了一个关于性能的最小视图,但是作为一种免费和直接的方式来获得 MongoDB 性能的摘要。
图 10-3
MongoDB 免费监控
从版本 4.0 开始,community edition 服务器可以免费监控。服务器主机防火墙必须允许访问 http://cloud.mongodb.com/freemonitoring
。
要启用免费监控,只需登录 MongoDB 服务器并运行db.enableFreeMonitoring()
。如果一切顺利,您将获得一个指向您的监控仪表板的 URL:
rsUser:PRIMARY> db.enableFreeMonitoring()
{
"state" : "enabled",
"message": "To see your monitoring data, navigate to the unique URL below. Anyone you share the URL with will also be able to view this page. You
can disable monitoring at any time by running db.disableFreeMonitoring().",
"url" : "https://cloud.mongodb.com/freemonitoring/cluster/WZFEDJBMA23QISXQDEDXACFWGB2OWQ7H",
"userReminder" : "",
"ok" : 1,
"operationTime" : Timestamp(1599995708,
Ops Manager
MongoDB Ops Manager(通常简称为“Ops Man”)是 MongoDB 的商业平台,用于管理、监控和自动化 MongoDB 服务器操作(图 10-4 )。Ops Man 可以与您现有的服务器一起部署,也可以用于创建新的基础架构。除了自动化和部署功能,Ops Man 还为所有注册的部署提供了一个性能监控仪表板。
图 10-4
MongoDB 运营经理
蒙戈布地图集
如果您已经在 MongoDB 的 Atlas database-as-a-service 平台上创建了一个集群,您将可以访问一个与 MongoDB Ops Manager 非常相似的图形监控界面。Atlas 仪表板(图 10-5 )提供了配置指标和选择生成活动图的时间窗口的能力。高级集群(M10 及以上)也将能够进行实时监控。
图 10-5
MongoDB 地图集监控
第三方监控工具
还有各种各样的免费和商业监控工具为 MongoDB 提供了强大的支持。一些最受欢迎的是
-
Percona 专门从事开源数据库软件和服务。除了提供自己的 MongoDB 发行版,他们还提供 Percona 监控和管理平台,该平台提供 MongoDB 服务器的实时和历史性能监控。
-
Datadog 是一个流行的监控平台,为应用堆栈的所有元素提供诊断。他们为 MongoDB 提供了一个专用模块。
-
网络安全管理软件产品于 2019 年收购了 VividCortex 。用于 MongoDB 的 VividCortex 产品为 MongoDB 提供了一个有些独特的监控解决方案,它使用低级的工具来实现对 MongoDB 性能的高粒度跟踪。
摘要
我们在本书中一直认为,在更改硬件或服务器配置之前,您应该优化您的工作负载和数据库设计。然而,一旦您有了一个调优的应用,就该监控和调优您的服务器了。
操作系统为 MongoDB 服务器提供了四种关键资源——网络、CPU、内存和磁盘 IO。在这一章中,我们看了监控和理解 CPU 和内存。在接下来的两章中,我们将深入探讨内存和磁盘 IO。
在第 3 章中,我们回顾了 MongoDB 调优的基本工具。图形监控可以通过提供更好的可视化和历史趋势来补充这些工具。MongoDB 在 Compass 桌面 GUI 和基于云的免费监控仪表板中提供免费的图形监控。更广泛的监控可以在 MongoDB 的商业产品中找到:MongoDB Atlas 和 MongoDB Ops Manager。许多商业监控工具也提供了对 MongoDB 性能的洞察。
十一、内存调优
在本书的前几章中,我们研究了减少 MongoDB 服务器上的工作负载需求的技术。我们考虑了对数据集进行结构化和索引的选项,并调整了我们的 MongoDB 请求,以最小化响应工作请求时必须处理的数据量。性能调优带来的 80%的性能提升可能来自于这些应用级的优化。
然而,在某种程度上,我们的应用模式和代码得到了优化,我们对 MongoDB 服务器的要求是合理的。我们现在的首要任务是确保 MongoDB 能够快速响应我们的请求。当我们向 MongoDB 发送数据请求时,最关键的因素变成了数据是在内存中还是必须从磁盘中获取?
和所有数据库一样,MongoDB 使用内存来避免磁盘 IO。从内存读取通常需要大约 20 纳秒。从一个非常快的固态硬盘读取数据需要大约 25 微秒,是这个时间的 1000 倍。从磁盘读取可能需要 4-10 毫秒,也就是慢了 2000 倍!所以 MongoDB——像所有数据库一样——被设计为尽可能避免磁盘 IO。
MongoDB 内存架构
MongoDB 支持多种可插拔存储引擎,每种引擎对内存的利用都不同。事实上,甚至有一个内存中的存储引擎,它只在内存中存储活动数据。然而,在本章中,我们将只关注默认的 WiredTiger 存储引擎。
当使用 WiredTiger 存储引擎时,MongoDB 消耗的大部分内存通常是 WiredTiger 缓存。
MongoDB 根据工作负载需求分配额外的内存。您不能直接控制分配的额外内存量,尽管工作负载和一些服务器配置参数确实会影响分配的内存总量。最重要的内存分配与排序和聚合操作有关——我们在第 7 章中看到了这些。每个到 MongoDB 的连接也需要内存。
在 WiredTiger 缓存中,内存被分配用于缓存集合和索引数据,用于支持事务多版本一致性控制的快照(参见第 9 章),以及缓冲 WiredTiger 预写日志。
图 11-1 展示了 MongoDB 内存的重要组成部分。
图 11-1
MongoDB 内存架构
主机内存
虽然配置 MongoDB 内存是一个大话题,但是从操作系统的角度来看,内存管理非常简单。要么有一些可用的空闲内存,一切都很好,要么没有足够的空闲内存,一切都很糟糕。
当物理空闲内存耗尽时,分配内存的尝试将导致现有的内存分配“换出”到磁盘。由于磁盘比内存慢几百倍,内存分配突然需要多几个数量级的时间来满足。
图 11-2 显示了当内存耗尽时,响应时间如何突然下降。随着可用内存的减少,响应时间保持稳定,但是一旦内存耗尽并且涉及到基于磁盘的交换,响应时间会突然显著下降。
图 11-2
内存、交换和响应时间
Tip
当服务器内存被过度利用时,内存可以被交换到磁盘。在 MongoDB 服务器上,这几乎总是表明 MongoDB 内存配置的内存不足。
虽然我们不希望看到内存过度分配和交换,但我们也不希望看到大量未分配的内存。未使用的内存没有任何用处——将这些内存分配给 WiredTiger 缓存可能比让其闲置更好。
测量记忆
在 Linux 系统上,您可以使用vmstat
命令来显示可用内存:
$ vmstat -s
16398036 K total memory
10921928 K used memory
10847980 K active memory
3778780 K inactive memory
1002340 K free memory
4236 K buffer memory
4469532 K swap cache
0 K total swap
0 K used swap
0 K free swap
这里最关键的计数器是active memory
——代表当前分配给一个进程的内存,以及used swap
,指示有多少内存已经交换到磁盘。如果active memory
接近总内存,你可能会遇到内存不足。Used swap
通常应该为零,尽管在解决了内存不足问题后,swap 可能会在一段时间内包含非活动内存。
在 Windows 上,您可以使用资源监控器应用或从 PowerShell 提示符发出以下命令来测量内存:
PS C:\Users\guy> systeminfo |Select-string Memory
Total Physical Memory: 16,305 MB
Available Physical Memory: 3,363 MB
Virtual Memory: Max Size: 27,569 MB
Virtual Memory: Available: 6,664 MB
Virtual Memory: In Use: 20,905 MB
db.serverStatus()
命令提供了 MongoDB 使用了多少内存的详细信息。以下脚本打印出内存利用率的顶级汇总: 1
mongo>function memory() {
... let serverStats = db.serverStatus();
... print('Mongod virtual memory ', serverStats.mem.virtual);
... print('Mongod resident memory', serverStats.mem.resident);
... print(
... 'WiredTiger cache size',
... Math.round(
... serverStats.wiredTiger.cache
['bytes currently in the cache'] / 1048576
... )
... );
... }
mongo>memory();
Mongod virtual memory 9854
Mongod resident memory 8101
WiredTiger cache size 6195
该报告告诉我们,MongoDB 已经分配了 9.8GB 的虚拟内存,其中 8.1GB 当前被主动分配给物理内存。虚拟内存和常驻内存之间的差异通常表示已经分配但尚未使用的内存。
在分配的 9.8GB 内存中,6.1GB 分配给了 WiredTiger 缓存。
有线内存
大多数 MongoDB 生产部署使用 WiredTiger 存储引擎。对于这些部署,最大的内存块将是 WiredTiger 缓存。在本章中,我们将只讨论 WiredTiger 存储引擎,因为虽然存在其他存储引擎,但它们远没有 WiredTiger 部署得广泛。
WiredTiger 缓存对服务器性能有很大的影响。如果没有缓存,每次数据读取都是磁盘读取。缓存通常会减少 90%以上的磁盘读取次数,因此可以大幅提高吞吐量。
缓存大小
默认情况下,WiredTiger 缓存将被设置为总内存的 50%减去 1GB 或 256MB,以最大值为准。因此,例如,在一个 16GB 的服务器上,您会期望默认大小为 7GB((16/2)-1)。剩余的内存可用于排序和聚合区域、连接内存和操作系统内存。
默认的 WiredTiger 缓存大小是一个有用的起点,但很少是最佳值。如果其他工作负载在同一台主机上运行,可能会过高。相反,在 MongoDB 专用的大型内存系统上,它可能太低了。鉴于 WiredTiger 缓存对性能的重要性,您应该准备好调整缓存大小以满足您的需要。
Tip
默认的 WiredTiger 缓存大小是一个有用的起点,但很少是最佳值。确定和设置最佳值通常是值得的。
mongod 配置参数wiredTigerCacheSizeGB
控制缓存的最大大小。在 MongoDB 配置文件中,这由storage/WiredTiger/engineConfig/cacheSizeGB
路径表示。例如,要将缓存大小设置为 12GB,您可以在您的mongod.conf
文件中指定以下内容:
storage:
wiredTiger:
engineConfig:
cacheSizeGB: 12
您可以在正在运行的服务器上调整 WiredTiger 缓存的大小。以下命令将缓存大小调整为 8GB:
db.getSiblingDB('admin').runCommand({setParameter: 1,
wiredTigerEngineRuntimeConfig: 'cache_size=8G'});
确定最佳缓存大小
太小的缓存会导致 IO 增加,从而降低性能。另一方面,增加超过可用操作系统内存的缓存大小会导致交换,甚至更严重的性能下降。MongoDB 越来越多地部署在云容器中,其中可用内存的数量可以动态调整。即便如此,内存通常是云环境中最昂贵的资源,因此在没有证据的情况下向服务器“扔更多内存”是不可取的。
那么,我们如何确定正确的缓存内存量呢?没有确定的方法来确定更多的高速缓存是否会带来更好的性能,但是我们确实有一些指标可以指导我们。两个最重要的是
-
缓存“命中率”
-
驱逐率
数据库缓存“命中率”
数据库缓存命中率是一个有点臭名昭著的指标,历史悠久。简而言之,缓存命中率描述了您在内存中找到所需数据块的频率:
高速缓存命中率表示数据库高速缓存在不需要读取磁盘的情况下满足的块请求的比例。每次“命中”——当在内存中找到该块时——都是一件好事,因为它避免了耗时的磁盘 IO。因此,很明显,高缓冲区缓存命中率也是一件好事。
不幸的是,虽然缓存命中率明确地衡量了某些东西,但是高缓存命中率并不总是或者甚至通常并不意味着数据库调优良好。特别是,调优不佳的工作负载经常反复读取相同的数据块;这些块几乎肯定在内存中,所以具有讽刺意味的是,最低效的操作往往会产生非常高的缓存命中率。著名的 Oracle 数据库管理员 Connor McDonald 创建了一个脚本,它可以生成任何期望的命中率,本质上是通过反复读取相同的块。Connor 的脚本不执行任何有用的工作,但可以实现几乎完美的命中率。
Tip
缓存命中率没有“正确”的值,高值很可能是工作负载调优不佳的结果,也可能是内存配置调优的结果。
尽管如此,对于一个经过良好调优的工作负载(具有良好的模式设计、适当的索引和优化的聚合管道),观察 WiredTiger 命中率可以让您了解 WiredTiger 缓存支持 MongoDB 工作负载需求的情况。
这里有一个计算命中率的脚本:
mongo> var cache=db.serverStatus().wiredTiger.cache;
mongo> var missRatio=cache['pages read into cache']*100/cache['pages requested from the cache'];
mongo> var hitRatio=100-missRatio;
mongo> print(hitRatio);
99.93843137484377
此计算返回自服务器上次启动以来的缓存命中率。要计算较短时间内的速率,您可以从我们的优化脚本中使用以下命令:
mongo> mongoTuning.monitorServerDerived(5000,/cacheHitRate/)
{
"cacheHitRate": "58.9262"
}
这表明在前 5 秒内的缓存命中率为 58%。
如果我们的工作负载得到很好的调优,较低的缓存命中率表明增加 WiredTiger 缓存可能会提高性能。
图 11-3 显示了不同的缓存大小如何影响未命中率和吞吐量。随着我们增加缓存的大小,我们的命中率会增加,吞吐量也会增加。因此,较低的初始命中率表明增加缓存大小可能会增加吞吐量。
图 11-3
WiredTiger 缓存大小(MB)、未命中率和吞吐量
随着我们增加缓存的大小,我们可能会看到命中率和吞吐量的增加。最后一句话的关键词是可能:一些工作负载将很少或根本不会从增加的缓存大小中受益,要么是因为所有需要的数据都已经在内存中,要么是因为一些数据从未被重新读取,因此无法从缓存中受益。
尽管 WiredTiger 的缺失率并不完美,但对于许多 MongoDB 数据库来说,它是一个至关重要的健康指标。
引用 Mongodb 手册:
性能问题可能表明数据库正在满负荷运行,是时候向数据库添加额外的容量了。特别是,应用的工作集应该适合可用的物理内存。
高缓存命中率是工作集适合内存的最佳指标。
Tip
如果您的工作负载得到了优化,WiredTiger 缓存命中率较低可能表明应该增加 WiredTiger 缓存的大小。
逐出
高速缓存通常不能在内存中保存所有的东西。通常,缓存通过仅将最近访问的数据页面保存在缓存中,来尝试将最频繁访问的文档保存在内存中。
一旦缓存达到其最大大小,为新数据腾出空间就需要从缓存中删除旧数据—逐出。被删除的数据页面通常是最近最少使用的(LRU)页面。
*MongoDB 不会等到缓存完全满了才执行驱逐。默认情况下,MongoDB 将尝试为新数据保留 20%的缓存空间,并在空闲百分比达到 5%时开始限制新页面进入缓存。
如果缓存中的数据项没有被修改,那么驱逐几乎是瞬间的。但是,如果数据块已被修改,则在写入磁盘之前,不能将其收回。这些磁盘写入需要时间。出于这个原因,MongoDB 试图将修改的“脏”块的百分比保持在 5%以下。如果修改块的百分比达到 20%,那么操作将被阻塞,直到达到目标值。
MongoDB 服务器为回收处理分配专用线程——默认情况下,分配四个回收线程。
阻止驱逐
当干净数据块或脏数据块的数量达到较高的阈值时,尝试将新数据块放入缓存的会话将被要求在读取操作完成之前执行驱逐。
因为“紧急”驱逐会阻塞操作,所以您希望确保驱逐配置能够避免这种情况。这些“阻塞”驱逐记录在 WiredTiger 参数“页面获取驱逐阻塞”中:
db.serverStatus().wiredTiger["thread-yield"]["page acquire eviction blocked"]
这些阻止驱逐应该保持相对罕见。您可以计算阻止驱逐与整体驱逐的总比率,如下所示:
mongo> var wt=db.serverStatus().wiredTiger;
mongo> var blockingEvictRate=wt['thread-yield']['page acquire eviction blocked'] *100 / wt['cache']['eviction server evicting pages'];
mongo>
mongo> print(blockingEvictRate);
0.10212131891589296
您可以使用我们的调优脚本计算较短时间段内的比率:
mongo> mongoTuning.monitorServerDerived(5000,/evictionBlock/)
{
"evictionBlockedPs": 0,
"evictionBlockRate": 0
}
如果阻塞驱逐率很高,这可能表明需要更积极的驱逐策略。要么早点开始驱逐,要么在驱逐过程中应用更多的线程。可以更改 WiredTiger 驱逐配置值,但这是一个有风险的过程,部分原因是尽管可以设置这些值,但不能直接检索现有的值。
例如,以下命令将回收线程数和目标设置为它们发布的默认值:
mongo>db.adminCommand({
... setParameter: 1,
... wiredTigerEngineRuntimeConfig:
... `eviction=(threads_min=4,threads_max=4),
... eviction_dirty_trigger=5,eviction_dirty_target=1,
... eviction_trigger=95,eviction_target=80`
... });
如果驱逐出现问题,我们可以尝试增加线程的数量或改变阈值,以促进或多或少的积极驱逐处理机制。
Tip
如果“阻止”驱逐的比率很高,那么可能需要更积极的驱逐政策。但是在调整 WiredTiger 内部参数时要非常小心。
检查站
当更新或其他数据操作语句更改缓存中的数据时,它不会立即反映在表示文档持久表示的数据文件中。数据更改的表示被写入顺序预写日志中。这些顺序日志写入可用于在服务器崩溃时恢复数据,并且所涉及的顺序写入比随机写入要快得多,而随机写入是保持数据文件与缓存绝对同步所必需的。
但是,我们不希望缓存在数据文件之前移动太远,部分原因是这样会增加在服务器崩溃时恢复数据库的时间。因此,MongoDB 会定期确保数据文件与缓存中的更改保持同步。这些检查点涉及将修改后的“脏”块写出到磁盘。默认情况下,检查点每 60 秒出现一次。
检查点是 IO 密集型的—根据缓存的大小和缓存中脏数据的数量,可能需要将许多千兆字节的信息刷新到磁盘。因此,检查点通常会导致吞吐量明显下降——尤其是对于数据操作语句。
图 11-4 说明了检查点的影响——每 60 秒一次;当出现检查点时,吞吐量会突然下降。结果是“锯齿”性能模式。
图 11-4
检查点会导致性能不均衡
这种锯齿性能曲线可能值得关注,也可能不值得关注。但是,有几个选项可以改变检查点的影响。以下设置是相关的:
-
上一节讨论的
eviction_dirty_trigger
和eviction_dirty_target settings
控制在驱逐处理开始之前,缓存中允许有多少修改的块。可以对这些进行调整,以减少缓存中修改的数据块数,从而减少在检查点期间必须写入磁盘的数据量。 -
eviction.threads_min
和eviction.threads_max
设置指定将有多少线程专用于驱逐处理。为逐出分配更多的线程将加快逐出处理的速度,这反过来会在检查点期间在缓存中留下更少的要刷新的块。 -
可以调整
checkpoint.wait
设置来增加或减少检查点之间的时间。如果设置了一个较高的值,那么在检查点出现之前,逐出处理可能会将大多数块写入磁盘,检查点的总体影响可能会降低。然而,这些延迟检查点的开销也可能是巨大的。
检查点没有一个正确的设置,有时检查点的影响可能是反直觉的。例如,当您拥有大型 WiredTiger 缓存时,检查点的开销会更大。这是因为修改块的默认回收策略被设置为 WiredTiger 缓存的一个百分比——缓存越大,回收处理器就越“懒惰”。
但是,如果您愿意尝试,您可以通过调整检查点之间的时间和回收处理的积极性来建立一个较低的检查点开销。例如,这里我们将检查点调整为每 5 分钟出现一次,增加回收线程数,并降低脏块回收的目标阈值:
db.adminCommand({
setParameter: 1,
wiredTigerEngineRuntimeConfig:
`eviction=(threads_min=10,threads_max=10),
checkpoint=(wait=500),
eviction_dirty_trigger=5,
eviction_dirty_target=1`
});
我们想绝对清楚地表明,我们并不推荐前面的设置,也不建议您修改这些参数。但是,如果您担心检查点会产生不可预测的响应时间,这些设置可能会有所帮助。
Tip
默认情况下,检查点每一分钟将修改过的页面写出到磁盘。如果您在一分钟的周期内遇到性能下降,您可能会考虑调整——小心地 WiredTiger 检查点和脏驱逐策略。
WiredTiger 并发
在 WiredTiger 缓存中读写数据需要一个线程获得一个读或写“票”默认情况下,有 128 张这样的票。db.serverStatus()
报告wiredTiger.concurrentTransactions
部分的可用门票数量:
mongo> db.serverStatus().wiredTiger.concurrentTransactions
{
"write": {
"out": 7,
"available": 121,
"totalTickets": 128
},
"read": {
"out": 28,
"available": 100,
"totalTickets": 128
}
}
在前面的示例中,128 个读取票证中有 28 个正在使用,128 个写入票证中有 7 个正在使用。
考虑到大多数 MongoDB 操作的持续时间很短,128 个票通常就足够了——如果并发操作超过 128 个,服务器或操作系统的其他地方就可能出现瓶颈——要么排队等待 CPU,要么排队等待 MongoDB 内部锁。但是,可以通过调整参数wiredTigerConcurrentReadTransactions
和wiredTigerConcurrentWriteTransactions
来增加这些票的数量。例如,要将并发读取器的数量增加到 256,我们可以发出以下命令:
db.getSiblingDB("admin").
runCommand({ setParameter: 1, wiredTigerConcurrentReadTransactions: 256 });
但是,增加并发读取器的数量时要小心,因为较高的值可能会淹没可用的硬件资源。
降低应用内存需求
正如我们前面强调的,当您在优化硬件和服务器配置之前优化应用设计和工作负载时,会产生最佳的优化结果。通过为 IO 开销较高的服务器增加内存,通常可以提高性能。然而,内存并不是免费的,而创建一个索引或调整一些代码不会花费你任何成本——至少从金钱的角度来看是这样的。
我们在本书的前十章中讨论了关键的应用调优原则。然而,关于它们如何影响内存消耗,这里有必要重新概括一下。
文件设计
WiredTiger 缓存存储完整的文档副本,而不仅仅是您感兴趣的文档部分。举例来说,如果你有一个像这样的文档:
{
_id: 23,
Ssn: 605-21-9090,
Name: 'Guy Harrison',
Address: '89 InfiniteLoop Drive, Cupertino, CA 9000',
HiResScanOfDriversLicense : BinData(0,"eJy0kb2O1UAMhV ……… ==")
}
除了用户驾驶执照的大量二进制表示外,文档相当小。WiredTiger 缓存将需要在缓存中存储驾照的所有高分辨率扫描,无论您是否要求。因此,为了最大化内存,你不妨采用第 4 章介绍的垂直分区设计模式。我们可以将驾照扫描放在一个单独的集合中,只在需要时才加载到缓存中,而不是在访问 SSN 记录时才加载。
Tip
请记住,文档越大,缓存中可以存储的文档就越少。保持文档较小可以提高缓存效率。
索引
索引为选定的数据提供了一个快速的路径,但也有助于内存。当我们使用全集合扫描来搜索数据时,所有文档都会被加载到缓存中,而不管该文档是否符合过滤标准。因此,索引查找有助于保持缓存的相关性和有效性。
索引还减少了排序所需的内存。我们在第 6 和 7 章中看到了如何使用索引来避免磁盘排序。但是,如果我们执行大量的内存排序,那么我们将需要操作系统内存(WiredTiger 缓存之外)来执行这些排序。索引排序没有同样的内存开销。
Tip
索引通过只将需要的文档引入缓存和减少排序的内存开销来帮助减少内存需求。
处理
我们在第 9 章中看到了 MongoDB 事务如何使用数据快照来确保会话不会读取未提交版本的文档。在 MongoDB 4.4 之前,这些快照保存在 WiredTiger 缓存中,减少了可用于其他目的的内存量。
因此,在 MongoDB 4.4 之前,向应用添加事务会增加 WiredTiger 缓存所需的内存量。此外,如果您调整transactionLifetimeLimitSeconds
参数以允许更长的事务,您将增加更多的内存压力。从 MongoDB 4.4 开始,快照作为“持久历史”存储在磁盘上,长事务对内存的影响不太显著。
摘要
和所有数据库一样,MongoDB 使用内存主要是为了避免磁盘 IO。如果可能的话,应该在优化内存之前优化应用的工作负载,因为对模式设计、索引和查询的更改都会改变应用的内存需求。
在 WiredTiger 实现中,MongoDB 内存由 WiredTiger 缓存(主要用于缓存频繁访问的文档)和操作系统内存组成,后者用于各种目的,包括连接数据和排序区域。无论您的内存占用量如何,请确保它永远不会超过操作系统的内存限制;否则,部分内存可能会被换出到磁盘。
您可以使用的最重要的调节旋钮是 WiredTiger 缓存大小。默认情况下,它略低于操作系统内存的一半,在许多情况下可以增加,尤其是在服务器上有大量空闲内存的情况下。缓存中的“命中率”是一个可能表明需要增加内存的指标。
缓存和内存的其他区域用来避免磁盘 IO,但是最终,数据库必须发生一些磁盘 IO 来完成它的工作。在下一章中,我们将考虑如何测量和优化必要的磁盘 IO。
*十二、磁盘 IO
在前面的章节中,我们已经尽了最大努力来避免磁盘 IO。通过优化数据库设计和调优查询,我们最小化了工作负载需求,从而降低了 MongoDB 上的逻辑 IO 需求。优化内存减少了转化为磁盘活动的工作量。如果您已经应用了前几章中的实践,那么您的物理磁盘需求已经最小化:现在是时候优化磁盘子系统来满足这种需求了。
降低 IO 需求应该总是先于磁盘 IO 调整。就时间、金钱和数据库可用性而言,磁盘调优通常是昂贵的。这可能涉及购买昂贵的新磁盘设备和执行耗时的数据重组,从而降低可用性和性能。如果您在优化工作负载和内存之前尝试这些事情,那么您可能会为了不切实际的需求而不必要地优化磁盘。
IO 基础知识
在我们研究 MongoDB 如何执行磁盘 IO 操作以及您可能部署的各种类型的 IO 系统之前,有必要回顾一下适用于任何磁盘 IO 系统和任何数据库系统的一些基本概念。
延迟和吞吐量
从性能角度来看,磁盘设备有两个与我们相关的基本特征:延迟和吞吐量。
延迟描述了从磁盘中检索一条信息所需的时间。对于旋转磁盘驱动器,这是将磁盘旋转到正确位置所需的时间(旋转延迟),加上将读/写磁头移动到正确位置所需的时间(寻道时间),再加上将数据从磁盘传输到服务器所需的时间。对于固态磁盘,没有机械寻道时间或旋转延迟,只有传输时间。
IO 吞吐量描述了在给定的时间单位内,磁盘设备可以执行的 IO 数量。吞吐量一般用每秒 IO 操作数来表示,通常缩写为 IOPS 。
对于单个磁盘设备,尤其是固态硬盘,吞吐量和延迟密切相关。吞吐量直接由延迟决定—如果每个 IO 花费千分之一秒,那么吞吐量应该是 1000 IOPS。但是,当多个设备合并到一个逻辑卷中时,延迟和吞吐量之间的关系就不那么直接了。此外,在磁盘中,顺序读取的吞吐量远远高于随机读取。
对于大多数数据库服务器,数据存储在多个磁盘设备上,并在相关的磁盘上“分条”。在这种情况下,IO 带宽是 IO 操作类型(随机与顺序)、服务时间和磁盘数量的函数。例如,包含 10 个服务时间为 10ms 的磁盘的完美条带化磁盘阵列的随机 IO 带宽约为 1000 IOPS(每个磁盘 100 IOPS 乘以 10 个磁盘)。
排队等候
当磁盘空闲并等待请求时,磁盘设备的服务时间仍然是相当可预测的。服务时间会有所不同,具体取决于磁盘的内部缓存以及(对于磁盘)读/写磁头获取相关数据所需移动的距离。但一般来说,响应时间会在磁盘制造商报价的范围内。
然而,随着请求数量的增加,一些请求将不得不等待,而其他请求将得到服务。随着请求速率的增加,最终会形成一个队列。就像在一个繁忙的超市里,你很快就会发现你排队的时间比实际得到服务的时间还要长。
由于排队,当磁盘系统接近最大容量时,磁盘延迟会急剧增加。当磁盘变得 100%繁忙时,任何额外的请求只会增加队列的长度,服务时间会增加,而吞吐量不会随之增加。
这里的教训是,随着磁盘吞吐量的增加,延迟也会增加。图 12-1 说明了吞吐量和延迟之间的典型关系:吞吐量的增加通常与延迟的增加有关。最终,无法实现更多的吞吐量;此时,请求速率的任何增加都会增加延迟,而不会增加吞吐量。
图 12-1
延迟与吞吐量
Note
延迟和吞吐量是相互关联的:增加吞吐量或对磁盘设备的需求通常会导致延迟增加。为了最大限度地减少延迟,可能需要以低于最大吞吐量的速度运行磁盘。
如果单个磁盘的最大 IOPS 有限制,那么实现更高的 IO 吞吐率将需要部署更多的物理磁盘。与延迟计算(由相对复杂的排队论计算控制)不同,所需磁盘设备数量的计算非常简单。如果单个磁盘可以执行 100 IOPS,同时提供可接受的延迟,并且我们认为我们需要提供 500 IOPS,那么我们可能需要至少 5 个磁盘设备。
Tip
IO 系统的吞吐量主要取决于它包含的物理磁盘设备的数量。要增加 IO 吞吐量,请增加磁盘卷中物理磁盘的数量。
但是,并不总是能够确定磁盘设备的“舒适”IO 速率,即提供可接受服务时间的 IO 速率。磁盘供应商指定最小延迟(在不争用磁盘的情况下可以实现的延迟)和最大吞吐量(在忽略服务时间限制的情况下可以实现的吞吐量)。根据定义,磁盘设备的报价吞吐量是磁盘 100%繁忙时可以达到的吞吐量。为了确定在获得接近最小值的服务时间的同时可以实现的 IO 速率,您将希望 IO 速率低于供应商所报的速率。确切的差异取决于您在应用中如何平衡响应时间与吞吐量,以及您使用的驱动器技术类型。然而,超过供应商报价的最大值的 50–70%的吞吐量通常会导致响应时间比供应商公布的最小值高出数倍。
顺序和随机 IO
出于数据库工作负载的目的,IO 操作可以分为两个维度:读取与写入 IO 和顺序与随机 IO。
当按顺序读取数据块时,会出现顺序 IO。例如,当我们使用集合扫描读取集合中的所有文档时,我们正在执行顺序 IO。随机 IO 以任意顺序访问数据页面。例如,当我们在索引查找之后从集合中检索单个文档时,我们正在执行随机 IO。
表 12-1 显示了数据库 IO 如何映射到这两个维度。
表 12-1
数据库 IO 的类别
| |阅读
|
写
|
| --- | --- | --- |
| 随机 | 使用索引阅读单个文档 | 驱逐后将数据从缓存写入磁盘(参见第 11 章) |
| 连续的 | 使用全集合扫描读取集合中的所有文档扫描索引条目以避免磁盘排序 | 写入 WiredTiger 日志或操作日志将数据大容量加载到数据库中 |
磁盘硬件
在本节中,我们将回顾构成存储子系统的各种硬件组件,从单个磁盘或 SSD 磁盘到硬件和基于云的存储阵列。
磁盘(硬盘)
对于几代 IT 专业人士来说,磁盘或硬盘驱动器(?? 硬盘驱动器)已经成为主流计算机设备中无处不在的组件。这项技术最早于 20 世纪 50 年代推出,基本技术一直保持不变:一个或多个盘片包含代表信息位的磁荷。这些磁荷由致动器臂读写,致动器臂在磁盘上移动到盘片半径上的特定位置,然后等待盘片旋转到适当的位置。读取一项信息所花费的时间是将磁头移动到位所花费的时间(寻道时间)、将该项旋转到位所花费的时间(旋转延迟)以及通过磁盘控制器传输该项所花费的时间(传输时间)的总和。图 12-2 1 说明了一个磁盘设备的核心架构。
图 12-2
硬盘驱动器架构
对于数据库工作负载,这个体系结构有一些我们应该知道的含义。虽然随机存取非常慢,因为我们必须等待磁盘磁头移动到位,但顺序读取和写入可以非常快,因为当顺序数据在其下方旋转时,读取磁头可以保持在原位。当我们稍后比较 HDD 和 SSD 的写入性能时,这具有一些含义。
摩尔定律——首先由英特尔创始人戈登·摩尔阐明——观察到晶体管密度每 18-24 个月翻一番。在最广泛的解释中,摩尔定律反映了几乎所有电子组件中常见的索引增长,影响了 CPU 速度、RAM 和磁盘存储容量。
虽然这种索引增长几乎出现在计算的所有电子方面,包括硬盘密度,但它不适用于机械技术,如磁盘 IO 的基础技术。例如,如果摩尔定律适用于磁盘设备的旋转速度,今天的磁盘应该比 20 世纪 60 年代初快 2000 万倍——事实上,它们的旋转速度只有 8 倍。
固态硬盘
固态硬盘(SSD)将数据存储在半导体单元中,没有移动部件。它们为数据传输提供了低得多的延迟,因为不需要等待磁盘设备中所需的磁盘或致动器臂的机械运动。
Note
人们通常将固态设备称为“磁盘”,即使它们没有旋转磁盘组件。
然而,只是在过去的 10-15 年间,固态硬盘才变得足够便宜,成为数据库系统的经济选择。即使是现在,磁盘提供的每 GB 存储比固态硬盘便宜得多,对于某些系统,磁盘或固态硬盘和磁盘的组合将提供最佳的性价比组合。
固态硬盘和磁盘之间的性能差异比简单的快速读取更复杂。正如磁盘的基础架构支持某些 IO 操作一样,固态硬盘的架构也支持不同类型的 IO。了解 SSD 如何处理不同类型的操作有助于我们做出部署 SSD 的最佳决策。
Note
在下面的讨论中,我们将集中讨论基于闪存的 SSD 技术,因为这种技术几乎普遍用于数据库系统。然而,也有基于 DRAM 的 SSD 设备具有更高的成本和更好的性能。
SSD 存储层次结构
固态硬盘有三层存储结构。单个信息位存储在单元中。在单层单元( SLC ) SSD 中,每个单元只存储一位。在多级单元( MLC )中,每个单元可以存储两位或多位信息。因此,MLC SSD 设备具有更高的存储密度,但性能和可靠性较低。
单元以页为单位排列(通常大小为 4K ),页分为 128K 到 1M 的块。
写入性能
由于闪存技术中写入 IO 的特殊特征,页面和数据块结构对于 SSD 性能尤为重要。读操作和初始写操作只需要一次页面 IO。然而,改变页面的内容需要擦除和重写整个块。即使是初始写入也比读取慢得多,但是块擦除操作尤其慢,大约两毫秒。
图 12-3 显示了页面寻道、初始页面写入和块擦除的大致时间。
图 12-3
SSD 性能特征
写入耐久性
写 IO 在固态硬盘中还有另一个后果:经过一定次数的写操作后,一个单元可能会变得不可用。此写耐久性限制因驱动器而异,但对于低端 MLC 设备,通常在 10,000 个周期之间,对于高端 SLC 设备,则高达 1,000,000 个周期。
垃圾收集和损耗均衡
企业 SSD 制造商尽最大努力来避免擦除操作的性能损失和写入耐久性引起的可靠性问题。使用复杂的算法来确保最大限度地减少擦除操作,并确保写入操作在整个设备中均匀分布。
在企业 SSD 中,通过使用空闲列表和垃圾收集来避免擦除操作。在更新期间,SSD 会将要修改的块标记为无效,并将更新的内容复制到从“空闲列表”中检索的空块稍后,垃圾收集例程将恢复无效的块,将其放在空闲列表中以供后续操作使用。一些 SSD 将保持高于驱动器宣传容量的存储,以确保空闲列表不会为此耗尽空块。
损耗均衡是一种算法,可确保任何特定数据块都不会遭受不成比例的写入次数。它可能涉及将“热”块的内容从空闲列表移到块中,并最终将过度使用的块标记为不可用。
SATA 与 PCI
固态硬盘通常以三种形式部署:
-
基于 SATA 或 SAS 的闪存驱动器与使用传统 SAS 或 SATA 连接器连接的其他磁性硬盘驱动器采用相同的封装形式。在图 12-4 中可以看到这样一个例子。
-
图 12-5 中的基于 PCI 的固态硬盘直接连接到电脑主板上的 PCIe 接口。 NVMe 或非易失性存储器快速规范描述了固态硬盘应该如何连接到 PCIe,因此这些类型的磁盘通常被称为 NVMe 固态硬盘。
-
Flash storage servers present multiple SSDs within a rack-mounted server with multiple high-speed network interface cards.
图 12-5
带 PCIe/NVMe 连接器的固态硬盘 3
图 12-4
SATA 和 mSATA 格式的固态硬盘 2
SATA 或 SAS 闪存驱动器比 PCI 便宜得多。然而,SATA 接口是为具有毫秒级延迟的较慢设备而设计的,因此在固态驱动器服务时间上强加了显著的开销。基于 PCI 的设备可以直接与服务器连接,并提供最佳性能。
对固态硬盘的建议
在过去的几页中,我们已经介绍了很多硬件的内部机制,您可能想知道如何将这些应用到您的 MongoDB 部署中。我们可以将磁盘和 SSD 架构的含义总结如下:
-
只要有可能,您应该为 MongoDB 数据库使用基于 SSD 的存储。只有当您拥有大量“冷”数据(很少被访问)时,磁盘才是合适的。
-
如果您正在混合使用存储技术,请记住,硬盘按 GB 计算更便宜,但按 IOPS 计算更贵。换句话说,您将花费更多的钱来尝试使用 HDD 实现给定的每秒 IO 速率,并花费更多的钱来尝试使用 SSD 实现一定量的 GB 存储。
-
基于 PCI 的固态硬盘(NVMe)比基于 SATA 的固态硬盘快,单层单元(SLC)固态硬盘比多层单元(MLC)固态硬盘快。
存储阵列
我们通常不会将生产 MongoDB 实例配置为直接写入单个设备。相反,MongoDB 访问多个磁盘,这些磁盘被组合成一个逻辑卷或存储阵列。
RAID 级别
RAID——最初是廉价磁盘冗余阵列 4 的缩写——定义了各种条带化和冗余方案。术语“RAID 阵列”通常指包括多个物理磁盘设备的存储设备,这些物理磁盘设备可以连接到服务器并作为一个或多个逻辑设备被访问。
存储供应商通常提供三种级别的 RAID:
-
RAID 0 被称为“条带化”磁盘。在这种配置中,逻辑磁盘由多个物理磁盘构成。逻辑磁盘上包含的数据均匀分布在物理磁盘上,因此随机 io 也可能均匀分布。这种配置没有内置冗余,因此如果磁盘出现故障,磁盘上的数据必须从备份中恢复。
-
RAID 1 被称为磁盘“镜像”在这种配置中,逻辑磁盘由两个物理磁盘组成。如果一个物理磁盘出现故障,可以使用另一个物理磁盘继续处理。每个磁盘都包含相同的数据,并且写入是并行处理的,因此对写入性能的负面影响应该很小或没有。可以从任何一个磁盘对进行读取,因此应该增加读取吞吐量。
-
在 RAID 5 中,一个逻辑磁盘由多个物理磁盘组成。数据以类似于磁盘条带化(RAID 0)的方式跨物理设备排列。但是,物理设备上一定比例的数据是奇偶校验数据。这种奇偶校验数据包含足够的信息,可以在单个物理设备出现故障时在其他磁盘上导出数据。
较低的 RAID 级别(2–4)具有与 RAID 5 相似的特征,但在实践中很少遇到。RAID 6 类似于 RAID 5,但具有更多冗余:两个磁盘可以同时发生故障而不会丢失数据。
将 RAID 0 和 RAID 1 组合使用(通常称为 RAID 10 或 RAID 0+1 )是很常见的。这种条带化和镜像配置提供了针对硬件故障的保护,并具有 IO 条带化的优势。RAID 10 有时被称为 SAME (条带化和镜像一切)策略。
图 12-6 说明了各种 raid 级别。
图 12-6
RAID 级别
您可以使用 Linux 和 Windows 提供的逻辑卷管理 ( LVM )软件,使用直接连接的磁盘设备实现 RAID。更常见的是,RAID 配置在硬件存储阵列中。我们很快就会看到这两种情况。
RAID 5 写入损失
RAID 5 为提供容错存储提供了最经济的体系结构,IO 分布在多个物理磁盘上。因此,它在存储供应商和 MIS 部门中都很受欢迎。然而,对于数据库服务器来说,这是一个非常可疑的配置。
RAID 0 和 RAID 5 都通过将负载分散到多个设备来提高并发随机读取的性能。但是,RAID 5 会降低写入 IO 的性能,因为在写入过程中,必须读取源数据块和奇偶校验数据块,然后进行更新,总共四次 IO。如果一个磁盘发生故障,这种退化会变得更加严重,因为为了重建故障磁盘的逻辑视图,必须访问所有磁盘。
从性能的角度来看,RAID 5 几乎没有什么优势,但也有非常明显的缺点。RAID 5 导致的写入损失通常会降低检查点、逐出和日志 IO 的性能。RAID 5 应该只考虑用于以只读为主的数据库。即使对于数据仓库这样的读取密集型数据库,当执行大型聚合时,RAID 5 仍然会导致灾难性的性能:临时文件 IO 将会严重降级,甚至只读性能也会明显受到影响。
Caution
RAID 5 的写入代价使其不适合大多数数据库。当临时文件 IO 发生时,即使显然是只读的数据库也可能被 RAID 5 降级。
RAID 5 设备中的非易失性缓存
通过使用非易失性高速缓存,可以减少与 RAID 5 设备相关的写入损失。非易失性高速缓存是一种带有备用电池的存储器,可确保高速缓存中的数据在断电时不会丢失。因为缓存中的数据受到保护,不会丢失,所以一旦数据存储到缓存中,磁盘设备就可以报告数据已经写入磁盘。数据可以在以后的某个时间点写入物理磁盘。
电池支持的缓存可以极大地提高写入性能,特别是当应用请求确认写入的数据实际上已经提交到磁盘时 MongoDB 几乎总是这样做。这种高速缓存在 RAID 设备中非常常见,部分原因是它们有助于减轻 RAID 5 配置中的磁盘写入开销。有了足够大的缓存,对于突发的写入活动,RAID 5 写入开销几乎可以消除。但是,如果写活动持续一段时间,缓存将被修改的数据填满,阵列性能将下降到底层磁盘的水平,性能可能会突然大幅下降。这种影响是非常显著的——磁盘吞吐量突然大幅下降,服务时间大幅缩短。
自己动手阵列
如果有多个设备直接连接到主机服务器,您可能希望自己对它们进行条带化和/或镜像。这个过程因系统而异,但是在大多数 Linux 系统上,您可以使用mdadm
命令。
这里,我们从两个原始设备/dev/sdh
和/dev/sdi
创建一个条带卷/dev/md0
。–level=0
参数表示 RAID 0 设备。
[root@Centos8 etc]# # Make the array
[root@Centos8 etc]# mdadm --create --verbose /dev/md0 --level=0
--name=raid1a --raid-devices=2 /dev/sdh /dev/sdi
mdadm: chunk size defaults to 512K
mdadm: Defaulting to version 1.2 metadata
mdadm: array /dev/md0 started.
[root@Centos8 etc]# # create a filesystem on the array
[root@Centos8 etc]# mkfs -t xfs /dev/md0
meta-data=/dev/md0 isize=512 agcount=16, agsize=1047424 blks
= sectsz=4096 attr=2,
...
[root@Centos8 etc]# # Mount the array
[root@Centos8 etc]# mkdir /mnt/raid1a
[root@Centos8 etc]# mount /dev/md0 /mnt/raid1a
Filesystem Type 1K-blocks Used Available Use% Mounted on
/dev/md0 xfs 67002404 501408 66500996 1% /mnt/raid1a
如果我们创建了多个 RAID 0 设备,我们可以使用 RAID 1 将它们组合起来,以创建一个 RAID 10 配置。
硬件存储阵列
许多 MongoDB 数据库使用直接连接的存储设备——运行 mongod 实例的服务器可以完全独占地访问使用 SATA、SAS 或 PCIe 接口直接连接到服务器的存储设备。然而,由通常被称为存储阵列的外部存储设备提供存储和 IO 至少是常见的。
存储阵列提供对设备池的共享访问,这些设备通常采用某种 RAID 配置来提供高可用性。通常有一个非易失性内存高速缓存,确保即使在电源故障的情况下,高速缓存中的数据仍能写入磁盘。
存储阵列通过本地(通常是专用的)网络接口连接到服务器,并为服务器提供块设备,该设备提供直接连接的磁盘驱动器的所有功能。
各种硬件供应商提供了各种各样的存储阵列配置。对于 MongoDB 服务器,存储阵列的关键考虑事项如下:
-
无论硬件存储阵列的内部配置有多好,都会增加每个 IO 请求的网络延迟。与优化的直连 IO 相比,硬件存储阵列可能具有更高的延迟。
-
存储阵列的内部配置很重要,关于 HDD 与 SSD、PCI 与 SATA 以及 SLC 与 MLC 的建议都适用于硬件存储阵列。
-
硬件存储阵列供应商通常会试图说服您,他们的 RAID 5 配置比 RAID 10 更经济。然而,数十年的数据库 IO 经验反对这一观点–RAID 5 是一种虚假的经济,它通常会增加 IOPS 的成本,即使它降低了每 GB 存储的美元成本。
Tip
当考虑 IO 子系统时,请记住,您必须为 IOPS 支付高达 GB 的存储费用。RAID 5 可能看起来每 GB 更具成本效益,但它将使实现所需的写入 IO 速率变得更加困难,最终也更加昂贵。
云存储
在云环境中,底层硬件架构通常是模糊的。相反,云供应商提供了各种块存储设备,每种设备都与特定的延迟和吞吐量服务级别相关联。
表 12-2 描述了亚马逊 AWS 云上可用的一些卷类型。谷歌云平台(GCP)和微软 Azure 提供非常相似的产品。
表 12-2
亚马逊 AWS 卷类型
|卷类型
|
描述
|
| --- | --- |
| 通用固态硬盘 | 这些卷基于商用固态硬盘,IO 限制取决于请求的 GB 存储量。用于配置卷的 SSD 数量由请求的存储量决定。100 GB 的卷提供 300 IOPS 的基准。 |
| 调配 IOPS 固态硬盘 | 这些 SSD 卷提供特定的 IO 级别,与所调配的存储量无关。实际上,这意味着 SSD 设备的数量是由 IO 需求决定的,而不是由请求的存储决定的。 |
| 吞吐量优化的硬盘 | 针对顺序读写操作优化的高性能磁盘卷。 |
| 冷硬盘 | 为低成本存储而优化的廉价磁盘。 |
| 实例存储 | 实例存储(或临时磁盘)是直接连接到托管 EC2 虚拟机的物理机的硬盘、SATA 固态硬盘或 NVMe 固态硬盘设备。短暂的 NVMe 磁盘是所有设备类型中速度最快的,但与所有短暂的磁盘一样,一旦实例出现故障,数据就会丢失,因此这些磁盘不应用于 MongoDB 数据文件。 |
我们优化 IO 的指导原则是根据 IO 速率而不是存储容量来配置磁盘。因此,如果为 MongoDB 安装调配基于云的虚拟机,您通常会选择调配的 IOPS SSD 磁盘类型。在 AWS 中,这意味着选择一个配置的 IOPS SSD ,在 GCP 选择 SSD 持久磁盘(pd-ssd) 类型,在 Azure 选择高级 SSD 磁盘。
上述每种磁盘类型都是通过专用网络连接到虚拟机的外部磁盘阵列中的磁盘实现的。如果您需要直连设备的极高性能,例如 NVMe 连接的 SSD,您可以考虑在高性能 EC2 虚拟机中提供高速直连磁盘设备的 AWS Nitro 配置。
Tip
在为 MongoDB 服务器配置基于云的虚拟机时,使用预配置的 IOPS SSD(亚马逊)、高级 SSD 磁盘(GCP)或 SSD 持久磁盘(GCP)。根据所需的 IO 容量而不是存储容量来选择设备。
MongoDB Atlas 中的磁盘设备
在配置 MongoDB Atlas 集群时,您需要配置集群所需的最大 IOPS。在后台,Atlas 会从您选择的云平台中为调配的 SSD 设备附加所需的 IOPS 容量。
蒙戈布我
现在我们已经回顾了各种类型的存储设备的性能特征,让我们来看看 MongoDB 是如何使用这些设备的。
在使用 WiredTiger 作为存储引擎的 MongoDB 的标准配置中,MongoDB 执行三种主要类型的 IO 操作:
-
临时文件 IO 涉及对
dbPath
目录下的_tmp
目录的读写。当进行磁盘排序或基于磁盘的聚合操作时,会出现这些 io。我们在第七章和第十一章中讨论了这些操作。这些 io 通常是顺序读写操作。 -
数据文件 IO 发生在 WiredTiger 读写
dbPath
目录中的集合和索引文件时。对索引文件的读写往往是随机访问(尽管索引扫描可能是顺序的),而对集合文件的读写可能是随机的,也可能是顺序的。 -
当 WiredTiger 存储引擎写入“预写”
journal
文件时,日志文件 IO 发生。这些是顺序写入 io。
图 12-7 展示了 MongoDB IO 的各种类型。
图 12-7
蒙戈布 IO 体系结构
临时文件 IO
当 MongoDB 聚合请求不能在内存中执行,并且allowDiskUse
子句被设置为 true 时,会出现临时文件 IO。在这种情况下,多余的数据将被写入到dbPath
目录下的_tmp
目录下的临时文件中。
例如,在这里我们看到正在进行三次磁盘排序,每次都写入到_tmp
目录中的一个唯一文件:
$ ls -l _tmp
total 916352
-rw-------. 1 mongod mongod 297770960 Sep 26 05:19 extsort-sort-executor.3
-rw-------. 1 mongod mongod 223665943 Sep 26 05:19 extsort-sort-executor.4
-rw-------. 1 mongod mongod 99258259 Sep 26 05:19 extsort-sort-executor.5
读写这些文件的 IO 数量不会直接暴露在db.serverStatus()
中,也不会从监控工具中暴露出来,因此很容易“被发现”事实上,实际上您可能找到磁盘排序证据的唯一地方是在 MongoDB 日志中,并且只有当您设置了慢速查询设置时(参见第 3 章):
[root@Centos8 mongodb]# tail mongod.log |grep '"usedDisk"'|jq
{
<snip>
"msg": "Slow query",
"attr": {
"type": "command",
"ns": "SampleCollections.baseCollection",
"appName": "MongoDB Shell",
"command": {
"aggregate": "baseCollection",
<snip>
"planSummary": "COLLSCAN",
"keysExamined": 0,
"docsExamined": 1000000,
"hasSortStage": true,
"usedDisk": true,
<snip>
"protocol": "op_msg",
"durationMillis": 28011
}
}
当此 IO 变得极端时,它会中断对数据文件和日志的 IO。因此,除了创建缓慢的聚合管道之外,磁盘排序还很容易造成普遍的性能瓶颈。
如果您怀疑临时文件的 IO 是一个问题,您应该考虑增加配置参数internalQueryMaxBlockingSortMemoryUsageBytes
。这一改变可能允许这些操作在内存中得到满足,并避免对_tmp
目录的 IO。
或者,因为这些 io 只针对临时文件,所以您可以考虑将“_tmp
”目录放在快速易失性介质上。这可能是专用的 SSD 或基于云的临时磁盘。正如我们在上一节中讨论的,在云托管的虚拟机中,您通常可以配置快速、直接连接的磁盘,这些磁盘不会在虚拟机重启后持续存在。这些设备可能适用于“_tmp
”目录。
不幸的是,在 MongoDB 的当前实现中,无法将“_tmp
”直接映射到专用设备。您唯一的选择是将所有其他内容映射到专用设备上——这是可能的,但在大多数情况下可能不切实际。有关过程,请参阅本章后面的“在多个设备上拆分数据文件”一节。
《日刊》
当 MongoDB 更改 WiredTiger 缓存中的文档图像时,修改后的“脏”副本不会立即写入磁盘。仅当出现检查点时,修改的页面才会被写入磁盘。我们在前一章讨论了检查点。
为了确保在服务器出现故障时数据不会丢失,WiredTiger 将所有更改写入一个日志文件。日志文件是预写日志(WAL) 模式的一个例子,这种模式在数据库系统中已经普遍使用了几十年。预写日志的优点是可以顺序写入,对于大多数设备(尤其是磁盘),顺序写入可以获得比随机写入更大的吞吐量。
MongoDB 通过db.serverStatus()
输出中“WiredTiger”部分的“log”子部分公开 WiredTiger 日志统计信息:
rs1:PRIMARY> db.serverStatus().wiredTiger.log
{
"busy returns attempting to switch slots" : 1318029,
"force archive time sleeping (usecs)" : 0,
"log bytes of payload data" : 83701979208,
"log bytes written" : 97884903040,
...
"log sync operations" : 415082,
"log sync time duration (usecs)" : 47627625426,
"log sync_dir operations" : 936,
"log sync_dir time duration (usecs)" : 331288246,
...
}
在此部分中,以下统计信息最有用:
-
日志写入字节数:写入日志的数据量。
-
日志同步操作:日志“同步”操作的次数。当内存中保存的日志信息被刷新到磁盘时,就会发生同步。
-
日志同步持续时间(微秒):同步操作花费的微秒数。
通过监控这些指标,我们可以确定数据写入日志的速率以及将数据刷新到磁盘时发生的延迟。由于 MongoDB 会话必须等待这些刷新的发生,因此花费在刷新操作上的时间尤其重要。
以下命令计算自服务器启动以来的平均日志同步时间:
rs1:PRIMARY> var journalStats = db.serverStatus().wiredTiger.log;
rs1:PRIMARY> var avgSyncTimeMs =
... journalStats['log sync time duration (usecs)'] / 1000 /
journalStats['log sync operations'];
rs1:PRIMARY> print('Journal avg sync time (ms)', avgSyncTimeMs);
Journal avg sync time (ms) 114.07684435539662
平均日志同步时间可能是日志磁盘争用最敏感的衡量标准。但是,预期时间确实取决于工作量的性质。在小文档更新的情况下,我们希望日志同步时间非常短,因为要写入的平均数据量很小。另一方面,批量装载大量文档可能会导致更长的平均时间。然而,我们通常对超过 100 毫秒的同步时间感到不舒服,之前的 114 毫秒同步时间可能需要注意。
在我们的调优脚本中(参见第 3 章),我们计算一些与日志相关的统计数据,所有这些数据都以“log”开始。例如,在以下示例中,我们检索 5 秒钟内的日志统计信息:
rs1:PRIMARY> mongoTuning.monitorServerDerived(5000,/^log/)
{
"logKBRatePS": "888.6250",
"logSyncTimeRateMsPS": "379.9926",
"logSyncOpsPS": "6.2000",
"logAvgSyncTime": "61.2891"
}
在本例中,我们看到服务器每秒写入大约 888KB 的日志数据,每秒将这些数据刷新到磁盘大约六次,每次刷新大约需要 61 毫秒。
不幸的是,日志同步时间没有“正确”的值。执行相同逻辑工作量的工作负载可能会导致非常不同的日志活动,具体取决于“批处理”到每个语句中的工作量。例如,考虑以下更新:
db.iotData.find({ _id: { $lt: limit } }, { _id: 1 }).
forEach(id => {
var rc = db.iotData.update(
{ _id: id['_id'] },
{ $inc: { a: 1 } },
{ multi: false }
);
});
该语句会生成许多单独的更新,因此会产生大量的小日志写入。但是,下面的语句执行相同的工作,但只使用一条语句。这会导致日志写入减少,但每次日志写入都会增加。
db.iotData.update(
{ _id: { $lt: limit } },
{ $inc: { a: 1 } },
{ multi: true }
);
图 12-8 说明了效果。单次大容量更新导致日志写入减少,但每次写入操作花费的时间更长。请注意,批量更新所用的日志时间总量是最低的。
图 12-8
批量更新会导致更少但更大的日志同步写入
Note
平均日志“同步”时间是日志写入 IO 争用的最佳指标。然而,平均时间在很大程度上取决于工作负载,并且对于该延迟没有“正确”的值。
将日志移动到专用设备
因为日志的 IO 本质上与其他数据文件的 IO 完全不同,并且因为数据库修改通常必须等待日志写入完成,所以在某些情况下,您可能希望将日志装载到专用的高速设备上。此过程包括安装新的外部磁盘设备,并将日志文件移动到该设备。
这里有一个例子,我们将日志文件移动到位于/dev/sde
的专用设备上:
$ # go to the dbpath directory
$ cd /var/lib/mongodb
$ # Stop the Mongod service
$ service mongod stop
Redirecting to /bin/systemctl stop mongod.service
$ # Mount /dev/sde as the new journal device
$ # and copy existing journal files into it
$ mv journal OldJournal
$ mkdir journal
$ mount /dev/sde journal
$ cp -p OldJournal/* journal
$ # Set permissions including selinux
$ chown -R mongod:mongod journal
$ chcon -R -u system_u -t mongod_var_lib_t journal
$ service mongod start
Redirecting to /bin/systemctl start mongod.service
您还需要通过向/dev/fstab
添加适当的条目来确保这个新设备是永久安装的。
移动日志文件不是一件轻而易举的事情,只有当您有强烈的动机去优化写性能时,才应该这样做。然而,这种影响是显著的。在图 12-9 中,我们比较了装载在外部 HDD 或 SSD 上的日志延迟与日志与数据文件放置在同一文件系统上的默认延迟。
将日志文件移动到专用磁盘增加了写入日志条目的平均时间。但是,将日志移动到专用的高速设备显著减少了平均同步时间。
图 12-9
将日志文件移动到专用设备的效果
Tip
因为日志 IO 本质上与数据文件 IO 完全不同,所以值得将日志移动到专用的高速设备。
数据文件 IO
对于大多数数据库,读取远远超过写入。即使系统是更新密集型系统,也必须先读取数据,然后才能写入数据。只有当工作负载几乎完全由大容量插入组成时,写性能才成为主导因素。
在前一章中,我们详细讨论了 WiredTiger 缓存在避免磁盘读取中的作用。如果可以在缓存中找到文档,则不需要从磁盘中读取该文档,对于典型的工作负载,90%以上的文档读取可以在缓存中找到。
但是,当在缓存中找不到数据时,必须从磁盘中读取数据。将 IO 读取到缓存中会记录在db.serverStatus()
输出的wiredTiger.cache
部分的以下两个统计数据中:
-
应用线程页面从磁盘读取到缓存计数:记录从磁盘读取到 WiredTiger 缓存的次数。
-
应用线程页面从磁盘读取到缓存的时间(usecs) :记录将数据从磁盘移动到缓存所花费的微秒数。
从磁盘读取页面到缓存的平均时间是 IO 子系统健康状况的一个很好的指标。从db.serverStatus()
我们可以计算如下:
mongo> var cache=db.serverStatus().wiredTiger.cache;
mongo> var reads=cache
['application threads page read from disk to cache count'];
mongo> var time=cache
['application threads page read from disk to cache time (usecs)'];
mongo> print ('avg disk read time (ms):',time/1000/reads);
avg disk read time (ms): 0.10630484187820192
虽然将页面读入缓存的平均时间肯定取决于您的硬件配置,并在一定程度上取决于工作负载,但这是一个我们有很好的经验法则基础的指标。如果时间超过了磁盘设备的正常读取时间,那么一定是出了问题!
通常,磁盘到缓存的平均读取时间应该少于 10 毫秒,即使您使用的是磁盘。如果您的磁盘子系统在固态磁盘设备上,那么平均读取时间通常应该低于 1 毫秒。
Tip
如果从磁盘加载页面到缓存的平均时间超过 1–2 毫秒,那么您的 IO 子系统可能会过载。如果您使用磁盘,那么平均时间可能接近 10ms。
数据文件写入
正如我们在第 11 章中讨论的,WiredTiger 异步写入数据文件,大多数时候,应用不需要等待这些写入。如前所述,应用通常只会等待日志写入完成。
但是,如果写 IO 成为瓶颈,那么逐出过程将阻止操作,直到缓存中的脏(修改)数据被充分清除。这些等待很难监控,但是我们在第 11 章中讨论了优化检查点和驱逐处理的选项,试图减少这些等待。
从缓存到磁盘的写入以下列指标记录在db.serverStatus()
输出的WiredTiger.cache
部分:
-
应用线程从缓存到磁盘的页面写入计数:从缓存到磁盘的写入次数
-
应用线程页面从缓存写入磁盘的时间(微秒):从缓存写入磁盘所花费的时间
然而,虽然我们可以从这些指标中计算出平均写入时间,但是很难解释这个结果。从磁盘读取的页面通常应该是可预测的,但是写入磁盘的页面大小可能会有很大差异,因此,您可能会看到平均写入时间因工作负载波动而有所不同。因此,最好使用平均读取时间作为数据文件 IO 运行状况的主要指标。
跨多个设备拆分数据文件
磁盘布局的常规做法是将所有数据文件放在由磁盘阵列支持的单个文件系统上,该磁盘阵列配置为 RAID 10 条带化和镜像。但是,在某些情况下,将数据文件的特定元素映射到专用设备可能是值得的。
例如,您的服务器可能包含一个数据库,其中包含大量“冷”存档数据,以及少量经常修改的“热”数据。将冷数据存储在廉价的磁盘上,将热数据存储在优质的固态硬盘上,这可能既经济又明智。
跨多个设备拆分数据文件是可能的。但是,如果在最初创建数据库时就计划好了,那就简单多了。directoryPerDB
和directoryForIndexes
配置参数导致每个数据库的数据文件存储在它们自己的目录中,索引和集合文件存储在单独的子目录中。
下面是一个配置文件示例,其中设置了这两个参数:
# Where and how to store data.
storage:
dbPath: /mnt/mongodb/mongoData/rs1
directoryPerDB: true
journal:
enabled: true
wiredTiger:
engineConfig:
cacheSizeGB: 16
directoryForIndexes: true
该服务器的dbPath
目录如下所示:
├── _tmp
├── admin
│ ├── collection
│ │ ├── 13--419801202851022452.wt
│ │ ├── 21--419801202851022452.wt
│ │ └── 23--419801202851022452.wt
│ └── index
│ ├── 14--419801202851022452.wt
│ ├── 22--419801202851022452.wt
│ ├── 24--419801202851022452.wt
│ └── 25--419801202851022452.wt
├── config
│ ├── collection
│ │ ├── 17--419801202851022452.wt
│ │ ├── 19--419801202851022452.wt
│ │ └── 34--419801202851022452.wt
│ └── index
│ ├── 18--419801202851022452.wt
│ ├── 20--419801202851022452.wt
│ ├── 35--419801202851022452.wt
│ └── 36--419801202851022452.wt
├── diagnostic.data
│ └── metrics.2020-10-04T07-12-03Z-00000
├── journal
│ ├── WiredTigerLog.0000000014
│ ├── WiredTigerPreplog.0000000014
│ └── WiredTigerPreplog.0000000015
├── sizeStorer.wt
└── storage.bson
如您所见,每个数据库现在都有自己的目录,其中包含集合和索引文件的子目录。要将数据库转移到专用设备,我们可以按照前面使用的相同过程将日志文件转移到专用设备。例如,如果我们有一个包含不常访问的归档的数据库,我们可以将其安装在一个便宜的 HDD 上,而不是安装在可能支持服务器其余部分的快速 SSD 上。
检测和解决 IO 问题
正如您现在所看到的,在 IO 子系统类型、MongoDB IO 操作以及创建 IO 的工作负载方面有很多变化。现在,我们已经回顾了这些方面,是时候面对 IO 调优的两个关键问题了:
-
我如何知道我的 IO 子系统是否过载?
-
对于过载的 IO 子系统,我能做些什么?
我们已经回顾了 IO 过载的一些症状。例如,我们看到从磁盘读取一页数据到缓存的平均时间不应超过 1–2 毫秒(对于基于 SSD 的 IO)。
我们还可以从操作系统统计数据中寻找 IO 过载的证据。您可能还记得本章前面的内容,过载的 IO 子系统会显示出排队。这种排队在操作系统命令中是可见的。
在 Linux 中,我们可以使用iostat
命令来查看磁盘统计数据。这里,我们来看一下sdc
设备(在这个服务器上托管 MongoDB dbPath
目录的设备) 5 的聚合统计数据:
# iostat -xm -o JSON sdc 5 2 |jq
{
"avg-cpu": {
"user": 45.97,
"nice": 0,
"system": 3.63,
"iowait": 1.81,
"steal": 0,
"idle": 48.59
},
"disk": [
{
"disk_device": "sdc",
"r/s": 0.4,
"w/s": 49.2,
"rkB/s": 15.2,
"wkB/s": 2972,
"rrqm/s": 0,
"wrqm/s": 0.4,
"rrqm": 0,
"wrqm": 0.81,
"r_await": 15.5,
"w_await": 42.55,
"aqu-sz": 2.08,
"rareq-sz": 38,
"wareq-sz": 60.41,
"svctm": 0.87,
"util": 4.32
}
]
}
在这个输出中,aqu-sz
统计数据表明了磁盘队列的长度。较高的值表示队列较长,并且表示设备过载。r_await
统计数据表明服务一个读 IO 请求的平均时间,以毫秒为单位。大于 10 毫秒的值可能表示设备过载或配置不足。对于网络连接设备,它可能表示网络传输时间过长。
在 Windows 中,PowerShell 提供了原始性能计数器:
PS C:\Users\guy> Get-Counter -Counter '\\win10\physicaldisk(_total)\% disk time'
Timestamp CounterSamples
--------- --------------
4/10/2020 4:11:56 PM \\win10\physicaldisk(_total)\% disk time :
0.201584556251408
PS C:\Users\guy> Get-Counter -Counter '\\win10\physicaldisk(_total)\current disk queue length'
Timestamp CounterSamples
--------- --------------
4/10/2020 4:12:24 PM \\win10\physicaldisk(_total)\current disk queue length :
0
Tip
磁盘 IO 瓶颈的最佳迹象是将页面读入 WiredTiger 缓存的平均等待时间比平时长。在操作系统层面,队列长度过长也是问题的征兆。
当出现 IO 瓶颈时,有两种补救措施:
-
降低对 IO 子系统的需求。
-
增加 IO 子系统的带宽。
第一个选择——降低对 IO 子系统的需求——几乎是本书前几章的主题。创建索引、优化模式、调优聚合等等都会减少逻辑 IO 请求的数量,从而减少对物理 IO 子系统的需求。配置 WiredTiger 缓存有助于减少变成物理 IO 的逻辑 IO 数量。
本章的重点是优化物理 IO。然而,在你对你的 IO 子系统进行任何重组之前,绝对要确保你已经做了一切来减少需求。特别是,您能为 WiredTiger 缓存腾出更多的内存吗?是否有一个主导 IO 的查询可以优化?如果没有,那么是时候考虑增加 IO 子系统的容量了。
增加 IO 子系统带宽
在“过去”,当数据库在专用硬件设备上运行时,IO 子系统瓶颈的解决方案相对简单:添加更多磁盘或获得更快的磁盘。这仍然是基本的解决方案,尽管它可能被磁盘阵列、云存储设备等提供的抽象层所掩盖。
让我们根据硬件平台的性质,考虑一下增加 IO 带宽可以采取的措施。
带有专用磁盘的专用服务器
如果您的 MongoDB 服务器托管在直接连接磁盘的专用服务器上,那么您有以下选择:
-
如果您直接连接的磁盘是多层单元(MLC)固态硬盘或(抖动)磁盘,那么您应该考虑用高速单层单元(SLC)设备替换它们。SLC 设备的延迟明显低于 MLC 设备,尤其是对于写操作。由于简单的垃圾收集算法,廉价的 MLC 设备通常表现出较差的持续写入吞吐量。
-
您还可以考虑使用 NVMe/PCI 连接的固态硬盘,而不是基于 SATA 或 SAS 的设备。
-
如果您的服务器有额外磁盘的空闲插槽,您可以添加额外的设备,或者跨所有磁盘条带化数据,或者通过将日志文件或数据文件重新定位到专用设备来对 IO 进行分段,如前几节所述。
这些操作中的每一项都涉及数据移动和大量停机时间。因此,如果有更简单的选择(比如给服务器增加更多的内存),你一定要确保你已经用尽了这些选择。
Tip
在直接连接设备的专用服务器上,您可以考虑用高性能设备替换较慢的 SSD 或 HDD,或者连接更多设备并将数据分布到其他设备上。
存储阵列
如果您的 IO 服务是由存储阵列提供的,并且您遇到了 IO 瓶颈,那么您应该检查以下内容:
-
阵列中有哪些类型的设备?一些存储阵列混合使用磁盘和 SSD 来提供存储经济性。然而,这种混合阵列提供了不可预测的性能,尤其是对于数据库工作负载。如果可能,您的存储阵列应该只包含高速固态硬盘。
-
阵列中是否有足够的设备?阵列的最大 IO 带宽将由阵列中的设备数量决定。大多数阵列允许在不停机的情况下添加额外的设备:这可能是增加阵列 IO 容量的最简单的方法。
-
阵列中使用的 RAID 级别是什么?对于数据库工作负载,RAID 10(“条带化和镜像一切”)几乎总是正确的 RAID 级别,而 RAID 5 或 6 几乎总是错误的级别。如果供应商试图告诉您,他们的 RAID 5 拥有某种神奇的技术,可以避免 RAID 5 的写入损失,请持非常怀疑的态度,因为 RAID 5 对于数据库工作负载来说几乎总是坏消息。
Tip
对于依赖存储阵列 IO 的数据库服务器,请确保使用的设备是高速固态硬盘,有足够的固态硬盘来满足 IO 要求,并且 RAID 配置是 RAID 10,而不是 RAID 5 或 RAID 6。
云存储
如果您的服务器运行在云环境中,如 AWS、Azure 或 GCP,那么增加 IO 带宽的常用方法是重新配置虚拟磁盘。只需点击几下鼠标,您就可以为任何连接的磁盘更改类型和调配的 IOPS。在某些情况下,需要重新启动虚拟机才能实施更改。
图 12-10 显示了调整 AWS 卷的大小是多么容易。这里,我们修改连接到 EC2 虚拟机的 EBS 卷的最大 IOPS。
图 12-10
更改 AWS 卷的 IOPS
蒙戈布地图集
为基于 Atlas 的服务器更改 IO 级别甚至更容易。Atlas 控制台允许您选择所需的 IOPS 级别。不需要重新启动服务器,但是当更改通过副本集迁移时,会发生一系列主要的逐步降低。在 Atlas 中配置 IO 的界面如图 12-11 所示。
图 12-11
为 Atlas 服务器调整 IO
Tip
对于 AWS、Azure、GCP 或 Atlas 上基于云的 MongoDB 服务器,只需几次点击就可以改变 IO 带宽,有时甚至不需要停机!
摘要
一旦您尽了一切合理的努力来避免物理 IO——通过减少工作负载和优化内存——就该配置 IO 子系统了,以便它可以满足由此产生的 IO 需求。
单个 IO 的延迟被称为延迟或服务时间,通常以毫秒为单位。单位时间内可以完成的 IO 数量被称为吞吐量,通常用每秒 IO 操作数(IOPS)来表示。
延迟和吞吐量成反比,吞吐量越高,延迟越差。请注意,即使您成功地通过数据库完成了更多的工作,您也可能会对单个事务造成不可接受的延迟。
检测磁盘瓶颈的最佳方法是测量从磁盘读取一个页面到 WiredTiger 缓存的平均时间。如果这个平均值大于几毫秒,那么就有改进的空间。
固态硬盘(SSD)的延迟远远低于磁盘。在固态硬盘中,单层单元(SLC)设备优于多层单元(MLC)设备,NVMe 连接设备优于通过 SATA 或 SAS 接口连接的设备。
吞吐量通常是通过使用多个磁盘设备并跨设备条带化数据来实现的。只有获得足够的磁盘来满足总 IO 需求,才能实现吞吐量目标。或者,您可以将日志文件或特定的数据库目录直接挂载到专用设备上。
配置磁盘阵列的两种最流行的方式是 RAID 5 和 SAME(条带化和镜像一切)(RAID 10)。RAID 5 对写入性能有很大的影响,即使对于主要是只读的数据库,也不推荐使用 RAID 5。基于性能的技术选择也是如此。
十三、副本集和地图集
到目前为止,我们已经考虑了性能调优 singleton MongoDB 服务器——不属于集群的服务器。然而,大多数生产 MongoDB 实例都被配置为副本集,因为只有这种配置才能为现代的“永不停机”应用提供足够的高可用性保证。
在副本集配置中,我们在前面章节中介绍的所有优化原则都不会失效。然而,副本集给我们带来了一些额外的性能挑战和机遇,这将在本章中介绍。
MongoDB Atlas 为我们提供了一种创建云托管、完全托管的 MongoDB 集群的简单方法。除了提供便利和经济优势之外,MongoDB Atlas 还包含一些独特的功能,这些功能涉及到性能机遇和挑战。
副本集基础
我们在第 2 章中介绍了副本集。遵循最佳实践的副本集由一个主节点和两个或多个次节点组成。建议使用三个或更多节点,节点总数为奇数。主节点接受所有同步或异步传播到辅助节点的写请求。在主节点出现故障的情况下,会进行选举,选出一个辅助节点作为新的主节点,并且数据库操作可以继续。
在默认配置中,副本集的性能影响很小。所有读写操作都将定向到主节点,虽然主节点在向辅助节点传输数据时会产生少量开销,但这种开销并不严重。
但是,如果需要更高程度的容错,则要求在确认之前在一个或多个辅助节点上完成写入,可能会牺牲写入性能。这由 MongoDB 写关注点参数控制。此外,MongoDB 读取偏好参数可以配置为允许辅助节点服务读取请求,从而潜在地提高读取性能。
Note
为了清楚地说明读取偏好和写入关注的相对影响,我们使用了一个副本集,其中包含地理上分布广泛的节点——在香港、首尔和东京,应用工作负载来自悉尼。这种配置的延迟比典型配置要高得多,但允许我们更清楚地显示各种配置的相对效果。
使用阅读偏好设置
默认情况下,所有读取都定向到主节点。然而,我们可以设置一个读取偏好,它指示 MongoDB 驱动程序将读取请求指向辅助节点。从二级读取可能更好,原因有两个:
-
辅助节点可能没有主节点忙,因此能够更快地响应读取请求。
-
通过将读取定向到辅助节点,我们减少了主节点上的负载,可能会增加群集的写入吞吐量。
-
通过将读取请求分布到集群的所有节点,我们提高了整体读取吞吐量,因为我们利用了其他空闲的辅助节点。
-
我们可以通过将读取请求定向到离我们“更近”的辅助节点来减少网络延迟——就网络延迟而言。
这些优势需要与读取“陈旧”数据的可能性进行权衡。在默认配置中,只有主设备保证拥有所有信息的最新副本(尽管我们可以通过调整写关注点来改变这一点,如下一节所述)。如果我们从辅助节点读取,我们可能会得到过时的信息。
Warning
辅助读取可能会导致返回陈旧数据。如果这是不可接受的,要么配置写问题以防止过时读取,要么使用默认的primary
读问题。
表 13-1 总结了各种读取偏好设置。
表 13-1
阅读偏好设置
|阅读偏好
|
影响
|
| --- | --- |
| primary
| 这是默认设置。所有读取都定向到主副本集。 |
| primaryPreferred
| 直接读取到主节点,但是如果没有可用的主节点,则直接读取到辅助节点。 |
| secondary
| 直接读取到辅助节点。 |
| secondaryPreferred
| 直接读取到辅助节点,但如果没有辅助节点可用,则直接读取到主节点。 |
| nearest
| 直接读取到副本集成员,与调用程序的网络往返时间最短。 |
如果您已经决定将读取路由到非主节点,建议设置为secondaryPreferred
或nearest
。secondaryPreferred
通常比secondary
好,因为如果没有辅助节点可用,它允许读操作回退到主节点。当有多个辅助节点可供选择,而其中一些“更远”(网络延迟更大)时,那么nearest
会将请求路由到“最近”的节点——辅助节点或非辅助节点。
图 13-1 提供了一个读取偏好设置对从不同位置发出的查询的影响的例子。查询是从每个拥有一个复制集成员的节点(东京、香港和首尔)和悉尼的一个不属于复制集的远程节点发出的。除了在主服务器上直接发出查询之外,secondaryPreferred
读取比primary
读取更快。然而,nearest
读取偏好总是会产生最佳的读取性能。
图 13-1
阅读偏好对阅读性能影响(阅读 411,000 个文档)
Tip
辅助读取通常比主读取快。nearest
读取偏好可以帮助挑选具有最低网络延迟的副本集节点。
设置阅读偏好
可以在连接级别或语句级别设置读取首选项。
要在连接到 MongoDB 时设置它,可以将首选项添加到 MongoDB URI 中。这里,我们将 readPreference 设置为secondary
:
mongodb://n1,n2,n3/?replicaSet=rs1&readPreference=secondary
要为特定语句设置读取首选项,请在与每个命令关联的选项文档中包含读取首选项。例如,在这里,对于 NodeJS 中的 find 命令,我们将 read 首选项设置为nearest
:
const client = await mongo.MongoClient.connect(myMongoDBURI);
const collection=client.db('MongoDBTuningBook').
collection('customers');
const options={'readPreference': mongo.ReadPreference.NEAREST};
await collection.find({}, options).forEach((customer) => {
count++;
});
});
请参阅 MongoDB 驱动程序文档,了解如何在编程语言中设置读取首选项。
maxStalenessSeconds
maxStalenessSeconds
可添加到读取首选项中,以控制数据的容许延迟。当选择一个辅助节点时,MongoDB 驱动程序将只考虑那些在主节点的maxStalenessSeconds
秒内拥有数据的节点。最小值是 90 秒。
例如,此 URL 指定了辅助节点的首选项,但前提是它们的数据时间戳与主节点的数据时间戳相差不超过 5 分钟(300 秒):
mongodb://n1,n2,n3/?replicaSet=rs1\
&readPreference=secondary&maxStalenessSeconds=300
Tip
maxStalenessSeconds
使用辅助阅读首选项时,可以保护您免受严重过时数据的影响。
标签集
标签集可用于微调阅读偏好。使用标记集,我们可以将查询指向特定的辅助节点或辅助节点集。例如,我们可以指定一个节点作为商业智能服务器,另一个节点用于 web 应用流量。
这里,我们将“位置”和“角色”标签应用于副本集中的三个节点:
mongo> conf = rs.conf();
mongo> conf.members.forEach((m)=>{print(m.host);});
mongors01.eastasia.cloudapp.azure.com:27017
mongors02.japaneast.cloudapp.azure.com:27017
mongors03.koreacentral.cloudapp.azure.com:27017
mongo> conf.members[0].tags={"location":"HongKong","role": "prod" };
mongo> conf.members[1].tags={"location":"Tokyo","role":"BI" };
mongo> conf.members[2].tags={"location":"Korea","role": "prod" };
mongo> rs.reconfig(conf);
{
"ok": 1,
...
}
我们现在可以在读取首选项字符串中使用任一标签:
db.customers.
find({ Phone: 40367898 }).
readPref('secondaryPreferred', [{ role: 'prod' }]);
如果我们想设置一个特定的二级服务器作为只读服务器进行分析,标签集是一个完美的解决方案。
我们还可以使用标记集在服务器中的节点上平均分配工作负载。例如,考虑这样一个场景,我们并行地从三个集合中读取数据。使用默认读取首选项,所有读取都将定向到主节点。如果我们选择secondaryPreferred
,那么我们可能会有更多的节点参与到工作中,但是仍然有可能所有的请求都指向同一个节点。然而,通过标记集,我们可以将每个查询指向不同的节点。
例如,这里我们将查询指向香港:
db.getMongo().setReadPref('secondaryPreferred', [{
"location": "HongKong"
}]);
db.iotData1.aggregate(pipeline, {
allowDiskUse: true
});
对集合iotData2
和iotData3
的查询可以类似地指向韩国和日本。这不仅允许集群中的每个节点同时参与,还有助于提高缓存效率,因为每个节点负责一个特定的集合,所以该节点的所有缓存都可以专用于该集合。
图 13-2 显示了使用不同的读取偏好对不同的集合同时进行三次查询所用的时间。使用secondaryPreferred
提高了性能,但是最好的性能是在使用标签集在所有节点上分配负载时实现的。
图 13-2
使用标记集在集群中的所有节点之间分配工作
Tip
标签集可用于将读取请求定向到特定节点。您可以使用标记集来指定用于特殊目的(如分析)的节点,或者在集群中的所有节点之间更均匀地分配读取工作负载。
写关注
读取偏好帮助 MongoDB 决定哪个服务器应该服务于读取请求。写关注点告诉 MongoDB 一个写请求中应该涉及多少个服务器。
默认情况下,当更改进入主数据库的日志文件时,MongoDB 认为写请求完成。写关注允许你改变这个缺省值。写入问题有三种设置:
-
w
控制在写入操作完成之前应该有多少节点接收写入。w
可以设置为一个数字或设置为“majority
”。 -
j
控制写操作在完成前是否需要日志写。它被设置为true
或false
。 -
wtimeout
指定在返回错误之前允许实现写问题的时间量。
日志
如果指定了j:false
,那么如果 mongod 服务器接收到写操作,则认为写操作完成。如果指定了j:true
,那么一旦写入到我们在第 12 章中讨论的预写日志,写入就被认为完成。
没有日志记录的运行被认为是鲁莽的,因为如果 mongod 服务器崩溃,它会导致数据丢失。然而,一些配置允许这样的数据丢失。例如,在w:1,j:true
场景中,如果服务器死亡并故障转移到尚未收到写入的辅助服务器,数据可能会丢失。在这种情况下,设置j:false
可能会增加吞吐量,而不会不可接受地增加数据丢失的机会。
写关注点w
选项
w
选项控制在写入操作完成之前群集中必须有多少节点接收写入。默认设置 1 要求只有主节点接收写入。较高的值要求写入传播到更多的节点。
w:"majority"
设置要求大多数节点在写入完成前接收写入操作。对于数据丢失被视为不可接受的系统而言,w:"majority"
是一个合理的默认值。如果大多数节点都有更新,那么在任何单节点故障或网络分区情况下,新选出的主节点都可以访问该数据。
当然,写入多个节点的影响会产生性能开销。您可能会想象您的数据被同时写入多个节点。但是,写入是对主节点进行的,然后才通过复制机制传播到其他节点。如果已经存在显著的复制延迟,那么延迟可能比预期的要高得多。即使复制延迟很小,复制也只能在初始写入成功后开始,因此性能延迟总是大于w:1
。
图 13-3 显示了{w:2,j:true}
写问题的事件顺序。只有在主节点收到写入并同步到日志后,才会通过复制传输到辅助节点。然后,写操作必须同步到辅助节点上的日志,写操作才能完成。这些操作按顺序进行,而不是并行进行。换句话说,复制延迟会添加到主写入延迟中,而不是同时发生。
图 13-3
w:2, j:true
写问题的事件顺序
图 13-4 显示了插入 50,000 个具有不同写关注级别的文档所花费的时间。更高级别的写入问题会导致吞吐量显著降低。
图 13-4
写操作对写吞吐量的影响
您的写问题设置应该由容错问题决定,而不是由写性能决定。但是,重要的是要认识到,更高级别的写入问题可能会对性能产生重大影响。
Tip
更高级别的写入问题可能会导致写入吞吐量显著下降。但是,如果服务器出现故障,较低级别的写入问题可能会导致数据丢失。
正如我们所见,w:0
提供了绝对最佳的性能。然而,即使数据没有到达 MongoDB 服务器,使用w:0
的写操作也可以成功。即使短暂的网络故障也可能导致数据丢失。在几乎所有的情况下,w:0
就是太不靠谱了。
Warning
w:0
的写入问题可能会导致性能提升,但代价是完全不可靠的数据写入。
写入问题和二次读取
尽管更高级别的写操作会降低修改工作负载,但是如果应用的整体性能是以读操作为主的,那么可能会有一个令人愉快的副作用。如果写入问题被设置为写入集群的所有成员,那么辅助读取将总是返回正确的数据。这可能允许您使用二次读取,即使您不能容忍陈旧的查询。
但是,请注意,如果您手动设置群集中的节点数,群集中的任何故障都可能导致读取超时。
Warning
将w
设置为集群中的节点数将导致辅助读取始终返回最新数据。但是,如果节点不可用,写操作可能会失败。
蒙戈布地图集
MongoDB Atlas 是 MongoDB 的完全托管的数据库即服务(DBaaS)产品。使用 Atlas,您可以从 web 界面创建和配置 MongoDB 副本集和分片集群,而无需配置自己的硬件或虚拟机。Atlas 负责大多数数据库操作考虑事项,包括备份、版本升级和性能监控。Atlas 也可以在三大公共云中使用:AWS、Azure 和 Google Cloud。
在部署 MongoDB 集群时,Atlas 通过在幕后处理大量脏活来提供很多便利。然而,除了操作优势之外,Atlas 还拥有其他部署类型所不具备的额外功能。这些特性包括高级分片和查询选项,这些选项在创建新集群时非常有吸引力。
尽管实施这些选项可能就像点击一个按钮一样简单,但重要的是要记住,它们也可能需要仔细的规划和设计才能发挥其全部潜力。在下文中,我们将详细介绍这些 Atlas 特性及其对性能的影响。
地图集搜索
Atlas Search (以前称为 Atlas 全文搜索)是建立在 Apache Lucene 基础上的一个功能,提供了更强大的文本搜索功能。尽管所有版本的 MongoDB 都支持文本索引(参见第 5 章),但是 Apache Lucene 集成提供了更强大的文本搜索能力。
Apache Lucene 的优势是通过分析器提供的。简单地说,分析器将决定如何创建文本索引。您可以创建一个定制的分析器,但是 Atlas 提供的内置选项将覆盖大多数用例。
在索引创建过程中选择合适的分析器是改善 Atlas 搜索查询结果的最简单方法之一。
Note
当我们谈论提高文本搜索的性能时,我们并不总是指查询速度。一些分析器可以通过提供更相关的评分结果来提高查询的“性能”,但也可能导致查询速度变慢。
五个预构建的分析器包括
-
标准:所有单词转换成小写,忽略标点符号。此外,标准分析器可以正确解释特殊符号和首字母缩略词,并会丢弃“和”等连接词以提供更好的结果。标准分析器为每个“单词”创建索引条目,是最常用的索引类型。
-
Simple :正如您可能猜到的,Simple 分析器类似于标准分析器,但是在确定每个索引条目的“单词”时,它的逻辑不太先进。所有单词都转换成小写。一个简单的分析器将通过在任何两个不是字母的字符之间找到一个单词来创建一个条目。与标准分析器不同,简单分析器不处理连接词。
-
空白符:如果简单分析器是标准分析器的简化版本,空白符分析器会更进一步。单词不会被转换为小写,并且条目是为由空白字符分隔的任何字符串创建的,不需要额外处理标点符号或特殊字符。
-
Keyword :关键字分析器将字段的整个值作为单个条目,需要精确匹配才能在查询中返回结果。这是所提供的最具体的分析器。
-
语言:语言分析器是 Lucene 特别强大的地方,因为它为你可能遇到的每种语言提供了一系列预置。每个预设将基于以该语言编写的文本的典型结构创建索引条目。
在创建 Atlas 搜索索引时,没有单一的最佳分析器可供选择,做出选择也不仅仅是关于查询速度。您必须考虑数据的形状和用户可能发送的查询类型。
让我们看一个基于房产租赁市场数据集的例子。在这个数据集中,大量的文本数据以不同的属性存在。名称、地址、描述和属性元数据都作为字符串存储在每个列表中,还有评论和评论。
根据哪个分析器最适合匹配的查询,这些属性中的每一个都最适合不同类型的搜索索引。描述和注释最好由解释特定语言语义的语言索引来提供。像“房子”或“公寓”这样的属性类型最匹配关键字分析器,因为我们想要精确的匹配。其他字段可能被标准分析器正确索引,或者根本不需要索引。
选择分析器时要考虑的另一个因素是创建的索引的大小。图 13-5 是每个分析器在小文本字段(属性名称)和大文本字段(属性描述)上的索引大小的比较。
图 13-5
按分析器和字段长度的索引大小(5555 个文档)
尽管这些结果会因文本数据本身的不同而有很大差异,但该图表主要表明了两件事。
首先,文本字段越小,索引大小的变化就越小,甚至没有变化(因此扫描索引所需的时间也就越少)。这是有意义的,因为较少数量的单词或字符可以被细分成较少数量的方式,并且不太可能需要复杂的规则来创建索引。
其次,对于更大、更复杂的文本数据,不同分析器类型的索引大小会有很大的不同。有时,较大的索引是一件好事,可以提供更好的结果和性能。然而,在创建 Atlas 搜索索引时,这仍然是值得考虑的事情。
现在我们知道了不同的分析器类型如何影响索引大小,但是查询时间呢?图 13-6 显示了针对五种不同索引分析器类型执行的相同查询的执行时间。
图 13-6
按索引分析器类型划分的查询持续时间(5555 个文档,1000 个查询)
如果我们只看这些数据,我们会假设关键字分析器将为我们的查询提供最佳性能。然而,对于任何文本搜索,我们也需要考虑我们的结果评分。
例如,考虑以下查询:
db.listingsAndReviews.aggregate([
{
$search: {
text: {
query: ["oven", "microwave", "air conditioning"],
path: "notes",
},
},
},
{$limit: 3,},
{$project: {
name: 1,
score: { $meta: "searchScore" },},
},
]);
表 13-2 显示了我们针对每种指标类型的得分最高的文档。
表 13-2
不同分析仪类型的性能
|分析者
|
查询时间(分钟)
|
得分
|
文件
|
| --- | --- | --- | --- |
| 标准 | Two point one three | Six point two five | studio 1q LeBron,促销... |
| 简单的 | Two point five | Six point zero nine | 1q LeBron 工作室,推广自-我...。 |
| 空白 | Two point one | Six point one six | 树蕨园附件,… |
| 关键字 | One point nine nine | | |
| 语言 | Two point one one | Five point four eight | 1q LeBron 工作室,推广自-我...。 |
您可能注意到的第一件事是,关键字分析器没有为我们的查询返回任何文档(因此得分为 0),尽管查询时间最短。这是意料之中的,因为关键字索引要求与字段的整个值完全匹配。所以虽然很快,但不一定能返回最好的结果。
您可能还注意到,对于我们剩余的分析器,只有空白索引返回了不同的结果。其他类型的人找到了同样的文件,但是他们的可信度不同。图 13-7 显示了这些结果的散点图。
图 13-7
查询持续时间、文档得分和按分析器的文档(5555 个文档,1000 个查询)
这些结果大致对应于我们创建的索引大小,索引越大,返回结果的时间就越长。有趣的是,尽管标准分析器不是最快的,但它确实提供了高可信度结果的最佳组合,只不过查询时间多了一点点。您可能期望特定于语言的分析器比标准分析器执行得更好。在这种情况下,在索引字段和许多其他字段中都有多种语言。当涉及到用户输入时,很难保证有一种统一的语言。
您可以在数据集上重复这一分析,尝试为地图集搜索找到合适的分析器。在创建图集搜索索引时,考虑数据类型以及查询类型是必不可少的。虽然没有永远正确或永远错误的答案,但标准分析仪很可能为您提供良好的整体性能。但是,要注意不同的分析器可以返回不同的结果,如果返回错误的结果,那么提高查询速度通常不是好的做法。
Tip
各种 Atlas Search 文本搜索分析器具有不同的性能特征。然而,最快的分析器可能不会为您的应用返回最佳结果。确保在结果的准确性和文本搜索的速度之间取得平衡。
阿特拉斯数据湖
随着大数据和 Hadoop 等技术的兴起,“数据湖”作为大量结构化或非结构化数据的集中存储库的概念变得流行起来。从那时起,它已经成为许多企业环境中的标准配置。MongoDB 引入了 Atlas 数据湖作为与该模式集成的方法。简而言之,Atlas 数据湖允许您使用 Mongo 查询语言从亚马逊 S3 存储桶中查询数据。
Atlas Data Lake 是一个强大的工具,可以将您的 MongoDB 系统扩展到外部、非 BSON 数据,虽然它具有普通 MongoDB 数据库的外观,但是在查询 Data Lake 时需要考虑一些事项。
数据湖的第一个方面可能会让您止步不前,那就是缺少索引。Data Lake 中没有索引,所以默认情况下,您的许多查询将通过对所有文件的完整扫描来解决。
但是,有一种方法可以绕过这个限制。通过创建名称反映关键属性值的文件,我们可以将文件访问限制为仅相关文件。
例如,假设您的数据湖设置为每个集合一个文件。一个单独的customers.json
文件包含您的所有客户,它被映射到customers
集合,如下例所示:
databases: {
dataLakeTest: {
customers: [
{
definition: '/customers.json',
store: 's3store'
}
],
}
}
我们无法索引这些文件;然而,我们可以用多个文件来定义集合,每个客户一个文件,其中文件名是customerId
(我们想要索引的字段):
customers: [
{
definition: '/customers/{customerId string}',
store: 's3store'
}
],
我们的新集合现在由/customers 文件夹中所有文件的联合来定义。customers
文件夹中的每个文件将以customerid
值命名;例如,文件/customers/1234.json
将包含所有带有1234
的customerId
的数据。数据湖现在只需要扫描查询中涉及的客户 id 的文件,而不是目录中的所有文件。通过查看解释计划,您可以看到这一点:
> db.customersNew.find({customerId:"1234"}).explain("queryPlanner")
{
"ok": 1,
"plan": {
"kind": "mapReduce",
"map": [{
"$match": {
"customerId": {
"$eq": "1234"
}
}
}],
"node": {
"kind": "data",
"partitions": [{
"source": "s3://datalake02/customers/1234?delimiter=/®ion=ap-southeast-2",
"attributes": {
"customerId": "1234"
}
}]
}
}
}
我们可以看到只有一个文件(分区)和匹配分区的名称被访问。
Tip
我们可以通过创建其内容和文件名对应于特定键值的文件来避免扫描 Atlas 数据湖中的所有文件。
缺少索引会导致问题的另一个领域是在$lookup
的情况下。正如我们在第 7 章中讨论的,当用$lookup
优化连接时,索引是绝对必要的。
如果我们在一个 Atlas 数据湖中的两个集合之间进行连接,我们肯定希望确保在$lookup
部分中引用的集合是基于连接条件进行分区的。我们可以在图 13-8 中看到这是如何提高$lookup
性能的。
图 13-8
$lookup
数据湖中文件结构的性能(5555 个文档)
此外,这种方法更具可扩展性。使用针对单个文件的$lookup
,必须为我们加入的每个客户重复扫描该文件。然而,由于每个客户有单独的文件,所以每个$lookup
操作读取的文件要小得多。对于单个大型文件,随着文档被添加到文件中,性能会急剧下降,而对于多个文件,性能会更线性地增长。
将数据分割成多个文件有一些缺点。如您所料,当扫描整个集合时,打开每个文件会有开销。例如,对一个集合中的所有文档进行计数的简单聚合在单个文件上几乎可以立即完成,但是当每个文档都存在于其文件中时,就要花费长得多的时间。打开每个文件的开销决定了查询的性能。我们可以在图 13-9 中看到这一点。
图 13-9
数据湖中按文件结构分类的完整收集查询持续时间(254,058 个文档)
总之,虽然不能直接在 Data Lake 中索引文件,但是可以通过操作文件名来弥补一些性能损失。文件名可以成为一种高级索引,这在使用$lookup
时特别有用。但是,如果您总是访问完整的数据集,那么对单个文件的扫描性能将是最佳的。
摘要
大多数 MongoDB 产品实现都包含副本集,以提供高可用性和容错能力。副本集并不是为了解决性能问题,但是它们肯定会影响性能。
在副本集中,读取偏好可以被设置为允许从辅助节点读取。辅助读取可以在集群中的更多节点上分配工作,减少地理上分散的集群中的网络延迟,并允许并行处理工作负载。然而,二次读取可能会返回过时的结果,这并不总是可以接受的。
副本集写关注点控制在可以确认写之前必须有多少节点确认写。更高级别的写操作为数据提供了更大的保证,但却是以牺牲性能为代价的。
MongoDB Atlas 至少增加了两个对性能有影响的重要特性。Atlas 文本搜索允许更复杂的全文索引,而 Atlas 数据湖允许对低成本云存储上的数据进行查询。
十四、分片
在前一章中,我们介绍了最常部署的 MongoDB 配置:副本集。副本集对于现代应用来说是必不可少的,这些应用需要单个 MongoDB 实例无法提供的可用性。正如我们已经看到的,副本集可以通过二次写入进行一些有限的读取扩展。但是,对于大型应用,尤其是写入工作负载超过单个群集的能力时,可以部署分片群集。
我们在前面章节中介绍的所有内容都完全适用于分片的 MongoDB 服务器。事实上,在使用前面章节中介绍的技术优化应用工作负载和单个服务器配置之前,最好不要考虑分片。
然而,分片 MongoDB 部署带来了一些重要的性能机会和挑战,这些将在本章中讨论。
切分基础知识
我们在第 2 章介绍了分片。在分片的数据库集群中,所选的集合跨多个数据库实例进行分区。每个分区被称为一个“碎片”这种划分是基于分片键值的。
副本集旨在提供高可用性,而分片旨在提供更大的可伸缩性。当您的工作负载(尤其是写入工作负载)超过服务器的容量时,分片提供了一种将工作负载分散到多个节点的方法。
缩放和分片
分片是一种架构模式,旨在让数据库支持世界上最大的网站的大量工作负载。
随着应用负载的增长,在某些时候,工作负载会超出单台服务器的能力。可以通过将一些读取工作负载转移到辅助节点来扩展服务器的能力,但是最终主节点的写入工作负载量会变得太大。我们不能再“扩大规模”
当“纵向扩展”变得不可能时,我们转向“横向扩展”我们添加更多主节点,并使用分片在这些主节点之间分配工作负载。
大规模分片对现代网络的建立至关重要——脸书和 Twitter 都是使用 MySQL 大规模分片的早期采用者。然而,它并不普遍受欢迎 MySQL 的分片涉及大量的手动配置,并破坏了一些核心数据库功能。然而,MongoDB 中的分片完全集成到核心数据库中,并且相对容易配置和管理。
分片概念
分片是一个很大的话题,我们不能在这里提供所有分片考虑的教程。请查阅 MongoDB 文档或 Nicholas Cottrell 的书 MongoDB 拓扑设计(a press,2020)以获得对分片概念的完整回顾。
以下分片概念尤为重要:
-
碎片键:碎片键是决定任何给定文档将被放入哪个碎片的属性。分片键应该具有高基数(许多唯一值),以确保数据可以均匀地分布在各个分片上。
-
组块:文档包含在组块中,组块被分配给特定的碎片。分块避免了 MongoDB 必须费力地跨分片移动单个文档。
-
范围分片:使用范围分片,相邻的分片键组存储在同一个块中。范围分片允许高效的分片键范围扫描,但是如果分片值单调增加,可能会导致“热”块。
-
散列分片:在基于散列的分片中,基于应用于分片密钥的散列函数来分发密钥。
-
平衡器 : MongoDB 试图保持分配给每个分片的数据和工作负载相等。平衡器定期将数据从一个碎片移动到另一个碎片,以保持这种平衡。
切还是不切?
分片是最复杂的 MongoDB 配置拓扑,世界上一些最大、性能最好的网站都在使用分片。所以分片一定对性能有好处,对吧?嗯,事情没那么简单。
分片在您的 MongoDB 数据库之上增加了一层复杂性和处理,这通常会使单个操作变得稍微慢一些。但是,它允许您在工作负载上投入更多的硬件资源。如果且仅如果您有一个涉及到主副本集操作的硬件瓶颈,那么分片可能是最好的解决方案。然而,在大多数其他情况下,分片会增加部署的复杂性和开销。
图 14-1 比较了相同硬件上一些简单操作的分片和非共享集合的性能。 1 在大多数情况下,对分片集合的操作要比对非分片集合的操作慢。当然,每个工作负载都会有所不同,但关键是单靠分片并不能让事情进展得更快!
图 14-1
分片并不总是有助于性能
就硬件的美元成本和运营开销而言,分片是昂贵的。这确实应该是最后的手段。只有当您用尽了所有其他调优措施和所有“扩展”选项时,才应该考虑分片。特别是,在考虑分片之前,要确保主磁盘上的磁盘子系统已经过优化。购买和部署一些新的固态硬盘比共享一个主硬盘要便宜得多,也容易得多!
Warning
分片应该是扩展 MongoDB 部署的最后手段。在开始分片项目之前,请确保您的工作负载、服务器和副本集配置已经过优化。
即使您认为分片是不可避免的,您仍然应该在开始分片项目之前彻底调优您的数据库。如果您的工作负载和配置产生了不必要的负载,那么您最终可能会产生更多不必要的碎片。只有当您的工作负载得到优化时,您才能合理地确定您的分片需求。
碎片键选择
分片发生在集合级别。虽然集群中的碎片数量对于所有集合都是相同的,但是并非所有集合都需要被碎片化,并且并非所有集合都需要具有相同的碎片键。
如果集合上的总 IO 写入需求超过单个主节点的容量,则应该对集合进行分片。然后,我们根据以下标准选择分片密钥:
-
这些键应该有一个高基数,以便在必要时可以将数据分成小块。
-
这些键应该有个均匀分布的值。如果任何单个值特别常见,那么 shard 键可能是一个糟糕的选择。
-
这个键应该经常包含在查询中,这样查询就可以被路由到特定的碎片。
-
关键应该是非单调递增。当碎片键值单调增加时(例如,总是以设定值增加),则新文档出现在相同的块中,导致热点。如果您确实有一个单调递增的键值,可以考虑使用散列分片键。
Tip
选择正确的分片键对于分片项目的成功至关重要。分片键应该支持跨分片文档的良好平衡,并支持尽可能多的查询过滤条件。
基于范围和基于散列的分片
跨碎片的数据分布可以是基于范围的或基于散列的。在基于范围的分区中,每个分片都被分配了一个特定范围的分片键值。MongoDB 查询索引中键值的分布,以确保每个碎片都分配有大致相同数量的键。在基于散列的分片中,基于应用于分片密钥的散列函数来分发密钥。
每种方案都有优点和折衷之处。图 14-2 展示了插入和范围查询的范围和散列分片所固有的性能权衡。
图 14-2
基于范围和基于哈希的分片比较
基于范围的分区允许高效地执行分片键范围扫描,因为这些查询通常可以通过访问单个分片来解决。基于散列的分片要求通过访问所有分片来解决范围查询。另一方面,基于散列的分片更有可能将“热”文档(例如,未完成的订单或最近的帖子)均匀地分布在集群中,从而更有效地平衡负载。
Tip
散列碎片键导致更均匀分布的数据和工作负载。但是,对于基于范围的查询,它们会导致较差的性能。
散列碎片键确实会导致更均匀的数据分布。然而,我们很快就会看到,散列碎片键确实给各种查询操作带来了巨大的挑战,尤其是那些涉及排序或范围查询的查询操作。此外,我们只能对单个属性进行哈希,而我们理想的碎片键通常由多个属性组成。
然而,有一个用例明确指出了散列碎片键。如果我们必须对一个不断增加的属性进行分片——通常称为单调增加的属性——那么范围分片策略将导致所有新文档被插入到一个分片中。这个碎片在插入和读取方面将变得“热”,因为最近的文档比旧文档更容易被更新和读取。
散列碎片键在这里起了拯救作用,因为散列值将均匀地分布在碎片上。
图 14-3 展示了单调递增的分片键如何影响使用散列或范围分片键的集合插入。在这个例子中,碎片键是orderDate
,它总是随着时间的推移而增加。使用散列分片,插入在分片之间均匀分布。在范围分片场景中,所有文档都被插入到一个单独的分片中。散列碎片键不仅将工作负载分布在多个节点上,而且由于单个节点上的争用更少,还会导致更大的吞吐量。
图 14-3
将 120,000 个文档插入分片集合的时间–散列与范围单调递增键
Tip
如果您的碎片键必须是一个永久(单调)递增的值,那么散列碎片键是更好的选择。但是,如果需要对 shard 键进行范围查询,请考虑对另一个属性进行分片的可能性。
区域分片
大多数时候,我们的分片策略是将文档和工作负载平均分布在所有的分片上。只有平均分配负载,我们才有希望获得有效的可伸缩性。如果一个碎片负责不成比例的工作量,那么这个碎片可能会成为我们整个应用吞吐量的一个限制因素。
然而,分片还有另一个可能的动机——在分片之间分配工作负载,以便从网络角度来看,数据靠近需要该数据的应用,或者分配数据,以便“热”数据存储在昂贵的高性能硬件上,而“冷”数据存储在较便宜的硬件上。
区域分片允许 MongoDB 管理员微调文档到分片的分发。通过将一个碎片与一个区域相关联,并在该区域内的集合中关联一系列键,管理员可以明确地确定这些文档将驻留在哪个碎片上。这可以用于将数据归档到更便宜但速度更慢的存储碎片中,或者将特定数据定向到特定的数据中心或地理位置。
为了创建区域,我们首先将碎片分配给区域。在这里,我们为美国创建一个区域,为世界其他地区创建另一个区域:
sh.addShardToZone("shardRS2", "US");
sh.addShardToZone("shardRS", "TheWorld");
尽管我们只有两个区域,但我们可以拥有任意多的碎片,每个区域可以有多个碎片。
现在我们给每个区域分配分片键范围。在这里,我们按照国家和城市进行了划分,因此我们使用minKey
和maxKey
作为国家范围内城市值高低的代表:
sh.addTagRange(
"MongoDBTuningBook.customers",
{ "Country" : "Afghanistan", "City" : MinKey },
{ "Country" : "United Kingdom", "City" : MaxKey },
"TheWorld");
sh.addTagRange(
"MongoDBTuningBook.customers",
{ "Country" : "United States", "City" : MinKey },
{ "Country" : "United States", "City" : MaxKey },
"US");
sh.addTagRange(
"MongoDBTuningBook.customers",
{ "Country" : "Venezuela", "City" : MinKey },
{ "Country" : "Zambia", "City" : MaxKey },
"TheWorld");
然后,我们会将“美国”区域的硬件放在美国的某个地方,将“世界”区域的硬件放在世界其他地方(可能是欧洲)。我们还将在这些地区部署 mongos 路由。图 14-4 展示了这种部署可能的样子。
图 14-4
减少地理网络延迟的区域共享
最终结果是,从美国路由发出的美国查询的延迟更低,其他地区也是如此。当然,如果从欧洲发出对美国数据的查询,往返时间会更长。但是,如果从一个区域发出的查询主要是针对分区到该区域的数据,那么整体性能会得到提高。
随着应用的增长,我们可以在其他区域添加更多的区域。
Tip
区域分片可用于跨地理分布数据,减少特定区域查询的延迟。
区域分片的另一个用途是在缓慢但便宜的硬件上创建旧数据的档案。例如,如果我们有几十年的订单数据,我们可以为托管在具有更少 CPU、内存的虚拟机或服务器上的旧数据创建一个区域,甚至可以使用磁盘而不是高级 SSD。最近的数据可以保存在高速服务器上。对于给定的硬件预算,这可能会带来更好的整体性能。
碎片平衡
getShardDistribution()
方法可以显示跨分片的数据分解。以下是一个平衡的分片系列示例:
mongo> db.iotDataHshard.getShardDistribution()
Shard shard02 at shard02/localhost:27022,localhost:27023
data : 304.04MiB docs : 518520 chunks : 12
estimated data per chunk : 25.33MiB
estimated docs per chunk : 43210
Shard shard01 at shard01/localhost:27019,localhost:27020
data : 282.33MiB docs : 481480 chunks : 11
estimated data per chunk : 25.66MiB
estimated docs per chunk : 43770
Totals
data : 586.38MiB docs : 1000000 chunks : 23
Shard shard02 contains 51.85% data, 51.85% docs in cluster, avg obj size on shard : 614B
Shard shard01 contains 48.14% data, 48.14% docs in cluster, avg obj size on shard : 614B
在一个平衡的分片集群中,每个分片中有大约相同数量的块和相同数量的数据。如果碎片之间的块数量不一致,那么平衡器应该能够迁移块以恢复集群的平衡。
如果块的数量大致相当,但是每个分片的数据量相差很大,那么可能是你的分片键分布不均匀。单个碎片键值不能跨越块,所以如果一些碎片键有大量的文档,那么就会产生大量的“巨型”块。巨型块是次优的,因为其中的数据不能有效地跨分片分布,因此更大比例的查询可能被发送到单个分片。
重新平衡碎片
假设您已经选择了一个合适的分片键类型(range 或 hashed ),并且该键拥有正确的属性——高基数、均匀分布、频繁查询、非单调递增。在这种情况下,您的块可能会在各个分片之间得到很好的平衡,因此,您将获得分布良好的工作负载。然而,几个因素可能会导致碎片失去平衡,一个碎片上的块比另一个碎片上的块多得多。当这种情况发生时,单个节点将成为瓶颈,直到数据可以在多个节点之间均匀地重新分布——如图 14-5 所示。
图 14-5
一组不均衡的碎片,大部分查询将去往碎片 01
如果我们能够在我们的碎片之间保持适当的平衡,查询负载更有可能在节点之间平均分配——如图 14-6 所示。
图 14-6
一组平衡良好的碎片:查询负载将均匀分布
幸运的是,只要在碎片之间检测到足够大的差异,MongoDB 就会自动重新平衡碎片集合。这种差异的阈值取决于总块的数量。例如,如果有 80 个或更多的块,阈值将是一个分片上最多的块和最少的块之间的差值 8。对于 20 到 80 之间的块,阈值是 4,如果块少于 20,阈值是 2。
如果检测到这种差异,分片平衡器将开始迁移块,以重新平衡数据的分布。这种迁移可能是由于在特定范围内插入了大量新数据,或者仅仅是由于添加了一个碎片。一个新的碎片最初是空的,因此会导致块分布的巨大差异,需要重新平衡。
balancerStatus
命令允许您查看当前平衡器的状态:
mongos> db.adminCommand({ balancerStatus: 1})
{
"mode" : "full",
"inBalancerRound" : false,
"numBalancerRounds" : NumberLong(64629),
"ok" : 1,
"operationTime" : Timestamp(1604706062, 1),
. . .
}
在前面的输出中,mode
字段表示启用了平衡器,而inBalancerRound
字段表示平衡器当前没有分发块。
尽管 MongoDB 会自动处理重新平衡,但重新平衡不会对性能没有影响。在区块迁移期间,带宽、工作负载和磁盘空间使用率都会增加。为了减轻这种性能损失,MongoDB 一次只迁移一个碎片。此外,每个碎片一次只能参与一个迁移。如果数据块迁移的影响正在影响您的应用性能,那么有一些事情可以尝试:
-
修改平衡器窗口
-
手动启用和禁用平衡器
-
更改块大小
我们将在接下来的几页中讨论这些选项。
修改平衡器窗口
平衡器窗口定义平衡器处于活动状态的时间段。修改平衡器窗口将阻止平衡器在给定的时间窗口之外运行;例如,您可能只想在应用负载最低时平衡块。在本例中,我们将重新平衡限制在从晚上 10:30 开始的 90 分钟窗口内:
mongos> use config
switched to db config
mongos> db.settings.update(
... { _id: "balancer" },
... { $set: {activeWindow :{ start: "22:30", stop: "23:59" } } },
... { upsert: true })
WriteResult({ "nMatched" : 1, "nUpserted" : 0, "nModified" : 1 })
Note
选择平衡窗口时,必须确保提供足够的时间来平衡当天的所有新单据。如果你的窗口太小,将会有剩余碎片的累积效应,这将使你的碎片越来越不平衡。
禁用平衡器
可以禁用平衡器,稍后再重新启用。例如,您可以在修改大量文档的夜间批处理窗口中禁用平衡器,因为您不希望平衡器在此过程中“反复折腾”。
但是,使用这种方法时要小心,因为未能重新启用平衡器可能会导致碎片严重失衡。下面是一些代码,展示了停止和重新启动平衡器的过程:
mongos> sh.getBalancerState()
true
mongos> sh.stopBalancer()
{
"ok" : 1,
"operationTime" : Timestamp(1604706472, 3),
. . .
}
mongos> sh.getBalancerState()
false
mongos> sh.startBalancer()
{
"ok" : 1,
"operationTime" : Timestamp(1604706529, 3),
. . .
}
mongos> sh.getBalancerState()
true
Note
禁用平衡器后,迁移可能仍在进行中。您可能需要等到sh.isBalancerRunning()
返回false
才能确定平衡器已经完全停止。
更改块大小
chunksize
选项——默认为 64MB 将决定一个块在被分割之前将增长到多大。通过减少chunksize
选项,您将拥有更多的小块。这将增加迁移和查询路由时间,但也会提供更均匀的数据分布。通过增加块大小,您将拥有更少、更大的块;这在迁移和路由方面会更有效,但可能会导致更大比例的数据位于单个区块中。此选项不会立即生效,您必须更新或插入到现有的块中才能触发拆分。
Note
一旦块被分割,它们就不能通过增加chunksize
选项来重新组合,所以在减少这个参数时要小心。此外,有时一个块可能会增长到超过这个参数,但是不能被分割,因为所有的文档都有相同的碎片键。这些不可分割的块被称为巨型块。
这些重新平衡选项中的每一个都涉及维护集群平衡和优化重新平衡开销之间的权衡。持续的重新平衡可能会对您的吞吐量造成明显的拖累,而允许集群失去平衡可能会在单个碎片上造成性能瓶颈。没有“一刀切”的解决方案,但是为重新平衡操作建立一个维护窗口是一种低风险、低影响的方法,可以确保重新平衡操作不会在高峰期导致性能下降。
Tip
为重新平衡操作建立维护窗口通常是维护集群平衡同时避免过多重新平衡开销的最佳方式。
在使用这些方法直接控制平衡器之前,首先要避免碎片失去平衡!仔细选择一个分布良好的分片密钥是很好的第一步。如果集群正在经历持续的高重新平衡开销,散列碎片键也可能值得考虑。
更改碎片密钥
如果您已经确定一个选择不当的分片键会产生性能开销,那么有一些方法可以改变这个分片键。在 MongoDB 中,更改或重新创建 shard 密钥并不是一个容易或快速的过程。没有可以运行的自动过程或命令。更改集合的 shard 键的过程甚至比一开始就创建它还要麻烦。更改现有分片密钥的过程是
-
备份您的数据
-
删除整个集合
-
创建新的碎片密钥
-
导入旧数据
可以想象,对于大型数据集,这可能是一个漫长而乏味的过程。
这个笨拙的过程使得从一开始就考虑、设计和实现一个好的分片密钥变得更加重要。如果您不确定您是否有正确的 shard 键,那么用较小的数据子集创建一个测试集合会很有用。然后,您可以在观察分布的同时创建和重新创建碎片密钥。请记住,在选择要测试的数据子集时,它必须代表整个数据集,而不仅仅是单个数据块。
尽管 MongoDB 没有明确支持更改分片键,但从 4.4 版开始,它支持一种无需完全重新创建就能提高现有分片集合性能的方法。在 MongoDB 中,这被称为提炼一个分片密钥。
当细化一个分片键时,我们可以向分片键添加额外的字段,但是不能删除或编辑现有的字段。可以添加这些后缀字段来增加粒度并减小块的大小。记住,平衡器不能分割或移动由单个碎片键的文档组成的巨型块(大于chunksize
选项的块)。通过细化我们的分片密钥,我们也许能够将一个巨大的块分成许多小块,然后可以重新平衡这些小块。
假设我们的应用相对较小,最初,通过country
字段进行分片就足够了。然而,随着我们应用的增长,我们在一个国家有很多用户,产生了巨大的块。通过用district
字段细化这个分片键,我们增加了块的粒度,从而消除了巨型块造成的永久不平衡。
下面是一个用district
属性细化country
分片密钥的例子:
mongos> db.adminCommand({
refineCollectionShardKey:
"MongoDBTuningBook.customersSCountry",
key: {
Country: 1, District: 1}
})
{
"ok" : 1,
"operationTime" : Timestamp(1604713390, 40),
. . .
}
Note
要细化分片键,必须确保新的分片键属性上存在匹配的索引。例如,在前面的代码片段中,索引必须存在于{Country: 1, District: 1}
上。
请记住,优化碎片键不会对数据分布产生直接影响:它只会增强平衡器拆分和重新平衡现有数据的能力。此外,新插入的数据将具有更精细的粒度,这将导致更少的巨型块和更平衡的分片。
共享查询
分片可能会帮助您摆脱写瓶颈,但是如果关键查询受到负面影响,那么您的分片项目就不太可能被认为是成功的。我们希望确保分片不会导致任何查询降级。
共享解释计划
像往常一样,我们可以使用explain()
方法来查看 MongoDB 将如何执行请求——即使请求是在一个分片集群的多个节点上执行的。一般来说,在查看分片查询时,我们会希望使用executionStats
选项,因为只有该选项会向我们展示工作是如何在集群中分配的。
下面是一个分片查询的executionStats
部分的例子。在输出中,我们应该看到一个shards
步骤,它包含每个分片的子步骤。下面是一个分片查询的 explain 输出的截断版本:
var exp=db.customers.explain('executionStats').
find({'views.title':'PRINCESS GIANT'}).next();
mongos > exp.executionStats {
"nReturned": 17874,
"executionTimeMillis": 9784,
"executionStages": {
"stage": "SHARD_MERGE",
"nReturned": 17874,
"executionTimeMillis": 9784,
"shards": [
{"shardName": "shard01",
"executionStages": {
"stage": "SHARDING_FILTER",
"inputStage": {
"stage": "COLLSCAN"}}},
{"shardName": "shard02",
"executionStages": {
"stage": "SHARDING_FILTER",
"inputStage": {
"stage": "COLLSCAN"}}}}}
该计划显示,查询是通过在每个碎片上执行集合扫描,然后在将数据返回给客户机之前合并结果来解决的。
我们的调优脚本(参见第 3 章)为分片查询生成一个易读的执行计划。下面是一个输出示例,显示了每个分片上的计划:
mongos> var exp=db.customers.explain('executionStats').
find({'views.title':'PRINCESS GIANT'}).next();
mongos> mongoTuning.executionStats(exp)
1 COLLSCAN ( ms:4712 returned:6872 docs:181756)
2 SHARDING_FILTER ( ms:4754 returned:6872)
3 Shard ==> shard01 ()
4 COLLSCAN ( ms:6395 returned:11002 docs:229365)
5 SHARDING_FILTER ( ms:6467 returned:11002)
6 Shard ==> shard02 ()
7 SHARD_MERGE ( ms:6529 returned:17874)
Totals: ms: 6529 keys: 0 Docs: 411121
当我们组合来自多个碎片的输出时,就会发生SHARD_MERGE
步骤。表示mongos
路由从多个分片接收数据,并组合成统一输出。
然而,如果我们发出一个根据 shard 键过滤的查询,那么我们可能会看到一个SINGLE_SHARD
计划。在下面的例子中,集合在LastName
上被分片,因此mongos
能够从单个分片中检索所有需要的数据:
mongos> var exp=db.customersShardName.explain('executionStats').
find({'LastName':'HARRISON'})
mongos> mongoTuning.executionStats(exp)
1 IXSCAN ( LastName_1_FirstName_1 ms:0
returned:730 keys:730)
2 SHARDING_FILTER ( ms:0 returned:730)
3 FETCH ( ms:149 returned:730 docs:730)
4 Shard ==> shard01 ()
5 SINGLE_SHARD ( ms:158 returned:730)
Totals: ms: 158 keys: 730 Docs: 730
分片键查找
正如我们所看到的,当查询包含碎片键时,MongoDB 可能能够从单个碎片中满足查询。
例如,如果我们在LastName
上分片,那么在LastName
上的查询解析如下:
mongos> var exp=db.customersSLName.explain('executionStats').
find({LastName:'SMITH','FirstName':'MARY'});
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( LastName_1 ms:0 returned:711 keys:711)
2 FETCH ( ms:93 returned:9 docs:711)
3 SHARDING_FILTER ( ms:93 returned:9)
4 Shard ==> shardRS ( ms:97 returned:9)
5 SINGLE_SHARD ( ms:100 returned:9)
Totals: ms: 100 keys: 711 Docs: 711
但是,请注意,在前面的示例中,我们缺少对LastName
和FirstName
的组合索引,因此查询的效率比预期的要低。我们应该细化 shard 键以包含FirstName
,或者我们可以简单地在两个属性上创建一个新的复合索引:
mongo> var exp=db.customersSLName.explain('executionStats').
find({LastName:'SMITH','FirstName':'MARY'});
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( LastName_1_FirstName_1 ms:0 returned:9 keys:9)
2 SHARDING_FILTER ( ms:0 returned:9)
3 FETCH ( ms:0 returned:9 docs:9)
4 Shard ==> shardRS ( ms:1 returned:9)
5 SINGLE_SHARD ( ms:2 returned:9)
Totals: ms: 2 keys: 9 Docs: 9
Tip
如果查询包含碎片键和附加过滤条件,您可以通过创建一个包含碎片键和这些附加属性的索引来优化查询。
意外碎片合并
只要有可能,我们希望将查询发送到单个碎片。为了实现这一点,我们应该确保我们的 shard 键与我们的查询过滤器一致。
例如,如果我们按Country
分片,但按City
查询,MongoDB 将需要进行分片合并,即使给定城市的所有文档都在包含该城市所在国家的分片中:
mongo> var exp=db.customersSCountry.explain('executionStats').
find({City:"Hiroshima"});
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( City_1 ms:0 returned:544 keys:544)
2 FETCH ( ms:0 returned:544 docs:544)
3 SHARDING_FILTER ( ms:0 returned:0)
4 Shard ==> shardRS ( ms:2 returned:0)
5 IXSCAN ( City_1 ms:0 returned:684 keys:684)
6 FETCH ( ms:0 returned:684 docs:684)
7 SHARDING_FILTER ( ms:0 returned:684)
8 Shard ==> shardRS2 ( ms:2 returned:684)
9 SHARD_MERGE ( ms:52 returned:684)
Totals: ms: 52 keys: 1228 Docs: 1228
按City
切分可能比按Country
切分更好——因为City
有更高的基数。然而,在这种情况下,简单地将Country
添加到查询过滤器中同样有效:
mongo> var exp=db.customersSCountry.explain('executionStats').
find({Country:'Japan',City:"Hiroshima"});
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( City_1 ms:0 returned:684 keys:684)
2 FETCH ( ms:0 returned:684 docs:684)
3 SHARDING_FILTER ( ms:0 returned:684)
4 Shard ==> shardRS2 ( ms:2 returned:684)
5 SINGLE_SHARD ( ms:55 returned:684)
Totals: ms: 55 keys: 684 Docs: 684
Tip
只要有意义,就向针对分片集群执行的查询添加分片键。如果 shard 键不包含在查询过滤器中,那么查询将被发送到所有的 shard,即使数据只存在于其中一个 shard 中。
分片密钥范围
如果 shard 键是范围分片的,那么我们可以使用该键来执行索引范围扫描。例如,在本例中,我们按照orderDate
对订单进行了分段:
mongo> var startDate=ISODate("2018-01-01T00:00:00.000Z");
mongo> var exp=db.ordersSOrderDate.explain('executionStats').
find({orderDate:{$gt:startDate}});
mongo> mongoTuning.executionStats(exp);
1 IXSCAN ( orderDate_1 ms:0 returned:7191 keys:7191)
2 SHARDING_FILTER ( ms:0 returned:7191)
3 FETCH ( ms:0 returned:7191 docs:7191)
4 Shard ==> shardRS2 ( ms:16 returned:7191)
5 SINGLE_SHARD ( ms:68 returned:7191)
Totals: ms: 68 keys: 7191 Docs: 7191
但是,如果实现了散列分片,则需要在每个分片中进行集合扫描:
mongo> var exp=db.ordersHOrderDate.explain('executionStats').
find({orderDate:{$gt:startDate}});
mongo> mongoTuning.executionStats(exp);
1 COLLSCAN ( ms:1 returned:2615 docs:28616)
2 SHARDING_FILTER ( ms:1 returned:2615)
3 Shard ==> shardRS ( ms:17 returned:2615)
4 COLLSCAN ( ms:1 returned:4576 docs:29881)
5 SHARDING_FILTER ( ms:1 returned:4576)
6 Shard ==> shardRS2 ( ms:20 returned:4576)
7 SHARD_MERGE ( ms:72 returned:7191)
Totals: ms: 72 keys: 0 Docs
: 58497
Tip
如果您经常对分片键执行范围扫描,则范围分片比哈希分片更可取。但是,请记住,如果键值不断增加,范围分片会导致热点。
整理
当从多个碎片中检索排序后的数据时,排序操作分两个阶段进行。首先,数据在每个分片上进行排序,然后返回到mongos
,在这里SHARD_MERGE_SORT
将排序后的输入组合成一个合并的、排序后的输出。
支持排序的索引——如果合适的话,包括分片键索引——可以在每个分片上使用,以便于排序,但是即使您按分片键排序,最终的排序操作仍然必须在mongos
上执行。
下面是一个根据orderDate
对订单进行排序的查询示例。shard 键用于在 mongos 上执行最终的SHARD_MERGE_SORT
之前,从每个分片中按排序顺序返回数据:
1 IXSCAN ( orderDate_1 ms:22 returned:527890 keys:527890)
2 SHARDING_FILTER ( ms:58 returned:527890)
3 FETCH ( ms:87 returned:527890 docs:527890)
4 Shard ==> shardRS2 ( ms:950 returned:527890)
5 IXSCAN ( orderDate_1 ms:29 returned:642050 keys:642050)
6 SHARDING_FILTER ( ms:58 returned:642050)
7 FETCH ( ms:102 returned:642050 docs:642050)
8 Shard ==> shardRS ( ms:1011 returned:642050)
9 SHARD_MERGE_SORT ( ms:1013 returned:1169940)
Totals: ms: 1013 keys: 1169940 Docs: 1169940
如果没有合适的索引来支持排序,那么需要在每个碎片上执行分块排序:
1 COLLSCAN ( ms:37 returned:564795 docs:564795)
2 SHARDING_FILTER ( ms:70 returned:564795)
3 SORT ( ms:237 returned:564795)
4 Shard ==> shardRS ( ms:1111 returned:564795)
5 COLLSCAN ( ms:30 returned:605145 docs:605145)
6 SHARDING_FILTER ( ms:78 returned:605145)
7 SORT ( ms:273 returned:605145)
8 Shard ==> shardRS2 ( ms:1315 returned:605145)
9 SHARD_MERGE_SORT ( ms:1363 returned:1169940)
Totals: ms: 1363 keys: 0 Docs: 1169940
优化排序的一般考虑适用于每种分片排序。特别是,你需要确保不超过每个碎片上的排序内存限制——更多细节见第 6 章。
非分片键查找
如果一个查询不包含一个分片键谓词,那么该查询被发送到每个分片,结果在mongos
上被合并。例如,这里我们在每个碎片上执行集合扫描,并在SHARD_MERGE
步骤中合并结果:
mongo> var exp=db.customersSCountry.explain('executionStats').
find({'views.filmId':637});
mongo> mongoTuning.executionStats(exp);
1 COLLSCAN ( ms:648 returned:10331 docs:199078)
2 SHARDING_FILTER ( ms:648 returned:10331)
3 Shard ==> shardRS ( ms:1602 returned:10331)
4 COLLSCAN ( ms:875 returned:4119 docs:212043)
5 SHARDING_FILTER ( ms:882 returned:4119)
6 Shard ==> shardRS2 ( ms:1954 returned:4119)
7 SHARD_MERGE ( ms:2002 returned:14450)
Totals: ms: 2002 keys: 0 Docs: 411121
使用SHARD_MERGE
没有任何问题——我们完全应该预料到许多查询需要以这种方式解决。但是,您应该确保在每个碎片上运行的查询是优化的。在前面的例子中,清楚地表明了对views.filmId
的索引的需求。
Tip
对于必须针对每个分片执行的查询,使用前面章节中概述的索引和文档设计原则,确保每个分片的工作量最小化。
聚合和排序
当执行聚合操作时,MongoDB 试图将尽可能多的工作推给碎片。碎片不仅负责聚合的数据访问部分(如$match
和$project
),还负责满足$group
和$unwind
操作所需的预聚合。
分片聚合的解释计划包括独特的部分,用于说明如何解析聚合。
例如,考虑以下聚合:
db.customersSCountry.aggregate([
{ $unwind: "$views" },
{ $group:{ _id:{ "views_title":"$views.title" },
"count":{$sum:1}
}
},
]);
此聚合的执行计划包含一个独特的部分,显示如何在聚合中拆分工作:
"mergeType": "mongos",
"splitPipeline": {
"shardsPart": [
{
"$unwind": {
"path": "$views"
}
},
{
"$group": {
"_id": {
"views_title": "$views.title"
},
"count": {
"$sum": {
"$const": 1
}
}
}
}
],
"mergerPart": [
{
"$group": {
"_id": "$$ROOT._id",
"count": {
"$sum": "$$ROOT.count"
},
"$doingMerge": true
}
}
]
},
mergeType
部分告诉我们哪个组件将执行合并。我们期望在这里看到mongos
,但是在某些情况下,我们可能会看到分配给其中一个碎片的合并,在这种情况下,我们会看到“primaryShard
或“anyShard
”。
splitPipeLine
显示了发送到碎片的聚合阶段。在这个例子中,我们可以看到$group
和$unwind
操作将在碎片上执行。
最后,mergerPart
向我们展示了在合并节点中会发生什么操作——在本例中,是在mongos
上。
对于最常用的聚合步骤,MongoDB 会将大部分工作下推到碎片上,并在mongos
上组合输出。
分片$查找操作
分片集合仅部分支持使用$lookup
的连接操作。在$lookup
阶段的from
部分引用的集合不能分片。因此,$lookup
的工作不能跨分片分布。所有的工作都将发生在包含查找集合的主碎片上。
Warning
$lookup
不完全支持分片集合。在$lookup
管道阶段中引用的集合不能是分片集合,尽管启动集合可能是分片的。
摘要
分片为超大型 MongoDB 实现提供了一个横向扩展解决方案。特别是,它允许写工作负载分布在多个节点上。然而,分片增加了操作复杂性和性能开销,不应该轻易实现。
对于分片集群实现,最重要的考虑是小心选择一个分片键。shard 键应该具有较高的基数,以允许块随着数据的增长而分裂,应该支持可以针对单个碎片进行操作的查询,并且应该在各个碎片之间均匀地分配工作负载。
重新平衡是 MongoDB 为保持碎片平衡而执行的后台操作。重新平衡操作可能会导致性能下降:您可能希望调整重新平衡以避免这种情况,或者将重新平衡限制在维护窗口内。
分片集群上的查询调优是由与单节点 MongoDB 相同的考虑因素驱动的——索引和文档设计仍然是最重要的因素。但是,您应该确保可以包含 shard 键的查询确实包含该键,并且存在索引来支持路由到每个 shard 的查询。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 清华大学推出第四讲使用 DeepSeek + DeepResearch 让科研像聊天一样简单!
· 实操Deepseek接入个人知识库
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库