1. 程式人生 > >ES倒排索引與三種Cache詳細介紹

ES倒排索引與三種Cache詳細介紹

網上看到的一篇文章,對Lucene的倒排索引是如何執行的,說的比較易懂,就轉過來分享下。

Elasticsearch是通過Lucene的倒排索引技術實現比關係型資料庫更快的過濾。特別是它對多條件的過濾支援非常好,比如年齡在18和30之間,性別為女性這樣的組合查詢。倒排索引很多地方都有介紹,但是其比關係型資料庫的b-tree索引快在哪裡?到底為什麼快呢?

籠統的來說,b-tree索引是為寫入優化的索引結構。當我們不需要支援快速的更新的時候,可以用預先排序等方式換取更小的儲存空間,更快的檢索速度等好處,其代價就是更新慢。要進一步深入的化,還是要看一下Lucene的倒排索引是怎麼構成的。

這裡有好幾個概念。我們來看一個實際的例子,假設有如下的資料:

 

這裡每一行是一個document。每個document都有一個docid。那麼給這些document建立的倒排索引就是:

可以看到,倒排索引是per field的,一個欄位由一個自己的倒排索引。18,20這些叫做 term,而[1,3]就是posting list。Posting list就是一個int的陣列,儲存了所有符合某個term的文件id。那麼什麼是term dictionary 和 term index?

假設我們有很多個term,比如:

Carla,Sara,Elin,Ada,Patty,Kate,Selena

如果按照這樣的順序排列,找出某個特定的term一定很慢,因為term沒有排序,需要全部過濾一遍才能找出特定的term。排序之後就變成了:

Ada,Carla,Elin,Kate,Patty,Sara,Selena

這樣我們可以用二分查詢的方式,比全遍歷更快地找出目標的term。這個就是 term dictionary。有了term dictionary之後,可以用 logN 次磁碟查詢得到目標。但是磁碟的隨機讀操作仍然是非常昂貴的(一次random access大概需要10ms的時間)。所以儘量少的讀磁碟,有必要把一些資料快取到記憶體裡。但是整個term dictionary本身又太大了,無法完整地放到記憶體裡。於是就有了term index。term index有點像一本字典的大的章節表。比如:

A開頭的term ……………. Xxx頁

C開頭的term ……………. Xxx頁

E開頭的term ……………. Xxx頁

如果所有的term都是英文字元的話,可能這個term index就真的是26個英文字元表構成的了。但是實際的情況是,term未必都是英文字元,term可以是任意的byte陣列。而且26個英文字元也未必是每一個字元都有均等的term,比如x字元開頭的term可能一個都沒有,而s開頭的term又特別多。實際的term index是一棵trie 樹:

 

例子是一個包含 "A", "to", "tea", "ted", "ten", "i", "in", 和 "inn" 的 trie 樹。這棵樹不會包含所有的term,它包含的是term的一些字首。通過term index可以快速地定位到term dictionary的某個offset,然後從這個位置再往後順序查詢。再加上一些壓縮技術(搜尋 Lucene Finite State Transducers) term index 的尺寸可以只有所有term的尺寸的幾十分之一,使得用記憶體快取整個term index變成可能。整體上來說就是這樣的效果。

 

現在我們可以回答“為什麼Elasticsearch/Lucene檢索可以比mysql快了。Mysql只有term dictionary這一層,是以b-tree排序的方式儲存在磁碟上的。檢索一個term需要若干次的random access的磁碟操作。而Lucene在term dictionary的基礎上添加了term index來加速檢索,term index以樹的形式快取在記憶體中。從term index查到對應的term dictionary的block位置之後,再去磁碟上找term,大大減少了磁碟的random access次數。

額外值得一提的兩點是:term index在記憶體中是以FST(finite state transducers)的形式儲存的,其特點是非常節省記憶體。Term dictionary在磁碟上是以分block的方式儲存的,一個block內部利用公共字首壓縮,比如都是Ab開頭的單詞就可以把Ab省去。這樣term dictionary可以比b-tree更節約磁碟空間。

如何聯合索引查詢?

所以給定查詢過濾條件 age=18 的過程就是先從term index找到18在term dictionary的大概位置,然後再從term dictionary裡精確地找到18這個term,然後得到一個posting list或者一個指向posting list位置的指標。然後再查詢 gender=女 的過程也是類似的。最後得出 age=18 AND gender=女 就是把兩個 posting list 做一個“與”的合併。

這個理論上的“與”合併的操作可不容易。對於mysql來說,如果你給age和gender兩個欄位都建立了索引,查詢的時候只會選擇其中最selective的來用,然後另外一個條件是在遍歷行的過程中在記憶體中計算之後過濾掉。那麼要如何才能聯合使用兩個索引呢?有兩種辦法:

  • 使用skip list資料結構。同時遍歷gender和age的posting list,互相skip;
  • 使用bitset資料結構,對gender和age兩個filter分別求出bitset,對兩個bitset做AN操作。

PostgreSQL 從 8.4 版本開始支援通過bitmap聯合使用兩個索引,就是利用了bitset資料結構來做到的。當然一些商業的關係型資料庫也支援類似的聯合索引的功能。Elasticsearch支援以上兩種的聯合索引方式,如果查詢的filter快取到了記憶體中(以bitset的形式),那麼合併就是兩個bitset的AND。如果查詢的filter沒有快取,那麼就用skip list的方式去遍歷兩個on disk的posting list。

利用 Skip List 合併

 

以上是三個posting list。我們現在需要把它們用AND的關係合併,得出posting list的交集。首先選擇最短的posting list,然後從小到大遍歷。遍歷的過程可以跳過一些元素,比如我們遍歷到綠色的13的時候,就可以跳過藍色的3了,因為3比13要小。

整個過程如下

Next -> 2
Advance(2) -> 13
Advance(13) -> 13
Already on 13
Advance(13) -> 13 MATCH!!!
Next -> 17
Advance(17) -> 22
Advance(22) -> 98
Advance(98) -> 98
Advance(98) -> 98 MATCH!!!

最後得出的交集是[13,98],所需的時間比完整遍歷三個posting list要快得多。但是前提是每個list需要指出Advance這個操作,快速移動指向的位置。什麼樣的list可以這樣Advance往前做蛙跳?skip list:

 

從概念上來說,對於一個很長的posting list,比如:

[1,3,13,101,105,108,255,256,257]

我們可以把這個list分成三個block:

[1,3,13] [101,105,108] [255,256,257]

然後可以構建出skip list的第二層:

[1,101,255]

1,101,255分別指向自己對應的block。這樣就可以很快地跨block的移動指向位置了。

Lucene自然會對這個block再次進行壓縮。其壓縮方式叫做Frame Of Reference編碼。示例如下:

 

考慮到頻繁出現的term(所謂low cardinality的值),比如gender裡的男或者女。如果有1百萬個文件,那麼性別為男的posting list裡就會有50萬個int值。用Frame of Reference編碼進行壓縮可以極大減少磁碟佔用。這個優化對於減少索引尺寸有非常重要的意義。當然mysql b-tree裡也有一個類似的posting list的東西,是未經過這樣壓縮的。

因為這個Frame of Reference的編碼是有解壓縮成本的。利用skip list,除了跳過了遍歷的成本,也跳過了解壓縮這些壓縮過的block的過程,從而節省了cpu。

利用bitset合併

Bitset是一種很直觀的資料結構,對應posting list如:

[1,3,4,7,10]

對應的bitset就是:

[1,0,1,1,0,0,1,0,0,1]

每個文件按照文件id排序對應其中的一個bit。Bitset自身就有壓縮的特點,其用一個byte就可以代表8個文件。所以100萬個文件只需要12.5萬個byte。但是考慮到文件可能有數十億之多,在記憶體裡儲存bitset仍然是很奢侈的事情。而且對於個每一個filter都要消耗一個bitset,比如age=18快取起來的話是一個bitset,18<=age<25是另外一個filter快取起來也要一個bitset。

所以祕訣就在於需要有一個數據結構:

  • 可以很壓縮地儲存上億個bit代表對應的文件是否匹配filter;
  • 這個壓縮的bitset仍然可以很快地進行AND和 OR的邏輯操作。

Lucene使用的這個資料結構叫做 Roaring Bitmap。

 

其壓縮的思路其實很簡單。與其儲存100個0,佔用100個bit。還不如儲存0一次,然後宣告這個0重複了100遍。

這兩種合併使用索引的方式都有其用途。Elasticsearch對其效能有詳細的對比(https://www.elastic.co/blog/frame-of-reference-and-roaring-bitmaps)。簡單的結論是:因為Frame of Reference編碼是如此 高效,對於簡單的相等條件的過濾快取成純記憶體的bitset還不如需要訪問磁碟的skip list的方式要快。

如何減少文件數?

一種常見的壓縮儲存時間序列的方式是把多個數據點合併成一行。Opentsdb支援海量資料的一個絕招就是定期把很多行資料合併成一行,這個過程叫compaction。類似的vivdcortext使用mysql儲存的時候,也把一分鐘的很多資料點合併儲存到mysql的一行裡以減少行數。

這個過程可以示例如下:

可以看到,行變成了列了。每一列可以代表這一分鐘內一秒的資料。

Elasticsearch有一個功能可以實現類似的優化效果,那就是Nested Document。我們可以把一段時間的很多個數據點打包儲存到一個父文件裡,變成其巢狀的子文件。示例如下:

{timestamp:12:05:01, idc:sz, value1:10,value2:11}
{timestamp:12:05:02, idc:sz, value1:9,value2:9}
{timestamp:12:05:02, idc:sz, value1:18,value:17}

可以打包成:

{
max_timestamp:12:05:02, min_timestamp: 1205:01, idc:sz,
records: [
		{timestamp:12:05:01, value1:10,value2:11}
{timestamp:12:05:02, value1:9,value2:9}
{timestamp:12:05:02, value1:18,value:17}
]
}

這樣可以把資料點公共的維度欄位上移到父文件裡,而不用在每個子文件裡重複儲存,從而減少索引的尺寸。

在儲存的時候,無論父文件還是子文件,對於Lucene來說都是文件,都會有文件Id。但是對於巢狀文件來說,可以儲存起子文件和父文件的文件id是連續的,而且父文件總是最後一個。有這樣一個排序性作為保障,那麼有一個所有父文件的posting list就可以跟蹤所有的父子關係。也可以很容易地在父子文件id之間做轉換。把父子關係也理解為一個filter,那麼查詢時檢索的時候不過是又AND了另外一個filter而已。前面我們已經看到了Elasticsearch可以非常高效地處理多filter的情況,充分利用底層的索引。

使用了巢狀文件之後,對於term的posting list只需要儲存父文件的doc id就可以了,可以比儲存所有的資料點的doc id要少很多。如果我們可以在一個父文件裡塞入50個巢狀文件,那麼posting list可以變成之前的1/50。

前面提及了欄位過濾快取,那麼與之相反的清楚快取策略
單一索引快取,多索引快取和全部快取的清理

1.清空全部快取
curl localhost:9200/_cache/clear?pretty
{
  "_shards" : {
    "total" : 72,
    "successful" : 72,
    "failed" : 0
  }
}

2.清除單一索引快取
curl localhost:9200/index/_cache/clear?pretty
{
  "_shards" : {
    "total" : 2,
    "successful" : 2,
    "failed" : 0
  }
}

3.清除多索引快取
curl localhost:9200/index1,index2,index3/_cache/clear?pretty
{
  "_shards" : {
    "total" : 12,
    "successful" : 12,
    "failed" : 0
  }
}
當然了清楚快取時也可以新增引數使之清楚對用的快取並非所有的
filter:此類快取可以設定filter引數為true來清理,相反的不需要清楚此類快取那麼可以設定引數為false來保留此類快取
field_data:此類快取可以設定filter引數為true來清理,相反的不需要清楚此類快取那麼可以設定引數為false來保留此類快取
bloom:此類快取可以設定filter引數為true來清理(如果某種倒排索引格式中引用了bloom filter則可能使用此類快取),相反的不需要清楚此類快取那麼可以設定引數為false來保留此類快取
fields:清楚欄位相關的快取,可以為單個或者多個欄位,多個欄位的時候用逗號隔開(英文)

上述引數使用格式(可以使用一個或者多個引數)
curl localhost:9200/index/_cache/clear?pretty&filter=false&field_data=true&bloom=false&fields=tag,name

 

Elasticsearch 三種快取:Query Cache、Request Cache、Fielddata Cache

一、Query Cache

Query Cache也稱為Filter Cache,顧名思義它的作用就是對一個查詢中包含的過濾器執行結果進行快取。

比如我們常用的term,terms,range過濾器都會在滿足某種條件後被快取,注意,這裡的bool過濾器是不會被快取的,但bool過濾器包含的子query clause會被快取,我們可以用下面的命令來查詢Query Cache的情況。

http://192.168.0.109:9200/_stats/query_cache?pretty&human 

舉個栗子,看下面的查詢

{
  "from": 0,
  "size": 5,
  "query": {
    "bool": {
      "filter": {
        "bool": {
          "must": [
            {
              "term": {
                "productID": "JODL-X-1937-#pV7"
              }
            },
            {
              "range": {
                "price": {
                  "from": 20,
                  "to": null,
                  "include_lower": true,
                  "include_upper": true
                }
              }
            }
          ]
        }
      }
    }
  }
}

上面有兩個過濾器一個Term過濾器用來過濾productID為“JODL-X-1937-#pV7” 的產品,一個range過濾器用來過濾價格在20以上的產品,在這個例子中這兩個過濾器執行的結果會分別作為一個BitSet(點陣圖)快取,返回的查詢結果則是這兩個點陣圖交集。

上面提到Filter Cache只會在滿足某種條件下才會被快取,至於是哪些條件這裡就不介紹了,想了解的童鞋戳下面連結。

關於Filter執行流程及快取原理 ,請參看此文:《Elasticsearch2.X Filter執行流程及快取原理》

二、Request Cache

當一個查詢傳送到ES叢集的某個節點上時,這個節點會把該查詢擴散到其他節點並在相應分片上執行,我們姑且把在分片上執行的結果叫“本地結果集“,這些本地結果集最終會彙集到最初請求到達的那個協調節點,這些“分片級”的結果集會合併成“全域性”結果集返回給呼叫端。

Request Cache模組就是為了快取這些“分片級”的本地結果集,但是目前只會快取查詢中引數size=0的請求,所以就不會快取hits 而是快取 hits.total,aggregations和suggestions

快取失效

Request Cache是非常智慧的,它能夠保證和在近實時搜尋中的非快取查詢結果一致。這句話讀起來很難懂,簡單解釋下。

我們都知道ES是一個“near real-time”(近實時)搜尋引擎,為什麼是近實時搜尋呢,那是因為當我們向ES傳送一個索引文件請求到這個文件變成Searchable(可搜尋)預設的時間是1秒,我們可以通過index.refresh_interval引數來設定重新整理時間間隔,也就是說我們在執行一個搜尋請求時實際上資料是有延遲的。回到剛才的問題,剛才那句話其實指的就是:ES能保證在使用Request Cache的情況下的搜尋結果和不使用Request Cache的近實時搜尋結果相同,那ES是如何保證兩者結果相同的呢?繼續……

Request Cache快取失效是自動的,當索引refresh時就會失效,也就是說在預設情況下Request Cache是每1秒鐘失效一次(注意:分片在這段時間內確實有改變才會失效)。也就是說當一個文件被索引到該文件變成Searchable之前的這段時間內,不管是否有請求命中快取該文件都不會被返回,正是是因為如此ES才能保證在使用Request Cache的情況下執行的搜尋和在非快取近實時搜尋的結果一致。

如果我們把索引重新整理時間設定得越長那麼快取失效的時間越長,如果快取被寫滿將採用LRU策略清除。當然我們也可以手動設定引數indices.request.cache.expire指定失效時間,但是基本上我們沒必要去這樣做,因為快取在每次索引refresh時都會自動失效。

下面的命令可以手動清除快取

curl -XPOST 'localhost:9200/kimchy,elasticsearch/_cache/clear?request_cache=true'  

快取使用

在預設情況下Request Cache是關閉的,我們需要手動開啟

curl -XPUT localhost:9200/my_index/_settings -d'  
{ "index.requests.cache.enable": true }  
'  

開啟快取後,我們需要在那些需要快取的搜尋請求上加上request_cache=true這個引數才能使我們的查詢請求被快取,比如:

curl 'localhost:9200/my_index/_search?request_cache=true' -d'  
{  
  "size": 0,  
  "aggs": {  
    "popular_colors": {  
      "terms": {  
        "field": "colors"  
      }  
    }  
  }  
}'  

注意1:上面的引數size:0非常重要必須強制指定才能被快取,否則請求是不會快取的。

注意2(重要):在使用script執行查詢時一定要指定request_cache=false,因為指令碼的執行結果是不確定的(比如使用random函式或使用了當前時間作為引數),這種情況下應該避免使用快取

快取的Cache Key

對於Request Cache來說,它的Cache Key就是整個查詢的DSL語句,所以如果要命中快取查詢生成的DSL一定要一樣,這裡的一樣是指DSL這個字串一樣。只要有一個字元或者子查詢的順序變化都不會命中快取。

通過下面的引數我們可以設定快取的大小,預設情況下是JVM堆的1%大小,當然我們也可以手動設定在elasticsearch.yml檔案裡

indices.requests.cache.size: 1%  

Request Cache總結:

  1. Request Cache是一個“分片級”的快取
  2. Request Cache是一個面向請求的快取,快取的key是查詢DSL字串
  3. Request Cache預設沒有開啟,需要手動開啟,且要快取生效需要在請求引數上加上request_cache=true並把size設定為0
  4. 快取是自動失效的,失效時間就是索引的refresh時間(index.refresh_interval),在分片有改變的情況下預設是1秒失效一次
  5. 快取的預設大小是JVM堆記憶體的1%,可以通過引數indices.request.cache.expire 手動設定

三、Fielddata

一談到Fielddata我們不得不提到doc_values,這兩者的作用都是一樣:能夠讓我們在inverted index(倒排索引)的基礎之上做aggregation、sort或者在查詢中通過script訪問doc屬性,這裡我們不討論doc_values,主要講下Fielddata,doc values相關知識請戳:http://blog.csdn.net/chennanymy/article/details/52555055

想必大家都知道倒排索引這種結構,如果我們僅僅依靠倒排是很難在查詢中做到排序和統計的,因為它並不是像關係型資料庫那樣採用“列式儲存”,而是基於一個“詞”到“文件”的倒排。

Fielddata是專門針對分詞的欄位在query-time(查詢期間)的資料結構的快取。當我們第一次在一個分詞的欄位上執行聚合、排序或通過指令碼訪問的時候就會觸發該欄位Fielddata Cache的載入,這種快取是“segment”級別的,當有新的segment開啟時舊的快取不會重新載入,而是直接把新的segement對應的Fielddata Cache載入到記憶體。

載入Fielddata Cache是一個非常昂貴的操作,一旦Fielddata被載入到記憶體,那麼在該Fielddata Cache對應的Segement生命週期範圍內都會駐留在記憶體中。也就是說當段合併時會觸發合併後更大段的Fielddata Cache載入。

Fielddata會消耗大部分的JVM堆記憶體,特別是當載入“高基數”的分詞欄位時(那些分詞後存在大量不同詞的欄位),針對這種欄位的聚合排序其實是非常沒有意義的,我們更多的要去考慮是否能用not_analyzed代替(這樣就可以使用doc_values實現聚合、排序)。

預設情況下Fielddate Cache是預設開啟的,我們可以通過下面的設定來關閉,關閉後就無法對分詞欄位執行聚合、排序操作了。

PUT my_index
{
  "mappings": {
    "my_type": {
      "properties": {
        "text": {
          "type": "string",
          "fielddata": {
            "format": "disabled" 
          }
        }
      }
    }
  }
}

在ES1.X裡面除了string型別其他型別也是使用Fieldata的,在ES2.X中除了分詞的String型別欄位,其他型別都使用doc_values。

Fielddata的載入方式有3種:

1.lazy: 懶載入是預設的載入方式,當第一次使用時載入

2.eager:預載入模式是當一個新的索引段變成Searchable之前會被載入

3.eager_global_ordinals:全域性序數預載入模式,這種方式能生成一份全域性序數表,可降低記憶體使用。

如下設定為eager_global_ordinals

PUT my_index
{
  "mappings": {
    "my_type": {
      "properties": {
        "text": {
          "type": "string",
           "fielddata": {
            "loading": "eager_global_ordinals" 
          }
        }
      }
    }
  }
}

Fielddata也可指定滿足某些條件的term才被載入進記憶體

1.通過詞頻載入

PUT my_index  
{  
  "mappings": {  
    "my_type": {  
      "properties": {  
        "tag": {  
          "type": "string",  
          "fielddata": {  
            "filter": {  
              "frequency": {  
                "min": 0.001,  
                "max": 0.1,  
                "min_segment_size": 500  
              }  
            }  
          }  
        }  
      }  
    }  
  }  
}  

上面的設定表示詞頻在0.001到0.1之間的且段持有的文件數在500以上的term會被載入到記憶體。

2.通過正則表示式載入

PUT my_index  
{  
  "mappings": {  
    "my_type": {  
      "properties": {  
        "tweet": {  
          "type": "string",  
          "analyzer": "whitespace",  
          "fielddata": {  
            "filter": {  
              "regex": {  
                "pattern": "^#.*"  
              }  
            }  
          }  
        }  
      }  
    }  
  }  
}  

上面的設定表示只有滿足pattern的詞才會被載入到記憶體。

Fielddata Cache設定

1.indices.fielddata.cache.size:此引數設定快取大小(預設是不限制)。可設定百分數如30%,或者數字12GB

2.indices.breaker.fielddata.limit:此引數設定Fielddata斷路器限制大小(公式:預計算記憶體 + 現有記憶體 <= 斷路器設定記憶體限制),預設是60%JVM堆記憶體,當查詢嘗試載入更多資料到記憶體時會拋異常(以此來阻止JVM OOM發生)

3.indices.breaker.fielddata.overhead:一個常數表示記憶體預估值係數,預設1.03,比如預計算載入100M資料,那麼100*1.03=103M會用103M作為引數計算是否超過斷路器設定的最大值。

原文出處:http://www.infoq.com/cn/articles/database-timestamp-02