上周,在某多多搬磚的一位朋友在微信上找我咨詢,說他們公司一個ES叢集從2.4更新到5.5以後,一個很簡單的Query查詢耗時突然從幾十毫秒,變成800-1000毫秒,幾十倍的性能下降!原始問題連結:# Why my search slow?
這個查詢非常簡單,就是3個過濾條件求并集而已:
{
"from": 0,
"size": 10,
"query": {
"bool": {
"filter": [
{
"terms": {
"goods_id": [
"262628158"
],
"boost": 1.0
}
},
{
"terms": {
"status": [
"2",
"4"
],
"boost": 1.0
}
},
{
"range": {
"create_time": {
"from": "1514027649",
"to": "1514632449",
"include_lower": true,
"include_upper": true,
"boost": 1.0
}
}
}
],
"disable_coord": false,
"adjust_pure_negative": true,
"boost": 1.0
}
},
"sort": [
{
"create_time": {
"order": "desc"
}
}
]
}
通過profile檢視,發現耗時主要在status字段的
build_scorer
這個階段。
對方同時提到,隻要去掉
"status":["2", "4"]
這個查詢條件,速度就會恢複正常。進一步詢問後得知,查詢的索引文檔總量相當巨大,達到16億條,而status字段隻有幾個不同的數字,在mapping裡被定義為數值型
short
。
我的第一反應,status隻有幾個值,意味着該字段的filter得到的結果集是海量的。可能是處理這個大結果集的代價很高造成的緩慢,但是具體什麼原因我一時也說不上來。
腦子裡開始翻查ES 2.x -> 5.x更新對于數值類型和Term Query有何重大變化?想起來兩點:
- Lucene6.0引入了重新設計的數值類型的索引結構,不再采用倒排索,而是使用了更适合範圍查找的Block K-d Tree。 ES從5.0開始引入這種新結構。(參考: searching-numb3rs-in-5.0)
- Term Query由于通常非常快,從5.1.1開始不再被緩存到Query Cache
顯然這個status字段不用于範圍查找,字段類型設定上keyword比number更合理。 但我也沒想明白為何number在這場景下查詢會慢這麼多,是以我也稍稍有些懷疑2.x緩存了Term Query是造成性能差異的原因。 當時讓朋友做了個測試,将TermQuery換成RangeQuery,被告知速度飛快,隻要幾十個毫秒,并且多執行幾次後更是快到隻有幾個毫秒了。(因為RangeQuery反複執行會被Cache起來)。
隔天,朋友根據建議将status先改為keyword,重新索引資料後,查詢性能奇迹般的恢複到正常,是以基本可以确定和緩存無關了。
恰巧社群也有人在經曆同樣的問題: Elastic對類似枚舉資料的搜尋性能優化 ,看起來是個普遍現象,值得研究找出問題根源。
花了幾天的時間參閱技術文檔,也粗略讀了一下ES/Lucene相關代碼後,總算搞清楚了問題的來龍去脈。 本文将對相關技術細節做分析,然後回答下面3個問題:
- 為什麼ES5.x裡對數值型字段做TermQuery可能會很慢?
- 為何Profile裡顯示的耗時幾乎全部在
?
build_scorer
- 為什麼對同樣的數值型字段做RangeQuery卻又很快了?
為更好的了解這個問題,先談一下幾點預備知識:
- ES2.x和5.x的數值類型分别是如何索引的
- Block k-d tree的基本概念和Lucene實作
- Queries/filters執行的先後順序及結果合并是怎樣做的
ES2.x和5.x的數值類型分别是如何索引的
ES5.x之前用到的Lucene版本,實際上隻能夠索引文本類型的資料,表面上被定義為數值類型的字段,在暗地裡都被轉換成了字元串,編排成了反向索引。例如:
Term | Postings List |
---|---|
2 | [doc3, doc5, doc10 ...] |
5 | [doc1, doc3, doc9 ... ] |
... | ... |
90 | [doc2, doc3, doc8 ...] |
99 | [doc3, doc5, doc20 ...] |
... | ... |
這種結構對于精确的數值查詢速度還是比較快的,直接從反向索引根據查找的term拿到postings list就好了。 但類似
range: [50, 100]
這樣的範圍查找就比較麻煩了,Lucene在找到對應的term後,隻能将其轉換成類似
50 OR 51 OR 52 ... OR 100
這樣的Bool查詢。可想而知,這個多條件OR查詢開銷很高,執行很慢。是以Lucene在建立索引的時候,會自動産生一些類似
50x75
這樣的特殊Term,指向包含在該範圍的文檔清單,進而可以将查詢優化成類似
50x75 OR 76x99 OR 100
這種形式。但是這種優化在字段的不同值很多,查詢範圍很大的時候,依然很無力。 是以早期版本的Lucene和ES的範圍查詢性能一直被诟病。
Lucene從6.0開始引入了Block k-d tree來重新設計數值類型的索引結構,其目标是讓數值型資料索引的結構更緊湊,搜尋速度更快。這種資料結構是為多元數值字段設計的,可以高效的用于諸如地理位置這類資料的快速過濾,但同樣适用于單次元的數值型。
Block k-d tree的基本概念和Lucene實作
基本思想就是将一個N維的數值空間,不斷標明包含值最多的次元做2分切割,反複疊代,直到切分出來的空間單元(
cell
)包含的資料點數量小于某個數值。 對于單次元的資料,實際上就是簡單的對所有值做一個排序,然後反複從中間做切分,生成一個類似于B-tree這樣的結構。和傳統的B-tree不同的是,他的葉子結點存儲的不是單值,而是一組值的集合,也就是是所謂的一個Block。每個Block内部包含的值數量控制在512- 1024個,保證值的數量在block之間盡量均勻分布。 其資料結構大緻看起來是這樣的:
Lucene将這顆B-tree的非葉子結點部分放在記憶體裡,而葉子結點緊緊相鄰存放在磁盤上。當作range查詢的時候,記憶體裡的B-tree可以幫助快速定位到滿足查詢條件的葉子結點塊在磁盤上的位置,之後對葉子結點塊的讀取幾乎都是順序的。
要注意一點,不是簡單的将拿到的所有塊合并就可以得到想要的docID結果集,因為查詢的上下邊界不一定剛好落在兩端block的上下邊界上。 是以如果需要拿到range filter的結果集,就要對于兩端的block内的docid做掃描,将他們的值和range的上下邊界做比較,挑選出match的docid集合。
Queries/filters執行的先後順序及結果合并是怎樣做的
ES的Queries/filters執行順序比較複雜,并非按照Query裡條件的排列順序來挨個執行;也不是某些人想象的那樣,每個filter/Query都獨立執行,拿到各自的結果集以後,再做結果集的合并。 在elasticsearch-query-execution-order 這篇部落格裡對這個主題做了比較詳細的介紹。
簡單來說,ES會先通過調用每個查詢的
cost()
函數估算一下該查詢的代價,然後選擇代價最小的查詢作為起點,在其圈定的docid集合上生成一個疊代器。然後反複疊代,根據和其他條件之間是AND還是OR的關系,再去決定結果集合并的方式。
這個結果集的疊代,以及合并,就是上面連結裡提到的和
nextdoc()
等操作。 比較複雜的地方是這些操作根據資料類型的不同和查詢類型的不同,ES都有針對性的進行操作優化,同樣的操作有些可能是在記憶體中進行,有些則可能直接在磁盤上進行。
advance()
以最常見的keyword字段做TermQuery為例,其cost就是Term Frequency,這個值可以直接從反向索引讀取。 Frequency越高的Term,其postings list就越長,疊代起來的代價就越高。 是以如果對多個TermQuery做AND合并,就會選擇Frequency最低的Term,以其postings list為起點做疊代(
nextdoc
)。 Postings list是按照docid順序存放的,并且在資料結構上還增加了跳表來加快
advance()
操作。是以多個postings list的合并可以直接操作磁盤上的資料而不會引起過多的随機IO,加上ES5.0以後對于索引資料采取了mmap file的方式通路,熱資料讀取引發的磁盤IO愈發的少。 這也是為什麼5.1.1之後取消了TermQuery的cache,因為在跳表和OS page cache的加持下,直接合并磁盤上的postings list已經非常快了。 取消對其cache後,可以減少構造cache的開銷,并且将寶貴的cache空間留給代價更高的filter,一定程度上可以提升ES整體性能。
有了這些預備知識,再來解答文首抛出的3個問題。
1. 為什麼ES5.x裡對數值型字段做TermQuery可能會很慢?
首先,使用者範例查詢裡還有其他更加結果集更小的TermQuery,cost更低,是以疊代器從選擇從這個低代價的Query作為起點開始執行; 其次,因為數值型字段在5.x裡沒有采用倒排表索引, 而是以value為序,将docid切分到不同的block裡面。對應的,數值型字段的TermQuery被轉換為了PointRangeQuery。這個Query利用Block k-d tree進行範圍查找速度非常快,但是滿足查詢條件的docid集合在磁盤上并非向Postlings list那樣按照docid順序存放,也就無法實作postings list上借助跳表做蛙跳的操作。 要實作對docid集合的快速advance操作,隻能将docid集合拿出來,做一些再處理。 這個處理過程在
org.apache.lucene.search.PointRangeQuery#createWeight
這個方法裡可以讀取到。 這裡就不貼冗長的代碼了,主要邏輯就是在建立scorer對象的時候,順帶先将滿足查詢條件的docid都選出來,然後構造成一個代表docid集合的bitset,這個過程和構造Query cache的過程非常類似。 之後advance操作,就是在這個bitset上完成的。
2. 為何Profile裡顯示的耗時幾乎全部在 build_scorer
?
build_scorer
回答第一個問題的時候提到了,如果檢視PointRangeQuery的源碼,構造scorer對象的構造過程包含了bitset的生成過程,是以耗時的實際上是構造一個巨大的bitset并在上面生成一個疊代器。
3. 為什麼對同樣的數值型字段做RangeQuery卻又很快了?
從上面數值型字段的Block k-d tree的特性可以看出,rangeQuery的結果集比較小的時候,其構造bitset的代價很低,不管是從他開始疊代做
nextdoc()
,或者從其他結果集開始疊代,對其做
advance
,都會比較快。 但是如果rangeQuery的結果集非常巨大,則構造bitset的過程會大大延緩scorer對象的構造過程,造成結果合并過程緩慢。
這個問題官方其實早已經意識到了,是以從ES5.4開始,引入了
indexOrDocValuesQuery
作為對RangeQuery的優化。(參考: better-query-planning-for-range-queries-in-elasticsearch)。 這個Query包裝了上面的
PointRangeQuery
和
SortedSetDocValuesRangeQuery
,并且會根據Rang查詢的資料集大小,以及要做的合并操作類型,決定用哪種Query。 如果Range的代價小,可以用來引領合并過程,就走
PointRangeQuery
,直接構造bitset來進行疊代。 而如果range的代價高,構造bitset太慢,就使用
SortedSetDocValuesRangeQuery
。 這個Query利用了DocValues這種全局docID序,并包含每個docid對應value的資料結構來做文檔的比對。 當給定一個docid的時候,一次随機磁盤通路就可以定位到該id對應的value,進而可以判斷該doc是否match。 是以它非常适合從其他查詢條件得到的一個小結果集作為疊代起點,對于每個docid依次調用其内部的
matches()
函數判斷比對與否。也就是說, 5.4新增的
indexOrDocValuesQuery
将Range查詢過程中的順序通路任務扔給Block k-d Tree索引,将随機訪任務交給doc values。 值得注意的是目前這個優化隻針對RangeQuery!對于TermQuery,因為實際的複雜性,還未做類似的優化,也就導緻對于數值型字段,Term和Range Query的性能差異極大。
小結:
- 在ES5.x裡,一定要注意數值類型是否需要做範圍查詢,看似數值,但其實隻用于Term或者Terms這類精确比對的,應該定義為keyword類型。典型的例子就是索引web日志時常見的HTTP Status code。
- 如果RangeQuery的結果集很大,并且還需要和其他結果集更小的查詢條件做AND的,應該更新到ES5.4+,該版本在底層引入的
,可以極大提升該場景下RangeQuery的查詢速度。indexOrDocValuesQuery
文章來源:https://elasticsearch.cn/article/446