天天看點

「資料庫」得物社群億級 Elasticsearch 資料搜尋性能調優實踐

作者:架構思考

1. 背景

2020 年以來内容标注結果搜尋就是社群中背景業務的核心高頻使用場景之一,為了支撐複雜的背景搜尋,我們将社群内容的關鍵資訊額外存了一份到 Elasticsearch 中作為二級索引使用。随着标注業務的細分、疊代和時間的推移,這個索引的文檔數和搜尋的 RT 開始逐漸上升。下面是這個索引目前的監控情況。

「資料庫」得物社群億級 Elasticsearch 資料搜尋性能調優實踐

本文介紹社群利用 IndexSorting,将億級文檔搜尋性能由最開始 2000ms 優化到 50ms 的過程。如果大家遇到相似的問題和場景,相信看完之後一定能夠一行代碼成噸收益。

2. 探索過程

2.1 初步優化

最開始需求很簡單,隻需要取最新釋出的動态分頁展示。這時候實作也是簡單粗暴,滿足功能即可。查詢語句如下:

{

"track_total_hits": true,

"sort": [

{

"publish_time": {

"order": "desc"

}

}

],

"size": 10

}
           

由于首頁加載時沒加任何篩選條件,于是變成了從億級内容庫中找出最新釋出的 10 條内容。

針對這個查詢很容易發現問題出現在大結果集的排序,要解決問題,自然的想到了兩條路徑:

  1. 去掉 sort
  2. 縮小結果集

經過使用者訴求和開發成本的權衡後,當時決定 “先扛住,再優化”:在使用者打開首頁的時候,預設增加 “釋出時間在最近一周内” 的篩選條件,這時語句變成了:

{

"track_total_hits": true,

"query": {

"bool": {

"filter": [

{

"range": {

"publish_time": {

"gte": 1678550400,

"lt": 1679155200

}

}

}

]

}

},

"sort": [

{

"publish_time": {

"order": "desc"

}

}

],

"size": 10

}
           

這個改動上線後,效果可以說是立竿見影,首頁加載速度立馬降到了 200ms 以内,平均 RT60ms。這次改動也為我們減小了來自業務的壓力,為後續的優化争取了不少調研的時間。

雖然搜尋首頁的加載速度明顯快了,但是并沒有實際解決根本問題 ——ES 大結果集指定字段排序還是很慢。對業務來說,結果頁上的一些邊界功能的體驗依舊不能盡如人意,比如導出、全量動态的搜尋等等。這一點從監控上也能夠較明顯的看出:慢查詢還是存在,并且還伴随着少量的接口逾時。

「資料庫」得物社群億級 Elasticsearch 資料搜尋性能調優實踐

老實說這個時期我們對于 ES 的了解還比較基礎,隻能說會用、知道分片、反向索引、相關性打分,然後就沒有了。總之我們有了方向,開始奮起直追。

2.2 細緻打磨

2.2.1 知識積累

帶着之前遺留的問題,我們開始開始重新出發,從頭學習 ES。要優化搜尋性能,首先我們要知道的是搜尋是怎麼做的。下面我們就以一個最簡單的搜尋為例,拆解一下整個搜尋請求的過程。

(1)搜尋請求

{

"track_total_hits":false,

"query": {

"bool": {

"filter": [

{

"term": {

"category_id.keyword": "xxxxxxxx"

}

}

]

}

},

"size": 10

}
           
精确查詢 category_id 為 "xxxxxxxx" 的文檔,取 10 條資料,不需要排序,不需要總數

總流程分 3 步:

  1. 用戶端發起請求到 Node1
  2. Node1 作為協調節點,将請求轉發到索引的每個主分片或副分片中,每個分片在本地執行查詢。
  3. 每個節點傳回各自的資料,協調節點彙總後傳回給用戶端

如圖可以大緻描繪這個過程:

「資料庫」得物社群億級 Elasticsearch 資料搜尋性能調優實踐

我們知道 ES 是依賴 Lucene 提供的能力,真正的搜尋發生在 Lucene 中,還需要繼續了解 Lucene 中的搜尋過程。

(2)Lucene

Lucene 中包含了四種基本資料類型,分别是:

  • Index:索引,由很多的 Document 組成。
  • Document:由很多的 Field 組成,是 Index 和 Search 的最小機關。
  • Field:由很多的 Term 組成,包括 Field Name 和 Field Value。
  • Term:由很多的位元組組成。一般将 Text 類型的 Field Value 分詞之後的每個最小單元叫做 Term。

在介紹 Lucene index 的搜尋過程之前,這裡先說一下組成 Lucene index 的最小資料存儲單元 ——Segment。

Lucene index 由許許多多的 Segment 組成,每一個 Segment 裡面包含着文檔的 Term 字典、Term 字典的倒排表、文檔的列式存儲 DocValues 以及正排索引。它能夠獨立的直接對外提供搜尋功能,幾乎是一個縮小版的 Lucene index。

(3)Term 字典和倒排表

「資料庫」得物社群億級 Elasticsearch 資料搜尋性能調優實踐

上圖是 Term 字典和其倒排表的大緻樣子當然這裡還有些重要資料結構,比如:

  • FST:term 索引,在記憶體中建構。可以快速實作單 Term、Term 範圍、Term 字首和通配符查詢。
  • BKD-Tree:用于數值類型 (包括空間點) 的快速查找。
  • SkipList:倒排表的資料結構

這裡面的細節比較多,感興趣的可以單獨了解,這裡不影響我們的整體搜尋流程,不過多贅述。有了 Term 字典和倒排表我們就能直接拿到搜尋條件比對的結果集了,接下來隻需要通過 docID 去正排索引中取回整個 doc 然後傳回就完事兒了。這是 ES 的基本盤理論上不會慢,我們猜測慢查詢發生在排序上。那給請求加一個排序會發生什麼呢?比如:

{

"track_total_hits":false,

"query": {

"bool": {

"filter": [

{

"term": {

"category_id.keyword": "xxxxxxxx"

}

}

]

}

},

"sort": [

{

"publish_time": {

"order": "desc"

}

}

],

"size": 10

}
           

通過倒排表拿到的 docId 是無序的,現在指定了排序字段,最簡單直接的辦法是全部取出來,然後排序取前 10 條。這樣固然能實作效果,但是效率卻是可想而知。那麼 Lucene 是怎麼解決的呢?

(4)DocValues

反向索引能夠解決從詞到文檔的快速映射,但需要對檢索結果進行分類、排序、數學計算等聚合操作時需要文檔号到值的快速映射。而正排索引又過于臃腫龐大,怎麼辦呢?

這時候各位大佬可能就直接想到了列式存儲,沒有錯,Lucene 就引入了基于 docId 的列式存儲結構 ——DocValues

文檔号 列值 列值映射
2023-01-13 2
1 2023-01-12 1
2 2023-03-13 3

比如上表中的 DocValues=[2023-01-13, 2023-01-12,2023-03-13]

如果列值是字元串,Lucene 會把原來的字元串值按照字典排序生成數字 ID,這樣的預處理能進一步加快排序速度。于是我們得到了 DocValues=[2, 1, 3]

Docvalues 的列式存儲形式可以加快我們的周遊的速度。到這裡一個正常的搜尋取前 N 條記錄的請求算是真正的拆解完成。這裡不讨論詞頻、相關性打分、聚合等功能的分析,是以本文對整個過程和資料結構做了大幅簡化。如果對這部分感興趣,歡迎一起讨論。

此時排序慢的問題也逐漸浮出了水面:盡管 Docvalues 又是列式存儲,又是将複雜值預處理為簡單值避免了查詢時的複雜比較,但是依舊架不住我們需要排序的資料集過大。

看起來 ES 盡力了,它好像确實不擅長解決我們這個場景的慢查詢問題。

不過有靈性的各位讀者肯定想到了,如果能把倒排表按照我們預先指定的順序存儲好,就能省下整個排序的時間。

2.2.2 IndexSorting

很快 ES 官方文檔《How to tune for search speed》中提到了一個搜尋優化手段 —— 索引排序 (Index Sorting) 出現在了我們的視野中。

從文檔上的描述我們可以知道,索引排序對于搜尋性能的提升主要在兩個方面:

  1. 對于多條件并列查詢(a and b and ...),索引排序可以幫助我們把不符合條件的文檔存在一起,跳過大量的不比對的文檔。但是此技巧僅适用于經常用于篩選的低基數字段。
  2. 提前中斷:當搜尋排序和索引排序指定的順序一樣時,隻需要比較每個段的前 N 個文檔,其他的文檔僅需要用于總數計算。比如:我們的文檔中有一個時間戳,而我們經常需要按照時間戳來搜尋和排序,這時候如果指定的索引排序和搜尋排序一緻,通常能夠極大的提高搜尋排序的效率。

提前中斷!!!簡直是缺什麼來什麼,于是我們開始圍繞這一點展開調研。

(1)開啟索引排序

{

"settings": {

"index": {

"sort.field": "publish_time", // 可指定多個字段

"sort.order": "desc"

}

},

"mappings": {

"properties": {

"content_id": {

"type": "long"

},

"publish_time": {

"type": "long"

},

...

}

}

}
           

如上面的例子,文檔在寫入磁盤時會按照 publish_time 字段的遞減序進行排序。

在前面的段落中我們反複提到了 docID 和正排索引。這裡我們順帶簡單介紹下他們的關系,首先 Segment 中的每個文檔,都會被配置設定一個 docID,docID 從 0 開始,順序配置設定。在沒有 IndexSorting 時,docID 是按照文檔寫入的順序進行配置設定的,在設定了 IndexSorting 之後,docID 的順序就與 IndexSorting 的順序一緻。

下圖描述了 docID 和正排索引的關系:

「資料庫」得物社群億級 Elasticsearch 資料搜尋性能調優實踐

那麼再次回頭來看看我們最開始的查詢:

{

"track_total_hits":true,

"sort": [

{

"publish_time": {

"order": "desc"

}

}

],

"size": 10

}
           

在 Lucene 中進行查詢時,發現結果集的倒排表順序剛好是 publish_time 降序排序的,是以查詢到前 10 條資料之後即可傳回,這就做到了提前中斷,省下了排序開銷。那麼代價是什麼呢?

(2)代價

IndexSorting 和查詢時排序不一樣,本質是在寫入時對資料進行預處理。是以排序字段隻能在建立時指定且不可更改。并且由于寫入時要對資料進行排序,是以也會對寫入性能也會有一定負面影響。

之前我們提到了 Lucene 本身對排序也有各種優化,是以如果搜尋結果集本身沒有那麼多的資料,那麼就算不開啟這個功能,也能有不錯的 RT。

另外由于多數時候還是要計算總數,是以開啟索引排序之後隻能提前中斷排序過程,還是要對結果集的總數進行 count。如果能夠不查總數,或者說通過另外的方式擷取總數,那麼能夠更好的利用這個特性。

小結:

  1. 針對大結果集的排序取前 N 條的場景下,索引排序能顯著提高搜尋性能。
  2. 索引排序隻能在建立索引時指定,不可更改。如果你有多個指定字段排序的場景,可能需要慎重選擇排序字段。
  3. 不擷取總數能更好的利用索引排序。
  4. 開啟索引排序會一定程度降低寫性能。 這裡貼一條 ElaticsearchBenchmarks 的資料截圖供大家參考。
「資料庫」得物社群億級 Elasticsearch 資料搜尋性能調優實踐

見:Elasticsearch Benchmarks

2.3 效果

由于我們的業務遠遠沒有達到 ES 的寫入瓶頸,而且也少有頻繁變更排序字段的場景。在經過短暫的權衡之後,确定索引排序正是我們需要的,于是開始使用線上真實資料對索引排序的效果進行簡單的性能測試。

(1)性能測試:首頁

「資料庫」得物社群億級 Elasticsearch 資料搜尋性能調優實踐

(2)性能測試:其他

這裡開啟索引排序後,随機幾個正常條件和時間視窗的搜尋組合測試
「資料庫」得物社群億級 Elasticsearch 資料搜尋性能調優實踐

可以看到效果非常明顯,沒有以前的那種尖刺,RT 也很穩定,于是我們決定正式上線這個功能。

(3)線上效果

慢查詢

「資料庫」得物社群億級 Elasticsearch 資料搜尋性能調優實踐

!

整體前後對比

「資料庫」得物社群億級 Elasticsearch 資料搜尋性能調優實踐

和我們預期的基本一樣,搜尋 RT 大幅降低,慢查詢完全消失。

2.4 後續優化

在探索過程中,其實還發現了一些其他的優化手段,鑒于開發成本和收益,有些我們并沒有完全應用于生産環境。這裡列出其中幾點,希望能給大家一些啟發。

  1. 不擷取總數:大部分場景下,不查詢總數都能減少開銷,提高性能。ES 7.x 之後的搜尋接口預設不傳回總數了,由此可見一斑。
  2. 自定義 routing 規則:從上文的查詢過程我們可以看到,ES 會輪詢所有分片以擷取想要的資料,如果我們能控制資料的分片落點,那麼也能節省不少開銷。比如說:如果我們将來如果有大量的場景都是查某個使用者的動态,那麼可以控制按照使用者分片,這樣就避免了分片輪詢,也能提升搜尋效率。
  3. keyword: 不是所有的數字都應該按照數值字段來存,如果你的數字值很少用于範圍查詢,但是經常被用作 term 查詢,并且對搜尋 rt 很敏感。那麼 keyword 才是最适合的存儲方式。
  4. 資料預處理:就像 IndexSoting 一樣,如果我們能夠在寫入時預處理好資料,也能節省搜尋時的開銷。這一點配合_ingest/pipeline 也許能發揮意想不到的效果。

3. 寫在最後

相信看到這裡的大家都能看出,我們的優化中也沒有涉及到十分高深的技術難點,我們隻是在解決問題的過程中,逐漸從小白轉變成了一個初學者。來一個大牛也許從一開始就能直接繞過我們的彎路,不過萬裡之行始于足下,最後這裡總結一點經驗和感受分享給大家,希望能給與我們一樣的初學者一些參考。

ES 在大結果集指定字段排序的場景下性能不佳,我們使用時應該盡量避免出現這種場景。如果無法避免,合适的 IndexSorting 設定能大幅提升排序性能。

優化永無止境,權衡好成本和收益,集中資源解決最優先和重要的問題才是我們應該做的。

文章來源:得物技術公衆号

繼續閱讀