讀古今文學網 > MongoDB實戰 > 7.3 查詢優化 >

7.3 查詢優化

查詢優化是識別慢查詢、找出它們為什麼慢、逐步讓它們變快的過程。本節裡,我們會依次看到查詢優化過程中的每一步,當你讀完本節之後,基本就能找出MongoDB裡所有的問題查詢了。

在深入之前,我必須提醒一下,本節出現的技術不能解決所有查詢的性能問題。慢查詢的原因千奇百怪,糟糕的應用程序設計、不恰當的數據模型、硬件配置不夠都是常見的原因,處理這些問題要耗費大量時間。此處我們會看到通過重新組織查詢以及構建有效的索引來進行優化的方法。我還將介紹其他途徑,以便你在上述手段不奏效時嘗試一下。

7.3.1 識別慢查詢

如果感覺基於MongoDB的應用程序變慢了,那麼就該著手剖析查詢語句了。任何嚴謹的應用程序設計方法中都應該包含對查詢語句的審核;考慮到MongoDB中這一切都是如此簡單,沒有理由不這麼做。雖然每個應用程序對查詢語句的要求各有不同,但可以保守地進行假設:對於大多數應用而言,查詢都不該超過100 ms。這個假設被固化在了MongoDB的日誌裡,無論什麼操作(包括查詢在內),只要超過100 ms就會輸出一條警告。因此,要識別慢查詢,第一時間就該看日誌。

到目前為止,我們的數據集都很小,無法生成執行時間超過100 ms的查詢。所以隨後的例子裡,我們將使用一組由NASDAQ日匯總數據組成的數據集。如果你也希望能執行這些查詢,需要將它們放到本地數據庫裡。要導入它,首先從http://mng.bz/ii49下載壓縮包,然後將其解壓到一個臨時文件夾裡。你將看到如下輸出:

$ unzip stocks.zip
Archive: stocks.zip
   creating: dump/stocks/
  inflating: dump/stocks/system.indexes.bson
  inflating: dump/stocks/values.bson
  

最後,用以下命令將數據還原到數據庫裡:

$ mongorestore -d stocks -c values dump/stocks
  

股票數據集很大,而且方便使用。針對某個NASDAQ上市股票的子集,有從1983年開始25年的數據,每天一個文檔,記錄每日的最高價、最低價、收盤價和成交量。有了如此數量的集合文檔,很容易就能生成一條日誌警告。試著查詢第一條谷歌股價:

db.values.find({"stock_symbol": "GOOG"}).sort({date: -1}).limit(1)
  

你會注意到這條查詢執行了一段時間。如果查看MongoDB的日誌,會看到預期中的慢查詢警告。下面就是一段示例輸出:

Thu Nov 16 09:40:26 [conn1] query stocks.values
          ntoreturn:1 scanAndOrder reslen:210 nscanned:4308303
          { query: { stock_symbol: "GOOG" }, orderby: { date: -1.0 } }
          nreturned:1 4011ms
  

其中包含大量信息,在討論explain時,我們會研究其中所有內容的含義。現在,如果仔細閱讀這段消息,應該能抽取出最重要的部分:這是針對stocks.values的查詢;執行的查詢選擇器包含匹配stock_symbol以及排序;最關鍵的可能是這個查詢花了4 s(4011 ms)之久。

一定要設法處理這樣的警告。它們太關鍵了,值得你時不時地篩查MongoDB的日誌。可以通過grep輕鬆實現篩查:

grep -E '([0-9])+ms' mongod.log
  

如果100 ms的閾值太高了,可以通過--slowms服務器選項降低這個值。要是把慢查詢定義為執行時間超過50 ms,那麼用--slowms 50來啟動mongod

當然,篩查日誌還不夠徹底。你可以通過日誌檢查慢查詢,但這個過程太粗糙了,應該將其作為預發佈或生產環境中的一種「健康檢查」。要在那些慢查詢成為問題之前識別它們,你需要一個更精確的工具,MongoDB內置的查詢剖析器正是你所需要的。

使用剖析器

要識別慢查詢,離不開MongoDB內置的剖析器。剖析功能默認是關閉的,讓我們先把它打開。在MongoDB Shell中,輸入以下命令:

use stocks
db.setProfilingLevel(2)
  

先選擇要剖析的數據庫,因為剖析總是針對某個特定數據庫的。隨後將剖析級別設置為2,這是最詳細的級別;它告訴剖析器將每次的讀和寫都記錄到日誌裡。還有一些其他選項。若只要記錄慢(100 ms)操作,可以將剖析級別設置為1。要徹底禁用剖析器,將級別設置為0。如果只想在日誌裡記錄耗時超過一定毫秒閾值的操作,可以像下面這樣將毫秒數作為第二個參數:

use stocks
db.setProfilingLevel(1, 50)
  

一旦開啟了剖析器,就可以執行查詢了。讓我們再運行一條對股票數據庫的查詢,找出數據集中最高的收盤價:

db.values.find({}).sort({close: -1}).limit(1)
  

剖析結果會保存在一個特殊的名為system.profile的固定集合裡。你是否還記得,固定集合的大小是確定的,數據會像環一樣寫入其中,一旦集合達到最大尺寸,新文檔會覆蓋最早的文檔。system.profile被分配了128 KB,因此確保剖析數據不會消耗太多資源。

你可以像查詢任何固定集合那樣查詢system.profile。舉例來說,查詢所有耗時超過 150 ms的語句:

db.system.profile.find({millis: {$gt: 150}})
  

因為固定集合保持了自然插入順序,可以用$natural操作符進行排序,以便先顯示最近的結果:

db.system.profile.find.sort({$natural: -1}).limit(5)
  

回到剛才的查詢語句,結果集裡應該會有大致這樣一條內容:

{ "ts" : ISODate("2011-09-22T22:42:38.332Z"),
"op" : "query", "ns" : "stocks.values",
"query" : { "query ":{}, "orderby " : { "close" : -1 } },
"ntoreturn" : 1, "nscanned" : 4308303, "scanAndOrder" : true,
"nreturned" : 1, "responseLength" : 194, "millis" : 14576,
"client" : "127.0.0.1", "user" : "" }
  

又是一條慢查詢:耗時將近15 s!除了執行時間,其中還包含所有在MongoDB慢查詢警告中出現查詢的信息,足夠進行更深一步的排查了,而下一節裡就會講到這個話題。

但在繼續之前,還得再說一些與剖析策略有關的內容。先使用較粗的設置,然後不斷細化,用這種方式來使用剖析器就挺不錯的。首先保證沒有查詢超過100 ms,然後將閾值降低到75 ms,以此類推。開啟剖析器之後,你會想把應用程序測一遍,最起碼把每個讀寫操作都執行一遍。如果考慮的周到一些,就必須在真實條件下執行那些操作,數據大小、查詢負載和硬件都應該能代表應用程序的生產環境。

查詢剖析器十分有用,但要將它發揮到極致,你還得有條理。比起生產環境,最好能在開發過程中找到慢查詢,不然補救的成本會大很多。

7.3.2 分析慢查詢

有了MongoDB的剖析器,可以很方便地找到慢查詢。要知道這些查詢為什麼慢會更麻煩一點,因為這個過程中可能還要求有點「偵察工作」。正如前文所述,慢查詢的原因是多種多樣的。走運的話,加個索引就能解決慢查詢。在更複雜的情況裡,可能不得不重新安排索引、重建數據模型,或者升級硬件。但是,總是應該先看看最簡單的情況,本節就與此相關。

最簡單的情況裡,問題的根本原因是缺少索引、索引不當或者查詢不理想。可以在慢查詢上運行explain來確認原因。現在,讓我們來瞭解下具體的做法。

1. 使用並瞭解EXPLAIN

MongoDB的explain命令提供了關於指定查詢路徑的詳細信息。1 讓我們仔細地看一看,對上一節裡運行的最後一條查詢執行explain能收集到什麼信息。要在Shell中運行explain,只需在查詢後附上explain方法調用:

1. 可以回憶一下我在第2章中介紹的explain,當時只是簡單地介紹。本節我將提供完整的命令說明及其輸出。

db.values.find({}).sort({close: -1}).limit(1).explain
{
  "cursor" : "BasicCursor",
  "nscanned" : 4308303,
  "nscannedObjects" : 4308303,
  "n" : 1,
  "scanAndOrder" : true,
  "millis" : 14576,
  "nYields" : 0,
  "nChunkSkips" : 0,
  "indexBounds":{}
}
  

millis字段指出該查詢耗時超過14 s,其原因很明顯。請看nscanned的值,它表明查詢引擎必須掃瞄4 308 303個文檔才能完成查詢。現在,在values集合上運行count

db.values.count
4308303
  

掃瞄的文檔數與集合中的文檔總數一致,也就是說執行了一次全集合掃瞄。如果你希望查詢返回集合裡的全部文檔,這倒不是一件壞事。但是如果僅需返回一個文檔,正如explain中的n所示,那這就成問題了。一般來說,希望n的值與nscanned的值盡可能接近。在進行集合掃瞄時,情況往往不是這樣的。cursor字段指明你在使用BasicCursor,這只能說明在掃瞄集合本身而非索引。

scanAndOrder字段進一步解釋了查詢緩慢的原因,當查詢優化器無法使用索引來返回排序結果集時,它就會出現。因此,本例中不僅查詢引擎需要掃瞄集合,還要求手動對結果集進行排序。

如此之差的性能是無法接受的,好在應對之道比較簡單。你只需要在close字段上構建一個索引。現在就動手,然後重新發起查詢:2

2. 注意,索引的構建可能需要幾分鐘。

db.values.ensureIndex({close: 1})
db.values.find({}).sort({close: -1}).limit(1).explain
{
  "cursor" : "BtreeCursor close_1 reverse",
  "nscanned" : 1,
  "nscannedObjects" : 1,
  "n" : 1,
  "millis" : 0,
  "nYields" : 0,
  "nChunkSkips" : 0,
  "indexBounds" : {
    "close" : [
      [
        {
          "$maxElement" : 1
        },
        {
          "$minElement" : 1
        }
      ]
    ]
  }
}
  

差距太大了!這次的查詢處理只用了不到1 ms。通過cursor字段可以看到,正在使用名為close_1的索引上的BtreeCursor,而且在倒序迭代索引。在indexBounds字段裡,可以看到特殊值$maxElement$minElement,它們說明查詢橫跨了整個索引。此時查詢優化器經過B樹的最右邊才找到最大鍵,然後再沿路返回。因為限制了返回集為1,在找到了最大元素後查詢就完成了。當然,由於索引是有序保存索引項的,就沒有必要再進行scanAndOrder所指定的手工排序了。

如果在查詢選擇器中使用了經過索引的鍵,就會看到輸出中有些許不同之處。來看看查詢收盤價大於500的查詢語句的explain輸出:

> db.values.find({close: {$gt: 500}}).explain
{
  "cursor" : "BtreeCursor close_1",
  "nscanned" : 309,
  "nscannedObjects" : 309,
  "n" : 309,
  "millis" : 5,
  "nYields" : 0,
  "nChunkSkips" : 0,
  "indexBounds" : {
    "close" : [
      [
        500,
        1.7976931348623157e+308
      ]
    ]
  }
}
  

掃瞄的文檔數仍然與返回的文檔數相同(nnscanned是一致的),這是理想狀態。請注意,在索引邊界的指定方式上,此處與前者有所不同。這裡沒有使用$maxElement$minElement鍵,邊界是實際值。下限是500,上限實際是無限大。這些值必須和正在查詢的值使用相同的數據類型;在查詢的是數字,所以這裡的索引邊界是數字。如果要查詢一系列字符串,那麼邊界就是字符串。3

3. 如果覺得這無法理解,請回憶一下,某個指定索引能包含多種數據類型的鍵。因此,查詢結果總是會被限制在查詢所使用的數據類型中。

在繼續之前,請自己在查詢上運行explain,要注意nnscanned之間的不同。

2. MongoDB的查詢優化器與hint

查詢優化器是MongoDB中的一部分,如果存在可用的索引,它會為給定查詢選擇一個最高效的索引。在為查詢選擇理想的索引時,查詢優化器使用了一套相當簡單的規則:

  1. 避免scanAndOrder。如果查詢中包含排序,嘗試使用索引進行排序;

  2. 通過有效的索引約束來滿足所有字段——嘗試對查詢選擇器裡的字段使用索引;

  3. 如果查詢包含範圍查找或者排序,那麼對於選擇的索引,其中最後用到的鍵需能滿足該範圍查找或排序。

如果某個索引能滿足以上所有這些條件,那麼它就會被視為最佳索引並予以使用。要是有多個最佳索引,則任意選擇其一。可以遵循這條經驗:如果能為查詢構建最優索引,查詢優化器的工作能更輕鬆些。為此,請盡力而為。

讓我們來看一個查詢,它完全滿足索引(和查詢優化器)。回顧股票數據集,假設要執行如下查詢,獲取所有大於200的谷歌收盤價:

db.values.find({stock_symbol: "GOOG", close: {$gt: 200}})
  

該查詢的最優索引同時包含這兩個鍵,但其中把close鍵放在最後以便執行範圍查詢:

db.values.ensureIndex({stock_symbol: 1, close: 1})
  

如果執行查詢,會看到這兩個鍵都被用到了,索引邊界也和預想的一樣:

db.values.find({stock_symbol: "GOOG", close: {$gt: 200}}).explain
{
  "cursor" : "BtreeCursor stock_symbol_1_close_1",
  "nscanned" : 730,
  "nscannedObjects" : 730,
  "n" : 730,
  "millis" : 1,
  "nYields" : 0,
  "nChunkSkips" : 0,
  "isMultiKey" : false,
  "indexOnly" : false,
  "indexBounds" : {
    "stock_symbol" : [
      [
        "GOOG",
        "GOOG"
      ]
    ],
    "close" : [
      [
        200,
        1.7976931348623157e+308
      ]
    ]
  }
}
>
  

這是本條查詢的最優explain輸出:nnscanned的值相同。現在再來考慮一下沒有索引能完美運用於查詢之上的情況。例如,沒有{stock_symbol: 1,close: 1}索引,但是在那兩個字段上分別建有索引。通過getIndexKeys列出索引,會看到:

db.values.getIndexKeys
[ { "_id ":1},{"close ":1},{"stock_symbol ":1}]
  

因為查詢中同時包含stock_symbolclose兩個鍵,沒有很明顯的索引可用。這時就該查詢優化器出馬了,它所用的試探方式比想像的要簡單得多,完全基於nscanned的值。換言之,優化器會選擇掃瞄索引項最少的索引。查詢首次運行時,優化器會為每個可能有效適用於該查詢的索引創建查詢計劃,隨後並行運行各個計劃4,nscanned值最低的計劃勝出。優化器會停止那些長時間運行的計劃,將勝出的計劃保存在來,以便後續使用。

4. 嚴格地說,這些計劃是交錯在一起的。

你可以發起查詢並運行explain來查看實際的過程。首先,刪除復合索引{stock_symbol: 1,close: 1},在這些鍵上構建單獨的索引:

db.values.dropIndex("stock_symbol_1_close_1")
db.values.ensureIndex({stock_symbol: 1})
db.values.ensureIndex({close: 1})
  

true作為參數傳遞給explain方法,這能將查詢優化器嘗試的計劃列表包含在輸出裡。輸出見代碼清單7-1。

代碼清單7-1 用explain(true)查看查詢計劃

db.values.find({stock_symbol: "GOOG", close: {$gt: 200}}).explain(true)
{
   "cursor" : "BtreeCursor stock_symbol_1",
   "nscanned" : 894,
   "nscannedObjects" : 894,
   "n" : 730,
   "millis" : 8,
   "nYields" : 0,
   "nChunkSkips" : 0,
   "isMultiKey" : false,
   "indexOnly" : false,
   "indexBounds" : {
     "stock_symbol" : [
       [
         "GOOG",
         "GOOG"
       ]
     ]
   },
   "allPlans" : [
     {
       "cursor" : "BtreeCursor close_1",
       "indexBounds" : {
         "close" : [
           [
             100,
             1.7976931348623157e+308
           ]
         ]
       }
     },
   {
     "cursor" : "BtreeCursor stock_symbol_1",
     "indexBounds" : {
       "stock_symbol" : [
         [
           "GOOG",
           "GOOG"
         ]
       ]
     }
   },
   {
     "cursor" : "BasicCursor",
     "indexBounds" : {
     }
   }
 ]
}
  

你馬上能發現查詢計劃選擇了{stock_symbol: 1}索引來實現查詢。輸出的下方,allPlans鍵指向一個列表,其中還包含了兩個額外的查詢計劃:一個使用{close: 1}索引,另一個用BasicCursor掃瞄集合。

優化器拒絕集合掃瞄的原因顯而易見,但不選擇{close :1}索引的原因卻不明顯。可以通過hint找到答案,hint能強迫查詢優化器使用某個特定索引:

query = {stock_symbol: "GOOG", close: {$gt: 100}}
db.values.find(query).hint({close: 1}).explain
{
  "cursor" : "BtreeCursor close_1",
  "nscanned" : 5299,
  "n" : 730,
  "millis" : 36,
  "indexBounds" : {
    "close" : [
      [
        200,
        1.7976931348623157e+308
      ]
    ]
  }
}
  

nscanned的值是5299,這比之前掃瞄的894項要多得多,完成查詢的時間也證實了這一點。

剩下的就是要理解查詢優化器是如何緩存它所選擇的查詢計劃,並讓其過期的。畢竟,你不會希望優化器對每條查詢都並行運行所有計劃。

在發現了一個成功的計劃之後,會記錄下查詢模式(query pattern)、nscanned的值以及索引說明。針對剛才的查詢,所記錄的結構是這樣的:

{ pattern:{stock_symbol:'equality',close: 'bound'}, 
  index:{stock_symbol:1}, 
  nscanned:894}
  

查詢模式記錄下了每個鍵的匹配類型,你正請求對stock_symbol的精確匹配(相等),對close的範圍匹配(邊界)5。只要新的查詢匹配此模式,就會使用該索引。

5. 也許你會對此感興趣,共有三種範圍匹配類型:上界(upper)、下界(lower)以及上下界(upper-and-lower)。查詢模式還包含各種排序。

但這一信息不應該是永久的,實際情況也是如此。在發生以下事件之後優化器會自動讓計劃過期。

  • 對集合執行了100次寫操作。

  • 在集合上增加或刪除了索引。

  • 雖然使用了緩存的查詢計劃,但工作量大於預期。此處,「工作量大」的標準是nscanned超過緩存的nscanned值的10倍。

發生最後一種事件時,優化器會立即開始交錯執行其他查詢計劃,也許另一個索引會更高效。

7.3.3 查詢模式

此處列舉了幾種常見的查詢模式,以及它們所使用的索引。

1. 單鍵索引

要討論單鍵索引,請回憶一下為股票集合的收盤價創建的索引{close: 1},該索引能用於以下場景。

  • 精確匹配

舉例來說,要精確匹配所有收盤價是100的條目:

db.values.find({close: 100})
  
  • 排序

可以對被索引字段排序。例如:

db.values.find({}).sort({close: 1})
 

本例中的排序沒有查詢選擇器,除非真的打算迭代整個集合,否則你可能會希望再增加一個限制。

  • 範圍查詢

針對某個字段進行範圍查詢,在同一字段上帶不帶排序都可以。例如,查詢所有大於或等於100的收盤價:

db.values.find({close: {$gte: 100}})
  

如果對同一個鍵增加排序子句,優化器仍能使用相同的索引:

db.values.find({close: {$gte: 100}}).sort({close: 1})
  

2. 復合鍵索引

復合鍵索引稍微複雜一點,但它們的用法與單鍵索引類似。有一點要牢記,針對每個查詢,復合鍵索引只能高效適用於單個範圍或排序。仍然是股價的例子,想像一個三復合鍵索引{close: 1,open: 1,date: 1},可能會有以下幾種場景。

  • 精確匹配

精確匹配第一個鍵、第一和第二個鍵,或者第一、第二和第三個鍵,按照這個順序:

db.values.find({close: 1})
db.values.find({close: 1, open: 1})
db.values.find({close: 1, open: 1, date: "1985-01-08"})
  
  • 範圍匹配

精確匹配任意一組最左鍵(包含空),隨後對其右邊緊鄰的鍵進行範圍查詢或者排序。於是,以下所有的查詢對於該三鍵索引而言都是十分理想的:

db.values.find({}).sort({close: 1})
db.values.find({close: {$gt: 1}})

db.values.find({close: 100}).sort({open: 1})
db.values.find({close: 100, open: {$gt: 1}})

db.values.find({close: 1, open: 1.01, date: {$gt: "2005-01-01"}})
db.values.find({close: 1, open: 1.01}).sort({date: 1})
  

3. 覆蓋索引

如果你從未聽說過覆蓋索引(covering index,也稱索引覆蓋),那麼從一開始就要意識到這個術語並不恰當。覆蓋索引不是一種索引,而是對索引的一種特殊用法。如果查詢所需的所有數據都在索引自身之中,那就可以說索引能覆蓋該查詢。覆蓋索引查詢也稱僅使用索引的查詢(index-only query),因為不用引用被索引文檔本身就能實現這些查詢,這能帶來性能的提升。

MongoDB中能很方便地使用覆蓋索引,簡單地選擇存在於單個索引裡的字段集合,排除掉_id字段(因為這個字段幾乎不會出現在正使用的索引中)。下面這個例子裡用到了上一節創建的三復合鍵索引:

db.values.find({open: 1}, {open: 1, close: 1, date: 1, _id: 0})
  

如果對它執行explain,你會看到其中標識為indexOnly的字段被設為了true。這說明查詢結果是由索引而非實際集合數據提供的。

查詢優化總是針對特定應用程序的,但是我希望本節的理念和技術能幫助你更好地調整查詢。通過觀察和實驗進行調整總是行之有效的方法。要養成習慣剖析並解釋你的查詢,在此過程中,你會瞭解查詢優化器鮮為人知的一面,並能保證應用程序的查詢性能。