原文地址:https://mp.weixin.qq.com/s/zsAjhhoOy__UlSf6i-ZMSA
1 背景Qunar 酒店的搜尋和 suggest 是基於 Lucene 構建的,在我們的使用場景中,由於召回和排序是作為兩個單獨的應用,當召回的文件數量比較多的時候,響應速度較慢,Young GC 也比較嚴重,導致併發量很難上去。經過分析我們發現,主要的問題是因為需要獲取大量文件的儲存欄位,造成反序列化比較多,所以影響速度,GC 也比較多。
為什麼獲取儲存欄位會有速度和 GC 的問題呢?
我們知道,Lucene 的 Stored Fields 在儲存的時候,會把文件的欄位按照某種形式編碼後儲存,並且會按塊進行壓縮。所以獲取儲存欄位的時候,先會對欄位所在的塊解壓縮,然後將對應的欄位值反序列化為 Java 物件,放到 StoredField 物件中,文件的所有欄位組裝成一個 Document 物件。
這裡頭對時間影響比較大的是解壓縮和反序列化,對 GC 影響大的是兩部分,一部分是反序列化會產生很多小的 Java 物件,另外是每個欄位都會建立一個 StoredField Java 物件。
壓縮的問題,可以透過選項禁用壓縮解決,其他的在現有的實現上就不好避免了。
那麼有沒有其他的選項呢?Doc Values 提供了另外一種儲存欄位的方法,它採用列式儲存,但其目的並不是為了替代 Stored Fields,Doc Values 適用於獲取大量文件的少數字段的情況,而 Stored Fields 適用於獲取少數文件的大量欄位的情況,Doc Values 通常用於排序、算分或者 Facet 聚合計算等場景。
儘管用 Doc Values 來儲存是比較接近我們的最佳化目標,但當欄位比較多的時候不太合適,而且 String 型別的數值需要以 binary 的形式儲存,編解碼次數多了也比較耗時,所以我們想,能不能自己實現欄位的儲存,把欄位 cache 到記憶體裡頭,每次訪問的時候,直接根據文件 ID 去獲取相應的欄位,這樣就基本上沒有序列化的開銷,也少建立很多物件,對於我們這種資料量不是特別大的情況來說,效果應該更好。基於這個想法,我們調研了一下 Lucene 提供的相關機制,證明這麼做是可行的,下面我們說一下 Lucene 提供的機制,以及我們怎麼利用這種機制去實現我們想要的功能。
2 Lucene 自定義 Codec 機制Lucene 內部透過 codec API 來讀寫索引檔案,codec 是 Lucene 的一個非常重要的抽象:它把索引資料結構的儲存和上層的建索引和搜尋的複雜邏輯隔離了開來,訪問索引的時候都是透過 codec API 來操作,這樣就允許我們實驗各種不同的索引儲存格式,而不會影響上層的搜尋和建索引的邏輯。
Codec 針對不同型別的索引資料定義了 10 種 Format,每種型別的 Format 又定義了讀寫的 API,其中讀的 API 在搜尋時使用,寫的 API 在建索引的時候使用,每個 Segment 可以設定自己單獨的 Codec。
Lucene 中的抽象類 org.apache.lucene.codec.Codec 定義了 Codec 的介面:
每個 codec 必須有一個唯一的名字,比如"Lucene80",codec 透過 Java 的 SPI(Service Provider Interface)機制進行註冊,所以只要知道了名字,就可以找到對應的 codec 例項,同時在建索引的過程中 codec 的名字也會寫入到每個 Segment 對應的索引元資料 SegmentInfos 中,所以 Lucene 能夠根據索引中的資訊找到對應的 codec。
Lucene 8 中有 10 種 Format,具體每種 Format 處理什麼型別的索引,我們這裡就不一一詳細列舉了,簡單說下其中幾個:
PostingsFormat 支援倒排索引的讀寫,倒排索引我們知道,是從 Term→{docId List}的一個索引,其中 docId List 就叫做 posting list。StoredFieldsFormat 支援儲存欄位的讀寫,Stored Fields Index 可以算作是一種正排索引(forward index)的儲存方式,透過 docId 可以直接獲取,Stored Fields 採用行式儲存,為了節省儲存,做了壓縮編碼。在建索引時,針對某個欄位如果指定 stored=true,會儲存到 StoredFields 索引檔案中。DocValuesFormat 支援 Doc Values 的讀寫,Doc Values 也是一種正排的儲存方式,是為了解決排序、算分、Facet 聚合等場景引入的一種列式儲存方式,當需要訪問大量文件的同一欄位時的效能提升比較明顯。我們要最佳化的就是 StoredFields 的訪問,其他部分不做修改,所以並不需要自定義所有的 Format,Lucene 提供了 FilterCodec 類,允許我們選擇性地改寫某個 Format 的實現,其他則 delegate 給預設的實現:
所以我們只需要選擇性地覆蓋 StoredFieldsFormat 的實現,其他的使用 Lucene80 Codec 預設的實現:
Lucene 提供了完善的單元測試,可以用來驗證縮寫的 Codec 功能是否正常,具體可以參考:build-your-own-lucene-codec
https://dzone.com/articles/build-your-own-lucene-codec
3 自定義 StoredFieldsFormat 實現我們希望將 Stored Fields 資料全載入到記憶體,儘量減少序列化和建立物件的開銷。要達成這個目標,實際上我們並不需要完全從頭開始定義自己的 Stored Fields 儲存格式,我們可以利用原來的索引儲存格式,只需要改寫讀索引的 StoredFieldsReader,將資料快取到記憶體中,建索引時使用的 StoredFieldsWriter 和磁碟儲存格式都可以保持不變,這樣是最簡單的。因為我們的整個架構是基於 Lucene NRT replication 構建的一個主從式的架構,所以我們在 Primary(master)建索引的時候,可以按照正常的方式建,在 Replica(slave)使用索引的時候,可以透過開關開啟 cache,整個的過程大概是這樣的:
Primary 節點在建索引的時候配 IndexWriterConfig,透過 IndexWriterConfig.setCodec 設定我們自定義的 codec,codec 的資訊會寫入索引的元資料中。Primary 端按正常方式建索引。Replica 節點載入 segment 資料的時候,會呼叫自定義的 codec,進而呼叫我們自定義的 StoredFieldsReader,自定義的 StoredFieldsReader 透過原有的 Lucene80Codec的Reader 讀入資料,快取到記憶體中(多個列式儲存的向量),後續所有訪問操作直接讀取記憶體中的資料。自定義的 Codec,StoredFieldsFormat 和 StoredFieldsReader 之間的關係如下圖所示:
其中 StoredFieldsFormat 的介面定義如下:
我們只需要在覆蓋 fieldsReader 方法,在其中初始化自定義的 MemoryStoredFieldsReader,傳入的引數有 Segment 和欄位相關的資訊,所以可以透過 delegate 的原始 StoredFieldsReader 讀取儲存欄位的資料(透過 visitDocument 方法訪問),並存儲到記憶體資料結構(記憶體資料結構我們下一節說明)中,因為 Lucene 中的 Segment 資料是不變的,所以一次性讀入就可以。
資料放到記憶體資料結構中之後,可以透過 StoredFieldsReader 的 visitDocument 介面訪問:
標準的 StoredFieldsVisitor 實現(比如 DocumentStoredFieldVisitor)有個問題,建立了太多的中間物件,比如每個欄位會建一個 StoredField 物件,String 型別的欄位需要先轉成 byte[],然後再轉成 String 等等,產生了很多不必要的中間物件,為了充分利用快取和減少中間轉化的代價,除支援標準介面外,我們自定義了 StoredFieldsVisitor,直接在記憶體資料結構的基礎上包裝了一個文件訪問的介面,並透過 StoredFieldsVistor 對外提供。
虛擬碼示例如下:
visitDocument 介面最終是被 IndexSearcher.doc(int docId, StoredFieldVisitor storedFieldsVistor)介面使用的,搜尋的時候返回 docId,獲取儲存欄位透過 Searcher 的 doc 介面。
4 記憶體儲存結構將資料 cache 到記憶體裡頭,一是為了解決序列化的速度問題,二是為了減少過多的中間物件,但是我們又不希望儲存過度膨脹,那樣我們就沒法在單個機器儲存所有的資料,因此,選擇合適的儲存結構非常重要。
一般來說,有兩種儲存的方法,一種是行式儲存,一種是列式儲存,Lucene 裡頭預設的 StoredFields 儲存是行式儲存,DocValues 是列式儲存。假設我們用行式儲存的方式,如果將文件序列化之後再儲存,從空間、時間和產生的中間物件上來看相較原始的儲存方式並沒有什麼優勢,如果以 Java Bean 的方式來儲存,速度上是最快的,產生的中間物件也比較少,但是儲存空間消耗非常大,主要是因為 Java 在儲存方面並不是很經濟:
因為很多欄位是允許多值的,所以我們需要採用陣列來儲存,陣列在 Java 裡頭,64位系統下光物件頭就要佔用24個位元組(啟用指標壓縮的情況下也得佔用16個位元組,如果超過堆記憶體大小超過32G,雖然也能對指標進行壓縮,但是會有額外的對齊的開銷);空值欄位也會消耗空間,比如一個 null 引用也會佔用64位,空的原生型別欄位也會佔用空間;物件對齊的開銷;複合物件引用的開銷。所以採用 Java Bean 的方式在儲存上代價有點高,不太能滿足要求。
而列式儲存的方式,將同一個欄位的放到連續的儲存中,可以減少陣列物件頭的開銷,訪問的時候,也只是增加了一些偏移量計算的開銷,在空間和時間上相對來說更適合。
我們透過一個例子來說明列式儲存怎麼實現,假設有四個文件,有一個別名欄位 hotelAliases:
其中 ID 為 5 的文件有兩個別名,ID 為 2 和 6 的文件沒有別名,採用列式儲存的方式可以用兩個陣列來表示,一個 value 陣列用來儲存別名,一個 offset 陣列用來指示文件值的起始和結束位置:
其中 offset 的下標為文件 ID,offset[docId+1] - offset[docId]表示值的個數,如果不為 0,表示有值,值在 value 陣列中的起始位置為 offset[docId]。
value 陣列如果是 String 型別的物件,我們可以透過對 String 做 intern 操作來去除重複,考慮到 intern 操作本身會使用一個 Map 型別的索引來做去重,如果維護一個全域性的索引的話,需要一直留著不能釋放,佔用記憶體較多,所以我們只在一個 Segment 內做 intern,因為 Segment 的資料是不變的,做完了之後,我們可以將 intern 使用的 Map 釋放掉,經過測試,這樣做可以節省空間,原因猜測是因為我們的資料重複的值比較集中,大都是一些低 cardinality(基數)的資料,而高 cardinality 的值則很少重複,儲存去重的索引反而佔用空間較多。
透過列式儲存的方式,可以將儲存消耗降低為 Java Bean 方式的 65%,訪問速度上,損失大概百分之十幾。
上面的編碼方式,空值是不佔 value 陣列儲存空間的,但是會佔用 offset 陣列的儲存空間,雖然看起來單個文件只佔用一個 int,但當存在很多不同型別的文件時,有些型別可能根本就不存在某個欄位,這樣就會存在大量空值,加起來浪費也比較嚴重,所以我們後來又在這個基礎的列式儲存上進一步做了最佳化,透過採用 succinct data structure 中的 rank/select 操作,用兩個 bit 陣列代替 int 陣列,這個最佳化能夠將儲存空間消耗進一步減少將近 20%(12G->10G)。關於這一塊,我們在將來的文章中再做介紹。
不同型別的資料,記憶體佔用會有區別,除了提供通用的 Object 型別的實現,我們也針對 Primitive Type 提供單獨的實現。
5 寫在最後本文所述的 Lucene Stored Fields 儲存最佳化,主要是對我們的特殊應用場景:資料量不是特別大,每次查詢返回文件數較多,做了針對性的最佳化,降低了生成的中間物件的數量,從我們的線上監控看,Young GC 頻次從原來的每秒 2-3 次,變成 9-10 秒發生一次,響應時間也降低了 80%多,儲存空間上面,透過採用緊湊的記憶體儲存格式,也較好地解決了空間消耗的問題,使得我們能夠將全量的儲存欄位資料載入到記憶體裡頭。
未來,我們還計劃在這個基礎上進一步做一些最佳化,比如:
嘗試堆外儲存,減少堆空間佔用,更好地利用指標壓縮(不過這樣會有字串編碼開銷,需要測試下);實現 Per-field 的儲存欄位 cache,只對必要的欄位做記憶體快取,減少總的記憶體佔用;