1
背景
2020年以來內(nèi)容標(biāo)注結(jié)果搜索就是社區(qū)中后臺(tái)業(yè)務(wù)的核心高頻使用場(chǎng)景之一,為了支撐復(fù)雜的后臺(tái)搜索,我們將社區(qū)內(nèi)容的關(guān)鍵信息額外存了一份到Elasticsearch中作為二級(jí)索引使用。隨著標(biāo)注業(yè)務(wù)的細(xì)分、迭代和時(shí)間的推移,這個(gè)索引的文檔數(shù)和搜索的RT開始逐步上升。
下面是這個(gè)索引當(dāng)前的監(jiān)控情況。
本文介紹社區(qū) 利用IndexSorting,將億級(jí)文檔搜索性能由最開始2000ms優(yōu)化到50ms的過程。如果大家遇到相似的問題和場(chǎng)景,相信看完之后一定能夠一行代碼成噸收益。
2
探索過程
2.1 初步優(yōu)化
最開始需求很簡(jiǎn)單,只需要 取最新發(fā)布的動(dòng)態(tài)分頁展示。這時(shí)候?qū)崿F(xiàn)也是簡(jiǎn)單粗暴,滿足功能即可。查詢語句如下:
GET /content-alias/_search { "track_total_hits": true, "sort": [ { "publish_time": { "order": "desc" } } ], "size": 10 }由于首頁加載時(shí)沒加任何篩選條件,于是變成了 從億級(jí)內(nèi)容庫中找出最新發(fā)布的10條內(nèi)容。
針對(duì)這個(gè)查詢很容易發(fā)現(xiàn)問題出現(xiàn)在大結(jié)果集的排序,要解決問題,自然的想到了兩條路徑:
- 去掉sort
- 縮小結(jié)果集
經(jīng)過用戶訴求和開發(fā)成本的權(quán)衡后,當(dāng)時(shí)決定“先扛住,再優(yōu)化”:在用戶打開首頁的時(shí)候,默認(rèn)增加“發(fā)布時(shí)間在最近一周內(nèi)”的篩選條件,這時(shí)語句變成了:
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 }這個(gè)改動(dòng)上線后,效果可以說是立竿見影, 首頁加載速度立馬降到了200ms以內(nèi),平均RT60ms。這次改動(dòng)也為我們減小了來自業(yè)務(wù)的壓力,為后續(xù)的優(yōu)化爭(zhēng)取了不少調(diào)研的時(shí)間。
雖然搜索首頁的加載速度明顯快了,但是并沒有實(shí)際解決根本問題—— ES大結(jié)果集指定字段排序還是很慢。對(duì)業(yè)務(wù)來說,結(jié)果頁上的一些邊界功能的體驗(yàn)依舊不能盡如人意,比如導(dǎo)出、全量動(dòng)態(tài)的搜索等等。這一點(diǎn)從監(jiān)控上也能夠較明顯的看出:慢查詢還是存在,并且還伴隨著少量的接口超時(shí)。
老實(shí)說這個(gè)時(shí)期我們對(duì)于ES的了解還比較基礎(chǔ),只能說會(huì)用、知道分片、倒排索引、相關(guān)性打分,然后就沒有了。總之我們有了方向,開始奮起直追。
2.2 細(xì)致打磨2.2.1 知識(shí)積累
帶著之前遺留的問題,我們開始開始重新出發(fā),從頭學(xué)習(xí)ES。要優(yōu)化搜索性能,首先我們要知道的是搜索是怎么做的。下面我們就以一個(gè)最簡(jiǎn)單的搜索為例,拆解一下整個(gè)搜索請(qǐng)求的過程。
(1)搜索請(qǐng)求
GET /content-alias/_search { "track_total_hits":false, "query": { "bool": { "filter": [ { "term": { "category_id.keyword": "xxxxxxxx" } } ] } }, "size": 10 }精確查詢category_id為"xxxxxxxx"的文檔,取10條數(shù)據(jù),不需要排序,不需要總數(shù)
總流程分3步:
- 客戶端發(fā)起請(qǐng)求到Node1
- Node1作為協(xié)調(diào)節(jié)點(diǎn),將請(qǐng)求轉(zhuǎn)發(fā)到索引的每個(gè)主分片或副分片中,每個(gè)分片在本地執(zhí)行查詢。
- 每個(gè)節(jié)點(diǎn)返回各自的數(shù)據(jù),協(xié)調(diào)節(jié)點(diǎn)匯總后返回給客戶端
如圖可以大致描繪這個(gè)過程:
我們知道ES是依賴Lucene提供的能力,真正的搜索發(fā)生在Lucene中,還需要繼續(xù)了解Lucene中的搜索過程。
(2)Lucene
Lucene中包含了四種基本數(shù)據(jù)類型,分別是:
- Index:索引,由很多的Document組成。
- Document:由很多的Field組成,是Index和Search的最小單位。
- Field:由很多的Term組成,包括Field Name和Field Value。
- Term:由很多的字節(jié)組成。一般將Text類型的Field Value分詞之后的每個(gè)最小單元叫做Term。
在介紹Lucene index的搜索過程之前,這里先說一下組成Lucene index的最小數(shù)據(jù)存儲(chǔ)單元——Segment。
Lucene index由許許多多的Segment組成,每一個(gè)Segment里面包含著文檔的Term字典、Term字典的倒排表、文檔的列式存儲(chǔ)DocValues以及正排索引。它能夠獨(dú)立的直接對(duì)外提供搜索功能,幾乎是一個(gè)縮小版的Lucene index。
(3)Term字典和倒排表
上圖是Term字典和其倒排表的大致樣子
當(dāng)然這里還有些重要數(shù)據(jù)結(jié)構(gòu),比如:
-
FST:term索引,在內(nèi)存中構(gòu)建。可以快速實(shí)現(xiàn)單Term、Term范圍、Term前綴和通配符查詢。
-
BKD-Tree:用于數(shù)值類型(包括空間點(diǎn))的快速查找。
-
SkipList:倒排表的數(shù)據(jù)結(jié)構(gòu)
這里面的細(xì)節(jié)比較多,感興趣的可以單獨(dú)了解,這里不影響我們的整體搜索流程,不過多贅述。
有了Term字典和倒排表我們就能直接拿到搜索條件匹配的結(jié)果集了,接下來只需要通過docID去正排索引中取回整個(gè)doc然后返回就完事兒了。
這是ES的基本盤理論上不會(huì)慢,我們猜測(cè)慢查詢發(fā)生在排序上。那給請(qǐng)求加一個(gè)排序會(huì)發(fā)生什么呢?比如:
GET /content-alias/_search { "track_total_hits":false, "query": { "bool": { "filter": [ { "term": { "category_id.keyword": "xxxxxxxx" } } ] } }, "sort": [ { "publish_time": { "order": "desc" } } ], "size": 10 }通過倒排表拿到的docId是無序的,現(xiàn)在指定了排序字段,最簡(jiǎn)單直接的辦法是全部取出來,然后排序取前10條。這樣固然能實(shí)現(xiàn)效果,但是效率卻是可想而知。那么Lucene是怎么解決的呢?
(4)DocValues
倒排索引能夠解決從詞到文檔的快速映射,但需要對(duì)檢索結(jié)果進(jìn)行分類、排序、數(shù)學(xué)計(jì)算等聚合操作時(shí)需要文檔號(hào)到值的快速映射。而正排索引又過于臃腫龐大,怎么辦呢?
這時(shí)候各位大佬可能就直接想到了列式存儲(chǔ),沒有錯(cuò),Lucene就引入了基于docId的列式存儲(chǔ)結(jié)構(gòu)——DocValues
文檔號(hào) |
列值 |
列值映射 |
0 |
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會(huì)把原來的字符串值按照字典排序生成數(shù)字ID,這樣的預(yù)處理能進(jìn)一步加快排序速度。于是我們得到了DocValues=[2, 1, 3]
Docvalues的列式存儲(chǔ)形式可以加快我們的遍歷的速度。到這里一個(gè)常規(guī)的搜索取前N條記錄的請(qǐng)求算是真正的拆解完成。這里不討論詞頻、相關(guān)性打分、聚合等功能的分析,所以本文對(duì)整個(gè)過程和數(shù)據(jù)結(jié)構(gòu)做了大幅簡(jiǎn)化。如果對(duì)這部分感興趣,歡迎一起討論。
此時(shí)排序慢的問題也逐漸浮出了水面:盡管Docvalues又是列式存儲(chǔ),又是將復(fù)雜值預(yù)處理為簡(jiǎn)單值避免了查詢時(shí)的復(fù)雜比較,但是依舊架不住我們需要排序的數(shù)據(jù)集過大。
看起來ES盡力了,它好像確實(shí)不擅長(zhǎng)解決我們這個(gè)場(chǎng)景的慢查詢問題。
不過有靈性的各位讀者肯定想到了, 如果能把倒排表按照我們預(yù)先指定的順序存儲(chǔ)好,就能省下整個(gè)排序的時(shí)間。
2.2.2 IndexSorting
很快ES官方文檔《How to tune for search speed》中提到了一個(gè)搜索優(yōu)化手段——索引排序(Index Sorting)出現(xiàn)在了我們的視野中。
從文檔上的描述我們可以知道,索引排序?qū)τ谒阉餍阅艿奶嵘饕趦蓚€(gè)方面:
- 對(duì)于多條件并列查詢(a and b and ...),索引排序可以幫助我們把不符合條件的文檔存在一起,跳過大量的不匹配的文檔。但是此技巧僅適用于經(jīng)常用于篩選的低基數(shù)字段。
- 提前中斷:當(dāng)搜索排序和索引排序指定的順序一樣時(shí),只需要比較每個(gè)段的前 N 個(gè)文檔,其他的文檔僅需要用于總數(shù)計(jì)算。比如:我們的文檔中有一個(gè)時(shí)間戳,而我們經(jīng)常需要按照時(shí)間戳來搜索和排序,這時(shí)候如果指定的索引排序和搜索排序一致,通常能夠極大的提高搜索排序的效率。
提前中斷!!!簡(jiǎn)直是缺什么來什么,于是我們開始圍繞這一點(diǎn)展開調(diào)研。
(1)開啟索引排序
PUT /content { "settings": { "index": { "sort.field": "publish_time", // 可指定多個(gè)字段 "sort.order": "desc" } }, "mAppings": { "properties": { "content_id": { "type": "long" }, "publish_time": { "type": "long" }, ... } } }如上面的例子,文檔在寫入磁盤時(shí)會(huì)按照 publish_time 字段的遞減序進(jìn)行排序。
在前面的段落中我們反復(fù)提到了docID和正排索引。這里我們順帶簡(jiǎn)單介紹下他們的關(guān)系,首先Segment中的每個(gè)文檔,都會(huì)被分配一個(gè)docID,docID從0開始,順序分配。在沒有IndexSorting時(shí),docID是按照文檔寫入的順序進(jìn)行分配的,在設(shè)置了IndexSorting之后,docID的順序就與IndexSorting的順序一致。
下圖描述了docID和正排索引的關(guān)系:
那么再次回頭來看看我們最開始的查詢:
GET /content-alias/_search { "track_total_hits":true, "sort": [ { "publish_time": { "order": "desc" } } ], "size": 10 }在Lucene中進(jìn)行查詢時(shí),發(fā)現(xiàn)結(jié)果集的倒排表順序剛好是publish_time降序排序的,所以查詢到前10條數(shù)據(jù)之后即可返回,這就做到了提前中斷,省下了排序開銷。那么代價(jià)是什么呢?
(2)代價(jià)
IndexSorting和查詢時(shí)排序不一樣,本質(zhì)是在寫入時(shí)對(duì)數(shù)據(jù)進(jìn)行預(yù)處理。所以排序字段只能在創(chuàng)建時(shí)指定且不可更改。并且由于寫入時(shí)要對(duì)數(shù)據(jù)進(jìn)行排序,所以也會(huì)對(duì)寫入性能也會(huì)有一定負(fù)面影響。
之前我們提到了Lucene本身對(duì)排序也有各種優(yōu)化,所以如果搜索結(jié)果集本身沒有那么多的數(shù)據(jù),那么就算不開啟這個(gè)功能,也能有不錯(cuò)的RT。
另外由于多數(shù)時(shí)候還是要計(jì)算總數(shù),所以開啟索引排序之后只能提前中斷排序過程,還是要對(duì)結(jié)果集的總數(shù)進(jìn)行count。如果能夠不查總數(shù),或者說通過另外的方式獲取總數(shù),那么能夠更好的利用這個(gè)特性。
小結(jié):
- 針對(duì)大結(jié)果集的排序取前N條的場(chǎng)景下,索引排序能顯著提高搜索性能 。
- 索引排序只能在創(chuàng)建索引時(shí)指定,不可更改 。如果你有多個(gè)指定字段排序的場(chǎng)景,可能需要慎重選擇排序字段。
- 不獲取總數(shù)能更好的利用索引排序 。
- 開啟索引排序會(huì)一定程度降低寫性能。 這里貼一條ElaticsearchBenchmarks的數(shù)據(jù)截圖供大家參考。
見:Elasticsearch Benchmarks
2.3 效果
由于我們的業(yè)務(wù)遠(yuǎn)遠(yuǎn)沒有達(dá)到ES的寫入瓶頸,而且也少有頻繁變更排序字段的場(chǎng)景。在經(jīng)過短暫的權(quán)衡之后,確定索引排序正是我們需要的,于是開始使用線上真實(shí)數(shù)據(jù)對(duì)索引排序的效果進(jìn)行簡(jiǎn)單的性能測(cè)試。
(1)性能測(cè)試:首頁
(2)性能測(cè)試:其他
這里開啟索引排序后,隨機(jī)幾個(gè)常規(guī)條件和時(shí)間窗口的搜索組合測(cè)試
可以看到效果非常明顯,沒有以前的那種尖刺,RT也很穩(wěn)定,于是我們決定正式上線這個(gè)功能。
(3)線上效果
慢查詢
整體前后對(duì)比
和我們預(yù)期的基本一樣, 搜索RT大幅降低,慢查詢完全消失。
2.4 后續(xù)優(yōu)化
在探索過程中,其實(shí)還發(fā)現(xiàn)了一些其他的優(yōu)化手段,鑒于開發(fā)成本和收益,有些我們并沒有完全應(yīng)用于生產(chǎn)環(huán)境。這里列出其中幾點(diǎn),希望能給大家一些啟發(fā)。
- 不獲取總數(shù): 大部分場(chǎng)景下,不查詢總數(shù)都能減少開銷,提高性能。ES 7.x之后的搜索接口默認(rèn)不返回總數(shù)了,由此可見一斑。
- 自定義routing規(guī)則: 從上文的查詢過程我們可以看到,ES會(huì)輪詢所有分片以獲取想要的數(shù)據(jù),如果我們能控制數(shù)據(jù)的分片落點(diǎn),那么也能節(jié)省不少開銷。比如說:如果我們將來如果有大量的場(chǎng)景都是查某個(gè)用戶的動(dòng)態(tài),那么可以控制按照用戶分片,這樣就避免了分片輪詢,也能提升搜索效率。
- keyword: 不是所有的數(shù)字都應(yīng)該按照數(shù)值字段來存,如果你的數(shù)字值很少用于范圍查詢,但是經(jīng)常被用作term查詢,并且對(duì)搜索rt很敏感。那么keyword才是最適合的存儲(chǔ)方式。
- 數(shù)據(jù)預(yù)處理:就像IndexSoting一樣,如果我們能夠在寫入時(shí)預(yù)處理好數(shù)據(jù),也能節(jié)省搜索時(shí)的開銷。這一點(diǎn)配合 _ingest/pipeline 也許能發(fā)揮意想不到的效果。
3
寫在最后
相信看到這里的大家都能看出,我們的優(yōu)化中也沒有涉及到十分高深的技術(shù)難點(diǎn),我們只是在解決問題的過程中,逐步從小白轉(zhuǎn)變成了一個(gè)初學(xué)者。來一個(gè)大牛也許從一開始就能直接繞過我們的彎路,不過萬里之行始于足下,最后這里總結(jié)一點(diǎn)經(jīng)驗(yàn)和感受分享給大家,希望能給與我們一樣的初學(xué)者一些參考。
ES在大結(jié)果集指定字段排序的場(chǎng)景下性能不佳,我們使用時(shí)應(yīng)該盡量避免出現(xiàn)這種場(chǎng)景。如果無法避免,合適的IndexSorting設(shè)置能大幅提升排序性能。
優(yōu)化永無止境,權(quán)衡好成本和收益,集中資源解決最優(yōu)先和重要的問題才是我們應(yīng)該做的。
END