十分鍾搞懂Elasticsearch數字搜索原理


更多精彩內容請看我的個人博客或者掃描二維碼,關注微信公眾號佛西先森
wechat

前言

Elasticsearch誕生的本意是為了解決文本搜索太慢的問題,ES會默認將所有的輸入內容當作字符串來理解,對於字段類型是keyword或者text的數據比較友好。但是如果輸入的類型是數字,ES還會把數字當作字符串嗎?排序問題還有范圍查詢問題怎么解決呢?這篇文章就簡單介紹了ES對於數字類型(numeric)數據的處理,能讓你大漲姿勢

簡介

Elasticsearch專為字符串搜索而生,在建立索引的時候針對字符串進行了非常多的優化,在對字符串進行准確匹配或者前綴匹配等匹配的時候效率是很高的。ES底層把所有的數據都會當成字符串,其中就包括數字——所有的數字在ES底層都是會以字符串的形式保存。

這就和我們通常理解的數字就不一樣了,在MySQL或者各種編程語言比如Java、Python中,數字比如int類型就是4個字節呀!最高位符號位,后面的位數按照二進制進行存儲,還能有其他的表示方式?

出現這個現象的原因就是ES和MySQL或者Java中對數字的需求不一樣了。在編程語言中,數字要經常參與計算比如加減乘除還有移位以及比較大小操作,這個時候用int這種原生的方式是簡單直接效率高的;在MySQL中,索引是以B+樹的形式存儲,每次查詢某一個數字都要在樹中把整個數字和分隔節點進行比較操作,直到找到最后的目標數據節點。

可以看到直接使用int的本身的結構來存儲的優勢就是直接比較大小的效率非常高(空間消耗小也是另外一個優勢),但是如果進行范圍查詢,就會有問題了。比如在MySQL的組合查詢中如果出現范圍查詢,那么很有可能出現范圍查詢后面的索引是不生效的(具體的MySQL的組合查詢的原理可以在網上看看),也就是說范圍查詢可能會降低查詢性能;在編程語言中的集合的范圍查詢就只能遍歷所有的元素,一一比較大小,沒有優化。

ES的出現為解決范圍查詢提供了一個新的思路——為不同精度范圍內的數據直接建立索引,把符合范圍查詢要求的數據聚合到一個索引上面,在搜索的時候把大的搜索范圍拆分成很多小的范圍索引,直接用term搜索就可以找到符合要求的所有文檔。

emmm...是不是有點抽象╮(╯_╰)╭

比如想搜索在[300,500]內的文檔並且事先已經把值在[300,400]之間的文檔索引到了3[400,500]之間的文檔索引到了4,那么就直接通過term查詢取出34對應的文檔id列表並且進行or操作就可以了,簡單直接高效。

range-query

原理雖然簡單,不過實現起來還是有些困難需要解決的。

數字直接變成字符串的問題

ES並沒有直接把數字變成字符串,也沒有對每個數字建立簡單的索引,因為這兩種做法可能會帶來一些問題。

字符串比較

首先最大的問題是數字變成字符串之后如何進行比較,如果直接是把十進制的數字變成字符串,排序按照字典序(lexicographic)比較(默認所有的term都是按照字典序比較大小),會有不同位數比較的問題。比如搜索[423,642]內的文檔,5也會被算在內,因為字典序"5""423"大,比"642"小。

這個問題的一個解決方案是在每個數字變成字符串的時候在前面填充0,把5變成005,這樣就能正確比較大小了,這也是舊版本的ES采用的解決方案。但是每次把int轉化成string的時候要填充多少個0呢?太多了占空間,太少了又可能因為數字太長影響比較,比如最多只填充2個0,對於1000以下的數字沒有問題,當數字大於1000了,個位數填充2個0就不夠用了。

獲取的范圍過多

另一個問題是范圍內的term過多帶來的性能下降。比如現在有很多文檔,其中索引的數字的列表為[421,423,445,446,448,521,522,632,633,634,641,642,644]一共13個term,如果我們想要查詢[423,642]之間的所有的文檔,需要取出一共11個term,然后用這些term去搜索對應的文檔。

es-docs

當范圍越來越大,需要的term的數量就越來越多,查詢的性能就會不斷下降。

ES是怎么把數字變成字符串

先來解決第一個問題,數字怎么變成字符串。

十進制的數字有填充問題,如果變成了二進制,再進行詞典序比較,不就沒有問題了嗎?Perfect,似乎問題完美的解決了。

哥么你就沒有考慮過負數的感受嗎?

sad

二進制的int保證是32位,對於正數和正數的比較或者負數和負數的比較是沒有問題的,可是正數和負數的比較就不行了。正數的最高位是0,負數的最高位是1,直接比較,負數永遠大於正數。這個時候ES采用的方法是把正數最高位變成1,負數最高位變成0,這樣正數用於大於負數,問題就解決了。

int類型解決了,float呢?由於浮點數在Java中的表示方法,最高位符號位,2330位是指數位,022是尾數,如果直接把一個正的float當作int類型來比較好像也沒有什么問題,指數位高的,當然大;指數相同,尾數大的也自然數字就大,所以正浮點型可以直接當成int轉化。但是負數就不行了,指數越大,數字越小,尾數越大,數字也越小。ES給出的解決方案是直接對低31位每一位取反,1變成0,0變成1,這樣負數的float就可以比較大小了。總結就是正float當int用,負float低31位取反后當成int用。

對於long和double類型,也是同樣的道理,只不過32位變成了64位。

你以為就這樣變成了二進制字符串了嗎?不,還沒有,沒有這么簡單。

剛才是把int變成了二進制的字符串,一個字符只保存0和1不覺得浪費嗎?一個int要用32個字符也就是32個字節保存,暴殄天物呀!

Java的1個int占4個字節,1個char是2個字節,1個int用2個char不就行了。但是Java使用Unicode字符集來保存字符串,ES用UTF8編碼保存Unicode字符,對於0~127使用1個字節,大於127一般2個字節,漢字通常3個字節,這樣的話1個int用2個char表示,最多需要6個字節(這里int雖然不是漢字,但是在變成char之后有可能在Unicode字符集中表示某一個漢字)

ES表示還能做的更好,上面不是說0~127只用一個字節嗎?好,我就把int切分之后的大小限制在127以內(原來默認切分是4組8位的二進制數字)。127是7位二進制數字,int是32位的,那就把32位的int變成由4、7、7、7、7這5組二進制組成,最后這個字符串只需要5個字節就可以了,和上面的6個字節相比,空間利用率提高了17%

transform

數字的索引是什么樣子

上面說到的另外一個問題是查詢term數量太多的問題,解決方案就是用空間換時間,通過前綴聚合部分的term來達到。

這里的聚合的實現方式是采用trie的數據結構,比如445、446和448這個三個term,可以聚合到44這個term的下面,節點44包含的文檔的id列表應該是所有子節點的並集,這樣原先需要的11個term就可以減少2個。同理對於其他的term也進行合並,合並之后[423,642]查詢就只需要6個term,效率提高了一倍

然而聚合也是要講道理的,把445、446和448聚合到44以及把44聚合到4相當於是把數字除以10,精度就是10。但是並不是一直都希望這個精度是10,也可以設置為100(精度相對應的降低,節約索引空間)等等。ES提供了precisionStep來定制化這個精度,不過不是針對十進制,而是二進制的位數。比如precisionStep設置為4,那么在二進制位里面每隔4位(相當於十進制的16)就建立一個前綴聚合索引。

trie

比如對於二進制數字0100 0011 0001 1010,當precisionStep為4的時候,會建立4個索引——0100 0011 0001 10100100 0011 00010100 0011以及0100(最高的4位),這四個索引相當於從trie的子節點一直到根節點

精度越高(precisionStep越小)索引就越大,查詢速度越快;精度越低,索引越小,相對查詢速度比較慢。

比如對於long類型的數據,precisionStep是4的時候,最多需要同時搜索465個term;precisionStep是2的時候,最多只要189個term。不過並不能絕對的說精度越高越好,因為查找這些term需要的時間也會相應增加。實際上最佳的precisionStep還是要根據業務情況測試得出。

上面根據precisionStep建立索引的過程中有一個特殊的分詞器來幫助拆分,比如把423拆成42342以及4。不過分詞器會同樣的把4拆分成4,那怎么區分423444呢?

那就需要額外的空間來區分這兩個4,ES給出的解決方案是在這兩個數字前面加上一個前綴shift表示偏移量。比如4234shift242342shift1423shift0);而44shift0,所以前者的4比后者的要大。分詞之后的term在每次比較之前都會先比較shiftshift越大,相應的term也越大,避免的重復的問題。

總結上面建立索引的過程:當一個文檔進來的時候,有一個數字423需要建立索引,於是先把這個int數據轉化成字符串,再用一個特殊的分詞器根據精度把423分成對應的三個term423424,並且附上對應的前綴shift,接下來在trie中找到這幾個term,把穩定的id添加到這幾個term的文檔id列表里面(如果不存在就創建這個term)。

查詢原理

清楚了數字類型的數據的索引機制之后,范圍查詢的原理就比較簡單了。

比如有一個范圍[423, 642],要找到字段大於等於423並且小於等於642的文檔。

  1. 先在索引的trie里面找到這兩個term以及范圍內的兄弟節點,分別是trie的兩個葉節點423、641和642
  2. 從葉節點向上縮小范圍,對兩個數字分別除以10加一和減一之后查找范圍為[43,63],此時的shift是1,得到這一層級的“葉子節點”以及范圍內的兄弟節點是44和63
  3. 再從這一層向上,兩個數字除以10,分別加一減一,得到范圍[5,5]shift為2,這就是最后的節點了,term是5
  4. 上面三個步驟得到最后需要的term是423、44、5、63、641和642

上面是用十進制舉得一個例子,在二進制里面也是同樣的道理,這里就不啰嗦了。實際上在ES實現里面用了很多位操作,效率相比於使用十進制要高很多,感興趣的同學可以去看源代碼,在LegacyNumericUtils類的splitRange方法里面。

總結

總的來說,Elasticsearch對於數字類型數據的索引和搜索不同於傳統的MySQL或者Java等編程語言,采用了獨特的字符串存儲以及Trie數據結構保存索引的方式。

ES先將輸入的數字進行預處理,把float和double分別映射成int和long,原來是int和long類型的則保持不變

然后把輸入的整型數字切分成許多組由最長7位二進制數字組成的二進制串,每組二進制數字都是一個Unicode字符,整體連起來變成一個Unicode字符串

接下來根據precisionStep把這個字符串數字分詞成很多term,並附上前綴shift

根據這些term建立索引詞典,詞典的結構類似於一個trie

范圍查詢的時候根據trie把所謂的范圍區間划分成離散的term字符串,這些term指向的文檔的並集就是范圍查詢的結果

One more thing

謝謝各位大佬看完了這篇文章,在這里我很遺憾的通知您,以上提到的ES數字類型數據處理的方式已經被廢棄了😓

angry

ES使用的搜索庫Lucene在6.0版本以及以后為了解決多維空間位置搜索問題,改用新的數據結構——BKD樹來實現位置搜索,帶來了很大的性能提升。開發者發現這種實現也能用於一維數據搜索,於是用新的數據結構代替了現在的字符串的實現

我會在以后專門寫一篇文章介紹BKD樹在ES中的應用

參考

The Evolution of Numeric Range Filters in Apache Lucene
Numeric datatypes
Class IntField
Package org.apache.lucene.search
Integer size vs Long size
Lucene的數字范圍搜索 (Numeric Range Query)原理
Lucene 4 和 Solr 4 學習筆記(3)
Class LegacyNumericRangeQuery
LegacyNumericUtils
Class NumericRangeQuerys


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM