<div data-v-e73bee8c="" class="article-typo article-content"><div><h2>如何快速檢索?</h2>mysql
<p>Elasticsearch 是經過 Lucene 的倒排索引技術實現比關係型數據庫更快的過濾。特別是它對多條件的過濾支持很是好,好比年齡在 18 和 30 之間,性別爲女性這樣的組合查詢。倒排索引不少地方都有介紹,可是其比關係型數據庫的 b-tree 索引快在哪裏?到底爲何快呢?</p>sql
<p>籠統的來講,b-tree 索引是爲寫入優化的索引結構。當咱們不須要支持快速的更新的時候,能夠用預先排序等方式換取更小的存儲空間,更快的檢索速度等好處,其代價就是更新慢。要進一步深刻的化,仍是要看一下 Lucene 的倒排索引是怎麼構成的。</p>數據庫
<p><img src="https://static001.infoq.cn/resource/image/37/6a/378bc62acf1a493c402291a8f8e99e6a.jpg"></p>數組
<p>這裏有好幾個概念。咱們來看一個實際的例子,假設有以下的數據:</p>緩存
<table><tbody><tr><td> <p>docid</p> </td> <td> <p>年齡</p> </td> <td> <p>性別</p> </td> </tr><tr><td> <p>1</p> </td> <td> <p>18</p> </td> <td> <p>女</p> </td> </tr><tr><td> <p>2</p> </td> <td> <p>20</p> </td> <td> <p>女</p> </td> </tr><tr><td> <p>3</p> </td> <td> <p>18</p> </td> <td> <p>男</p> </td> </tr></tbody></table><p>這裏每一行是一個 document。每一個 document 都有一個 docid。那麼給這些 document 創建的倒排索引就是:</p>微信
<p>年齡</p>數據結構
<p>性別</p>架構
<p>能夠看到,倒排索引是 per field 的,一個字段由一個本身的倒排索引。18,20 這些叫作 term,而 [1,3] 就是 posting list。Posting list 就是一個 int 的數組,存儲了全部符合某個 term 的文檔 id。那麼什麼是 term dictionary 和 term index?</p>dom
<p>假設咱們有不少個 term,好比:</p>分佈式
<p><b>Carla,Sara,Elin,Ada,Patty,Kate,Selena</b></p>
<p>若是按照這樣的順序排列,找出某個特定的 term 必定很慢,由於 term 沒有排序,須要所有過濾一遍才能找出特定的 term。排序以後就變成了:</p>
<p><b>Ada,Carla,Elin,Kate,Patty,Sara,Selena</b></p>
<p>這樣咱們能夠用二分查找的方式,比全遍歷更快地找出目標的 term。這個就是 term dictionary。有了 term dictionary 以後,能夠用 logN 次磁盤查找獲得目標。可是磁盤的隨機讀操做仍然是很是昂貴的(一次 random access 大概須要 10ms 的時間)。因此儘可能少的讀磁盤,有必要把一些數據緩存到內存裏。可是整個 term dictionary 自己又太大了,沒法完整地放到內存裏。因而就有了 term index。term index 有點像一本字典的大的章節表。好比:</p>
<p>A 開頭的 term ……………. Xxx 頁</p>
<p>C 開頭的 term ……………. Xxx 頁</p>
<p>E 開頭的 term ……………. Xxx 頁</p>
<p>若是全部的 term 都是英文字符的話,可能這個 term index 就真的是 26 個英文字符表構成的了。可是實際的狀況是,term 未必都是英文字符,term 能夠是任意的 byte 數組。並且 26 個英文字符也未必是每個字符都有均等的 term,好比 x 字符開頭的 term 可能一個都沒有,而 s 開頭的 term 又特別多。實際的 term index 是一棵 trie 樹:</p>
<p><img src="https://static001.infoq.cn/resource/image/e4/e0/e4632ac1392b01f7a39d963fddb1a1e0.png"></p>
<p>例子是一個包含 "A", "to", "tea", "ted", "ten", "i", "in", 和 "inn" 的 trie 樹。這棵樹不會包含全部的 term,它包含的是 term 的一些前綴。經過 term index 能夠快速地定位到 term dictionary 的某個 offset,而後從這個位置再日後順序查找。再加上一些壓縮技術(搜索 Lucene Finite State Transducers) term index 的尺寸能夠只有全部 term 的尺寸的幾十分之一,使得用內存緩存整個 term index 變成可能。總體上來講就是這樣的效果。</p>
<p><img src="https://static001.infoq.cn/resource/image/e4/26/e4599b618e270df9b64a75eb77bfb326.jpg"></p>
<p>如今咱們能夠回答「爲何 Elasticsearch/Lucene 檢索能夠比 mysql 快了。Mysql 只有 term dictionary 這一層,是以 b-tree 排序的方式存儲在磁盤上的。檢索一個 term 須要若干次的 random access 的磁盤操做。而 Lucene 在 term dictionary 的基礎上添加了 term index 來加速檢索,term index 以樹的形式緩存在內存中。從 term index 查到對應的 term dictionary 的 block 位置以後,再去磁盤上找 term,大大減小了磁盤的 random access 次數。</p>
<p>額外值得一提的兩點是:term index 在內存中是以 FST(finite state transducers)的形式保存的,其特色是很是節省內存。Term dictionary 在磁盤上是以分 block 的方式保存的,一個 block 內部利用公共前綴壓縮,好比都是 Ab 開頭的單詞就能夠把 Ab 省去。這樣 term dictionary 能夠比 b-tree 更節約磁盤空間。</p>
<h2>如何聯合索引查詢?</h2>
<p>因此給定查詢過濾條件 age=18 的過程就是先從 term index 找到 18 在 term dictionary 的大概位置,而後再從 term dictionary 裏精確地找到 18 這個 term,而後獲得一個 posting list 或者一個指向 posting list 位置的指針。而後再查詢 gender= 女 的過程也是相似的。最後得出 age=18 AND gender= 女 就是把兩個 posting list 作一個「與」的合併。</p>
<p>這個理論上的「與」合併的操做可不容易。對於 mysql 來講,若是你給 age 和 gender 兩個字段都創建了索引,查詢的時候只會選擇其中最 selective 的來用,而後另一個條件是在遍歷行的過程當中在內存中計算以後過濾掉。那麼要如何才能聯合使用兩個索引呢?有兩種辦法:</p>
<ul><li>使用 skip list 數據結構。同時遍歷 gender 和 age 的 posting list,互相 skip;</li> <li>使用 bitset 數據結構,對 gender 和 age 兩個 filter 分別求出 bitset,對兩個 bitset 作 AN 操做。</li> </ul><p>PostgreSQL 從 8.4 版本開始支持經過 bitmap 聯合使用兩個索引,就是利用了 bitset 數據結構來作到的。固然一些商業的關係型數據庫也支持相似的聯合索引的功能。Elasticsearch 支持以上兩種的聯合索引方式,若是查詢的 filter 緩存到了內存中(以 bitset 的形式),那麼合併就是兩個 bitset 的 AND。若是查詢的 filter 沒有緩存,那麼就用 skip list 的方式去遍歷兩個 on disk 的 posting list。</p>
<h3>利用 Skip List 合併</h3>
<p><img src="https://static001.infoq.cn/resource/image/ea/9f/eafa46683272ff1b2081edbc8db5469f.jpg"></p>
<p>以上是三個 posting list。咱們如今須要把它們用 AND 的關係合併,得出 posting list 的交集。首先選擇最短的 posting list,而後從小到大遍歷。遍歷的過程能夠跳過一些元素,好比咱們遍歷到綠色的 13 的時候,就能夠跳過藍色的 3 了,由於 3 比 13 要小。</p>
<p>整個過程以下</p>
<pre> 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!!!</pre>
<p>最後得出的交集是 [13,98],所需的時間比完整遍歷三個 posting list 要快得多。可是前提是每一個 list 須要指出 Advance 這個操做,快速移動指向的位置。什麼樣的 list 能夠這樣 Advance 往前作蛙跳?skip list:</p>
<p><img src="https://static001.infoq.cn/resource/image/a8/34/a8b78c8e861c34a1afd7891284852b34.png"></p>
<p>從概念上來講,對於一個很長的 posting list,好比:</p>
<p>[1,3,13,101,105,108,255,256,257]</p>
<p>咱們能夠把這個 list 分紅三個 block:</p>
<p>[1,3,13] [101,105,108] [255,256,257]</p>
<p>而後能夠構建出 skip list 的第二層:</p>
<p>[1,101,255]</p>
<p>1,101,255 分別指向本身對應的 block。這樣就能夠很快地跨 block 的移動指向位置了。</p>
<p>Lucene 天然會對這個 block 再次進行壓縮。其壓縮方式叫作 Frame Of Reference 編碼。示例以下:</p>
<p><img src="https://static001.infoq.cn/resource/image/9c/b7/9c03d3e449e3f8fb8182287048ad6db7.png"></p>
<p>考慮到頻繁出現的 term(所謂 low cardinality 的值),好比 gender 裏的男或者女。若是有 1 百萬個文檔,那麼性別爲男的 posting list 裏就會有 50 萬個 int 值。用 Frame of Reference 編碼進行壓縮能夠極大減小磁盤佔用。這個優化對於減小索引尺寸有很是重要的意義。固然 mysql b-tree 裏也有一個相似的 posting list 的東西,是未通過這樣壓縮的。</p>
<p>由於這個 Frame of Reference 的編碼是有解壓縮成本的。利用 skip list,除了跳過了遍歷的成本,也跳過了解壓縮這些壓縮過的 block 的過程,從而節省了 cpu。</p>
<h3>利用 bitset 合併</h3>
<p>Bitset 是一種很直觀的數據結構,對應 posting list 如:</p>
<p>[1,3,4,7,10]</p>
<p>對應的 bitset 就是:</p>
<p>[1,0,1,1,0,0,1,0,0,1]</p>
<p>每一個文檔按照文檔 id 排序對應其中的一個 bit。Bitset 自身就有壓縮的特色,其用一個 byte 就能夠表明 8 個文檔。因此 100 萬個文檔只須要 12.5 萬個 byte。可是考慮到文檔可能有數十億之多,在內存裏保存 bitset 仍然是很奢侈的事情。並且對於個每個 filter 都要消耗一個 bitset,好比 age=18 緩存起來的話是一個 bitset,18<=age<25 是另一個 filter 緩存起來也要一個 bitset。</p>
<p>因此祕訣就在於須要有一個數據結構:</p>
<ul><li>能夠很壓縮地保存上億個 bit 表明對應的文檔是否匹配 filter;</li> <li>這個壓縮的 bitset 仍然能夠很快地進行 AND 和 OR 的邏輯操做。</li> </ul><p>Lucene 使用的這個數據結構叫作 Roaring Bitmap。</p>
<p><img src="https://static001.infoq.cn/resource/image/94/7e/9482b84c4aa3fb77a959c1ead553037e.png"></p>
<p>其壓縮的思路其實很簡單。與其保存 100 個 0,佔用 100 個 bit。還不如保存 0 一次,而後聲明這個 0 重複了 100 遍。</p>
<p>這兩種合併使用索引的方式都有其用途。Elasticsearch 對其性能有詳細的對比(<a href="https://www.elastic.co/blog/frame-of-reference-and-roaring-bitmaps" target="_blank">https://www.elastic.co/blog/frame-of-reference-and-roaring-bitmaps</a>)。簡單的結論是:由於 Frame of Reference 編碼是如此 高效,對於簡單的相等條件的過濾緩存成純內存的 bitset 還不如須要訪問磁盤的 skip list 的方式要快。</p>
<h3>如何減小文檔數?</h3>
<p>一種常見的壓縮存儲時間序列的方式是把多個數據點合併成一行。Opentsdb 支持海量數據的一個絕招就是按期把不少行數據合併成一行,這個過程叫 compaction。相似的 vivdcortext 使用 mysql 存儲的時候,也把一分鐘的不少數據點合併存儲到 mysql 的一行裏以減小行數。</p>
<p>這個過程能夠示例以下:</p>
<div> <table><tbody><tr><td> <p>12:05:00</p> </td> <td> <p>10</p> </td> </tr><tr><td> <p>12:05:01</p> </td> <td> <p>15</p> </td> </tr><tr><td> <p>12:05:02</p> </td> <td> <p>14</p> </td> </tr><tr><td> <p>12:05:03</p> </td> <td> <p>16</p> </td> </tr></tbody></table></div>
<p>合併以後就變成了:</p>
<p>能夠看到,行變成了列了。每一列能夠表明這一分鐘內一秒的數據。</p>
<p>Elasticsearch 有一個功能能夠實現相似的優化效果,那就是 Nested Document。咱們能夠把一段時間的不少個數據點打包存儲到一個父文檔裏,變成其嵌套的子文檔。示例以下:</p>
<pre> {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}</pre>
<p>能夠打包成:</p>
<pre> { 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} ] }</pre>
<p>這樣能夠把數據點公共的維度字段上移到父文檔裏,而不用在每一個子文檔裏重複存儲,從而減小索引的尺寸。</p>
<p><img src="https://static001.infoq.cn/resource/image/91/a3/917578288797efab8f67e7b74d5ec6a3.png"></p>
<p>(圖片來源:<a href="https://www.youtube.com/watch?v=Su5SHc_uJw8" target="_blank">https://www.youtube.com/watch?v=Su5SHc_uJw8</a>,Faceting with Lucene Block Join Query)</p>
<p>在存儲的時候,不管父文檔仍是子文檔,對於 Lucene 來講都是文檔,都會有文檔 Id。可是對於嵌套文檔來講,能夠保存起子文檔和父文檔的文檔 id 是連續的,並且父文檔老是最後一個。有這樣一個排序性做爲保障,那麼有一個全部父文檔的 posting list 就能夠跟蹤全部的父子關係。也能夠很容易地在父子文檔 id 之間作轉換。把父子關係也理解爲一個 filter,那麼查詢時檢索的時候不過是又 AND 了另一個 filter 而已。前面咱們已經看到了 Elasticsearch 能夠很是高效地處理多 filter 的狀況,充分利用底層的索引。</p>
<p>使用了嵌套文檔以後,對於 term 的 posting list 只須要保存父文檔的 doc id 就能夠了,能夠比保存全部的數據點的 doc id 要少不少。若是咱們能夠在一個父文檔裏塞入 50 個嵌套文檔,那麼 posting list 能夠變成以前的 1/50。</p>
<h2>做者簡介</h2>
<p><strong>陶文</strong>,曾就任於騰訊 IEG 的藍鯨產品中心,負責過告警平臺的架構設計與實現。2006 年從 ThoughtWorks 開始職業生涯,在大型遺留系統的重構,持續交付能力建設,高可用分佈式系統構建方面積累了豐富的經驗。</p>
<hr><p>感謝<a href="http://www.infoq.com/cn/author/%E5%BC%A0%E5%87%AF%E5%B3%B0" target="_blank">張凱峯</a>對本文的策劃,<a href="http://www.infoq.com/cn/author/%E4%B8%81%E6%99%93%E6%98%80" target="_blank">丁曉昀</a>對本文的審校。</p>
<p>給 InfoQ 中文站投稿或者參與內容翻譯工做,請郵件至<a href="mailto:editors@cn.infoq.com" target="_blank">editors@cn.infoq.com</a>。也歡迎你們經過新浪微博(<a href="http://www.weibo.com/infoqchina" target="_blank">@InfoQ</a>,<a href="http://weibo.com/u/1451714913" target="_blank">@丁曉昀</a>),微信(微信號:<a href="http://weixin.sogou.com/gzh?openid=oIWsFt0HnZ93MfLi3pW2ggVJFRxY" target="_blank">InfoQChina</a>)關注咱們,並與咱們的編輯和其餘讀者朋友交流(歡迎加入 InfoQ 讀者交流羣<a href="http://shang.qq.com/wpa/qunwpa?idkey=cc82a73d7522f0090aa3cbb6a8f4bdafa8b82177f481014c976a8740d927997a" target="_blank"><img src="https://static001.infoq.cn/resource/image/06/9f/06e1fec4a87eca3142d54d09844c629f.png"></a>)。</p></div></div>