震撼揭秘:線上MongoDB慢查詢終極優化實戰解析
背景
研發反饋指出,線上某個頁面的響應速度異常緩慢,達到了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:過期時間的索引
根據研發團隊的反饋和對數據的分析,我們發現當前集合使用 company_id_hashed 索引進行分片存在問題。哈希索引對等值查詢最為友好,但對于范圍查詢支持不佳。由于 company_id 是公司維度字段,相同數據較多,因此使用哈希分片并不合適。建議直接創建 company_id 和 create_time 的聯合范圍分片鍵。這樣不僅能夠友好地支持范圍查詢,還能更細粒度地拆分數據,提高查詢和寫入的效率。
針對當前情況就這點數據量,按理說會用到索引的,不應該執行耗時16s,接下來執行計劃分析。
Explain執行計劃
winningPlan
"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索引。
"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_1、create_time_1索引
"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_id、create_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 索引中過濾出來的文檔。這種額外的查找和合并過程會增加查詢的成本,并且降低性能。
問題原因
首先,集合片鍵選擇錯誤是問題的根本原因。由于集合的分片鍵是 company_id_hashed,查詢必然會使用這個索引。然而,這引發了一系列連鎖反應:即“查詢需要跨越多個索引鍵值”和“額外的查找和合并成本”。
具體來說,由于需要進行范圍查詢,首先會使用 company_id_hashed 索引。然而,MongoDB 還需要查找和合并從 company_id_1 索引中過濾出來的文檔。這種額外的查找和合并過程會增加查詢的成本,并且降低性能。這也導致了 create_time_1 索引無法被有效利用。
針對此問題,我們將已有索引進行了整改,如下:
分片鍵不重做(達到毫秒級別)
//分片鍵不做修整
db.getCollection("visitor").createIndex({
"company_id": "hashed"
}, {
name: "company_id_hashed"
});
//添加范圍聯合索引
db.getCollection("js_visitor").createIndex({
"company_id": NumberInt("1"),
"create_time": NumberInt("1")
}, {
name: "company_id_create_time"
});
//過期索引保留
db.getCollection("visitor").createIndex({
"create_time": NumberInt("1")
}, {
name: "create_time_1",
expireAfterSeconds: NumberInt("604800")
});
//刪掉company_id
db.getCollection("visitor").createIndex({
"company_id": NumberInt("1")
}, {
name: "company_id_1"
});
分片鍵重做(最完美方案,但需要重新創建集合并遷移數據)
//分片鍵重做
sh.shardCollection("cmdb.visitor",{ "company_id": "1","create_time": "1"});
索引如下:
db.getCollection("js_visitor").createIndex({
"company_id": NumberInt("1"),
"create_time": NumberInt("1")
}, {
name: "company_id_create_time"
});
//過期索引保留
db.getCollection("visitor").createIndex({
"create_time": NumberInt("1")
}, {
name: "create_time_1",
expireAfterSeconds: NumberInt("604800")
});
注意事項
1、選擇合適的分片鍵
- 分片鍵應盡量均勻分布,以避免“熱點”問題(即大多數查詢集中在某些特定分片上,導致這些分片負載過重)。
- 常用的選擇包括用戶ID、時間戳等具有自然分布特性的字段。
2、查詢模式
- 考慮主要的查詢模式,選擇的分片鍵應當能夠最大化地利用分片查詢。例如,如果大部分查詢都是基于用戶ID的,那么用戶ID就是一個合適的分片鍵。
3、寫操作分布
- 分片鍵應盡量避免集中寫入。例如,使用時間戳作為分片鍵可能導致最新的分片上寫入壓力過大。
4、更改分片鍵
- 分片鍵在集合創建后無法更改,因此在設計時需要慎重選擇。如果需要更改分片鍵,通常需要重新創建集合并遷移數據。
5、復合分片鍵
- 可以使用多個字段組合成復合分片鍵,以滿足更復雜的查詢需求。例如,使用 { userId: 1, timestamp: 1 } 作為分片鍵,可以優化基于用戶ID和時間戳的查詢。
6、哈希分片鍵
- 哈希分片鍵可以將數據均勻地分布到所有分片中,適合高并發的寫入場景。例如,使用 { _id: "hashed" } 作為分片鍵。
總結
選擇合適的分片鍵是MongoDB分片設計中的重要步驟。分片鍵的選擇需要考慮數據的分布、查詢模式和寫操作分布等因素。理解分片鍵的約束和注意事項,可以幫助我們設計高效、可擴展的分布式數據庫架構。