掌握 MongoDB 索引以优化查询性能
通过掌握索引技术,释放 MongoDB 的最佳查询性能。本全面指南涵盖单字段索引、复合索引和多键索引,解释覆盖查询的强大功能,并指导您使用 `explain()` 进行性能分析。学习最佳实践,加速读取操作,降低数据库负载,构建响应更快的应用程序。对于任何专注于性能调优的 MongoDB 开发者来说,这是必读内容。
掌握 MongoDB 索引以优化查询性能
当数据库不再小到可以靠运气猜测时,MongoDB 索引就变得有趣了。在开发环境中感觉瞬间完成的查询,在生产环境中一旦集合拥有数百万文档、仪表板添加了排序、或者 API 端点开始按多个字段过滤时,可能会变得令人痛苦。
目标不是索引每个字段。这通常会使写入变慢,消耗内存和磁盘,并且仍然无法覆盖重要的查询。目标是理解你的应用程序实际依赖的少数查询模式,然后构建匹配这些模式的索引。
理解 MongoDB 索引
核心上,索引就像书中的索引。不是阅读整本书来查找主题,而是查阅排序的参考并跳到正确的页面附近。MongoDB 索引帮助查询规划器定位匹配的文档,而无需扫描整个集合。如果没有有用的索引,MongoDB 可能会执行集合扫描,逐个检查文档直到找到匹配项。
集合扫描并不总是坏事。扫描一个小集合可能没问题。每月运行一次管理报告可能没问题。但在高流量请求路径中的集合扫描则不同。它会与正常的读取和写入竞争,随着数据增长而恶化,并且通常表现为不可预测的延迟。
索引如何工作
MongoDB 通常对普通字段索引使用 B 树风格的索引。重要的实际细节是索引值按顺序存储。这种顺序帮助 MongoDB 在查询形状与索引对齐时处理相等过滤器、范围过滤器和排序。
例如,{ email: 1 } 上的索引非常适合:
db.users.findOne({ email: "[email protected]" })
它对于以下查询没有用:
db.users.find({ lastLoginAt: { $lt: ISODate("2025-01-01") } })
第二个查询需要一个以 lastLoginAt 开头的索引,否则需要扫描。
何时使用索引
索引对于经常用于以下方面的字段最有益:
- 查询条件 (
find(),findOne()): 查询的filter文档中使用的字段。 - 排序条件 (
sort()): 用于对查询结果排序的字段。 _id字段: 默认情况下,MongoDB 在_id字段上创建索引,确保唯一性和快速按 ID 查找。
然而,索引也有成本:
- 存储空间: 索引消耗磁盘空间。
- 写入性能: 插入、更新或删除文档时需要更新索引,这可能会减慢写入操作。
- 内存压力: 频繁使用的索引页面竞争缓存。太多大型索引会使工作集更难保留在内存中。
因此,策略性地创建索引至关重要,重点关注那些能为常见读取操作带来最大性能提升的字段。
创建和管理索引
MongoDB 提供了 createIndex() 方法来创建索引,getIndexes() 方法来查看现有索引。dropIndex() 方法用于删除索引。
基本索引创建
要创建单字段索引,你需要指定字段名称和索引类型(通常 1 表示升序,-1 表示降序)。
db.collection.createIndex( { fieldName: 1 } );
示例: 按升序索引 username 字段:
db.users.createIndex( { username: 1 } );
查看索引
要查看集合上的索引:
db.collection.getIndexes();
示例: 查看 users 集合上的索引:
db.users.getIndexes();
这将返回一个索引定义数组,包括默认的 _id 索引。
在繁忙的集合上,要刻意创建索引。现代 MongoDB 版本在许多常见情况下支持在线索引构建,但索引构建仍然消耗 CPU、磁盘 I/O 和内存。在生产系统上,将大型索引构建安排在较安静的时段,如果运行副本集,请监控复制延迟。
删除索引
要删除索引:
db.collection.dropIndex( "indexName" );
你可以从 getIndexes() 的输出中找到 indexName。或者,你可以通过指定与 createIndex() 相同格式的索引字段来删除索引:
db.collection.dropIndex( { fieldName: 1 } );
示例: 删除 username 索引:
db.users.dropIndex( "username_1" ); // 使用索引名称
// 或者
db.users.dropIndex( { username: 1 } ); // 使用索引定义
在删除索引之前,检查是否有任何内容仍在使用它:
db.users.aggregate([{ $indexStats: {} }])
这显示了自服务器启动以来的访问计数器。计数器为零是一个线索,而不是绝对证据。服务器可能最近重启过,或者查询可能只在每周作业期间运行。对于重要系统,结合 $indexStats、应用程序代码搜索、查询日志和短暂的观察期。
复合索引
复合索引涉及多个字段。复合索引中字段的顺序至关重要。MongoDB 将复合索引用于涉及 filter 或 sort 子句中多个字段的查询。
何时使用复合索引
当你的查询经常按字段组合进行过滤或排序时,复合索引最有效。索引可以满足与索引中定义顺序相同的字段或索引前缀的查询。
示例: 考虑一个包含 userId、orderDate 和 status 等字段的 orders 集合。如果你经常按特定用户查询订单并按日期排序,那么 { userId: 1, orderDate: 1 } 上的复合索引将非常有益。
db.orders.createIndex( { userId: 1, orderDate: 1 } );
这个索引可以高效地支持如下查询:
db.orders.find( { userId: "user123" } ).sort( { orderDate: -1 } )db.orders.find( { userId: "user123", orderDate: { $lt: ISODate() } } )
但是,如果查询仅按 orderDate 过滤而未指定 userId,或者字段顺序不同,它可能不那么有效。
字段顺序很重要
复合索引中字段的顺序决定了它可以很好地支持哪些查询模式。一个有用的经验法则是:等值字段在前,然后是排序字段,最后是范围字段。这通常被称为 ESR 指南:等值、排序、范围。这是一个指南,不是定律,但它可以防止许多糟糕的索引设计。
假设你的订单页面运行此查询:
db.orders.find({
tenantId: "t1",
status: "paid",
createdAt: { $gte: ISODate("2025-01-01") }
}).sort({ createdAt: -1 })
一个合理的索引可能是:
db.orders.createIndex({ tenantId: 1, status: 1, createdAt: -1 })
tenantId 和 status 是等值过滤器。createdAt 支持排序和范围。如果你改为创建 { createdAt: -1, status: 1, tenantId: 1 },MongoDB 在某些情况下可能仍会使用它,但它通常与此查询不太对齐。
对于对结果进行排序的查询,索引中字段的顺序应与 sort() 操作中字段的顺序匹配,以获得最佳性能。如果查询同时包含过滤器和排序,并且索引与过滤器字段匹配,则它也可以用于排序,而无需单独的集合扫描来进行排序。
复合索引也可以服务于前缀查询。{ tenantId: 1, status: 1, createdAt: -1 } 上的索引可以帮助仅针对 tenantId 的查询,或 tenantId 加 status 的查询。它通常对仅针对 status 的查询帮助不大,因为 status 不是前导字段。
覆盖查询
覆盖查询是指 MongoDB 可以仅使用索引来满足整个查询的查询。这意味着索引包含所有被查询和投影的字段。覆盖查询避免了从集合本身获取文档,因此速度极快。
如何实现覆盖查询
要实现覆盖查询,请确保:
- 你有一个包含查询过滤器中所用所有字段的索引。
- 你只在投影中包含这些索引字段(或其子集)。
示例: 考虑一个包含 name、age 和 city 字段的 employees 集合。如果你有一个索引 { city: 1, age: 1 },并且想要检索特定城市中员工的姓名和年龄,你可以创建一个覆盖查询:
db.employees.find( { city: "New York" }, { name: 1, age: 1, _id: 0 } ).explain()
在这个查询中,city 在索引中,name 和 age 包含在投影中。如果索引也包含 name 和 age,那么它将是一个覆盖查询。
让我们完善索引和查询以实现真正的覆盖查询:
// 创建一个包含查询和投影所需所有字段的索引
db.employees.createIndex( { city: 1, age: 1, name: 1 } );
// 现在,按城市过滤并投影姓名和年龄的查询可以被覆盖
db.employees.find( { city: "New York" }, { name: 1, age: 1, _id: 0 } )
当你对此查询运行 explain("executionStats") 时,覆盖计划应该检查索引键,而无需从集合中获取完整文档。在许多解释计划中,这意味着你将看到 IXSCAN 而没有 FETCH 阶段,并且 totalDocsExamined 应为 0。解释输出因 MongoDB 版本和查询形状而异,因此请关注实际的计划阶段和检查计数,而不是寻找一个确切的标签。
覆盖查询对于热读取路径(如自动完成、小列表视图或权限检查)很有用。如果投影包含大字段、许多字段或不断变化的字段,则不太有用。仅仅为了覆盖查询而向索引添加太多字段可能会创建一个庞大的索引,从而损害写入性能。
其他重要的索引类型
MongoDB 为特定用例提供了各种索引类型:
多键索引
当你索引数组字段时,会自动创建多键索引。它们允许你查询数组中的元素。
示例: 如果你有一个包含 tags 数组字段 ["electronics", "gadgets"] 的 products 集合:
db.products.createIndex( { tags: 1 } );
这个索引将支持诸如 db.products.find( { tags: "electronics" } ) 之类的查询。
数组在复合索引中需要格外小心。多键索引存储数组元素的条目,这可能会迅速增加索引大小。当同一文档中多个索引字段可以包含数组时,MongoDB 对复合多键索引也有限制。如果你的数据模型有多个数组和复杂的过滤器,请在使用代表性数据测试确切查询之前,不要假设复合索引的行为与标量字段索引相同。
文本索引
文本索引支持对文档中的字符串内容进行高效搜索。它们用于使用 $text 运算符的文本搜索查询。
db.articles.createIndex( { content: "text" } );
这允许进行如下搜索:db.articles.find( { $text: { $search: "database performance" } } )。
文本索引对于基本文本搜索很有用,但它们不是一个完整的搜索平台。如果你需要高级相关性调整、拼写容错、分面、高亮或特定语言的搜索行为,MongoDB Atlas Search 或专用搜索引擎可能更合适。
地理空间索引
地理空间索引用于使用 $near、$geoWithin 和 $geoIntersects 运算符高效查询地理数据。
db.locations.createIndex( { loc: "2dsphere" } ); // 对于 2dsphere 索引
唯一索引
唯一索引强制字段或字段组合的唯一性。如果插入或更新重复值,MongoDB 将返回错误。
db.users.createIndex( { email: 1 }, { unique: true } );
对于生产用户表,在强制唯一性之前进行规范化。电子邮件地址是一个常见的例子。如果你的应用程序将 [email protected] 和 [email protected] 视为同一用户,请存储一个规范化字段,例如 emailLower,并在那里放置唯一索引。不要仅依赖应用程序代码来防止并发下的重复。
部分索引
部分索引仅索引与过滤器表达式匹配的文档。当查询专注于集合的子集时,它们很有用。
db.orders.createIndex(
{ tenantId: 1, createdAt: -1 },
{ partialFilterExpression: { status: "open" } }
)
如果你的应用程序经常读取未结订单,而已结订单占集合的大部分,这可能会有所帮助。该索引更小,因为它排除了不匹配部分过滤器的文档。查询必须包含兼容的条件,MongoDB 才能使用它。
TTL 索引
TTL 索引在配置的时间后自动删除文档。它们通常用于会话、临时令牌或短期事件。
db.sessions.createIndex(
{ expiresAt: 1 },
{ expireAfterSeconds: 0 }
)
TTL 删除在确切到期时间并非即时发生。MongoDB 在后台删除过期文档。将其用于清理,而不是用于令牌必须立即失效的精确安全计时。你的应用程序仍应在读取期间检查过期。
使用 explain() 进行性能分析
理解 MongoDB 如何执行你的查询对于优化它们至关重要。explain() 方法提供了查询执行计划的见解,包括是否使用了索引以及如何使用。
db.collection.find( {...} ).explain( "executionStats" );
在 explain() 输出中要查找的关键字段:
winningPlan.stage:指示执行计划的阶段(例如,COLLSCAN表示集合扫描,IXSCAN表示索引扫描)。executionStats.totalKeysExamined:检查的索引键数量。executionStats.totalDocsExamined:检查的文档数量。
一个好的执行计划将具有接近或等于返回文档数的 totalDocsExamined,以及远小于集合中文档总数的 totalKeysExamined。如果 totalDocsExamined 非常高,或者使用了 COLLSCAN,则表明索引缺失或未被有效使用。
以下是我快速阅读解释计划的方法:
- 查找
COLLSCAN。如果这是一个热路径并且集合很大,那通常是第一个问题。 - 查找
IXSCAN后跟FETCH。当查询需要索引之外的字段时,获取是正常的,但过多的文档检查意味着索引的选择性不够。 - 比较
nReturned、totalKeysExamined和totalDocsExamined。检查 25 个键后返回 20 个文档是健康的。检查 500,000 个键后返回 20 个文档则不是。 - 注意内存中的排序。如果 MongoDB 在过滤后必须对大量结果集进行排序,支持排序的复合索引可能会有所帮助。
测试时使用现实的过滤器。tenantId: "demo" 的解释计划可能与拥有数百万文档的大型租户不匹配。数据分布很重要。
实用的索引设计演练
想象一个具有 tickets 集合的应用程序。支持代理使用具有以下过滤器的队列页面:
db.tickets.find({
tenantId: "acme",
status: "open",
assigneeId: "u123"
}).sort({ updatedAt: -1 }).limit(50)
从查询形状开始,而不是字段列表。该集合是多租户的,代理通常按状态和分配对象过滤,UI 按最新更新排序。一个实用的索引是:
db.tickets.createIndex({
tenantId: 1,
status: 1,
assigneeId: 1,
updatedAt: -1
})
现在考虑另一个页面:经理查看所有未结工单,无论分配对象如何:
db.tickets.find({
tenantId: "acme",
status: "open"
}).sort({ updatedAt: -1 }).limit(100)
之前的索引可以使用前缀 { tenantId, status },但 assigneeId 位于 updatedAt 之前,因此对于此经理查询,它可能无法很好地支持排序。你可能需要第二个索引:
db.tickets.createIndex({
tenantId: 1,
status: 1,
updatedAt: -1
})
这是一个正常的权衡。一个索引很少能完美地服务于每个屏幕。工作是支持重要的路径,而不会创建一堆重叠的索引,这些索引都会增加写入成本。
MongoDB 索引的最佳实践
- 只索引你需要的: 避免在很少查询或排序的字段上创建索引。每个索引都会增加开销。
- 明智地使用复合索引: 根据查询模式正确排序字段。首先考虑最具选择性的字段。
- 目标是覆盖查询: 如果读取性能至关重要,请设计索引以覆盖常见的读取操作。
- 监控索引使用情况: 定期使用
explain()和db.collection.aggregate([{ $indexStats: {} }])审查索引使用情况,以识别未使用或低效的索引。 - 考虑索引选择性: 基数低(不同值少)的字段上的索引可能不如基数高的字段上的索引有效。
- 保持索引小巧: 除非绝对需要用于覆盖查询,否则避免在索引中包含大字段或数组。
- 测试你的索引: 始终在现实的负载条件下测试新索引对读取和写入性能的影响。
- 小心删除冗余索引: 如果你有
{ a: 1, b: 1 },单独的{ a: 1 }索引对于许多工作负载可能是冗余的。在删除之前确认使用情况。 - 围绕实际屏幕和作业进行设计: 索引应映射到应用程序行为:登录查找、队列页面、报告过滤器、后台工作程序扫描。
- 在架构更改后重新审视: 新字段、新排序顺序或新租户模型可能会使旧索引变得不那么有用。
好的索引是什么样的
好的 MongoDB 索引通常是安静的。重要的查询检查的数据量大致等于它们返回的数据量。排序不会溢出到昂贵的工作中。写入不会因十几个推测性索引而负担过重。当新功能添加新的查询形状时,在其成为生产事故之前,使用 explain("executionStats") 进行测试。
实用的习惯很简单:收集真实的查询,为该查询形状设计最小的有用索引,使用代表性数据进行测试,并持续检查索引使用情况。这个习惯比记住每种索引类型对 MongoDB 性能更有帮助。