elastic search 底层原理简析

 

1.基本概念

索引(Index)

ES将数据存储于一个或多个索引中,索引是具有类似特性的文档的集合。类比传统的关系型数据库领域来说,索引相当于SQL中的一个数据库,或者一个数据存储方案(schema)。索引由其名称(必须为全小写字符)进行标识,并通过引用此名称完成文档的创建、搜索、更新及删除操作。一个ES集群中可以按需创建任意数目的索引。

类型(Type)

类型是索引内部的逻辑分区(category/partition),其意义完全取决于用户需求。因此,一个索引内部可定义一个或多个类型(type)。一般来说,类型就是为那些拥有相同的域的文档做的预定义。例如,在索引中,可以定义一个用于存储用户数据的类型,一个存储日志数据的类型,以及一个存储评论数据的类型。类比传统的关系型数据库领域来说,类型相当于 “表” 。

Elasticsearch 为何要在 7.X版本中 去除type的概念

  • 背景说明

           Elasticsearch是一个基于Apache Lucene(TM)的开源搜索引擎。无论在开源还是专有领域,Lucene可以被认为是迄今为止最先进、性能最好的、功能最全的搜索引擎库。

           Elasticsearch 是一种NoSQL数据库(非关系型数据库),和常规的关系型数据库(比如:MySQL,Oralce等)的基本概念,对应关系如下:

           Elasticsearch:index   -->  type     -->   doc   -->  field

           MySQL:           数据库  -->   数据表 -->    行     -->  列

           因为关系型数据库比非关系型数据库的概念提出的早,而且很成熟,应用广泛。

          所以,后来很多NoSQL(包括:MongoDB,Elasticsearch等)都参考并延用了传统关系型数据库的基本概念。

          一个客观的现象和事实如下:

          Elasticsearch 官网提出的近期版本对 type 概念的演变情况如下:

          在 5.X 版本中,一个 index 下可以创建多个 type

          在 6.X 版本中,一个 index 下只能存在一个 type

          在 7.X 版本中,直接去除了 type 的概念,就是说 index 不再会有 type。 

  • 原因

    因为 Elasticsearch 设计初期,是直接查考了关系型数据库的设计模式,存在了 type(数据表)的概念。

    但是,其搜索引擎基于 Lucene 的,这种 “基因” 决定了 type 是多余的。 Lucene 的全文检索功能之所以快,是因为 倒排索引 的存在。

    而这种 倒排索引 的生成是基于 index 的,而并非 type。多个type 反而会减慢搜索的速度。

    为了保持 Elasticsearch “一切为了搜索” 的宗旨,适当的做些改变(去除 type)也是无可厚非的,也是值得的。

    为何不是在 6.X 版本开始就直接去除 type,而是要逐步去除type?

    因为历史原因,前期 Elasticsearch 支持一个 index 下存在多个 type的,而且,有很多项目在使用 Elasticsearch 作为数据库。

    如果直接去除 type 的概念,不仅是很多应用 Elasticsearch 的项目面临 业务、功能和代码的大改

    而且对于 Elasticsearch 官方来说,也是一个巨大的挑战(这个是伤筋动骨的大手术,很多涉及到 type 源码是要修改的)。

    所以,权衡利弊,采取逐步过渡的方式,最终,推迟到 7.X 版本才完成 “去除 type”  这个 革命性的变革

文档(Document)

文档是索引和搜索的原子单位,它是包含了一个或多个域(Field)的容器,基于JSON格式进行表示。文档由一个或多个域组成,每个域拥有一个名字及一个或多个值,有多个值的域通常称为 “多值域” 。每个文档可以存储不同的域集,但同一类型下的文档至应该有某种程度上的相似之处。

  • document路由原理

          ①路由算法:shard = hash(routing) % number_of_primary_shards
          ②决定一个document在哪个shard上,最重要的一个值就是routing值,默认是_id,也可手动指定,相同的routing值,每次过来,从hash函数中,产出的hash值一定是相同的
          例:手动指定一个routing value,比如 put /index/type/id?routing=user_id
          ③这就是primary shard数量不可变的原因。

倒排索引(Inverted Index)

每一个文档都对应一个ID。倒排索引会按照指定语法对每一个文档进行分词,然后维护一张表,列举所有文档中出现的terms以及它们出现的文档ID和出现频率。搜索时同样会对关键词进行同样的分词分析,然后查表得到结果。

什么是倒排索引?

假设有下列几条数据:

1
2
3
4

ID    Name    Age    Sex
1     Kate    24     Female
2     John    24     Male
3     Bill    29     Male


ID是Elasticsearch自建的文档id,那么Elasticsearch建立的索引如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

Name:
Term Posting List
Kate 1
John 2
Bill 3
 
Age:
Term Posting List
24 [1,2]
29 3
 
Sex:
Term Posting List
Female 1
Male [2,3]


Elasticsearch分别为每个field都建立了一个倒排索引,Kate, John, 24, Female这些叫term,而 [1,2] 就是Posting List。Posting list就是一个int的数组,存储了所有符合某个term的文档id。通过posting list这种索引方式可以很快进行查找,比如要找age=24的人。

Term Dictionary

Elasticsearch为了能快速找到某个term,将所有的term排序,二分法查找term,logN的查找效率,就像通过字典查找一样,这就是Term Dictionary。类似于传统数据库的B-Tree的,但是Term Dictionary较B-Tree的查询快。

Term Index

B-Tree通过减少磁盘寻道次数来提高查询性能,Elasticsearch也是采用同样的思路,直接通过内存查找term,不读磁盘,但是如果term太多,term dictionary也会很大,放内存不现实,于是有了Term Index,就像字典里的索引页一样,A开头的有哪些term,分别在哪页,term index其实是一颗 (trie) 前缀树:

 

这棵树不会包含所有的term,它包含的是term的一些前缀。通过term index可以快速地定位到term dictionary的某个offset,然后从这个位置再往后顺序查找。

所以term index不需要存下所有的term,而仅仅是他们的一些前缀与Term Dictionary的block之间的映射关系,再结合FST(Finite State Transducers)的压缩技术,可以使term index缓存到内存中。从term index查到对应的term dictionary的block位置之后,再去磁盘上找term,大大减少了磁盘随机读的次数。

假设我们现在要将mop, moth, pop, star, stop, top (term index里的term前缀) 映射到序号:0,1,2,3,4,5 (term dictionary的block位置)。最简单的做法就是定义个Map,大家找到自己的位置取值即可,但从内存占用少的角度考虑,FST更节省空间。

 

⭕️ 表示一种状态

–> 表示状态的变化过程,上面的字母/数字表示状态变化和权重

将单词分成单个字母通过⭕️和–>表示出来,0权重不显示。如果⭕️后面出现分支,就标记权重,最后整条路径上的权重加起来就是这个单词对应的序号。

FST以字节的方式存储所有的term,这种压缩方式可以有效的缩减存储空间,使得term index足以放进内存,但这种方式也会导致查找时需要更多的CPU资源。

 

压缩技巧

Elasticsearch里除了上面说到用FST压缩term index外,对posting list也有压缩技巧。如果Elasticsearch需要对人的性别进行索引,如果有上千万个人,而性别只分男/女,每个posting list都会有至少百万个文档id。Elasticsearch采用一定的压缩算法对这些文档id进行压缩:

增量编码压缩,将大数变小数,按字节存储

首先,Elasticsearch要求posting list是有序的(为了提高搜索的性能),这样做的好处是方便压缩,看下面这个图例:

原理就是通过增量,将原来的大数变成小数仅存储增量值,再精打细算按bit排好队,最后通过字节存储,而不是用int(4个字节)来存储。

Roaring bitmaps

Roaring bitmaps基于bitmap。Bitmap是一种数据结构,假设某个posting list:[1,3,4,7,10],其对应的bitmap就是:[1,0,1,1,0,0,1,0,0,1]。
用0/1表示某个值是否存在,存在的值对应的bit值是1,即一个字节 (8位) 可以代表8个文档id,旧版本 (5.0之前) 的Lucene就是用这样的方式来压缩的,但这样的压缩方式仍然不够高效,如果有1亿个文档,那么需要12.5MB的存储空间,这仅仅是对应一个索引字段。于是衍生出了Roaring bitmaps这样更高效的数据结构。
将posting list按照65535为界限分块 (block) ,比如第一块所包含的文档id范围在0~65535之间,第二块的id范围是65536~131071,以此类推。再用<商,余数>的组合表示每一组id,这样每组里的id范围都在0~65535内了。

Bitmap的缺点是存储空间随着文档个数线性增长,Roaring bitmaps利用了某些指数特性来规避这一点:

”为什么是以65535为界限?”

65535=2^16-1,正好是2个字节能表示的最大数,一个short的存储单位,注意到上图里的最后一行“If a block has more than 4096 values, encode as a bit set, and otherwise as a simple array using 2 bytes per value”,如果是较大的 (block) 块,用 bitset 存,小块用一个 short[] 存储。

那为什么用4096来区分采用数组还是bitmap的阀值呢?

这个是从内存大小考虑的,当block块里元素超过4096后,用bitmap更省空间: 采用bitmap需要的空间是恒定的: 65536/8 = 8192bytes 而如果采用short[],所需的空间是: 2*N(N为数组元素个数) N=4096刚好是边界:

联合索引

上述都是单field索引,如果是多个field索引的联合查询,倒排索引如何满足快速查询的要求呢?

利用跳表(Skip list)的数据结构快速做“与”运算,或者利用上面提到的bitset按位“与”。先看看跳表的数据结构:

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

如果使用跳表,对最短的posting list中的每个id,逐个在另外两个posting list中查找看是否存在,最后得到交集的结果。如果使用bitset,就很直观了,直接按位与,得到的结果就是最后的交集。

节点(Node)

一个运行中的 Elasticsearch 实例称为一个节点,而集群是由一个或者多个拥有相同 cluster.name 配置的节点组成, 它们共同承担数据和负载的压力。

ES集群中的节点有三种不同的类型:

  • 主节点:负责管理集群范围内的所有变更,例如增加、删除索引,或者增加、删除节点等。 主节点并不需要涉及到文档级别的变更和搜索等操作。可以通过属性node.master进行设置。
  • 数据节点:存储数据和其对应的倒排索引。默认每一个节点都是数据节点(包括主节点),可以通过node.data属性进行设置。
  • 协调节点:如果node.master和node.data属性均为false,则此节点称为协调节点,用来响应客户请求,均衡每个节点的负载。

分片(Shard)

一个索引中的数据保存在多个分片中,相当于水平分表。一个分片便是一个Lucene 的实例,它本身就是一个完整的搜索引擎。我们的文档被存储和索引到分片内,但是应用程序是直接与索引而不是与分片进行交互。

ES实际上就是利用分片来实现分布式。分片是数据的容器,文档保存在分片内,分片又被分配到集群内的各个节点里。 当你的集群规模扩大或者缩小时, ES会自动的在各节点中迁移分片,使得数据仍然均匀分布在集群里。

一个分片可以是主分片或者副本分片。 索引内任意一个文档都归属于一个主分片,所以主分片的数目决定着索引能够保存的最大数据量。一个副本分片只是一个主分片的拷贝。 副本分片作为硬件故障时保护数据不丢失的冗余备份,并为搜索和返回文档等读操作提供服务。

如果当前插入大量数据,那么会对es集群造成一定的压力,所以在插入大量数据前,也就是在建立索引的时候,我们最好把副本数设置为0;等数据建立完索引之后,在手动的将副本数更改到2,这样可以提高数据的索引效率

在索引建立的时候就已经确定了主分片数,但是副本分片数可以随时修改。默认情况下,一个索引会有5个主分片,而其副本可以有任意数量。

主分片和副本分片的状态决定了集群的健康状态。每一个节点上都只会保存主分片或者其对应的一个副本分片,相同的副本分片不会存在于同一个节点中。如果集群中只有一个节点,则副本分片将不会被分配,此时集群健康状态为yellow,存在丢失数据的风险。

拥有三个节点的集群

 3个节点,3个主分片,1份副本

拥有2份副本分片3个节点的集群

增加一份副本

关闭了一个节点后的集群

 其中一个节点出现故障

实际上,每一个分片还会进一步拆分为分段(Segment)。这是ES写入文档所采用的机制决定的。

2.写数据

原理解析预备知识

  • index:类似数据库,是存储、索引数据的地方。
  • shard:index 由 shard 组成,一个 primary shard,其他是 replica shard。
  • segment:shard 包含 segment,segment 中是倒排索引,它是不可变的;segment 内的文档数量的上限是 2^31
  • 倒排索引:倒排索引是 Lucene 中用于使数据可搜索的数据结构。
  • translog:记录文档索引和删除操作的日志。Lucene 在每次 commit 之后把数据持久化到磁盘,但是 commit 操作代价很大,所以不能在每次数据变更之后执行 commit。Elasticsearch 为防止宕机造成数据丢失,每次写入数据时会同步写到 buffer 和 translog,在 flush 操作时把数据持久化。
  • commit point:列出所有已知 segment 的文件。

当用户向一个节点提交了一个索引新文档的请求,节点会计算新文档应该加入到哪个分片(shard)中。每个节点都存储有每个分片存储在哪个节点的信息,因此协调节点会将请求发送给对应的节点。注意这个请求会发送给主分片,等主分片完成索引,会并行将请求发送到其所有副本分片,保证每个分片都持有最新数据。

每次写入新文档时,都会先写入内存 buffer(这时数据是搜索不到的),同时将数据写入 translog 日志文件。

buffer.png

  • es每隔1秒(可配置)或者 buffer 快满时执行一次刷新(refresh)操作,将 buffer 数据 refresh 到 os cache 即操作系统缓存。这时数据就可以被搜索到了:
  • buffer 的文档被写入到一个新的 segment 中(这1秒时间内写入内存的新文档都会被写入一个文件系统缓存(filesystem cache)中,并构成一个分段(segment));
  • segment 被打开以供搜索(此时这个segment里的文档可以被搜索到,但是尚未写入硬盘,即如果此时发生断电,则这些文档可能会丢失);
  • 内存 buffer 清空。

refresh.png

  • 不断有新的文档写入,则这一过程将不断重复执行。每隔一秒将生成一个新的segment,当 translog 越来越大达到一定长度的时候,就会触发 flush 操作(flush 完成了 Lucene 的 commit 操作)
  • 第一步将 buffer 中现有数据 refresh 到 os cache 中去,清空 buffer
  • 然后,将一个 commit point 写入磁盘文件,同时强行将 os cache 中目前所有的数据都 fsync 到磁盘文件中去;
  • 最后清空现有 translog 日志文件并生成新的translog。

flush.png

                                         执行fsync后segment写入磁盘,清空内存和translog

由上面的流程可以看出,在两次fsync操作之间,存储在内存和文件系统缓存中的文档是不安全的,一旦出现断电这些文档就会丢失。所以ES引入了translog来记录两次fsync之间所有的操作,这样机器从故障中恢复或者重新启动,ES便可以根据translog进行还原。

当然,translog本身也是文件,存在于内存当中,如果发生断电一样会丢失。因此,ES会在每隔5秒时间或是一次写入请求完成后将translog写入磁盘。可以认为一个对文档的操作一旦写入磁盘便是安全的可以复原的,因此只有在当前操作记录被写入磁盘,ES才会将操作成功的结果返回发送此操作请求的客户端。

3.segment 合并

buffer 每 refresh 一次,就会产生一个 segment(默认情况下是 1 秒钟产生一个),这样 segment 会越来越多,此时会定期执行 merge。

  • 将多个 segment 合并成一个,并将新的 segment 写入磁盘;
  • 新增一个 commit point,标识所有新的 segment
  • 新的 segment 被打开供搜索使用;
  • 删除旧的 segment

对于一个分片进行查询请求,将会轮流查询分片中的所有segment,这将降低搜索的效率。因此ES会自动启动合并segment的工作,将一部分相似大小的segment合并成一个新的大segment。合并的过程实际上是创建了一个新的segment,当新segment被写入磁盘,所有被合并的旧segment被清除。

Two commited segments and one uncommited segment in the process of being merged into a bigger segment

合并segment

合并完成后删除旧segment,新segment可供搜索

整个过程如图:

write.png

4.删除和更新

由于 segment 是不可变的,索引删除的时候既不能把文档从 segment 删除,也不能修改 segment 反映文档的更新。

  • 删除操作,会生成一个 .del 文件,commit point 会包含这个 .del 文件。.del 文件将文档标识为 deleted 状态,在结果返回前从结果集中删除。
  • 更新操作,会将原来的文档标识为 deleted 状态,然后新写入一条数据。查询时两个文档有可能都被索引到,但是被标记为删除的文档会被从结果集删除。

5.查询

查询的过程大体上分为查询(query)和取回(fetch)两个阶段。这个节点的任务是广播查询请求到所有相关分片,并将它们的响应整合成全局排序后的结果集合,这个结果集合会返回给客户端。

查询阶段

当一个节点接收到一个搜索请求,则这个节点就变成了协调节点。

查询过程分布式搜索

图10、查询过程分布式搜索

第一步是广播请求到索引中每一个节点的分片拷贝。 查询请求可以被某个主分片或某个副本分片处理,协调节点将在之后的请求中轮询所有的分片拷贝来分摊负载。

每个分片将会在本地构建一个优先级队列。如果客户端要求返回结果排序中从第from名开始的数量为size的结果集,则每个节点都需要生成一个from+size大小的结果集,因此优先级队列的大小也是from+size。分片仅会返回一个轻量级的结果给协调节点,包含结果集中的每一个文档的ID和进行排序所需要的信息。

协调节点会将所有分片的结果汇总,并进行全局排序,得到最终的查询排序结果。此时查询阶段结束。

取回阶段

查询过程得到的是一个排序结果,标记出哪些文档是符合搜索要求的,此时仍然需要获取这些文档返回客户端。

协调节点会确定实际需要返回的文档,并向含有该文档的分片发送get请求;分片获取文档返回给协调节点;协调节点将结果返回给客户端。

分布式搜索的取回阶段

图11、分布式搜索的取回阶段

相关性计算

在搜索过程中对文档进行排序,需要对每一个文档进行打分,判别文档与搜索条件的相关程度。在旧版本的ES中默认采用TF/IDF(term frequency/inverse document frequency)算法对文档进行打分。

查询的时候操作系统会将磁盘文件里的数据自动缓存到 filesystem cache。Elasticsearch 严重依赖于底层的 filesystem cache,如果给 filesystem cache 很大,可以容纳所有的 indexsegment 等文件,那么搜索的时候就基本都是走内存的,性能会非常高;反之,搜索速度并不会很快。

read.png

6.filter执行原理

当一个filter搜索请求打到Elasticsearch的时候,ES会进行下面的操作:

(1)在倒排索引中查找搜索串,获取document list

以date来举例:

word         doc1    doc2    doc3
2019-01-01    *       *
2019-02-02            *        *
2019-03-03    *       *        *

filter: 2019-02-02
在倒排索引中寻找,我们发现2019-02-02对应的document list是doc2、doc3

(2)为每个在倒排索引中搜索到的结果,构建一个bitset

这一步是非常重要的,使用找到的doc list,构建一个bitset,即一个二进制的数组,数组的每个元素都是0或1,用来标识一个doc对一个filter条件是否匹配,如果匹配的话值就是1,不匹配值就是0。
所以上面的filter的bitset的结果就是:

[0,1,1]

doc1:不匹配这个filter的
doc2和doc3:匹配这个filter的

注:尽可能用简单的数据结构去实现复杂的功能,可以节省内存空间,提升性能。

(3)遍历每个过滤条件对应的bitset,优先从最稀疏的开始搜索,查找满足所有条件的document

由于一次性可以在一个search请求中发出多个filter条件,那么就会产生多个bitset,遍历每个filter条件对应的bitset优先从最稀疏的开始遍历

[0,0,0,0,0,0,0,1]  比较稀疏的bitset
[1,0,1,1,0,1,0,1]

这里主要是因为先遍历比较稀疏的bitset,就可以先过滤掉尽可能多的数据

(4)caching bitset

caching bitset会跟踪query,在最近256个query中超过一定次数的过滤条件,缓存其bitset。对于小segment(<1000 或<3%),不缓存bitset。这样下次如果在有这个条件过来的时候,就不用重新扫描倒排索引,反复生成bitset,可以大幅度提升性能。

说明:
1、在最近的256个filter中,有某个filter超过了一定次数,这个次数不固定,那么elasticsearch就会缓存这个filter对应的bitset
2、filter针对小的segment获取到的结果,是可以不缓存的,segment记录数小于1000,或者segment大小小于index总大小的3%。因为此时segment数据量很小,哪怕是扫描也是很快的;segment会在后台自动合并,小segment很快会跟其它小segment合并成大segment,此时缓存就没有什么意思了,segment很快会消失。

filter比query好的原因除了不计算相关度分数以外还有这个caching bitset。所以filter性能会很高。

(5)filter大部分的情况下,是在query之前执行的,可以尽可能过滤掉多的数据

query: 会计算每个doc的相关度分数,还会根据这个相关度分数去做排序
filter: 只是简单过滤出想要的数据,不计算相关度分数,也不排序

(6)如果document有新增和修改,那么caching bitset会被自动更新

这个过程是ES内部做的,比如之前的bitset是[0,0,0,1]。那么现在插入一条数据或是更新了一条数据doc5,而且doc5也在缓存的bitset[0,0,0,1]的filter查询条件中,那么ES会自动更新这个bitset,变为[0,0,0,1,1]

(7)以后只要有相同的filter条件的查询请求打过来,就会直接使用这个过滤条件对应的bitset

这样查询性能就会很高,一些热的filter查询,就会被cache住。

posted @ 2021-01-14 14:17  如果屈原会编程  阅读(1588)  评论(0编辑  收藏  举报