ElasticSearch原理

698 阅读19分钟

基础概念

先来说一下为什么要使用ElasticSearch,一般web系统都是依附于关系型数据库这样的行数据库创建的,比如mysql这样的。常用的索引的组织结构都是B+或B树创建的,一行的数据存储在磁盘上相邻的存储位置,所以叫行数据库,而HBase这种列式存储的其实是吧一列存储在相邻的位置上,这就是另外一回事儿了,对于mysql这种树形存储结构的数据库来说,一个问题绕不开就是查询走的其实也是B+树,但是这个查询过程是有一个最左匹配原则的。

也就是说,他是看的节点的前缀匹不匹配来查询走左子树还是右子树来选择的。而在订单管理,或者条件很多而且没有固定顺序的查询来说,这样是用不到索引的。或者要查询的过滤字段在字表,为了分页信息的准确性,就只能在主表冗余,造成主表越来越宽,索引越来越多,不仅插入慢,索引还很不稳定,因为mysql执行计划是根据数据量采样来确定选择的索引的,所以你很难控制,是不是走的你创建的索引,就算使用了index_merge这样多个索引合并,其实原理也就是走不同的索引树,查出来的数据在进行交集或者并集。但是这样受数量影响更大,因为走三条索引树,在mysql看来可能还不如我走全表扫描来的数量少,所以条目数量对索引选择影响很大。

所以总结来说,mysql并不是专门为索引而生的。

那再来说一下为什么选择ES,ES其实是一个分布式的nosql数据库,尽可能的把数据缓存斤内存来提升查询效率,减少与磁盘的交互次数,所以这也就造成它不得不是分布式的,因为一台机子上不可能存储所有的数据。

ES对于内存使用和搜索查询的优化可以用一个词来描述奇技淫巧。。。。这个后面快速索引原理的时候会说,也是查询过程的核心。下面先简单介绍下基础的概念。

术语

  • Document:Document相当于数据库的一行记录,这个有点儿类似于mongo的存储,其实就是存了个json串,你想要啥字段往里加就行了。
  • Index:Elasticsearch的Index相当于数据库的Table,其实就是一对json串的集合。
  • Type:这个在新的Elasticsearch版本已经废除,一般都是_doc类型。
  • Field:相当于数据库的Column的概念
  • Mapping:相当于数据库的Schema的概念,其实就是表结构,可以设置表里面的字段是啥类型啊,制定这个字段用什么分词器分词啊,可以用什么查询方式查询啊这类的。
  • DSL:相当于数据库的SQL语句(给我们读取Elasticsearch数据的API)

架构

因为es是分布式的架构,所以集群会有多个节点,也就是多个实例,一般来说生产环境上应该一个服务器上运行一个节点,但是其实也可以在单机上搭建多个节点的集群,可以指定每个节点的名字等信息,也会自动分配一个UID标记实例,存放在data目录下。

在这么多节点中,会有一个主节点Master Node,他的主要功能就是维护索引元数据和切断主副分片,比如一个主分片挂了,会切换该分片的副分片进行调用,其实它主要就是保证了一个公共信息的全局唯一性,避免多个节点修改造成数据的不统一,比如索引的结构Mappping信息,分片的路由信息等。

分片的概念是为了分散存储,这样可以存储更多的数据,相似的做法在redis里面也有,其实说白了就是hash散列到不同的分片,这样就能在不同的服务器上存储数据,但是这也是有问题的,类似于在HashMap扩容的时候需要重新散列分配到不同的槽里去,es扩容的时候其实也是需要调用ReIndex()方法去重新散列。分片的主副分片其实就是为了高可用,讲一个分片的主副分片存储在不同的节点上,这样当主分片挂掉之后,Master Node 将会把副分片提升为主分片。但是分片数量设置过多会导致查询之后聚合的速度变慢,7.0之后默认的分片数量就是1,这个可以创建索引的时候更改的,所以分片数量最好以2的倍数增长,用来均匀分散。

索引过程

写入

先来说一下协调写入的过程。

  • 客户端请求集群中某一个节点,发送请求(其实节点分为协调节点和数据节点,默认都是协调节点,不过可以启动实例的时候这是协调能力关闭,只做为数据节点存储数据)
  • 假设请求节点1为接受请求的节点,作为协调节点,会根据文档_id来哈希一下(这个哈希函数可以指定),确定主分片到底在哪个节点上,比如哈希完之后,确定需要存储到节点3的主分片P0上,那么就会把请求转发到节点3上。
  • 节点3接收到请求之后,会在分片上写入,写成功后同时会把请求转发到在节点2上的副分片上进行数据同步,在收到节点2的副分片写入成功的回复后,会回复协调节点1,写入成功,然后节点1返回给客户端成功。

上面就是协调写入的过程,但是没有说主副分片上的本地存储是怎么存的。

看上面的图来理一下是怎么进行分片上存储数据的

  1. 首先,会把数据放到内存的缓冲区上memory buffer,然后这个内存缓冲期其实每隔1s会刷先到文件系统的缓存区,在文件缓存区生成segment文件(具体的数据结构待查),所以es是近实时的原因在这里,只有在写入到文件系统的缓存区之后,数据才能被查到。
  2. 为了防止宕机,内存数据丢失,在写入内存数据的时候,还会写入到内存的一个事务日志的buffer中,这个日志是个增量的追加日志,所以写入是比较快的,但是也会每隔5s才会同步到磁盘上,所以如果宕机的时候,可能会造成5s的数据的丢失。
  3. 等到磁盘上的日志文件达到一定程度或者30分钟,触发一个commit操作,会把内存中的segment文件刷入到磁盘中,其实原理就是生成数据持久的镜像,然后再去只记录追加的数据,这个原理有点儿像redis的AOF持久化,一个快照一个追加。

查询

其实ES的查询可以分为两种:

  • 根据_id去查询doc
  • 根据query 搜索去查询匹配的doc

从写入的过程可以知道通过id去查询可以达到实时查询,因为你在写入的时候,其实可以先检查内存的translog,然后再检测硬盘里的translog,最后在检测硬盘的segment文件,其实按照道理说硬盘里的segment文件也会以某种方式缓存在内存里,毕竟就是以内存加速的,不可能一直往磁盘里去请求的。所以这里只是用这种方式来说明通过id来查询可以达到实时查询的要求。

query匹配doc其实一般都是把这个查询请求发送一个节点,这个节点作为协调节点,把这个请求发送到所有的分片上查询,然后返回符合条件的docid,然后再通过docid拉取doc数据,先说一下为什么这么做,是因为一般其实是有一个打分或者根据某个字段排序的过程,而且不可能把节点上所有数据都获取过来,只能获取一部分数据,这一部分数据有需要排序才行,比如每个分片获取最新的20条数据,会在每个分片上获取20条最新的数据,然后拿过来在进行排序选取20条,才是最终要返回的20条。而每一个分片上的查询过程其实是先查询内存中的segment,同时匹配磁盘里的segment去查询符合条件的doc,所以es的优化过程中要提高内存中segment的命中,或者尽可能要在内存中存储更多的索引数据,这样才能减少与磁盘的交互,提高查询速度。这个如何提高也会在下面的快速索引原理里面说。

快速索引原理

倒排索引

其实倒排索引的原理是分为三个部分,优化也是分别对三个部分进行优化。

例如有这么一个index 里面有三条文档。

其实Term就是每一个分词,在es里面是使用插件的方式来进行分词的,这个后面再说还能指定每个字段的分词方法。

上面这三条doc,插入时会为每个filed也就是每个字段创建倒排索引。

比如上面这个图,根据分词创建了倒排索引,其中的PostingList就是这个词在哪些doc中出现了。

-----------------------我是华丽丽的分割线精彩才刚刚开始--------------------------

下面就开始存储优化的骚操作

TermDictionary

ES为了能够快速的找到Term,会讲所有的Term排序,然后使用二分查找,logN的查找效率,这个TermDicTionary其实类似于一个存储的数组,存储了所有的数据,这又有点儿想传统数据库的二分查找。

TermIndex

按照TermDictionary的思路,你想想得有个多大的数组存储term,ES的思路就是用内存来优化查询,就拿单单一篇文章来说得有多少个词组Term,全放这肯定不现实的,所以就有了TermIndex,也就是TermIndex是Termdictionary的索引,为了快速定位Term,那其实这样的方式是不是类似于传统数据库可以用二叉树来存储,因为排序完之后的Term就是前缀一样的排序在一起的,刚好可以用二叉树来构建索引。

但是这颗树不会包含所有的Term,他只会包含Term的前缀,查到符合的最大前缀之后,会得到一个TermDictionary的position,再通过这个position往后找就能找到Term的准确数值。

但是!!!这怎么能满足es的骚操作!上面我们看到B+树的一个问题是什么,加入存储字符串abcbbc会怎么做,a是一个节点,b是一个节点,ab是一个节点挂在a节点下面,bb是一个节点挂在b下面,这样做的问题是什么?内存占用还是大,因为有重复的节点被创建,在这里ES用了FST的算法,重用不同的字母。

比如现在举一个例子,我们现在有一组词需要构建查询mop、moth、pop、star、stop、top这些都是term的前缀,然后在创建一个数组把这个词依次按照这个顺序放进去。然后构建FST,每一个单词的权重就是他们在数组中的位置这里放出来一个构建的网站可以构建看一下FST构建网站

这个图是怎么构建出来的?我TM也不知道。。。。原始的理论Paper在这里加权有限状态转换器算法概述-_-!!! 这名字看着就不想看而且是英文的,查了一下是纽约计算机和和数学教授,全球人工智能大咖Mehryar Mohri在美国最大电信公司AT & T 工作期间撰写的论文。身为IT码农的我已经放弃了,我只需要知道,FST算法可以压缩这个索引的存储大小,并且这个一条连路上的权重加起来就是这个term前缀对应的数组的下标,在es的实现上,其实就是对应TermDictionary的在磁盘上存储block的位置,然后再去磁盘上读取Term,这样就能大大减少磁盘随机访问的次数,从而提高命中和查询的效率。

--------------------------再来一条华丽丽的分割线 第二波骚操作------------------

上面说完了TermIndex是怎么压缩的,下面是Posting list的压缩技巧。posting list是存储docid的,但是如果说存的是 男女这种区分度比较小的,数据多了这个posting list其实是无限大的,所以这玩意儿根本不可能放到内存里,所以es使用了三个技术或者阶段来压缩,让这个玩意儿可以放到内存。

Frame Of Reference 增量编码压缩

首先,posting list是有序的,为了提高搜索性能,可能会用到二分等查找方法,这个上面说过,另一个有序的原因是方便压缩,可以减少空间的大小。

比如现在有六个posting list的数值,上面图中,首先第一步,这六个数值,可以以73为基数,逐步递增,通过增量把大数变成小数,后一个数值都是前面数值只和再加上本数值,比如302=73+227+2。通过这种方式把大的数变成小数,为什么要这么干,因为原始的六个数值其实每个数值int占4个字节,变小数了之后,比如30这个数值,我用5个bits就能表示,因为5个bits的二进制能表示的范围是0-31。通过这种精打细算的计算方式,可以把数值分组,找到最大的数字,以它所占的最小的bits能够表示的范围作为压缩的基准,比如图示中,227需要用8个bit表示,那这一组的三个数字就都是8bits,这样算出来用3个byte就能表示三个数。最后,原始的六个数字需要用24字节才能表示,现在压缩成7个字节就能表示。

bitmap存储

bitmap的存储的理念其实在Redis里面也有,包裹bloomfilter布隆过滤器其实也是基于bitmap的。

假设有一个posting list[1,3,4,7,10],如果用bitmap来表示的话就是[1,0,1,1,0,0,1,0,0,1],可以看出来 其实是非常简单粗暴的。。。。就是用0/1 表示数值是不是存在的,比如这个10这个元素,那就在二进制的第10位上表示1,表示他是存在的,其实这个随便比直接存数字4个byte好一点儿,但是依然非常诡异,比如我现在有个数字100000000,那其实前面的bit都是0,之后第100000000上是1,如果有一亿个文档,那么就需要12.5M的存储空间,而且这还只是一个term的postring list的索引,lucene5.0之前就是用这个bitmap这么做的,但是你一个index里面不可能只有一个filed吧,所以又引出了Roaring bitmaps这样有限的存储结构。

Roaring bitmaps存储

bitmap的缺点就是,文档越多,你这个存储的空间就会越来越大,是线性增长的。下面看看Roaring bitmaps是怎么做的。

首先Roaring bitmaps将posting list按照65535这个数值为界限将数据分块,为什么是65535,因为65535是2^16-1,正好是两个字节可以存储的最大值,也就是说,Roaring bitMap一块儿就是2个bytes,比如第一块儿的文档id范围在0-65535之间,第二块儿在65535-131071之间,以此类推。在用(商,余数)来表示每一个文档id的值,其实这个表示方法类似于数组回环重新定位,其实用商来表示,文档落在第几块商,余数表示在块上的偏移量。这样每一个postring list的id就不会变得无限大,就可以用有效的方式存储ID了。

看图里这个英文的文字,梳理一下这个过程以62101这个数值为例:

  1. 首先将62101/65536=0...62101,也就是621001这个数值商0余62101
  2. 将运算结果(0,62101)分配到第一个数据块里,这时候看到第一个数据块里有两个元素1000和62101。
  3. 判断这个数据块是要被编码成bitmap还是short[]数组中的一个元素,因为现在块里只有2个数值,所以这两个元素会被放到short[]数组中的0,1两个位置,为啥是short数组,因为除得是65536,是2^16,一个short就是两个字节就是2^16次方,所以余数不会超过65536,所以用两个字节存储就够了。

但是图上底部的英文说了,如果一个块里的元素超过了4096个数据,就需要使用bitmap来保存了,因为bitmap是恒定的,每一位上就代表这个数字存不存在,比如就是65535这个数据,表示这个数字怎么表示,就是需要65535位,前65534位都是0,第65535位是1,那么表示这个65535就是需要65535位,一个字节是8位,所以就是65535/8=8129bytes,而对于short[]来保存4096个元素,每个元素占两个字节,就需要用4096*2=8192bytes,这样看来就是大于4096个元素,使用short[]数组存储就大于使用bitmap存储,所以这是一个阀值,大于这个阀值就会转换。

压缩总结

再来理一理这几个压缩的特性:

  1. FOR增量压缩,其实是用来把postring list压缩放在磁盘上的,不是放在内存的,这时候因为其实内存已经通过FST进行了term的快速搜索,所以其实可以跟内存的的查询相媲美,因为他是直接读取,没有随机读取的寻道的一些时间。
  2. Roaring bitmaps 其实是采用的bitmap和short数组相结合的方式来进行存储的,因为在稀疏的块中,short数组中的元素都是排列在一起的,反而遍历或者其他的操作效率要快,因为稀疏的块用bitmap存储的话,可能会有很多bit是空的没有数据的,导致遍历或者其他操作变慢。而在稠密的块中,数据相对密集,会把bitmap的空都填上,减少存储的空间,反观short数组则不然,太吃内存了。

联合索引

上面所有的压缩和索引的优化都是针对单个filed,比如一个term,也就是一个词项的查询,首先会走TermIndex走FST找到在TermDictionary的docid的posting list,查出来了之后会被缓存进内存,也就是走上面的到底是走bitmap存储还是普通的byte存储。这个就是按照条件filter的cache,会把这个缓存的posting list缓存下来,这样实现了一个term的查询,但是针对一个filed的查询里面可能有多个term,比如“你好 我在”,这样查询分词就是两个term“你好”、“我在”,那么根据这两个term查询出来了两个posting list,那么这两个posting list怎么合并,才能满足返回同时满足两个条件的docid。

其实是有两个方法:

  1. 利用跳表的数据结构做快速的与运算,这个就是posting list没有压缩,用的就是shot[]去存储的
  2. 利用上面的bitmap做位与运算,这个其实就是posting list数量比较多给他压缩成bitmap,所以其实这两个方法针对的是不同的数据结构来做的。

跳表

将一个有序链表level0,挑出其中几个元素到level1及level2,每个level越往上,选出来的指针元素越少,查找时依次从高level往低查找,比如55,先找到level2的31,再找到level1的47,最后找到55,一共3次查找,查找效率和2叉树的效率相当,但也是用了一定的空间冗余来换取的。

其实我个人理解(没有找到相关文献),其实posting list从磁盘里拿出来了之后可以构造出一个跳表,这样查询的时候就不需要一个个遍历过去了。

假设有下面三个posting list需要联合索引,其实他可能是一个filed中存储的三个term查询出来的三个posting list,也可以是不同filed中相同的term出来的,总之就是查出来三个posting list需要合并:

首先说一遍跳表的过程,其实就是找出最短的posting list,然后里面每个元素,在其他posting list构造出来的跳表中查询,看是否存在,然后最后再合并取交集返回。

第二如果说是用bitmap存储的结构,那其实bitmap中直接每一位直接做位与操作,获取出来的位是1的位置,那这个元素就是交集存在的,最后直接返回就行了。

基础查询与使用