1.概念
elasticsearch使用一种称为倒排索引的数据结构,用于快速的全文搜索。一个倒排索引有文档中所有不重复词的列表构成,每个词都有一个包含它的文档列表。
2.b-tree 和 倒排索引的比较
-
btree是针对写入优化的索引结构
-
luence倒排索引采用预先排序的方式换取更小的存储空、更快的检索速度,但是更新速度慢
3.Term idnex,term dictionary,posting list
有一下一组数据:
docId | age | gender |
---|---|---|
1 | 18 | 男 |
2 | 18 | 女 |
3 | 20 | 男 |
Age倒排索引:
18 | 1,2 |
---|---|
20 | 3 |
gender倒排索引
男 | 1,3 |
---|---|
女 | 2 |
可以看到,每个term都有一个倒排索引。18、20这些叫做term,1、2叫做posting list。posting list就是一组int数据,里面的值是文档Id。那么什么是term index和term dictionary呢?
假如我们有很多term,比如:
Carla,Sara,Elin,Ada,Patty,Kate,Selena
那么我们找出某一特定term时需要进行遍历,速度很慢。
如果对term进行排序的话:
Ada,Carla,Elin,Kate,Patty,Sara,Selena
就可以通过二分查找更快找到指定term,这个有序的term列表就是term dictionary。查找的时间复杂度为logN,logN次的磁盘读写仍然是非常昂贵的(一次random access大概需要10ms)。所以需要尽量少的读写磁盘,有必要把一些数据缓存到内存里,但是整个term dictionary又非常大,无法完整地放到内存里。于是就有了term index:
term idnex有点像书的章节:
A开头的term *********XX页
B开头的term *********XX页
C开头的term *********XX页
但是term的开头不只是26个英文字母,而且数据分布上也不是均匀的,term index的数据结构实际上是tier树:
这棵树不会包含所有的term,它包含的是term的一些前缀。通过term index可以快速地定位到term dictionary中的某个offset,然后从这个位置再往后顺序查找。**再加上一些压缩技术(lucene finite state transducer),使得使用内存缓存整个term index变成可能。 ** 整体效果:
由此可以得出为什么es检索比mysql快:mysql只有term dictionary这一层,是以btree的方式存储的。检索一个term需要若干次的random access的磁盘操作。而lucene在term dictionary的基础上添加了term index来加快检索,term index以树的形式缓存在内存中。从term index找到term dictionary所在block后,再去磁盘查找term,大大减少了磁盘的random access次数。
另外,term index是以FST(finite state transducers)的形式保存的,其特点是非常节省内存。term dictionary在磁盘上是以分block的方式保存的,一个block内部利用公共前缀压缩,比如都是AB开头的单词就可以把ab去掉,这样term dictionary就可以比btree更节省空间。
4.如何使用联合索引
给定的查询条件是:age=18时,首先会从term index中找到age=18在term dictionary的大概位置,然后从term dictionary里精确查找到这个18的term,然后得到一个指向posting list位置的指针。然后再查gender=女的过程也是类似的。最后得出一个满足age=18和gender=女的结果就是汇总两个posting list。
对于mysql来说,如果在age和gender上都建立索引,那么在联合查询时只会选择最selective的索引来使用,另外的条件会在之后对内存进行遍历时使用。那么如何才能联合使用两个索引呢?有两种方法:
- 使用skip list数据结构,同时遍历age和gender的posting list,互相skip
- 使用bitset数据结构,对gender和age两个filter分别求出bitset,对两个bitset做AND操作
PostgreSQL从8.4版本开始就是利用bitset数据结构联合使用两个索引的。es同时支持以上两种联合索引方式,如果查询的filter缓存到了内存中,那么合并就是两个bitset的and。如果查询的filtre没有缓存,那么就用skip list的方式去遍历两个 on disk的posting list。
-
利用skip list 进行合并
首先选择最短的posting list,然后从小到大遍历。遍历的过程可以跳过一些元素。遍历过程如下:
next : 2 advanced(2): 13 advanced(13): 13 already on advanced(13)(13) MATCH!!! next: 17 advanced(17): 22 advanced(22): 98 advanced(98): 98 advanced(98): 98 advanced(98): 98 MATCH!!!
前提是什么样的list支持快速移动到指定位置?skip list
对于一个很长的posting list,比如:[1,3,13,101,105,108,255,256,257],我们可以对其进行划分:
[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编码进行压缩可以极大减少磁盘占用。这个优化对于减少索引尺寸有非常重要的意义。当然mysql b-tree里也有一个类似的posting list的东西,是未经过这样压缩的。
因为这个Frame of Reference的编码是有解压缩成本的。利用skip list,除了跳过了遍历的成本,也跳过了解压缩这些压缩过的block的过程,从而节省了cpu。
-
利用bitset合并
bitset是一种很直观的数据结构,对应posting list,比如[1,3,4,7,10]对应的就是[1,0,1,1,0,0,1,0,0,1]
每个文档按照文档id顺序进行排序对应其中的一个bit。bitset本身就有压缩的特点,一个byte就可以保存8个文档,所以100万个文档只需要12.5万个bit,但是考虑到文档可能有数十亿之多,在内存里保存bitset仍然是很奢侈的事情,而且对于每一个filter都要消耗一个bitset,比如age=18缓存起来的话是一个bitset,18<=age<=25是另外一个filter缓存起来也要一个bitset
所以就需要有一个数据结构:
-
可以很压缩地保存数亿个文档代表的对应的文档是否匹配filter;
-
这个压缩的bitset可以很快地进行AND和OR的逻辑操作
lucene使用的这个数据结构叫做roaring bitmap:
其压缩的思路其实很简单。与其保存100个0,占用100个bit。还不如保存0一次,然后声明这个0重复了100遍。
-
-
比较结果:因为Frame of Reference编码是如此 高效,对于简单相等条件的过滤缓存成内存的bitset还不如需要访问磁盘的skip list的方式快
5.如何减少文档数?
一种常见的压缩存储时间序列的方式是把多个数据合并成一行。Opentsdb支持海量数据的一个绝招就是把很多行数据合并成一行,这个过程叫做compaction。
过程如下:
elasticsearch有一个功能可以实现类似的优化效果,那就是nested document,可以把一段时间的很多数据点打包存储到一个父文档里,变成其嵌套的子文档。
{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}
]
}
可以把数据点公共的维度移到父文档里,而不用在每个子文档里重复存储,从而减少索引的尺寸。
参考文档:
https://zhuanlan.zhihu.com/p/33671444