Elasticsearch 技術分析(七): Elasticsearch 的性能優化

硬件選擇

Elasticsearch(後文簡稱 ES)的基礎是 Lucene,所有的索引和文檔數據是存儲在本地的磁盤中,具體的路徑可在 ES 的配置文件../config/elasticsearch.yml中配置,如下:

# ----------------------------------- Paths ------------------------------------
#
# Path to directory where to store the data (separate multiple locations by comma):
#
path.data: /path/to/data
#
# Path to log files:
#
path.logs: /path/to/logs

磁盤在現代服務器上通常都是瓶頸。Elasticsearch 重度使用磁盤,你的磁盤能處理的吞吐量越大,你的節點就越穩定。這裏有一些優化磁盤 I/O 的技巧:

  • 使用 SSD。就像其他地方提過的, 他們比機械磁盤優秀多了。
  • 使用 RAID 0。條帶化 RAID 會提高磁盤 I/O,代價顯然就是當一塊硬盤故障時整個就故障了。不要使用鏡像或者奇偶校驗 RAID 因爲副本已經提供了這個功能。
  • 另外,使用多塊硬盤,並允許 Elasticsearch 通過多個 path.data 目錄配置把數據條帶化分配到它們上面。
  • 不要使用遠程掛載的存儲,比如 NFS 或者 SMB/CIFS。這個引入的延遲對性能來說完全是背道而馳的。
  • 如果你用的是 EC2,當心 EBS。即便是基於 SSD 的 EBS,通常也比本地實例的存儲要慢。

內部壓縮

硬件資源比較昂貴,一般不會花大成本去購置這些,可控的解決方案還是需要從軟件方面來實現性能優化提升。

其實,對於一個分佈式、可擴展、支持PB級別數據、實時的搜索與數據分析引擎,ES 本身對於索引數據和文檔數據的存儲方面內部做了很多優化,具體體現在對數據的壓縮,那麼是如何壓縮的呢?介紹前先要說明下 Postings lists 的概念。

倒排列表 - postings list

搜索引擎一項很重要的工作就是高效的壓縮和快速的解壓縮一系列有序的整數列表。我們都知道,Elasticsearch 基於 Lucene,一個 Lucene 索引 我們在 Elasticsearch 稱作 分片 , 並且引入了 按段搜索 的概念。

新的文檔首先被添加到內存索引緩存中,然後寫入到一個基於磁盤的段。在每個 segment 內文檔都會有一個 0 到文檔個數之間的標識符(最高值 2^31 -1),稱之爲 doc ID。這在概念上類似於數組中的索引:它本身不做存儲,但足以識別每個item 數據。

Segments 按順序存儲有關文檔的數據,在一個Segments 中 doc ID 是 文檔的索引。因此,segment 中的第一個文檔的 doc ID 爲0,第二個爲1,等等。直到最後一個文檔,其 doc ID 等於 segment 中文檔的總數減1。

那麼這些 doc ID 有什麼用呢?倒排索引需要將 terms 映射到包含該單詞 (term) 的文檔列表,這樣的映射列表我們稱之爲:倒排列表(postings list)。具體某一條映射數據稱之爲:倒排索引項(Posting)

舉個例子,文檔和詞條之間的關係如下圖所示,右邊的關係表即爲倒排列表:

倒排列表 用來記錄有哪些文檔包含了某個單詞(Term)。一般在文檔集合裏會有很多文檔包含某個單詞,每個文檔會記錄文檔編號(doc ID),單詞在這個文檔中出現的次數(TF)及單詞在文檔中哪些位置出現過等信息,這樣與一個文檔相關的信息被稱做 倒排索引項(Posting),包含這個單詞的一系列倒排索引項形成了列表結構,這就是某個單詞對應的 倒排列表

Frame Of Reference

瞭解了分詞(Term)和文檔(Document)之間的映射關係後,爲了高效的計算交集和並集,我們需要倒排列表(postings lists)是有序的,這樣方便我們壓縮和解壓縮。

針對倒排列表,Lucene 採用一種增量編碼的方式將一系列 ID 進行壓縮存儲,即稱爲Frame Of Reference的壓縮方式(FOR),自Lucene 4.1以來一直在使用。

在實際的搜索引擎系統中,並不存儲倒排索引項中的實際文檔編號(Doc ID),而是代之以文檔編號差值(D-Gap)。文檔編號差值是倒排列表中相鄰的兩個倒排索引項文檔編號的差值,一般在索引構建過程中,可以保證倒排列表中後面出現的文檔編號大於之前出現的文檔編號,所以文檔編號差值總是大於0的整數。

如下圖所示的例子中,原始的 3個文檔編號分別是187、196和199,通過編號差值計算,在實際存儲的時候就轉化成了:187、9、3。

之所以要對文檔編號進行差值計算,主要原因是爲了更好地對數據進行壓縮,原始文檔編號一般都是大數值,通過差值計算,就有效地將大數值轉換爲了小數值,而這有助於增加數據的壓縮率。

比如一個詞對應的文檔ID 列表 [73, 300, 302, 332,343, 372] ,ID列表首先要從小到大排好序;

  • 第一步: 增量編碼就是從第二個數開始每個數存儲與前一個id的差值,即300-73=227302-300=2,...,一直到最後一個數。
  • 第二步: 就是將這些差值放到不同的區塊,Lucene使用256個區塊,下面示例爲了方便展示使用了3個區塊,即每3個數一組。
  • 第三步: 位壓縮,計算每組3個數中最大的那個數需要佔用bit位數,比如30、11、29中最大數30最小需要5個bit位存儲,這樣11、29也用5個bit位存儲,這樣才佔用15個bit,不到2個字節,壓縮效果很好。

如下面原理圖所示,這是一個區塊大小爲3的示例(實際上是256):

考慮到頻繁出現的term(所謂low cardinality的值),比如gender裏的男或者女。如果有1百萬個文檔,那麼性別爲男的 posting list 裏就會有50萬個int值。用 Frame of Reference 編碼進行壓縮可以極大減少磁盤佔用。這個優化對於減少索引尺寸有非常重要的意義。

因爲這個 FOR 的編碼是有解壓縮成本的。利用skip list(跳錶),除了跳過了遍歷的成本,也跳過了解壓縮這些壓縮過的block的過程,從而節省了cpu。

Roaring bitmaps (RBM)

在 elasticsearch 中使用filters 優化查詢,filter查詢只處理文檔是否匹配與否,不涉及文檔評分操作,查詢的結果可以被緩存。具體的 Filter 和Query 的異同讀者可以自行網上查閱資料。

對於filter 查詢,elasticsearch 提供了Filter cache 這種特殊的緩存,filter cache 用來存儲 filters 得到的結果集。緩存 filters 不需要太多的內存,它只保留一種信息,即哪些文檔與filter相匹配。同時它可以由其它的查詢複用,極大地提升了查詢的性能。

Frame Of Reference 壓縮算法對於倒排表來說效果很好,但對於需要存儲在內存中的 Filter cache 等不太合適。

倒排表和Filter cache兩者之間有很多不同之處:

  • 倒排表存儲在磁盤,針對每個詞都需要進行編碼,而Filter等內存緩存只會存儲那些經常使用的數據。
  • 針對Filter數據的緩存就是爲了加速處理效率,對壓縮算法要求更高。

這就產生了下面針對內存緩存數據可以進行高效壓縮解壓和邏輯運算的roaring bitmaps算法。

說到Roaring bitmaps,就必須先從bitmap說起。Bitmap是一種數據結構,假設有某個posting list:

[3,1,4,7,8]

對應的Bitmap就是:

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

非常直觀,用0/1表示某個值是否存在,比如8這個值就對應第8位,對應的bit值是1,這樣用一個字節就可以代表8個文檔id(1B = 8bit),舊版本(5.0之前)的Lucene就是用這樣的方式來壓縮的。但這樣的壓縮方式仍然不夠高效,Bitmap自身就有壓縮的特點,其用一個byte就可以代表8個文檔,所以100萬個文檔只需要12.5萬個byte。但是考慮到文檔可能有數十億之多,在內存裏保存Bitmap仍然是很奢侈的事情。而且對於個每一個filter都要消耗一個Bitmap,比如age=18緩存起來的話是一個Bitmap,18<=age<25是另外一個filter緩存起來也要一個Bitmap。

Bitmap的缺點是存儲空間隨着文檔個數線性增長,所以祕訣就在於需要有一個數據結構打破這個魔咒,那麼就一定要用到某些指數特性:

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

Lucene使用的這個數據結構叫做 Roaring Bitmap,即位圖壓縮算法,簡稱BMP

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

這兩種合併使用索引的方式都有其用途。Elasticsearch 對其性能有詳細的對比,可閱讀 Frame of Reference and Roaring Bitmaps

分片策略

合理設置分片數

創建索引的時候,我們需要預分配 ES 集羣的分片數和副本數,即使是單機情況下。如果沒有在 mapping 文件中指定,那麼索引在默認情況下會被分配5個主分片和每個主分片的1個副本。

分片和副本的設計爲 ES 提供了支持分佈式和故障轉移的特性,但並不意味着分片和副本是可以無限分配的。而且索引的分片完成分配後由於索引的路由機制,我們是不能重新修改分片數的。

例如某個創業公司初始用戶的索引 t_user 分片數爲2,但是隨着業務的發展用戶的數據量迅速增長,這時我們是不能重新將索引 t_user 的分片數增加爲3或者更大的數。

可能有人會說,我不知道這個索引將來會變得多大,並且過後我也不能更改索引的大小,所以爲了保險起見,還是給它設爲 1000 個分片吧…

一個分片並不是沒有代價的。需要了解:

  • 一個分片的底層即爲一個 Lucene 索引,會消耗一定文件句柄、內存、以及 CPU 運轉。
  • 每一個搜索請求都需要命中索引中的每一個分片,如果每一個分片都處於不同的節點還好, 但如果多個分片都需要在同一個節點上競爭使用相同的資源就有些糟糕了。
  • 用於計算相關度的詞項統計信息是基於分片的。如果有許多分片,每一個都只有很少的數據會導致很低的相關度。

適當的預分配是好的。但上千個分片就有些糟糕。我們很難去定義分片是否過多了,這取決於它們的大小以及如何去使用它們。 一百個分片但很少使用還好,兩個分片但非常頻繁地使用有可能就有點多了。 監控你的節點保證它們留有足夠的空閒資源來處理一些特殊情況。

一個業務索引具體需要分配多少分片可能需要架構師和技術人員對業務的增長有個預先的判斷,橫向擴展應當分階段進行。爲下一階段準備好足夠的資源。 只有當你進入到下一個階段,你纔有時間思考需要作出哪些改變來達到這個階段。

一般來說,我們遵循一些原則:

  1. 控制每個分片佔用的硬盤容量不超過ES的最大JVM的堆空間設置(一般設置不超過32G,參考下文的JVM設置原則),因此,如果索引的總容量在500G左右,那分片大小在16個左右即可;當然,最好同時考慮原則2。

  2. 考慮一下node數量,一般一個節點有時候就是一臺物理機,如果分片數過多,大大超過了節點數,很可能會導致一個節點上存在多個分片,一旦該節點故障,即使保持了1個以上的副本,同樣有可能會導致數據丟失,集羣無法恢復。所以, 一般都設置分片數不超過節點數的3倍。

  3. 主分片,副本和節點最大數之間數量,我們分配的時候可以參考以下關係:

    節點數<=主分片數*(副本數+1)
    

創建索引的時候需要控制分片分配行爲,合理分配分片,如果後期索引所對應的數據越來越多,我們還可以通過索引別名等其他方式解決。

調整分片分配器的類型

以上是在創建每個索引的時候需要考慮的優化方法,然而在索引已創建好的前提下,是否就是沒有辦法從分片的角度提高了性能了呢?當然不是,首先能做的是調整分片分配器的類型,具體是在 elasticsearch.yml 中設置cluster.routing.allocation.type 屬性,共有兩種分片器even_shardbalanced(默認)

even_shard 是儘量保證每個節點都具有相同數量的分片,balanced 是基於可控制的權重進行分配,相對於前一個分配器,它更暴漏了一些參數而引入調整分配過程的能力。

每次ES的分片調整都是在ES上的數據分佈發生了變化的時候進行的,最有代表性的就是有新的數據節點加入了集羣的時候。當然調整分片的時機並不是由某個閾值觸發的,ES內置十一個裁決者來決定是否觸發分片調整,這裏暫不贅述。另外,這些分配部署策略都是可以在運行時更新的,更多配置分片的屬性也請大家自行查閱網上資料。

推遲分片分配

對於節點瞬時中斷的問題,默認情況,集羣會等待一分鐘來查看節點是否會重新加入,如果這個節點在此期間重新加入,重新加入的節點會保持其現有的分片數據,不會觸發新的分片分配。這樣就可以減少 ES 在自動再平衡可用分片時所帶來的極大開銷。

通過修改參數 delayed_timeout ,可以延長再均衡的時間,可以全局設置也可以在索引級別進行修改:

PUT /_all/_settings 
{
  "settings": {
    "index.unassigned.node_left.delayed_timeout": "5m" 
  }
}

通過使用 _all 索引名,我們可以爲集羣裏面的所有的索引使用這個參數,默認時間被延長成了 5 分鐘。

這個配置是動態的,可以在運行時進行修改。如果你希望分片立即分配而不想等待,你可以設置參數: delayed_timeout: 0

延遲分配不會阻止副本被提拔爲主分片。集羣還是會進行必要的提拔來讓集羣回到 yellow 狀態。缺失副本的重建是唯一被延遲的過程。

索引優化

Mapping建模

  1. 儘量避免使用nested或 parent/child,能不用就不用;

    nested query慢, parent/child query 更慢,比nested query慢上百倍;因此能在mapping設計階段搞定的(大寬表設計或採用比較smart的數據結構),就不要用父子關係的mapping。

  2. 如果一定要使用nested fields,保證nested fields字段不能過多,目前ES默認限制是50。參考:

    index.mapping.nested_fields.limit :50
    

    因爲針對1個document, 每一個nested field, 都會生成一個獨立的document, 這將使Doc數量劇增,影響查詢效率,尤其是Join的效率。

  3. 避免使用動態值作字段(key),動態遞增的mapping,會導致集羣崩潰;同樣,也需要控制字段的數量,業務中不使用的字段,就不要索引。

    控制索引的字段數量、mapping深度、索引字段的類型,對於ES的性能優化是重中之重。以下是ES關於字段數、mapping深度的一些默認設置:

    index.mapping.nested_objects.limit :10000
    index.mapping.total_fields.limit:1000
    index.mapping.depth.limit: 20
    
  4. 不需要做模糊檢索的字段使用 keyword類型代替 text 類型,這樣可以避免在建立索引前對這些文本進行分詞。

  5. 對於那些不需要聚合和排序的索引字段禁用Doc values。

    Doc Values 默認對所有字段啓用,除了 analyzed strings。也就是說所有的數字、地理座標、日期、IP 和不分析( not_analyzed )字符類型都會默認開啓。

    因爲 Doc Values 默認啓用,也就是說ES對你數據集裏面的大多數字段都可以進行聚合和排序操作。但是如果你知道你永遠也不會對某些字段進行聚合、排序或是使用腳本操作, 儘管這並不常見,這時你可以通過禁用特定字段的 Doc Values 。這樣不僅節省磁盤空間,也會提升索引的速度。

    要禁用 Doc Values ,在字段的映射(mapping)設置 doc_values: false 即可。

索引設置

  1. 如果你的搜索結果不需要近實時的準確度,考慮把每個索引的 index.refresh_interval 改到 30s或者更大。 如果你是在做大批量導入,設置 refresh_interval 爲-1,同時設置 number_of_replicas 爲0,通過關閉 refresh 間隔週期,同時不設置副本來提高寫性能。

    文檔在複製的時候,整個文檔內容都被髮往副本節點,然後逐字的把索引過程重複一遍。這意味着每個副本也會執行分析、索引以及可能的合併過程。

    相反,如果你的索引是零副本,然後在寫入完成後再開啓副本,恢復過程本質上只是一個字節到字節的網絡傳輸。相比重複索引過程,這個算是相當高效的了。

  2. 修改 index_buffer_size 的設置,可以設置成百分數,也可設置成具體的大小,最多給512M,大於這個值會觸發refresh。默認值是JVM的內存10%,但是是所有切片共享大小。可根據集羣的規模做不同的設置測試。

    indices.memory.index_buffer_size:10%(默認)
    indices.memory.min_index_buffer_size: 48mb(默認)
    indices.memory.max_index_buffer_size
    
  3. 修改 translog 相關的設置:

  • a. 控制數據從內存到硬盤的操作頻率,以減少硬盤IO。可將 sync_interval 的時間設置大一些。
    index.translog.sync_interval:5s(默認)。
    
  • b. 控制 tranlog 數據塊的大小,達到 threshold 大小時,纔會 flush 到 lucene 索引文件。
    index.translog.flush_threshold_size:512mb(默認)
    
  1. _id字段的使用,應儘可能避免自定義_id, 以避免針對ID的版本管理;建議使用ES的默認ID生成策略或使用數字類型ID做爲主鍵,包括零填充序列 ID、UUID-1 和納秒;這些 ID 都是有一致的,壓縮良好的序列模式。相反的,像 UUID-4 這樣的 ID,本質上是隨機的,壓縮比很低,會明顯拖慢 Lucene。

  2. _all 字段及 _source 字段的使用,應該注意場景和需要,_all字段包含了所有的索引字段,方便做全文檢索,如果無此需求,可以禁用;_source存儲了原始的document內容,如果沒有獲取原始文檔數據的需求,可通過設置includes、excludes 屬性來定義放入_source的字段。

  3. 合理的配置使用index屬性,analyzed 和not_analyzed,根據業務需求來控制字段是否分詞或不分詞。只有 groupby需求的字段,配置時就設置成not_analyzed, 以提高查詢或聚類的效率。

查詢效率

  1. 使用批量請求,批量索引的效率肯定比單條索引的效率要高。

  2. query_stringmulti_match 的查詢字段越多, 查詢越慢。可以在 mapping 階段,利用 copy_to 屬性將多字段的值索引到一個新字段,multi_match時,用新的字段查詢。

  3. 日期字段的查詢, 尤其是用now 的查詢實際上是不存在緩存的,因此, 可以從業務的角度來考慮是否一定要用now, 畢竟利用 query cache 是能夠大大提高查詢效率的。

  4. 查詢結果集的大小不能隨意設置成大得離譜的值, 如query.setSize不能設置成 Integer.MAX_VALUE, 因爲ES內部需要建立一個數據結構來放指定大小的結果集數據。

  5. 儘量避免使用 script,萬不得已需要使用的話,選擇painless & experssions 引擎。一旦使用 script 查詢,一定要注意控制返回,千萬不要有死循環(如下錯誤的例子),因爲ES沒有腳本運行的超時控制,只要當前的腳本沒執行完,該查詢會一直阻塞。如:

     {
        “script_fields”:{
            “test1”:{
                “lang”:“groovy”,
                “script”:“while(true){print 'don’t use script'}”
            }
        }
    }
    
  6. 避免層級過深的聚合查詢, 層級過深的group by , 會導致內存、CPU消耗,建議在服務層通過程序來組裝業務,也可以通過pipeline 的方式來優化。

  7. 複用預索引數據方式來提高 AGG 性能:

    如通過 terms aggregations 替代 range aggregations, 如要根據年齡來分組,分組目標是: 少年(14歲以下) 青年(14-28) 中年(29-50) 老年(51以上), 可以在索引的時候設置一個age_group字段,預先將數據進行分類。從而不用按age來做range aggregations, 通過age_group字段就可以了。

  8. Cache的設置及使用:

    **a) QueryCache: **ES查詢的時候,使用filter查詢會使用query cache, 如果業務場景中的過濾查詢比較多,建議將querycache設置大一些,以提高查詢速度。

    indices.queries.cache.size: 10%(默認),//可設置成百分比,也可設置成具體值,如256mb。
    

    當然也可以禁用查詢緩存(默認是開啓), 通過index.queries.cache.enabled:false設置。

    **b) FieldDataCache: **在聚類或排序時,field data cache會使用頻繁,因此,設置字段數據緩存的大小,在聚類或排序場景較多的情形下很有必要,可通過indices.fielddata.cache.size:30% 或具體值10GB來設置。但是如果場景或數據變更比較頻繁,設置cache並不是好的做法,因爲緩存加載的開銷也是特別大的。

    **c) ShardRequestCache: **查詢請求發起後,每個分片會將結果返回給協調節點(Coordinating Node), 由協調節點將結果整合。

    如果有需求,可以設置開啓; 通過設置index.requests.cache.enable: true來開啓。

    不過,shard request cache 只緩存 hits.total, aggregations, suggestions 類型的數據,並不會緩存hits的內容。也可以通過設置indices.requests.cache.size: 1%(默認)來控制緩存空間大小。

ES的內存設置

由於ES構建基於lucene, 而lucene設計強大之處在於lucene能夠很好的利用操作系統內存來緩存索引數據,以提供快速的查詢性能。lucene的索引文件segements是存儲在單文件中的,並且不可變,對於OS來說,能夠很友好地將索引文件保持在cache中,以便快速訪問;因此,我們很有必要將一半的物理內存留給lucene ; 另一半的物理內存留給ES(JVM heap )。所以, 在ES內存設置方面,可以遵循以下原則:

  1. 當機器內存小於64G時,遵循通用的原則,50%給ES,50%留給lucene。

  2. 當機器內存大於64G時,遵循以下原則:

    • a. 如果主要的使用場景是全文檢索, 那麼建議給ES Heap分配 4~32G的內存即可;其它內存留給操作系統, 供lucene使用(segments cache), 以提供更快的查詢性能。
    • b. 如果主要的使用場景是聚合或排序, 並且大多數是numerics, dates, geo_points 以及not_analyzed的字符類型, 建議分配給ES Heap分配 4~32G的內存即可,其它內存留給操作系統,供lucene使用(doc values cache),提供快速的基於文檔的聚類、排序性能。
    • c. 如果使用場景是聚合或排序,並且都是基於analyzed 字符數據,這時需要更多的 heap size, 建議機器上運行多ES實例,每個實例保持不超過50%的ES heap設置(但不超過32G,堆內存設置32G以下時,JVM使用對象指標壓縮技巧節省空間),50%以上留給lucene。
  3. 禁止swap,一旦允許內存與磁盤的交換,會引起致命的性能問題。 通過: 在elasticsearch.yml 中 bootstrap.memory_lock: true, 以保持JVM鎖定內存,保證ES的性能。

  4. GC設置原則:

    • a. 保持GC的現有設置,默認設置爲:Concurrent-Mark and Sweep (CMS),別換成G1GC,因爲目前G1還有很多BUG。
    • b. 保持線程池的現有設置,目前ES的線程池較1.X有了較多優化設置,保持現狀即可;默認線程池大小等於CPU核心數。如果一定要改,按公式((CPU核心數* 3)/ 2)+ 1 設置;不能超過CPU核心數的2倍;但是不建議修改默認配置,否則會對CPU造成硬傷。

調整JVM設置

ES 是在 lucene 的基礎上進行研發的,隱藏了 lucene 的複雜性,提供簡單易用的 RESTful Api接口。ES 的分片相當於 lucene 的索引。由於 lucene 是 Java 語言開發的,是 Java 語言就涉及到 JVM,所以 ES 存在 JVM的調優問題。

  • 調整內存大小。當頻繁出現full gc後考慮增加內存大小,但是堆內存和堆外內存不要超過32G。
  • 調整寫入的線程數和隊列大小。不過線程數最大不能超過33個(es控制死)。
  • ES非常依賴文件系統緩存,以便快速搜索。一般來說,應該至少確保物理上有一半的可用內存分配到文件系統緩存。

參考文檔:

  1. elasticsearch倒排表壓縮及緩存合併策略
  2. Frame of Reference and Roaring Bitmaps
  3. elasticsearch 倒排索引原理
  4. Elasticsearch性能優化總結
  5. 億級 Elasticsearch 性能優化
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章