时间序列数据库的秘密 (2)——索引

<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 -&gt; 2 Advance(2) -&gt; 13 Advance(13) -&gt; 13 Already on 13 Advance(13) -&gt; 13 MATCH!!! Next -&gt; 17 Advance(17) -&gt; 22 Advance(22) -&gt; 98 Advance(98) -&gt; 98 Advance(98) -&gt; 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&lt;=age&lt;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>

相关文章
相关标签/搜索