本节包含:
- 倒排索引原理
- 倒排索引数据结构
- 倒排合并算法
- 倒排表压缩算法
ES 和 MySQL 等传统数据库的差别之一就是在于索引。ES 采用的是一种名叫 倒排索引 的数据结构。
先看看 正排索引 :
| doc_id | body | download_count |
|---|---|---|
| 1 | mick li | 12 |
| 2 | jetty chen | 21 |
| 3 | steven li | 31 |
类似于散列表,正排索引通过 doc_Id 对对象查询。
倒排索引
那如果反过来,我想查询 body 中包含了 li 的数据有哪些?
在传统数据库中,我可能会使用
select * form doc where body like '%li%';
这样的方式,数据库可能会选择使用全表扫描来判断body是否包含 li,这样十分低效。
但如果我们实现这样的一个索引结构:
| Term(词条) | Posting List(倒排表) |
|---|---|
| mick | [1] |
| jetty | [2] |
| steven | [3] |
| li | [1,3] |
| chen | 2 |
- Term:索引里面最小的存储和查询单元。
- Posting List:一个文档通常由多个词组成,倒排表记录的是某个词在哪些文档里出现过,以及出现的位置。每条记录成为一个倒排项。倒排表记录的不单是文档编号,还存储了词频等信息。
- Inverted File:所有单词的倒排列表往往有序地存储在磁盘的某个文件里,倒排文件是存储倒排索引的物理文件。
当要查询 body 中包含 li 的数据时,只需要通过这个索引结构查询到 Posting List 中所包含的数据,再通过映射的方式查询到最终的数据。
这个结构就是倒排索引。
Term Dictionary
但如何高效的在这个索引结构中查询到 li 呢? 只要我们将 Term 有序排列,便可以使用二叉搜索树在 o(logn) 下查询到数据。
将一个文本拆分成一个一个独立的 Term 的过程便是分词。
一般 Term 都是按照顺序排序的,排序之后,当我们搜索某一个 Term 时,就不需要从头遍历,而是采用二分查找。而将所有的 Term 合并在一起就是 Term Dictionary,即单词词典。
当我们文本量巨大时,分词后的 Term 也会很多,这样一个倒排索引的数据结构如果存放于内存肯定是不够的。
Term Index
既然无法将整个 Term Dictionary 放入内存中,那我们可以为 Term Dictionary 创建一个索引然后放入内存中。
这样便可以高效的查询 Term Dictionary,最后再通过 Term Dictionary 查询到 Posting List;
相对于 MySQL 中的 B+树 来说也会减少了几次 磁盘 IO。
这个 Term Index 我们可以使用字典树(Burst-Trie)来存放,它是前缀树(Trie)的变种,它主要将后缀进行了压缩,降低了Trie的高度,从而获得了更好的查询性能。
Term Index 一般全部缓存在内存中。查询时,先通过其快速定位到 Term Dictionary 对应的大致范围,然后再从磁盘中取出范围内的所有 Term,然后进行二分查找找到对应的 Term。这样就大大减少了磁盘 I/O 的次数。
联合索引查询
ES 处理复杂联合索引修需要分别按照条件进行查询,获取相应的 Posting List,再将其做交运算合并即可。
合并多个 Posting List ES 主要是通过 Skip List 和 Bitmap 的方式进行的。
Skip List跳表结构:同时遍历查询出来的Posting List, 利用Skip List结构,相互跳跃对比,得出合集。Bitmap位图结构:对查出来的Posting List计算出Bitmap,然后进行与运算。
Skip List 跳表
ElasticSearch 在存储 Posting List 数据时,就保存了对应的多级跳表结构相应的数据。跳表其实就是一个可以通过二分查找的有序链表。
合并的具体过程是:选取最短的 Posting List,然后最左到右遍历元素,在另外一个 Posting List 通过二分查找遍历到的元素。
对于较长的 Posting List 会使用 Frame Of Reference 进行压缩编码,减少磁盘占用,减少了索引尺寸。
Bitmap 位图
当我们对两个字段进行检索时,就可以利用 Bitmap 进行优化。
比如现在需要查询 body=li and download_count = 12 的数据,这时候我们需要通过着两个字段各自把结果集Posting List取出,然后遍历两个数据,取出两者的交集,但明显效率低下。
| name | doc_ids |
|---|---|
| name=li | [1,3] |
| download_count=12 | [1] |
通过 Bitmap 的方式对数据进行存储,然后通过 位与 计算便可以得出结构。
[1, 3] 101
[1] 100
与运算解得:100 [1]
则 doc_ids 为 [1],这样合并效率和存储效率自然是较先前高。
同样的查询需求在 MySQL 中没有特殊优化,具体可以在同系列文章: 为什么 Mysql 不适合大数据文本检索 中了解。
但 BitMap 存在几个问题:
BitMap的位数是增量的,而且每个Posting List都必须包含相同位数的BitMap。假设有 4000 万个doc,那么BitMap就是 500 万位,约等于 5 MB。那么假设索引有 10 万行,那么就是10 w * 5 MB = 500GB,磁盘一般无法承受。BitMap无法解决稀疏带来的空间浪费,4000 万个也许只有 2 个包含,这样的结果显然不可取。
所以必须要使用压缩算法。
FOR 压缩算法:
主要原理是对相邻 doc id 求差值,然后将差按照算法分桶(哪些数据在一个桶会让总体存储空间占用最小)。
每个桶的首位代表了桶中单个数据占用的存储空间(占一位)。
73 300 302 332 343 372 // 单个 int 占用 4 bytes
73 227 2 30 11 29 // 求差
73 227 | 2 30 11 29 // 分桶
桶1:8 | 73 227 // 1 + 2 * 8 bits / 8 = 3 bytes
桶2:5 | 2 30 11 29 // 1 + 4 * 5 bits / 8 = 4 bytes
// 压缩后占用 7 bytes,原先占用 24 bytes
对于稀疏散列,差值值大于 65535 (2^16 = 2 bytes) 时,使用 FOR压缩算法 就没有优势了。
RBM 压缩算法:
主要原理是对稀疏散列对 65535 取商取模(将数据分为高 16 位和低 16 位两部分),将商值作为桶的序号,将模值存入桶中。然后根据桶内元素的数量,选择存储数据的容器,在此主要介绍 Array 和 Bitmap 两种方式实现的容器:
ArrayContainer: 长度为 n 的 short 数组(16 位),存储空间不固定
BitmapContainer: 长度为 1024 的 long 数组(64 位),存储空间固定为 8 KB
short 占 2 字节 16 位
当基数 时,
当基数 时,
当基数 时,就已经超过了 65536 bits = 8 KB,这个数字根据 n 的增加还会增长。
long 占 8 字节 64 位,BitMap 底层使用了长度为 1024 的 long 数组
刚好覆盖了容器内数据的范围。
1000 62101 131385 132052 191173 196658
(0, 1000) (0, 62101) (2, 313) (2, 980) (2, 60101) (3,50) // 分割高低位
桶1:1000 62101 //array container: 2 * 2 = 4 Bytes
桶2:none
桶3:311 980 60101 //array container: 3 * 2 = 6 Bytes
桶4:50 // array container: 1 * 2 = 2 Bytes
// 共占 12 Bytes