一、Doc Values介紹
倒排索引在搜索包含指定 term 的文檔時效率極高,但在執行相反操作,比如查詢一個文檔中包含哪些 term,以及進行排序、聚合等與指定字段相關的操作時,表現就很差了,這時候就需要用到 Doc Values。
倒排索引是將 term 映射到包含它們的文檔,而 Doc Values 則是將文檔映射到它們包含的所有詞項,以下是一個示例:
Doc | Terms |
Doc_1 | brown, dog, fox, jumped, lazy, over, quick, the |
Doc_2 | brown, dogs, foxes, in, lazy, leap, over, quick, summer |
Doc_3 | dog, dogs, fox, jumped, over, quick, the |
當數據被這樣倒置之后,想要收集到 Doc_1 和 Doc_2 的唯一 token 就變得非常容易。只需要獲取每個文檔行,提取所有的詞項,然后求兩個集合的并集即可。
其實,Doc Values 本質上是一個序列化了的列式存儲結構,這種結構非常適合排序、聚合以及字段相關的腳本操作。而且這種存儲方式便于壓縮,尤其是對于數字類型,壓縮后能夠大大減少磁盤空間占用,同時提升訪問速度。下面是一個數字類型的 Doc Values 示例:
Doc | Terms |
Doc_1 | 100 |
Doc_2 | 1000 |
Doc_3 | 1500 |
Doc_4 | 1200 |
Doc_5 | 300 |
Doc_6 | 1900 |
Doc_7 | 4200 |
列式存儲意味著這些數據會形成一個連續的數據塊:[100, 1000, 1500, 1200, 300, 1900, 4200]。因為我們知道它們都是數字(不像文檔或行中看到的異構集合),所以可以使用統一的偏移量來將它們緊密排列。
而且,針對這樣的數字有很多種壓縮技巧。你會注意到這里每個數字都是 100 的倍數,Doc Values 會檢測一個段里面的所有數值,并使用一個最大公約數來進行進一步的數據壓縮。比如在這個例子中,可以用 100 作為公約數,那么以上數字就變為 [1, 10, 15, 12, 3, 19, 42],這樣可用很少的 bit 就能存儲,節約了磁盤空間。一般來說,Doc Values 按順序檢測以下壓縮方案:
如果所有值都相同(或缺失),就設置一個標志并記錄該值
如果少于 256 個值,就會使用一個簡易碼表
如果值個數大于 256,就檢查是否存在最大公約數
如果沒有最大公約數,就以偏移量的方式從最小值開始對所有值編碼
String 類型使用順序表,按和數字類型類似的方式編碼。String 類型去重后排序,然后寫入一個表中,并分配一個 ID 號,這些 ID 號就被當做數字類型的 Doc Values。這意味著字符串享有許多與數字相同的壓縮特點。
Doc Values 是在字段索引時與倒排索引同時生成,并且生成以后是不可變的。
Doc Value 默認對除了 analyzed String 外的所有字段啟用(因為分詞后會生成很多 token 使得 Doc Values 效率降低)。但是當你知道某些字段永遠不會進行排序、聚合以及腳本操作的時候,可以禁用 Doc Values 以節約磁盤空間、提升索引速度,示例如下:
PUT my_index{"mappings": {"my_type": {"properties": {"session_id": {"type": "string","index": "not_analyzed","doc_values": false}}}}}
以上配置后,session_id 字段就只能被搜索,不能被用于排序、聚合以及腳本操作了。
還可以通過設定 doc_values 為 true,index 為 no 來讓字段不能被搜索但可以用于排序、聚合以及腳本操作:
PUT my_index{"mappings": {"my_type": {"properties": {"customer_token": {"type": "string","index": "not_analyzed","doc_values": true,"index": "no"}}}}}
Doc Value 的特點是快速、高效、內存友好,使用由 linux kernel 管理的文件系統緩存彈性存儲。doc values 在排序、聚合或與字段相關的腳本計算中得到了高效運用,任何需要查找某個文檔包含的值的操作都必須使用它。如果你確定某個 field 不會做字段相關操作,可以直接關掉 doc_values,節約內存,加快訪問速度。
二、Fielddata介紹
上文說過,在排序、聚合以及在腳本中訪問 field 值時,需要一種與倒排索引截然不同的數據訪問模式:不同于倒排索引中的查找 term-> 找到對應 docs 的過程,我們需要直接查找 doc 然后找到指定某個 field 中包含的 terms。
大多數 field 使用索引時、磁盤上的 doc_values 來支持這種訪問模式,但是分詞了的 String field 不支持 Doc Values,而是使用一種叫 FieldData 的數據結構。
FieldData 主要是針對 analyzed String,它是一種查詢時(query-time)的數據結構。
FieldData 緩存主要應用場景是在對某一個 field 排序或者計算類的聚合運算時。它會把這個 field 列的所有值加載到內存,目的是提供對這些值的快速文檔訪問。為 field 構建 FieldData 緩存可能會很昂貴,因此建議有足夠的內存來分配它,并保持其處于已加載狀態。
FieldData 是在第一次將該 field 用于聚合、排序或在腳本中訪問時按需構建。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 擁有多種類型的斷路器:
indices.breaker.fielddata.limit
indices.breaker.request.limit
indices.breaker.total.limit
可以根據實際需要進行配置。
FieldData 是為分詞 String 而生,它會消耗大量的 java 堆空間,特別是加載基數(cardinality)很大的分詞 String field 時。但是往往對這種類型的分詞 Field 做聚合是沒有意義的。
值得注意的是,FieldData 和 Doc Values 的加載時機不同,前者是首次查詢時,后者是 doc 索引時。還有一點,FieldData 是按每個段來緩存的。
三、Doc values 與 Fielddata 對比
doc_values 與 fielddata 一個很顯著的區別是,前者的工作地盤主要在磁盤,而后者的工作地盤在內存。
維度 | doc_values | fielddata |
創建時間 | index 時創建 | 使用時動態創建 |
創建位置 | 磁盤 | 內存 (jvm heap) |
優點 | 不占用內存空間 | 不占用磁盤空間 |
缺點 | 索引速度稍低 | 文檔很多時,動態創建開銷大,且占內存 |
索引速度稍低是相對于 fielddata 方案而言的,其實仔細想想也可以理解。拿排序舉例,一個在磁盤排序,一個在內存排序,誰的速度快不言而喻。
而且,隨著 ES 版本的升級,對于 doc_values 的優化越來越好,索引的速度已經很接近 fielddata 了,而且我們知道硬盤的訪問速度也是越來越快(比如 SSD)。所以 doc_values 現在可以滿足大部分場景,也是 ES 官方重點維護的對象。
所以,doc values 相比 field data 還是有很多優勢的。因此 ES2.x 之后,支持聚合的字段屬性默認都使用 doc_values,而不是 fielddata。
Global Ordinals 全局序號
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 的數據。怎么減少占用空間呢?首先想到的就是用數字來進行編碼,碼表如下:
Ordinal | Term |
0 | status_deleted |
1 | status_pending |
2 | status_published |
這樣的話,初始的那三個 String 就只在碼表內被存了一次。FieldData 中的 doc 就可以直接用編碼來指向實際值:
Doc | Ordinal |
0 | 1 # pending |
1 | 1 # pending |
2 | 2 # published |
3 | 0 # deleted |
這樣編碼以后,直接把數據量壓縮了十倍左右。但有個問題是 FieldData 是按每個段來分別加載、緩存的。那么就會出現一種情況,如果一個段內的 doc 只有 deleted 和 published 兩個狀態,那么就會導致該 FieldData 算出來的碼表只有 0 和 1,這就和擁有 3 個狀態的段算出的 FieldData 碼表不同。這樣的話,聚合的時候就必須一個段一個段的計算,最后再聚合,十分緩慢,開銷巨大。
ES 的做法是用 Global Ordinals 這種構建在 FieldData 之上的小巧數據結構,編碼會結合所有段來計算唯一值然后存放為一個序號碼表。這樣一來,term aggregation 可以只在全局序號上進行聚合,而且只會在聚合的最終階段來計算從序號到真實的 String 值一次。這個機制可以提升聚合的性能 3-4 倍。