1. 程式人生 > >技術分享 | MongoDB 一次排序超過記憶體限制的排查

技術分享 | MongoDB 一次排序超過記憶體限制的排查

作者:任仲禹

本文目錄:

一、背景

1. 配置引數檢查

2. 排序欄位是否存在索引

二、測試環境模擬索引對排序的影響

1. 測試環境資訊

2. 報錯語句的執行計劃解釋 3. 建立新的組合索引進行測試

三、引申的組合索引問題

1. 查詢語句中,排序欄位 _id 使用降序

2. 查詢語句中,排序欄位 Num 和 _id 全部使用降序

四、引申的聚合查詢問題

1.Sort stage 使用記憶體排序

五、結論

1. 排序記憶體限制的問題

2. 使排序操作使用到索引 

1) 為查詢語句建立合適的索引

2) 注意字首索引的使用

3.聚合查詢新增allowDiskUse選項

六、參考文獻

一、背景

某次在客戶現場處理一起APP業務中頁面訪問異常的問題,該頁面直接是返回一行行碩大的報錯程式碼,錯誤大概如下所示:

MongoDB.Driver.MongoQueryException: QueryFailure flag was Executor error: OperationFailed: Sort operation used more than the maximum 33554432 bytes of RAM. Add an index, or specify a smaller limit

報錯頁面很明顯告知了問題排查的方向:

  • Sort operation 該頁面涉及的MongoDB查詢語句使用了排序。

  • more than the maximum 33554432 排序操作超過了MongoDB單個Session排序可使用的最大記憶體限制。

檢索MongoDB的日誌確實存在大量的查詢報錯,跟APP頁面報錯能夠對應上;並且日誌中排序使用的欄位為DT 和 _id ,升序排序。

涉及業務敏感字,全文會略過、改寫或使用'xxx'代替
2019-XX-XXTXX:XX:XX.XXX+0800 E QUERY [conn3644666] Plan executor error during find: FAILURE, ·········· sortPattern: {DT: 1, _id: 1 }, memUsage: 33555513, memLimit: 33554432, ·············· }
2019-XX-XXTXX:XX:XX.XXX+0800 I QUERY [conn3644666] assertion 17144 Executor error: OperationFailed: Sort operation used more than the maximum 33554432 bytes of RAM. Add an index, or specify a smaller limit. ns:XXXXX query:{ $query:········ $orderby: { DT: 1, _id: 1 }, $hint: { CID: 1, CVX: 1 } }

1. 配置引數檢查

MongoDB Server中確認了對於Sort排序能夠支援的最大記憶體限制為32M。

> use admin
switched to db admin
> db.runCommand({ getParameter : 1, "internalQueryExecMaxBlockingSortBytes" : 1 } )
{ "internalQueryExecMaxBlockingSortBytes" : 33554432, "ok" : 1 }

2. 排序欄位是否存在索引

根據報錯資訊的建議,檢視官方文件的解釋:

In MongoDB, sort operations can obtain the sort order by retrieving documents based on the ordering in an index. If the query planner cannot obtain the sort order from an index, it will sort the results in memory. Sort operations that use an index often have better performance than those that do not use an index. In addition, sort operations that do not use an index will abort when they use 32 megabytes of memory.

文件中意思大概是:在排序欄位未利用到索引的情況下,若超過32M記憶體則會被Abort,語句直接返回報錯。

那麼現在方向基本可以鎖定在排序操作是否使用到索引了;檢視該集合狀態,排序欄位 DT 和 _id確實存在索引_id_、 DT_1 、 DT_1_CID_1_id_1 ,為啥還會報錯?帶著疑問我們下文在測試環境進行模擬。

> db.xxx.getIndexes()
[
·········
{
"v" : 1,
"key" : {
"_id" : 1
},
"name" : "_id_",
"ns" : "xxx.xxx"
},
{
"v" : 1,
"key" : {
"DT" : 1
},
"name" : "DT_1",
"ns" : "xxx.xxx"
},
{
"v" : 1,
"key" : {
"DT" : 1,
"CID" : 1,
"_id" : 1
},
"name" : "DT_1_CID_1_id_1",
"ns" : "xxx.xxx"
}
···········
 
  1. > db.xxx.getIndexes()

  2. [

  3. ·········

  4. {

  5. "v" : 1,

  6. "key" : {

  7. "_id" : 1

  8. },

  9. "name" : "_id_",

  10. "ns" : "xxx.xxx"

  11. },

  12. {

  13. "v" : 1,

  14. "key" : {

  15. "DT" : 1

  16. },

  17. "name" : "DT_1",

  18. "ns" : "xxx.xxx"

  19. },

  20. {

  21. "v" : 1,

  22. "key" : {

  23. "DT" : 1,

  24. "CID" : 1,

  25. "_id" : 1

  26. },

  27. "name" : "DT_1_CID_1_id_1",

  28. "ns" : "xxx.xxx"

  29. }

  30. ···········

 

二、測試環境模擬索引對排序的影響

1.測試環境資訊

MongoDB版本 4.0.10
MongoDB 儲存引擎 wiredTiger
資料量 1000000
測試集合名 data_test

集合資料儲存格式

> db.data_test.findOne()
{
"_id" : ObjectId("5d0872dc5f13ad3173457186"),
"Name" : "Edison",
"Num" : 195930,
"loc" : {
"type" : "Point",
"coordinates" : [
118.0222094243601,
36.610739264097646
]
}
}

集合索引資訊

> db.data_test.getIndexes()
[
{
"v" : 2,
"key" : {
"_id" : 1
},
"name" : "_id_",
"ns" : "mongobench.data_test"
},
{
"v" : 2,
"key" : {
"Name" : 1
},
"name" : "Name_1",
"ns" : "mongobench.data_test"
},
{
"v" : 2,
"key" : {
"Num" : 1
},
"name" : "Num_1",
"ns" : "mongobench.data_test"
},
{
"v" : 2,
"key" : {
"Num" : 1,
"Name" : 1,
"_id" : 1
},
"name" : "Num_1_Name_1__id_1",
"ns" : "mongobench.data_test"
}
]

查詢語句

為測試方便,將業務中報錯的聚合查詢按同樣查詢邏輯修改為 Mongo Shell 中的普通 find() 查詢

 

2. 報錯語句的執行計劃解釋

測試查詢報錯的語句,嘗試檢視其查詢計劃如下:

> db.data_test.find({'Num':{"$gt":500000}}).sort({"Num":1,"_id":1}).explain()
2019-06-19T18:21:14.745+0800 E QUERY [js] Error: explain failed: {
"ok" : 0,
"errmsg" : "Sort operation used more than the maximum 33554432 bytes of RAM. Add an index, or specify a smaller limit.",
"code" : 96,
"codeName" : "OperationFailed"
}

直接報錯,這裡有個疑問為啥連執行計劃都看不了?先不急,我們先刪除對於排序欄位的組合索引 Num_1_Name_1_id_1 後,再檢視執行計劃:

> db.data_test.dropIndex('Num_1_Name_1__id_1')
{ "nIndexesWas" : 4, "ok" : 1 }
db.data_test.find({'Num':{"$gt":500000}}).sort({"Num":1,"_id":1}).explain('executionStats')
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "mongobench.data_test",
"indexFilterSet" : false,
"parsedQuery" : {
"Num" : {
"$gt" : 500000
}
},
"winningPlan" : {
"stage" : "SORT",
"sortPattern" : {
"Num" : 1,
"_id" : 1
},
"inputStage" : {
"stage" : "SORT_KEY_GENERATOR",
·······
"rejectedPlans" : [ ]
},
"executionStats" : {
"executionSuccess" : false,
"errorMessage" : "Exec error resulting in state FAILURE :: caused by :: Sort operation used more than the maximum 33554432 bytes of RAM. Add an index, or specify a smaller limit.",
"errorCode" : 96,
"nReturned" : 0,
"executionTimeMillis" : 1504,
"totalKeysExamined" : 275037,
"totalDocsExamined" : 275037,
"executionStages" : {
"stage" : "SORT",
"nReturned" : 0,
"executionTimeMillisEstimate" : 188,
····
"memUsage" : 33554514,
"memLimit" : 33554432,
"inputStage" : {
"stage" : "SORT_KEY_GENERATOR",
"nReturned" : 275037,
·····

查詢計劃中關鍵引數的解釋:

1. queryPlanner:explain中三種模式之一,預設模式。表示不會執行查詢語句而是選出最優的查詢計劃即winning plan,剩餘兩種模式分別是 executionStats 和 allPlansExecution

  • winningPlan:MongoDB優化器選擇的最優執行計劃

[1]stage:包括COLLSCAN 全表掃描、IXSCAN 索引掃描、FETCH 根據索引去檢索指定文件、SORT 在記憶體中進行排序(未使用索引)

[2]sortPattern:需排序的欄位

[3]inputStage:winningPlan.stage的子階段

  • rejectedPlans:優化器棄用的執行計劃

2. executionStats:返回執行結果的狀態,如語句成功或失敗等

  • executionSuccess:語句執行是否成功

  • errorMessage:錯誤資訊

  • nReturned:返回的記錄數

  • totalKeysExamined:索引掃描總行數

  • totalDocsExamined:文件掃描總行數

  • memUsage:Sort 使用記憶體排序操作使用的記憶體大小

  • memLimit:MongoDB 內部限制Sort操作的最大記憶體

上述執行計劃表明查詢語句在未使用索引排序的情況下如果排序使用的記憶體超過32M必定會報錯,那麼為什麼沒有使用到索引排序,是不是跟組合索引的順序有關?

 

3. 建立新的組合索引進行測試

直接建立 Num 和 _id 列都為升序的組合索引,再次檢視執行計劃:

> db.data_test.ensureIndex({Num:1,_id:1})
{
"createdCollectionAutomatically" : false,
"numIndexesBefore" : 3,
"numIndexesAfter" : 4,
"ok" : 1
}
> db.data_test.find({'Num':{"$gt":500000}}).sort({"Num":1,"_id":1}).explain('executionStats')
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "mongobench.data_test",
"indexFilterSet" : false,
"parsedQuery" : {
"Num" : {
"$gt" : 500000
}
},
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"Num" : 1,
"_id" : 1
},
"indexName" : "Num_1__id_1",
·········
"rejectedPlans" : [
{
"stage" : "SORT",
"sortPattern" : {
"Num" : 1,
"_id" : 1
},
"inputStage" : {
"stage" : "SORT_KEY_GENERATOR",
·········
"executionStats" : {
"executionSuccess" : true,
"nReturned" : 499167,
"executionTimeMillis" : 1355,
"totalKeysExamined" : 499167,
"totalDocsExamined" : 499167,
"executionStages" : {
"stage" : "FETCH",
"nReturned" : 499167,
"executionTimeMillisEstimate" : 102,
"works" : 499168,
"advanced" : 499167,
"needTime" : 0,
"needYield" : 0,
"saveState" : 3901,
"restoreState" : 3901,
"isEOF" : 1,
"invalidates" : 0,
"docsExamined" : 499167,
"alreadyHasObj" : 0,
"inputStage" : {
"stage" : "IXSCAN",
"nReturned" : 499167,
"executionTimeMillisEstimate" : 14,
"works" : 499168,
·······

上述執行計劃說明:

  • winningPlan.stage:優化器選擇了FETCH+IXSCAN的Stage,而不是之前的Sort;這是最優的方式之一,也就是通過索引檢索指定的文件資料,並在索引中完成排序 (”keyPattern” : {“Num” : 1,”_id” : 1}) ,效率最高

  • rejectedPlans:Sort 使用記憶體排序的方式被優化器棄用

  • executionSuccess:語句執行成功

  • nReturned:語句返回結果數為499167

 

三、引申的組合索引問題

上文中查詢語句explain()直接報錯,是因為組合索引為{Num_1_Name_1_id_1},而查詢語句為sort({“Num”:1,”_id”:1}),未遵循最左原則,索引無法被使用到而後優化器選擇Sort Stage觸發了記憶體限制並Abort。

至於為啥MongoDB連執行計劃都不返回給你,可以後續再討論,歡迎評論

建立合適的組合索引後,查詢語句成功執行;那麼如果不按照索引的升降順序執行語句會怎樣?

 

1.查詢語句中,排序欄位 _id 使用降序

當前的組合索引為{“key” : {“Num” : 1, “_id” : 1} },也就是都為升序,而我們將查詢語句中排序欄位 _id使用降序排序時,查詢語句直接報錯,說明該語句也未使用到索引排序,而是使用的Sort Stage。

> db.data_test.find({'Num':{"$gt":500000}}).sort({"Num":1,"_id":-1}).explain('executionStats')
2019-06-19T19:32:30.939+0800 E QUERY [js] Error: explain failed: {
"ok" : 0,
"errmsg" : "Sort operation used more than the maximum 33554432 bytes of RAM. Add an index, or specify a smaller limit.",
"code" : 96,
"codeName" : "OperationFailed"
}

2.查詢語句中,排序欄位 Num _id 全部使用降序

我們現在將查詢語句的排序欄位全部使用降序,與組合索引全部相反再測試,執行成功。

> db.data_test.find({'Num':{"$gt":500000}}).sort({"Num":-1,"_id":-1}).explain('executionStats')
{
"queryPlanner" : {
······
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"Num" : 1,
"_id" : 1
},
"indexName" : "Num_1__id_1",
·······
"rejectedPlans" : [
{
"stage" : "SORT",
·······
"executionStats" : {
"executionSuccess" : true,
·······
"inputStage" : {
"stage" : "IXSCAN",
·······
"indexName" : "Num_1__id_1",
······
"ok" : 1
}

再次做其他查詢組合測試 sort({“Num”:-1,”_id”:1}),執行依然失敗;說明只有在排序列的升降序只有和組合索引中的 方向 保持 全部相同 全部相反,語句執行才能成功。

四、引申的聚合查詢問題

上文中的查詢測試語句是在 MongoDB Shell 執行的 find() 查詢方法,但是業務程式中查詢一般都是使用聚合查詢方法 aggregate(),對於聚合查詢中的Sort Stage,官方文件說明了使用記憶體排序能使用最大的記憶體為100M,若需要避免報錯則需要新增 {allowDiskUse : true} 引數。

The $sort stage has a limit of 100 megabytes of RAM. By default, if the stage exceeds this limit, $sort will produce an error. To allow for the handling of large datasets, set the allowDiskUse option to true to enable $sort operations to write to temporary files. See the allowDiskUse option in db.collection.aggregate() method and the aggregate command for details.

1.Sort stage 使用記憶體排序

將普通的 find() 方法轉為 aggregate() 聚合方法,語義不變,特意將排序欄位 _id 修改為 降序 -1 ,那麼查詢計劃將無法使用到組合索引只能使用Sort stage。下文中查詢依然報錯,Sort stage操作使用的記憶體超過100M

> db.data_test.explain('executionStats').aggregate([{ $match : { Num : { $gt : 500000} } },{ $sort : { "Num" : 1, _id: -1 } }])
2019-06-19T20:28:43.859+0800 E QUERY [js] Error: explain failed: {
"ok" : 0,
"errmsg" : "Sort exceeded memory limit of 104857600 bytes, but did not opt in to external sorting. Aborting operation. Pass allowDiskUse:true to opt in.",
"code" : 16819,
"codeName" : "Location16819"
} :
_getErrorWithCode@src/mongo/shell/utils.js:25:13
throwOrReturn@src/mongo/shell/explainable.js:31:1
constructor/this.aggregate@src/mongo/shell/explainable.js:121:1
@(shell):1:1

新增 {allowDiskUse: true} 引數,可以使Sort stage操作繞過記憶體限制而使用磁碟,查詢語句可以執行成功:

> db.data_test.explain('executionStats').aggregate([{ $match : { Num : { $gt : 500000} } },{ $sort : { "Num" : 1, _id: -1 } }],{allowDiskUse: true})
{
"stages" : [
······
"executionStats" : {
"executionSuccess" : true,
"nReturned" : 499167,
"executionTimeMillis" : 4128,
"totalKeysExamined" : 499167,
"totalDocsExamined" : 499167,
······
{
"$sort" : {
"sortKey" : {
"Num" : 1,
"_id" : -1
}
}
}
],
"ok" : 1
}

五、結論

1.排序記憶體限制的問題

MongoDB使用記憶體進行排序的場景只有是Sort stage,官方文件有說明:

If MongoDB can use an index scan to obtain the requested sort order, the result will not include a SORT stage. Otherwise, if MongoDB cannot use the index to sort, the explain result will include a SORT stage.

意思大概是如果MongoDB可以使用索引掃描來進行排序,那麼結果將不包括SORT stage。否則如果MongoDB無法使用索引進行排序,那麼查詢計劃將包括SORT stage。

使用索引掃描的效率是遠大於直接將結果集放在記憶體排序的,所以MongoDB為了使查詢語句更有效率的執行,限制了 排序記憶體的使用,因而規定了只能使用 32M,該種考慮是非常合理的。

但也可通過手工調整引數進行修改(不建議):

# 比如調大到 128M
## 線上調整
> db.adminCommand({setParameter:1, internalQueryExecMaxBlockingSortBytes:134217728})
## 持久到配置檔案
setParameter:
internalQueryExecMaxBlockingSortBytes: 134217728

2.使排序操作使用到索引

1)為查詢語句建立合適的索引如果查詢中排序是單列排序,如sort({“Num”:1}),那麼只需新增為 Num 列新增索引即可,排序的順序無影響

## 例如索引為 {'Num':1},查詢不管升/降序都可使用到索引排序
db.data_test.find().sort({Num:1}) 
db.data_test.find().sort({Num:-1}) 

如果查詢中排序是使用組合排序,如sort({“Num”:1,”id”:1}),那麼需要建立對應的組合索引,如{“key” : {“Num” : 1, “_id” : 1} 或者 {“key” : {“Num” : -1, “_id” : -1}

## 例如索引為{"Num" : 1, "_id" : 1},可以用到索引排序的場景為
db.data_test.find().sort({Num:1,_id:1})
db.data_test.find().sort({Num:-1,_id:-1})

注意保持查詢中組合排序的升降序和組合索引中的 方向 保持 全部相同 或 全部相反

 

2)注意字首索引的使用

上文查詢報錯的案例分析已說明了組合索引每一個鍵的順序非常重要,這將決定該組合索引在查詢過程中能否被使用到,也將是MongoDB的索引及排序同樣需遵循最左字首原則。

 

3. 聚合查詢新增allowDiskUse選項

儘可能的保證查詢語句的排序能夠使用索引排序,但如果業務需要規避排序記憶體限制報錯的問題,那麼需要在程式碼中新增 {allowDiskUse : true} 引數。

六、參考文獻

https://docs.mongodb.com/manual/tutorial/sort-results-with-indexes/index.html

https://docs.mongodb.com/manual/reference/operator/aggregation/sort/#sort-memory-limit

https://docs.mongodb.com/manual/reference/explain-results/#executionstats