倒排索引在搜索包含指定term的doc時非常高效,但是在相反的操作時表現很差:查詢壹個文檔中包含哪些term。具體來說,倒排索引在搜索時最為高效,但在排序、聚合等與指定filed相關的操作時效率低下,需要用 doc_values 。
倒排索引將term映射到包含它們的doc,而doc values將doc映射到它們包含的所有詞項,下面是壹個示例:
當數據被逆置之後,想要收集到 Doc_1 和 Doc_2 的唯壹 token 會非常容易。獲得每個文檔行,獲取所有的詞項,然後求兩個集合的並集。
其實,Doc Values本質上是壹個序列化了的列式存儲結構,非常適合排序、聚合以及字段相關的腳本操作。而且這種存儲方式便於壓縮,尤其是數字類型。壓縮後能夠大大減少磁盤空間,提升訪問速度。下面是壹個數字類型的 Doc Values示例:
列式存儲意味著有壹個連續的數據塊: [100,1000,1500,1200,300,1900,4200] 。因為我們已經知道他們都是數字(而不是像文檔或行中看到的異構集合),所以可以使用統壹的偏移量來將他們緊緊排列。
而且,針對這樣的數字有很多種壓縮技巧。妳會註意到這裏每個數字都是 100 的倍數,Doc Values會檢測壹個段裏面的所有數值,並使用壹個最大公約數,方便做進壹步的數據壓縮。
比如,這個例子中可以用100作為公約數,那麽以上數字就變為[1,10,15,12,3,19,42],可用很少的bit就能存儲,節約了磁盤空間。壹般來說,Doc Values按順序來檢測以下壓縮方案:
String類型使用順序表,按和數字類型類似的方式編碼。String類型去重後排序,然後寫入壹個表中,並分配壹個ID號,然後這些ID號就被當做數字類型的Doc Values。這意味著字符串享有許多與數字相同的壓縮特點。
Doc Values是在字段索引時與倒排索引同時生成,而且生成以後是不可變的。
Doc Value 默認對除了 analyzed String 外的所有字段啟用(因為分詞後會生成很多token使得Doc Values效率降低)。但是當妳知道某些字段永遠不會進行排序、聚合以及腳本操作的時候可以禁用Doc Values以節約磁盤空間提升索引速度,示例如下:
以上配置以後,session_id字段就只能被搜索,不能被用於排序、聚合以及腳本操作了。
還可以通過設定doc_values為true,index為no來讓字段不能被搜索但可以用於排序、聚合以及腳本操作:
Doc Value的特點就是快速、高效、內存友好,使用由linux kernel管理的文件系統緩存彈性存儲。doc values在排序、聚合或與字段相關的腳本計算得到了高效的運用,任何需要查找某個文檔包含的值的操作都必須使用它。如果妳確定某個filed不會做字段相關操作,可以直接關掉doc_values,節約內存,加快訪問速度。
上文說過,在排序、聚合以及在腳本中訪問field值時需要壹個與倒排索引截然不同的數據訪問模式:不同於倒排索引中的查找term->找到對應docs的過程,我們需要直接查找doc然後找到指定某個filed中包含的terms。
大多數field使用索引時、磁盤上的doc_values來支持這種訪問模式,但是分詞了的String filed不支持Doc Values,而是使用壹種叫FieldData的數據結構。
FieldData主要是針對analyzed String ,它是壹種查詢時(query-time)的數據結構。
FieldData緩存主要應用場景是在對某壹個field排序或者計算類的聚合運算時。它會把這個field列的所有值加載到內存,這樣做的目的是提供對這些值的快速文檔訪問。為field構建FieldData緩存可能會很昂貴,因此建議有足夠的內存來分配它,並保持其處於已加載狀態。
FieldData是在第壹次將該filed用於聚合,排序或在腳本中訪問時按需構建 。FieldData是通過從磁盤讀取每個段來讀取整個反向索引,然後逆置term->doc的關系,並將結果存儲在JVM堆中構建的。
所以,加載FieldData是開銷很大的操作,壹旦它被加載後,就會在整個段的生命周期中保留在內存中。
這了可以註意下FieldData和Doc Values的區別。較早的版本中,其他數據類型也是用的FieldData,但是目前已經用隨文檔索引時創建的Doc Values所替代。
JVM堆內存資源是非常寶貴的,能用好它對系統的高效穩定運行至關重要。FieldData是直接放在堆內的,所以必須合理設定用於存放它的堆內存資源數。ES中控制FieldData內存使用的參數是 indices.fielddata.cache.size ,可以用x%表示占該節點堆內存百分比,也可以用如12GB這樣的數值。默認狀況下,這個設置是無限制的,ES不會從FieldData中驅逐數據。如果生成的fielddata大小超過指定的size,則將驅逐其他值以騰出空間。使用時壹定要註意,這個設置只是壹個安全策略而並非內存不足的解決方案。因為通過此配置觸發數據驅逐,ES會立刻開始從磁盤加載數據,並把其他數據驅逐以保證有足夠空間,導致很高的IO以及大量的需要被垃圾回收的內存垃圾。
舉個例子來說:每天為日誌文件建壹個新的索引。壹般來說我們只對最近幾天數據感興趣,很少查詢老數據。但是,按默認設置FieldData中的老索引數據是不會被驅逐的。這樣的話,FieldData就會壹直持續增長直到觸發 熔斷機制 ,這個機制會讓妳再也不能加載更多的FieldData到內存。這樣的場景下,妳只能對老的索引訪問FieldData,但不能加載更多新數據。所以,這個時候就可以通過以上配置來把最近最少使用的FieldData驅逐以夠新進來的數據騰空間。
FieldData是在數據被加載後再檢查的,那麽如果壹個查詢導致嘗試加載超過可用內存的數據就會導致OOM異常。ES中使用了 FieldData Circuit Breaker 來處理上述問題,他可以通過分析壹個查詢涉及到的字段的類型、基數、大小等來評估所需內存。如果估計的查詢大小大於配置的堆內存使用百分比限制,則斷路器會跳閘,查詢將被中止並返回異常。
斷路器是工作是在數據加載前,所以妳不用擔心遇到FieldData導致的OOM異常。ES擁有多種類型的斷路器:
可以根據實際需要進行配置。
FieldData是為分詞String而生,它會消耗大量的java 堆空間,特別是加載基數(cardinality)很大的分詞String filed時。但是往往對這種類型的分詞Field做聚合是沒有意義的。
值得註意的是,FieldData和Doc Values的加載時機不同,前者是首次查詢時,後者是doc索引時。還有壹點,FieldData是按每個段來緩存的。
doc_values與fielddata壹個很顯著的區別是,前者的工作地盤主要在磁盤,而後者的工作地盤在內存。
索引速度稍低這個是相對於fielddata方案的,其實仔細想想也可以理解。拿排序舉例,相對於壹個在磁盤排序,壹個在內存排序,誰的速度快不言自明。
在ES 1.x版本的官方說法是,
雖然速度稍慢,doc_values的優勢還是非常明顯的。壹個很顯著的點就是它不會隨著文檔的增多引起OOM問題。正如前面說的,doc_values在磁盤創建排序和聚合所需的正排索引。這樣我們就避免了在生產環境給ES設置壹個很大的 HEAP_SIZE ,也使得JVM的GC更加高效,這個又為其它的操作帶來了間接的好處。
而且,隨著ES版本的升級,對於doc_values的優化越來越好,索引的速度已經很接近fielddata了,而且我們知道硬盤的訪問速度也是越來越快(比如SSD)。所以 doc_values 現在可以滿足大部分場景,也是ES官方重點維護的對象。
所以我想說的是,doc values相比field data還是有很多優勢的。所以 ES2.x 之後,支持聚合的字段屬性默認都使用doc_values,而不是fielddata。
Global Ordinals是壹個在Doc Values和FieldData之上的數據結構,它為每個唯壹的term按字典序維護了壹個自增的數字序列。每個term都有自己的壹個唯壹數字,而且字母A的全局序號小於字母B。特別註意,全局序號只支持String類型的field。
請註意,Doc Values和FieldData也有自己的ordinals序號,這個序號是特定segment和field中的唯壹編號。通過提供Segment Ordinals和Global Ordinals間的映射關系,全局序號只是在此基礎上創建,後者(即全局序號)是在整個shard分片中是唯壹的。
壹個特定字段的Global Ordinals跟壹個分片中的所有段相關,而Doc Values和FieldData的ordinals只跟單個段相關。因此,只要是壹個新段要變得可見,那麽就必須完全重建全局序號。
也就是說,跟FieldData壹樣,在默認情況下全局序號也是懶加載的,會在第壹個請求FieldData命中壹個索引時來構建全局序號。實際上,在為每個段加載FieldData後,ES就會創建壹個稱為Global Ordinals(全局序號)的數據結構來構建壹個由分片內的所有段中的唯壹term組成的列表。
全局序號的內存開銷小的原因是它由非常高效的壓縮機制。提前加載的全局序號可以將加載時間從第壹次搜索時轉到全局序號刷新時。
全局序號的加載時間依賴於壹個字段中的term數量,但是總的來說耗時較低,因為來源的字段數據都已經加載到內存了。
全局序號在用到段序號的時候很有用,比如排序或者terms aggregation,可以提升執行效率。
我們舉個簡單的例子。比如有十億級別的doc,每個doc都有壹個status字段,但只有pending, published, deleted三個狀態數據。如果直接存整個String數據到內存,那麽就算每個doc有15字節,那麽壹***就是差不多14GB的數據。怎麽減少占用空間呢?首先想到的就是用數字來進行編碼,碼表如下:
這樣的話,初始的那三個String就只在碼表內被存了壹次。FieldData中的doc就可以直接用編碼來指向實際值:
這樣編碼以後,直接把數據量壓縮了十倍左右。但有個問題是FieldData是按每個段來分別加載、緩存的。那麽就會出現壹個情況,如果壹個段內的doc只有deleted和published兩個狀態,那麽就會導致該FieldData算出來的碼表只有0和1,這就和擁有3個狀態的段算出的FieldData碼表不同。這樣的話,聚合的時候就必須壹個段壹個段的計算,最後再聚合,十分緩慢,開銷巨大。
ES的做法是用Global Ordinals這種構建在FieldData之上的小巧數據結構,編碼會結合所有段來計算唯壹值然後存放為壹個序號碼表。這樣壹來,term aggregation可以只在全局序號上進行聚合,而且只會在聚合的最終階段來計算從序號到真實的String值壹次。這個機制可以提升聚合的性能3-4倍。