天天看點

得物社群億級ES資料搜尋性能調優實踐

作者:閃念基因

1

背景

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

下面是這個索引目前的監控情況。

得物社群億級ES資料搜尋性能調優實踐

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

2

探索過程

2.1 初步優化

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

GET /content-alias/_search
{
  "track_total_hits": true,
  "sort": [
    {
      "publish_time": {
        "order": "desc"
      }
    }
  ],
  "size": 10
}           

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

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

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

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

GET /content-alias/_search
{
  "track_total_hits": true,
  "query": {
    "bool": {
      "filter": [
        {
          "range": {
            "publish_time": {
              "gte": 1678550400,
              "lt": 1679155200
            }
          }
        }
      ]
    }
  },
  "sort": [
    {
      "publish_time": {
        "order": "desc"
      }
    }
  ],
  "size": 10
}           

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

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

得物社群億級ES資料搜尋性能調優實踐

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

2.2 細緻打磨

2.2.1 知識積累

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

(1)搜尋請求

GET /content-alias/_search
{
  "track_total_hits":false,
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "category_id.keyword": "xxxxxxxx"
          }
        }
      ]
    }
  }, 
  "size": 10
}           
精确查詢category_id為"xxxxxxxx"的文檔,取10條資料,不需要排序,不需要總數

總流程分3步:

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

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

得物社群億級ES資料搜尋性能調優實踐

我們知道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字典和倒排表

得物社群億級ES資料搜尋性能調優實踐

上圖是Term字典和其倒排表的大緻樣子

當然這裡還有些重要資料結構,比如:

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

這裡面的細節比較多,感興趣的可以單獨了解,這裡不影響我們的整體搜尋流程,不過多贅述。

有了Term字典和倒排表我們就能直接拿到搜尋條件比對的結果集了,接下來隻需要通過docID去正排索引中取回整個doc然後傳回就完事兒了。

這是ES的基本盤理論上不會慢,我們猜測慢查詢發生在排序上。那給請求加一個排序會發生什麼呢?比如:

GET /content-alias/_search
{
  "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)開啟索引排序

PUT /content
{
    "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和正排索引的關系:

得物社群億級ES資料搜尋性能調優實踐

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

GET /content-alias/_search
{
  "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的資料截圖供大家參考。
得物社群億級ES資料搜尋性能調優實踐

見:Elasticsearch Benchmarks

2.3 效果

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

(1)性能測試:首頁

得物社群億級ES資料搜尋性能調優實踐

(2)性能測試:其他

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

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

(3)線上效果

慢查詢

得物社群億級ES資料搜尋性能調優實踐

整體前後對比

得物社群億級ES資料搜尋性能調優實踐

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

2.4 後續優化

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

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

3

寫在最後

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

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

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

作者:海帶

來源:微信公衆号:得物技術

出處:https://mp.weixin.qq.com/s/K8yaJzjwF8h-5hFUXwcFow

繼續閱讀