FST有穷状态转换器:
Finite StateTransducers 简称 FST,通常中文译作有穷状态转换器或者有限状态传感器
FSTs are finite-state machines that map a term (byte sequence) to an arbitrary output。FST是一项将一个字节序列映射到block块的技术
假设我们现在要将mop, moth, pop, star, stop and top(term index里的term前缀)映射到序号:0,1,2,3,4,5(term dictionary的block位置)。最简单的做法就是定义个Map<string, integer="">,
大家找到自己的位置对应入座就好了,但从内存占用少的角度想想,有没有更优的办法呢?答案就是:FST。
⭕(上图的大圈圈) 表示一种状态,-->表示状态的变化过程,上面的字母/数字表示状态变化和权重,将单词分成单个字母通过⭕ 和-->表示出来,0权重不显示。如果⭕ 后面出现分支,就标记权重,最后整条路径上的权重加起来就是这个单词对应的序号。当遍历上面的每一条边的时候,都会加上这条边的输出,比如当输入是 stop 的时候会经过 s/3 和o/1 ,相加得到的排序的顺序是 4 ;而对于 mop ,得到的排序的结果是 0但是这个树并不会包含所有的term,而是很多term(分词)的前缀,通过这些前缀快速定位到这个前缀所属的磁盘的block,再从这个block去找文档列表。为了压缩词典的空间,实际上每个block都只会保存block内不同的部分,比如 mop 和 moth 在同一个以 mo 开头的block,那么在对应的词典里面只会保存 p 和th ,这样空间利用率提高了一倍。
使用有限状态转换器在内存消耗上面要比远比 SortedMap 要少,但是在查询的时候需要更多的CPU资源。维基百科的索引就是使用的FST,只使用了69MB的空间,花了大约8秒钟,就为接近一千万个词条建立了索引,使用的堆空间不到256MB。现在已经把词典压缩成了词条索引,尺寸已经足够小到放入内存,通过索引能够快速找到文档列表。现在又有另外一个问题,把所有的文档的id放入磁盘中会不会占用了太多空间?如果有一亿个文档,每个文档有10个字段,为了保存这个posting list就需要消耗十亿个integer的空间,磁盘空间的消耗也是巨大的,ES采用了一个更加巧妙的方式来保存所有的 id。
Frame Of Reference(索引帧):
增量编码压缩,将大数变小数,按字节存储
Elasticsearch里除了上面说到用FST压缩 term index(分词索引)外,对posting list(文档ID列表)也有压缩技巧。posting list不是已经只存储文档id了吗?还需要压缩?我们再看以下的例子,如果Elasticsearch需要对同学的性别进行索引会怎样?
如果男同学和女同学数量很接近,传统关系型数据库针对性别列的索引是不会起到作用,如果差距大,还是会走索引的。
如果有上千万个同学,而世界上只有男/女这样两个性别,每个posting list都会有至少百万个文档id。Elasticsearch是如何有效的对这些文档id压缩的呢?
在进行查询的时候经常会进行组合查询,比如查询同时包含man和woman的文档,那么就需要分别查出包含这两个单词的文档的id,然后取这两个id列表的交集;如果是查包含man或者woman的文档,那么就需要分别查出posting list然后取并集。为了能够高效的进行交集和并集的操作。为了方便压缩,Elasticsearch要求posting list是有序的(为了提高搜索的性能,再任性的要求也得满足)。同时为了减小存储空间,所有的id都会进行delta编码。
比如现在有id列表 [73, 300, 302, 332, 343, 372] ,转化成每一个id相对于前一个id的增量值(第一个id的前一个id默认是0,增量就是它自己)列表是 [73, 227, 2, 30, 11, 29] 。在这个新的列表里面,所有的id都是小于255的,所以每个id只需要一个字节存储。实际上ES会做的更加精细,它会把所有的文档分成很多个block,每个block正好包含256个文档,然后单独对每个文档进行增量编码,计算出存储这个block里面所有文档最多需要多少位来保存每个id,并且把这个位数作为头信息(header)放在每个block 的前面。这个技术叫Frame of Reference,翻译成索引帧。比如对上面的数据进行压缩(假设每个block只有3个文件而不是256),压缩过程如下
这种压缩算法的原理就是通过增量,将原来的大数变成小数仅存储增量值,再精打细算按bit排好队,最后通过字节存储,而不是大大咧咧的尽管是2也是用int(4个字节)来存储。
在返回结果的时候,其实也并不需要把所有的数据直接解压然后一股脑全部返回,可以直接返回一个迭代器 iterator ,直接通过迭代器的 next 方法逐一取出压缩的id,这样也可以极大的节省计算和内存开销。通过以上的方式可以极大的节省posting list的空间消耗,提高查询性能。不过ES为了提高filter过滤器查询的性能,还做了更多的工作,那就是缓存。
缓存技巧之Roaring Bitmaps 咆哮位图:
ES会缓存频率比较高的filter查询,其中的原理也比较简单,即生成 (fitler, segment数据空间) 和id列表的映射,但是和倒排索引不同,我们只把常用的filter缓存下来而倒排索引是保存所有的,并且filter缓存应该足够快,不然直接查询不就可以了。ES直接把缓存的filter放到内存里面,映射的postinglist放入磁盘中。
ES在filter缓存使用的压缩方式和倒排索引的压缩方式并不相同,filter缓存使用了roaring bitmap的数据结构,在查询的时候相对于上面的Frame of Reference方式CPU消耗要小,查询效率更高,代价就是需要的存储空间(磁盘)更多。典型的以空间换时间。
Bitmap是一种数据结构,假设有某个posting list:[1,3,4,7,10]
对应的bitmap就是:[1,0,1,1,0,0,1,0,0,1]。
非常直观,用0/1表示某个值是否存在,比如10这个值就对应第10位,对应的bit值是1,这样用一个字节就可以代表8个文档id,旧版本(5.0之前)的Lucene就是用这样的方式来压缩的,但这样的压缩方式仍然不够高效,如果有1亿个文档,那么需要12.5MB的存储空间,这仅仅是对应一个索引字段(我们往往会有很多个索引字段)。于是有人想出了Roaring bitmaps这样更高效的数据结构。
Bitmap的缺点是存储空间随着文档个数线性增长,Roaring bitmaps需要打破这个魔咒就一定要用到某些指数特性.
- Roaring Bitmap首先会根据每个id的高16位分配id到对应的block里面,比如第一个block里面id应该都是在0到65535之间,第二个block的id在65536和131071之间
- 对于每一个block里面的数据,根据id数量分成两类
- 如果数量小于4096,就是用short数组保存
- 数量大于等于4096,就使用bitmap保存
在每一个block里面,一个数字实际上只需要2个字节来保存就行了,因为高16位在这个block里面都是相同的,高16位就是block的id,block id和文档的id都用short保存。
至于4096这个分界线,因为当数量小于4096的时候,如果用bitmap就需要8kB的空间,而使用2个字节的数组空间消耗就要少一点。比如只有2048个值,每个值2字节,一共只需要4kB就能保存,但是bitmap需要8kB。
由此见得,Elasticsearch使用的倒排索引确实比关系型数据库的B-Tree索引快。
注意:一个Lucene索引(也就是一个elasticsearch分片)不能处理多于21亿篇文档,或者多于2740亿的唯一词条。但达到这个极限之前,我们可能就没有足够的磁盘空间了!
倒排索引如何做联合索引:
如果多个field索引的联合查询,倒排索引如何满足快速查询的要求呢?利用跳表(Skip list)的数据结构快速做“与”运算,或者利用上面提到的bitset按位“与”。先看看跳表的数据结构:
将一个有序链表level0,挑出其中几个元素到level1及level2,每个level越往上,选出来的指针元素越少,查找时依次从高level往低查找,比如45,先找到level2的25,最后找到45,查找效率和2叉树的效率相当,但也是用了一定的空间冗余来换取的。
假设有下面三个posting list需要联合索引:
如果使用跳表,对最短的posting list中的每个id,逐个在另外两个posting list中查找看是否存在,最后得到交集的结果。
如果使用bitset(基于bitMap),就很直观了,直接按位与,得到的结果就是最后的交集。注意,这是我们倒排索引实现联合索引的方式,不是我们ES就是这样操作的。
总结和思考:
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方案。