作者 | 吳守陽(yáng)
審校 | 重樓
背景

線上某個(gè)頁(yè)面的響應(yīng)速度異常緩慢,達(dá)到了16秒,嚴(yán)重影響了業(yè)務(wù)的正常運(yùn)行。經(jīng)過(guò)與研發(fā)的溝通得知,該頁(yè)面調(diào)用的數(shù)據(jù)集合只會(huì)保留7天的數(shù)據(jù),集合有6000萬(wàn)條記錄。針對(duì)過(guò)期數(shù)據(jù)的處理,使用了根據(jù) create_time 字段創(chuàng)建的過(guò)期索引,以自動(dòng)使數(shù)據(jù)失效。此外,數(shù)據(jù)集合還通過(guò) company_id 字段進(jìn)行了哈希分片。
問(wèn)題排查
慢語(yǔ)句分析
在后臺(tái)拿到了慢查詢(xún)語(yǔ)句,如下:
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")
 }
});很簡(jiǎn)單的一個(gè)查詢(xún),語(yǔ)句上沒(méi)有再優(yōu)化的必要了。如果索引都在不應(yīng)該出現(xiàn)這種十多秒的耗時(shí),接下來(lái)開(kāi)始分析索引。
索引分析
索引如下:
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:創(chuàng)建集合分片使用的hash索引
 - company_id_1:普通查詢(xún)的索引
 - create_time_1:過(guò)期時(shí)間的索引
就這點(diǎn)數(shù)據(jù)量,按理說(shuō)會(huì)用到索引的,不應(yīng)該執(zhí)行耗時(shí)16s,接下來(lái)執(zhí)行計(jì)劃分析。 
Explain執(zhí)行計(jì)劃
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索引,沒(méi)有用到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": [ ]
 },這部分顯示的是被拒絕的執(zhí)行計(jì)劃列表,不會(huì)用到company_id_1、create_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")這部分顯示的是查詢(xún)的執(zhí)行統(tǒng)計(jì)信息。
索引分析
通過(guò)explain的執(zhí)行計(jì)劃,可以看出索引的使用上存在問(wèn)題。按理說(shuō)company_id、create_time都已創(chuàng)建索引,為什么沒(méi)有使用上?是什么原因?qū)е滤?,沒(méi)有用上create_time索引?
下面列舉了失效的情況:
- 索引選擇性不高:由于查詢(xún)條件是一個(gè)范圍查詢(xún),create_time 字段可能有許多不同的值滿(mǎn)足條件。因此,單鍵索引 create_time_1 的選擇性(即索引中不同值的比例)可能不高,這使得使用該索引無(wú)法有效地減少需要檢索的文檔數(shù)量。
 - 查詢(xún)需要跨越多個(gè)索引鍵值:查詢(xún)涉及到了兩個(gè)字段 company_id 和 create_time。雖然索引 create_time_1 可以幫助過(guò)濾 create_time 符合條件的文檔,但在執(zhí)行查詢(xún)時(shí),還需要考慮 company_id 的匹配條件。因此,MongoDB 需要在兩個(gè)索引之間進(jìn)行查找和合并,而不是簡(jiǎn)單地使用單個(gè)索引來(lái)解決查詢(xún)。
 - 額外的查找和合并成本:在涉及多個(gè)條件的查詢(xún)中,MongoDB 會(huì)嘗試使用覆蓋索引(Covered Index)來(lái)盡可能地減少在磁盤(pán)上的文檔檢索。然而,在這種情況下,create_time_1 索引不能單獨(dú)滿(mǎn)足查詢(xún)條件,因此 MongoDB 還需要查找和合并從 company_id_1 索引中過(guò)濾出來(lái)的文檔。這種額外的查找和合并過(guò)程會(huì)增加查詢(xún)的成本,并且降低性能。
因此,針對(duì)給定的查詢(xún)語(yǔ)句,MongoDB 不會(huì)使用 create_time_1 索引來(lái)優(yōu)化查詢(xún),而是會(huì)選擇其他更適合的索引,如 company_id_hashed 和 company_id_1。 
問(wèn)題原因
造成執(zhí)行耗時(shí)過(guò)長(zhǎng)的主要原因是索引失效的問(wèn)題,在涉及多個(gè)條件的查詢(xún)中,MongoDB 會(huì)嘗試使用覆蓋索引(Covered Index)來(lái)盡可能地減少在磁盤(pán)上的文檔檢索。然而,在這種情況下,create_time_1 索引不能單獨(dú)滿(mǎn)足查詢(xún)條件,因此 MongoDB 還需要查找和合并從 company_id_1 索引中過(guò)濾出來(lái)的文檔。這種額外的查找和合并過(guò)程會(huì)增加查詢(xún)的成本,并且降低性能。
優(yōu)化方案
創(chuàng)建新的復(fù)合索引company_id_create_time,讓其走company_id_hashed到company_id_create_time的鏈路。添加新的索引后,相同的語(yǔ)句執(zhí)行時(shí)間只需要400ms,能滿(mǎn)足業(yè)務(wù)的需求。

結(jié)論
要多關(guān)注索引在什么情況下會(huì)失效?復(fù)合索引的先后順序,不是每個(gè)條件字段都建個(gè)單個(gè)普通索引,查詢(xún)語(yǔ)句都會(huì)使用上,不要存在這種誤區(qū),有時(shí)候復(fù)合索引才是最完美的組合。
執(zhí)行計(jì)劃詳解
1、queryPlanner:包含了MongoDB查詢(xún)的執(zhí)行計(jì)劃。
- mongosPlannerVersion:MongoDB計(jì)劃版本。
 - winningPlan:勝出的執(zhí)行計(jì)劃,即MongoDB選擇的最佳執(zhí)行計(jì)劃。
 - shards: 分片的詳細(xì)信息,包括分片名稱(chēng)、連接字符串、服務(wù)器信息等。2、winningPlan: 勝出的執(zhí)行計(jì)劃。
 - stage: 執(zhí)行階段,這里是SINGLE_SHARD,表示單分片操作。
 - shardName: 執(zhí)行操作的分片名稱(chēng)。
 - plannerVersion: 計(jì)劃版本。
 - namespace: 查詢(xún)的命名空間。
 - indexFilterSet: 是否設(shè)置了索引過(guò)濾器。
 - parsedQuery: 解析后的查詢(xún)條件。
 - winningPlan: 勝出的執(zhí)行計(jì)劃的詳細(xì)信息,這里是SHARDING_FILTER。3、rejectedPlans: 被拒絕的執(zhí)行計(jì)劃列表,即非勝出的備選計(jì)劃。
每個(gè)被拒絕的執(zhí)行計(jì)劃包含了其詳細(xì)信息,包括執(zhí)行階段、過(guò)濾器、索引掃描等。
4、executionStats: 查詢(xún)的執(zhí)行統(tǒng)計(jì)信息。 - nReturned: 返回的文檔數(shù)量。
 - executionTimeMillis: 查詢(xún)執(zhí)行時(shí)間(毫秒)。
 - totalKeysExamined: 總共檢查的鍵數(shù)量。
 - totalDocsExamined: 總共檢查的文檔數(shù)量。
 - executionStages: 執(zhí)行階段的詳細(xì)統(tǒng)計(jì)信息。
 
作者介紹
吳守陽(yáng),51CTO社區(qū)編輯,擁有8年DBA工作經(jīng)驗(yàn),熟練管理MySQL、Redis、MongoDB等開(kāi)源數(shù)據(jù)庫(kù)。精通性能優(yōu)化、備份恢復(fù)和高可用性架構(gòu)設(shè)計(jì)。善于故障排除和自動(dòng)化運(yùn)維,保障系統(tǒng)穩(wěn)定可靠。具備良好的團(tuán)隊(duì)合作和溝通能力,致力于為企業(yè)提供高效可靠的數(shù)據(jù)庫(kù)解決方案。















 
 
 









 
 
 
 