解密Elasticsearch:深入探究這款搜尋和分析引擎

京東雲技術團隊發表於2023-05-06

作者:京東保險 管順利

開篇

最近使用Elasticsearch實現畫像系統,實現的dmp的資料中臺能力。同時調研了競品的架構選型。以及重溫了redis原理等。特此做一次es的總結和回顧。網上沒看到有人用Elasticsearch來完成畫像的。我來做第一次嘗試。

背景說完,我們先思考一件事,使用記憶體系統做資料庫。他的優點是什麼?他的痛點是什麼?

一、原理

這裡不在闡述全貌。只聊聊通訊、記憶體、持久化三部分。

通訊

es叢集最小單元是三個節點。兩個從節點搭配保證其高可用也是叢集化的基礎。那麼節點之間RPC通訊用的是什麼?必然是netty,es基於netty實現了Netty4Transport的通訊包。初始化Transport後建立Bootstrap,透過MessageChannelHandler完成接收和轉發。es裡區分server和client,如圖1。序列化使用的json。es在rpc設計上偏向於易用、通用、易理解。而不是單追求效能。

圖1

有了netty的保駕護航使得es放心是使用json序列化。

記憶體

圖2

es記憶體分為兩部分【on heap】和【off heap】。on heap這部分由es的jvm管理。off heap則是由lucene管理。on heap 被分為兩部分,一部分可以回收,一部分不能回收。

能回收的部分index buffer儲存新的索引文件。當被填滿時,緩衝區的文件會被寫入到磁碟segment上。node上共享所有shards。

不能被回收的有node query cache、shard request cache、file data cache、segments cache

node query cache是node級快取,過濾後儲存在每個node上,被所有shards共享,使用bitset資料結構(布隆最佳化版)關掉了評分。使用的LRU淘汰策略。GC無法回收。

shard request cache是shard級快取,每個shard都有。預設情況下該快取只儲存request結果size等於0的查詢。所以該快取不會被hits,但卻快取hits.total,aggregations,suggestions。可以透過clear cache api清除。使用的LRU淘汰策略。GC無法回收。

file data cache 是把聚合、排序後的data快取起來。初期es是沒有doc values的,所以聚合、排序後需要有一個file data來快取,避免磁碟IO。如果沒有足夠記憶體儲存file data,es會不斷地從磁碟載入資料到記憶體,並刪除舊的資料。這些會造成磁碟IO和引發GC。所以2.x之後版本引入doc values特性,把文件構建在indextime上,儲存到磁碟,透過memory mapped file方式訪問。甚至如果只關心hits.total,只返回doc id,關掉doc values。doc values支援keyword和數值型別。text型別還是會建立file data。

segments cache是為了加速查詢,FST永駐堆內記憶體。FST可以理解為字首樹,加速查詢。but!!es 7.3版本開始把FST交給了堆外記憶體,可以讓節點支援更多的資料。FST在磁碟上也有對應的持久化檔案。

off heap 即Segments Memory,堆外記憶體是給Lucene使用的。 所以建議至少留一半的記憶體給lucene。

es 7.3版本開始把tip(terms index)透過mmp方式載入,交由系統的pagecache管理。除了tip,nvd(norms),dvd(doc values), tim(term dictionary),cfs(compound)型別的檔案都是由mmp方式載入傳輸,其餘都是nio方式。tip off heap後的效果jvm佔用量下降了78%左右。可以使用_cat/segments API 檢視 segments.memory記憶體佔用量。

由於對外記憶體是由作業系統pagecache管理記憶體的。如果發生回收時,FST的查詢會牽扯到磁碟IO上,對查詢效率影響比較大。可以參考linux pagecache的回收策略使用雙鏈策略。

持久化

es的持久化分為兩部分,一部分類似快照,把檔案快取中的segments 重新整理(fsync)磁碟。另一部分是translog日誌,它每秒都會追加操作日誌,預設30分鐘刷到磁碟上。es持久化和redis的RDB+AOF模式很像。如下圖

圖3

上圖是一個完整寫入流程。磁碟也是分segment記錄資料。這裡濡染跟redis很像。但是內部機制沒有采用COW(copy-on-write)。這也是查詢和寫入並行時load被打滿的原因所在。

小結

es記憶體和磁碟的設計上非常巧妙。零複製上採用mmap方式,磁碟資料對映到off heap,也就是lucene。為了加速資料的訪問,es每個segment都有會一些索引資料駐留在off heap裡;因此segment越多,瓜分掉的off heap也越多,這部分是無法被GC回收!

結合以上兩點可以清楚知道為什麼es非常吃記憶體了。

二、應用

使用者畫像系統中有以下難點需要解決。

1.人群預估:根據標籤選出一類人群,如20-25歲的喜歡電商社交的男性。20-25歲∩電商社交∩男性。透過與或非的運算選出符合特徵的clientId的個數。這是一組。

我們組與組之前也是可以在做交併差的運算。如既是20-25歲的喜歡電商社交的男性,又是北京市喜歡擼鐵的男性。(20-25歲∩電商社交∩男性)∩(20-25歲∩擼鐵∩男性)。對於這樣的遞迴要求在17億多的畫像庫中,秒級返回預估人數。

2.人群包圈選:上述圈選出的人群包。 要求分鐘級構建。

3.人包判定:判斷一個clientId是否存在若干個人群包中。要求10毫秒返回結果。

我們先嚐試用es來解決以上所有問題。

人群預估,最容易想到方案是在服務端的記憶體中做邏輯運算。但是圈選出千萬級的人群包人數秒級返回的話在服務端做代價非常大。這時候可以吧計算壓力拋給es儲存端,像查詢資料庫一樣。使用一條語句查出我們想要的資料來。

例如mysql

select a.age from a where a.tel in (select b.age from b);

對應的es的dsl類似於

{"query":{"bool":{"must":[{"bool":{"must":[{"term":{"a9aa8uk0":{"value":"age18-24","boost":1.0}}},{"term":{"a9ajq480":{"value":"male","boost":1.0}}}],"adjust_pure_negative":true,"boost":1.0}},{"bool":{"adjust_pure_negative":true,"boost":1.0}}],"adjust_pure_negative":true,"boost":1.0}}}

這樣使用es的高檢索效能來滿足業務需求。無論所少組,組內多少的標籤。都打成一條dsl語句。來保證秒級返回結果。

使用官方推薦的RestHighLevelClient,實現方式有三種,一種是拼json字串,第二種呼叫api去拼字串。我使用第三種方式BoolQueryBuilder來實現,比較優雅。它提供了filter、must、should和mustNot方法。如

     /**
     * Adds a query that <b>must not</b> appear in the matching documents.
     * No {@code null} value allowed.
     */
    public BoolQueryBuilder mustNot(QueryBuilder queryBuilder) {
        if (queryBuilder == null) {
            throw new IllegalArgumentException("inner bool query clause cannot be null");
        }
        mustNotClauses.add(queryBuilder);
        return this;
    }

    /**
     * Gets the queries that <b>must not</b> appear in the matching documents.
     */
    public List<QueryBuilder> mustNot() {
        return this.mustNotClauses;
    }

使用api的可以大大的show下編程式碼的能力。

構建人群包。目前我們圈出最大的包有7千多萬的clientId。想要分鐘級別構建完(7千萬資料在條件限制下35分鐘構建完)需要注意兩個地方,一個是es深度查詢,另一個是批次寫入。

es分頁有三種方式,深度分頁有兩種,後兩種都是利用遊標(scroll和search_after)滾動的方式檢索。

scroll需要維護遊標狀態,每一個執行緒都會建立一個32位唯一scroll id,每次查詢都要帶上唯一的scroll id。如果多個執行緒就要維護多個遊標狀態。search_after與scroll方式相似。但是它的引數是無狀態的,始終會針對對新版本的搜尋器進行解析。它的排序順序會在滾動中更改。scroll原理是將doc id結果集保留在協調節點的上下文裡,每次滾動分批獲取。只需要根據size在每個shard內部按照順序取回結果即可。

寫入時使用執行緒池來做,注意使用的阻塞佇列的大小,還要選擇適的拒絕策略(這裡不需要拋異常的策略)。批次如果還是寫到es中(比如做了讀寫分離)寫入時除了要多執行緒外,還有最佳化寫入時的refresh policy。

人包判定介面,由於整條業務鏈路非常長,這塊檢索,上游服務設定的熔斷時間是10ms。所以最佳化要最佳化es的查詢(也可以redis)畢竟沒負責邏輯處理。使用執行緒池解決IO密集型最佳化後可以達到1ms。tp99高峰在4ms。

三、最佳化、瓶頸與解決方案

以上是針對業務需求使用es的解題方式。還需要做響應的最佳化。同時也遇到es的瓶頸。

1.首先是mapping的最佳化。畫像的mapping中fields中的type是keyword,index要關掉。人包中的fields中的doc value關掉。畫像是要精確匹配;人包判定只需要結果而不需要取值。es api上人包計算使用filter去掉評分,filter內部使用bitset的布隆資料結構,但是需要對資料預熱。寫入時執行緒不易過多,和核心數相同即可;調整refresh policy等級。手動刷盤,構建時index.refresh_interval 調整-1,需要注意的是停止刷盤會加大堆記憶體,需要結合業務調整刷盤頻率。構建大的人群包可以將index拆分成若干個。分散儲存可以提高響應。目前幾十個人群包還是能支撐。如果日後成長到幾百個的時候。就需要使用bitmap來構建儲存人群包。es對檢索效能很卓越。但是如遇到寫操作和查操作並行時,就不是他擅長的。比如人群包的資料是每天都在變化的。這個時候es的記憶體和磁碟io會非常高。上百個包時我們可以用redis來存。也可以選擇使用MongoDB來存人包資料。

四、總結

以上是我們使用Elasticsearch來解決業務上的難點。同時發現他的持久化沒有使用COW(copy-on-write)方式。導致在實時寫的時候檢索效能降低。

使用記憶體系統做資料來源有點非常明顯,就是檢索塊!尤其再實時場景下堪稱利器。同時痛點也很明顯,實時寫會拉低檢索效能。當然我們可以做讀寫分離,拆分index等方案。

除了Elasticsearch,我們還可以選用ClickHouse,ck也是支援bitmap資料結構。甚至可以上Pilosa,pilosa本就是BitMap Database。

參考

貝殼DMP平臺建設實踐

Mapping parameters | Elasticsearch Reference [7.10] | Elastic

Elasticsearch 7.3 的 offheap 原理

相關文章