如何聯合索引查詢?
所以給定查詢過濾條件 age=18 的過程就是先從term index找到18在term dictionary的大概位置,然后再從term dictionary里精確地找到18這個term,然后得到一個posting list或者一個指向posting list位置的指針。然后再查詢 gender=女 的過程也是類似的。最后得出 age=18 AND gender=女 就是把兩個 posting list 做一個“與”的合並。
這個理論上的“與”合並的操作可不容易。對於mysql來說,如果你給age和gender兩個字段都建立了索引,查詢的時候只會選擇其中最selective的來用,然后另外一個條件是在遍歷行的過程中在內存中計算之后過濾掉。那么要如何才能聯合使用兩個索引呢?有兩種辦法:
- 使用skip list數據結構。同時遍歷gender和age的posting list,互相skip;
- 使用bitset數據結構,對gender和age兩個filter分別求出bitset,對兩個bitset做AN操作。
PostgreSQL 從 8.4 版本開始支持通過bitmap聯合使用兩個索引,就是利用了bitset數據結構來做到的。當然一些商業的關系型數據庫也支持類似的聯合索引的功能。Elasticsearch支持以上兩種的聯合索引方式,如果查詢的filter緩存到了內存中(以bitset的形式),那么合並就是兩個bitset的AND。如果查詢的filter沒有緩存,那么就用skip list的方式去遍歷兩個on disk的posting list。
利用 Skip List 合並
以上是三個posting list。我們現在需要把它們用AND的關系合並,得出posting list的交集。首先選擇最短的posting list,然后從小到大遍歷。遍歷的過程可以跳過一些元素,比如我們遍歷到綠色的13的時候,就可以跳過藍色的3了,因為3比13要小。
整個過程如下
Next -> 2 Advance(2) -> 13 Advance(13) -> 13 Already on 13 Advance(13) -> 13 MATCH!!! Next -> 17 Advance(17) -> 22 Advance(22) -> 98 Advance(98) -> 98 Advance(98) -> 98 MATCH!!!
最后得出的交集是[13,98],所需的時間比完整遍歷三個posting list要快得多。但是前提是每個list需要指出Advance這個操作,快速移動指向的位置。什么樣的list可以這樣Advance往前做蛙跳?skip list:
從概念上來說,對於一個很長的posting list,比如:
[1,3,13,101,105,108,255,256,257]
我們可以把這個list分成三個block:
[1,3,13] [101,105,108] [255,256,257]
然后可以構建出skip list的第二層:
[1,101,255]
1,101,255分別指向自己對應的block。這樣就可以很快地跨block的移動指向位置了。
Lucene自然會對這個block再次進行壓縮。其壓縮方式叫做Frame Of Reference編碼。示例如下:
考慮到頻繁出現的term(所謂low cardinality的值),比如gender里的男或者女。如果有1百萬個文檔,那么性別為男的posting list里就會有50萬個int值。用Frame of Reference編碼進行壓縮可以極大減少磁盤占用。這個優化對於減少索引尺寸有非常重要的意義。因為這個Frame of Reference的編碼是有解壓縮成本的。利用skip list,除了跳過了遍歷的成本,也跳過了解壓縮這些壓縮過的block的過程,從而節省了cpu。
利用bitset合並
Bitset是一種很直觀的數據結構,對應posting list如:
[1,3,4,7,10]
對應的bitset就是:
[1,0,1,1,0,0,1,0,0,1]
每個文檔按照文檔id排序對應其中的一個bit。Bitset自身就有壓縮的特點,其用一個byte就可以代表8個文檔。所以100萬個文檔只需要12.5萬個byte。但是考慮到文檔可能有數十億之多,在內存里保存bitset仍然是很奢侈的事情。而且對於個每一個filter都要消耗一個bitset,比如age=18緩存起來的話是一個bitset,18<=age<25是另外一個filter緩存起來也要一個bitset。
所以秘訣就在於需要有一個數據結構:
- 可以很壓縮地保存上億個bit代表對應的文檔是否匹配filter;
- 這個壓縮的bitset仍然可以很快地進行AND和 OR的邏輯操作。
Lucene使用的這個數據結構叫做 Roaring Bitmap。
其壓縮的思路其實很簡單。與其保存100個0,占用100個bit。還不如保存0一次,然后聲明這個0重復了100遍。
這兩種合並使用索引的方式都有其用途。Elasticsearch對其性能有詳細的對比(https://www.elastic.co/blog/frame-of-reference-and-roaring-bitmaps)。簡單的結論是:因為Frame of Reference編碼是如此高效,對於簡單的相等條件的過濾緩存成純內存的bitset還不如需要訪問磁盤的skip list的方式要快。
如何減少文檔數?
一種常見的壓縮存儲時間序列的方式是把多個數據點合並成一行。Opentsdb支持海量數據的一個絕招就是定期把很多行數據合並成一行,這個過程叫compaction。類似的vivdcortext使用mysql存儲的時候,也把一分鍾的很多數據點合並存儲到mysql的一行里以減少行數。例如可以把一段時間的很多個數據點打包存儲到一個父文檔里,變成其嵌套的子文檔。示例如下:
{timestamp:12:05:01, idc:sz, value1:10,value2:11} {timestamp:12:05:02, idc:sz, value1:9,value2:9} {timestamp:12:05:02, idc:sz, value1:18,value:17}
可以打包成:
{ max_timestamp:12:05:02, min_timestamp: 1205:01, idc:sz, records: [ {timestamp:12:05:01, value1:10,value2:11} {timestamp:12:05:02, value1:9,value2:9} {timestamp:12:05:02, value1:18,value:17} ] }
這樣可以把數據點公共的維度字段上移到父文檔里,而不用在每個子文檔里重復存儲,從而減少索引的尺寸。如果我們可以在一個父文檔里塞入50個嵌套文檔,那么posting list可以變成之前的1/50。
總結和思考
Elasticsearch的索引思路:
將磁盤里的東西盡量搬進內存,減少磁盤隨機讀取次數(同時也利用磁盤順序讀特性)。
對於使用Elasticsearch進行索引時需要注意:
- 不需要索引的字段,一定要明確定義出來,因為默認是自動建索引的
- 同樣的道理,對於String類型的字段,不需要analysis的也需要明確定義出來,因為默認也是會analysis的
- 選擇有規律的ID很重要,隨機性太大的ID(比如java的UUID)不利於查詢
關於最后一點,個人認為有多個因素:
其中一個(也許不是最重要的)因素: 上面看到的壓縮算法,都是對Posting list里的大量ID進行壓縮的,那如果ID是順序的,或者是有公共前綴等具有一定規律性的ID,壓縮比會比較高;
另外一個因素: 可能是最影響查詢性能的,應該是最后通過Posting list里的ID到磁盤中查找Document信息的那步,因為Elasticsearch是分Segment存儲的,根據ID這個大范圍的Term定位到Segment的效率直接影響了最后查詢的性能,如果ID是有規律的,可以快速跳過不包含該ID的Segment,從而減少不必要的磁盤讀次數,具體可以參考這篇如何選擇一個高效的全局ID方案(評論也很精彩)
這篇文章非常棒:https://neway6655.github.io/elasticsearch/2015/09/11/elasticsearch-study-notes.html#section-1