Elasticsearch在Hdfs上build的實現及優化

睡覺好累發表於2018-11-29

引言

前段時間參與Elasticsearch離線平臺化專案,主要是做一套Elasticsearch的buildservice, 一方面通過bahamut的資料流定義能力,直接對接使用者原始資料,實現全增量一體化,解決使用者準備資料的痛點。另一方面,社群的elasticsearch並沒有全量增量的概念,所有資料都是使用者通過sdk一條一條發給es線上服務構建索引,很難處理海量資料的場景,而且也難免對線上的效能產生影響,尤其是索引Merge的時候會嚴重影響線上服務的穩定性。所以,需要給Es做一個BuildService, 使之能夠在blink叢集上直接構建索引。

志宸老師在阿里雲Elasticsearch離線平臺化建設這篇文章中介紹了總體架構,本文再詳細對ElasticBuild如何在Hdfs上構建索引以及一些相關的優化做一些介紹。

載入Hdfs索引

Elasticsearch在啟動的時候,會為每個Index的每個Shard初始化一個InternalEngine例項,主要工作是恢復lucene的indexWriter及es的translog,原生的es只支援從本地載入索引檔案,而修改後的ElasticBuild由於索引不落盤直接寫到hdfs, 所以需要實現一種繞過本地磁碟直接載入hdfs上索引的方案。

HdfsDirectory

直接想到的一種辦法是將索引拖到本地後再載入,顯然這種方式時間代價太大,而且blink上的例項磁碟不一定能支援這麼大的索引。所幸的是,lucene的索引讀寫介面Directory支援各種讀寫擴充套件,參考開源的元件,引入HdfsDirectory可以解決這個問題。
image.png

簡單剖析下HdfsDirectory的實現,它實現了Directory的Input、Writer的各種讀寫介面:
image.png

給HdfsDirectory加層Cache

效能瓶頸

方案制定到這裡,感覺信心滿滿,似乎最主要的難點已經找到解法,但是接下來的效能測試讓專案回到了最初的不確定。。。

開發機上起了一個HdfsDirectory版本的es服務,並通過esrally工具構建一個70G的索引,一開始跑得挺歡樂,但是Build任務就是結束不了,而且cpu也變得一陣一陣地跑不上去,又等了4個小時,還是跑不出來。。

經過分析,發現是索引merge結束不了,主要效能消耗在hdfs的讀寫上面,也就是說,直接用HdfsDirecoty讀寫hdfs是行不通的。

BlockDirectory

回頭想想,hdfs上讀寫索引和本地磁碟上讀寫索引,除了網路的開銷,還有一個重要原因是本地磁碟有PageCache, 而hdfs沒有(hdfs自身對物理block是有快取的,但在es機器上沒有相應的快取)。那是不是可以在HdfsDirctory上面加一層Cache來達到同樣的效果呢。

通過調研,發現solr上實現了一個BlockDirectory,它呼叫開源的CaffeineCache可以在普通的Directory上層加上Cache功能。我們結合ElasticBuild的場景對BlockDirectory做了少許定製:

  • ElasticBuild是跑在blink上的,同一個Blink例項裡面會有多個shard,我們不單獨給每個shard申請一塊cache, 而是全域性使用一個靜態的cache,這樣可以有效地避免熱點資料導致的cache大小分配不合理。
  • 只有從Hdfs上讀出來的資料進Cache, 從記憶體寫到hdfs的資料是不直接進cache的。原因有兩個:一是我們通過日誌發現,IndexWriter在merge的時候會高頻率地重複讀取一些有重疊的資料塊,而寫入的時候並沒有這個現象,所以可以多留些記憶體給讀資料用。另一方面,寫的檔案通常會比較大,可能一下子就把cache裡的內容重新整理一遍,導致cache命中率實然下降。

從而ElasticBuild使用的BlockDirectory如下圖所示:
image.png

經過測試,BlockDirectory的cache命中率達到90+%,大大提升了Hdfs上的索引讀寫效能,主要是Merge的效能得到大大改善。

NrtCachingDirectory

回過頭來想想,有了BlockDirectory之後,節省了很大一部分讀Hdfs的開銷,但是寫Hdfs還是有些消耗的,閱讀es程式碼後發現,InternalEngine在生成IndexWriter的config時指定了索引最終以CompoundFile形式存在:

image.png

CompoundFile的存在主要是為了減少索引檔案數目,避免開啟索引時檔案控制程式碼過多,所以es裡的索引生成過程其實是這樣的:

image.png

換句話說,中間產生的那一大堆只是臨時檔案,對最終結果沒有任何影響,只有最終的四種格式的檔案才是需要持久化的。那麼,這些檔案就不必從hdfs繞一圈了,直接在記憶體裡消化掉就可以了。

lucene-core裡提供了一個NRTCachingDirectory,它可以在其它Directory上層再封裝一個RAMDirectory, 實現將某些小檔案直接在記憶體裡消化掉,我們可以把它拿過來,修改一下關於 哪些檔案在記憶體處理,哪些檔案扔給其它Directory處理 的邏輯,從而達到只處理最終檔案的目的:

image.png

經70G資料的測試,單個shard的NRTCaching中,一直維持著30多個檔案直接在記憶體中處理,無需到Hdfs上繞一圈。這對效能的提升還是有些作用的,因為沒有NRTCaching的話,所有檔案需要寫到hdfs, 在flush時又將那些檔案讀回來,再合併成cfs,cfe,si等檔案。

自適應記憶體分配

以上分析瞭如果通過cache來提升hdfs的讀寫效能,現在例項的記憶體主要由以下幾個部分消耗:

  • lucene的indexWriter需要RamBuffer
  • BlockDirectory需要一段BlockCache
  • NRTCachingDirectory裡的RAMDirectory需要一段RAM

由於在blink上的例項規格是不固定的,所以我們沒法直接寫死每個模組的記憶體需要多少,最好能做到自動適配,減少運維成本。經過一段測試,我們得出一些實驗結論:

  • 將es的indices.memory.index_buffer_size調整到40%,這樣es在indexwriter記憶體達到系統記憶體的40%時,會觸發flush動作。
  • 將IndexWriter的indexingBufferSize調整到堆上freeMem的40%, 這時IndexWriter在記憶體達到限制後也會觸發flush動作。
  • 當IndexWriter記憶體調整到40%時,flush出來的索引大小預計會在20%左右,這些檔案會流轉到RAMDirectory中,由於RAMDirectory中的檔案大小是預估的,有時候相差還是比較大的,為了避免記憶體用超了,給RAMDirectory一些空間,所以NRTCachingDirectory的記憶體分配也給個40%, 也就是最多跟IndexWriter裡一樣。
  • 剩下的20%給BlockDirectory, 實驗下來看,BlockDirectory分配這麼多,已經足夠了。

這樣,我們就不需要為每種規格的例項單獨配置各個模組的記憶體比例了。

Meta資訊同步

上面說到的都是indexWriter索引如果同步到hdfs上,除此之外,還有些額外的資料需要跟著es一起同步到hdfs上。

shard_state

Shard的_state檔案記錄了shard的primary,indexUUID等資訊。需要在es更新本地shard的_state時同步copy到hdfs上去。

index_state

Index的_state檔案裡記錄了一些index的Setting,shard數等關鍵資訊,需要在es更新本地index的_state時同步copy到hdfs上去。

transLog

translog比較特別,它的檔案比較多,而且同步點也比較多,不能每次整個目錄同步到hdfs上,效能上扛不住。一路上改了很多個同步點後發現行不通,感謝 @志宸 老師的建議,translog因為在elasticBuild中不起作用,恢復點都是通過blink的checkpoint實現的,而且elasticBuild只要保證atleast once就可以了,所以直接不同步translog, 在elasticbuild failover或者暫停繼續後,新建一個空的translog目錄,保證程式能起來就可以了。

展望

我們規劃了一些優化點由於時間原因沒有在一期完成,後面可以考慮調研起來。

shard級別併發

上面做了很多的優化,但是有一個限制,就是同一個shard只能落到單個sink上處理,不能夠做到shard級別的併發build.

其實將shard拆成多個任務只要解決以下兩個問題即可:

多個shard的segment合併及snapshot功能的外圍實現

各個子shard單獨build索引後,需發在外層合併成同一個可以被load的索引,簡單看了看,其實只需要改一下cfe,cfs,si的generation號,併合並一下segmentInfo資訊到segments_0檔案裡就可以了。

合併的資訊包括:

  • Translog.TRANSLOG_GENERATION_KEY
  • Translog.TRANSLOG_UUID_KEY
  • SequenceNumbers.LOCAL_CHECKPOINT_KEY
  • Engine.SYNC_COMMIT_ID
  • SequenceNumbers.MAX_SEQ_NO
  • MAX_UNSAFE_AUTO_ID_TIMESTAMP_COMMIT_ID
  • HISTORY_UUID_KEY

這些資訊都是可以合併的。

另外,我們在hdfs上合併好索引後,還需要在外層合併成一個snapshot上傳到oss, 供線上es來載入,這也是可以實現的,唯一的問題是,在外層上傳的話,時間比較久,需要後面直接同步到oss的功能支援下。

image.png

全域性seqNo分配

elasticsearch 6.x還引入了一個_seqNo的概念,這個_seqNo是寫到doc裡的一個預設欄位,由於它是shard粒度嚴格自增的,所以需要在多個子shard外層分配這個_seqNo傳給各個子shard.

image.png

直接同步到oss

當前版本,是借住了HdfsDirectory將索引寫在hdfs上,然後最終在hdfs上生成了snapshot上傳到oss. 這個上傳還是有些代價的,所以最好能實現一個OssDirectory, 理論上可以直接替換HdfsDirectory, 省去一次上傳的代價,而且也可以在外層高效地做segments合併了.

致謝

從對Elasticsearch和Lucene的零認知到專案功能點的完成,少不了各路大神的協助。感謝 @洪震老大、@崑崙老大在Lucene及blink上的指導,感謝 @萬喜團隊的兄弟們一起探討方案,解決疑各種難雜症,後面二期需求再一起合作~


相關文章