AI 日报

线上MongoDB查询慢,如何通过索引优化直降响应时间?

  • By 51ITO
  • Apr 29, 2024 - 2 min read



作者 | 吴守阳

审校 | 重楼

背景

线上某个页面的响应速度异常缓慢,达到了16秒,严重影响了业务的正常运行。经过与研发的沟通得知,该页面调用的数据集合只会保留7天的数据,集合有6000万条记录。针对过期数据的处理,使用了根据 create_time 字段创建的过期索引,以自动使数据失效。此外,数据集合还通过 company_id 字段进行了哈希分片。

问题排查

慢语句分析

在后台拿到了慢查询语句,如下:

db.visitor.find({
 "company_id": 13272,
 "create_time": {
 "$gte": ISODate("2024-04-11T00:00:00.000+0800"),
 "$lte": ISODate("2024-04-11T23:59:59.000+0800")
 }
});

db.visitor.find({
 "company_id": 13272,
 "create_time": {
 "$gte": ISODate("2024-04-12T00:00:00.000+0800"),
 "$lte": ISODate("2024-04-18T23:59:59.000+0800")
 }
});

很简单的一个查询,语句上没有再优化的必要了。如果索引都在不应该出现这种十多秒的耗时,接下来开始分析索引。

索引分析

索引如下:

db.getCollection("visitor").createIndex({
 "company_id": "hashed"
}, {
 name: "company_id_hashed"
});

db.getCollection("visitor").createIndex({
 "company_id": NumberInt("1")
}, {
 name: "company_id_1"
});

db.getCollection("visitor").createIndex({
 "create_time": NumberInt("1")
}, {
 name: "create_time_1",
 expireAfterSeconds: NumberInt("604800")
});

其中:

  • company_id_hashed:创建集合分片使用的hash索引
  • company_id_1:普通查询的索引
  • create_time_1:过期时间的索引
    就这点数据量,按理说会用到索引的,不应该执行耗时16s,接下来执行计划分析。

Explain执行计划

winningPlan

"stage": "SHARDING_FILTER",
 "inputStage": {
 "stage": "FETCH",
 "filter": {
 "$and": [
 {
 "company_id": {
 "$eq": 13272
 }
 },
 {
 "create_time": {
 "$lte": ISODate("2024-04-17T15:59:59.000Z")
 }
 },
 {
 "create_time": {
 "$gte": ISODate("2024-04-10T16:00:00.000Z")
 }
 }
 ]
 },
 "inputStage": {
 "stage": "IXSCAN",
 "keyPattern": {
 "company_id": "hashed"
 },
 "indexName": "company_id_hashed",
 "isMultiKey": false,
 "isUnique": false,
 "isSparse": false,
 "isPartial": false,
 "indexVersion": NumberInt("2"),
 "direction": "forward",
 "indexBounds": {
 "company_id": [
 "[7977521071453068053, 7977521071453068053]"

这部分显示只用到了company_id_hashed索引,没有用到create_time_1索引。

rejectedPlans

"stage": "SHARDING_FILTER",
 "inputStage": {
 "stage": "FETCH",
 "filter": {
 "company_id": {
 "$eq": 13272
 }
 },
 "inputStage": {
 "stage": "IXSCAN",
 "keyPattern": {
 "create_time": 1
 },
 "indexName": "create_time_1",
 "isMultiKey": false,
 "multiKeyPaths": {
 "create_time": [ ]
 },
 "isUnique": false,
 "isSparse": false,
 "isPartial": false,
 "indexVersion": NumberInt("2"),
 "direction": "forward",
 "indexBounds": {
 "create_time": [
 "[new Date(1712764800000), new Date(1713369599000)]"
 ]
 }
 }
 }
 },
 {
 "stage": "SHARDING_FILTER",
 "inputStage": {
 "stage": "FETCH",
 "filter": {
 "$and": [
 {
 "create_time": {
 "$lte": ISODate("2024-04-17T15:59:59.000Z")
 }
 },
 {
 "create_time": {
 "$gte": ISODate("2024-04-10T16:00:00.000Z")
 }
 }
 ]
 },
 "inputStage": {
 "stage": "IXSCAN",
 "keyPattern": {
 "company_id": 1
 },
 "indexName": "company_id_1",
 "isMultiKey": false,
 "multiKeyPaths": {
 "company_id": [ ]
 },

这部分显示的是被拒绝的执行计划列表,不会用到company_id_1create_time_1索引。

executionStats

"nReturned": NumberInt("229707"),
 "executionTimeMillis": NumberInt("15668"),
 "totalKeysExamined": NumberInt("238012"),
 "totalDocsExamined": NumberInt("238012"),
 "executionStages": {
 "stage": "SINGLE_SHARD",
 "nReturned": NumberInt("229707"),
 "executionTimeMillis": NumberInt("15668"),
 "totalKeysExamined": NumberInt("238012"),
 "totalDocsExamined": NumberInt("238012"),
 "totalChildMillis": NumberLong("15667"),
 "shards": [
 {
 "shardName": "d-m5eee03fdeaeaee4",
 "executionSuccess": true,
 "executionStages": {
 "stage": "SHARDING_FILTER",
 "nReturned": NumberInt("229707"),
 "executionTimeMillisEstimate": NumberInt("14996"),
 "works": NumberInt("238013"),
 "advanced": NumberInt("229707"),
 "needTime": NumberInt("8305"),
 "needYield": NumberInt("0"),
 "saveState": NumberInt("1980"),
 "restoreState": NumberInt("1980"),
 "isEOF": NumberInt("1"),
 "chunkSkips": NumberInt("0"),
 "inputStage": {
 "stage": "FETCH",
 "filter": {
 "$and": [
 {
 "company_id": {
 "$eq": 13272
 }
 },
 {
 "create_time": {
 "$lte": ISODate("2024-04-17T15:59:59.000Z")
 }
 },
 {
 "create_time": {
 "$gte": ISODate("2024-04-10T16:00:00.000Z")
 }
 }
 ]
 },
 "nReturned": NumberInt("229707"),
 "executionTimeMillisEstimate": NumberInt("14595"),
 "works": NumberInt("238013"),
 "advanced": NumberInt("229707"),
 "needTime": NumberInt("8305"),
 "needYield": NumberInt("0"),
 "saveState": NumberInt("1980"),
 "restoreState": NumberInt("1980"),
 "isEOF": NumberInt("1"),
 "docsExamined": NumberInt("238012"),
 "alreadyHasObj": NumberInt("0"),
 "inputStage": {
 "stage": "IXSCAN",
 "nReturned": NumberInt("238012"),
 "executionTimeMillisEstimate": NumberInt("251"),
 "works": NumberInt("238013"),
 "advanced": NumberInt("238012"),
 "needTime": NumberInt("0"),
 "needYield": NumberInt("0"),
 "saveState": NumberInt("1980"),
 "restoreState": NumberInt("1980"),
 "isEOF": NumberInt("1"),
 "keyPattern": {
 "company_id": "hashed"
 },
 "indexName": "company_id_hashed",
 "isMultiKey": false,
 "isUnique": false,
 "isSparse": false,
 "isPartial": false,
 "indexVersion": NumberInt("2"),
 "direction": "forward",
 "indexBounds": {
 "company_id": [
 "[7977521071453068053, 7977521071453068053]"
 ]
 },
 "keysExamined": NumberInt("238012"),
 "seeks": NumberInt("1"),
 "dupsTested": NumberInt("0"),
 "dupsDropped": NumberInt("0")

这部分显示的是查询的执行统计信息。

索引分析

通过explain的执行计划,可以看出索引的使用上存在问题。按理说company_idcreate_time都已创建索引,为什么没有使用上?是什么原因导致它失效,没有用上create_time索引?

下面列举了失效的情况:

  • 索引选择性不高:由于查询条件是一个范围查询,create_time 字段可能有许多不同的值满足条件。因此,单键索引 create_time_1 的选择性(即索引中不同值的比例)可能不高,这使得使用该索引无法有效地减少需要检索的文档数量。
  • 查询需要跨越多个索引键值:查询涉及到了两个字段 company_id 和 create_time。虽然索引 create_time_1 可以帮助过滤 create_time 符合条件的文档,但在执行查询时,还需要考虑 company_id 的匹配条件。因此,MongoDB 需要在两个索引之间进行查找和合并,而不是简单地使用单个索引来解决查询。
  • 额外的查找和合并成本:在涉及多个条件的查询中,MongoDB 会尝试使用覆盖索引(Covered Index)来尽可能地减少在磁盘上的文档检索。然而,在这种情况下,create_time_1 索引不能单独满足查询条件,因此 MongoDB 还需要查找和合并从 company_id_1 索引中过滤出来的文档。这种额外的查找和合并过程会增加查询的成本,并且降低性能。
    因此,针对给定的查询语句,MongoDB 不会使用 create_time_1 索引来优化查询,而是会选择其他更适合的索引,如 company_id_hashed 和 company_id_1。

问题原因

造成执行耗时过长的主要原因是索引失效的问题,在涉及多个条件的查询中,MongoDB 会尝试使用覆盖索引(Covered Index)来尽可能地减少在磁盘上的文档检索。然而,在这种情况下,create_time_1 索引不能单独满足查询条件,因此 MongoDB 还需要查找和合并从 company_id_1 索引中过滤出来的文档。这种额外的查找和合并过程会增加查询的成本,并且降低性能。

优化方案

创建新的复合索引company_id_create_time,让其走company_id_hashed到company_id_create_time的链路。添加新的索引后,相同的语句执行时间只需要400ms,能满足业务的需求。

结论

要多关注索引在什么情况下会失效?复合索引的先后顺序,不是每个条件字段都建个单个普通索引,查询语句都会使用上,不要存在这种误区,有时候复合索引才是最完美的组合。

执行计划详解

1、queryPlanner包含了MongoDB查询的执行计划。

  • mongosPlannerVersionMongoDB计划版本。
  • winningPlan胜出的执行计划,即MongoDB选择的最佳执行计划。
  • shards: 分片的详细信息,包括分片名称、连接字符串、服务器信息等。2、winningPlan: 胜出的执行计划。
  • stage: 执行阶段,这里是SINGLE_SHARD,表示单分片操作。
  • shardName: 执行操作的分片名称。
  • plannerVersion: 计划版本。
  • namespace: 查询的命名空间。
  • indexFilterSet: 是否设置了索引过滤器。
  • parsedQuery: 解析后的查询条件。
  • winningPlan: 胜出的执行计划的详细信息,这里是SHARDING_FILTER。3、rejectedPlans: 被拒绝的执行计划列表,即非胜出的备选计划。
    每个被拒绝的执行计划包含了其详细信息,包括执行阶段、过滤器、索引扫描等。
    4、executionStats: 查询的执行统计信息。
  • nReturned: 返回的文档数量。
  • executionTimeMillis: 查询执行时间(毫秒)。
  • totalKeysExamined: 总共检查的键数量。
  • totalDocsExamined: 总共检查的文档数量。
  • executionStages: 执行阶段的详细统计信息。

作者介绍

吴守阳,51CTO社区编辑,拥有8年DBA工作经验,熟练管理MySQL、Redis、MongoDB等开源数据库。精通性能优化、备份恢复和高可用性架构设计。善于故障排除和自动化运维,保障系统稳定可靠。具备良好的团队合作和沟通能力,致力于为企业提供高效可靠的数据库解决方案。