關於mongodb的一些常見話題深入

關於mongodb的一些常見話題深入

來自專欄猿論7 人贊了文章

說明

全篇的測試數據(沒有特別說明的情況下)基於新華詞典,通過腳本重複生成了100萬條記錄,集合名為"word",集合文檔模型大致如下:

{ "_id" : ObjectId("5b72c9169db571c8ab7ee375"), "word" : "吖", "oldword" : "吖", "strokes" : NumberInt(6), "pinyin" : "ā", "radicals" : "口", "explanation" : "喊叫天~地。
形容喊叫的聲音高聲叫~~。

吖ā[吖啶黃](-dìnghuáng)〈名〉一種注射劑。
────────────────—

吖yā 1.呼;喊。", "more" : "吖 a 部首 口 部首筆畫 03 總筆畫 06 吖2

喊,呼喊 [cry]
不索你沒來由這般叫天吖地。--高文秀《黑旋風》


喊聲
則聽得巡院家高聲的叫吖吖。--張國賓《合汗衫》
另見ā
吖1
ā
--外國語的音譯,主要用於有機化學。如吖嗪
吖啶
ādìng
[acridine] 一種無色晶狀微鹼性三環化合物c13h9n,存在於煤焦油的粗蒽餾分中,是製造染料和藥物(如吖啶黃素和奎吖因)的重要母體化合物
吖1
yā ㄧㄚˉ
(1)
喊叫天~地。
(2)
形容喊叫的聲音高聲叫~~。
鄭碼jui,u5416,gbkdfb9
筆畫數6,部首口,筆順編號251432
吖2
ā ㄚˉ
嘆詞,相當於呵」。
鄭碼jui,u5416,gbkdfb9
筆畫數6,部首口,筆順編號251432"}

常見話題

1. 執行分析

與大多數關係型資料庫一樣,mongo也為我們提供了explain方法用於分析一個語句的執行計劃。explain支持queryPlanner(僅給出執行計劃)、executionStats(給出執行計劃並執行)和allPlansExecution(前兩種的結合)三種分析模式,默認使用的是queryPlanner:

db.getCollection("word").find({ strokes: 5 }).explain()"queryPlanner" : { "parsedQuery" : { "strokes" : { "$eq" : 5.0 } }, "winningPlan" : { "stage" : "FETCH", "inputStage" : { "stage" : "IXSCAN", "keyPattern" : { "strokes" : 1.0 }, "indexName" : "strokes_1", "isMultiKey" : false, "indexBounds" : { "strokes" : [ "[5.0, 5.0]" ] } } }, "rejectedPlans" : [ { "stage" : "FETCH", "inputStage" : { "stage" : "IXSCAN", "keyPattern" : { "strokes" : 1.0, "pinyin" : 1.0 }, "indexName" : "strokes_1_pinyin_1" } } ]}

這個查詢語句同時命中了兩個索引:

  • strokes_1
  • strokes_1_pinyin_1

mongo會通過優化分析選擇其中一種更好的方案放置到winningPlan,最終的執行計劃是winningPlan所描述的方式。

其它稍次的方案則會被放置到rejectedPlans中,僅供參考。

所以queryPlanner的關注點是winningPlan,如果希望排除其它雜項的干擾,可以直接只返回winningPlan即可:

db.getCollection("word").find({ strokes: 5 }).explain().queryPlanner.winningPlan

winningPlan中,總執行流程分為若干個stage(階段),一個stage的分析基礎可以是其它stage的輸出結果。從這個案例來說,首先是通過IXSCAN(索引掃描)的方式獲取到初步結果(匹配查詢結果的所有文檔的位置信息),再通過FETCH的方式提取到各個位置所對應的文檔。這是一種很常見的索引查詢計劃。

如果沒有命中索引的話,winningPlan就有明顯的不同了:

db.getCollection("word").find({ word: 陳 }).explain().queryPlanner.winningPlan{ "stage" : "COLLSCAN", "filter" : { "word" : { "$eq" : "陳" } }, "direction" : "forward"}

COLLSCAN即全文檔掃描。

除了queryPlanner之外,還有一種非常有用的executionStats模式:

db.getCollection("word").find({ word: 陳 }).explain(executionStats).executionStats{ "executionSuccess" : true, "nReturned" : 62.0, "executionTimeMillis" : 1061.0, "totalKeysExamined" : 0.0, "totalDocsExamined" : 1000804.0, "executionStages" : { "stage" : "COLLSCAN", "filter" : { "word" : { "$eq" : "陳" } }, "nReturned" : 62.0, "executionTimeMillisEstimate" : 990.0, "works" : 1000806.0, "advanced" : 62.0, "needTime" : 1000743.0, "needYield" : 0.0, "saveState" : 7853.0, "restoreState" : 7853.0, "isEOF" : 1.0, "invalidates" : 0.0, "direction" : "forward", "docsExamined" : 1000804.0 }}

大同小異,一些關鍵欄位可以了解一下:

  • nReturned:執行返回的文檔數
  • executionTimeMillis: 執行時間(ms)
  • totalKeysExamined:索引掃描條數
  • totalDocsExamined:文檔掃描條數
  • executionStages:執行步驟

在executionStats中,我們可以更好地了解執行的情況,但由於該模式會附帶執行,如果對一個語句不夠了解的話,建議先通過queryPlanner初步評估,再決定是先優化還是接著觀察executionStats的狀況。

mongo的explain也不僅可以應用於查詢語句,通過help方法來獲取幫助的同時,也能了解explain所支持的範圍:

db.getCollection("word").explain().help()Explainable operations .aggregate(...) - explain an aggregation operation .count(...) - explain a count operation .distinct(...) - explain a distinct operation .find(...) - get an explainable query .findAndModify(...) - explain a findAndModify operation .group(...) - explain a group operation .remove(...) - explain a remove operation .update(...) - explain an update operationExplainable collection methods .getCollection() .getVerbosity() .setVerbosity(verbosity)

例如一個count方法:

(db.getCollection("word").explain(executionStats).count({ word: 陳 })).executionStats

除以上提到的stage,還有一些其它的stage也會經常遇見,例如SORT(內存排序),COUNT_SCAN(基於索引的統計),COUNT(全文檔統計)。

從資料庫高效查詢的角度來說,如果查詢語句有排序參與,那麼期待執行計劃至少不要出現SORT這個階段。但並非SORT沒有出現就算高效的,基於索引的排序也有性能之分:

db.getCollection("word").find({ strokes: 5 }).sort({ _id: 1 }).explain(executionStats).executionStats

由於返回的文檔順序與索引欄位_id沒有任何關係,通過_id去排序儘管會用到索引,但實際上需要全表掃描,效率較低(當然,如果不用索引欄位來排,受限於內存,可能根本就沒法執行成功):

{ "nReturned" : 33356.0, "executionTimeMillis" : 1261.0, "totalKeysExamined" : 1000804.0, "totalDocsExamined" : 1000804.0}

如果排序跟返回的文檔順序有關聯效率就完全不同了:

db.getCollection("word").find({ strokes: 5 }).sort({ strokes: -1 }).explain(executionStats).executionStats{ "nReturned" : 33356.0, "executionTimeMillis" : 83.0, "totalKeysExamined" : 33356.0, "totalDocsExamined" : 33356.0}

所以說,在有排序需求的場景中,索引的創建最好是結合排序欄位,以達到最優的執行效率。

2. 線上資料庫查詢慢的問題定位

如果對資料庫的性能沒有一個好的概念,很容易生產出一些健美型項目,看起來三大五粗,好不威風,直到數據量達到一定的規模它們就開始三步一喘了,如果到這個時候還沒有及時排除隱患,最終的結果自然就是項目運行崩潰,健美選手的肌肉始終不如專業運動員厚實,撐不住真正的壓力。

基於這些問題,mongo提供了很多好用的特性幫助我們快速定位隱患,例如使用mongostat查看實時的運行狀況:

mongostat [--host {ip}:{port}] [-u {user} -p {password} --authenticationDatabase {dbName}]

大致可以獲得如下的結果:

insert、delete、update和query指示了該時段每秒執行的次數,可以粗略評估資料庫壓力。其它欄位也可以作為參考,比如vsize(佔用多少兆的虛擬內存),res(佔用多少兆的物理內存),net_in(入網流量),net_out(出網流量),conn(當前連接數)。

對於conn,需要捎帶一提。mongo為每個連接建立一條線程,線程創建、釋放以及上下文切換的開銷同樣會體現在每一條連接上。通常客戶端會維護一個連接池,所以conn的量應該是得到控制的,如果發現有異常則需要儘快排查優化。

通過下面的方式可以查看當前資料庫的可用連接數:

db.serverStatus().connections{ "current" : 6.0, "available" : 3885.0, "totalCreated" : 9.0}

是的,db.serverStatus()也能獲取資料庫的一些統計信息,有需要可以作為參考項。

如果覺得這些不夠直白的話,也可以開啟慢日誌,在發現運行緩慢的時候通過分析慢日誌很容易定位到問題。只要設置mongo profiling的級別即可開啟:

db.setProfilingLevel(level, slowms)

關於level,mongo支持三個級別:

  • 0:默認,不開啟命令記錄
  • 1:記錄慢日誌,默認記錄執行時間大於100ms的命令
  • 2:記錄所有命令

slowms指明了超過多少ms被認為是慢命令。

開啟命令日誌之後(僅作用於接受執行命令的db),每一條命令的運行情況都會被當成一條文檔插入該db的system.profile集合中,下面是剔除部分欄位後的一條記錄:

{ "op" : "query", "ns" : "dictionary.word", "query" : { "find" : "word", "filter" : { "strokes" : 5.0 }, "limit" : NumberInt(10), "batchSize" : NumberInt(4850) }, "keysExamined" : NumberInt(10), "docsExamined" : NumberInt(10), "fromMultiPlanner" : true, "cursorExhausted" : true, // 命令返回的文檔數 "nreturned" : NumberInt(10), // 命令返回的位元組數 "responseLength" : NumberInt(44810), "protocol" : "op_query", // 命令執行時間 "millis" : NumberInt(1), // 命令執行計劃的簡要說明 "planSummary" : "IXSCAN { strokes: 1.0 }", // 命令的執行時間點 "ts" : ISODate("2018-08-16T06:33:44.084+0000")}

欄位見名思義,很是清晰。需要注意的是,該集合沒有設置任何索引欄位,也不允許自行建立索引,為了後期的查詢效率,建議只開啟慢日誌記錄,而非全命令。

除了慢日誌分析之外,還可以直接獲取當前資料庫正在執行中的命令:

db.currentOp()

下面是剔除了無關命令以及部分欄位的一條記錄

{ "inprog" : [ { // 該操作的id "opid" : 99080.0, // 已運行的描述 "secs_running" : 1.0, // 已運行的微秒數 "microsecs_running" : NumberLong(1088762), "op" : "query", "ns" : "dictionary.word", "query" : { "find" : "word", "filter" : { "pinyin" : { "$regex" : "z" } }, "batchSize" : 4850.0 }, "planSummary" : "IXSCAN { pinyin: 1, strokes: 1 }" } ]}

通過currentOp可以方便地查看當前資料庫有哪些命令執行有異常,從而針對性做出優化。當然,它還有一個用途,比如某個天氣晴朗的好日子,一個新來的臨時工在生產上執行了一條不可描述的語句,將整個資料庫給阻塞住了,線上相關項目停擺,大量用戶熱火朝天開始撥出投訴電話,就在大家火急火燎地接待解釋時,優雅的你,只是隨手執行了一下這個語句:

db.killOp(99080)

很好,一切恢復正常,繼續喝茶聊天。

3. 查詢返回的文檔順序

很多人誤認為mongo用_id欄位創建了一個默認索引,所以當我們進行查詢時,返回來的結果是以_id排好序的,實際上不是。

mongo返回的文檔順序與查詢時的掃描順序一致,掃描順序則分為全表掃描和索引掃描。全表掃描的順序與文檔在磁碟上的存儲順序相同,所以有時會巧合地發現全表掃描返回的文檔順序跟ObjectId有關,這純屬誤解。

什麼時候會採用全表掃描?

在我們不加任何查詢條件或者使用非索引查詢時,mongo會直接掃描全文檔,這時候就是全表掃描了。

什麼時候會採用索引掃描?

當我們以一個索引欄位去查詢時,mongo不會直接載入磁碟中的文檔,而是先以索引值進行匹配,得到所有能初步匹配的文檔位置之後,再根據條件決定是否去磁碟載入對應的文檔或者繼續下一個階段。因為索引本身是排好序的,所以掃描的規律自然也是跟著索引的順序走的。

例如我們有下面的一個集合(test):

{ "first" : 1.0, "second" : 3.0}{ "first" : 2.0, "second" : 2.0}{ "first" : 3.0, "second" : 1.0}{ "first" : 2.0, "second" : 1.0}

我們為它創建一個first_1索引,再通過first欄位進行查詢:

db.getCollection(word).find({ first: { $ne: null } })

返回的結果是first有序的:

{ "first" : 1.0, "second" : 3.0}{ "first" : 2.0, "second" : 2.0}{ "first" : 2.0, "second" : 1.0}{ "first" : 3.0, "second" : 1.0}

如果我們需要使用first查詢,但卻能以second排序呢?正常來說是使用下面的查詢語句:

db.getCollection(test).find({ first: { $ne: null } }).sort({ second: 1 })

但很不幸,這需要使用到內存排序,所以會經過這些階段:

  1. IXSCAN:通過first_1索引掃描出相關文檔的位置
  2. FETCH:根據前面掃描到的位置抓取完整文檔
  3. SORT_KEY_GENERATOR:獲取每一個文檔排序所用的鍵值
  4. SORT:進行內存排序,最終返回結果

效率自然極低,該怎麼優化呢?嘗試建立一個複合索引first_1_second_1可行否?將second也加入排序返回來的結果不就正好有序了嗎?真不是,複合索引的排序與欄位的前後有關,first_1_second_1這個索引會優先確保first有序,在first相同時,這些相同的文檔間則以second為排序依據,所以複合索引的第一個欄位才是真正決定文檔間如何排序的依據。

既然這樣,我們可否創建一個second_1_first_1索引呢?命中這個索引的話,返回的結果就已經是以second排好序了吧?所以後面的sort也可以乾脆不需要了?想法是對的,但執行還是有點問題:

db.getCollection(test).find({ first: { $ne: null } }).explain(executionStats).executionStats

通過分析計劃可以看到,根本不會命中second_1_first_1索引:

{ "parsedQuery" : { "first" : { "$lte" : 2.0 } }, "winningPlan" : { "stage" : "COLLSCAN", "filter" : { "first" : { "$lte" : 2.0 } }, "direction" : "forward" }, "rejectedPlans" : [ ]}

這又是怎麼回事呢?又回到複合索引欄位前後的問題了,mongo的索引有點局限,要求使用複合索引時必須命中第一個欄位,這一點可能會是很多人使用mongo索引的誤區。

當然,辦法也是有的,我們先嘗試把sort欄位加回去,為了更好地發現下一個問題,將條件稍作改變再分析一下:

db.getCollection(test).find({ first: { $lte: 2 } }).explain(executionStats).executionStats{ "executionSuccess" : true, "nReturned" : 3.0, "executionTimeMillis" : 0.0, "totalKeysExamined" : 4.0, "totalDocsExamined" : 4.0, "executionStages" : { "stage" : "FETCH", "filter" : { "first" : { "$lte" : 2.0 } }, "nReturned" : 3.0, "executionTimeMillisEstimate" : 0.0, "docsExamined" : 4.0, "inputStage" : { "stage" : "IXSCAN", "nReturned" : 4.0, "executionTimeMillisEstimate" : 0.0, "works" : 5.0, "advanced" : 4.0, "needTime" : 0.0, "needYield" : 0.0, "saveState" : 0.0, "restoreState" : 0.0, "isEOF" : 1.0, "invalidates" : 0.0, "keyPattern" : { "second" : 1.0, "first" : 1.0 }, "indexName" : "second_1_first_1", "isMultiKey" : false, "multiKeyPaths" : { "second" : [ ], "first" : [ ] }, "isUnique" : false, "isSparse" : false, "isPartial" : false, "indexVersion" : 2.0, "direction" : "forward", "indexBounds" : { "second" : [ "[MinKey, MaxKey]" ], "first" : [ "[MinKey, MaxKey]" ] }, "keysExamined" : 4.0, "seeks" : 1.0, "dupsTested" : 0.0, "dupsDropped" : 0.0, "seenInvalidated" : 0.0 } }}

加了sort之後確實命中索引了,這實際上是mongo的一個優化行為,sort欄位也可以參與索引的匹配,只是存在一個問題,這條命令最終返回三個文檔,但它不像我們命中first_1索引一樣,只掃描命中的部分,而是先掃描second,由於這裡second只是作為排序來使用,沒有明確的值,那麼second欄位有多少記錄就會掃描多少個鍵,基本等同於全表掃描,唯一的優化點在於不需要排序(相較於全表掃描之後還要進行排序,略有優勢)。

如果也將second加入查詢條件,結果就會好多了。

看起來mongo的索引確實不如預期的靈活,但如果把場景變換一下,就會發現好用多了。例如,我們需要查first為一個確定值的記錄,並希望按照second排序,那麼first_1_second_1就完全滿足需要了,first用於索引,second用於排序(強調一下,前提是first為一個定值)。

用一個更具體的例子描述一下,比如有一個存儲用戶行為的集合:

{ userId: blurooo, action: sleep, timestamp: ISODate(2018-08-16T23:00:00.0000)}{ userId: blurooo, action: wake, timestamp: ISODate(2018-08-16T07:30:00.0000)}

需要查詢某個用戶一天的所有行為,按照行為發生的時間排序。建立一個userId_1_timestamp_1索引,查詢時只要以userId為條件即可:

db.getCollection(user_action).find({ userId: blurooo })

不需要二次排序即已滿足需要。

4. 如何創建索引?

db.collection.createIndex(keys[, options])

例如為word集合的strokes欄位建立一個升序索引(單鍵索引的排序方向對性能影響不大,多鍵索引的排序方向則需要遵從場景來選擇):

db.getCollection(word).createIndex({ strokes: 1 })

需要注意的是,這種索引創建方式會阻塞資料庫的所有讀寫操作,直到索引建立完成。為線上已有大量數據的資料庫建立索引時,更合理的是使用速度相對較慢的後台創建方式:

db.getCollection(word).createIndex({ strokes: 1 }, { background: true })

以這種方式創建索引時,在創建期間資料庫可以正常接受讀寫,如果需要了解創建進度,可以通過currentOp來查看:

db.currentOp(){ "inprog" : [ { "opid" : 406436.0, "secs_running" : 2.0, "microsecs_running" : NumberLong(2619306), "query" : { "createIndexes" : "word", "indexes" : [ { "key" : { "strokes" : 1.0 }, "name" : "strokes_1", "background" : true } ] }, "msg" : "Index Build (background) Index Build (background): 439475/1000804 43%", "progress" : { "done" : 439476.0, "total" : 1000804.0 } } ], "ok" : 1.0}

5. 索引的限制

索引的創建有一些正常場景下很難觸發所以存在感很低的限制,了解一下是有必要的:

  • 被索引的欄位值最大不能超過1024個位元組,否則會得到一個KeyTooLong的錯誤(儘管可以通過配置參數解除限制,但盡量不要這麼做)。
  • 一個集合最多可以有64個索引。
  • 索引名稱長度:包括資料庫與集合名稱總共不超過125字元。
  • 聯合索引最多可以有31個欄位參與。

索引不能被以下的查詢使用:

  • 正則表達式及非操作符,如 $nin, $not, 等。
  • 算術運算符,如 $mod, 等。
  • $where 子句

6. 索引的佔用空間對性能影響

在這種磁碟白菜價的年代討論空間大小似乎有點不合時宜,索引文件不也是存在磁碟的嗎?佔用空間稍微大點不礙事吧?nono,索引雖然也是持久化在磁碟中的,但為了確保索引的速度,實際上需要將其載入到內存中使用,討論索引的佔用空間其實也是在討論內存的佔用空間。當然了,資料庫伺服器內存動輒T計的土豪朋友請忽略這個話題。

索引依賴於內存,當內存不足以承載所有索引的大小時,就會出現內存 - 磁碟交換的情況,從而大大地降低索引的性能。所以在創建索引時,也應該評估好內存狀態。可通過下面的方式獲取某一個文檔總的索引大小(bytes):

db.collection.totalIndexSize()

29642752

由於索引是直接以被索引的欄位值為鍵的,當出現一個需求場景允許選擇多種索引方案的其中一種時,在內存的層面上看,選擇欄位值相對較小的方案是更划算的。

7. 說說_id欄位

mongodb默認為每一個文檔創建了_id欄位,並作為索引存在,其值為一個12位元組的ObjectId類型:

ObjectId = 4個位元組的unix時間戳 + 3個位元組的機器信息 + 2個位元組的進程id + 3個位元組的自增隨機數

ObjectId的生成方式可以借鑒在很多有分散式id生成需要的場景中。

清楚了ObjectId的生成方式,我們可以很方便地將它與時間戳互轉,下面以Javascript為例示例:

// 日期轉ObjectIdfunction timeToObjectId(date) { let seconds = Math.floor(date.getTime() / 1000); // 將時間戳(s)轉化為十六進位,再補充16個0 return seconds.toString(16) + 0000000000000000;}// ObjectId轉時間,mongo本身可以直接獲取ObjectId("5b72c9169db571c8ab7ee374").getTimestamp();

ObjectId的構成特性給我們帶來了一些額外的思考,關於文檔維護一個createTime欄位是否有必要?這實際上需要視場景而定。一個時間戳是8個位元組,一個ObjectId是12位元組,在不缺磁碟空間的今天,增加一個createTime不會帶來多少負擔,但可以更直觀地觀察到文檔的創建時間,如果創建時間需要被展示到業務場景中,每次通過ObjectId去轉換也是相當吃力不討好。更進一步,如果有按照創建時間建立複合索引的用途,時間戳也要比ObjectId節省近30%的內存使用量。當然,如果業務場景不關注創建時間,僅僅需要獲取某個時間段創建的記錄,那麼只使用ObjectId也是非常合理的。

作者:buthim

鏈接:imooc.com/article/68412

來源:慕課網

推薦閱讀:

PlayScala技巧 - 實時同步MongoDB高可用方案
如何安裝MongoDB(MongoDB安裝教程)
Mongodb學習記錄:入門(一)
使用Studio 3T鏈接MongoDB Atlas
數據量在億級以上,hbase與mongodb的選擇?

TAG:SQL | 資料庫 | MongoDB |