千萬級 MongoDB 數據索引優化實踐

小李是這家公司的後端負責人,忽然有一天下午,收到大量客服反饋用戶沒法使用咱們的APP,不少操做與加載都是網絡等待超時。數據庫

收到信息後,小李立馬排查問題緣由,不過多一會,定位到數據庫出現大量慢查詢致使服務器超負荷負載狀態,CPU居高不下,那麼爲何會出現這個狀況呢,此時小李很慌,通過查詢資料,開始往慢查詢方向探究,果不其然,因爲業務數據增加迅猛,對應的數據表沒有相應查詢的索引數據,此刻小李嘴角上揚,面露微笑,信心百倍上手的給數據庫相關數據表加上了索引字段。可是狀況並無好轉,線上依舊沒有恢復,經驗使然,最後只能採起降級的方案(關閉此表的相關查詢業務)臨時先恢復線上正常。後端

可是事情並無結束,問題沒有根本性的解決,公司和本身依舊很是在乎這個問題的解決,晚上吃飯的時候,小李忽然想起了本身有認識一個行業大佬(老白)。把問題跟老白說了一遍,老白並沒過多久,很快就專業的告訴了小白哪些操做存在問題,怎麼樣能夠正確的解決這個問題,加索引的時候首先要學會作查詢分析,而後瞭解ESR最佳實踐規則(下面會作說明),小李沒有由於本身的不足感到失落,反而是由於本身的不足更是充滿了求知慾。設計模式

數據庫索引的應用有哪些優秀的姿式呢?數組

MongoDB 索引類型

單鍵索引

db.user.createIndex({createdAt: 1})

createdAt建立了單字段索引,能夠快速檢索createdAt字段的各類查詢請求,比較常見
{createdAt: 1} 升序索引,也能夠經過{createdAt: -1}來降序索引,對於單字段索引,
升序/降序效果是同樣的。安全

組合索引

db.user.createIndex({age: 1, createdAt: 1})

能夠對多個字段聯合建立索引,先按第一個字段排序,第一個字段相同的文檔按第二個字段排序,依次類推,因此在作查詢的時候排序與索引的應用也是很是重要。服務器

實際場景,使用最多的也是這類索引,在MongoDB中是知足因此能匹配符合索引前綴的查詢,例如已經存在db.user.createIndex({age: 1, createdAt: 1})
咱們就不須要單獨爲db.user.createIndex({age: 1}) 創建索引,由於單獨使用age作查詢條件的時候,也是能夠命中db.user.createIndex({age: 1, createdAt: 1}) ,可是使用createdAt單獨做爲查詢條件的時候是不能匹配db.user.createIndex({age: 1, createdAt: 1})網絡

多值索引

當索引的字段爲數組時,建立出的索引稱爲多key索引,多key索引會爲數組的每一個元素創建一條索引app

// 用戶的社交登陸信息,
schema = {
    …
    snsPlatforms:[{
        platform:String, // 登陸平臺
        openId:String, // 登陸惟一標識符
    }]
}
// 這也是一個列轉行文檔設計,後面會說
db.user.createIndex({snsPlatforms.openId:1})

TTL 索引

能夠針對某個時間字段,指定文檔的過時時間(用於僅在一段時間有效的數據存儲,文檔達到指定時間就會被刪除,這樣就能夠完成自動刪除數據)
這個刪除操做是安全的,數據會選擇在應用的低峯期執行,因此不會由於刪除大量文件形成高額IO嚴重影響數據性能。函數

部分索引

3.2版本才支持該特性,給符合條件的數據文檔創建索引,意在節約索引存儲空間與寫入成本性能

db.user.createIndex({sns.qq.openId:1})
/**
 * 給qq登陸openid加索引,系統其實只有不多一部分用到qq登陸 ,而後纔會存在這個數據字段,這個時
 * 候就沒有必要給全部文檔加上這個索引,僅須要知足條件才加索引
 */
db.user.createIndex({sns.qq.openId:1} ,{partialFilterExpression:{$exists:1}})

稀疏索引

稀疏索引僅包含具備索引字段的文檔條目,即便索引字段包含空值也是如此。
索引會跳過缺乏索引字段的全部文檔。

db.user.createIndex({sns.qq.openId:1} ,{sparse:true})

注:3.2版本開始,提供了部分索引,能夠當作稀疏索引的超集,官方推薦優先使用部分索引而不是稀疏索引。

ESR索引規則

索引字段順序: equal(精準匹配) > sort (排序條件)> range (範圍查詢)

精確(Equal)匹配的字段放最前面,排序(Sort)條件放中間,範圍(Range)匹配的字段放最後面,也適用於ES,ER。

實際例子:獲取成績表中,高2班中數學分數大於120的學生,按照分數從大到小排序
不難看出,班級和學科(數學)能夠是精準匹配,分數是一個範圍查詢,同時也是排序條件
那麼按照ESR規則咱們能夠這樣創建索引
{"班級":1,"學科":1,"分數":1}

咱們怎麼分析這個索引的命中與有效狀況呢?

db.collection.explain()函數能夠輸出文檔查找執行計劃,能夠幫助咱們作更正確的選擇。
分析函數返回的數據不少,但咱們主要能夠關注這個字段

executionStats 執行統計

{
    "queryPlanner": {
        "plannerVersion": 1,
        "namespace": "test.user",
        "indexFilterSet": false,
        "parsedQuery": {
            "age": {
                "$eq": 13
            }
        },
        "winningPlan": { ... },
        "rejectedPlans": []
    },
    "executionStats": {
        "executionSuccess": true,
        "nReturned": 100,
        "executionTimeMillis": 137,
        "totalKeysExamined": 48918,
        "totalDocsExamined": 48918,
        "allPlansExecution": []
    },
    "ok": 1,
}

nReturned 實際返回數據行數

executionTimeMillis 命令執行總時間,單位毫秒

totalKeysExamined 表示MongoDB 掃描了N個索引數據。 檢查的鍵數與返回的文檔數相匹配,這意味着mongod只需檢查索引鍵便可返回結果。mongod沒必要掃描全部文檔,只有N個匹配的文檔被拉入內存。 這個查詢結果是很是高效的。

totalDocsExamined 文檔掃描數

這幾個字段的值越小說明效率越好,最佳狀態是
nReturned = totalKeysExamined = totalDocsExamined
若是相差很大,說明還有很大優化空間,當具體業務還要酌情分析。
查詢優化器針對該query所返回的最優執行計劃的詳細內容(queryPlanne.winningPlan)

stage

COLLSCAN:全表掃描,這個狀況是最糟糕的
IXSCAN:索引掃描
FETCH:根據索引去檢索指定document
SHARD_MERGE:將各個分片返回數據進行merge
SORT:代表在內存中進行了排序
LIMIT:使用limit限制返回數
SKIP:使用skip進行跳過
IDHACK:針對_id進行查詢
SHARDING_FILTER:經過mongos對分片數據進行查詢
COUNT:利用db.coll.explain().count()之類進行count運算
COUNTSCAN: count不使用Index進行count時的stage返回
COUNT_SCAN: count使用了Index進行count時的stage返回
SUBPLA:未使用到索引的$or查詢的stage返回
TEXT:使用全文索引進行查詢時候的stage返回
PROJECTION:限定返回字段時候stage的返回

咱們不但願看到的(出現如下狀況,就要注意了,問題可能就出現了)

COLLSCAN(全表掃描)
SORT可是沒有相關的索引
超大的SKIP
SUBPLA在使用$or的時候沒有命中索引
COUNTSCAN 執行count沒有命中索引

而後是咱們看看一條普通查詢實際執行順序

db.user.find({age:13}).skip(100).limit(100).sort({createdAt:-1})

image.png

圖中能夠看出,首先是IXSCAN索引掃描,最後是SKIP跳過數據進行過濾。

在executionStats每個項都有nReturned 與 executionTimeMillisEstimate,這樣咱們能夠由內向外查看整個查詢執行狀況,在哪一步出現執行慢的問題。

關於列轉行文檔設計模式

首先數據庫索引並非越多越好,在MongoDB單文檔索引上限,集合中索引不能超過64個,一些知名大廠推薦不超過10個。

而在一個主表中,因爲冗餘文檔設計,就會存在很是多信息須要增長索引,咱們仍是以社交登陸爲例子

常規設計

schema = {
…
        qq:{
            openId:String
        },
        wxapp:{
            openId:String,
        },
        weibo:{
            openId:String,
        }
…
}

// 每次增長新的登陸類型,須要修改文檔schema和增長索引
db.user.createIndex({qq.openId:1}) 
db.user.createIndex({wxapp.openId:1}) 
db.user.createIndex({weibo.openId:1})

列轉行設計

schema = {
…
 snsPlatforms:[{
    platform:String, // 登陸平臺
    openId:String, // 登陸惟一標識符
 }]
}
// 此時不管是新增登陸平臺仍是刪除,都不須要變動索引設計,一個索引解決全部同類型問題
db.user.createIndex({snsPlatforms.openId:1,snsPlatforms.platform:1})

提問:爲何openId要放在plaform前面呢?

這個小故事講述了小李在遇到自身知識不能解決的問題,而後事情的處理思路與過程。每一個人都有本身能力所不及的地方,那麼這種狀況要優先解決問題,或者下降事故的影響範圍。

相關文章
相關標籤/搜索