MongoDB 性能调优教程(三)
八、插入、更新和删除
在这一章中,我们来看看与数据操作语句的性能相关的问题。这些语句(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 事务的一个关键性能拖累,应该是事务调优工作的重点。
在本章中,我们研究了几种减少争用从而提高事务吞吐量的方法:
-
我们有时可以完全避免事务,例如,通过在单个文档中嵌入必须自动更新的数据。
-
我们可以通过将高争用操作移到事务的末尾来减少事务中止的机会。
-
我们可以将“热”文档划分为多个文档,从而减少这些文档中的数据争用。
https://docs.mongodb.com/manual/core/transactions/
https://docs.mongodb.com/drivers/node/fundamentals/logging见
十、服务器监控
到目前为止,我们一直致力于通过优化应用代码和数据库设计来管理性能。在理想情况下,这是我们开始调优工作的地方,通过优化应用,我们使 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。
Footnotes [1](#Fn1_source)这个脚本作为mongoTuning.memoryReport()包含在我们的调优脚本中。
*
十二、磁盘 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。基于性能的技术选择也是如此。
Footnotes [1](#Fn1_source)维基百科: http://en.wikipedia.org/wiki/Hard_disk_drive
维基百科: https://tinyurl.com/y4tfn3n7
维基百科: https://tinyurl.com/y6dr2tm5
后来被磁盘供应商更改为独立磁盘冗余阵列;RAID 系统通常并不便宜。
您可能需要安装sysstat包来启用iostat命令。